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_FOUNDING_MASK) {
220 continue; }
222 if banned.contains(&grant.member) {
223 continue; }
225 let Ok(member) = PublicKey::parse(&grant.member) else { continue };
226 if member == owner {
227 continue; }
229 grant_admin(transport, twin, &member).await?;
230 }
231 Ok(())
232}
233
234pub fn twin_join_material(twin: &CommunityV2) -> super::list::JoinMaterial {
236 join_material(twin)
237}
238
239pub async fn send_message<T: Transport + ?Sized>(
243 transport: &T,
244 community: &CommunityV2,
245 channel_id: &ChannelId,
246 content: &str,
247) -> Result<String, String> {
248 send_chat_message(transport, community, channel_id, content, None, &[], vec![]).await
249}
250
251pub async fn send_chat_message<T: Transport + ?Sized>(
255 transport: &T,
256 community: &CommunityV2,
257 channel_id: &ChannelId,
258 content: &str,
259 reply_to: Option<(&str, &str)>,
260 emoji: &[(&str, &str)],
261 extra_tags: Vec<nostr_sdk::prelude::Tag>,
262) -> Result<String, String> {
263 send_chat_message_at(transport, community, channel_id, content, reply_to, emoji, extra_tags, now_ms()).await
264}
265
266#[allow(clippy::too_many_arguments)]
271pub async fn send_chat_message_at<T: Transport + ?Sized>(
272 transport: &T,
273 community: &CommunityV2,
274 channel_id: &ChannelId,
275 content: &str,
276 reply_to: Option<(&str, &str)>,
277 emoji: &[(&str, &str)],
278 extra_tags: Vec<nostr_sdk::prelude::Tag>,
279 at_ms: u64,
280) -> Result<String, String> {
281 let (author_pk, group, epoch, session) = chat_send_context(community, channel_id)?;
282 let rumor = chat::build_message_rumor(author_pk, channel_id, epoch, content, reply_to, emoji, extra_tags, at_ms);
283 publish_chat(transport, community, &session, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await
284}
285
286#[allow(clippy::too_many_arguments)]
291pub async fn send_reaction<T: Transport + ?Sized>(
292 transport: &T,
293 community: &CommunityV2,
294 channel_id: &ChannelId,
295 target_id_hex: &str,
296 target_author_hex: &str,
297 target_kind: u16,
298 emoji_content: &str,
299 emoji: Option<(&str, &str)>,
300) -> Result<String, String> {
301 let (author_pk, group, epoch, session) = chat_send_context(community, channel_id)?;
302 let at_ms = now_ms();
303 let rumor =
304 chat::build_reaction_rumor(author_pk, channel_id, epoch, target_id_hex, target_author_hex, target_kind, emoji_content, emoji, at_ms);
305 publish_chat(transport, community, &session, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await
306}
307
308pub async fn send_edit<T: Transport + ?Sized>(
312 transport: &T,
313 community: &CommunityV2,
314 channel_id: &ChannelId,
315 target_id_hex: &str,
316 new_content: &str,
317) -> Result<String, String> {
318 let (author_pk, group, epoch, session) = chat_send_context(community, channel_id)?;
319 let at_ms = now_ms();
320 let rumor = chat::build_edit_rumor(author_pk, channel_id, epoch, target_id_hex, new_content, at_ms);
321 publish_chat(transport, community, &session, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await
322}
323
324pub async fn send_delete<T: Transport + ?Sized>(
328 transport: &T,
329 community: &CommunityV2,
330 channel_id: &ChannelId,
331 target_id_hex: &str,
332 target_kind: u16,
333) -> Result<String, String> {
334 let (author_pk, group, epoch, session) = chat_send_context(community, channel_id)?;
335 let at_ms = now_ms();
336 let rumor = chat::build_delete_rumor(author_pk, channel_id, epoch, target_id_hex, target_kind, at_ms, None);
337 let id = publish_chat(transport, community, &session, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await?;
338 let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
343 spawn_pin_duty(&ch_hex, target_id_hex, None);
344 Ok(id)
345}
346
347pub async fn moderation_delete<T: Transport + ?Sized>(
357 transport: &T,
358 community: &CommunityV2,
359 channel_id: &ChannelId,
360 target_id_hex: &str,
361 target_kind: u16,
362 target_author: &PublicKey,
363) -> Result<String, String> {
364 let (author_pk, group, epoch, session) = chat_send_context(community, channel_id)?;
365 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
366 if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
367 return Err("this community is dissolved — it accepts no new moderation actions".to_string());
368 }
369 let owner_hex = community.owner()?.to_hex();
370 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
371 if !crate::community::moderation::can_hide(
372 Some(&owner_hex),
373 &roster,
374 &author_pk.to_hex(),
375 &target_author.to_hex(),
376 ) {
377 return Err("you can't hide a message from a member who outranks you (or the owner)".to_string());
378 }
379 let at_ms = now_ms();
380 let citation = required_authority_citation(community, &author_pk)?;
381 let rumor = chat::build_delete_rumor(author_pk, channel_id, epoch, target_id_hex, target_kind, at_ms, citation.as_ref());
382 let id = publish_chat(transport, community, &session, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await?;
383 let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
387 spawn_pin_duty(&ch_hex, target_id_hex, None);
388 Ok(id)
389}
390
391pub async fn send_webxdc_signal<T: Transport + ?Sized>(
398 transport: &T,
399 community: &CommunityV2,
400 channel_id: &ChannelId,
401 topic_id: &str,
402 node_addr: Option<&str>,
403) -> Result<(), String> {
404 let (author_pk, group, epoch, session) = chat_send_context(community, channel_id)?;
405 let at_ms = now_ms();
406 let content = crate::webxdc::peer_signal_content(topic_id, node_addr);
407 let rumor = chat::build_webxdc_rumor(author_pk, channel_id, epoch, &content, vec![], at_ms);
408 publish_chat(transport, community, &session, &group, author_pk, channel_id, epoch, rumor, at_ms, false).await.map(|_| ())
409}
410
411pub async fn send_typing<T: Transport + ?Sized>(
413 transport: &T,
414 community: &CommunityV2,
415 channel_id: &ChannelId,
416) -> Result<(), String> {
417 let (author_pk, group, epoch, session) = chat_send_context(community, channel_id)?;
418 let at_ms = now_ms();
419 let rumor = chat::build_typing_rumor(author_pk, channel_id, epoch, at_ms);
420 publish_chat(transport, community, &session, &group, author_pk, channel_id, epoch, rumor, at_ms, true).await.map(|_| ())
421}
422
423fn chat_send_context(community: &CommunityV2, channel_id: &ChannelId) -> Result<(PublicKey, GroupKey, Epoch, SessionGuard), String> {
429 let session = SessionGuard::capture();
430 let author_pk = me_pk()?;
431 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
432 if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
433 return Err("this community has been dissolved".to_string());
434 }
435 if crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default().contains(&author_pk.to_hex()) {
439 return Err("you are banned from this community".to_string());
440 }
441 let ch = community.channel(channel_id).ok_or("no such channel in this community")?;
442 if ch.private && ch.key.is_none() {
443 return Err("this private channel has no key yet (awaiting rekey delivery)".to_string());
444 }
445 let (secret, epoch) = community.channel_secret(ch);
446 Ok((author_pk, channel_group_key(&secret, channel_id, epoch), epoch, session))
447}
448
449#[allow(clippy::too_many_arguments)]
452async fn publish_chat<T: Transport + ?Sized>(
453 transport: &T,
454 community: &CommunityV2,
455 session: &SessionGuard,
456 group: &GroupKey,
457 author_pk: PublicKey,
458 channel_id: &ChannelId,
459 epoch: Epoch,
460 rumor: nostr_sdk::prelude::UnsignedEvent,
461 at_ms: u64,
462 ephemeral: bool,
463) -> Result<String, String> {
464 let rumor_id = rumor.id.ok_or("rumor has no id")?.to_hex();
465 let signer = crate::signer::active_signer()?;
466 let (wrap, _p_tag_keys) = chat::seal_chat_rumor_signed(&signer, author_pk, &rumor, group, Timestamp::from_secs(at_ms / 1000), ephemeral).await
467 .map_err(|e| e.to_string())?;
468 if !session.is_valid() {
469 return Err("account changed before send".to_string());
470 }
471 transport.publish(&wrap, &community.relays).await?;
472 if !ephemeral {
478 if !session.is_valid() {
479 return Ok(rumor_id);
480 }
481 crate::db::community::store_message_key(&rumor_id, &wrap.id.to_hex(), group.keys(), &community.relays)?;
482 }
483 if !ephemeral {
488 if let Ok(event) = chat::open_chat_event(&wrap, group, channel_id, epoch) {
489 let channel_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
490 let outcome = {
491 let mut st = crate::state::STATE.lock().await;
492 if !session.is_valid() {
493 return Ok(rumor_id); }
495 super::inbound::apply_chat_to_state(&mut st, &event, &channel_hex, &author_pk)
496 };
497 if let Some(outcome) = outcome {
498 if !session.is_valid() {
499 return Ok(rumor_id);
500 }
501 super::inbound::persist_chat(&channel_hex, &outcome).await;
502 }
503 }
504 }
505 Ok(rumor_id)
506}
507
508pub struct FetchedEvent {
511 pub event: ChatEvent,
512 pub epoch: Epoch,
513}
514
515fn heal_own_wrap_key(event: &ChatEvent, group: &GroupKey, relays: &[String]) {
522 if !matches!(event, ChatEvent::Message { .. } | ChatEvent::Reaction { .. }) {
523 return;
524 }
525 let opened = event.opened();
526 if crate::state::my_public_key() != Some(opened.author) {
527 return;
528 }
529 let rumor_hex = opened.rumor_id.to_hex();
530 if !matches!(crate::db::community::get_message_key(&rumor_hex), Ok(None)) {
533 return;
534 }
535 if crate::db::community::store_message_key(&rumor_hex, &opened.wrapper_id.to_hex(), group.keys(), relays).is_ok() {
536 crate::traits::emit_event("message_delete_meta_changed", &serde_json::json!({ "id": rumor_hex }));
539 }
540}
541
542pub async fn fetch_channel<T: Transport + ?Sized>(
546 transport: &T,
547 community: &CommunityV2,
548 channel_id: &ChannelId,
549 limit: usize,
550) -> Result<Vec<FetchedEvent>, String> {
551 fetch_channel_history(transport, community, channel_id, limit, 1, None, None, crate::community::transport::Evidence::Quorum, |_| true).await
552}
553
554pub async fn fetch_channel_history<T: Transport + ?Sized>(
568 transport: &T,
569 community: &CommunityV2,
570 channel_id: &ChannelId,
571 page: usize,
572 max_pages: usize,
573 since: Option<u64>,
574 start_until: Option<u64>,
577 evidence: crate::community::transport::Evidence,
578 mut keep_paging: impl FnMut(&[FetchedEvent]) -> bool,
579) -> Result<Vec<FetchedEvent>, String> {
580 let session = SessionGuard::capture();
583 let ch = community.channel(channel_id).ok_or("no such channel in this community")?;
584 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
589 let coords: Vec<([u8; 32], Epoch)> = if ch.private {
590 let Some(current) = ch.key else {
591 return Ok(Vec::new());
592 };
593 let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
594 let mut held = crate::db::community::held_epoch_keys(&cid_hex, &ch_hex).unwrap_or_default();
595 if !held.iter().any(|(ep, _)| *ep == ch.epoch) {
596 held.push((ch.epoch, current));
597 }
598 held.into_iter().filter(|(_, k)| *k != community.community_root).map(|(ep, k)| (k, ep)).collect()
601 } else {
602 let mut roots = crate::db::community::held_epoch_keys(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap_or_default();
603 if !roots.iter().any(|(ep, _)| *ep == community.root_epoch) {
604 roots.push((community.root_epoch, community.community_root));
605 }
606 roots.into_iter().map(|(ep, root)| (root, ep)).collect()
607 };
608 if coords.is_empty() {
609 return Ok(Vec::new());
610 }
611
612 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
613 let mut seen_rumors = std::collections::HashSet::new();
614 let mut out: Vec<(u64, FetchedEvent)> = Vec::new();
615 let mut until: Option<u64> = start_until;
616 let mut oldest: Option<u64> = None;
617 for _ in 0..max_pages {
618 let mut wraps: Vec<Event> = Vec::new();
624 let mut wrap_ids: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
625 for (secret, epoch) in &coords {
626 let plane = channel_group_key(secret, channel_id, *epoch);
627 let q = Query {
628 kinds: vec![stream::KIND_WRAP],
629 authors: vec![plane.pk_hex()],
630 since,
631 until,
632 limit: Some(page),
633 evidence,
634 ..Default::default()
635 };
636 if let Ok(evs) = transport.fetch_plane(plane.keys(), &q, &community.relays).await {
637 for e in evs {
638 if wrap_ids.insert(e.id) {
639 wraps.push(e);
640 }
641 }
642 }
643 }
644 if wraps.is_empty() {
645 break;
646 }
647 let mut fresh = 0usize;
648 let mut page_events: Vec<FetchedEvent> = Vec::new();
649 for wrap in &wraps {
650 if !seen_wraps.insert(wrap.id) {
651 continue;
652 }
653 fresh += 1;
654 let at = wrap.created_at.as_secs();
655 if oldest.is_none_or(|o| at < o) {
656 oldest = Some(at);
657 }
658 for (secret, epoch) in &coords {
660 let group = channel_group_key(secret, channel_id, *epoch);
661 if wrap.pubkey != group.pk() {
662 continue;
663 }
664 if let Ok(event) = chat::open_chat_event(wrap, &group, channel_id, *epoch) {
665 let id = event.opened().rumor_id;
666 if seen_rumors.insert(id) {
667 if session.is_valid() {
668 heal_own_wrap_key(&event, &group, &community.relays);
669 }
670 page_events.push(FetchedEvent { event, epoch: *epoch });
671 }
672 }
673 break;
674 }
675 }
676 if fresh == 0 {
677 if wraps.len() < page {
678 break; }
680 let Some(o) = oldest else { break };
684 if o == 0 {
685 break;
686 }
687 crate::log_warn!("v2: same-second history wall at {o} — stepping past it (messages beyond the relay page cap in that second are unreachable)");
688 until = Some(o - 1);
689 continue;
690 }
691 let stop = !page_events.is_empty() && !keep_paging(&page_events);
692 out.extend(page_events.into_iter().map(|e| (e.event.opened().at_ms, e)));
693 if stop {
694 break; }
696 until = oldest; }
698 out.sort_by_key(|(ms, _)| *ms);
699 Ok(out.into_iter().map(|(_, e)| e).collect())
700}
701
702#[derive(Debug, Clone, Copy, PartialEq, Eq)]
711pub enum BundleAudience {
712 Link,
714 Member(PublicKey),
716}
717
718pub fn bundle_of(
724 community: &CommunityV2,
725 audience: BundleAudience,
726 creator: Option<PublicKey>,
727 expires_at_ms: Option<u64>,
728 label: Option<String>,
729) -> CommunityInvite {
730 bundle_of_with_overlay(community, audience, creator, expires_at_ms, label, &[], &[])
731}
732
733pub fn bundle_of_with_overlay(
737 community: &CommunityV2,
738 audience: BundleAudience,
739 creator: Option<PublicKey>,
740 expires_at_ms: Option<u64>,
741 label: Option<String>,
742 with: &[String],
743 without: &[String],
744) -> CommunityInvite {
745 let hex = crate::simd::hex::bytes_to_hex_32;
746 let cid_hex = hex(&community.identity.community_id.0);
747 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
748 let owner_hex = community.owner().ok().map(|o| o.to_hex());
749 let recipient_hex = match audience {
750 BundleAudience::Link => None,
751 BundleAudience::Member(pk) => Some(pk.to_hex()),
752 };
753 let channels = community
754 .vendable_channels(&roster, owner_hex.as_deref(), recipient_hex.as_deref(), with, without)
755 .into_iter()
756 .map(|c| invite::ChannelGrant {
757 id: hex(&c.id.0),
758 key: hex(&c.key.unwrap_or(community.community_root)),
759 epoch: c.epoch.0,
760 name: c.name.clone(),
761 })
762 .collect();
763 CommunityInvite {
764 community_id: hex(&community.identity.community_id.0),
765 owner: hex(&community.identity.owner_xonly),
766 owner_salt: hex(&community.identity.owner_salt),
767 community_root: hex(&community.community_root),
768 root_epoch: community.root_epoch.0,
769 channels,
770 relays: community.relays.clone(),
771 name: community.name.clone(),
772 icon: community.icon.clone(),
775 expires_at: expires_at_ms,
776 creator_npub: creator.map(|p| p.to_hex()),
777 label,
778 extra: Default::default(),
779 }
780}
781
782pub async fn send_direct_invite<T: Transport + ?Sized>(
788 transport: &T,
789 community: &CommunityV2,
790 recipient: &PublicKey,
791 expires_at_ms: Option<u64>,
792 label: Option<String>,
793) -> Result<Event, String> {
794 let session = SessionGuard::capture();
795 assert_current_root(community)?;
798 let signer = crate::signer::active_signer()?;
799 let inviter_pk = me_pk()?;
800 let bundle = bundle_of(community, BundleAudience::Member(*recipient), Some(inviter_pk), expires_at_ms, label);
801 let wrap = invite::build_direct_invite_signed(&signer, inviter_pk, recipient, &bundle).await.map_err(|e| e.to_string())?;
802 if !session.is_valid() {
803 return Err("account changed before sending invite".to_string());
804 }
805 transport.publish(&wrap, &community.relays).await?;
806 Ok(wrap)
807}
808
809pub struct MintedLink {
813 pub url: String,
814 pub bundle_event: Event,
815 pub link_signer: Keys,
816 pub token: [u8; super::derive::TOKEN_LEN],
817 pub expires_at_ms: Option<u64>,
822 pub label: Option<String>,
823}
824
825pub async fn mint_public_link<T: Transport + ?Sized>(
831 transport: &T,
832 community: &CommunityV2,
833 base: &str,
834 expires_at_ms: Option<u64>,
835 label: Option<String>,
836) -> Result<MintedLink, String> {
837 let session = SessionGuard::capture();
838 ensure_folded_permission(community, &me_pk()?, crate::community::roles::Permissions::CREATE_INVITE, "minting a public invite link")?;
841 let mut token = [0u8; super::derive::TOKEN_LEN];
842 token.copy_from_slice(&super::super::random_32()[..super::derive::TOKEN_LEN]);
843 let link_signer = Keys::generate();
844 let bundle = bundle_of(community, BundleAudience::Link, Some(me_pk()?), expires_at_ms, label.clone());
845 let bundle_key = super::derive::invite_bundle_key(&token);
846 let bundle_event = invite::build_bundle_event(&link_signer, &bundle, &bundle_key).map_err(|e| e.to_string())?;
847 let url = invite::build_invite_url(base, &link_signer.public_key(), &token, &community.relays).map_err(|e| e.to_string())?;
848
849 if !session.is_valid() {
850 return Err("account changed before minting link".to_string());
851 }
852 transport.publish_durable(&bundle_event, &community.relays).await?;
853 let minted = MintedLink { url, bundle_event, link_signer, token, expires_at_ms, label: label.clone() };
854 let _ = record_minted_link(transport, community, &minted).await;
858 if session.is_valid() {
863 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
864 let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
865 let _ = crate::db::community::save_public_invite(&token_hex, &cid_hex, &minted.url, expires_at_ms.map(|e| e as i64), label.as_deref());
866 }
867 Ok(minted)
868}
869
870async fn fetch_invite_list<T: Transport + ?Sized>(
879 transport: &T,
880 relays: &[String],
881) -> Result<Option<invite::InviteList>, String> {
882 let signer = crate::signer::active_signer()?;
883 let my_pk = me_pk()?;
884 let query = Query {
885 kinds: vec![super::kind::INVITE_LIST],
886 authors: vec![my_pk.to_hex()],
887 limit: Some(4),
888 evidence: crate::community::transport::Evidence::Full,
889 ..Default::default()
890 };
891 let events = transport.fetch(&query, relays).await?;
892 let mut best: Option<(u64, invite::InviteList)> = None;
893 for e in events {
894 if let Ok(l) = invite::parse_invite_list_event_signed(&signer, my_pk, &e).await {
895 let at = e.created_at.as_secs();
896 if best.as_ref().map(|(b, _)| at > *b).unwrap_or(true) {
897 best = Some((at, l));
898 }
899 }
900 }
901 Ok(best.map(|(_, l)| l))
902}
903
904fn live_signers_for(list: &invite::InviteList, community_id_hex: &str, now_ms: u64) -> Vec<PublicKey> {
912 let dead: std::collections::HashSet<&str> = list.tombstones.iter().map(|t| t.token.as_str()).collect();
913 list.entries
914 .iter()
915 .filter(|e| e.community_id == community_id_hex && !dead.contains(e.token.as_str()))
916 .filter(|e| !e.expires_at.is_some_and(|exp| now_ms > exp))
917 .filter_map(|e| Keys::parse(&e.signer_sk).ok().map(|k| k.public_key()))
918 .collect()
919}
920
921async fn publish_invite_registry<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, session: &SessionGuard, live_signers: &[PublicKey]) -> Result<(), String> {
925 let my_pk = me_pk()?;
926 let eid = super::derive::invite_links_locator(community.id(), &my_pk.to_bytes());
927 let content = invite::build_registry_content(live_signers);
928 publish_control_edition(transport, community, session, vsk::INVITE_LINKS, &eid, &content).await?;
929 refresh_invite_registry_cache(transport, community, session).await;
933 Ok(())
934}
935
936async fn refresh_invite_registry_cache<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, session: &SessionGuard) {
940 let Ok(owner) = community.owner() else { return };
941 let Some(editions) = fetch_control_plane_whole(transport, community).await else { return };
942 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
943 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
944 .unwrap_or_default()
945 .into_iter()
946 .filter(|(_, f)| f.0 == community.root_epoch.0)
947 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
948 .collect();
949 let authority = fold_authority(community, &editions, &floors);
950 let sets = live_invite_link_sets(community.id(), &owner.to_hex(), &editions, &authority, &floors);
951 if session.is_valid() {
952 let _ = crate::db::community::set_community_invite_registry(&cid_hex, &flatten_link_sets(&sets));
953 let _ = crate::db::community::replace_invite_link_sets(&cid_hex, &sets);
954 }
955}
956
957async fn record_minted_link<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, minted: &MintedLink) -> Result<(), String> {
960 let session = SessionGuard::capture();
961 let signer = crate::signer::active_signer()?;
962 let my_pk = me_pk()?;
963 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
964 let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
965 let mut list = fetch_invite_list(transport, &community.relays).await?.unwrap_or_default();
970 if !list.entries.iter().any(|e| e.token == token_hex) {
971 list.entries.push(invite::InviteEntry {
972 token: token_hex,
973 signer_sk: minted.link_signer.secret_key().to_secret_hex(),
974 community_id: cid_hex.clone(),
975 url: minted.url.clone(),
976 label: minted.label.clone(),
977 created_at: now_ms() / 1000,
978 expires_at: minted.expires_at_ms,
979 extra: Default::default(),
980 });
981 }
982 if !session.is_valid() {
983 return Err("account changed during link record".to_string());
984 }
985 let event = invite::build_invite_list_event_signed(&signer, my_pk, &list).await.map_err(|e| e.to_string())?;
986 transport.publish(&event, &community.relays).await?;
987 let signers = live_signers_for(&list, &cid_hex, now_ms());
988 publish_invite_registry(transport, community, &session, &signers).await
989}
990
991pub async fn revoke_public_link<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, token_hex: &str) -> Result<(), String> {
997 let session = SessionGuard::capture();
998 let signer = crate::signer::active_signer()?;
999 let my_pk = me_pk()?;
1000 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1001 let mut list = fetch_invite_list(transport, &community.relays).await?.ok_or("no invite list found to revoke from")?;
1002 let entry = list
1003 .entries
1004 .iter()
1005 .find(|e| e.token == token_hex && e.community_id == cid_hex)
1006 .cloned()
1007 .ok_or("no such link in the invite list")?;
1008 let link_signer = Keys::parse(&entry.signer_sk).map_err(|_| "malformed link signer")?;
1010 let revocation = invite::build_revocation(&link_signer).map_err(|e| e.to_string())?;
1011 if !session.is_valid() {
1012 return Err("account changed during revoke".to_string());
1013 }
1014 transport.publish_durable(&revocation, &community.relays).await?;
1015 list.tombstones.push(invite::InviteTombstone { token: token_hex.to_string(), community_id: cid_hex.clone(), extra: Default::default() });
1017 list.entries.retain(|e| e.token != token_hex);
1018 let event = invite::build_invite_list_event_signed(&signer, my_pk, &list).await.map_err(|e| e.to_string())?;
1019 transport.publish(&event, &community.relays).await?;
1020 let signers = live_signers_for(&list, &cid_hex, now_ms());
1021 publish_invite_registry(transport, community, &session, &signers).await?;
1022 if session.is_valid() {
1024 let _ = crate::db::community::delete_public_invite(token_hex);
1025 }
1026 Ok(())
1027}
1028
1029pub async fn refresh_public_links<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
1034 let session = SessionGuard::capture();
1035 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1036 let signer = crate::signer::active_signer()?;
1041 let my_pk = me_pk()?;
1042 let query = Query {
1043 kinds: vec![super::kind::INVITE_LIST],
1044 authors: vec![my_pk.to_hex()],
1045 limit: Some(4),
1046 ..Default::default()
1047 };
1048 let events = transport.fetch(&query, &community.relays).await?;
1049 let mut best: Option<(u64, invite::InviteList)> = None;
1050 for e in events {
1051 if let Ok(l) = invite::parse_invite_list_event_signed(&signer, my_pk, &e).await {
1052 let at = e.created_at.as_secs();
1053 if best.as_ref().map(|(b, _)| at > *b).unwrap_or(true) {
1054 best = Some((at, l));
1055 }
1056 }
1057 }
1058 let Some((_, list)) = best else {
1059 return Ok(());
1060 };
1061 let creator = my_pk;
1062 let now = now_ms();
1063 let dead: std::collections::HashSet<&str> = list.tombstones.iter().map(|t| t.token.as_str()).collect();
1064 for entry in &list.entries {
1065 if entry.community_id != cid_hex || dead.contains(entry.token.as_str()) || entry.token.len() != 2 * super::derive::TOKEN_LEN {
1066 continue;
1067 }
1068 if entry.expires_at.is_some_and(|exp| now > exp) {
1071 continue;
1072 }
1073 let Ok(link_signer) = Keys::parse(&entry.signer_sk) else { continue };
1074 let token = crate::simd::hex::hex_to_bytes_16(&entry.token);
1075 let bundle = bundle_of(community, BundleAudience::Link, Some(creator), entry.expires_at, entry.label.clone());
1076 let bundle_key = super::derive::invite_bundle_key(&token);
1077 if let Ok(event) = invite::build_bundle_event(&link_signer, &bundle, &bundle_key) {
1078 if !session.is_valid() {
1079 return Err("account changed during link refresh".to_string());
1080 }
1081 let _ = transport.publish_durable(&event, &community.relays).await;
1082 }
1083 }
1084 let mine_here = list.entries.iter().any(|e| e.community_id == cid_hex);
1094 if !mine_here {
1095 return Ok(());
1096 }
1097 let signers = live_signers_for(&list, &cid_hex, now);
1098 if !session.is_valid() {
1099 return Err("account changed during link refresh".to_string());
1100 }
1101 let _ = publish_invite_registry(transport, community, &session, &signers).await;
1102 Ok(())
1103}
1104
1105pub async fn community_is_public<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> bool {
1110 let Ok(owner) = community.owner() else { return false };
1111 let Some(editions) = fetch_control_plane_whole(transport, community).await else { return true };
1115 let cid = community.id();
1116 let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
1117 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
1118 .unwrap_or_default()
1119 .into_iter()
1120 .filter(|(_, f)| f.0 == community.root_epoch.0)
1121 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
1122 .collect();
1123 let authority = fold_authority(community, &editions, &floors);
1124 !live_invite_link_sets(cid, &owner.to_hex(), &editions, &authority, &floors).is_empty()
1125}
1126
1127async fn fetch_control_plane_whole<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Option<Vec<ParsedEdition>> {
1133 let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
1134 let mut editions: Vec<ParsedEdition> = Vec::new();
1135 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
1136 let mut oldest: Option<u64> = None;
1137 let mut until: Option<u64> = None;
1138 for page in 0..COMPACT_MAX_PAGES {
1139 let query = Query {
1143 kinds: vec![stream::KIND_WRAP],
1144 authors: vec![control.pk_hex()],
1145 until,
1146 limit: Some(FOLLOW_PAGE),
1147 evidence: crate::community::transport::Evidence::Quorum,
1148 ..Default::default()
1149 };
1150 let Ok(wraps) = transport.fetch(&query, &community.relays).await else { return None };
1151 let mut fresh = 0usize;
1152 for w in &wraps {
1153 if !seen_wraps.insert(w.id) {
1154 continue;
1155 }
1156 fresh += 1;
1157 let at = w.created_at.as_secs();
1158 if oldest.is_none_or(|o| at < o) {
1159 oldest = Some(at);
1160 }
1161 if let Ok((ed, _)) = control::open_control_edition(w, &control) {
1162 editions.push(ed);
1163 }
1164 }
1165 if fresh == 0 {
1166 if wraps.len() >= FOLLOW_PAGE {
1167 return None; }
1169 return Some(editions);
1170 }
1171 until = oldest;
1172 if page + 1 == COMPACT_MAX_PAGES {
1173 return None;
1174 }
1175 }
1176 Some(editions)
1177}
1178
1179fn live_invite_link_sets(
1184 cid: &crate::community::CommunityId,
1185 owner_hex: &str,
1186 editions: &[ParsedEdition],
1187 authority: &AuthoritySet,
1188 floors: &Floors,
1189) -> Vec<crate::db::community::InviteLinkSetRow> {
1190 use crate::community::roles::Permissions;
1191 use std::collections::BTreeMap;
1192 let mut by_eid: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
1193 for e in editions {
1194 if e.vsk == vsk::INVITE_LINKS {
1195 by_eid.entry(e.entity_id).or_default().push(e);
1196 }
1197 }
1198 let mut sets: Vec<crate::db::community::InviteLinkSetRow> = Vec::new();
1199 for (eid, group) in &by_eid {
1200 let authed: Vec<&ParsedEdition> = group
1206 .iter()
1207 .copied()
1208 .filter(|p| {
1209 let author = p.author.to_hex();
1210 !authority.banned.contains(&author)
1212 && authority.roles.is_authorized(&author, Some(owner_hex), Permissions::CREATE_INVITE)
1213 && super::derive::invite_links_locator(cid, &p.author.to_bytes()) == *eid
1214 })
1215 .collect();
1216 if authed.is_empty() {
1217 continue;
1218 }
1219 let fold_eds: Vec<version::Edition> = authed.iter().map(|p| p.to_fold_edition()).collect();
1220 let (Some(hi), _) = fold_head(&fold_eds, floors.get(&crate::simd::hex::bytes_to_hex_32(eid))) else { continue };
1221 if let Ok(signers) = invite::parse_registry_content(&authed[hi].content) {
1222 if signers.is_empty() {
1223 continue; }
1225 sets.push(crate::db::community::InviteLinkSetRow {
1226 creator_hex: authed[hi].author.to_hex(),
1227 locators: signers.iter().map(|p| p.to_hex()).collect(),
1228 });
1229 }
1230 }
1231 sets
1232}
1233
1234fn flatten_link_sets(sets: &[crate::db::community::InviteLinkSetRow]) -> Vec<String> {
1236 let mut flat: Vec<String> = sets.iter().flat_map(|s| s.locators.iter().cloned()).collect();
1237 flat.sort();
1238 flat.dedup();
1239 flat
1240}
1241
1242async fn accept_bundle<T: Transport + ?Sized>(
1248 transport: &T,
1249 session: &SessionGuard,
1250 bundle: &CommunityInvite,
1251 invited_by: Option<PublicKey>,
1252 announce_join: bool,
1253) -> Result<CommunityV2, String> {
1254 let signer = crate::signer::active_signer()?;
1255 let my_pk = me_pk()?;
1256 let at_ms = now_ms();
1257 if bundle.expired(at_ms) {
1259 return Err("this invite has expired".to_string());
1260 }
1261 let community = CommunityV2::from_bundle(bundle, at_ms)?;
1263 let already_held = crate::db::community::load_community_v2(community.id()).ok().flatten().is_some();
1266
1267 let handoff = VERIFIED_PREVIEW.lock().unwrap().take().filter(|v| {
1276 v.session.is_valid()
1277 && v.at.elapsed() < VERIFIED_PREVIEW_TTL
1278 && v.community_id == community.id().0
1279 && v.community_root == community.community_root
1280 });
1281 let (community, join_heads, join_banlist) = match handoff {
1282 Some(v) => {
1283 let mut c = v.folded;
1284 c.created_at_ms = at_ms;
1286 (c, v.heads, v.banned)
1287 }
1288 None => verify_owner_root_and_reconcile(transport, community).await?,
1289 };
1290
1291 if is_dissolved(transport, &community).await {
1293 return Err("this community has been dissolved".to_string());
1294 }
1295
1296 if join_banlist.contains(&my_pk.to_hex()) {
1301 return Err("you are banned from this community".to_string());
1302 }
1303
1304 if !session.is_valid() {
1308 return Err("account changed during join".to_string());
1309 }
1310 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1315 for h in &join_heads {
1316 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)?;
1317 }
1318 crate::db::community::save_community_v2(&community)?;
1319 let _ = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, community.root_epoch.0, &community.community_root);
1322 for ch in &community.channels {
1325 if let (true, Some(key)) = (ch.private, ch.key) {
1326 let _ = crate::db::community::store_epoch_key(&cid_hex, &crate::simd::hex::bytes_to_hex_32(&ch.id.0), ch.epoch.0, &key);
1327 }
1328 }
1329
1330 if announce_join && !already_held {
1336 let attribution = invited_by
1337 .map(|p| p.to_hex())
1338 .or_else(|| bundle.creator_npub.clone())
1339 .zip(Some(bundle.label.clone().unwrap_or_default()));
1340 let attr_ref = attribution.as_ref().map(|(c, l)| (c.as_str(), l.as_str()));
1341 let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1342 let join_rumor = guestbook::build_join_rumor(my_pk, attr_ref, at_ms);
1343 if let Ok((join_wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &join_rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await {
1344 let _ = transport.publish(&join_wrap, &community.relays).await;
1345 }
1346 }
1347
1348 match republish_community_list(transport, Some(community.id())).await {
1352 Ok(true) => {}
1353 Ok(false) => republish_community_list_durable(Some(*community.id())),
1354 Err(e) => {
1355 crate::log_warn!("[CommunityList] failed to record this join across devices ({}) — retrying", e);
1356 republish_community_list_durable(Some(*community.id()));
1357 }
1358 }
1359 Ok(community)
1360}
1361
1362async fn verify_owner_root_and_reconcile<T: Transport + ?Sized>(
1379 transport: &T,
1380 community: CommunityV2,
1381) -> Result<(CommunityV2, Vec<FoldedHead>, std::collections::BTreeSet<String>), String> {
1382 let owner = community.owner()?;
1383 let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
1384 let control_pk = control.pk_hex();
1385
1386 super::streamauth::prime(&community);
1393
1394 const PAGE: usize = 500;
1415 const MAX_PAGES: usize = 4;
1416 const FAR_FUTURE_SECS: u64 = 4_102_444_800; let mut editions: Vec<ParsedEdition> = Vec::new();
1418 let mut all_editions: Vec<ParsedEdition> = Vec::new();
1419 let mut found_genesis = false;
1420 let mut compacted_metadata = false;
1428 crate::log_debug!(
1429 "[JoinVerify] control_pk={} root_epoch={:?} relays={:?}",
1430 &control_pk[..12], community.root_epoch, community.relays
1431 );
1432 let anchored = |found_genesis: bool, compacted_metadata: bool, owner_editions: usize, epoch: Epoch| {
1433 found_genesis || (epoch.0 > 0 && compacted_metadata && owner_editions > 0)
1434 };
1435 for attempt in 0..2 {
1436 editions.clear();
1437 all_editions.clear();
1438 compacted_metadata = false;
1439 let mut until: Option<u64> = Some(FAR_FUTURE_SECS);
1440 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
1441 for page_no in 0..MAX_PAGES {
1442 let query = Query {
1443 kinds: vec![stream::KIND_WRAP],
1444 authors: vec![control_pk.clone()],
1445 until,
1446 limit: Some(PAGE),
1447 evidence: crate::community::transport::Evidence::Full,
1448 ..Default::default()
1449 };
1450 let wraps = transport.fetch(&query, &community.relays).await?;
1451 crate::log_trace!(
1452 "[JoinVerify] attempt {} page {}: fetched {} wraps",
1453 attempt, page_no, wraps.len()
1454 );
1455 let mut oldest = u64::MAX;
1459 let mut fresh = 0usize;
1460 for w in &wraps {
1461 if !seen_wraps.insert(w.id) {
1462 continue;
1463 }
1464 fresh += 1;
1465 oldest = oldest.min(w.created_at.as_secs());
1466 if let Ok((ed, _)) = control::open_control_edition(w, &control) {
1467 crate::log_trace!(
1468 "[JoinVerify] edition vsk={} eid={} owner={} at={}",
1469 ed.vsk, crate::simd::hex::bytes_to_hex_32(&ed.entity_id)[..12].to_string(),
1470 ed.author == owner, w.created_at.as_secs()
1471 );
1472 if ed.vsk == vsk::COMMUNITY_METADATA && ed.entity_id == community.id().0 {
1473 if ed.author == owner {
1474 found_genesis = true;
1475 } else {
1476 compacted_metadata = true;
1477 }
1478 }
1479 if ed.author == owner {
1480 editions.push(ed.clone());
1481 }
1482 all_editions.push(ed);
1485 }
1486 }
1487 crate::log_debug!(
1488 "[JoinVerify] attempt {} page {}: fresh={} opened_owner={} opened_any={} genesis={} compacted={}",
1489 attempt, page_no, fresh, editions.len(), all_editions.len(), found_genesis, compacted_metadata
1490 );
1491 if anchored(found_genesis, compacted_metadata, editions.len(), community.root_epoch) || fresh == 0 {
1492 break; }
1494 until = Some(oldest);
1495 }
1496 if anchored(found_genesis, compacted_metadata, editions.len(), community.root_epoch) {
1497 break;
1498 }
1499 if attempt == 0 {
1500 if let Some(client) = crate::state::nostr_client() {
1507 super::streamauth::prime_auth(&client, &community.relays).await;
1508 }
1509 }
1510 }
1511 if !anchored(found_genesis, compacted_metadata, editions.len(), community.root_epoch) {
1512 return Err(
1513 "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"
1514 .to_string(),
1515 );
1516 }
1517 let empty_floors = Floors::new();
1526 let authority = AuthoritySet::owner_only();
1527 let fold = apply_control_fold(&community, &editions, &empty_floors, &authority);
1528 let join_banlist = fold_authority(&community, &all_editions, &empty_floors).banned;
1533 Ok((fold.updated.unwrap_or(community), fold.heads, join_banlist))
1534}
1535
1536pub async fn accept_direct_invite<T: Transport + ?Sized>(transport: &T, wrap: &Event) -> Result<CommunityV2, String> {
1540 let session = SessionGuard::capture();
1541 let signer = crate::signer::active_signer()?;
1542 let (inviter, bundle) = invite::unwrap_direct_invite_signed(&signer, wrap).await.map_err(|e| e.to_string())?;
1543 accept_bundle(transport, &session, &bundle, Some(inviter), true).await
1544}
1545
1546pub async fn accept_parked_invite<T: Transport + ?Sized>(
1552 transport: &T,
1553 bundle_json: &str,
1554 inviter_hex: Option<&str>,
1555) -> Result<CommunityV2, String> {
1556 let session = SessionGuard::capture();
1557 let bundle = CommunityInvite::from_bundle_json(bundle_json).map_err(|e| e.to_string())?;
1558 let invited_by = inviter_hex.and_then(|h| PublicKey::parse(h).ok());
1559 accept_bundle(transport, &session, &bundle, invited_by, true).await
1560}
1561
1562pub async fn accept_migration_material<T: Transport + ?Sized>(
1568 transport: &T,
1569 jm: &super::list::JoinMaterial,
1570) -> Result<CommunityV2, String> {
1571 let session = SessionGuard::capture();
1572 let bundle = material_to_invite(jm);
1573 accept_bundle(transport, &session, &bundle, None, true).await
1574}
1575
1576pub async fn fetch_public_bundle<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityInvite, String> {
1583 let parsed = invite::parse_invite_link(url).map_err(|e| e.to_string())?;
1584 let query = Query {
1589 kinds: vec![super::kind::INVITE_BUNDLE],
1590 authors: vec![parsed.link_signer.to_hex()],
1591 ..Default::default()
1592 };
1593 let relays = if parsed.bootstrap_relays.is_empty() {
1594 invite::stock_relays()
1595 } else {
1596 parsed.bootstrap_relays.clone()
1597 };
1598 let events = match transport.fetch(&query, &relays).await {
1603 Ok(evs) => evs,
1604 Err(_) => {
1605 wait_for_bootstrap_relay(&relays).await;
1606 transport.fetch(&query, &relays).await?
1607 }
1608 };
1609 let bundle_key = super::derive::invite_bundle_key(&parsed.token);
1610
1611 let mut newest_live: Option<(u64, CommunityInvite)> = None;
1613 for event in &events {
1614 match invite::parse_bundle_event(event, &parsed.link_signer, &bundle_key) {
1615 Ok(invite::BundleState::Revoked) => return Err("this invite link has been revoked".to_string()),
1616 Ok(invite::BundleState::Live(bundle)) => {
1617 let at = event.created_at.as_secs();
1618 if newest_live.as_ref().is_none_or(|(t, _)| at > *t) {
1619 newest_live = Some((at, *bundle));
1620 }
1621 }
1622 Err(_) => {} }
1624 }
1625 newest_live.map(|(_, b)| b).ok_or_else(|| "invite bundle not found on relays".to_string())
1626}
1627
1628async fn wait_for_bootstrap_relay(relays: &[String]) {
1632 let Some(client) = crate::state::nostr_client() else { return };
1633 let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(8);
1634 loop {
1635 for url in relays {
1636 if let Ok(Some(relay)) = client.relay(url).await {
1637 if relay.status() == nostr_sdk::prelude::RelayStatus::Connected {
1638 return;
1639 }
1640 }
1641 }
1642 if tokio::time::Instant::now() >= deadline {
1643 return;
1644 }
1645 tokio::time::sleep(std::time::Duration::from_millis(400)).await;
1646 }
1647}
1648
1649struct VerifiedPreview {
1655 session: SessionGuard,
1656 at: std::time::Instant,
1657 community_id: [u8; 32],
1658 community_root: [u8; 32],
1659 folded: CommunityV2,
1660 heads: Vec<FoldedHead>,
1661 banned: std::collections::BTreeSet<String>,
1664}
1665static VERIFIED_PREVIEW: std::sync::Mutex<Option<VerifiedPreview>> = std::sync::Mutex::new(None);
1666const VERIFIED_PREVIEW_TTL: std::time::Duration = std::time::Duration::from_secs(120);
1667
1668pub async fn preview_public_link<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityV2, String> {
1675 let bundle = fetch_public_bundle(transport, url).await?;
1676 preview_bundle(transport, &bundle).await
1677}
1678
1679pub async fn preview_bundle<T: Transport + ?Sized>(transport: &T, bundle: &CommunityInvite) -> Result<CommunityV2, String> {
1684 let community = CommunityV2::from_bundle(bundle, 0)?;
1685 match verify_owner_root_and_reconcile(transport, community.clone()).await {
1686 Ok((folded, heads, banned)) => {
1687 *VERIFIED_PREVIEW.lock().unwrap() = Some(VerifiedPreview {
1688 session: SessionGuard::capture(),
1689 at: std::time::Instant::now(),
1690 community_id: folded.id().0,
1691 community_root: folded.community_root,
1692 folded: folded.clone(),
1693 heads,
1694 banned,
1695 });
1696 Ok(folded)
1697 }
1698 Err(_) => Ok(community),
1699 }
1700}
1701
1702pub async fn accept_public_link<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityV2, String> {
1704 let session = SessionGuard::capture();
1706 let bundle = fetch_public_bundle(transport, url).await?;
1707 if !session.is_valid() {
1708 return Err("account changed during join".to_string());
1709 }
1710 accept_bundle(transport, &session, &bundle, None, true).await
1711}
1712
1713pub async fn leave_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
1715 let session = SessionGuard::capture();
1716 let signer = crate::signer::active_signer()?;
1717 let my_pk = me_pk()?;
1718 let at_ms = now_ms();
1719 let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1720 let leave_rumor = guestbook::build_leave_rumor(my_pk, at_ms);
1721 if let Ok((wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &leave_rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await {
1722 let _ = transport.publish(&wrap, &community.relays).await;
1723 }
1724 if !session.is_valid() {
1725 return Err("account changed during leave".to_string());
1726 }
1727 let _ = tombstone_community_list(transport, community.id(), &community.relays).await;
1731 if !session.is_valid() {
1733 return Err("account changed during leave".to_string());
1734 }
1735 crate::db::community::delete_community(&crate::simd::hex::bytes_to_hex_32(&community.id().0))?;
1736 Ok(())
1737}
1738
1739pub async fn kick_member<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, target: &PublicKey) -> Result<(), String> {
1744 let session = SessionGuard::capture();
1745 assert_current_root(community)?;
1746 let signer = crate::signer::active_signer()?;
1747 let my_pk = me_pk()?;
1748 let authority = fetch_authority(transport, community).await;
1750 let owner_hex = community.owner()?.to_hex();
1751 if !authority.roles.can_act_on_member(
1752 &my_pk.to_hex(),
1753 Some(&owner_hex),
1754 &target.to_hex(),
1755 crate::community::roles::Permissions::KICK,
1756 ) {
1757 return Err("not authorized to kick this member".to_string());
1758 }
1759 let target_hex = target.to_hex();
1771 let holds_roles = authority.roles.grants.iter().any(|g| g.member == target_hex && !g.role_ids.is_empty());
1772 let may_strip = authority.roles.can_act_on_member(
1773 &my_pk.to_hex(),
1774 Some(&owner_hex),
1775 &target_hex,
1776 crate::community::roles::Permissions::MANAGE_ROLES,
1777 );
1778 if holds_roles && may_strip {
1779 grant_roles(transport, community, target, Vec::new())
1780 .await
1781 .map_err(|e| format!("could not strip this member's roles before kicking: {e}"))?;
1782 if !session.is_valid() {
1783 return Err("account changed during kick".to_string());
1784 }
1785 }
1786 let at_ms = now_ms();
1787 let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1788 let citation = required_authority_citation(community, &my_pk)?;
1791 let rumor = guestbook::build_kick_rumor(my_pk, *target, citation.as_ref(), at_ms);
1792 let (wrap, _) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await
1793 .map_err(|e| e.to_string())?;
1794 if !session.is_valid() {
1795 return Err("account changed before send".to_string());
1796 }
1797 transport.publish(&wrap, &community.relays).await?;
1798 Ok(())
1799}
1800
1801pub struct AuthorityView {
1807 pub roles: crate::community::roles::CommunityRoles,
1808 pub banned: std::collections::BTreeSet<String>,
1809 pub gapped: bool,
1811 pub floored: std::collections::BTreeSet<String>,
1813 pub head_entities: std::collections::BTreeSet<String>,
1815 pub banned_at: std::collections::BTreeMap<String, u64>,
1817}
1818
1819pub async fn fetch_authority<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> AuthorityView {
1825 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1826 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
1827 .unwrap_or_default()
1828 .into_iter()
1829 .filter(|(_, f)| f.0 == community.root_epoch.0)
1830 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
1831 .collect();
1832 let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
1833
1834 let mut editions: Vec<ParsedEdition> = Vec::new();
1835 let mut seen: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
1836 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
1837 let mut oldest: Option<u64> = None;
1838 let mut until: Option<u64> = None;
1839 let mut a = fold_authority(community, &[], &floors);
1844 for _ in 0..FOLLOW_MAX_PAGES {
1845 let query = Query {
1849 kinds: vec![stream::KIND_WRAP],
1850 authors: vec![control.pk_hex()],
1851 until,
1852 limit: Some(FOLLOW_PAGE),
1853 evidence: crate::community::transport::Evidence::Quorum,
1854 ..Default::default()
1855 };
1856 let Ok(wraps) = transport.fetch(&query, &community.relays).await else { break };
1857 let mut fresh = 0usize;
1858 for w in &wraps {
1859 if !seen_wraps.insert(w.id) {
1860 continue;
1861 }
1862 fresh += 1;
1863 let at = w.created_at.as_secs();
1864 if oldest.is_none_or(|o| at < o) {
1865 oldest = Some(at);
1866 }
1867 if let Ok((ed, _)) = control::open_control_edition(w, &control) {
1868 if seen.insert(ed.inner_id) {
1869 editions.push(ed);
1870 }
1871 }
1872 }
1873 a = fold_authority(community, &editions, &floors);
1874 if !a.gapped || fresh == 0 {
1875 break;
1876 }
1877 until = oldest;
1878 }
1879 AuthorityView {
1880 roles: a.roles,
1881 banned: a.banned,
1882 gapped: a.gapped,
1883 floored: floors.keys().cloned().collect(),
1884 head_entities: a.heads.iter().map(|h| h.entity_hex.clone()).collect(),
1885 banned_at: a.banned_at,
1886 }
1887}
1888
1889async fn fetch_guestbook_events<T: Transport + ?Sized>(
1901 transport: &T,
1902 community: &CommunityV2,
1903 since_secs: u64,
1904) -> Result<(Vec<guestbook::GuestbookEvent>, u64), String> {
1905 let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1906 const GB_PAGE: usize = 500;
1907 const GB_MAX_PAGES: usize = 12;
1908 let mut events = Vec::new();
1909 let mut newest: u64 = since_secs;
1910 let mut seen: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
1911 let mut until: Option<u64> = None;
1912 let mut oldest: Option<u64> = None;
1913 for _ in 0..GB_MAX_PAGES {
1914 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() };
1917 let wraps = transport.fetch(&query, &community.relays).await?;
1918 let mut fresh = 0usize;
1919 for wrap in &wraps {
1920 if !seen.insert(wrap.id) {
1921 continue;
1922 }
1923 fresh += 1;
1924 let at = wrap.created_at.as_secs();
1925 if oldest.is_none_or(|o| at < o) {
1926 oldest = Some(at);
1927 }
1928 if at > newest {
1929 newest = at;
1930 }
1931 if at < since_secs {
1933 continue;
1934 }
1935 if let Ok(opened) = stream::open_wrap(wrap, &gb_group) {
1936 if let Ok(ev) = guestbook::parse_guestbook_event(&opened) {
1937 events.push(ev);
1938 }
1939 }
1940 }
1941 if fresh == 0 || wraps.len() < GB_PAGE || oldest.is_some_and(|o| o < since_secs) {
1942 break;
1943 }
1944 match oldest {
1945 Some(o) if o > 0 => until = Some(o),
1946 _ => break,
1947 }
1948 }
1949 Ok((events, newest))
1950}
1951
1952fn fold_members(
1957 community: &CommunityV2,
1958 events: &[guestbook::GuestbookEvent],
1959 mut observed: std::collections::BTreeMap<PublicKey, u64>,
1960 roles: &crate::community::roles::CommunityRoles,
1961 banlist: &std::collections::BTreeSet<PublicKey>,
1962 banned_at: &std::collections::BTreeMap<PublicKey, u64>,
1963) -> Result<Vec<PublicKey>, String> {
1964 let owner = community.owner()?;
1965 let owner_hex = owner.to_hex();
1966 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1967
1968 for g in &roles.grants {
1975 if let Some(pk) = PublicKey::from_hex(&g.member).ok().filter(|_| !g.role_ids.is_empty()) {
1976 observed.entry(pk).or_insert(0);
1977 }
1978 }
1979
1980 let snapshot_authority = (community.root_epoch.0 > 0).then_some(&owner);
1989 let can_kick = |actor: &PublicKey, target: &PublicKey, citation: Option<&crate::community::edition::AuthorityCitation>| {
1993 let actor_hex = actor.to_hex();
1994 citation_is_synced(&cid_hex, &owner_hex, &actor_hex, citation)
1995 && roles.can_act_on_member(&actor_hex, Some(&owner_hex), &target.to_hex(), crate::community::roles::Permissions::KICK)
1996 };
1997 let coalesced = guestbook::coalesce(events, now_ms(), snapshot_authority, &can_kick);
1998 let mut members = guestbook::complete_memberlist(&coalesced, &observed, banlist, banned_at);
1999 if !banlist.contains(&owner) {
2001 members.insert(owner);
2002 }
2003 Ok(members.into_iter().collect())
2004}
2005
2006pub fn stored_kick_verdict(community: &CommunityV2, member: &PublicKey) -> bool {
2016 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2017 let Ok((events, _cursor)) = crate::db::community::get_guestbook(&cid_hex) else {
2018 return false;
2019 };
2020 let Ok(owner) = community.owner() else { return false };
2021 let owner_hex = owner.to_hex();
2022 let roles = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
2023 let snapshot_authority = (community.root_epoch.0 > 0).then_some(&owner);
2024 let can_kick = |actor: &PublicKey, target: &PublicKey, citation: Option<&crate::community::edition::AuthorityCitation>| {
2025 let actor_hex = actor.to_hex();
2026 citation_is_synced(&cid_hex, &owner_hex, &actor_hex, citation)
2027 && roles.can_act_on_member(&actor_hex, Some(&owner_hex), &target.to_hex(), crate::community::roles::Permissions::KICK)
2028 };
2029 matches!(
2030 guestbook::coalesce(&events, now_ms(), snapshot_authority, &can_kick).get(member),
2031 Some(st) if st.verdict == guestbook::Verdict::Kicked
2032 )
2033}
2034
2035pub async fn sync_guestbook<T: Transport + ?Sized>(
2040 transport: &T,
2041 community: &CommunityV2,
2042 session: &SessionGuard,
2043) -> Result<Vec<guestbook::GuestbookEvent>, String> {
2044 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2045 let (mut events, cursor) = crate::db::community::get_guestbook(&cid_hex)?;
2046 let since = cursor.saturating_sub(1);
2049 let (fresh, newest) = fetch_guestbook_events(transport, community, since).await?;
2050 if !session.is_valid() {
2051 return Err("account changed during guestbook sync".to_string());
2052 }
2053 let known: std::collections::HashSet<[u8; 32]> = events.iter().map(|e| e.rumor_id).collect();
2054 let mut added = Vec::new();
2055 for ev in fresh {
2056 if !known.contains(&ev.rumor_id) {
2057 events.push(ev.clone());
2058 added.push(ev);
2059 }
2060 }
2061 if !added.is_empty() || newest > cursor {
2062 crate::db::community::set_guestbook(&cid_hex, &events, newest.max(cursor))?;
2063 }
2064 Ok(added)
2065}
2066
2067pub fn ingest_guestbook_event(community: &CommunityV2, ev: guestbook::GuestbookEvent, wrap_secs: u64) -> Result<bool, String> {
2070 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2071 let (mut events, cursor) = crate::db::community::get_guestbook(&cid_hex)?;
2072 if events.iter().any(|e| e.rumor_id == ev.rumor_id) {
2073 return Ok(false);
2074 }
2075 events.push(ev);
2076 crate::db::community::set_guestbook(&cid_hex, &events, cursor.max(wrap_secs))?;
2077 Ok(true)
2078}
2079
2080pub fn stored_memberlist(community: &CommunityV2) -> Result<Vec<PublicKey>, String> {
2087 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2088 let (events, _cursor) = crate::db::community::get_guestbook(&cid_hex)?;
2089 let mut observed: std::collections::BTreeMap<PublicKey, u64> = std::collections::BTreeMap::new();
2090 for (npub, last_active_secs) in crate::db::community::community_member_activity(&cid_hex).unwrap_or_default() {
2091 if let Ok(pk) = PublicKey::parse(&npub) {
2092 observed.insert(pk, last_active_secs.saturating_mul(1000));
2093 }
2094 }
2095 let roles = crate::db::community::get_community_roles(&cid_hex)?;
2096 let banlist: std::collections::BTreeSet<PublicKey> = crate::db::community::get_community_banlist(&cid_hex)
2097 .unwrap_or_default()
2098 .iter()
2099 .filter_map(|h| PublicKey::from_hex(h).ok())
2100 .collect();
2101 let banned_at: std::collections::BTreeMap<PublicKey, u64> = crate::db::community::get_community_ban_marks(&cid_hex)
2104 .unwrap_or_default()
2105 .into_iter()
2106 .filter_map(|(h, at)| PublicKey::from_hex(&h).ok().map(|pk| (pk, at)))
2107 .collect();
2108 fold_members(community, &events, observed, &roles, &banlist, &banned_at)
2109}
2110
2111pub async fn memberlist<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<Vec<PublicKey>, String> {
2117 let (events, _newest) = fetch_guestbook_events(transport, community, 0).await?;
2118 let mut observed: std::collections::BTreeMap<PublicKey, u64> = std::collections::BTreeMap::new();
2121 for ch in &community.channels {
2122 if let Ok(page) = fetch_channel(transport, community, &ch.id, 200).await {
2123 for f in &page {
2124 let e = observed.entry(f.event.opened().author).or_insert(0);
2125 *e = (*e).max(f.event.opened().at_ms);
2126 }
2127 }
2128 }
2129
2130 let authority = fetch_authority(transport, community).await;
2135 let banlist: std::collections::BTreeSet<PublicKey> =
2137 authority.banned.iter().filter_map(|h| PublicKey::from_hex(h).ok()).collect();
2138 let mut banned_at: std::collections::BTreeMap<PublicKey, u64> = crate::db::community::get_community_ban_marks(
2142 &crate::simd::hex::bytes_to_hex_32(&community.id().0),
2143 )
2144 .unwrap_or_default()
2145 .into_iter()
2146 .filter_map(|(h, at)| PublicKey::from_hex(&h).ok().map(|pk| (pk, at)))
2147 .collect();
2148 for (h, at) in &authority.banned_at {
2149 if let Ok(pk) = PublicKey::from_hex(h) {
2150 let slot = banned_at.entry(pk).or_insert(0);
2151 *slot = (*slot).max(*at);
2152 }
2153 }
2154 fold_members(community, &events, observed, &authority.roles, &banlist, &banned_at)
2155}
2156
2157pub async fn dissolve_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
2165 let session = SessionGuard::capture();
2166 let signer = crate::signer::active_signer()?;
2167 let my_pk = me_pk()?;
2168 if community.owner()? != my_pk {
2169 return Err("only the owner can dissolve a community".to_string());
2170 }
2171 let at = now_ms() / 1000;
2172 let rumor = super::dissolution::dissolved_tombstone_rumor(my_pk, community.id(), at);
2173 let wrap = super::dissolution::seal_dissolved_signed(&signer, my_pk, &rumor, community.id(), Timestamp::from_secs(at)).await.map_err(|e| e.to_string())?;
2174 if !session.is_valid() {
2175 return Err("account changed during dissolve".to_string());
2176 }
2177 transport.publish_durable(&wrap, &community.relays).await?;
2179 crate::db::community::set_community_dissolved(&crate::simd::hex::bytes_to_hex_32(&community.id().0))?;
2180 Ok(())
2181}
2182
2183pub async fn is_dissolved<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> bool {
2188 let group = super::derive::dissolved_group_key(community.id());
2189 let query = Query {
2190 kinds: vec![stream::KIND_WRAP],
2191 authors: vec![group.pk_hex()],
2192 limit: Some(20),
2193 ..Default::default()
2194 };
2195 let Ok(wraps) = transport.fetch(&query, &community.relays).await else {
2196 return false;
2197 };
2198 wraps.iter().any(|w| super::dissolution::verify_dissolved(w, &community.identity))
2199}
2200
2201pub async fn refound_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, removed: &[PublicKey]) -> Result<CommunityV2, String> {
2214 let session = SessionGuard::capture();
2215 let cid = community.id();
2216 let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
2217 if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
2219 return Err("this community has been dissolved; it cannot be re-founded".to_string());
2220 }
2221 let signer = crate::signer::active_signer()?;
2222 let my_pk = me_pk()?;
2223 let lock = super::realtime::follow_lock(cid);
2227 let _guard = lock.lock().await;
2228 let fresh = crate::db::community::load_community_v2(cid)?.ok_or("community gone before re-founding")?;
2232 let community = &fresh;
2233 let owner = community.owner()?;
2234
2235 {
2242 let owner_hex = owner.to_hex();
2243 let me_hex = my_pk.to_hex();
2244 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
2250 let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
2251 let authorized = my_pk == owner
2252 || (!banned.contains(&me_hex)
2253 && roster.is_authorized(&me_hex, Some(&owner_hex), crate::community::roles::Permissions::BAN)
2254 && removed.iter().all(|t| {
2255 roster.can_act_on_member(&me_hex, Some(&owner_hex), &t.to_hex(), crate::community::roles::Permissions::BAN)
2256 }));
2257 if !authorized {
2258 return Err("re-founding requires the BAN permission and outranking every removed member".to_string());
2259 }
2260 }
2261
2262 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
2266 .into_iter()
2267 .filter(|(_, f)| f.0 == community.root_epoch.0)
2268 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
2269 .collect();
2270 let current_control = control_group_key(&community.community_root, cid, community.root_epoch);
2271 let mut opened: Vec<(ParsedEdition, super::stream::OpenedStream)> = Vec::new();
2278 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
2279 let mut oldest: Option<u64> = None;
2280 let mut until: Option<u64> = None;
2281 let mut truncated = false;
2285 for page in 0..COMPACT_MAX_PAGES {
2286 let query = Query {
2290 kinds: vec![stream::KIND_WRAP],
2291 authors: vec![current_control.pk_hex()],
2292 until,
2293 limit: Some(FOLLOW_PAGE),
2294 evidence: crate::community::transport::Evidence::Full,
2295 ..Default::default()
2296 };
2297 let wraps = transport.fetch(&query, &community.relays).await?;
2298 let mut fresh = 0usize;
2299 for w in &wraps {
2300 if !seen_wraps.insert(w.id) {
2301 continue;
2302 }
2303 fresh += 1;
2304 let at = w.created_at.as_secs();
2305 if oldest.is_none_or(|o| at < o) {
2306 oldest = Some(at);
2307 }
2308 if let Ok(parsed) = control::open_control_edition(w, ¤t_control) {
2309 opened.push(parsed);
2310 }
2311 }
2312 if fresh == 0 {
2313 truncated = wraps.len() >= FOLLOW_PAGE;
2317 break;
2318 }
2319 until = oldest;
2320 if page + 1 == COMPACT_MAX_PAGES {
2321 truncated = true;
2322 }
2323 }
2324 if truncated {
2325 return Err(
2326 "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(),
2327 );
2328 }
2329
2330 let prev_epoch = community.root_epoch;
2331 let new_epoch = Epoch(prev_epoch.0.checked_add(1).ok_or("root epoch overflow")?);
2332 let prev_commit = super::derive::epoch_key_commitment(prev_epoch, &community.community_root);
2333 if !session.is_valid() {
2339 return Err("account changed during re-founding compaction".to_string());
2340 }
2341 let new_root = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch.0)?;
2342 let new_control = control_group_key(&new_root, cid, new_epoch);
2343 let at = now_ms();
2344 let at_secs = at / 1000;
2345
2346 use std::collections::BTreeMap;
2352 let mut by_eid: BTreeMap<String, Vec<usize>> = BTreeMap::new();
2353 for (i, (e, _)) in opened.iter().enumerate() {
2354 by_eid.entry(crate::simd::hex::bytes_to_hex_32(&e.entity_id)).or_default().push(i);
2355 }
2356 let mut carried: Vec<(FoldedHead, Event)> = Vec::new();
2357 for (floor_key, floor) in &floors {
2358 let head_idx = by_eid
2366 .get(floor_key)
2367 .and_then(|v| v.iter().copied().find(|&i| opened[i].0.self_hash == floor.1));
2368 let Some(head_idx) = head_idx else {
2369 return Err(format!("re-founding aborted: the committed head of control entity {floor_key} (v{}) was not served; no state published", floor.0));
2370 };
2371 let (head_ed, head_os) = &opened[head_idx];
2372 let h = FoldedHead { entity_hex: floor_key.clone(), version: head_ed.version, self_hash: head_ed.self_hash, inner_id: head_ed.inner_id };
2373 let (rewrapped, _) = super::stream::rewrap_seal(&head_os.seal, &new_control, Timestamp::from_secs(at_secs)).map_err(|e| e.to_string())?;
2374 carried.push((h, rewrapped));
2375 }
2376 if !session.is_valid() {
2377 return Err("account changed during re-founding acquire".to_string());
2378 }
2379
2380 let members = memberlist(transport, community).await?;
2382 let removed_set: std::collections::HashSet<[u8; 32]> = removed.iter().map(|p| p.to_bytes()).collect();
2383 let mut recipients: Vec<PublicKey> = members.into_iter().filter(|m| !removed_set.contains(&m.to_bytes())).collect();
2384 if !recipients.iter().any(|p| *p == my_pk) {
2385 recipients.push(my_pk);
2386 }
2387
2388 let mut base_blobs = Vec::new();
2390 for r in &recipients {
2391 base_blobs.push(
2392 super::rekey::build_blob(&signer, &my_pk.to_bytes(), r, super::rekey::RekeyScope::Root, new_epoch, &new_root)
2393 .await
2394 .map_err(|e| e.to_string())?,
2395 );
2396 }
2397 let base_group = super::derive::base_rekey_group_key(&community.community_root, cid, new_epoch);
2398 let base_chunks =
2399 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())
2400 .await
2401 .map_err(|e| e.to_string())?;
2402
2403 let mut roster_for_channels = fetch_authority(transport, community).await.roles;
2415 {
2416 let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
2417 for r in cached.roles {
2418 if !roster_for_channels.roles.iter().any(|x| x.role_id == r.role_id) {
2419 roster_for_channels.roles.push(r);
2420 }
2421 }
2422 for g in cached.grants {
2423 if !roster_for_channels.grants.iter().any(|x| x.member == g.member) {
2424 roster_for_channels.grants.push(g);
2425 }
2426 }
2427 }
2428 if !session.is_valid() {
2429 return Err("account changed during re-founding entitlement fetch".to_string());
2430 }
2431 let owner_hex_for_channels = community.owner().ok().map(|o| o.to_hex());
2432 let mut channel_updates: Vec<(ChannelId, [u8; 32], Epoch)> = Vec::new();
2433 let mut channel_chunk_sets: Vec<Vec<Event>> = Vec::new();
2434 for ch in &community.channels {
2435 let (Some(old_key), true) = (ch.key, ch.private) else { continue };
2436 let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
2437 let entitled: Vec<PublicKey> = recipients
2438 .iter()
2439 .copied()
2440 .filter(|r| {
2441 *r == my_pk
2442 || roster_for_channels.is_entitled(owner_hex_for_channels.as_deref(), &r.to_hex(), &ch_hex, &[], &[])
2443 })
2444 .collect();
2445 let ch_new_epoch = Epoch(ch.epoch.0.checked_add(1).ok_or("channel epoch overflow")?);
2446 if !session.is_valid() {
2450 return Err("account changed during re-founding channel prepare".to_string());
2451 }
2452 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)?;
2453 let ch_prev_commit = super::derive::epoch_key_commitment(ch.epoch, &old_key);
2454 let mut ch_blobs = Vec::new();
2455 for r in &entitled {
2456 ch_blobs.push(
2457 super::rekey::build_blob(&signer, &my_pk.to_bytes(), r, super::rekey::RekeyScope::Channel(ch.id), ch_new_epoch, &ch_new_key)
2458 .await
2459 .map_err(|e| e.to_string())?,
2460 );
2461 }
2462 let ch_group = super::derive::channel_rekey_group_key(&community.community_root, &ch.id, ch_new_epoch);
2463 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())
2464 .await
2465 .map_err(|e| e.to_string())?;
2466 channel_updates.push((ch.id, ch_new_key, ch_new_epoch));
2467 channel_chunk_sets.push(ch_chunks);
2468 }
2469 if !session.is_valid() {
2470 return Err("account changed during re-founding prepare".to_string());
2471 }
2472
2473 for c in &base_chunks {
2476 transport.publish_durable(c, &community.relays).await?;
2477 }
2478 for set in &channel_chunk_sets {
2479 for c in set {
2480 transport.publish_durable(c, &community.relays).await?;
2481 }
2482 }
2483 for (_, wrap) in &carried {
2484 transport.publish_durable(wrap, &community.relays).await?;
2485 }
2486 let gb_group = super::derive::guestbook_group_key(&new_root, cid, new_epoch);
2489 let snap_id = crate::community::random_32();
2490 for rumor in guestbook::build_snapshot_rumors(my_pk, &recipients, snap_id, at) {
2491 if let Ok((wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &rumor, &gb_group, Timestamp::from_secs(at_secs)).await {
2492 let _ = transport.publish(&wrap, &community.relays).await;
2493 }
2494 }
2495
2496 if !session.is_valid() {
2498 return Err("account changed during re-founding commit".to_string());
2499 }
2500 if crate::db::community::community_protocol(cid)?.is_none() {
2501 return Ok(community.clone()); }
2503 let mut updated = community.clone();
2507 updated.community_root = new_root;
2508 updated.root_epoch = new_epoch;
2509 for (id, key, ep) in &channel_updates {
2510 if let Some(c) = updated.channels.iter_mut().find(|c| c.id.0 == id.0) {
2511 c.key = Some(*key);
2512 c.epoch = *ep;
2513 }
2514 }
2515 crate::db::community::save_community_v2(&updated)?;
2516 crate::db::community::advance_server_root_epoch(&cid_hex, new_epoch.0, &new_root)?;
2521 for (h, _) in &carried {
2522 crate::db::community::set_edition_head_at_epoch(&cid_hex, &h.entity_hex, h.version, &h.self_hash, &h.inner_id, new_epoch.0)?;
2523 }
2524 if let Some(client) = crate::state::nostr_client() {
2530 super::realtime::refresh_subscription(&client).await;
2531 }
2532 for attempt in 0..3u8 {
2538 match refresh_public_links(transport, &updated).await {
2539 Ok(()) => break,
2540 Err(_) if !session.is_valid() => break, Err(e) if attempt == 2 => {
2542 crate::log_warn!("v2: post-refounding public-link refresh failed after retries ({e}); live links may serve the prior root until the next refresh");
2543 }
2544 Err(_) => continue,
2545 }
2546 }
2547 Ok(updated)
2548}
2549
2550pub async fn refound_at_birth<T: Transport + ?Sized>(
2566 transport: &T,
2567 community: &CommunityV2,
2568 snapshot_members: &[PublicKey],
2569) -> Result<CommunityV2, String> {
2570 let session = SessionGuard::capture();
2571 let cid = community.id();
2572 let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
2573 if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
2576 return Err("this community has been dissolved; it cannot be birth-refounded".to_string());
2577 }
2578 let signer = crate::signer::active_signer()?;
2579 let my_pk = me_pk()?;
2580 if my_pk != community.owner()? {
2581 return Err("only the owner can birth-refound the migration twin".to_string());
2582 }
2583 let lock = super::realtime::follow_lock(cid);
2584 let _guard = lock.lock().await;
2585 let community = crate::db::community::load_community_v2(cid)?.ok_or("twin gone before birth refound")?;
2586 if community.root_epoch.0 == 1 {
2592 return Ok(community);
2593 }
2594 if community.root_epoch.0 != 0 {
2595 return Err("birth refound only rolls a genesis (epoch 0) twin".to_string());
2596 }
2597 let community = &community;
2598
2599 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
2603 .into_iter()
2604 .filter(|(_, f)| f.0 == community.root_epoch.0)
2605 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
2606 .collect();
2607 let current_control = control_group_key(&community.community_root, cid, community.root_epoch);
2608 let mut opened: Vec<(ParsedEdition, super::stream::OpenedStream)> = Vec::new();
2609 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
2610 let mut oldest: Option<u64> = None;
2611 let mut until: Option<u64> = None;
2612 let mut truncated = false;
2614 for page in 0..COMPACT_MAX_PAGES {
2615 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() };
2616 let wraps = transport.fetch(&query, &community.relays).await?;
2617 let mut fresh = 0usize;
2618 for w in &wraps {
2619 if !seen_wraps.insert(w.id) { continue; }
2620 fresh += 1;
2621 let at = w.created_at.as_secs();
2622 if oldest.is_none_or(|o| at < o) { oldest = Some(at); }
2623 if let Ok(parsed) = control::open_control_edition(w, ¤t_control) {
2624 opened.push(parsed);
2625 }
2626 }
2627 if fresh == 0 {
2628 truncated = wraps.len() >= FOLLOW_PAGE;
2629 break;
2630 }
2631 until = oldest;
2632 if page + 1 == COMPACT_MAX_PAGES { truncated = true; }
2633 }
2634 if truncated {
2635 return Err(
2636 "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(),
2637 );
2638 }
2639
2640 let prev_epoch = community.root_epoch; let new_epoch = Epoch(1);
2642 let prev_commit = super::derive::epoch_key_commitment(prev_epoch, &community.community_root);
2643 if !session.is_valid() {
2644 return Err("account changed during birth-refound compaction".to_string());
2645 }
2646 let new_root = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch.0)?;
2647 let new_control = control_group_key(&new_root, cid, new_epoch);
2648 let at = now_ms();
2649 let at_secs = at / 1000;
2650
2651 use std::collections::BTreeMap;
2652 let mut by_eid: BTreeMap<String, Vec<usize>> = BTreeMap::new();
2653 for (i, (e, _)) in opened.iter().enumerate() {
2654 by_eid.entry(crate::simd::hex::bytes_to_hex_32(&e.entity_id)).or_default().push(i);
2655 }
2656 let mut carried: Vec<(FoldedHead, Event)> = Vec::new();
2657 for (floor_key, floor) in &floors {
2658 let head_idx = by_eid.get(floor_key).and_then(|v| v.iter().copied().find(|&i| opened[i].0.self_hash == floor.1));
2659 let Some(head_idx) = head_idx else {
2660 return Err(format!("birth refound aborted: committed head of entity {floor_key} (v{}) not served; no state published", floor.0));
2661 };
2662 let (head_ed, head_os) = &opened[head_idx];
2663 let h = FoldedHead { entity_hex: floor_key.clone(), version: head_ed.version, self_hash: head_ed.self_hash, inner_id: head_ed.inner_id };
2664 let (rewrapped, _) = super::stream::rewrap_seal(&head_os.seal, &new_control, Timestamp::from_secs(at_secs)).map_err(|e| e.to_string())?;
2665 carried.push((h, rewrapped));
2666 }
2667 if !session.is_valid() {
2668 return Err("account changed during birth-refound acquire".to_string());
2669 }
2670
2671 let base_blobs = vec![
2673 super::rekey::build_blob(&signer, &my_pk.to_bytes(), &my_pk, super::rekey::RekeyScope::Root, new_epoch, &new_root)
2674 .await
2675 .map_err(|e| e.to_string())?,
2676 ];
2677 let base_group = super::derive::base_rekey_group_key(&community.community_root, cid, new_epoch);
2678 let base_chunks =
2679 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())
2680 .await
2681 .map_err(|e| e.to_string())?;
2682 if !session.is_valid() {
2683 return Err("account changed during birth-refound prepare".to_string());
2684 }
2685
2686 for c in &base_chunks {
2688 transport.publish_durable(c, &community.relays).await?;
2689 }
2690 for (_, wrap) in &carried {
2691 transport.publish_durable(wrap, &community.relays).await?;
2692 }
2693 let gb_group = super::derive::guestbook_group_key(&new_root, cid, new_epoch);
2701 let snap_id = crate::community::random_32();
2702 let snapshot_wraps: Vec<Event> = {
2703 let mut out = Vec::new();
2704 for rumor in guestbook::build_snapshot_rumors(my_pk, snapshot_members, snap_id, at) {
2705 let (wrap, _) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &rumor, &gb_group, Timestamp::from_secs(at_secs))
2706 .await
2707 .map_err(|e| format!("seal birth snapshot: {e}"))?;
2708 out.push(wrap);
2709 }
2710 out
2711 };
2712 for wrap in &snapshot_wraps {
2713 transport.publish_durable(wrap, &community.relays).await?;
2714 }
2715 let verify_view = {
2724 let mut v = community.clone();
2725 v.community_root = new_root;
2726 v.root_epoch = new_epoch;
2727 v
2728 };
2729 let expected: Vec<PublicKey> = {
2730 let banlist = fetch_authority(transport, &verify_view).await.banned;
2731 snapshot_members.iter().copied()
2732 .filter(|m| *m != my_pk && !banlist.contains(&m.to_hex()))
2733 .collect()
2734 };
2735 if !expected.is_empty() {
2736 let folded = memberlist(transport, &verify_view).await.unwrap_or_default();
2737 let missing = expected.iter().filter(|m| !folded.contains(m)).count();
2738 if missing > 0 {
2739 return Err(format!("birth snapshot verify-back: {missing} seeded member(s) not readable from relays; not committing"));
2740 }
2741 }
2742
2743 if !session.is_valid() {
2745 return Err("account changed during birth-refound commit".to_string());
2746 }
2747 if crate::db::community::community_protocol(cid)?.is_none() {
2748 return Ok(community.clone());
2749 }
2750 let mut updated = community.clone();
2751 updated.community_root = new_root;
2752 updated.root_epoch = new_epoch;
2753 crate::db::community::save_community_v2(&updated)?;
2754 crate::db::community::advance_server_root_epoch(&cid_hex, new_epoch.0, &new_root)?;
2755 for (h, _) in &carried {
2756 crate::db::community::set_edition_head_at_epoch(&cid_hex, &h.entity_hex, h.version, &h.self_hash, &h.inner_id, new_epoch.0)?;
2757 }
2758 Ok(updated)
2759}
2760
2761fn mint_or_reuse_rotation_key(community_id_hex: &str, scope_hex: &str, new_epoch: u64) -> Result<[u8; 32], String> {
2768 if let Some(existing) = crate::db::community::held_epoch_key(community_id_hex, scope_hex, new_epoch)? {
2769 return Ok(existing);
2770 }
2771 let fresh = crate::community::random_32();
2772 crate::db::community::store_epoch_key(community_id_hex, scope_hex, new_epoch, &fresh)?;
2773 Ok(fresh)
2774}
2775
2776fn join_material(community: &CommunityV2) -> super::list::JoinMaterial {
2782 let hex = crate::simd::hex::bytes_to_hex_32;
2783 let channels = community
2784 .channels
2785 .iter()
2786 .filter(|c| c.private)
2787 .filter_map(|c| {
2791 c.key.map(|k| super::list::ChannelKeyRef { id: hex(&c.id.0), key: Some(hex(&k)), epoch: c.epoch.0, name: c.name.clone() })
2792 })
2793 .collect();
2794 super::list::JoinMaterial {
2795 community_id: hex(&community.identity.community_id.0),
2796 owner: hex(&community.identity.owner_xonly),
2797 owner_salt: hex(&community.identity.owner_salt),
2798 community_root: hex(&community.community_root),
2799 root_epoch: community.root_epoch.0,
2800 channels,
2801 relays: community.relays.clone(),
2802 name: community.name.clone(),
2803 extra: Default::default(),
2804 }
2805}
2806
2807fn material_to_invite(jm: &super::list::JoinMaterial) -> CommunityInvite {
2811 let channels = jm
2814 .channels
2815 .iter()
2816 .filter_map(|c| {
2817 c.key.as_ref().map(|k| invite::ChannelGrant { id: c.id.clone(), key: k.clone(), epoch: c.epoch, name: c.name.clone() })
2818 })
2819 .collect();
2820 CommunityInvite {
2821 community_id: jm.community_id.clone(),
2822 owner: jm.owner.clone(),
2823 owner_salt: jm.owner_salt.clone(),
2824 community_root: jm.community_root.clone(),
2825 root_epoch: jm.root_epoch,
2826 channels,
2827 relays: jm.relays.clone(),
2828 name: jm.name.clone(),
2829 icon: None,
2830 expires_at: None,
2831 creator_npub: None,
2832 label: None,
2833 extra: Default::default(),
2834 }
2835}
2836
2837fn held_v2_relays() -> Vec<String> {
2840 let mut set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
2841 if let Ok(ids) = crate::db::community::list_community_ids() {
2842 for id in ids {
2843 if matches!(crate::db::community::community_protocol(&id), Ok(Some(crate::community::ConcordProtocol::V2))) {
2844 if let Ok(Some(c)) = crate::db::community::load_community_v2(&id) {
2845 set.extend(c.relays);
2846 }
2847 }
2848 }
2849 }
2850 set.into_iter().collect()
2851}
2852
2853async fn fetch_community_list<T: Transport + ?Sized>(transport: &T, relays: &[String]) -> Result<Option<super::list::CommunityList>, String> {
2859 let signer = crate::signer::active_signer()?;
2860 let my_pk = me_pk()?;
2861 let query = Query {
2862 kinds: vec![super::kind::COMMUNITY_LIST],
2863 authors: vec![my_pk.to_hex()],
2864 limit: Some(4),
2865 ..Default::default()
2866 };
2867 let events = transport.fetch(&query, relays).await?;
2868 let seen = events.len();
2869 let mut undecryptable = 0usize;
2873 let mut unreadable: Option<(u64, String, usize, String)> = None;
2874 let mut best: Option<(u64, String, super::list::CommunityList)> = None;
2875 for e in events {
2876 let at = e.created_at.as_secs();
2877 let id_hex = e.id.to_hex();
2878 let content_len = e.content.len();
2879 match super::list::parse_list_event_signed(&signer, my_pk, &e).await {
2880 Ok(l) => {
2881 if best.as_ref().map(|(b, _, _)| at > *b).unwrap_or(true) {
2882 best = Some((at, id_hex, l));
2883 }
2884 }
2885 Err(err) => {
2886 undecryptable += 1;
2887 if unreadable.as_ref().map(|(a, _, _, _)| at > *a).unwrap_or(true) {
2888 unreadable = Some((at, id_hex, content_len, err.to_string()));
2889 }
2890 }
2891 }
2892 }
2893 if let Some((at, id, len, err)) = &unreadable {
2897 if best.as_ref().map(|(b, _, _)| at > b).unwrap_or(true) {
2898 crate::log_net_fail!(
2899 "[CommunityList] IGNORED a newer copy {} created_at={at} ({len} content bytes) — falling back to stale membership: {err}",
2900 &id[..8]
2901 );
2902 }
2903 }
2904 if let Some((at, id, l)) = &best {
2905 let bytes = serde_json::to_string(l).map(|s| s.len()).unwrap_or(0);
2906 crate::log_debug!(
2907 "[CommunityList] using {} created_at={at} ({bytes}/{} bytes) of {seen} copies, {undecryptable} unreadable",
2908 &id[..8],
2909 super::stream::NIP44_MAX_PLAINTEXT
2910 );
2911 }
2912 Ok(best.map(|(_, _, l)| l))
2913}
2914
2915pub async fn republish_community_list<T: Transport + ?Sized>(transport: &T, just_joined: Option<&crate::community::CommunityId>) -> Result<bool, String> {
2927 let session = SessionGuard::capture();
2928 let signer = crate::signer::active_signer()?;
2929 let my_pk = me_pk()?;
2930 let relays = held_v2_relays();
2931 if relays.is_empty() {
2932 return Ok(false); }
2934 let remote = match fetch_community_list(transport, &relays).await {
2938 Ok(r) => r.unwrap_or_default(),
2939 Err(e) => {
2940 crate::log_warn!(
2946 "[CommunityList] republish SKIPPED (remote fetch failed: {}){}",
2947 e,
2948 just_joined
2949 .map(|c| format!(" — the join of {} is NOT recorded across devices", &crate::simd::hex::bytes_to_hex_32(&c.0)[..8]))
2950 .unwrap_or_default()
2951 );
2952 return Ok(false);
2953 }
2954 };
2955 let just_joined_hex = just_joined.map(|c| crate::simd::hex::bytes_to_hex_32(&c.0));
2956 let now = now_ms();
2957 let mut local = super::list::CommunityList::default();
2958 for id in crate::db::community::list_community_ids()? {
2959 if !matches!(crate::db::community::community_protocol(&id), Ok(Some(crate::community::ConcordProtocol::V2))) {
2960 continue;
2961 }
2962 let Some(c) = crate::db::community::load_community_v2(&id)? else { continue };
2963 let cid_hex = crate::simd::hex::bytes_to_hex_32(&c.id().0);
2964 let is_join = just_joined_hex.as_deref() == Some(cid_hex.as_str());
2965 let tombstoned_at = remote
2977 .tombstones
2978 .iter()
2979 .find(|t| t.community_id == cid_hex)
2980 .map(|t| t.removed_at)
2981 .unwrap_or(0);
2982 let held_since = c.created_at_ms;
2983 if !is_join && !remote.is_live(&cid_hex) && tombstoned_at > 0 && held_since <= tombstoned_at {
2984 crate::log_warn!(
2985 "[CommunityList] holding {} but NOT recording it: a tombstone at {} post-dates our hold ({}) — treated as a leave from another device",
2986 &cid_hex[..8], tombstoned_at, held_since
2987 );
2988 continue;
2989 }
2990 let added_at = if remote.is_live(&cid_hex) && !is_join {
2995 remote.entries.iter().find(|e| e.community_id == cid_hex).map(|e| e.added_at).unwrap_or(now)
2996 } else if !is_join && tombstoned_at > 0 {
2997 held_since
2998 } else {
2999 now
3000 };
3001 let jm = join_material(&c);
3002 local.entries.push(super::list::CommunityListEntry { community_id: cid_hex, seed: jm.clone(), current: jm, added_at, extra: Default::default() });
3003 }
3004 let merged = remote.merge(&local);
3005 merged.assert_fits().map_err(|e| e.to_string())?;
3006 let event = super::list::build_list_event_signed(&signer, my_pk, &merged).await.map_err(|e| e.to_string())?;
3007 if !session.is_valid() {
3008 return Err("account changed during community-list publish".to_string());
3009 }
3010 if let Err(e) = transport.publish(&event, &relays).await {
3011 crate::log_warn!("[CommunityList] publish FAILED ({}) — memberships stay local-only until the next edit", e);
3012 return Err(e);
3013 }
3014 Ok(true)
3015}
3016
3017const LIST_REPUBLISH_BACKOFF_SECS: [u64; 6] = [2, 5, 15, 45, 120, 300];
3022
3023pub fn republish_community_list_durable(just_joined: Option<crate::community::CommunityId>) {
3036 if crate::state::nostr_client().is_none() {
3037 return;
3038 }
3039 let session = SessionGuard::capture();
3040 tokio::spawn(async move {
3041 for (attempt, wait) in LIST_REPUBLISH_BACKOFF_SECS.iter().enumerate() {
3042 if !session.is_valid() {
3043 return;
3044 }
3045 let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
3046 match republish_community_list(&transport, just_joined.as_ref()).await {
3047 Ok(true) => {
3048 if attempt > 0 {
3049 crate::log_info!("[CommunityList] membership recorded on retry #{}", attempt);
3050 }
3051 return;
3052 }
3053 Ok(false) => {} Err(e) => crate::log_warn!("[CommunityList] republish attempt #{} failed: {}", attempt, e),
3055 }
3056 tokio::time::sleep(std::time::Duration::from_secs(*wait)).await;
3057 }
3058 crate::log_warn!(
3059 "[CommunityList] gave up recording membership after {} attempts — it will re-record on the next join/leave",
3060 LIST_REPUBLISH_BACKOFF_SECS.len()
3061 );
3062 });
3063}
3064
3065async fn tombstone_community_list<T: Transport + ?Sized>(transport: &T, community_id: &crate::community::CommunityId, relays: &[String]) -> Result<(), String> {
3068 let signer = crate::signer::active_signer()?;
3069 let my_pk = me_pk()?;
3070 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community_id.0);
3071 let mut doc = match fetch_community_list(transport, relays).await {
3075 Ok(d) => d.unwrap_or_default(),
3076 Err(e) => return Err(e),
3077 };
3078 let now = now_ms();
3079 doc.tombstones.retain(|t| t.community_id != cid_hex);
3080 doc.tombstones.push(super::list::Tombstone { community_id: cid_hex, removed_at: now, extra: Default::default() });
3081 doc.assert_fits().map_err(|e| e.to_string())?;
3082 let event = super::list::build_list_event_signed(&signer, my_pk, &doc).await.map_err(|e| e.to_string())?;
3083 transport.publish(&event, relays).await
3084}
3085
3086pub struct ListSyncOutcome {
3093 pub joined: Vec<CommunityV2>,
3095 pub removed: Vec<(String, Vec<String>)>,
3102}
3103
3104pub async fn sync_community_list<T: Transport + ?Sized>(transport: &T, bootstrap_relays: &[String]) -> Result<ListSyncOutcome, String> {
3105 let session = SessionGuard::capture();
3106 let mut relays = held_v2_relays();
3107 relays.extend(bootstrap_relays.iter().cloned());
3108 relays.sort();
3109 relays.dedup();
3110 if relays.is_empty() {
3111 return Ok(ListSyncOutcome { joined: vec![], removed: vec![] });
3112 }
3113 let list = match fetch_community_list(transport, &relays).await {
3117 Ok(Some(l)) => {
3118 crate::log_debug!(
3119 "[CommunityList] fetched: {} entries, {} tombstones, across {} relays",
3120 l.entries.len(),
3121 l.tombstones.len(),
3122 relays.len()
3123 );
3124 l
3125 }
3126 Ok(None) => {
3127 crate::log_debug!("[CommunityList] no kind-13302 across {} relays", relays.len());
3131 return Ok(ListSyncOutcome { joined: vec![], removed: vec![] });
3132 }
3133 Err(e) => {
3134 crate::log_net_fail!("[CommunityList] fetch failed across {} relays: {e}", relays.len());
3135 return Ok(ListSyncOutcome { joined: vec![], removed: vec![] });
3136 }
3137 };
3138 let mut removed: Vec<(String, Vec<String>)> = Vec::new();
3144 for t in &list.tombstones {
3145 if list.is_live(&t.community_id) {
3146 continue;
3147 }
3148 let Some(cid) = crate::simd::hex::hex_to_bytes_32_checked(&t.community_id) else { continue };
3149 let id = crate::community::CommunityId(cid);
3150 let Some(held) = crate::db::community::load_community_v2(&id).ok().flatten() else {
3151 continue; };
3153 if held.created_at_ms > t.removed_at {
3159 crate::log_warn!(
3160 "[CommunityList] {} is tombstoned at {} but our hold ({}) post-dates it — treating as a rejoin, not tearing down",
3161 &t.community_id[..8], t.removed_at, held.created_at_ms
3162 );
3163 continue;
3164 }
3165 if !session.is_valid() {
3166 return Err("account changed during community-list sync".to_string());
3167 }
3168 let channel_ids: Vec<String> = held.channels.iter().map(|c| crate::simd::hex::bytes_to_hex_32(&c.id.0)).collect();
3169 let _ = crate::db::community::delete_community(&t.community_id);
3170 removed.push((t.community_id.clone(), channel_ids));
3171 }
3172 let mut joined = Vec::new();
3173 for entry in list.live_entries() {
3174 let Some(cid) = crate::simd::hex::hex_to_bytes_32_checked(&entry.community_id) else { continue };
3175 if crate::db::community::community_exists(&crate::community::CommunityId(cid)).unwrap_or(false) {
3180 continue; }
3182 if !session.is_valid() {
3183 return Err("account changed during community-list sync".to_string());
3184 }
3185 let bundle = material_to_invite(&entry.current);
3190 match accept_bundle(transport, &session, &bundle, None, false).await {
3191 Ok(community) => joined.push(community),
3192 Err(e) => crate::log_net_fail!(
3196 "[CommunityList] {} is listed but adoption failed: {e}",
3197 &entry.community_id[..entry.community_id.len().min(8)]
3198 ),
3199 }
3200 }
3201 Ok(ListSyncOutcome { joined, removed })
3202}
3203
3204pub(super) fn citation_is_synced(
3234 cid_hex: &str,
3235 owner_hex: &str,
3236 actor_hex: &str,
3237 citation: Option<&crate::community::edition::AuthorityCitation>,
3238) -> bool {
3239 if owner_hex == actor_hex {
3240 return true;
3241 }
3242 if citation.is_none() {
3243 return false;
3244 }
3245 let cid_bytes = crate::simd::hex::hex_to_bytes_32(cid_hex);
3246 let actor_bytes = crate::simd::hex::hex_to_bytes_32(actor_hex);
3247 let grant_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(
3248 &crate::community::CommunityId(cid_bytes),
3249 &actor_bytes,
3250 ));
3251 let head: Vec<crate::community::roster::EntityHead> =
3252 crate::db::community::get_edition_head(cid_hex, &grant_hex)
3253 .ok()
3254 .flatten()
3255 .map(|(version, self_hash)| crate::community::roster::EntityHead {
3256 entity_hex: grant_hex.clone(),
3257 version,
3258 self_hash,
3259 inner_id: [0u8; 32],
3260 citation: None,
3261 })
3262 .into_iter()
3263 .collect();
3264 crate::community::roster::authority_citation_satisfied(&head, Some(owner_hex), actor_hex, &grant_hex, citation)
3265}
3266
3267fn required_authority_citation(
3275 community: &CommunityV2,
3276 actor: &PublicKey,
3277) -> Result<Option<crate::community::edition::AuthorityCitation>, String> {
3278 if community.owner().ok().as_ref() == Some(actor) {
3279 return Ok(None); }
3281 my_authority_citation(community, actor).map(Some).ok_or_else(|| {
3282 "your admin rights aren't synced on this device yet — reopen the community and retry".to_string()
3283 })
3284}
3285
3286fn my_authority_citation(
3287 community: &CommunityV2,
3288 actor: &PublicKey,
3289) -> Option<crate::community::edition::AuthorityCitation> {
3290 if community.owner().ok().as_ref() == Some(actor) {
3291 return None;
3292 }
3293 let entity_id = super::derive::grant_locator(community.id(), &actor.to_bytes());
3294 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3295 let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
3296 crate::db::community::get_edition_head(&cid_hex, &entity_hex)
3297 .ok()
3298 .flatten()
3299 .map(|(version, edition_hash)| crate::community::edition::AuthorityCitation { entity_id, version, edition_hash })
3300}
3301
3302fn assert_current_root(community: &CommunityV2) -> Result<(), String> {
3310 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3311 match crate::db::community::get_server_root_epoch(&cid_hex)? {
3312 Some(held) if held != community.root_epoch.0 => Err(format!(
3313 "the community re-founded mid-action (epoch {} -> {held}); retry",
3314 community.root_epoch.0
3315 )),
3316 _ => Ok(()), }
3318}
3319
3320async fn publish_control_edition<T: Transport + ?Sized>(
3321 transport: &T,
3322 community: &CommunityV2,
3323 session: &SessionGuard,
3324 vsk: &str,
3325 entity_id: &[u8; 32],
3326 content: &str,
3327) -> Result<(), String> {
3328 assert_current_root(community)?;
3329 let signer = crate::signer::active_signer()?;
3330 let my_pk = me_pk()?;
3331 let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
3332 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3333 let entity_hex = crate::simd::hex::bytes_to_hex_32(entity_id);
3334 let (version, prev) = match crate::db::community::get_edition_head(&cid_hex, &entity_hex)? {
3335 Some((v, h)) => (v + 1, Some(h)),
3336 None => (1, None),
3337 };
3338 let citation = required_authority_citation(community, &my_pk)?;
3344 let at = now_ms() / 1000;
3345 let rumor = control::build_edition_rumor(my_pk, vsk, entity_id, version, prev.as_ref(), content, at, citation.as_ref());
3346 let (wrap, _) = control::seal_control_edition_signed(&signer, my_pk, &rumor, &control, Timestamp::from_secs(at)).await.map_err(|e| e.to_string())?;
3347 if !session.is_valid() {
3348 return Err("account changed before control publish".to_string());
3349 }
3350 transport.publish(&wrap, &community.relays).await?;
3351 if !session.is_valid() {
3357 return Ok(());
3358 }
3359 if let Ok((ed, _)) = control::open_control_edition(&wrap, &control) {
3360 crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, ed.version, &ed.self_hash, &ed.inner_id, community.root_epoch.0)?;
3361 }
3362 Ok(())
3363}
3364
3365fn merge_local_roster(cid_hex: &str, role: Option<&crate::community::roles::Role>, grant: Option<&crate::community::roles::MemberGrant>) {
3374 let mut roster = crate::db::community::get_community_roles(cid_hex).unwrap_or_default();
3375 if let Some(r) = role {
3376 match roster.roles.iter_mut().find(|x| x.role_id == r.role_id) {
3377 Some(slot) => *slot = r.clone(),
3378 None => roster.roles.push(r.clone()),
3379 }
3380 }
3381 if let Some(g) = grant {
3382 match roster.grants.iter_mut().find(|x| x.member == g.member) {
3383 Some(slot) => *slot = g.clone(),
3384 None => roster.grants.push(g.clone()),
3385 }
3386 }
3387 let at = crate::db::community::get_community_roles_at(cid_hex).unwrap_or(0);
3388 if let Err(e) = crate::db::community::set_community_roles(cid_hex, &roster, at) {
3389 crate::log_warn!("v2: local roster merge failed (heals on the next control fold): {e}");
3390 }
3391}
3392
3393pub async fn set_role<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, role: &crate::community::roles::Role) -> Result<(), String> {
3397 let session = SessionGuard::capture();
3398 super::roles::validate_role(role)?;
3399 let content = super::roles::role_content_json(role)?;
3400 let role_id = crate::simd::hex::hex_to_bytes_32_checked(&role.role_id).ok_or("role_id must be 32-byte hex")?;
3401 publish_control_edition(transport, community, &session, vsk::ROLE, &role_id, &content).await
3402}
3403
3404pub async fn grant_roles<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey, role_ids: Vec<String>) -> Result<(), String> {
3408 let session = SessionGuard::capture();
3409 let grant = crate::community::roles::MemberGrant { member: member.to_hex(), role_ids };
3410 let content = super::roles::grant_content_json(&grant)?;
3411 let eid = super::derive::grant_locator(community.id(), &member.to_bytes());
3412 publish_control_edition(transport, community, &session, vsk::GRANT, &eid, &content).await
3413}
3414
3415pub async fn ensure_admin_role<T: Transport + ?Sized>(
3420 transport: &T,
3421 community: &CommunityV2,
3422 view: &AuthorityView,
3423 create_if_missing: bool,
3424) -> Result<Option<String>, String> {
3425 use crate::community::roles::{Permissions, Role, RoleScope};
3426 if let Some(r) = view
3430 .roles
3431 .roles
3432 .iter()
3433 .find(|r| matches!(r.scope, RoleScope::Server) && r.permissions.contains(Permissions::ADMIN_FOUNDING_MASK))
3434 {
3435 return Ok(Some(r.role_id.clone()));
3436 }
3437 if !create_if_missing {
3438 return Ok(None);
3439 }
3440 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3441 let role_id = crate::crypto::sha256_hex(format!("vector/v2/role/admin/{cid_hex}").as_bytes());
3442 set_role(transport, community, &Role::admin(role_id.clone())).await?;
3443 Ok(Some(role_id))
3444}
3445
3446pub async fn grant_admin<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey) -> Result<(), String> {
3453 let session = SessionGuard::capture();
3456 let my_pk = me_pk()?;
3457 if my_pk != community.owner()? {
3458 return Err("only the community owner can grant @admin".to_string());
3459 }
3460 let view = fetch_authority(transport, community).await;
3461 if !session.is_valid() {
3462 return Err("account changed during grant".to_string());
3463 }
3464 let member_hex = member.to_hex();
3465 require_grant_head(community, &view, &member_hex)?;
3466 let role_id = ensure_admin_role(transport, community, &view, true)
3467 .await?
3468 .expect("create_if_missing yields an id");
3469 let mut role_ids = view
3470 .roles
3471 .grants
3472 .iter()
3473 .find(|g| g.member == member_hex)
3474 .map(|g| g.role_ids.clone())
3475 .unwrap_or_default();
3476 if role_ids.contains(&role_id) {
3477 return Ok(()); }
3479 role_ids.push(role_id);
3480 grant_roles(transport, community, member, role_ids).await
3481}
3482
3483pub async fn revoke_admin<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey) -> Result<(), String> {
3486 let session = SessionGuard::capture();
3487 let my_pk = me_pk()?;
3488 if my_pk != community.owner()? {
3489 return Err("only the community owner can revoke @admin".to_string());
3490 }
3491 let view = fetch_authority(transport, community).await;
3492 if !session.is_valid() {
3493 return Err("account changed during revoke".to_string());
3494 }
3495 let member_hex = member.to_hex();
3496 require_grant_head(community, &view, &member_hex)?;
3497 let Some(role_id) = ensure_admin_role(transport, community, &view, false).await? else {
3498 return Ok(()); };
3500 let mut role_ids = view
3501 .roles
3502 .grants
3503 .iter()
3504 .find(|g| g.member == member_hex)
3505 .map(|g| g.role_ids.clone())
3506 .unwrap_or_default();
3507 let before = role_ids.len();
3508 role_ids.retain(|r| r != &role_id);
3509 if role_ids.len() == before {
3510 return Ok(());
3511 }
3512 grant_roles(transport, community, member, role_ids).await
3513}
3514
3515fn require_grant_head(community: &CommunityV2, view: &AuthorityView, member_hex: &str) -> Result<(), String> {
3519 let Some(member) = crate::simd::hex::hex_to_bytes_32_checked(member_hex) else {
3520 return Err("malformed member key".to_string());
3521 };
3522 let eid_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &member));
3523 if view.floored.contains(&eid_hex) && !view.head_entities.contains(&eid_hex) {
3524 return Err("this member's current grant could not be fetched; try again once relays serve the control plane".to_string());
3525 }
3526 Ok(())
3527}
3528
3529pub async fn set_banlist<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, banned: &[String]) -> Result<(), String> {
3536 let session = SessionGuard::capture();
3537 super::roles::validate_banlist(banned)?;
3538 {
3539 let my_pk = me_pk()?;
3540 let owner = community.owner()?;
3541 if my_pk != owner {
3542 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3543 let me_hex = my_pk.to_hex();
3544 let current = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
3545 if current.contains(&me_hex) {
3546 return Err("you are banned from this community".to_string());
3547 }
3548 let roster = crate::db::community::get_community_roles(&cid_hex)?;
3549 let owner_hex = owner.to_hex();
3550 if !roster.is_authorized(&me_hex, Some(&owner_hex), crate::community::roles::Permissions::BAN) {
3551 return Err("editing the banlist needs the BAN permission".to_string());
3552 }
3553 for target in banned.iter().filter(|t| !current.contains(*t)) {
3556 if !roster.can_act_on_member(&me_hex, Some(&owner_hex), target, crate::community::roles::Permissions::BAN) {
3557 return Err("you do not outrank a member this ban targets".to_string());
3558 }
3559 }
3560 }
3561 }
3562 let content = super::roles::banlist_content_json(banned)?;
3563 let eid = super::derive::banlist_locator(community.id());
3564 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3565 let entity_hex = crate::simd::hex::bytes_to_hex_32(&eid);
3566 let version = match crate::db::community::get_edition_head(&cid_hex, &entity_hex)? {
3568 Some((v, _)) => v + 1,
3569 None => 1,
3570 };
3571 publish_control_edition(transport, community, &session, vsk::BANLIST, &eid, &content).await?;
3572 if session.is_valid() {
3579 let _ = crate::db::community::set_community_banlist(&cid_hex, banned, version as i64);
3580 }
3581 Ok(())
3582}
3583
3584pub async fn edit_community_metadata<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, meta: &control::CommunityMetadata) -> Result<(), String> {
3587 let session = SessionGuard::capture();
3588 ensure_folded_permission(community, &me_pk()?, crate::community::roles::Permissions::MANAGE_METADATA, "editing the community metadata")?;
3589 control::validate_community_metadata(meta).map_err(|e| e.to_string())?;
3590 let content = serde_json::to_string(meta).map_err(|e| e.to_string())?;
3591 publish_control_edition(transport, community, &session, vsk::COMMUNITY_METADATA, &community.id().0, &content).await
3592}
3593
3594pub async fn persist_community_image(
3599 id: &crate::community::CommunityId,
3600 img: control::ImageRef,
3601 is_banner: bool,
3602 session: &SessionGuard,
3603) -> Option<CommunityV2> {
3604 let lock = super::realtime::follow_lock(id);
3605 let _guard = lock.lock().await;
3606 if !session.is_valid() {
3607 return None;
3608 }
3609 let mut fresh = crate::db::community::load_community_v2(id).ok()??;
3610 if is_banner {
3611 fresh.banner = Some(img);
3612 } else {
3613 fresh.icon = Some(img);
3614 }
3615 crate::db::community::save_community_v2(&fresh).ok()?;
3616 Some(fresh)
3617}
3618
3619pub async fn edit_channel_metadata<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, channel_id: &ChannelId, meta: &control::ChannelMetadata) -> Result<(), String> {
3622 let session = SessionGuard::capture();
3623 let my_pk = me_pk()?;
3624 ensure_channel_manager(community, &my_pk)?;
3625 let old_name = community.channel(channel_id).map(|c| c.name.clone());
3626 if meta.private {
3631 if let Some(held) = community.channel(channel_id) {
3632 if !held.private {
3633 return Err("converting a public channel to private is not supported yet".to_string());
3634 }
3635 }
3636 }
3637 control::validate_channel_metadata(meta).map_err(|e| e.to_string())?;
3638 let content = serde_json::to_string(meta).map_err(|e| e.to_string())?;
3639 publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
3640 if !session.is_valid() {
3644 return Ok(());
3645 }
3646 if let Ok(Some(mut held)) = crate::db::community::load_community_v2(community.id()) {
3647 if let Some(ch) = held.channels.iter_mut().find(|c| c.id.0 == channel_id.0) {
3648 ch.name = meta.name.clone();
3649 ch.private = meta.private;
3650 ch.voice = meta.voice;
3651 ch.meta_custom = meta.custom.clone();
3652 ch.meta_extra = meta.extra.clone();
3653 crate::db::community::save_community_v2(&held)?;
3654 }
3655 }
3656 if meta.private {
3658 if let Some(old) = old_name.filter(|o| *o != meta.name) {
3659 rename_channel_access_role(transport, community, channel_id, &old, &meta.name, &session).await;
3660 }
3661 }
3662 Ok(())
3663}
3664
3665async fn rename_channel_access_role<T: Transport + ?Sized>(
3672 transport: &T,
3673 community: &CommunityV2,
3674 channel_id: &ChannelId,
3675 old_name: &str,
3676 new_name: &str,
3677 session: &SessionGuard,
3678) {
3679 let (Ok(my_pk), Ok(owner)) = (me_pk(), community.owner()) else {
3680 return;
3681 };
3682 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3683 let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
3684 let mut roster = fetch_authority(transport, community).await.roles;
3687 let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
3688 for r in cached.roles {
3689 if !roster.roles.iter().any(|x| x.role_id == r.role_id) {
3690 roster.roles.push(r);
3691 }
3692 }
3693 if !session.is_valid() {
3694 return;
3695 }
3696 let (me_hex, owner_hex) = (my_pk.to_hex(), owner.to_hex());
3700 if !roster.is_authorized_in(&me_hex, Some(&owner_hex), &chan_hex, crate::community::roles::Permissions::MANAGE_ROLES) {
3701 return;
3702 }
3703 let Some(mut role) = roster
3706 .channel_roles(&chan_hex)
3707 .into_iter()
3708 .find(|r| r.permissions == crate::community::roles::Permissions::empty() && r.name == old_name)
3709 .cloned()
3710 else {
3711 return;
3712 };
3713 if !roster.can_act_on_position(&me_hex, Some(&owner_hex), role.position, crate::community::roles::Permissions::MANAGE_ROLES) {
3714 return;
3715 }
3716 role.name = new_name.to_string();
3717 if let Err(e) = set_role(transport, community, &role).await {
3718 crate::log_warn!("v2: channel renamed but its access role did not follow: {e}");
3719 return;
3720 }
3721 if session.is_valid() {
3722 merge_local_roster(&cid_hex, Some(&role), None);
3723 }
3724}
3725
3726fn ensure_folded_permission(
3733 community: &CommunityV2,
3734 me: &PublicKey,
3735 needed: u64,
3736 verb: &str,
3737) -> Result<(), String> {
3738 let owner = community.owner()?;
3739 if *me == owner {
3740 return Ok(());
3741 }
3742 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3743 let me_hex = me.to_hex();
3744 if crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default().contains(&me_hex) {
3745 return Err("you are banned from this community".to_string());
3746 }
3747 let roster = crate::db::community::get_community_roles(&cid_hex)?;
3748 if roster.is_authorized(&me_hex, Some(&owner.to_hex()), needed) {
3749 Ok(())
3750 } else {
3751 Err(format!("{verb} needs the {} permission", permission_label(needed)))
3752 }
3753}
3754
3755fn permission_label(bits: u64) -> &'static str {
3756 use crate::community::roles::Permissions;
3757 match bits {
3758 Permissions::MANAGE_ROLES => "MANAGE_ROLES",
3759 Permissions::MANAGE_CHANNELS => "MANAGE_CHANNELS",
3760 Permissions::MANAGE_METADATA => "MANAGE_METADATA",
3761 Permissions::BAN => "BAN",
3762 Permissions::CREATE_INVITE => "CREATE_INVITE",
3763 _ => "required",
3764 }
3765}
3766
3767fn ensure_channel_manager(community: &CommunityV2, me: &PublicKey) -> Result<(), String> {
3769 ensure_folded_permission(community, me, crate::community::roles::Permissions::MANAGE_CHANNELS, "managing channels here")
3770}
3771
3772pub async fn create_public_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str) -> Result<ChannelId, String> {
3778 let channel_id = ChannelId(super::super::random_32());
3779 create_public_channel_with_id(transport, community, name, channel_id).await?;
3780 Ok(channel_id)
3781}
3782
3783pub async fn create_public_channel_with_id<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str, channel_id: ChannelId) -> Result<(), String> {
3787 let session = SessionGuard::capture();
3788 let lock = super::realtime::follow_lock(community.id());
3792 let _guard = lock.lock().await;
3793 let my_pk = me_pk()?;
3794 ensure_channel_manager(community, &my_pk)?;
3795 assert_channel_id_free(&channel_id, community.id())?;
3796 let meta = control::ChannelMetadata { name: name.to_string(), private: false, voice: None, deleted: None, custom: None, extra: Default::default() };
3797 control::validate_channel_metadata(&meta).map_err(|e| e.to_string())?;
3798 let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
3799 publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
3800 if !session.is_valid() {
3801 return Err("account changed during channel create".to_string());
3802 }
3803 let mut updated = community.clone();
3805 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() });
3806 crate::db::community::save_community_v2(&updated)?;
3807 Ok(())
3808}
3809
3810fn assert_channel_id_free(channel_id: &ChannelId, community_id: &crate::community::CommunityId) -> Result<(), String> {
3815 let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
3816 if let Ok(Some(existing)) = crate::db::community::community_id_for_channel(&ch_hex) {
3817 let mine = crate::simd::hex::bytes_to_hex_32(&community_id.0);
3818 let existing_id = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&existing));
3819 if existing != mine
3820 && matches!(crate::db::community::community_protocol(&existing_id), Ok(Some(crate::community::ConcordProtocol::V2)))
3821 {
3822 return Err("channel id is already live in another v2 community".to_string());
3823 }
3824 }
3825 Ok(())
3826}
3827
3828pub async fn create_private_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str) -> Result<ChannelId, String> {
3840 let channel_id = ChannelId(super::super::random_32());
3841 create_private_channel_with_id(transport, community, name, channel_id).await?;
3842 Ok(channel_id)
3843}
3844
3845pub fn channel_access_role(channel_id: &ChannelId, name: &str) -> crate::community::roles::Role {
3850 use crate::community::roles::{Permissions, Role, RoleScope};
3851 Role {
3852 role_id: crate::simd::hex::bytes_to_hex_32(&super::super::random_32()),
3853 name: name.to_string(),
3854 position: u32::MAX - 1,
3855 permissions: Permissions::empty(),
3856 scope: RoleScope::Channel(crate::simd::hex::bytes_to_hex_32(&channel_id.0)),
3857 color: 0,
3858 }
3859}
3860
3861pub async fn create_private_channel_with_id<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str, channel_id: ChannelId) -> Result<(), String> {
3864 let session = SessionGuard::capture();
3865 let lock = super::realtime::follow_lock(community.id());
3869 let _guard = lock.lock().await;
3870 let signer = crate::signer::active_signer()?;
3871 let my_pk = me_pk()?;
3872 ensure_channel_manager(community, &my_pk)?;
3873 assert_channel_id_free(&channel_id, community.id())?;
3874 let meta = control::ChannelMetadata { name: name.to_string(), private: true, voice: None, deleted: None, custom: None, extra: Default::default() };
3875 control::validate_channel_metadata(&meta).map_err(|e| e.to_string())?;
3876 let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
3877
3878 let channel_key = super::super::random_32();
3879 let epoch = Epoch(1);
3880
3881 let access_role = channel_access_role(&channel_id, name);
3884 let access_role_ids = vec![access_role.role_id.clone()];
3885
3886 let owner = community.owner()?;
3890 let mut recipients = vec![my_pk];
3891 if owner != my_pk {
3892 recipients.push(owner);
3893 }
3894 let prev_commit = super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
3895 let mut blobs = Vec::with_capacity(recipients.len());
3896 for r in &recipients {
3897 blobs.push(
3898 rekey::build_blob(&signer, &my_pk.to_bytes(), r, RekeyScope::Channel(channel_id), epoch, &channel_key)
3899 .await
3900 .map_err(|e| e.to_string())?,
3901 );
3902 }
3903 let group = channel_rekey_group_key(&community.community_root, &channel_id, epoch);
3904 let at_secs = now_ms() / 1000;
3905 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())
3906 .await
3907 .map_err(|e| e.to_string())?;
3908 if !session.is_valid() {
3909 return Err("account changed during channel create".to_string());
3910 }
3911 for c in &chunks {
3912 transport.publish_durable(c, &community.relays).await?;
3913 }
3914 publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
3915 if !session.is_valid() {
3916 return Err("account changed during channel create".to_string());
3917 }
3918 set_role(transport, community, &access_role).await?;
3923 grant_roles(transport, community, &my_pk, access_role_ids.clone()).await?;
3924 if !session.is_valid() {
3925 return Err("account changed during channel create".to_string());
3926 }
3927 merge_local_roster(
3930 &crate::simd::hex::bytes_to_hex_32(&community.id().0),
3931 Some(&access_role),
3932 Some(&crate::community::roles::MemberGrant { member: my_pk.to_hex(), role_ids: access_role_ids }),
3933 );
3934 if crate::db::community::community_protocol(community.id())?.is_none() {
3936 return Err("community removed during channel create".to_string());
3937 }
3938 let mut updated = community.clone();
3939 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() });
3940 crate::db::community::save_community_v2(&updated)?;
3941 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3944 crate::db::community::store_epoch_key(&cid_hex, &crate::simd::hex::bytes_to_hex_32(&channel_id.0), epoch.0, &channel_key)?;
3945 let _ = refresh_public_links(transport, &updated).await;
3948 Ok(())
3949}
3950
3951#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3956pub enum VendVerdict {
3957 Accept,
3959 Park(&'static str),
3963 Refuse(&'static str),
3965}
3966
3967pub fn judge_channel_key_vend(
3979 community: &CommunityV2,
3980 roster: &crate::community::roles::CommunityRoles,
3981 channel_id: &ChannelId,
3982 epoch: Epoch,
3983 sender_hex: &str,
3984) -> VendVerdict {
3985 let me = match me_pk() {
3986 Ok(pk) => pk.to_hex(),
3987 Err(_) => return VendVerdict::Park("no active identity"),
3988 };
3989 let owner_hex = match community.owner() {
3990 Ok(o) => o.to_hex(),
3991 Err(_) => return VendVerdict::Refuse("community has no resolvable owner"),
3992 };
3993 let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
3994
3995 let Some(ch) = community.channel(channel_id) else {
3998 return VendVerdict::Park("channel not in our fold yet");
3999 };
4000 if !ch.private {
4001 return VendVerdict::Refuse("vend names a channel our fold says is public");
4004 }
4005
4006 if ch.key.is_some() && epoch.0 <= ch.epoch.0 {
4014 return VendVerdict::Refuse("superseded: we already hold this epoch or newer");
4015 }
4016 if epoch.0 > ch.epoch.0.saturating_add(MAX_VEND_EPOCH_LEAD) {
4017 return VendVerdict::Refuse("vend epoch is implausibly far ahead of the channel head");
4018 }
4019
4020 if !roster.is_entitled(Some(&owner_hex), &me, &chan_hex, &[], &[]) {
4024 return VendVerdict::Park("our grant for this channel has not folded yet");
4025 }
4026
4027 if sender_hex != owner_hex && !roster.is_entitled(Some(&owner_hex), sender_hex, &chan_hex, &[], &[]) {
4030 return VendVerdict::Park("vendor's entitlement has not folded yet");
4031 }
4032
4033 VendVerdict::Accept
4034}
4035
4036const PARKED_VEND_TTL_SECS: u64 = 30 * 24 * 3600;
4039
4040const MAX_VEND_EPOCH_LEAD: u64 = 1024;
4044
4045pub fn absorb_parked_channel_keys(community: &CommunityV2, session: &SessionGuard) -> Vec<ChannelId> {
4051 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4052 let parked = match crate::db::community::get_pending_channel_keys(&cid_hex) {
4053 Ok(p) if !p.is_empty() => p,
4054 _ => return Vec::new(),
4055 };
4056 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
4057 let mut adopted = Vec::new();
4058 let now = now_ms() / 1000;
4059 for p in parked {
4060 if adopted.iter().any(|c: &ChannelId| crate::simd::hex::bytes_to_hex_32(&c.0) == p.channel_id) {
4064 let _ = crate::db::community::drop_pending_channel_key(p.id);
4065 continue;
4066 }
4067 if now.saturating_sub(p.received_at.max(0) as u64) > PARKED_VEND_TTL_SECS {
4073 let _ = crate::db::community::drop_pending_channel_key(p.id);
4074 continue;
4075 }
4076 let Some(id_bytes) = crate::simd::hex::hex_to_bytes_32_checked(&p.channel_id) else {
4077 let _ = crate::db::community::drop_pending_channel_key(p.id);
4078 continue;
4079 };
4080 let channel_id = ChannelId(id_bytes);
4081 match judge_channel_key_vend(community, &roster, &channel_id, Epoch(p.epoch), &p.sender) {
4082 VendVerdict::Accept => {
4083 if !session.is_valid() {
4084 return adopted;
4085 }
4086 let keyless = community.channel(&channel_id).is_some_and(|c| c.key.is_none());
4091 let seated = if keyless {
4092 crate::db::community::seat_channel_key(&cid_hex, &p.channel_id, p.epoch, &p.key)
4093 } else {
4094 crate::db::community::advance_channel_epoch(&cid_hex, &p.channel_id, p.epoch, &p.key).map(|_| ())
4095 };
4096 if let Err(e) = seated {
4097 crate::log_warn!("v2: adopting a vended channel key failed: {e}");
4098 continue;
4099 }
4100 let _ = crate::db::community::drop_pending_channel_keys_for(&cid_hex, &p.channel_id);
4102 adopted.push(channel_id);
4103 }
4104 VendVerdict::Refuse(why) => {
4105 crate::log_warn!("v2: refused a vended channel key for {}: {why}", p.channel_id);
4106 let _ = crate::db::community::drop_pending_channel_key(p.id);
4108 }
4109 VendVerdict::Park(_) => {}
4111 }
4112 }
4113 adopted
4114}
4115
4116pub async fn grant_channel_access<T: Transport + ?Sized>(
4126 transport: &T,
4127 community: &CommunityV2,
4128 channel_id: &ChannelId,
4129 member: &PublicKey,
4130) -> Result<(), String> {
4131 let session = SessionGuard::capture();
4132 let my_pk = me_pk()?;
4133 let ch = community.channel(channel_id).ok_or("unknown channel")?;
4134 if !ch.private {
4135 return Err("channel is public — every member already reads it".to_string());
4136 }
4137 if ch.key.is_none() {
4138 return Err("we hold no key for this channel, so we cannot vend it".to_string());
4139 }
4140 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4141 let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
4142 let owner_hex = community.owner()?.to_hex();
4143 let mut roster = fetch_authority(transport, community).await.roles;
4149 let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
4150 for r in cached.roles {
4151 if !roster.roles.iter().any(|x| x.role_id == r.role_id) {
4152 roster.roles.push(r);
4153 }
4154 }
4155 for g in cached.grants {
4156 if !roster.grants.iter().any(|x| x.member == g.member) {
4157 roster.grants.push(g);
4158 }
4159 }
4160 if !session.is_valid() {
4161 return Err("account changed during grant".to_string());
4162 }
4163 if !roster.is_authorized_in(&my_pk.to_hex(), Some(&owner_hex), &chan_hex, crate::community::roles::Permissions::MANAGE_ROLES) {
4166 return Err("not authorized to manage this channel's access".to_string());
4167 }
4168 let role_id = roster
4173 .channel_roles(&chan_hex)
4174 .into_iter()
4175 .find(|r| r.permissions == crate::community::roles::Permissions::empty())
4176 .map(|r| r.role_id.clone())
4177 .ok_or("channel has no permission-less access role to grant")?;
4178
4179 let mut role_ids: Vec<String> = roster.roles_of(&member.to_hex()).map(|r| r.role_id.clone()).collect();
4180 if !role_ids.contains(&role_id) {
4181 role_ids.push(role_id.clone());
4182 }
4183 grant_roles(transport, community, member, role_ids.clone()).await?;
4184 if !session.is_valid() {
4185 return Err("account changed during grant".to_string());
4186 }
4187 merge_local_roster(&cid_hex, None, Some(&crate::community::roles::MemberGrant { member: member.to_hex(), role_ids }));
4188 let bundle = bundle_of_with_overlay(
4190 community,
4191 BundleAudience::Member(*member),
4192 Some(my_pk),
4193 None,
4194 None,
4195 std::slice::from_ref(&role_id),
4196 &[],
4197 );
4198 let signer = crate::signer::active_signer()?;
4199 let wrap = invite::build_direct_invite_signed(&signer, my_pk, member, &bundle).await.map_err(|e| e.to_string())?;
4200 if !session.is_valid() {
4201 return Err("account changed before vending the key".to_string());
4202 }
4203 transport.publish(&wrap, &community.relays).await?;
4204 Ok(())
4205}
4206
4207pub async fn revoke_channel_access<T: Transport + ?Sized>(
4213 transport: &T,
4214 community: &CommunityV2,
4215 channel_id: &ChannelId,
4216 member: &PublicKey,
4217) -> Result<(), String> {
4218 let session = SessionGuard::capture();
4219 let my_pk = me_pk()?;
4220 let ch = community.channel(channel_id).ok_or("unknown channel")?;
4221 if !ch.private {
4222 return Err("channel is public — there is no access to revoke".to_string());
4223 }
4224 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4225 let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
4226 let owner_hex = community.owner()?.to_hex();
4227 let mut roster = fetch_authority(transport, community).await.roles;
4230 let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
4231 for r in cached.roles {
4232 if !roster.roles.iter().any(|x| x.role_id == r.role_id) {
4233 roster.roles.push(r);
4234 }
4235 }
4236 for g in cached.grants {
4237 if !roster.grants.iter().any(|x| x.member == g.member) {
4238 roster.grants.push(g);
4239 }
4240 }
4241 if !session.is_valid() {
4242 return Err("account changed during revoke".to_string());
4243 }
4244 if !roster.is_authorized_in(&my_pk.to_hex(), Some(&owner_hex), &chan_hex, crate::community::roles::Permissions::MANAGE_ROLES) {
4245 return Err("not authorized to manage this channel's access".to_string());
4246 }
4247 if *member == community.owner()? {
4248 return Err("the owner is supreme and cannot be removed".to_string());
4249 }
4250 let access_ids = roster.channel_role_ids(&chan_hex);
4251 if access_ids.is_empty() {
4255 return Err("this channel's access role has not folded yet — retry once the control plane serves it".to_string());
4256 }
4257 let remaining: Vec<String> = roster
4258 .roles_of(&member.to_hex())
4259 .map(|r| r.role_id.clone())
4260 .filter(|id| !access_ids.contains(id))
4261 .collect();
4262 grant_roles(transport, community, member, remaining.clone()).await?;
4263 if !session.is_valid() {
4264 return Err("account changed during revoke".to_string());
4265 }
4266 merge_local_roster(&cid_hex, None, Some(&crate::community::roles::MemberGrant { member: member.to_hex(), role_ids: remaining }));
4267 rekey_channel_excluding(transport, community, channel_id, &roster, &access_ids, member).await
4271}
4272
4273async fn rekey_channel_excluding<T: Transport + ?Sized>(
4283 transport: &T,
4284 community: &CommunityV2,
4285 channel_id: &ChannelId,
4286 roster: &crate::community::roles::CommunityRoles,
4287 access_ids: &[String],
4288 removed: &PublicKey,
4289) -> Result<(), String> {
4290 let session = SessionGuard::capture();
4291 let lock = super::realtime::follow_lock(community.id());
4293 let _guard = lock.lock().await;
4294 let signer = crate::signer::active_signer()?;
4295 let my_pk = me_pk()?;
4296 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4297 let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
4298 let ch = community.channel(channel_id).ok_or("unknown channel")?.clone();
4299 let old_key = ch.key.ok_or("we hold no key for this channel, so we cannot rotate it")?;
4300 let new_epoch = Epoch(ch.epoch.0.checked_add(1).ok_or("channel epoch overflow")?);
4301 let owner = community.owner()?;
4302 let owner_hex = owner.to_hex();
4303
4304 let removed_hex = removed.to_hex();
4308 let mut recipients: Vec<PublicKey> = vec![my_pk];
4309 if owner != my_pk {
4310 recipients.push(owner);
4311 }
4312 for g in &roster.grants {
4313 if g.member == removed_hex || g.member == owner_hex {
4314 continue;
4315 }
4316 if !g.role_ids.iter().any(|id| access_ids.contains(id)) {
4317 continue;
4318 }
4319 if let Ok(pk) = PublicKey::parse(&g.member) {
4320 if !recipients.contains(&pk) {
4321 recipients.push(pk);
4322 }
4323 }
4324 }
4325 let new_key = mint_or_reuse_rotation_key(&cid_hex, &chan_hex, new_epoch.0)?;
4328 let prev_commit = super::derive::epoch_key_commitment(ch.epoch, &old_key);
4329 let mut blobs = Vec::with_capacity(recipients.len());
4330 for r in &recipients {
4331 blobs.push(
4332 rekey::build_blob(&signer, &my_pk.to_bytes(), r, RekeyScope::Channel(*channel_id), new_epoch, &new_key)
4333 .await
4334 .map_err(|e| e.to_string())?,
4335 );
4336 }
4337 let group = channel_rekey_group_key(&community.community_root, channel_id, new_epoch);
4338 let at_secs = now_ms() / 1000;
4339 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())
4340 .await
4341 .map_err(|e| e.to_string())?;
4342 if !session.is_valid() {
4343 return Err("account changed during channel rekey".to_string());
4344 }
4345 for c in &chunks {
4346 transport.publish_durable(c, &community.relays).await?;
4347 }
4348 if !session.is_valid() {
4349 return Err("account changed during channel rekey".to_string());
4350 }
4351 if crate::db::community::community_protocol(community.id())?.is_none() {
4352 return Err("community removed during channel rekey".to_string());
4353 }
4354 crate::db::community::advance_channel_epoch(&cid_hex, &chan_hex, new_epoch.0, &new_key)?;
4356 crate::db::community::store_epoch_key(&cid_hex, &chan_hex, new_epoch.0, &new_key)?;
4357
4358 {
4365 let mut rotated = community.clone();
4366 if let Some(c) = rotated.channels.iter_mut().find(|c| c.id == *channel_id) {
4367 c.key = Some(new_key);
4368 c.epoch = new_epoch;
4369 }
4370 match read_channel_pins(&rotated, channel_id) {
4371 Ok(read) if !read.sealed && !read.pins.is_empty() => {
4372 let entries: Vec<super::pins::PinEntry> =
4373 read.pins.iter().map(|p| p.entry.clone()).collect();
4374 if let Some(ch2) = rotated.channel(channel_id).cloned() {
4375 if let Err(e) = publish_pin_list(transport, &rotated, &session, &ch2, &entries).await {
4376 crate::log_warn!("[pins] rotation reseal failed (a curator's next edit heals): {e}");
4377 } else {
4378 crate::log_info!("[pins] resealed {} pin(s) under epoch {}", entries.len(), new_epoch.0);
4379 }
4380 }
4381 }
4382 Ok(read) if read.sealed => {
4383 crate::log_warn!("[pins] rotating a channel whose pin list we cannot read; reseal skipped");
4384 }
4385 _ => {}
4386 }
4387 }
4388 Ok(())
4389}
4390
4391pub async fn sever_banned_private_reads<T: Transport + ?Sized>(
4407 transport: &T,
4408 community: &CommunityV2,
4409 member: &PublicKey,
4410 stripped_roles: &[String],
4411) -> Result<usize, String> {
4412 let session = SessionGuard::capture();
4413 let my_pk = me_pk()?;
4414 ensure_folded_permission(community, &my_pk, crate::community::roles::Permissions::MANAGE_CHANNELS, "severing a banned member's channel reads")?;
4415 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4416 let mut roster = fetch_authority(transport, community).await.roles;
4420 let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
4421 for r in cached.roles {
4422 if !roster.roles.iter().any(|x| x.role_id == r.role_id) {
4423 roster.roles.push(r);
4424 }
4425 }
4426 for g in cached.grants {
4427 if !roster.grants.iter().any(|x| x.member == g.member) {
4428 roster.grants.push(g);
4429 }
4430 }
4431 if !session.is_valid() {
4432 return Err("account changed during ban severance".to_string());
4433 }
4434 let owner_hex = community.owner().ok().map(|o| o.to_hex());
4435 let member_hex = member.to_hex();
4436 let mut rotated = 0usize;
4437 let mut failures: Vec<String> = Vec::new();
4438 for ch in &community.channels {
4439 if !ch.private || ch.key.is_none() {
4440 continue;
4441 }
4442 let chan_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
4443 if !roster.is_entitled(owner_hex.as_deref(), &member_hex, &chan_hex, stripped_roles, &[]) {
4444 continue;
4445 }
4446 let access_ids = roster.channel_role_ids(&chan_hex);
4447 if access_ids.is_empty() {
4450 continue;
4451 }
4452 let held = match crate::db::community::load_community_v2(community.id()) {
4455 Ok(Some(c)) => c,
4456 _ => return Err("community gone during ban severance".to_string()),
4457 };
4458 match rekey_channel_excluding(transport, &held, &ch.id, &roster, &access_ids, member).await {
4459 Ok(()) => rotated += 1,
4460 Err(e) => failures.push(format!("{}: {e}", &chan_hex[..12])),
4461 }
4462 if !session.is_valid() {
4463 return Err("account changed during ban severance".to_string());
4464 }
4465 }
4466 if failures.is_empty() {
4467 Ok(rotated)
4468 } else {
4469 Err(format!("{rotated} rotated; failed: {}", failures.join("; ")))
4470 }
4471}
4472
4473pub async fn delete_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, channel_id: &ChannelId, name: &str) -> Result<(), String> {
4476 let session = SessionGuard::capture();
4477 let lock = super::realtime::follow_lock(community.id());
4479 let _guard = lock.lock().await;
4480 let my_pk = me_pk()?;
4481 ensure_channel_manager(community, &my_pk)?;
4482 let mut meta = community.channel(channel_id).map(|c| c.metadata()).unwrap_or_else(|| control::ChannelMetadata {
4486 name: name.to_string(), private: false, voice: None, deleted: None, custom: None, extra: Default::default(),
4487 });
4488 meta.deleted = Some(true);
4489 let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
4490 publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
4491 if !session.is_valid() {
4492 return Err("account changed during channel delete".to_string());
4493 }
4494 let mut updated = community.clone();
4495 updated.channels.retain(|c| c.id.0 != channel_id.0);
4496 crate::db::community::save_community_v2(&updated)?;
4497 Ok(())
4498}
4499
4500pub async fn follow_control<T: Transport + ?Sized>(
4524 transport: &T,
4525 community: &CommunityV2,
4526 session: &SessionGuard,
4527) -> Result<Option<CommunityV2>, String> {
4528 community.owner()?; let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
4530 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4531
4532 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
4538 .into_iter()
4539 .filter(|(_, f)| f.0 == community.root_epoch.0)
4540 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
4541 .collect();
4542
4543 let mut editions: Vec<ParsedEdition> = Vec::new();
4547 let mut seen: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
4548 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
4549 let mut oldest: Option<u64> = None;
4550 let mut until: Option<u64> = None;
4551 let mut fold = ControlFold { updated: None, heads: Vec::new(), gapped: false, pins_persist: Vec::new() };
4552 let mut authority = AuthoritySet::owner_only();
4553 let mut truncated = true;
4559 for _ in 0..FOLLOW_MAX_PAGES {
4560 let query = Query {
4564 kinds: vec![stream::KIND_WRAP],
4565 authors: vec![control.pk_hex()],
4566 until,
4567 limit: Some(FOLLOW_PAGE),
4568 evidence: crate::community::transport::Evidence::Quorum,
4569 ..Default::default()
4570 };
4571 let wraps = transport.fetch(&query, &community.relays).await?;
4572 let mut fresh = 0usize;
4576 for w in &wraps {
4577 if !seen_wraps.insert(w.id) {
4578 continue;
4579 }
4580 fresh += 1;
4581 let at = w.created_at.as_secs();
4582 if oldest.is_none_or(|o| at < o) {
4583 oldest = Some(at);
4584 }
4585 if let Ok((ed, _)) = control::open_control_edition(w, &control) {
4588 if seen.insert(ed.inner_id) {
4589 editions.push(ed);
4590 }
4591 }
4592 }
4593 authority = fold_authority(community, &editions, &floors);
4596 fold = apply_control_fold(community, &editions, &floors, &authority);
4597 if !(fold.gapped || authority.gapped) {
4598 truncated = false; break;
4600 }
4601 if fresh == 0 {
4602 truncated = wraps.len() >= FOLLOW_PAGE;
4607 break;
4608 }
4609 until = oldest;
4610 }
4611
4612 if !session.is_valid() {
4615 return Err("account changed during control follow".to_string());
4616 }
4617 if crate::db::community::community_protocol(community.id())?.is_none() {
4620 return Ok(None);
4621 }
4622 for h in fold.heads.iter().chain(authority.heads.iter()) {
4628 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)?;
4629 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)?;
4630 }
4631 let mut authority_changed = false;
4634 let _ = crate::db::community::merge_community_ban_marks(&cid_hex, &authority.banned_at);
4638 if let Some((banned, version)) = &authority.banlist_persist {
4639 let mut before = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
4640 crate::db::community::set_community_banlist(&cid_hex, banned, *version as i64)?;
4641 let mut after = banned.clone();
4642 before.sort();
4643 after.sort();
4644 authority_changed |= before != after;
4645 }
4646 let newest_roster_at: i64 = editions
4653 .iter()
4654 .filter(|e| e.vsk == vsk::ROLE || e.vsk == vsk::GRANT || e.vsk == vsk::BANLIST)
4655 .map(|e| e.created_at as i64)
4656 .max()
4657 .unwrap_or(0);
4658 let stored = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
4664 let head_ents: std::collections::HashSet<&str> = authority.heads.iter().map(|h| h.entity_hex.as_str()).collect();
4665 let stored_complete = stored.roles.iter().all(|r| !floors.contains_key(&r.role_id) || head_ents.contains(r.role_id.as_str()))
4666 && stored.grants.iter().all(|g| {
4667 crate::simd::hex::hex_to_bytes_32_checked(&g.member).is_none_or(|m| {
4668 let eid = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &m));
4669 !floors.contains_key(&eid) || head_ents.contains(eid.as_str())
4670 })
4671 });
4672 if !truncated && !authority.gapped && stored_complete && newest_roster_at >= crate::db::community::get_community_roles_at(&cid_hex)? {
4677 authority_changed |= stored != authority.roles;
4678 crate::db::community::set_community_roles(&cid_hex, &authority.roles, newest_roster_at)?;
4679 }
4680 if !truncated && !authority.gapped && !fold.gapped {
4686 if let Ok(owner) = community.owner() {
4687 let sets = live_invite_link_sets(community.id(), &owner.to_hex(), &editions, &authority, &floors);
4688 let live = flatten_link_sets(&sets);
4689 let mut before = crate::db::community::get_community_invite_registry(&cid_hex).unwrap_or_default();
4690 before.sort();
4691 if before != live {
4692 crate::db::community::set_community_invite_registry(&cid_hex, &live)?;
4693 authority_changed = true;
4694 }
4695 crate::db::community::replace_invite_link_sets(&cid_hex, &sets)?;
4698 }
4699 }
4700 for (channel_hex, content, version, author_npub, created_at) in &fold.pins_persist {
4704 match crate::db::community::set_community_pins(&cid_hex, channel_hex, content, *version as i64) {
4705 Ok(true) => {
4706 crate::log_info!("[pins] fold adopted v{} for channel {}", version, &channel_hex[..12]);
4707 crate::emit_event(
4708 "community_pins_updated",
4709 &serde_json::json!({ "community_id": cid_hex, "channel_id": channel_hex }),
4710 );
4711 note_pins_modified(channel_hex, *version, author_npub, *created_at).await;
4712 }
4713 Ok(false) => {}
4714 Err(e) => crate::log_warn!("[pins] fold persist failed: {e}"),
4715 }
4716 }
4717 if authority_changed {
4722 crate::emit_event("community_refreshed", &serde_json::json!({ "community_id": cid_hex }));
4723 }
4724 {
4728 static UPGRADED: std::sync::Mutex<Option<std::collections::HashSet<String>>> = std::sync::Mutex::new(None);
4729 let first = UPGRADED
4730 .lock()
4731 .map(|mut set| set.get_or_insert_with(Default::default).insert(cid_hex.clone()))
4732 .unwrap_or(false);
4733 if first {
4734 let _ = upgrade_admin_role_pin_bit(transport, community).await;
4735 }
4736 }
4737 match fold.updated {
4738 Some(u) => {
4739 crate::db::community::save_community_v2(&u)?;
4740 Ok(Some(u))
4741 }
4742 None => Ok(None),
4743 }
4744}
4745
4746const FOLLOW_MAX_PAGES: usize = 32;
4755const FOLLOW_PAGE: usize = 500;
4756const COMPACT_MAX_PAGES: usize = 512;
4761
4762#[derive(Clone)]
4764struct FoldedHead {
4765 entity_hex: String,
4766 version: u64,
4767 self_hash: [u8; 32],
4768 inner_id: [u8; 32],
4769}
4770
4771struct ControlFold {
4777 updated: Option<CommunityV2>,
4778 heads: Vec<FoldedHead>,
4779 gapped: bool,
4780 pins_persist: Vec<(String, String, u64, String, u64)>,
4784}
4785
4786type Floors = std::collections::HashMap<String, (u64, [u8; 32], Option<[u8; 32]>)>;
4788
4789fn apply_control_fold(community: &CommunityV2, editions: &[ParsedEdition], floors: &Floors, authority: &AuthoritySet) -> ControlFold {
4810 use crate::community::roles::Permissions;
4811 use std::collections::BTreeMap;
4812
4813 let owner_hex = community.owner().ok().map(|o| o.to_hex());
4814
4815 let mut groups: BTreeMap<(String, [u8; 32]), Vec<&ParsedEdition>> = BTreeMap::new();
4816 for e in editions {
4817 groups.entry((e.vsk.clone(), e.entity_id)).or_default().push(e);
4818 }
4819
4820 let mut out = community.clone();
4821 let mut changed = false;
4822 let mut heads = Vec::new();
4823 let mut gapped = false;
4824 let mut pins_persist = Vec::new();
4825 let pins_by_eid: std::collections::HashMap<[u8; 32], String> = community
4830 .channels
4831 .iter()
4832 .map(|ch| (super::derive::pins_locator(community.id(), &ch.id), crate::simd::hex::bytes_to_hex_32(&ch.id.0)))
4833 .collect();
4834 for ((vsk_code, eid), group) in &groups {
4835 let is_meta = vsk_code == vsk::COMMUNITY_METADATA && *eid == community.id().0;
4841 let is_channel = vsk_code == vsk::CHANNEL_METADATA && *eid != community.id().0;
4842 let pins_channel = (vsk_code == vsk::PINS).then(|| pins_by_eid.get(eid)).flatten();
4843 if !is_meta && !is_channel && pins_channel.is_none() {
4844 continue;
4845 }
4846 let required = if is_meta {
4851 Permissions::MANAGE_METADATA
4852 } else if is_channel {
4853 Permissions::MANAGE_CHANNELS
4854 } else {
4855 Permissions::PIN_MESSAGES
4856 };
4857 let authed: Vec<&ParsedEdition> = group
4858 .iter()
4859 .copied()
4860 .filter(|e| {
4861 let author = e.author.to_hex();
4862 !authority.banned.contains(&author)
4865 && authority.roles.is_authorized(&author, owner_hex.as_deref(), required)
4866 && citation_ok_in_fold(community.id(), &authority.heads, owner_hex.as_deref(), &e.author, e.authority.as_ref())
4870 })
4871 .collect();
4872 if authed.is_empty() {
4873 continue;
4874 }
4875 let entity_hex = crate::simd::hex::bytes_to_hex_32(eid);
4876 let fold_eds: Vec<version::Edition> = authed.iter().map(|p| p.to_fold_edition()).collect();
4877 let (hi, entity_gapped) = fold_head(&fold_eds, floors.get(&entity_hex));
4878 gapped |= entity_gapped;
4879 let Some(hi) = hi else { continue };
4880
4881 let head = authed[hi];
4882 heads.push(FoldedHead { entity_hex, version: head.version, self_hash: head.self_hash, inner_id: head.inner_id });
4883 if is_meta {
4884 if let Ok(meta) = serde_json::from_str::<control::CommunityMetadata>(&head.content) {
4885 changed |= apply_community_metadata(&mut out, meta);
4886 }
4887 } else if is_channel {
4888 if let Ok(meta) = serde_json::from_str::<control::ChannelMetadata>(&head.content) {
4889 changed |= apply_channel_metadata(&mut out, ChannelId(*eid), meta);
4893 }
4894 } else if let Some(channel_hex) = pins_channel {
4895 use nostr_sdk::prelude::ToBech32;
4899 let author_npub = head
4900 .author
4901 .to_bech32()
4902 .unwrap_or_else(|_| head.author.to_hex());
4903 pins_persist.push((channel_hex.clone(), head.content.clone(), head.version, author_npub, head.created_at));
4904 }
4905 }
4906 ControlFold { updated: changed.then_some(out), heads, gapped, pins_persist }
4907}
4908
4909fn fold_head(fold_eds: &[version::Edition], floor: Option<&(u64, [u8; 32], Option<[u8; 32]>)>) -> (Option<usize>, bool) {
4916 let floor_v = floor.map(|f| f.0).unwrap_or(0);
4917 if floor_v == 0 {
4918 return (version::bootstrap_head(fold_eds, 0), false);
4919 }
4920 let floor_hash = floor.map(|f| &f.1);
4921 let held_inner = floor.and_then(|f| f.2);
4922 let result = version::fold(fold_eds, floor_v, floor_hash);
4923 if result.anchored {
4924 return (result.head, result.gap); }
4926 if result.head.is_none() && !result.gap {
4927 return (None, false); }
4929 let fork = fold_eds.iter().enumerate().filter(|(_, e)| e.version == floor_v).min_by_key(|(_, e)| e.tiebreak_id);
4933 let win_hash = match fork {
4934 Some((_, w)) if floor_hash != Some(&w.self_hash) && held_inner.is_none_or(|h| w.tiebreak_id < h) => w.self_hash,
4935 _ => return (None, true), };
4937 let re = version::fold(fold_eds, floor_v, Some(&win_hash));
4938 if !re.anchored {
4939 return (None, true);
4940 }
4941 (re.head, re.gap)
4942}
4943
4944struct AuthoritySet {
4949 roles: crate::community::roles::CommunityRoles,
4950 banned: std::collections::BTreeSet<String>,
4951 heads: Vec<FoldedHead>,
4952 gapped: bool,
4953 banlist_persist: Option<(Vec<String>, u64)>,
4957 banned_at: std::collections::BTreeMap<String, u64>,
4961}
4962
4963impl AuthoritySet {
4964 fn owner_only() -> Self {
4967 AuthoritySet {
4968 roles: Default::default(),
4969 banned: Default::default(),
4970 heads: vec![],
4971 gapped: false,
4972 banlist_persist: None,
4973 banned_at: Default::default(),
4974 }
4975 }
4976}
4977
4978fn fold_authority(community: &CommunityV2, editions: &[ParsedEdition], floors: &Floors) -> AuthoritySet {
4985 use crate::community::roles::Permissions;
4986 use std::collections::BTreeMap;
4987
4988 let cid = community.id();
4989 let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
4990 let owner = community.owner().ok();
4991 let owner_hex = owner.map(|o| o.to_hex());
4992 let banlist_eid = super::derive::banlist_locator(cid);
4993 let banlist_hex = crate::simd::hex::bytes_to_hex_32(&banlist_eid);
4994
4995 let mut groups: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
4996 for e in editions {
4997 if e.vsk == vsk::ROLE || e.vsk == vsk::GRANT || e.vsk == vsk::BANLIST {
4998 groups.entry(e.entity_id).or_default().push(e);
4999 }
5000 }
5001
5002 let mut role_cands: BTreeMap<String, Vec<AuthorityCand>> = BTreeMap::new();
5010 let mut grant_cands: BTreeMap<String, Vec<AuthorityCand>> = BTreeMap::new();
5011 let mut gapped = false;
5012
5013 for (eid, group) in &groups {
5014 if *eid == banlist_eid {
5016 continue;
5017 }
5018 let entity_hex = crate::simd::hex::bytes_to_hex_32(eid);
5019 let fold_eds: Vec<version::Edition> = group.iter().map(|p| p.to_fold_edition()).collect();
5020 let (_hi, entity_gapped) = fold_head(&fold_eds, floors.get(&entity_hex));
5021 gapped |= entity_gapped;
5022 let floor_v = floors.get(&entity_hex).map(|f| f.0).unwrap_or(0);
5023
5024 for p in group {
5025 if p.version < floor_v {
5027 continue;
5028 }
5029 let head = FoldedHead { entity_hex: entity_hex.clone(), version: p.version, self_hash: p.self_hash, inner_id: p.inner_id };
5030 match p.vsk.as_str() {
5031 vsk::ROLE => {
5032 if let Some(role) = super::roles::parse_role_content(&p.content) {
5034 if role.role_id == entity_hex && role.position != 0 {
5035 role_cands.entry(entity_hex.clone()).or_default().push(AuthorityCand { role: Some(role), grant: None, author: p.author, head, citation: p.authority.clone() });
5036 }
5037 }
5038 }
5039 vsk::GRANT => {
5040 if let Some(mut grant) = super::roles::parse_grant_content(&p.content) {
5041 if let Some(member) = crate::simd::hex::hex_to_bytes_32_checked(&grant.member) {
5042 if super::derive::grant_locator(cid, &member) == *eid {
5043 grant.role_ids.truncate(super::roles::MAX_ROLES_PER_MEMBER);
5044 grant_cands.entry(entity_hex.clone()).or_default().push(AuthorityCand { role: None, grant: Some(grant), author: p.author, head, citation: p.authority.clone() });
5045 }
5046 }
5047 }
5048 }
5049 _ => {}
5050 }
5051 }
5052 }
5053 for cands in role_cands.values_mut().chain(grant_cands.values_mut()) {
5054 cands.sort_by(|a, b| b.head.version.cmp(&a.head.version).then(a.head.inner_id.cmp(&b.head.inner_id)));
5055 }
5056
5057 let empty: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
5058 let (prelim, prelim_heads) = select_authorized(cid, &role_cands, &grant_cands, owner_hex.as_deref(), &empty);
5061
5062 let persisted_banned: Vec<String> = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
5073 let banned_authors: std::collections::HashSet<&str> = persisted_banned.iter().map(String::as_str).collect();
5078 let banlist_authored: Vec<&ParsedEdition> = groups
5079 .get(&banlist_eid)
5080 .map(|g| {
5081 g.iter()
5082 .copied()
5083 .filter(|e| {
5084 let ah = e.author.to_hex();
5085 !banned_authors.contains(ah.as_str())
5086 && prelim.is_authorized(&ah, owner_hex.as_deref(), Permissions::BAN)
5087 && citation_ok_in_fold(cid, &prelim_heads, owner_hex.as_deref(), &e.author, e.authority.as_ref())
5088 })
5089 .collect()
5090 })
5091 .unwrap_or_default();
5092 let mut banned_at: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();
5098 for p in &banlist_authored {
5099 for t in super::roles::parse_banlist_content(&p.content).unwrap_or_default() {
5100 if owner_hex.as_deref() == Some(t.as_str()) {
5101 continue;
5102 }
5103 let slot = banned_at.entry(t).or_insert(0);
5104 *slot = (*slot).max(p.created_at);
5105 }
5106 }
5107 let mut banlist_persist: Option<(Vec<String>, u64)> = None;
5108 let mut banlist_head: Option<FoldedHead> = None;
5109 let banned: std::collections::BTreeSet<String> = if banlist_authored.is_empty() {
5110 persisted_banned.into_iter().collect()
5111 } else {
5112 let fold_eds: Vec<version::Edition> = banlist_authored.iter().map(|p| p.to_fold_edition()).collect();
5113 let (hi, g) = fold_head(&fold_eds, floors.get(&banlist_hex));
5114 gapped |= g;
5115 match hi {
5116 Some(hi) => {
5117 let head = banlist_authored[hi];
5118 let ah = head.author.to_hex();
5119 let list: Vec<String> = super::roles::parse_banlist_content(&head.content)
5120 .unwrap_or_default()
5121 .into_iter()
5122 .filter(|t| prelim.can_act_on_member(&ah, owner_hex.as_deref(), t, Permissions::BAN))
5123 .take(super::roles::MAX_BANLIST)
5124 .collect();
5125 banlist_head = Some(FoldedHead { entity_hex: banlist_hex.clone(), version: head.version, self_hash: head.self_hash, inner_id: head.inner_id });
5126 banlist_persist = Some((list.clone(), head.version));
5127 list.into_iter().collect()
5128 }
5129 None => persisted_banned.into_iter().collect(),
5130 }
5131 };
5132
5133 let (mut authorized, mut heads) = select_authorized(cid, &role_cands, &grant_cands, owner_hex.as_deref(), &banned);
5137 if let Some(bh) = banlist_head {
5138 heads.push(bh);
5139 }
5140
5141 if authorized.roles.len() > super::roles::MAX_ROLES_PER_COMMUNITY {
5146 authorized.roles.sort_by(|a, b| a.role_id.cmp(&b.role_id));
5147 authorized.roles.truncate(super::roles::MAX_ROLES_PER_COMMUNITY);
5148 let kept: std::collections::HashSet<&str> = authorized.roles.iter().map(|r| r.role_id.as_str()).collect();
5149 authorized.grants.iter_mut().for_each(|g| g.role_ids.retain(|rid| kept.contains(rid.as_str())));
5150 authorized.grants.retain(|g| !g.role_ids.is_empty());
5151 }
5152
5153 AuthoritySet { roles: authorized, banned, heads, gapped, banlist_persist, banned_at }
5154}
5155
5156struct AuthorityCand {
5159 role: Option<crate::community::roles::Role>,
5160 grant: Option<crate::community::roles::MemberGrant>,
5161 author: PublicKey,
5162 head: FoldedHead,
5163 citation: Option<crate::community::edition::AuthorityCitation>,
5166}
5167
5168fn citation_ok_in_fold(
5177 cid: &crate::community::CommunityId,
5178 heads: &[FoldedHead],
5179 owner_hex: Option<&str>,
5180 author: &PublicKey,
5181 citation: Option<&crate::community::edition::AuthorityCitation>,
5182) -> bool {
5183 let actor_hex = author.to_hex();
5184 if owner_hex == Some(actor_hex.as_str()) {
5185 return true;
5186 }
5187 let grant_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(cid, &author.to_bytes()));
5188 let as_entity: Vec<crate::community::roster::EntityHead> = heads
5189 .iter()
5190 .map(|h| crate::community::roster::EntityHead {
5191 entity_hex: h.entity_hex.clone(),
5192 version: h.version,
5193 self_hash: h.self_hash,
5194 inner_id: h.inner_id,
5195 citation: None,
5196 })
5197 .collect();
5198 crate::community::roster::authority_citation_satisfied(&as_entity, owner_hex, &actor_hex, &grant_hex, citation)
5199}
5200
5201fn select_authorized(
5210 cid: &crate::community::CommunityId,
5211 role_cands: &std::collections::BTreeMap<String, Vec<AuthorityCand>>,
5212 grant_cands: &std::collections::BTreeMap<String, Vec<AuthorityCand>>,
5213 owner_hex: Option<&str>,
5214 excluded: &std::collections::BTreeSet<String>,
5215) -> (crate::community::roles::CommunityRoles, Vec<FoldedHead>) {
5216 use crate::community::roles::{CommunityRoles, Permissions};
5217 let mut accepted = CommunityRoles::default();
5218 let mut heads: Vec<FoldedHead> = Vec::new();
5219 let bound = 2 * (role_cands.len() + grant_cands.len()) + 8;
5224 for _ in 0..bound {
5225 let mut next = CommunityRoles::default();
5226 let mut next_heads: Vec<FoldedHead> = Vec::new();
5227
5228 for cands in role_cands.values() {
5229 let mut admissible: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
5242 let mut standing: Option<u32> = None;
5243 let mut i = cands.len();
5244 while i > 0 {
5245 let hi = i;
5246 let ver = cands[i - 1].head.version;
5247 while i > 0 && cands[i - 1].head.version == ver {
5248 i -= 1;
5249 }
5250 for c in cands[i..hi].iter().rev() {
5252 let Some(role) = &c.role else { continue };
5253 let ah = c.author.to_hex();
5254 if excluded.contains(&ah) || role.position == 0 {
5255 continue;
5256 }
5257 if !accepted.can_act_on_position(&ah, owner_hex, role.position, Permissions::MANAGE_ROLES) {
5258 continue;
5259 }
5260 if let Some(prev) = standing {
5261 if !accepted.can_act_on_position(&ah, owner_hex, prev, Permissions::MANAGE_ROLES) {
5262 continue;
5263 }
5264 }
5265 if !citation_ok_in_fold(cid, &heads, owner_hex, &c.author, c.citation.as_ref()) {
5266 continue;
5267 }
5268 admissible.insert(c.head.self_hash);
5269 standing = Some(role.position);
5270 break;
5271 }
5272 }
5273 for c in cands {
5274 let Some(role) = &c.role else { continue };
5275 if !admissible.contains(&c.head.self_hash) {
5276 continue;
5277 }
5278 next.roles.push(role.clone());
5279 next_heads.push(c.head.clone());
5280 break; }
5282 }
5283 for cands in grant_cands.values() {
5284 for c in cands {
5285 let Some(grant) = &c.grant else { continue };
5286 let ah = c.author.to_hex();
5287 if excluded.contains(&ah) || excluded.contains(&grant.member) {
5288 continue;
5289 }
5290 let positions: Option<Vec<u32>> = grant.role_ids.iter().map(|rid| accepted.role(rid).map(|r| r.position)).collect();
5293 let Some(positions) = positions else { continue };
5294 if !citation_ok_in_fold(cid, &heads, owner_hex, &c.author, c.citation.as_ref()) {
5295 continue;
5296 }
5297 if positions.iter().all(|p| accepted.can_act_on_position(&ah, owner_hex, *p, Permissions::MANAGE_ROLES))
5298 && accepted.can_act_on_member(&ah, owner_hex, &grant.member, Permissions::MANAGE_ROLES)
5299 {
5300 next_heads.push(c.head.clone());
5304 if !grant.role_ids.is_empty() {
5305 next.grants.push(grant.clone());
5306 }
5307 break;
5308 }
5309 }
5310 }
5311
5312 let converged = next.roles == accepted.roles && next.grants == accepted.grants;
5313 accepted = next;
5314 heads = next_heads;
5315 if converged {
5316 break;
5317 }
5318 }
5319 (accepted, heads)
5320}
5321
5322fn apply_community_metadata(out: &mut CommunityV2, meta: control::CommunityMetadata) -> bool {
5326 let mut changed = false;
5327 if out.name != meta.name {
5328 out.name = meta.name;
5329 changed = true;
5330 }
5331 if out.description != meta.description {
5332 out.description = meta.description;
5333 changed = true;
5334 }
5335 if out.icon != meta.icon {
5339 out.icon = meta.icon;
5340 changed = true;
5341 }
5342 if out.banner != meta.banner {
5343 out.banner = meta.banner;
5344 changed = true;
5345 }
5346 if out.meta_custom != meta.custom {
5349 out.meta_custom = meta.custom;
5350 changed = true;
5351 }
5352 if out.meta_extra != meta.extra {
5353 out.meta_extra = meta.extra;
5354 changed = true;
5355 }
5356 let relays = crate::community::cap_relays(meta.relays);
5364 if !relays.is_empty() && out.relays != relays {
5365 out.relays = relays;
5366 changed = true;
5367 }
5368 changed
5369}
5370
5371fn apply_channel_metadata(out: &mut CommunityV2, id: ChannelId, meta: control::ChannelMetadata) -> bool {
5376 let deleted = meta.deleted.unwrap_or(false);
5377 if deleted {
5378 let before = out.channels.len();
5379 out.channels.retain(|c| c.id.0 != id.0);
5380 return out.channels.len() != before;
5381 }
5382 match out.channels.iter_mut().find(|c| c.id.0 == id.0) {
5383 Some(existing) => {
5384 let mut changed = false;
5385 if existing.name != meta.name {
5386 existing.name = meta.name;
5387 changed = true;
5388 }
5389 if existing.voice != meta.voice {
5392 existing.voice = meta.voice;
5393 changed = true;
5394 }
5395 if existing.meta_custom != meta.custom {
5396 existing.meta_custom = meta.custom;
5397 changed = true;
5398 }
5399 if existing.meta_extra != meta.extra {
5400 existing.meta_extra = meta.extra;
5401 changed = true;
5402 }
5403 if !meta.private && (existing.private || existing.key.is_some()) {
5412 existing.private = false;
5413 existing.key = None;
5414 changed = true;
5415 }
5416 changed
5417 }
5418 None if !meta.private => {
5419 out.channels.push(ChannelV2 {
5422 id,
5423 name: meta.name,
5424 private: false,
5425 key: None,
5426 epoch: out.root_epoch,
5427 voice: meta.voice,
5428 meta_custom: meta.custom,
5429 meta_extra: meta.extra,
5430 });
5431 true
5432 }
5433 None => {
5434 out.channels.push(ChannelV2 {
5440 id,
5441 name: meta.name,
5442 private: true,
5443 key: None,
5444 epoch: Epoch(0),
5445 voice: meta.voice,
5446 meta_custom: meta.custom,
5447 meta_extra: meta.extra,
5448 });
5449 true
5450 }
5451 }
5452}
5453
5454pub struct RekeyFollow {
5458 pub updated: Option<CommunityV2>,
5461 pub self_removed: bool,
5464 pub dissolved: bool,
5467}
5468
5469const MAX_ADDRESSING_ROOTS: usize = 8;
5476
5477pub(crate) fn channel_rekey_addressing_roots(cur_root: [u8; 32], cid_hex: &str) -> Vec<[u8; 32]> {
5486 let mut roots: Vec<[u8; 32]> = vec![cur_root];
5487 let mut archived = crate::db::community::held_epoch_keys(cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX)
5488 .unwrap_or_default();
5489 archived.sort_by(|a, b| b.0 .0.cmp(&a.0 .0));
5490 for (_, r) in archived {
5491 if !roots.contains(&r) {
5492 roots.push(r);
5493 }
5494 }
5495 roots.truncate(MAX_ADDRESSING_ROOTS);
5496 roots
5497}
5498
5499#[cfg(debug_assertions)]
5534pub async fn debug_explain_base_rekey<T: Transport + ?Sized>(
5535 transport: &T,
5536 community: &CommunityV2,
5537) -> Result<serde_json::Value, String> {
5538 let my_xonly = me_pk()?.to_bytes();
5539 let owner = community.owner()?;
5540 let owner_hex = owner.to_hex();
5541 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5542 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
5543 let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
5544 let held_epoch = community.root_epoch;
5545 let held_key = community.community_root;
5546 let next = Epoch(held_epoch.0.saturating_add(1));
5547 let group = base_rekey_group_key(&held_key, community.id(), next);
5548 let chunks = fetch_rekey_chunks(transport, &community.relays, &group).await?;
5549 let rotations = rekey::collect_rotations(&chunks);
5550
5551 let reports: Vec<serde_json::Value> = rotations
5552 .iter()
5553 .map(|r| {
5554 let rotator_is_owner = r.rotator == owner;
5555 let rotator_authorized = rotator_is_owner
5558 || (!banned.contains(&r.rotator.to_hex())
5559 && roster.is_authorized(&r.rotator.to_hex(), Some(&owner_hex), crate::community::roles::Permissions::BAN));
5560 let scope_ok = r.scope.id32() == rekey::RekeyScope::Root.id32();
5561 let epoch_ok = r.new_epoch.0 == next.0;
5562 let complete = r.is_complete();
5563 let continuity = format!("{:?}", r.continuity(held_epoch, &held_key));
5564 let has_my_blob = rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &my_xonly, r.scope, r.new_epoch).is_some();
5565 let owner_kept = r.rotator == owner
5570 || rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &owner.to_bytes(), r.scope, r.new_epoch).is_some();
5571 let verdict = if !rotator_authorized {
5573 "REJECTED: rotator holds no BAN authority in the folded roster"
5574 } else if !scope_ok {
5575 "REJECTED: scope is not Root"
5576 } else if !epoch_ok {
5577 "REJECTED: new_epoch != held+1"
5578 } else if !complete {
5579 "WAIT: rotation incomplete (missing chunk) — never concludes removal"
5580 } else if continuity != "Extends" {
5581 "REJECTED: continuity does not extend my held root (FORK/GAP)"
5582 } else if has_my_blob {
5583 "ADOPT: authorized + complete + continuous + my blob present"
5584 } else {
5585 "REMOVED: complete authorized rotation with no blob for me"
5586 };
5587 serde_json::json!({
5588 "rotator": r.rotator.to_hex(),
5589 "rotator_is_recorded_owner": rotator_is_owner,
5590 "rotator_authorized_ban": rotator_authorized,
5591 "scope_is_root": scope_ok,
5592 "new_epoch": r.new_epoch.0,
5593 "prev_epoch": r.prev_epoch.0,
5594 "declared_chunks": r.declared_chunks,
5595 "held_chunks": r.held_chunks.iter().copied().collect::<Vec<_>>(),
5596 "is_complete": complete,
5597 "continuity_vs_held_root": continuity,
5598 "my_blob_present": has_my_blob,
5599 "owner_kept": owner_kept,
5600 "blob_count": r.blobs.len(),
5601 "verdict": verdict,
5602 })
5603 })
5604 .collect();
5605
5606 Ok(serde_json::json!({
5607 "recorded_owner": owner.to_hex(),
5608 "held_root_epoch": held_epoch.0,
5609 "probing_next_epoch": next.0,
5610 "base_plane_pk": group.pk_hex(),
5611 "raw_chunks_parsed": chunks.len(),
5612 "rotations_found": rotations.len(),
5613 "rotations": reports,
5614 }))
5615}
5616
5617pub async fn follow_rekeys<T: Transport + ?Sized>(
5618 transport: &T,
5619 community: &CommunityV2,
5620 session: &SessionGuard,
5621) -> Result<RekeyFollow, String> {
5622 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5625 if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
5626 return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: true });
5627 }
5628 if is_dissolved(transport, community).await {
5634 if session.is_valid() {
5635 let _ = crate::db::community::set_community_dissolved(&cid_hex);
5636 }
5637 return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: true });
5638 }
5639 let signer = crate::signer::active_signer()?;
5640 let my_pk = me_pk()?;
5641 let my_xonly = my_pk.to_bytes();
5642 let owner = community.owner()?;
5643 let owner_hex = owner.to_hex();
5644 let mut cur = community.clone();
5645 let mut changed = false;
5646
5647 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
5659 let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
5660 let me_hex = my_pk.to_hex();
5661 let cited_ok = |rot: &rekey::Rotation| -> bool {
5670 citation_is_synced(&cid_hex, &owner_hex, &rot.rotator.to_hex(), rot.citation.as_ref())
5671 };
5672 let channel_rotator_ok = |rotator: &PublicKey| -> bool {
5673 if *rotator == owner {
5674 return true;
5675 }
5676 let rh = rotator.to_hex();
5677 !banned.contains(&rh) && roster.is_authorized(&rh, Some(&owner_hex), crate::community::roles::Permissions::MANAGE_CHANNELS)
5678 };
5679 let channel_rotator_outranks_me = |rotator: &PublicKey| -> bool {
5684 if *rotator == owner {
5685 return true;
5686 }
5687 let rh = rotator.to_hex();
5688 !banned.contains(&rh) && roster.can_act_on_member(&rh, Some(&owner_hex), &me_hex, crate::community::roles::Permissions::MANAGE_CHANNELS)
5689 };
5690 let base_rotator_ok = |rotator: &PublicKey| -> bool {
5696 if *rotator == owner {
5697 return true;
5698 }
5699 let rh = rotator.to_hex();
5700 !banned.contains(&rh) && roster.is_authorized(&rh, Some(&owner_hex), crate::community::roles::Permissions::BAN)
5701 };
5702 let base_rotator_outranks_me = |rotator: &PublicKey| -> bool {
5708 if *rotator == owner {
5709 return true;
5710 }
5711 let rh = rotator.to_hex();
5712 !banned.contains(&rh) && roster.can_act_on_member(&rh, Some(&owner_hex), &me_hex, crate::community::roles::Permissions::BAN)
5713 };
5714
5715 const MAX_STEPS: usize = 128;
5719 for _ in 0..MAX_STEPS {
5720 let mut advanced = false;
5721
5722 let addressing_roots = channel_rekey_addressing_roots(cur.community_root, &cid_hex);
5727
5728 let channel_ids: Vec<ChannelId> = cur.channels.iter().filter(|c| c.private).map(|c| c.id).collect();
5731 for cid in channel_ids {
5732 let (held_key, held_epoch) = match cur.channel(&cid) {
5733 Some(ch) => (ch.key, ch.epoch),
5734 None => continue,
5735 };
5736 let next = Epoch(held_epoch.0.saturating_add(1));
5737 let ch_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
5738 let mut batches: Vec<(Vec<rekey::RekeyChunk>, Option<(Epoch, [u8; 32])>)> = Vec::new();
5739 for (ri, root) in addressing_roots.iter().enumerate() {
5742 let group = channel_rekey_group_key(root, &cid, next);
5743 let chunks = match fetch_rekey_chunks(transport, &cur.relays, &group).await {
5744 Ok(c) => c,
5745 Err(e) => {
5746 crate::log_warn!(
5747 "[v2:follow {}] ch {} next e{} root#{}/{}: rekey plane fetch failed: {}",
5748 &cid_hex[..8], &ch_hex[..8], next.0, ri, addressing_roots.len(), e
5749 );
5750 return Err(e);
5751 }
5752 };
5753 if chunks.is_empty() {
5754 continue;
5755 }
5756 crate::log_debug!(
5757 "[v2:follow {}] ch {} next e{} root#{}/{}: {} rekey chunk(s)",
5758 &cid_hex[..8], &ch_hex[..8], next.0, ri, addressing_roots.len(), chunks.len()
5759 );
5760 batches.push((chunks, held_key.map(|k| (held_epoch, k))));
5761 }
5762 match advance_scope(&batches, RekeyScope::Channel(cid), &channel_rotator_ok, &channel_rotator_outranks_me, &cited_ok, &signer, &my_xonly, next).await {
5769 Advance::Adopt { new_key } => {
5770 if let Some(ch) = cur.channels.iter_mut().find(|c| c.id.0 == cid.0) {
5771 ch.key = Some(new_key);
5772 ch.epoch = next;
5773 }
5774 crate::log_debug!("[v2:follow {}] ch {} ADOPTED e{}", &cid_hex[..8], &ch_hex[..8], next.0);
5775 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) {
5780 crate::log_warn!("v2: channel epoch-key archive failed (history across this rotation may not read back): {e}");
5781 }
5782 advanced = true;
5783 changed = true;
5784 }
5785 Advance::Removed => {
5786 match held_key {
5787 Some(_) => {
5789 cur.channels.retain(|c| c.id.0 != cid.0);
5790 }
5791 None => {
5796 if let Some(ch) = cur.channels.iter_mut().find(|c| c.id.0 == cid.0) {
5797 ch.epoch = next;
5798 }
5799 }
5800 }
5801 advanced = true;
5802 changed = true;
5803 }
5804 Advance::Stay => {}
5805 }
5806 }
5807
5808 {
5812 let held_epoch = cur.root_epoch;
5813 let held_key = cur.community_root;
5814 let next = Epoch(held_epoch.0.saturating_add(1));
5815 let group = base_rekey_group_key(&cur.community_root, cur.id(), next);
5816 let chunks = fetch_rekey_chunks(transport, &cur.relays, &group).await?;
5817 let batches = vec![(chunks, Some((held_epoch, held_key)))];
5818 let base_admissible = |r: &rekey::Rotation| -> bool {
5825 if r.rotator == owner {
5826 return true; }
5828 if !cited_ok(r) {
5832 return false;
5833 }
5834 let rotator_hex = r.rotator.to_hex();
5835 let has_blob = |xonly: &[u8; 32]| {
5836 rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), xonly, r.scope, r.new_epoch).is_some()
5837 };
5838 if !has_blob(&owner.to_bytes()) {
5840 return false;
5841 }
5842 for g in &roster.grants {
5843 if g.member == rotator_hex || g.member == owner_hex || banned.contains(&g.member) {
5844 continue; }
5846 if !roster.can_act_on_member(&rotator_hex, Some(&owner_hex), &g.member, crate::community::roles::Permissions::BAN) {
5848 if let Ok(pk) = PublicKey::from_hex(&g.member) {
5849 if !has_blob(&pk.to_bytes()) {
5850 return false; }
5852 }
5853 }
5854 }
5855 true
5856 };
5857 match advance_scope(&batches, RekeyScope::Root, &base_rotator_ok, &base_rotator_outranks_me, &base_admissible, &signer, &my_xonly, next).await {
5858 Advance::Adopt { new_key } => {
5859 cur.community_root = new_key;
5860 cur.root_epoch = next;
5861 if let Err(e) = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, next.0, &new_key) {
5865 crate::log_warn!("v2: base epoch-key archive failed (this epoch's history may not read back after the next rotation): {e}");
5866 }
5867 advanced = true;
5868 changed = true;
5869 }
5870 Advance::Removed => {
5871 if !session.is_valid() {
5872 return Err("account changed during rekey follow".to_string());
5873 }
5874 return Ok(RekeyFollow { updated: None, self_removed: true, dissolved: false });
5875 }
5876 Advance::Stay => {}
5877 }
5878 }
5879
5880 if !advanced {
5881 break;
5882 }
5883 }
5884
5885 if !changed {
5886 return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: false });
5887 }
5888 if !session.is_valid() {
5889 return Err("account changed during rekey follow".to_string());
5890 }
5891 if crate::db::community::community_protocol(community.id())?.is_none() {
5894 return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: false });
5895 }
5896 crate::db::community::save_community_v2(&cur)?;
5897 let _ = refresh_public_links(transport, &cur).await;
5905 Ok(RekeyFollow { updated: Some(cur), self_removed: false, dissolved: false })
5906}
5907
5908enum Advance {
5911 Adopt { new_key: [u8; 32] },
5913 Removed,
5915 Stay,
5917}
5918
5919async fn fetch_rekey_chunks<T: Transport + ?Sized>(
5921 transport: &T,
5922 relays: &[String],
5923 group: &GroupKey,
5924) -> Result<Vec<rekey::RekeyChunk>, String> {
5925 const REKEY_PAGE: usize = 200;
5933 const REKEY_MAX_PAGES: usize = 6;
5934 let mut out = Vec::new();
5935 let mut seen: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
5936 let mut until: Option<u64> = None;
5937 let mut oldest: Option<u64> = None;
5938 for _ in 0..REKEY_MAX_PAGES {
5939 let query = Query {
5940 kinds: vec![stream::KIND_WRAP],
5941 authors: vec![group.pk_hex()],
5942 until,
5943 limit: Some(REKEY_PAGE),
5944 ..Default::default()
5945 };
5946 let wraps = transport.fetch_plane(group.keys(), &query, relays).await?;
5951 let mut fresh = 0usize;
5952 for w in &wraps {
5953 if !seen.insert(w.id) {
5954 continue;
5955 }
5956 fresh += 1;
5957 let at = w.created_at.as_secs();
5958 if oldest.is_none_or(|o| at < o) {
5959 oldest = Some(at);
5960 }
5961 if let Ok(opened) = stream::open_wrap(w, group) {
5962 if let Ok(chunk) = rekey::parse_rekey_chunk(&opened) {
5963 out.push(chunk);
5964 }
5965 }
5966 }
5967 if fresh == 0 || wraps.len() < REKEY_PAGE {
5970 break;
5971 }
5972 match oldest {
5973 Some(o) if o > 0 => until = Some(o),
5974 _ => break,
5975 }
5976 }
5977 Ok(out)
5978}
5979
5980async fn advance_scope<S: crate::signer::VectorSigner + ?Sized>(
5994 batches: &[(Vec<rekey::RekeyChunk>, Option<(Epoch, [u8; 32])>)],
5995 scope: RekeyScope,
5996 rotator_ok: &(dyn Fn(&PublicKey) -> bool + Sync),
5997 rotator_may_remove_me: &(dyn Fn(&PublicKey) -> bool + Sync),
5998 admissible: &(dyn Fn(&rekey::Rotation) -> bool + Sync),
5999 signer: &S,
6000 my_xonly: &[u8; 32],
6001 next_epoch: Epoch,
6002) -> Advance {
6003 let mut winners: Vec<[u8; 32]> = Vec::new();
6004 let mut saw_complete_candidate = false;
6005 let mut saw_outranking_candidate = false;
6006 let keyed = batches.iter().any(|(_, held)| held.is_some());
6007 for (chunks, held) in batches {
6008 let rotations = rekey::collect_rotations(chunks);
6009 for r in &rotations {
6010 if !rotator_ok(&r.rotator) || r.scope.id32() != scope.id32() || r.new_epoch.0 != next_epoch.0 || !r.is_complete() {
6011 continue;
6012 }
6013 if let Some((held_epoch, held_key)) = held {
6014 if r.continuity(*held_epoch, held_key) != Continuity::Extends {
6015 continue;
6016 }
6017 }
6018 if !admissible(r) {
6024 continue;
6025 }
6026 saw_complete_candidate = true;
6027 saw_outranking_candidate |= rotator_may_remove_me(&r.rotator);
6028 if let Some(blob) = rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), my_xonly, r.scope, r.new_epoch) {
6029 if let Ok(k) = rekey::open_blob(signer, &r.rotator, r.scope, r.new_epoch, blob).await {
6030 winners.push(k);
6031 }
6032 }
6033 }
6034 }
6035 if !winners.is_empty() {
6036 let idx = rekey::lowest_key_winner(&winners).expect("winners is non-empty");
6044 return Advance::Adopt { new_key: winners[idx] };
6045 }
6046 if saw_complete_candidate && (!keyed || saw_outranking_candidate) {
6047 Advance::Removed
6048 } else {
6049 Advance::Stay
6050 }
6051}
6052
6053#[derive(Debug, serde::Serialize)]
6057pub struct ChannelPins {
6058 pub pins: Vec<super::pins::VerifiedPin>,
6060 pub sealed: bool,
6064 pub version: u64,
6066}
6067
6068fn channel_conv_key_at(community: &CommunityV2, ch: &ChannelV2, epoch: u64) -> Option<[u8; 32]> {
6072 let ikm = channel_conv_ikm(community, ch, epoch).ok()?;
6073 if ch.private && ikm == community.community_root {
6076 return None;
6077 }
6078 let group = channel_group_key(&ikm, &ch.id, Epoch(epoch));
6079 group.conv_key().as_bytes().try_into().ok()
6080}
6081
6082pub fn read_channel_pins(community: &CommunityV2, channel_id: &ChannelId) -> Result<ChannelPins, String> {
6086 let ch = community.channel(channel_id).ok_or("no such channel in this community")?;
6087 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6088 let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
6089 let Some((content, version)) = crate::db::community::get_community_pins(&cid_hex, &ch_hex)? else {
6090 return Ok(ChannelPins { pins: Vec::new(), sealed: false, version: 0 });
6091 };
6092 let read = super::pins::read_pin_list(&content, |epoch| channel_conv_key_at(community, ch, epoch));
6093 let pins = read
6094 .entries
6095 .iter()
6096 .filter_map(|e| super::pins::verify_pin_entry(e, &ch_hex))
6097 .collect();
6098 Ok(ChannelPins { pins, sealed: read.sealed, version: version.max(0) as u64 })
6099}
6100
6101async fn publish_pin_list<T: Transport + ?Sized>(
6105 transport: &T,
6106 community: &CommunityV2,
6107 session: &SessionGuard,
6108 ch: &ChannelV2,
6109 entries: &[super::pins::PinEntry],
6110) -> Result<(), String> {
6111 let content = if ch.private {
6112 let key = ch.key.ok_or("this private channel's key has not arrived yet")?;
6113 let group = channel_group_key(&key, &ch.id, ch.epoch);
6114 super::pins::serialize_sealed_pin_list(entries, group.conv_key(), ch.epoch.0)?
6115 } else {
6116 super::pins::serialize_public_pin_list(entries)?
6117 };
6118 let eid = super::derive::pins_locator(community.id(), &ch.id);
6119 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6120 let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
6121 let entity_hex = crate::simd::hex::bytes_to_hex_32(&eid);
6122 let version = match crate::db::community::get_edition_head(&cid_hex, &entity_hex)? {
6124 Some((v, _)) => v + 1,
6125 None => 1,
6126 };
6127 crate::log_info!("[pins] publishing v{} with {} entries for channel {}", version, entries.len(), &ch_hex[..12]);
6128 publish_control_edition(transport, community, session, vsk::PINS, &eid, &content).await?;
6129 if session.is_valid() {
6130 let _ = crate::db::community::set_community_pins(&cid_hex, &ch_hex, &content, version as i64);
6131 crate::emit_event(
6132 "community_pins_updated",
6133 &serde_json::json!({ "community_id": cid_hex, "channel_id": ch_hex }),
6134 );
6135 if let Ok(me) = me_pk() {
6136 use nostr_sdk::prelude::ToBech32;
6137 let me_npub = me.to_bech32().unwrap_or_else(|_| me.to_hex());
6138 note_pins_modified(&ch_hex, version, &me_npub, now_ms() / 1000).await;
6139 }
6140 }
6141 Ok(())
6142}
6143
6144async fn note_pins_modified(channel_hex: &str, version: u64, actor_npub: &str, at_secs: u64) {
6149 let event_id = format!("pins-mod-{}-v{}", &channel_hex[..16], version);
6150 let inserted = crate::db::events::save_system_event_at(
6151 &event_id,
6152 channel_hex,
6153 crate::stored_event::SystemEventType::PinsModified,
6154 actor_npub,
6155 None,
6156 at_secs,
6157 None,
6158 None,
6159 )
6160 .await
6161 .unwrap_or(false);
6162 if inserted {
6163 crate::emit_event(
6164 "system_event",
6165 &serde_json::json!({
6166 "conversation_id": channel_hex,
6167 "event_id": event_id,
6168 "event_type": crate::stored_event::SystemEventType::PinsModified.as_u8(),
6169 "member_pubkey": actor_npub,
6170 }),
6171 );
6172 }
6173}
6174
6175fn writable_pin_entries(community: &CommunityV2, channel_id: &ChannelId) -> Result<Vec<super::pins::PinEntry>, String> {
6179 let current = read_channel_pins(community, channel_id)?;
6180 if current.sealed {
6181 return Err("this channel's pins are sealed under a key you don't hold; pinning would erase them".to_string());
6182 }
6183 Ok(current.pins.into_iter().map(|p| p.entry).collect())
6184}
6185
6186pub async fn pin_message<T: Transport + ?Sized>(
6191 transport: &T,
6192 community: &CommunityV2,
6193 channel_id: &ChannelId,
6194 rumor_id_hex: &str,
6195) -> Result<(), String> {
6196 let session = SessionGuard::capture();
6197 let ch = community.channel(channel_id).ok_or("no such channel in this community")?.clone();
6198 let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
6199
6200 let mut entries = writable_pin_entries(community, channel_id)?;
6201 if entries.len() >= super::pins::PIN_MAX_ENTRIES {
6202 return Err(format!("this channel already holds {} pins; unpin one first", super::pins::PIN_MAX_ENTRIES));
6203 }
6204 if entries
6206 .iter()
6207 .filter_map(|e| super::pins::verify_pin_entry(e, &ch_hex))
6208 .any(|v| v.rumor_id == rumor_id_hex)
6209 {
6210 return Ok(());
6211 }
6212
6213 let (wrap_id, _tags) = crate::db::events::get_event_wrap_context(rumor_id_hex)?
6214 .ok_or("message not found in this device's history")?;
6215 let wrap_id = wrap_id.ok_or("this message's original wrap id was not recorded")?;
6216
6217 let wraps = transport
6220 .fetch(
6221 &Query {
6222 ids: vec![wrap_id.clone()],
6223 kinds: vec![super::stream::KIND_WRAP],
6224 limit: Some(1),
6225 evidence: crate::community::transport::Evidence::Full,
6226 ..Default::default()
6227 },
6228 &community.relays,
6229 )
6230 .await?;
6231 let wrap = wraps
6232 .iter()
6233 .find(|w| w.id.to_hex() == wrap_id)
6234 .ok_or("the message's wrap is no longer served by this community's relays")?;
6235
6236 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6242 let mut coords: Vec<(u64, [u8; 32])> = Vec::new();
6243 if ch.private {
6244 if let Some(k) = ch.key {
6245 coords.push((ch.epoch.0, k));
6246 }
6247 let held = crate::db::community::held_epoch_keys(&cid_hex, &ch_hex).unwrap_or_default();
6248 coords.extend(held.into_iter().filter(|(_, k)| *k != community.community_root).map(|(ep, k)| (ep.0, k)));
6249 } else {
6250 coords.push((community.root_epoch.0, community.community_root));
6251 let held = crate::db::community::held_epoch_keys(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap_or_default();
6252 coords.extend(held.into_iter().map(|(ep, k)| (ep.0, k)));
6253 }
6254 coords.dedup();
6255
6256 let mut found: Option<(super::stream::OpenedStream, [u8; 32])> = None;
6257 for (epoch, ikm) in coords {
6258 let group = channel_group_key(&ikm, &ch.id, Epoch(epoch));
6259 if let Ok(super::chat::ChatEvent::Message { opened, .. }) =
6260 super::chat::open_chat_event(wrap, &group, channel_id, Epoch(epoch))
6261 {
6262 if opened.rumor_id.to_hex() == rumor_id_hex {
6263 let conv: [u8; 32] = group
6264 .conv_key()
6265 .as_bytes()
6266 .try_into()
6267 .map_err(|_| "conversation key size".to_string())?;
6268 found = Some((opened, conv));
6269 break;
6270 }
6271 }
6272 }
6273 let Some((opened, conv_key)) = found else {
6274 return Err("that message is from a key epoch this device no longer holds".to_string());
6275 };
6276
6277 let entry = super::pins::build_pin_entry(&opened, &conv_key, &ch_hex).map_err(|e| match e {
6278 super::pins::PinBuildFailure::NotEncrypted => "this message's seal form cannot be pinned".to_string(),
6279 super::pins::PinBuildFailure::BadPayload => "that message is from a key epoch this device no longer holds".to_string(),
6280 super::pins::PinBuildFailure::Unverifiable => "this message's proof did not verify".to_string(),
6281 })?;
6282 entries.push(entry);
6283
6284 if !session.is_valid() {
6285 return Err("account changed before the pin was published".to_string());
6286 }
6287 publish_pin_list(transport, community, &session, &ch, &entries).await
6288}
6289
6290fn channel_conv_ikm(community: &CommunityV2, ch: &ChannelV2, epoch: u64) -> Result<[u8; 32], String> {
6293 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6294 if ch.private {
6295 if ch.epoch.0 == epoch {
6296 return ch.key.ok_or("this private channel's key has not arrived yet".to_string());
6297 }
6298 let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
6299 crate::db::community::held_epoch_keys(&cid_hex, &ch_hex)
6300 .unwrap_or_default()
6301 .into_iter()
6302 .find(|(ep, _)| ep.0 == epoch)
6303 .map(|(_, k)| k)
6304 .ok_or("that message is from a key epoch this device no longer holds".to_string())
6305 } else if community.root_epoch.0 == epoch {
6306 Ok(community.community_root)
6307 } else {
6308 crate::db::community::held_epoch_keys(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX)
6309 .unwrap_or_default()
6310 .into_iter()
6311 .find(|(ep, _)| ep.0 == epoch)
6312 .map(|(_, k)| k)
6313 .ok_or("that message is from a root epoch this device no longer holds".to_string())
6314 }
6315}
6316
6317pub async fn unpin_message<T: Transport + ?Sized>(
6319 transport: &T,
6320 community: &CommunityV2,
6321 channel_id: &ChannelId,
6322 rumor_id_hex: &str,
6323) -> Result<(), String> {
6324 let session = SessionGuard::capture();
6325 let ch = community.channel(channel_id).ok_or("no such channel in this community")?.clone();
6326 let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
6327 let entries = writable_pin_entries(community, channel_id)?;
6328 let kept: Vec<super::pins::PinEntry> = entries
6329 .into_iter()
6330 .filter(|e| {
6331 super::pins::verify_pin_entry(e, &ch_hex)
6332 .map(|v| v.rumor_id != rumor_id_hex)
6333 .unwrap_or(true)
6336 })
6337 .collect();
6338 publish_pin_list(transport, community, &session, &ch, &kept).await
6339}
6340
6341pub(crate) fn spawn_pin_duty(channel_hex: &str, target_rumor_hex: &str, edit: Option<super::stream::OpenedStream>) {
6345 let channel_hex = channel_hex.to_string();
6346 let target = target_rumor_hex.to_string();
6347 let session = SessionGuard::capture();
6348 tokio::spawn(async move {
6349 let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
6350 let _ = run_pin_duty(&transport, &channel_hex, &target, edit, session).await;
6351 });
6352}
6353
6354async fn run_pin_duty<T: Transport + ?Sized>(
6361 transport: &T,
6362 channel_hex: &str,
6363 target: &str,
6364 edit: Option<super::stream::OpenedStream>,
6365 session: SessionGuard,
6366) -> Result<(), String> {
6367 use crate::community::roles::Permissions;
6368 let Some(cid_hex) = crate::db::community::community_id_for_channel(channel_hex)? else {
6369 return Ok(());
6370 };
6371 let cid = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&cid_hex));
6372 let Some(community) = crate::db::community::load_community_v2(&cid)? else {
6373 return Ok(());
6374 };
6375 let channel_id = ChannelId(crate::simd::hex::hex_to_bytes_32(channel_hex));
6376
6377 let read = read_channel_pins(&community, &channel_id)?;
6379 if read.sealed {
6380 return Ok(());
6381 }
6382 let Some(hit) = read.pins.iter().find(|p| p.rumor_id == target) else {
6383 return Ok(());
6384 };
6385 if let Some(ed) = &edit {
6386 if hit.edited.as_ref().is_some_and(|held| held.ms >= ed.at_ms) {
6388 return Ok(());
6389 }
6390 }
6391 let me = me_pk()?;
6392 let me_hex = me.to_hex();
6393 let owner_hex = community.owner()?.to_hex();
6394 let roster = crate::db::community::get_community_roles(&cid_hex)?;
6395 if !roster.is_authorized(&me_hex, Some(&owner_hex), Permissions::PIN_MESSAGES) {
6396 return Ok(());
6397 }
6398
6399 if hit.author != me_hex {
6400 let mut h: u32 = 0;
6401 for b in me_hex.bytes().chain(target.bytes()) {
6402 h = h.wrapping_mul(31).wrapping_add(u32::from(b));
6403 }
6404 tokio::time::sleep(std::time::Duration::from_secs(5 + u64::from(h % 21))).await;
6405 if !session.is_valid() {
6406 return Ok(());
6407 }
6408 }
6409
6410 let read = read_channel_pins(&community, &channel_id)?;
6412 if read.sealed {
6413 return Ok(());
6414 }
6415 let Some(hit) = read.pins.iter().find(|p| p.rumor_id == target) else {
6416 return Ok(());
6417 };
6418 let ch = community.channel(&channel_id).ok_or("no such channel")?.clone();
6419
6420 let entries: Vec<super::pins::PinEntry> = match &edit {
6421 None => read
6423 .pins
6424 .iter()
6425 .filter(|p| p.rumor_id != target)
6426 .map(|p| p.entry.clone())
6427 .collect(),
6428 Some(ed) => {
6430 if hit.edited.as_ref().is_some_and(|held| held.ms >= ed.at_ms) {
6431 return Ok(());
6432 }
6433 let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
6434 let mut bundle = None;
6437 let mut epochs: Vec<u64> = vec![if ch.private { ch.epoch.0 } else { community.root_epoch.0 }];
6438 let scope = if ch.private { ch_hex.clone() } else { crate::community::SERVER_ROOT_SCOPE_HEX.to_string() };
6439 epochs.extend(
6440 crate::db::community::held_epoch_keys(&cid_hex, &scope)
6441 .unwrap_or_default()
6442 .into_iter()
6443 .map(|(ep, _)| ep.0),
6444 );
6445 epochs.dedup();
6446 for epoch in epochs {
6447 let Some(conv) = channel_conv_key_at(&community, &ch, epoch) else { continue };
6448 if let Ok(b) = super::pins::build_pin_edit_bundle(ed, &conv, &hit.author, target, &ch_hex) {
6449 bundle = Some(b);
6450 break;
6451 }
6452 }
6453 let Some(bundle) = bundle else { return Ok(()) };
6454 read.pins
6455 .iter()
6456 .map(|p| {
6457 let mut entry = p.entry.clone();
6458 if p.rumor_id == target {
6459 entry.edit = Some(bundle.clone());
6460 }
6461 entry
6462 })
6463 .collect()
6464 }
6465 };
6466
6467 if !session.is_valid() {
6468 return Ok(());
6469 }
6470 crate::log_info!(
6471 "[pins] duty {} for target {} in channel {}",
6472 if edit.is_some() { "edit-refresh" } else { "omission" },
6473 &target[..12],
6474 &channel_hex[..12]
6475 );
6476 publish_pin_list(transport, &community, &session, &ch, &entries).await
6477}
6478
6479pub async fn upgrade_admin_role_pin_bit<T: Transport + ?Sized>(
6484 transport: &T,
6485 community: &CommunityV2,
6486) -> Result<bool, String> {
6487 use crate::community::roles::{Permissions, RoleScope};
6488 let my_pk = me_pk()?;
6489 if community.owner()? != my_pk {
6490 return Ok(false);
6491 }
6492 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6493 let roles = crate::db::community::get_community_roles(&cid_hex)?;
6494 let Some(role) = roles.roles.iter().find(|r| {
6495 matches!(r.scope, RoleScope::Server)
6496 && r.permissions.contains(Permissions::ADMIN_FOUNDING_MASK)
6497 && !r.permissions.contains(Permissions::PIN_MESSAGES)
6498 }) else {
6499 return Ok(false);
6500 };
6501 let mut widened = role.clone();
6502 widened.permissions.0 |= Permissions::PIN_MESSAGES;
6503 set_role(transport, community, &widened).await?;
6504 Ok(true)
6505}
6506
6507#[cfg(test)]
6508mod tests {
6509 use crate::ClientRelayExt;
6510 use nostr_sdk::prelude::FinalizeEvent;
6511 use super::super::super::transport::memory::MemoryRelay;
6512 use super::*;
6513 use crate::community::roles::{MemberGrant, Permissions, Role, RoleScope};
6514
6515 fn account_name(n: u32) -> String {
6517 const B: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
6518 let mut acct = String::from("npub1");
6519 let mut v = n as usize;
6520 for _ in 0..58 {
6521 acct.push(B[v % 32] as char);
6522 v = v / 32 + 7;
6523 }
6524 acct
6525 }
6526
6527 struct Actor {
6529 keys: Keys,
6530 account: String,
6531 }
6532
6533 struct TestBed {
6539 _tmp: tempfile::TempDir,
6540 _guard: std::sync::MutexGuard<'static, ()>,
6541 relay: MemoryRelay,
6542 relays: Vec<String>,
6543 }
6544
6545 impl TestBed {
6546 fn new() -> (TestBed, Actor, Actor) {
6547 static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(70_000);
6548 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
6549 crate::db::close_database();
6550 crate::db::clear_id_caches();
6551 let tmp = tempfile::tempdir().unwrap();
6552 crate::db::set_app_data_dir(tmp.path().to_path_buf());
6553
6554 let mk = || {
6555 let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6556 let account = account_name(n);
6557 std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
6558 crate::db::set_current_account(account.clone()).unwrap();
6559 crate::db::init_database(&account).unwrap();
6560 Actor { keys: Keys::generate(), account }
6561 };
6562 let owner = mk();
6563 let member = mk();
6564 let _ = crate::state::take_nostr_client();
6565 let bed = TestBed {
6566 _tmp: tmp,
6567 _guard: guard,
6568 relay: MemoryRelay::new(),
6569 relays: vec!["wss://r".to_string()],
6570 };
6571 (bed, owner, member)
6572 }
6573
6574 fn swap_to(&self, actor: &Actor) {
6579 crate::state::bump_session_generation();
6580 crate::db::set_current_account(actor.account.clone()).unwrap();
6581 crate::db::init_database(&actor.account).unwrap();
6582 crate::db::clear_id_caches();
6583 crate::state::MY_SECRET_KEY.store_from_keys(&actor.keys, &[]);
6584 crate::state::set_my_public_key(actor.keys.public_key());
6585 }
6586 }
6587
6588 fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>, Keys) {
6590 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
6591 crate::db::close_database();
6592 crate::db::clear_id_caches();
6593 let tmp = tempfile::tempdir().unwrap();
6594 static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(50_000);
6595 let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6596 let acct = account_name(n);
6597 std::fs::create_dir_all(tmp.path().join(&acct)).unwrap();
6598 crate::db::set_app_data_dir(tmp.path().to_path_buf());
6599 crate::db::set_current_account(acct.clone()).unwrap();
6600 crate::db::init_database(&acct).unwrap();
6601 let _ = crate::state::take_nostr_client();
6602 let owner = Keys::generate();
6603 crate::state::MY_SECRET_KEY.store_from_keys(&owner, &[]);
6604 crate::state::set_my_public_key(owner.public_key());
6605 (tmp, guard, owner)
6606 }
6607
6608 struct SwapMidFetch {
6611 inner: MemoryRelay,
6612 }
6613 #[async_trait::async_trait]
6614 impl Transport for SwapMidFetch {
6615 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
6616 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
6617 self.inner.publish(e, r).await
6618 }
6619 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
6620 self.inner.publish_durable(e, r).await
6621 }
6622 async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> {
6623 let out = self.inner.fetch(q, r).await;
6624 crate::state::bump_session_generation();
6625 out
6626 }
6627 }
6628
6629 struct SwapMidPublish {
6632 inner: MemoryRelay,
6633 }
6634 #[async_trait::async_trait]
6635 impl Transport for SwapMidPublish {
6636 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
6637 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
6638 self.inner.publish(e, r).await
6639 }
6640 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
6641 let out = self.inner.publish_durable(e, r).await;
6642 crate::state::bump_session_generation();
6643 out
6644 }
6645 async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> {
6646 self.inner.fetch(q, r).await
6647 }
6648 }
6649
6650 struct FixedFetch {
6655 events: Vec<Event>,
6656 }
6657 #[async_trait::async_trait]
6658 impl Transport for FixedFetch {
6659 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
6660 async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
6661 Ok(())
6662 }
6663 async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
6664 Ok(())
6665 }
6666 async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
6667 Ok(self.events.clone())
6668 }
6669 }
6670
6671 async fn fetch_direct_invite(relay: &MemoryRelay, relays: &[String], me: &PublicKey) -> Event {
6674 let q = Query {
6675 kinds: vec![stream::KIND_WRAP],
6676 p_tags: vec![me.to_hex()],
6677 k_tags: vec!["3313".to_string()],
6678 ..Default::default()
6679 };
6680 relay.fetch(&q, relays).await.unwrap().into_iter().next().expect("a direct invite is waiting")
6681 }
6682
6683 #[tokio::test]
6684 async fn create_persists_and_reloads_a_v2_community() {
6685 let (_tmp, _guard, owner) = init_test_db();
6686 let relay = MemoryRelay::new();
6687 let relays = vec!["wss://r".to_string()];
6688
6689 let created = create_community(&relay, "Vectorville", relays.clone(), Some("hi".into())).await.unwrap();
6690 assert!(created.identity.verify());
6691 assert_eq!(created.owner().unwrap(), owner.public_key());
6692 assert_eq!(created.channels.len(), 1);
6693
6694 assert_eq!(
6696 crate::db::community::community_protocol(created.id()).unwrap(),
6697 Some(crate::community::ConcordProtocol::V2)
6698 );
6699 let loaded = crate::db::community::load_community_v2(created.id()).unwrap().expect("reloads");
6700 assert_eq!(loaded.name, "Vectorville");
6701 assert_eq!(loaded.community_root, created.community_root);
6702 assert_eq!(loaded.identity, created.identity);
6703 assert_eq!(loaded.channels[0].id.0, created.channels[0].id.0);
6704 assert!(!loaded.channels[0].private);
6705
6706 assert!(relay.count_on("wss://r") >= 3, "2 genesis editions + 1 guestbook join");
6708 }
6709
6710 #[tokio::test]
6711 async fn owner_sends_and_reads_back_a_message() {
6712 let (_tmp, _guard, _owner) = init_test_db();
6713 let relay = MemoryRelay::new();
6714 let community = create_community(&relay, "Chat", vec!["wss://r".into()], None).await.unwrap();
6715 let general = community.channels[0].id;
6716
6717 let id1 = send_message(&relay, &community, &general, "hello world").await.unwrap();
6718 let id2 = send_message(&relay, &community, &general, "second message").await.unwrap();
6719 assert_ne!(id1, id2);
6720
6721 let page = fetch_channel(&relay, &community, &general, 100).await.unwrap();
6722 let texts: Vec<String> = page
6723 .iter()
6724 .filter_map(|f| match &f.event {
6725 ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
6726 _ => None,
6727 })
6728 .collect();
6729 assert_eq!(texts, vec!["hello world", "second message"], "messages round-trip in ms order");
6730 }
6731
6732 #[tokio::test]
6733 async fn a_second_member_reads_the_public_channel_from_the_root() {
6734 let (_tmp, _guard, _owner) = init_test_db();
6738 let relay = MemoryRelay::new();
6739 let community = create_community(&relay, "Public", vec!["wss://r".into()], None).await.unwrap();
6740 let general = community.channels[0].id;
6741 send_message(&relay, &community, &general, "everyone can read this").await.unwrap();
6742
6743 let member_view = community.clone();
6745 let page = fetch_channel(&relay, &member_view, &general, 100).await.unwrap();
6746 assert_eq!(page.len(), 1);
6747 assert!(matches!(&page[0].event, ChatEvent::Message { .. }));
6748 assert_eq!(page[0].event.opened().rumor.content, "everyone can read this");
6749 }
6750
6751 async fn texts_in<T: crate::community::transport::Transport + ?Sized>(relay: &T, community: &CommunityV2, channel: &ChannelId) -> Vec<String> {
6754 fetch_channel(relay, community, channel, 100)
6755 .await
6756 .unwrap()
6757 .iter()
6758 .filter_map(|f| match &f.event {
6759 ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
6760 _ => None,
6761 })
6762 .collect()
6763 }
6764
6765 #[tokio::test]
6766 async fn direct_invite_full_loop_owner_and_member_converse() {
6767 let (bed, owner, member) = TestBed::new();
6768
6769 bed.swap_to(&owner);
6771 let community = create_community(&bed.relay, "Guild", bed.relays.clone(), None).await.unwrap();
6772 let general = community.channels[0].id;
6773 send_message(&bed.relay, &community, &general, "owner: welcome!").await.unwrap();
6774 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
6775
6776 bed.swap_to(&member);
6778 assert!(
6779 crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
6780 "the member does not hold the community before joining"
6781 );
6782 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
6783 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
6784 assert_eq!(joined.id().0, community.id().0, "joined the same community");
6785 assert!(joined.identity.verify(), "the joiner independently verifies the owner commitment");
6786 assert_eq!(joined.owner().unwrap(), owner.keys.public_key());
6787
6788 assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: welcome!"]);
6790 send_message(&bed.relay, &joined, &general, "member: thanks for the invite").await.unwrap();
6791
6792 bed.swap_to(&owner);
6794 assert_eq!(
6795 texts_in(&bed.relay, &community, &general).await,
6796 vec!["owner: welcome!", "member: thanks for the invite"],
6797 "both actors' messages interleave in ms order on the shared channel"
6798 );
6799
6800 let members = memberlist(&bed.relay, &community).await.unwrap();
6802 assert!(members.contains(&owner.keys.public_key()), "owner is a member (genesis Join)");
6803 assert!(members.contains(&member.keys.public_key()), "member is a member (invite Join)");
6804 assert_eq!(members.len(), 2);
6805 }
6806
6807 #[tokio::test]
6811 async fn a_banned_member_is_refused_at_join_time() {
6812 let (bed, owner, member) = TestBed::new();
6813
6814 bed.swap_to(&owner);
6815 let community = create_community(&bed.relay, "NoEntry", bed.relays.clone(), None).await.unwrap();
6816 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
6817 set_banlist(&bed.relay, &community, &[member.keys.public_key().to_hex()]).await.unwrap();
6818
6819 bed.swap_to(&member);
6820 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
6821 let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
6822 assert!(err.contains("banned"), "refusal names the reason: {err}");
6823 assert!(
6824 crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
6825 "a refused join persists nothing"
6826 );
6827
6828 bed.swap_to(&owner);
6831 set_banlist(&bed.relay, &community, &[]).await.unwrap();
6832 bed.swap_to(&member);
6833 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
6834 assert_eq!(joined.id().0, community.id().0, "unban restores joinability");
6835 }
6836
6837 #[tokio::test]
6841 async fn member_migrates_v1_to_v2_from_the_dissolution_payload() {
6842 use crate::community::migration;
6843 let (bed, owner, member) = TestBed::new();
6844
6845 bed.swap_to(&owner);
6847 let v2 = create_community(&bed.relay, "Guild v2", bed.relays.clone(), None).await.unwrap();
6848 let v2_hex = crate::simd::hex::bytes_to_hex_32(&v2.identity.community_id.0);
6849 let jm = join_material(&v2);
6850
6851 bed.swap_to(&member);
6854 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6855 let v1_cid = v1.id.to_hex();
6856 v1.owner_attestation = Some({
6857 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
6858 .finalize(&owner.keys).unwrap().as_json()
6859 });
6860 crate::db::community::save_community(&v1).unwrap();
6861 let v1_channel = v1.channels[0].id.to_hex();
6862
6863 let m = migration::seal_m(v1.server_root_key.as_bytes(), &serde_json::to_vec(&jm).unwrap()).unwrap();
6865 let signpost = migration::MigrationSignpost {
6866 v2_community_id: v2_hex.clone(),
6867 owner_xonly: owner.keys.public_key().to_hex(),
6868 owner_salt: crate::simd::hex::bytes_to_hex_32(&v2.identity.owner_salt),
6869 relays: bed.relays.clone(),
6870 name: "Guild".into(),
6871 primary_channel: v1_channel.clone(),
6872 root_epoch: 0,
6873 };
6874 let content = migration::build_migration_content(&signpost, Some(m)).unwrap();
6875 crate::db::community::set_migration_pointer(&v1_cid, &content).unwrap();
6876
6877 let flipped = migration::drive_migration(&bed.relay, &v1).await.unwrap();
6879 assert_eq!(flipped.as_deref(), Some(v2_hex.as_str()), "the flip completed to the v2 twin");
6880
6881 assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()));
6883 assert!(crate::db::community::get_community_dissolved(&v1_cid).unwrap(), "flip also seals v1 (fence layer 0)");
6884 assert!(crate::db::community::load_community_v2(&v2.identity.community_id).unwrap().is_some(), "v2 twin held");
6885 let _ = v1_channel;
6886
6887 assert_eq!(migration::drive_migration(&bed.relay, &v1).await.unwrap(), None);
6889 }
6890
6891 #[tokio::test]
6896 async fn owner_wizard_then_member_migrate_and_stitch() {
6897 use crate::community::migration;
6898 let (bed, owner, member) = TestBed::new();
6899 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
6900
6901 bed.swap_to(&owner);
6903 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6904 let v1_cid = v1.id.to_hex();
6905 let v1_channel = v1.channels[0].id.to_hex();
6906 v1.owner_attestation = Some({
6907 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
6908 .finalize(&owner.keys).unwrap().as_json()
6909 });
6910 crate::db::community::save_community(&v1).unwrap();
6911
6912 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
6914 assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()),
6915 "owner's own client flipped to v2");
6916 assert_eq!(crate::db::community::community_id_for_channel(&v1_channel).unwrap().as_deref(), Some(v2_hex.as_str()),
6919 "owner channel stitched to v2");
6920
6921 bed.swap_to(&member);
6923 let mut m_v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6924 m_v1.id = v1.id;
6926 m_v1.server_root_key = v1.server_root_key.clone();
6927 m_v1.channels[0].id = v1.channels[0].id;
6928 m_v1.owner_attestation = v1.owner_attestation.clone();
6929 crate::db::community::save_community(&m_v1).unwrap();
6930
6931 crate::community::service::fetch_and_apply_control(&bed.relay, &m_v1).await.unwrap();
6934 assert!(crate::db::community::get_community_dissolved(&v1_cid).unwrap(), "member sees v1 sealed");
6935 assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()),
6936 "the FOLD ITSELF flipped the member (auto-drive)");
6937 assert!(crate::db::community::load_community_v2(&crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&v2_hex))).unwrap().is_some(),
6938 "member holds the v2 twin");
6939 assert_eq!(migration::drive_migration(&bed.relay, &m_v1).await.unwrap(), None);
6941 }
6942
6943 #[tokio::test]
6948 async fn wizard_publishes_the_twin_to_the_cross_device_list() {
6949 use crate::community::migration;
6950 let (bed, owner, _member) = TestBed::new();
6951 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
6952
6953 bed.swap_to(&owner);
6954 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6955 let v1_cid = v1.id.to_hex();
6956 v1.owner_attestation = Some({
6957 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
6958 .finalize(&owner.keys).unwrap().as_json()
6959 });
6960 crate::db::community::save_community(&v1).unwrap();
6961
6962 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
6963
6964 let list = fetch_community_list(&bed.relay, &bed.relays).await.unwrap()
6966 .expect("the wizard published a community list");
6967 assert!(list.is_live(&v2_hex), "the twin must be live in the cross-device list");
6968 assert!(
6972 !list.tombstones.iter().any(|t| t.community_id == v1_cid),
6973 "migration must not tombstone the v1 community"
6974 );
6975 }
6976
6977 #[tokio::test]
6982 async fn wizard_refuses_while_a_drive_holds_the_claim() {
6983 use crate::community::migration;
6984 let (bed, owner, _member) = TestBed::new();
6985 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
6986
6987 bed.swap_to(&owner);
6988 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6989 let v1_cid = v1.id.to_hex();
6990 v1.owner_attestation = Some({
6991 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
6992 .finalize(&owner.keys).unwrap().as_json()
6993 });
6994 crate::db::community::save_community(&v1).unwrap();
6995
6996 migration::test_hold_drive_claim(&v1_cid);
6998 let err = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap_err();
6999 assert!(err.contains("already in progress"), "second wizard refused, got: {err}");
7000 assert!(crate::db::community::get_migration_ledger(&v1_cid).unwrap().is_none(), "no ledger row was written");
7002 assert!(crate::db::community::get_migrated_to(&v1_cid).unwrap().is_none(), "no flip happened");
7003
7004 migration::test_release_drive_claim(&v1_cid);
7006 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
7007 assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()));
7008 }
7009
7010 #[tokio::test]
7016 async fn wizard_flip_waits_for_an_in_flight_follow_pass() {
7017 use crate::community::migration;
7018 let (bed, owner, _member) = TestBed::new();
7019 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
7020 let relay = std::sync::Arc::new(MemoryRelay::new());
7022
7023 bed.swap_to(&owner);
7024 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
7025 let v1_cid = v1.id.to_hex();
7026 let v1_channel = v1.channels[0].id.to_hex();
7027 v1.owner_attestation = Some({
7028 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
7029 .finalize(&owner.keys).unwrap().as_json()
7030 });
7031 crate::db::community::save_community(&v1).unwrap();
7032
7033 let twin = create_migration_twin(
7036 &*relay, "Guild", bed.relays.clone(), None,
7037 (v1.channels[0].id, "general".to_string()),
7038 ).await.unwrap();
7039 let v2_id = twin.identity.community_id;
7040 let v2_hex = crate::simd::hex::bytes_to_hex_32(&v2_id.0);
7041 crate::db::community::set_migration_ledger(&v1_cid, &v2_hex, migration::PHASE_TWIN_MINTED, "").unwrap();
7042
7043 let held = crate::community::v2::realtime::follow_lock(&v2_id).lock_owned().await;
7045
7046 let wizard = tokio::spawn({
7047 let relay = relay.clone();
7048 let v1 = v1.clone();
7049 async move { migration::migrate_community_to_v2(&*relay, &v1, unlocked).await }
7050 });
7051
7052 tokio::time::sleep(std::time::Duration::from_millis(300)).await;
7054 assert!(!wizard.is_finished(), "the flip must wait for the in-flight follow pass");
7055 assert!(
7056 crate::db::community::get_migrated_to(&v1_cid).unwrap().is_none(),
7057 "the fence must not be stamped while the follow lock is held"
7058 );
7059
7060 drop(held);
7062 let flipped = wizard.await.unwrap().unwrap();
7063 assert_eq!(flipped, v2_hex, "the wizard completed onto the SAME twin (resumed, never re-minted)");
7064 assert_eq!(
7065 crate::db::community::community_id_for_channel(&v1_channel).unwrap().as_deref(),
7066 Some(v2_hex.as_str()),
7067 "the channel row is stitched to the twin, not pruned"
7068 );
7069 }
7070
7071 #[tokio::test]
7076 async fn banned_never_cut_member_opens_m_but_cannot_migrate() {
7077 use crate::community::migration;
7078 let (bed, owner, banned) = TestBed::new();
7079 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
7080
7081 bed.swap_to(&owner);
7083 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
7084 let v1_cid = v1.id.to_hex();
7085 v1.owner_attestation = Some({
7086 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
7087 .finalize(&owner.keys).unwrap().as_json()
7088 });
7089 crate::db::community::save_community(&v1).unwrap();
7090 crate::db::community::set_community_banlist(&v1_cid, &[banned.keys.public_key().to_hex()], 1_000).unwrap();
7091
7092 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
7093
7094 bed.swap_to(&banned);
7096 let mut m_v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
7097 m_v1.id = v1.id;
7098 m_v1.server_root_key = v1.server_root_key.clone();
7099 m_v1.channels[0].id = v1.channels[0].id;
7100 m_v1.owner_attestation = v1.owner_attestation.clone();
7101 crate::db::community::save_community(&m_v1).unwrap();
7102 crate::community::service::fetch_and_apply_control(&bed.relay, &m_v1).await.unwrap();
7103
7104 let raw = crate::db::community::get_migration_pointer(&v1_cid).unwrap().expect("pointer lands");
7106 let payload = migration::parse_migration_payload(&raw).unwrap();
7107 assert!(payload.m.is_some());
7108 let err = migration::drive_migration(&bed.relay, &m_v1).await.unwrap_err();
7109 assert!(err.contains("banned"), "refused at the join-time ban gate: {err}");
7110 assert!(crate::db::community::get_migrated_to(&v1_cid).unwrap().is_none(), "no flip");
7111 assert!(
7112 crate::db::community::load_community_v2(&crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&v2_hex))).unwrap().is_none(),
7113 "banned member never acquires the v2 twin"
7114 );
7115 }
7116
7117 #[tokio::test]
7122 async fn wizard_resume_continues_on_the_same_twin() {
7123 use crate::community::migration;
7124 let (bed, owner, banned) = TestBed::new();
7125 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
7126
7127 bed.swap_to(&owner);
7128 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
7129 let mut sibling = v1.channels[0].clone();
7131 sibling.id = crate::community::ChannelId(crate::community::random_32());
7132 sibling.name = "offtopic".into();
7133 v1.channels.push(sibling.clone());
7134 let v1_cid = v1.id.to_hex();
7135 v1.owner_attestation = Some({
7136 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
7137 .finalize(&owner.keys).unwrap().as_json()
7138 });
7139 crate::db::community::save_community(&v1).unwrap();
7140 crate::db::community::set_community_banlist(&v1_cid, &[banned.keys.public_key().to_hex()], 1_000).unwrap();
7141
7142 let twin = create_migration_twin(&bed.relay, &v1.name, bed.relays.clone(), None, (v1.channels[0].id, "general".into())).await.unwrap();
7145 let minted_hex = crate::simd::hex::bytes_to_hex_32(&twin.identity.community_id.0);
7146 crate::db::community::set_migration_ledger(&v1_cid, &minted_hex, migration::PHASE_TWIN_MINTED, "").unwrap();
7147
7148 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
7150 assert_eq!(v2_hex, minted_hex, "no second twin was minted");
7151 let (ledger_v2, phase, _) = crate::db::community::get_migration_ledger(&v1_cid).unwrap().unwrap();
7152 assert_eq!(ledger_v2, minted_hex);
7153 assert_eq!(phase, migration::PHASE_FLIPPED);
7154 assert_eq!(
7157 crate::db::community::community_id_for_channel(&sibling.id.to_hex()).unwrap().as_deref(),
7158 Some(minted_hex.as_str()),
7159 "sibling channel re-parented by the resumed run"
7160 );
7161 let twin_reloaded = crate::db::community::load_community_v2(&twin.identity.community_id).unwrap().unwrap();
7162 let (_, _, wire_banlist) = verify_owner_root_and_reconcile(&bed.relay, twin_reloaded.clone())
7163 .await
7164 .map(|(c, h, b)| (c, h, b))
7165 .unwrap();
7166 assert!(wire_banlist.contains(&banned.keys.public_key().to_hex()),
7167 "the resumed banlist clone is folded from the twin's wire control plane");
7168
7169 crate::db::community::set_migration_ledger(&v1_cid, &minted_hex, migration::PHASE_CARRIER_PUBLISHED, "").unwrap();
7172 let healed = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
7173 assert_eq!(healed, minted_hex);
7174 let (_, phase, _) = crate::db::community::get_migration_ledger(&v1_cid).unwrap().unwrap();
7175 assert_eq!(phase, migration::PHASE_FLIPPED, "ledger healed to FLIPPED");
7176
7177 let mut v1b = crate::community::Community::create("Guild2", "general", bed.relays.clone());
7183 let v1b_cid = v1b.id.to_hex();
7184 v1b.owner_attestation = Some({
7185 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1b_cid)
7186 .finalize(&owner.keys).unwrap().as_json()
7187 });
7188 crate::db::community::save_community(&v1b).unwrap();
7189 let twin2 = create_migration_twin(&bed.relay, &v1b.name, bed.relays.clone(), None, (v1b.channels[0].id, "general".into())).await.unwrap();
7190 let twin2_hex = crate::simd::hex::bytes_to_hex_32(&twin2.identity.community_id.0);
7191 crate::db::community::set_migration_ledger(&v1b_cid, &twin2_hex, migration::PHASE_CARRIER_PUBLISHED, "").unwrap();
7192 crate::db::community::set_community_dissolved(&v1b_cid).unwrap(); let resumed = migration::migrate_community_to_v2(&bed.relay, &v1b, unlocked).await.unwrap();
7194 assert_eq!(resumed, twin2_hex, "resume past a self-seal completes, not false-terminal");
7195 assert_eq!(crate::db::community::get_migrated_to(&v1b_cid).unwrap().as_deref(), Some(twin2_hex.as_str()));
7196 }
7197
7198 #[tokio::test]
7203 async fn birth_refound_seeds_an_explicit_roster() {
7204 let (bed, owner, _m) = TestBed::new();
7205 bed.swap_to(&owner);
7206 let twin = create_migration_twin(&bed.relay, "Guild", bed.relays.clone(), None,
7207 (ChannelId(crate::community::random_32()), "general".into())).await.unwrap();
7208 assert_eq!(twin.root_epoch, Epoch(0), "twin starts at genesis");
7209 let ghost_a = Keys::generate().public_key();
7211 let ghost_b = Keys::generate().public_key();
7212
7213 let rolled = refound_at_birth(&bed.relay, &twin, &[owner.keys.public_key(), ghost_a, ghost_b]).await.unwrap();
7214 assert_eq!(rolled.root_epoch, Epoch(1), "birth refound advanced the twin to epoch 1");
7215
7216 let members = memberlist(&bed.relay, &rolled).await.unwrap();
7219 assert!(members.contains(&owner.keys.public_key()), "owner in the roster");
7220 assert!(members.contains(&ghost_a) && members.contains(&ghost_b), "never-joined members are seeded (no ghost town)");
7221
7222 let (_, _, _banlist) = verify_owner_root_and_reconcile(&bed.relay, rolled.clone()).await
7226 .expect("the epoch-1 twin verifies from its compacted control plane");
7227
7228 let again = refound_at_birth(&bed.relay, &rolled, &[owner.keys.public_key(), ghost_a, ghost_b]).await.unwrap();
7231 assert_eq!(again.root_epoch, Epoch(1), "re-running the birth refound does not advance past epoch 1");
7232 assert_eq!(crate::db::community::load_community_v2(&twin.identity.community_id).unwrap().unwrap().root_epoch, Epoch(1));
7233 }
7234
7235 #[tokio::test]
7239 async fn birth_refound_ignores_a_banned_seed_entry() {
7240 let (bed, owner, _m) = TestBed::new();
7241 bed.swap_to(&owner);
7242 let twin = create_migration_twin(&bed.relay, "Guild", bed.relays.clone(), None,
7243 (ChannelId(crate::community::random_32()), "general".into())).await.unwrap();
7244 let good = Keys::generate().public_key();
7245 let banned = Keys::generate();
7246 set_banlist(&bed.relay, &twin, &[banned.public_key().to_hex()]).await.unwrap();
7248 let twin = crate::db::community::load_community_v2(&twin.identity.community_id).unwrap().unwrap();
7249
7250 let rolled = refound_at_birth(&bed.relay, &twin, &[owner.keys.public_key(), good, banned.public_key()]).await
7251 .expect("a banned seed entry is filtered, not a permanent verify-back wedge");
7252 assert_eq!(rolled.root_epoch, Epoch(1));
7253 let members = memberlist(&bed.relay, &rolled).await.unwrap();
7254 assert!(members.contains(&good), "the non-banned seed lands");
7255 assert!(!members.contains(&banned.public_key()), "the banned seed is not a member");
7256 }
7257
7258 #[tokio::test]
7264 async fn a_seeded_member_receives_a_later_refound_rekey() {
7265 let (bed, owner, _m) = TestBed::new();
7266 bed.swap_to(&owner);
7267 let twin = create_migration_twin(&bed.relay, "Guild", bed.relays.clone(), None,
7268 (ChannelId(crate::community::random_32()), "general".into())).await.unwrap();
7269 let ghost = Keys::generate();
7270 let rolled = refound_at_birth(&bed.relay, &twin, &[owner.keys.public_key(), ghost.public_key()]).await.unwrap();
7272 assert!(memberlist(&bed.relay, &rolled).await.unwrap().contains(&ghost.public_key()), "ghost is seeded");
7273
7274 let refounded = refound_community(&bed.relay, &rolled, &[]).await.unwrap();
7280 assert_eq!(refounded.root_epoch, Epoch(2), "the later refound advanced the epoch");
7281 assert!(
7282 memberlist(&bed.relay, &refounded).await.unwrap().contains(&ghost.public_key()),
7283 "a seeded member is a recipient of + re-seeded by a later refound (never misses an epoch)"
7284 );
7285 }
7286
7287 #[tokio::test]
7290 async fn v1_admin_stays_admin_across_migration() {
7291 use crate::community::migration;
7292 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
7293 let (bed, owner, admin) = TestBed::new();
7294 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
7295
7296 bed.swap_to(&owner);
7297 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
7298 let v1_cid = v1.id.to_hex();
7299 v1.owner_attestation = Some({
7300 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
7301 .finalize(&owner.keys).unwrap().as_json()
7302 });
7303 crate::db::community::save_community(&v1).unwrap();
7304 let admin_role = Role::admin("a1".repeat(32));
7306 let roles = CommunityRoles {
7307 roles: vec![admin_role.clone()],
7308 grants: vec![MemberGrant { member: admin.keys.public_key().to_hex(), role_ids: vec![admin_role.role_id.clone()] }],
7309 };
7310 crate::db::community::set_community_roles(&v1_cid, &roles, 1_000).unwrap();
7311
7312 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
7313 let twin = crate::db::community::load_community_v2(&crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&v2_hex))).unwrap().unwrap();
7314
7315 let authority = fetch_authority(&bed.relay, &twin).await;
7317 assert!(
7318 authority.roles.is_authorized(&admin.keys.public_key().to_hex(), Some(&owner.keys.public_key().to_hex()), Permissions::MANAGE_ROLES),
7319 "the v1 admin is an admin on the v2 twin"
7320 );
7321 assert!(
7322 !authority.roles.is_authorized(&Keys::generate().public_key().to_hex(), Some(&owner.keys.public_key().to_hex()), Permissions::MANAGE_ROLES),
7323 "a non-admin gains no authority"
7324 );
7325 }
7326
7327 #[tokio::test]
7336 async fn an_unsealed_v1_that_was_migrated_away_still_heals() {
7337 use crate::community::migration;
7338 let (bed, owner, member) = TestBed::new();
7339 bed.swap_to(&owner);
7340
7341 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
7342 let v1_cid = v1.id.to_hex();
7343 v1.owner_attestation = Some({
7344 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
7345 .finalize(&owner.keys).unwrap().as_json()
7346 });
7347 crate::db::community::save_community(&v1).unwrap();
7348
7349 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
7351 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
7352
7353 bed.swap_to(&member);
7356 crate::db::community::save_community(&v1).unwrap();
7357 assert!(
7358 !crate::db::community::get_community_dissolved(&v1_cid).unwrap(),
7359 "precondition: the wedged device has NOT sealed its v1 row"
7360 );
7361
7362 assert!(
7364 crate::db::community::migration_sweep_candidates().unwrap().contains(&v1_cid),
7365 "an unsealed, migrated-away v1 must be probed"
7366 );
7367
7368 migration::sweep_dissolved_for_migration(&bed.relay).await;
7369
7370 assert!(crate::db::community::get_community_dissolved(&v1_cid).unwrap(), "sealed by the sweep");
7372 assert_eq!(
7373 crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(),
7374 Some(v2_hex.as_str()),
7375 "flipped to the same twin the first device produced"
7376 );
7377 assert!(
7378 !crate::db::community::migration_sweep_candidates().unwrap().contains(&v1_cid),
7379 "and the sweep converges — no re-probing forever"
7380 );
7381 }
7382
7383 #[tokio::test]
7388 async fn sweep_marks_checked_only_on_an_owner_tombstone() {
7389 use crate::community::migration;
7390 let (bed, owner, stranger) = TestBed::new();
7391
7392 bed.swap_to(&owner);
7393 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
7394 let v1_cid = v1.id.to_hex();
7395 v1.owner_attestation = Some({
7396 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
7397 .finalize(&owner.keys).unwrap().as_json()
7398 });
7399 crate::db::community::save_community(&v1).unwrap();
7400
7401 let inner = crate::community::roster::build_group_dissolved_edition(&stranger.keys, &v1.id, 500).unwrap();
7404 let outer = crate::community::roster::seal_dissolved_edition(&Keys::generate(), &inner, &v1.id).unwrap();
7405 bed.relay.publish_durable(&outer, &bed.relays).await.unwrap();
7406 crate::db::community::set_community_dissolved(&v1_cid).unwrap();
7407
7408 migration::sweep_dissolved_for_migration(&bed.relay).await;
7410 assert!(
7411 crate::db::community::migration_sweep_candidates().unwrap().contains(&v1_cid),
7412 "a stranger-only probe must not converge the sweep"
7413 );
7414
7415 let owner_inner = crate::community::roster::build_group_dissolved_edition(&owner.keys, &v1.id, 600).unwrap();
7417 let owner_outer = crate::community::roster::seal_dissolved_edition(&Keys::generate(), &owner_inner, &v1.id).unwrap();
7418 bed.relay.publish_durable(&owner_outer, &bed.relays).await.unwrap();
7419 migration::sweep_dissolved_for_migration(&bed.relay).await;
7420 assert!(
7421 !crate::db::community::migration_sweep_candidates().unwrap().contains(&v1_cid),
7422 "an owner plain-dissolution converges the sweep"
7423 );
7424 }
7425
7426 #[tokio::test]
7428 async fn wizard_preflight_gates_timelock_and_ownership() {
7429 use crate::community::migration;
7430 let (bed, owner, _member) = TestBed::new();
7431 bed.swap_to(&owner);
7432 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
7433 v1.owner_attestation = Some({
7434 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1.id.to_hex())
7435 .finalize(&owner.keys).unwrap().as_json()
7436 });
7437 crate::db::community::save_community(&v1).unwrap();
7438
7439 let err = migration::migrate_community_to_v2(&bed.relay, &v1, migration::MIGRATION_UNLOCK_AT - 1).await.unwrap_err();
7441 assert!(err.contains("not unlocked"), "{err}");
7442 assert!(crate::db::community::get_migration_ledger(&v1.id.to_hex()).unwrap().is_none(), "no ledger row before unlock");
7443 }
7444
7445 #[tokio::test]
7446 async fn public_link_full_loop() {
7447 let (bed, owner, member) = TestBed::new();
7448
7449 bed.swap_to(&owner);
7450 let community = create_community(&bed.relay, "Public Guild", bed.relays.clone(), None).await.unwrap();
7451 let general = community.channels[0].id;
7452 send_message(&bed.relay, &community, &general, "come on in").await.unwrap();
7453 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
7455 assert!(link.url.starts_with("https://vectorapp.io/invite/"));
7456 assert!(link.url.contains('#'), "the fragment carries the token");
7457
7458 bed.swap_to(&member);
7460 let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
7461 assert_eq!(joined.id().0, community.id().0);
7462 assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["come on in"]);
7463 }
7464
7465 #[test]
7466 fn bundle_of_snapshots_the_held_icon() {
7467 let owner = Keys::generate();
7468 let g = control::genesis(&owner, control::CommunityMetadata { name: "Logo".into(), ..Default::default() }, 1_000).unwrap();
7469 let mut c = CommunityV2::from_genesis(&g, "Logo", None, vec!["wss://r".into()], 0);
7470 let icon = control::ImageRef { url: "https://blossom.example/i".into(), key: "k".into(), nonce: "n".into(), hash: "h".into(), extra: Default::default() };
7471 c.icon = Some(icon.clone());
7472 let bundle = bundle_of(&c, BundleAudience::Link, None, None, None);
7473 assert_eq!(bundle.icon, Some(icon), "a parked invite renders the real logo from the mint-time snapshot");
7474 }
7475
7476 #[test]
7477 fn addressing_roots_fan_current_plus_archived_bounded_and_deduped() {
7478 let (_tmp, _guard, _owner) = init_test_db();
7483 let cur_root = [9u8; 32];
7484 let cid = crate::community::CommunityId([1u8; 32]);
7485 let cid_hex = cid.to_hex();
7486
7487 let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
7489 assert_eq!(roots, vec![cur_root], "with no archived roots the fan is the current root alone");
7490
7491 crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 0, &[1u8; 32]).unwrap();
7493 crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1, &[2u8; 32]).unwrap();
7494 let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
7495 assert_eq!(roots[0], cur_root, "current root leads");
7496 assert!(roots.contains(&[1u8; 32]) && roots.contains(&[2u8; 32]), "both archived roots are in the fan");
7497 assert_eq!(roots.len(), 3, "current + 2 archived, no dupes");
7498 assert_eq!(roots[1], [2u8; 32], "higher archived epoch is addressed before the lower");
7500
7501 crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 2, &cur_root).unwrap();
7503 let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
7504 assert_eq!(roots.iter().filter(|r| **r == cur_root).count(), 1, "the current root is never duplicated");
7505
7506 for e in 3..20u64 {
7508 crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, e, &[e as u8; 32]).unwrap();
7509 }
7510 let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
7511 assert_eq!(roots.len(), MAX_ADDRESSING_ROOTS, "the fan is bounded so a relay can't feed an unbounded walk");
7512 }
7513
7514 #[tokio::test]
7515 async fn public_link_preview_shows_live_name_and_icon_without_joining() {
7516 let (bed, owner, member) = TestBed::new();
7517
7518 bed.swap_to(&owner);
7519 let community = create_community(&bed.relay, "Soapbox", bed.relays.clone(), None).await.unwrap();
7520 let icon = control::ImageRef {
7523 url: "https://blossom.example/soap".into(),
7524 key: "k".into(),
7525 nonce: "n".into(),
7526 hash: "h".into(),
7527 extra: Default::default(),
7528 };
7529 let mut meta = community.metadata();
7530 meta.icon = Some(icon.clone());
7531 edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
7532 let link = mint_public_link(&bed.relay, &community, "https://armada.buzz", None, None).await.unwrap();
7534
7535 bed.swap_to(&member);
7537 let preview = preview_public_link(&bed.relay, &link.url).await.unwrap();
7538 assert_eq!(preview.name, "Soapbox");
7539 assert_eq!(preview.icon, Some(icon), "the icon folds from the live Control Plane, not the bundle");
7540 assert!(
7541 crate::db::community::load_community_v2(preview.id()).unwrap().is_none(),
7542 "previewing must not persist a membership"
7543 );
7544 }
7545
7546 #[tokio::test]
7547 async fn a_previewed_join_reuses_the_verified_fold() {
7548 let (bed, owner, member) = TestBed::new();
7549 bed.swap_to(&owner);
7550 let community = create_community(&bed.relay, "FastJoin", bed.relays.clone(), None).await.unwrap();
7551 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
7552
7553 bed.swap_to(&member);
7554 let _ = preview_public_link(&bed.relay, &link.url).await.unwrap();
7555 let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
7556 assert_eq!(joined.id().0, community.id().0);
7557 assert!(joined.created_at_ms > 0, "the handoff stamps the JOIN's acquisition time, not the preview's");
7558 assert!(VERIFIED_PREVIEW.lock().unwrap().is_none(), "the handoff slot must be consumed by the join");
7561 }
7562
7563 #[tokio::test]
7564 async fn guestbook_store_seeds_syncs_incrementally_and_matches_the_live_fold() {
7565 let (bed, owner, member) = TestBed::new();
7566 bed.swap_to(&owner);
7567 let community = create_community(&bed.relay, "GB", bed.relays.clone(), None).await.unwrap();
7568 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
7569
7570 bed.swap_to(&member);
7571 let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
7572
7573 let session = SessionGuard::capture();
7575 assert!(!sync_guestbook(&bed.relay, &joined, &session).await.unwrap().is_empty(), "the seed folds fresh events");
7576 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
7577 let (_, cursor) = crate::db::community::get_guestbook(&cid_hex).unwrap();
7578 assert!(cursor > 0, "the cursor advanced past zero");
7579 let stored: std::collections::BTreeSet<_> = stored_memberlist(&joined).unwrap().into_iter().collect();
7580 let live: std::collections::BTreeSet<_> = memberlist(&bed.relay, &joined).await.unwrap().into_iter().collect();
7581 assert_eq!(stored, live, "stored fold == live fold after the seed");
7582 assert!(stored.contains(&member.keys.public_key()));
7583
7584 assert!(sync_guestbook(&bed.relay, &joined, &session).await.unwrap().is_empty());
7586
7587 bed.swap_to(&owner);
7589 kick_member(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
7590 bed.swap_to(&member);
7591 let session = SessionGuard::capture();
7592 assert!(!sync_guestbook(&bed.relay, &joined, &session).await.unwrap().is_empty(), "the kick lands incrementally");
7593 assert!(
7594 !stored_memberlist(&joined).unwrap().contains(&member.keys.public_key()),
7595 "an owner kick removes the member from the stored fold"
7596 );
7597 }
7598
7599 #[tokio::test]
7600 async fn a_preview_then_revoke_still_refuses_the_join() {
7601 let (bed, owner, member) = TestBed::new();
7602 bed.swap_to(&owner);
7603 let community = create_community(&bed.relay, "RevokeRace", bed.relays.clone(), None).await.unwrap();
7604 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
7605
7606 bed.swap_to(&member);
7608 let p = preview_public_link(&bed.relay, &link.url).await.unwrap();
7609 assert_eq!(p.name, "RevokeRace");
7610 bed.swap_to(&owner);
7611 let tombstone = invite::build_revocation(&link.link_signer).unwrap();
7612 bed.relay.publish_durable(&tombstone, &bed.relays).await.unwrap();
7613
7614 bed.swap_to(&member);
7617 let err = accept_public_link(&bed.relay, &link.url).await.unwrap_err();
7618 assert!(err.contains("revoked"), "got: {err}");
7619 }
7620
7621 #[tokio::test]
7622 async fn a_revoked_link_refuses_to_join() {
7623 let (bed, owner, member) = TestBed::new();
7624 bed.swap_to(&owner);
7625 let community = create_community(&bed.relay, "Revoked", bed.relays.clone(), None).await.unwrap();
7626 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
7627 let tombstone = invite::build_revocation(&link.link_signer).unwrap();
7629 bed.relay.publish_durable(&tombstone, &bed.relays).await.unwrap();
7630
7631 bed.swap_to(&member);
7632 let err = accept_public_link(&bed.relay, &link.url).await.unwrap_err();
7633 assert!(err.contains("revoked"), "a retired link finds the grave, not keys: {err}");
7634 }
7635
7636 #[tokio::test]
7637 async fn an_expired_direct_invite_refuses_to_join() {
7638 let (bed, owner, member) = TestBed::new();
7639 bed.swap_to(&owner);
7640 let community = create_community(&bed.relay, "Expired", bed.relays.clone(), None).await.unwrap();
7641 let inviter = owner.keys.clone();
7643 let mut bundle = bundle_of(&community, BundleAudience::Link, Some(inviter.public_key()), Some(1_000), None);
7644 bundle.expires_at = Some(1_000); let wrap = invite::build_direct_invite(&inviter, &member.keys.public_key(), &bundle).unwrap();
7646 bed.relay.publish(&wrap, &bed.relays).await.unwrap();
7647
7648 bed.swap_to(&member);
7649 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
7650 let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
7651 assert!(err.contains("expired"), "a past-expiry invite refuses to join: {err}");
7652 }
7653
7654 #[tokio::test]
7655 async fn a_tombstone_beats_a_live_bundle_regardless_of_fetch_order() {
7656 let (bed, owner, member) = TestBed::new();
7660 bed.swap_to(&owner);
7661 let community = create_community(&bed.relay, "Rev", bed.relays.clone(), None).await.unwrap();
7662 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
7663 let tombstone = invite::build_revocation(&link.link_signer).unwrap();
7664
7665 let union = FixedFetch { events: vec![link.bundle_event.clone(), tombstone] };
7668
7669 bed.swap_to(&member);
7670 let err = accept_public_link(&union, &link.url).await.unwrap_err();
7671 assert!(err.contains("revoked"), "a tombstone must beat a Live returned first: {err}");
7672 }
7673
7674 #[test]
7675 fn from_bundle_refuses_an_over_cap_bundle_before_allocating() {
7676 let owner = Keys::generate();
7682 let identity = super::super::control::CommunityIdentity::mint(&owner.public_key());
7683 let hex = crate::simd::hex::bytes_to_hex_32;
7684 let root = [0x11u8; 32];
7685 let mut bundle = CommunityInvite {
7686 community_id: hex(&identity.community_id.0),
7687 owner: hex(&identity.owner_xonly),
7688 owner_salt: hex(&identity.owner_salt),
7689 community_root: hex(&root),
7690 root_epoch: 0,
7691 channels: vec![],
7692 relays: vec!["wss://r".into()],
7693 name: "X".into(),
7694 icon: None,
7695 expires_at: None,
7696 creator_npub: None,
7697 label: None,
7698 extra: Default::default(),
7699 };
7700 bundle.channels = (0..=invite::MAX_BUNDLE_CHANNELS)
7701 .map(|i| {
7702 let mut id = [0u8; 32];
7703 id[..8].copy_from_slice(&(i as u64).to_be_bytes());
7704 invite::ChannelGrant { id: hex(&id), key: hex(&root), epoch: 0, name: "x".into() }
7705 })
7706 .collect();
7707 assert!(CommunityV2::from_bundle(&bundle, 0).is_err(), "an over-cap bundle is refused before allocating");
7708 }
7709
7710 #[tokio::test]
7711 async fn a_join_swap_between_fetch_and_save_aborts_and_leaves_the_other_account_clean() {
7712 let (bed, owner, member) = TestBed::new();
7717 bed.swap_to(&owner);
7718 let community = create_community(&bed.relay, "Straddle", bed.relays.clone(), None).await.unwrap();
7719 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
7720 let swap_relay = SwapMidFetch { inner: MemoryRelay::new() };
7722 swap_relay.inner.publish_durable(&link.bundle_event, &bed.relays).await.unwrap();
7723
7724 bed.swap_to(&member);
7725 let err = accept_public_link(&swap_relay, &link.url).await.unwrap_err();
7726 assert!(err.contains("account changed"), "a swap mid-join must abort: {err}");
7727 assert!(
7728 crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
7729 "the aborted join wrote nothing to the (member) account DB"
7730 );
7731 }
7732
7733 #[tokio::test]
7734 async fn the_owner_is_a_member_even_without_a_fetched_genesis_join() {
7735 let (_tmp, _guard, owner) = init_test_db();
7738 let relay = MemoryRelay::new();
7739 let community = create_community(&relay, "Owned", vec!["wss://r".into()], None).await.unwrap();
7740 let empty = MemoryRelay::new();
7743 let members = memberlist(&empty, &community).await.unwrap();
7744 assert_eq!(members, vec![owner.public_key()], "owner present with no fetched Join");
7745 }
7746
7747 #[tokio::test]
7748 async fn an_expiring_minted_invite_refuses_after_the_deadline() {
7749 let (bed, owner, member) = TestBed::new();
7752 bed.swap_to(&owner);
7753 let community = create_community(&bed.relay, "Timed", bed.relays.clone(), None).await.unwrap();
7754 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), Some(1_000), Some("beta".into()))
7755 .await
7756 .unwrap();
7757
7758 bed.swap_to(&member);
7759 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
7760 assert!(
7761 accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err().contains("expired"),
7762 "a minted expiring invite refuses past its deadline"
7763 );
7764 }
7765
7766 #[tokio::test]
7767 async fn a_member_who_leaves_drops_from_the_memberlist() {
7768 let (bed, owner, member) = TestBed::new();
7769 bed.swap_to(&owner);
7770 let community = create_community(&bed.relay, "Leaving", bed.relays.clone(), None).await.unwrap();
7771 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
7772
7773 bed.swap_to(&member);
7774 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
7775 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
7776 tokio::time::sleep(std::time::Duration::from_millis(2)).await;
7778 leave_community(&bed.relay, &joined).await.unwrap();
7779
7780 bed.swap_to(&owner);
7781 let members = memberlist(&bed.relay, &community).await.unwrap();
7782 assert!(members.contains(&owner.keys.public_key()));
7783 assert!(!members.contains(&member.keys.public_key()), "a member who left drops from the list");
7784 }
7785
7786 #[tokio::test]
7787 async fn a_swapped_member_cannot_see_the_owners_community_until_joining() {
7788 let (bed, owner, member) = TestBed::new();
7791 bed.swap_to(&owner);
7792 let community = create_community(&bed.relay, "Private-so-far", bed.relays.clone(), None).await.unwrap();
7793 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_some());
7794
7795 bed.swap_to(&member);
7796 assert!(
7797 crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
7798 "the owner's community must be invisible in the member's account DB"
7799 );
7800 assert_eq!(crate::db::community::list_community_ids().unwrap().len(), 0);
7801 }
7802
7803 async fn head_hash_on_relay(relay: &MemoryRelay, community: &CommunityV2, entity_id: &[u8; 32]) -> Option<[u8; 32]> {
7811 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
7812 let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
7813 let wraps = relay.fetch(&query, &community.relays).await.ok()?;
7814 let mut head: Option<(u64, [u8; 32])> = None;
7815 for w in &wraps {
7816 if let Ok((ed, _)) = control::open_control_edition(w, &group) {
7817 if ed.entity_id == *entity_id && head.is_none_or(|(v, _)| ed.version > v) {
7818 head = Some((ed.version, ed.self_hash));
7819 }
7820 }
7821 }
7822 head.map(|(_, h)| h)
7823 }
7824
7825 async fn cite_on_relay(
7830 relay: &MemoryRelay,
7831 community: &CommunityV2,
7832 signer: &Keys,
7833 ) -> Option<crate::community::edition::AuthorityCitation> {
7834 if community.owner().ok() == Some(signer.public_key()) {
7835 return None;
7836 }
7837 let entity_id = crate::community::v2::derive::grant_locator(community.id(), &signer.public_key().to_bytes());
7838 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
7839 let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
7840 let wraps = relay.fetch(&query, &community.relays).await.ok()?;
7841 let mut head: Option<(u64, [u8; 32])> = None;
7842 for w in &wraps {
7843 if let Ok((ed, _)) = control::open_control_edition(w, &group) {
7844 if ed.entity_id == entity_id && head.is_none_or(|(v, _)| ed.version > v) {
7845 head = Some((ed.version, ed.self_hash));
7846 }
7847 }
7848 }
7849 head.map(|(version, edition_hash)| crate::community::edition::AuthorityCitation { entity_id, version, edition_hash })
7850 }
7851
7852 async fn publish_channel_edition(
7853 relay: &MemoryRelay,
7854 community: &CommunityV2,
7855 signer: &Keys,
7856 channel_id: &ChannelId,
7857 name: &str,
7858 private: bool,
7859 version: u64,
7860 deleted: bool,
7861 ) {
7862 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
7863 let prev = head_hash_on_relay(relay, community, &channel_id.0).await;
7864 let meta = control::ChannelMetadata { name: name.into(), private, deleted: deleted.then_some(true), ..Default::default() };
7865 let content = serde_json::to_string(&meta).unwrap();
7866 let rumor = control::build_edition_rumor(signer.public_key(), vsk::CHANNEL_METADATA, &channel_id.0, version, prev.as_ref(), &content, 1_000, None);
7867 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
7868 relay.publish(&wrap, &community.relays).await.unwrap();
7869 }
7870
7871 async fn publish_community_meta(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, name: &str, version: u64) {
7874 publish_community_meta_at(relay, community, signer, name, version, 1_000).await;
7875 }
7876
7877 async fn publish_community_meta_at(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, name: &str, version: u64, at_secs: u64) {
7880 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
7881 let prev = head_hash_on_relay(relay, community, &community.id().0).await;
7882 let meta = control::CommunityMetadata { name: name.into(), ..Default::default() };
7883 let content = serde_json::to_string(&meta).unwrap();
7884 let cite = cite_on_relay(relay, community, signer).await;
7885 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());
7886 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(at_secs)).unwrap();
7887 relay.publish(&wrap, &community.relays).await.unwrap();
7888 }
7889
7890 #[test]
7891 fn metadata_apply_captures_undriven_fields_for_republish() {
7892 let owner = Keys::generate();
7893 let g = control::genesis(&owner, control::CommunityMetadata { name: "A".into(), ..Default::default() }, 1_000).unwrap();
7894 let mut held = CommunityV2::from_genesis(&g, "A", None, vec!["wss://r".into()], 0);
7895 let general = held.channels[0].id;
7896
7897 let mut custom = serde_json::Map::new();
7899 custom.insert("accent".into(), serde_json::Value::from("#89f0b6"));
7900 let mut extra = serde_json::Map::new();
7901 extra.insert("vnd_flag".into(), serde_json::Value::Bool(true));
7902 let meta = control::CommunityMetadata { name: "A".into(), custom: Some(custom.clone()), extra: extra.clone(), ..Default::default() };
7903 assert!(apply_community_metadata(&mut held, meta), "gaining custom/extra is a change");
7904 assert_eq!(held.meta_custom, Some(custom.clone()));
7905 assert_eq!(held.meta_extra, extra);
7906 assert_eq!(held.metadata().custom, Some(custom));
7908 assert_eq!(held.metadata().extra, held.meta_extra);
7909
7910 let mut ch_custom = serde_json::Map::new();
7912 ch_custom.insert("slowmode".into(), serde_json::Value::from(30));
7913 let ch_meta = control::ChannelMetadata {
7914 name: "general".into(),
7915 private: false,
7916 voice: Some(true),
7917 deleted: None,
7918 custom: Some(ch_custom.clone()),
7919 extra: Default::default(),
7920 };
7921 assert!(apply_channel_metadata(&mut held, general, ch_meta), "gaining voice/custom is a change");
7922 let ch = held.channel(&general).unwrap();
7923 assert_eq!(ch.voice, Some(true));
7924 assert_eq!(ch.meta_custom, Some(ch_custom.clone()));
7925 let rename = { let mut d = ch.metadata(); d.name = "lounge".into(); d };
7926 assert_eq!(rename.voice, Some(true), "our rename edition carries the foreign voice flag");
7927 assert_eq!(rename.custom, Some(ch_custom));
7928 }
7929
7930 #[test]
7931 fn community_metadata_apply_sets_and_clears_images() {
7932 let owner = Keys::generate();
7933 let g = control::genesis(&owner, control::CommunityMetadata { name: "A".into(), ..Default::default() }, 1_000).unwrap();
7934 let mut held = CommunityV2::from_genesis(&g, "A", None, vec!["wss://r".into()], 0);
7935
7936 let icon = control::ImageRef {
7937 url: "https://blossom.example/i".into(),
7938 key: "k".into(),
7939 nonce: "n".into(),
7940 hash: "h".into(),
7941 extra: Default::default(),
7942 };
7943 let with_icon = control::CommunityMetadata { name: "A".into(), icon: Some(icon.clone()), ..Default::default() };
7944 assert!(apply_community_metadata(&mut held, with_icon), "gaining an icon is a change");
7945 assert_eq!(held.icon.as_ref(), Some(&icon));
7946
7947 let without = control::CommunityMetadata { name: "A".into(), ..Default::default() };
7949 assert!(apply_community_metadata(&mut held, without), "losing the icon is a change");
7950 assert_eq!(held.icon, None);
7951 }
7952
7953 async fn publish_role(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, role: &Role, version: u64) {
7955 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
7956 let role_id = crate::simd::hex::hex_to_bytes_32_checked(&role.role_id).unwrap();
7957 let prev = head_hash_on_relay(relay, community, &role_id).await;
7958 let content = crate::community::v2::roles::role_content_json(role).unwrap();
7959 let cite = cite_on_relay(relay, community, signer).await;
7960 let rumor = control::build_edition_rumor(signer.public_key(), vsk::ROLE, &role_id, version, prev.as_ref(), &content, 1_000, cite.as_ref());
7961 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
7962 relay.publish(&wrap, &community.relays).await.unwrap();
7963 }
7964
7965 async fn publish_grant(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, member: &PublicKey, role_ids: Vec<String>, version: u64) {
7967 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
7968 let eid = crate::community::v2::derive::grant_locator(community.id(), &member.to_bytes());
7969 let prev = head_hash_on_relay(relay, community, &eid).await;
7970 let grant = MemberGrant { member: member.to_hex(), role_ids };
7971 let content = crate::community::v2::roles::grant_content_json(&grant).unwrap();
7972 let cite = cite_on_relay(relay, community, signer).await;
7973 let rumor = control::build_edition_rumor(signer.public_key(), vsk::GRANT, &eid, version, prev.as_ref(), &content, 1_000, cite.as_ref());
7974 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
7975 relay.publish(&wrap, &community.relays).await.unwrap();
7976 }
7977
7978 async fn publish_banlist(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, banned: &[String], version: u64) {
7980 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
7981 let eid = crate::community::v2::derive::banlist_locator(community.id());
7982 let prev = head_hash_on_relay(relay, community, &eid).await;
7983 let content = crate::community::v2::roles::banlist_content_json(banned).unwrap();
7984 let cite = cite_on_relay(relay, community, signer).await;
7985 let rumor = control::build_edition_rumor(signer.public_key(), vsk::BANLIST, &eid, version, prev.as_ref(), &content, 1_000, cite.as_ref());
7986 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
7987 relay.publish(&wrap, &community.relays).await.unwrap();
7988 }
7989
7990 fn admin_role(role_id: &str, perms: u64) -> Role {
7991 Role { role_id: role_id.into(), name: "Admin".into(), position: 1, permissions: Permissions(perms), scope: RoleScope::Server, color: 0 }
7992 }
7993
7994 #[tokio::test]
8000 async fn a_non_owner_cannot_suppress_the_admin_role_by_forging_a_higher_version() {
8001 let (bed, owner, attacker) = TestBed::new();
8002 bed.swap_to(&owner);
8003 let community = create_community(&bed.relay, "AttackA", bed.relays.clone(), None).await.unwrap();
8004 let victim = Keys::generate().public_key();
8005 grant_admin(&bed.relay, &community, &victim).await.unwrap();
8006
8007 let admin_rid = fetch_authority(&bed.relay, &community)
8009 .await
8010 .roles
8011 .roles
8012 .iter()
8013 .find(|r| r.permissions.contains(Permissions::ADMIN_ALL))
8014 .unwrap()
8015 .role_id
8016 .clone();
8017 publish_role(
8019 &bed.relay,
8020 &community,
8021 &attacker.keys,
8022 &Role { role_id: admin_rid.clone(), name: "pwned".into(), position: 1, permissions: Permissions(0), scope: RoleScope::Server, color: 0 },
8023 2,
8024 )
8025 .await;
8026
8027 let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
8028 assert!(authority.roles.is_admin(&victim.to_hex()), "the forged strip is DROPPED; the owner's admin role survives beneath it");
8029 assert!(
8030 authority.heads.iter().any(|h| h.entity_hex == admin_rid && h.version == 1),
8031 "the floor advances only to the AUTHORIZED head (owner v1)"
8032 );
8033 assert!(!authority.heads.iter().any(|h| h.version == 2), "the forged v2 never poisons the floor");
8034 }
8035
8036 #[tokio::test]
8037 async fn a_non_owner_cannot_strip_a_members_grant_by_forging_a_higher_version() {
8038 let (bed, owner, attacker) = TestBed::new();
8039 bed.swap_to(&owner);
8040 let community = create_community(&bed.relay, "AttackC", bed.relays.clone(), None).await.unwrap();
8041 let victim = Keys::generate();
8042 grant_admin(&bed.relay, &community, &victim.public_key()).await.unwrap();
8043
8044 publish_grant(&bed.relay, &community, &attacker.keys, &victim.public_key(), vec![], 9).await;
8046
8047 let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
8048 assert!(
8049 authority.roles.is_admin(&victim.public_key().to_hex()),
8050 "the forged strip is dropped; the owner's grant survives and the victim keeps admin"
8051 );
8052 }
8053
8054 #[tokio::test]
8055 async fn forged_low_id_roles_by_a_non_owner_never_enter_the_authorized_roster() {
8056 let (bed, owner, attacker) = TestBed::new();
8057 bed.swap_to(&owner);
8058 let community = create_community(&bed.relay, "AttackB", bed.relays.clone(), None).await.unwrap();
8059 let victim = Keys::generate().public_key();
8060 grant_admin(&bed.relay, &community, &victim).await.unwrap();
8061
8062 for i in 0u8..6 {
8065 let rid = crate::simd::hex::bytes_to_hex_32(&[i; 32]);
8066 publish_role(&bed.relay, &community, &attacker.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
8067 }
8068
8069 let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
8070 assert!(authority.roles.is_admin(&victim.to_hex()), "the legit admin survives the forged flood");
8071 assert_eq!(authority.roles.roles.len(), 1, "only the owner's admin role is authorized; every forgery is dropped");
8072 }
8073
8074 fn authority_fingerprint(a: &AuthoritySet) -> String {
8077 let mut roles = a.roles.roles.clone();
8078 roles.sort_by(|x, y| x.role_id.cmp(&y.role_id));
8079 let mut grants = a.roles.grants.clone();
8080 for g in &mut grants {
8081 g.role_ids.sort();
8082 }
8083 grants.sort_by(|x, y| x.member.cmp(&y.member));
8084 let banned: Vec<&String> = a.banned.iter().collect();
8085 serde_json::json!({ "roles": roles, "grants": grants, "banned": banned }).to_string()
8086 }
8087
8088 #[tokio::test]
8089 async fn the_v2_authority_fold_is_order_independent() {
8090 let (bed, owner, _a) = TestBed::new();
8096 bed.swap_to(&owner);
8097 let community = create_community(&bed.relay, "Determinism", bed.relays.clone(), None).await.unwrap();
8098
8099 let admin1 = Keys::generate().public_key();
8102 let admin2 = Keys::generate().public_key();
8103 grant_admin(&bed.relay, &community, &admin1).await.unwrap();
8104 grant_admin(&bed.relay, &community, &admin2).await.unwrap();
8105 let mod_rid = "5c".repeat(32);
8106 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&mod_rid, Permissions::KICK | Permissions::MANAGE_MESSAGES), 1).await;
8107 let member = Keys::generate().public_key();
8108 publish_grant(&bed.relay, &community, &owner.keys, &member, vec![mod_rid.clone()], 1).await;
8109 let banned_member = Keys::generate().public_key();
8110 publish_grant(&bed.relay, &community, &owner.keys, &banned_member, vec![mod_rid], 1).await;
8111 set_banlist(&bed.relay, &community, &[banned_member.to_hex()]).await.unwrap();
8112 let meta = control::CommunityMetadata { name: "Renamed".into(), relays: community.relays.clone(), ..Default::default() };
8113 edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
8114 create_public_channel(&bed.relay, &community, "extra").await.unwrap();
8115
8116 let editions = fetch_control(&bed.relay, &community).await;
8117 let floors = load_floors(&community);
8118 assert!(editions.len() >= 6, "a rich plane was built ({} editions)", editions.len());
8119
8120 let baseline = authority_fingerprint(&fold_authority(&community, &editions, &floors));
8121
8122 let mut orders: Vec<Vec<ParsedEdition>> = Vec::new();
8125 let mut rev = editions.clone();
8126 rev.reverse();
8127 orders.push(rev);
8128 for shift in [1usize, 3, 5, 7] {
8129 let n = editions.len();
8130 orders.push((0..n).map(|i| editions[(i + shift) % n].clone()).collect());
8131 }
8132 let mut zip = Vec::with_capacity(editions.len());
8134 let (mut lo, mut hi) = (0isize, editions.len() as isize - 1);
8135 while lo <= hi {
8136 zip.push(editions[lo as usize].clone());
8137 if lo != hi {
8138 zip.push(editions[hi as usize].clone());
8139 }
8140 lo += 1;
8141 hi -= 1;
8142 }
8143 orders.push(zip);
8144
8145 for (i, order) in orders.iter().enumerate() {
8146 let got = authority_fingerprint(&fold_authority(&community, order, &floors));
8147 assert_eq!(got, baseline, "arrival order #{i} must resolve the identical authority (consensus)");
8148 }
8149 assert!(baseline.contains(&admin1.to_hex()) || baseline.contains(&member.to_hex()), "grants are present in the fingerprint");
8152 assert!(baseline.contains(&banned_member.to_hex()), "the banlist entry is in the fingerprint");
8153 }
8154
8155 struct FetchErrors(MemoryRelay);
8157 #[async_trait::async_trait]
8158 impl crate::community::transport::Transport for FetchErrors {
8159 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
8160 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
8161 self.0.publish(e, r).await
8162 }
8163 async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
8164 Err("relay down".to_string())
8165 }
8166 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
8167 self.0.publish_durable(e, r).await
8168 }
8169 }
8170
8171 #[tokio::test]
8172 async fn fetch_authority_retains_the_persisted_banlist_on_a_transport_error() {
8173 let (bed, owner, victim) = TestBed::new();
8174 bed.swap_to(&owner);
8175 let community = create_community(&bed.relay, "BanRetain", bed.relays.clone(), None).await.unwrap();
8176 let victim_hex = victim.keys.public_key().to_hex();
8177 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
8179 crate::db::community::set_community_banlist(&cid_hex, &[victim_hex.clone()], 1).unwrap();
8180
8181 let down = FetchErrors(MemoryRelay::new());
8184 let view = fetch_authority(&down, &community).await;
8185 assert!(view.banned.contains(&victim_hex), "a transport error retains the persisted banlist");
8186 }
8187
8188 #[tokio::test]
8189 async fn follow_control_retains_the_roster_when_a_floored_role_ages_out() {
8190 let (bed, owner, _m) = TestBed::new();
8191 bed.swap_to(&owner);
8192 let community = create_community(&bed.relay, "Complete", bed.relays.clone(), None).await.unwrap();
8193 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
8194 let (a, b) = (Keys::generate().public_key(), Keys::generate().public_key());
8195 let rid = crate::simd::hex::bytes_to_hex_32(&[0x7c; 32]);
8196
8197 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
8199 publish_grant(&bed.relay, &community, &owner.keys, &a, vec![rid.clone()], 1).await;
8200 publish_grant(&bed.relay, &community, &owner.keys, &b, vec![rid.clone()], 1).await;
8201 let session = crate::state::SessionGuard::capture();
8202 follow_control(&bed.relay, &community, &session).await.unwrap();
8203 assert!(crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&a.to_hex()), "seeded");
8204
8205 let relay2 = MemoryRelay::new();
8209 publish_grant(&relay2, &community, &owner.keys, &a, vec![rid.clone()], 1).await;
8210 follow_control(&relay2, &community, &session).await.unwrap();
8211 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
8212 assert!(roster.is_admin(&a.to_hex()) && roster.is_admin(&b.to_hex()), "a floored-but-unfetched role retains the stored roster");
8213 }
8214
8215 #[tokio::test]
8216 async fn an_uncited_metadata_or_banlist_edition_is_dropped() {
8217 let (_tmp, _guard, owner) = init_test_db();
8222 let relay = MemoryRelay::new();
8223 let community = create_community(&relay, "Base", vec!["wss://r".into()], None).await.unwrap();
8224 let admin = Keys::generate();
8225 let rid = "a7".repeat(32);
8226 publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA | Permissions::BAN), 1).await;
8227 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid], 1).await;
8228
8229 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
8231 let meta = control::CommunityMetadata { name: "Uncited Rename".into(), ..Default::default() };
8232 let rumor = control::build_edition_rumor(
8233 admin.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2,
8234 head_hash_on_relay(&relay, &community, &community.id().0).await.as_ref(),
8235 &serde_json::to_string(&meta).unwrap(), 1_000, None,
8236 );
8237 let (wrap, _) = control::seal_control_edition(&rumor, &group, &admin, Timestamp::from_secs(1_000)).unwrap();
8238 relay.publish(&wrap, &community.relays).await.unwrap();
8239
8240 let ban_eid = crate::community::v2::derive::banlist_locator(community.id());
8241 let victim = Keys::generate().public_key().to_hex();
8242 let ban_rumor = control::build_edition_rumor(
8243 admin.public_key(), vsk::BANLIST, &ban_eid, 1, None,
8244 &serde_json::to_string(&vec![victim.clone()]).unwrap(), 1_000, None,
8245 );
8246 let (ban_wrap, _) = control::seal_control_edition(&ban_rumor, &group, &admin, Timestamp::from_secs(1_000)).unwrap();
8247 relay.publish(&ban_wrap, &community.relays).await.unwrap();
8248
8249 let session = SessionGuard::capture();
8250 let updated = follow_control(&relay, &community, &session).await.unwrap();
8251 assert!(
8252 updated.as_ref().is_none_or(|c| c.name != "Uncited Rename"),
8253 "an uncited metadata edit must not be honored",
8254 );
8255 let authority = fetch_authority(&relay, &community).await;
8256 assert!(!authority.banned.contains(&victim), "an uncited banlist edition must not be honored");
8257 }
8264
8265 #[tokio::test]
8266 async fn an_authorized_admin_edits_metadata_but_a_demoted_one_cannot() {
8267 let (_tmp, _guard, owner) = init_test_db();
8271 let relay = MemoryRelay::new();
8272 let community = create_community(&relay, "Base", vec!["wss://r".into()], None).await.unwrap();
8273 let admin = Keys::generate();
8274 let rid = "a1".repeat(32);
8275 publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
8276 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
8277 publish_community_meta(&relay, &community, &admin, "Admin Rename", 2).await;
8278
8279 let session = SessionGuard::capture();
8280 let updated = follow_control(&relay, &community, &session).await.unwrap().expect("admin edit authorized");
8281 assert_eq!(updated.name, "Admin Rename", "an admin with MANAGE_METADATA renames");
8282
8283 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![], 2).await; publish_community_meta(&relay, &community, &admin, "Demoted Rename", 3).await;
8285 let _ = follow_control(&relay, &community, &session).await.unwrap();
8286 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8287 assert_eq!(held.name, "Admin Rename", "a demoted admin's edit is dropped; the name holds");
8288 }
8289
8290 #[tokio::test]
8291 async fn a_roleless_member_cannot_edit_metadata() {
8292 let (_tmp, _guard, _owner) = init_test_db();
8293 let relay = MemoryRelay::new();
8294 let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
8295 let stranger = Keys::generate();
8296 publish_community_meta(&relay, &community, &stranger, "Hijacked", 2).await;
8297 let session = SessionGuard::capture();
8298 assert!(
8299 follow_control(&relay, &community, &session).await.unwrap().is_none(),
8300 "a roleless member's metadata edit never folds"
8301 );
8302 }
8303
8304 #[tokio::test]
8305 async fn a_self_signed_grant_is_not_authority() {
8306 let (_tmp, _guard, _owner) = init_test_db();
8310 let relay = MemoryRelay::new();
8311 let community = create_community(&relay, "NoSelfPromo", vec!["wss://r".into()], None).await.unwrap();
8312 let rogue = Keys::generate();
8313 let rid = "b2".repeat(32);
8314 publish_role(&relay, &community, &rogue, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
8315 publish_grant(&relay, &community, &rogue, &rogue.public_key(), vec![rid.clone()], 1).await;
8316 publish_community_meta(&relay, &community, &rogue, "Seized", 2).await;
8317 let session = SessionGuard::capture();
8318 assert!(
8319 follow_control(&relay, &community, &session).await.unwrap().is_none(),
8320 "a self-signed grant confers no authority"
8321 );
8322 }
8323
8324 #[tokio::test]
8325 async fn the_banlist_is_enforced_only_from_a_ban_holder() {
8326 let (_tmp, _guard, owner) = init_test_db();
8327 let relay = MemoryRelay::new();
8328 let community = create_community(&relay, "Bans", vec!["wss://r".into()], None).await.unwrap();
8329 let target = "cc".repeat(32);
8330
8331 let rogue = Keys::generate();
8333 publish_banlist(&relay, &community, &rogue, &[target.clone()], 1).await;
8334 let floors = load_floors(&community);
8335 let editions = fetch_control(&relay, &community).await;
8336 let authority = fold_authority(&community, &editions, &floors);
8337 assert!(authority.banned.is_empty(), "a non-owner (no BAN) banlist is not enforced");
8338
8339 publish_banlist(&relay, &community, &owner, &[target.clone()], 2).await;
8341 let editions = fetch_control(&relay, &community).await;
8342 let authority = fold_authority(&community, &editions, &floors);
8343 assert!(authority.banned.contains(&target), "the owner's banlist is enforced");
8344 }
8345
8346 #[tokio::test]
8347 async fn a_banned_admin_loses_all_authority() {
8348 let (_tmp, _guard, owner) = init_test_db();
8351 let relay = MemoryRelay::new();
8352 let community = create_community(&relay, "BanAuth", vec!["wss://r".into()], None).await.unwrap();
8353 let admin = Keys::generate();
8354 let rid = "e5".repeat(32);
8355 publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
8356 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
8357 publish_banlist(&relay, &community, &owner, &[admin.public_key().to_hex()], 1).await; publish_community_meta(&relay, &community, &admin, "Banned Rename", 2).await;
8359
8360 let session = SessionGuard::capture();
8361 assert!(
8362 follow_control(&relay, &community, &session).await.unwrap().is_none(),
8363 "a banned admin's edit is dropped even with an unstripped grant"
8364 );
8365 let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
8366 assert!(authority.banned.contains(&admin.public_key().to_hex()));
8367 assert!(
8368 !authority.roles.is_authorized(&admin.public_key().to_hex(), Some(&owner.public_key().to_hex()), Permissions::MANAGE_METADATA),
8369 "a banned admin holds no bit"
8370 );
8371 }
8372
8373 #[tokio::test]
8374 async fn a_ban_holder_cannot_ban_a_superior_or_the_owner() {
8375 let (_tmp, _guard, owner) = init_test_db();
8379 let relay = MemoryRelay::new();
8380 let community = create_community(&relay, "Ranks", vec!["wss://r".into()], None).await.unwrap();
8381 let admin = Keys::generate();
8382 let moder = Keys::generate();
8383 let stranger = Keys::generate();
8384 let (admin_rid, mod_rid) = ("a1".repeat(32), "b2".repeat(32));
8385 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;
8386 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;
8387 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![admin_rid], 1).await;
8388 publish_grant(&relay, &community, &owner, &moder.public_key(), vec![mod_rid], 1).await;
8389 publish_banlist(&relay, &community, &moder, &[admin.public_key().to_hex(), owner.public_key().to_hex(), stranger.public_key().to_hex()], 1).await;
8390
8391 let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
8392 assert!(!authority.banned.contains(&admin.public_key().to_hex()), "a mod cannot ban a superior admin");
8393 assert!(!authority.banned.contains(&owner.public_key().to_hex()), "nobody can ban the owner");
8394 assert!(authority.banned.contains(&stranger.public_key().to_hex()), "the mod CAN ban a lower-ranked member");
8395 }
8396
8397 #[tokio::test]
8398 async fn an_unauthorized_higher_banlist_cannot_unban() {
8399 let (_tmp, _guard, owner) = init_test_db();
8403 let relay = MemoryRelay::new();
8404 let community = create_community(&relay, "NoUnban", vec!["wss://r".into()], None).await.unwrap();
8405 let target = "cc".repeat(32);
8406 publish_banlist(&relay, &community, &owner, &[target.clone()], 1).await;
8407 let session = SessionGuard::capture();
8408 follow_control(&relay, &community, &session).await.unwrap(); let rogue = Keys::generate();
8411 publish_banlist(&relay, &community, &rogue, &[], 2).await; let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
8413 assert!(authority.banned.contains(&target), "an unauthorized higher banlist cannot un-ban");
8414 }
8415
8416 #[tokio::test]
8417 async fn the_community_list_syncs_a_membership_to_a_fresh_device() {
8418 let (_tmp, _guard, _owner) = init_test_db();
8421 let relay = MemoryRelay::new();
8422 let relays = vec!["wss://r".to_string()];
8423 let community = create_community(&relay, "Synced", relays.clone(), None).await.unwrap();
8424 crate::db::community::delete_community(&crate::simd::hex::bytes_to_hex_32(&community.id().0)).unwrap();
8425 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none());
8426
8427 let rehydrated = sync_community_list(&relay, &relays).await.unwrap().joined;
8428 assert_eq!(rehydrated.len(), 1, "the left-behind membership rehydrates");
8429 assert_eq!(rehydrated[0].id().0, community.id().0);
8430 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_some(), "and is now held locally");
8431 }
8432
8433 #[tokio::test]
8434 async fn a_leave_tombstones_the_membership_so_sync_does_not_rejoin() {
8435 let (_tmp, _guard, _owner) = init_test_db();
8436 let relay = MemoryRelay::new();
8437 let relays = vec!["wss://r".to_string()];
8438 let community = create_community(&relay, "Left", relays.clone(), None).await.unwrap();
8439 leave_community(&relay, &community).await.unwrap(); let rehydrated = sync_community_list(&relay, &relays).await.unwrap().joined;
8442 assert!(rehydrated.is_empty(), "a tombstoned membership is not rejoined on sync");
8443 }
8444
8445 #[tokio::test]
8446 async fn accepting_the_same_bundle_twice_is_idempotent() {
8447 let (bed, owner, member) = TestBed::new();
8451 bed.swap_to(&owner);
8452 let community = create_community(&bed.relay, "Idem", bed.relays.clone(), None).await.unwrap();
8453 create_public_channel(&bed.relay, &community, "extra").await.unwrap();
8454 let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8455 let bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
8456
8457 bed.swap_to(&member);
8458 let first = accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
8459 let channels_after_first = first.channels.len();
8460 let root_after_first = first.community_root;
8461
8462 let second = accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
8464 assert_eq!(second.id().0, first.id().0, "same community_id");
8465 assert_eq!(second.channels.len(), channels_after_first, "no duplicate channels on re-accept");
8466 assert_eq!(second.community_root, root_after_first, "root unchanged");
8467
8468 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8470 assert_eq!(reloaded.channels.len(), channels_after_first, "the DB holds one clean channel set");
8471 assert_eq!(crate::db::community::list_community_ids().unwrap().iter().filter(|id| id.0 == community.id().0).count(), 1, "exactly one community row");
8472 }
8473
8474 #[tokio::test]
8475 async fn a_severed_member_can_be_unbanned_and_re_admitted() {
8476 let (bed, owner, member) = TestBed::new();
8481 bed.swap_to(&owner);
8482 let mut community = create_community(&bed.relay, "Redeemable", bed.relays.clone(), None).await.unwrap();
8483 let general = community.channels[0].id;
8484 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
8485 send_message(&bed.relay, &community, &general, "owner: welcome").await.unwrap();
8486
8487 bed.swap_to(&member);
8488 let invite = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
8489 let joined = accept_direct_invite(&bed.relay, &invite).await.unwrap();
8490 assert!(texts_in(&bed.relay, &joined, &general).await.contains(&"owner: welcome".to_string()));
8491
8492 bed.swap_to(&owner);
8494 set_banlist(&bed.relay, &community, &[member.keys.public_key().to_hex()]).await.unwrap();
8495 grant_roles(&bed.relay, &community, &member.keys.public_key(), vec![]).await.unwrap();
8496 community = refound_community(&bed.relay, &community, &[member.keys.public_key()]).await.unwrap();
8497 assert_eq!(community.root_epoch, Epoch(1));
8498 send_message(&bed.relay, &community, &general, "owner: after the ban").await.unwrap();
8499
8500 bed.swap_to(&member);
8502 let session = SessionGuard::capture();
8503 assert!(follow_rekeys(&bed.relay, &joined, &session).await.unwrap().self_removed, "the member is cryptographically severed");
8504
8505 bed.swap_to(&owner);
8509 set_banlist(&bed.relay, &community, &[]).await.unwrap();
8510 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8511 assert_eq!(community.root_epoch, Epoch(1), "the owner's bundle carries epoch 1");
8512 let fresh_bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
8513
8514 bed.swap_to(&member);
8516 let rejoined = accept_parked_invite(&bed.relay, &fresh_bundle, None).await.unwrap();
8517 assert_eq!(rejoined.root_epoch, Epoch(1), "rejoined at the current epoch");
8518 assert_eq!(rejoined.community_root, community.community_root, "holds the NEW root");
8519 let seen = texts_in(&bed.relay, &rejoined, &general).await;
8520 assert!(seen.contains(&"owner: after the ban".to_string()), "reads post-ban history with the new root");
8521 send_message(&bed.relay, &rejoined, &general, "member: i am back").await.unwrap();
8522
8523 bed.swap_to(&owner);
8524 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8525 assert!(
8526 texts_in(&bed.relay, &community, &general).await.contains(&"member: i am back".to_string()),
8527 "the re-admitted member converses again at the new epoch"
8528 );
8529 let members = memberlist(&bed.relay, &community).await.unwrap();
8531 assert!(members.contains(&member.keys.public_key()), "the re-admitted member is in the list");
8532 }
8533
8534 #[tokio::test]
8535 async fn dissolution_blocks_a_join() {
8536 let (bed, owner, member) = TestBed::new();
8539 bed.swap_to(&owner);
8540 let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
8541 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
8542 let bundle_json = serde_json::to_string(&bundle).unwrap();
8543 dissolve_community(&bed.relay, &community).await.unwrap();
8544 assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the owner's local hold is sealed");
8545
8546 bed.swap_to(&member);
8547 let err = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap_err();
8548 assert!(err.contains("dissolved"), "a join refuses a dissolved community: {err}");
8549 }
8550
8551 #[tokio::test]
8552 async fn dissolution_seals_writes_but_not_reads() {
8553 let (bed, owner, _member) = TestBed::new();
8556 bed.swap_to(&owner);
8557 let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
8558 let general = community.channels[0].id;
8559 send_message(&bed.relay, &community, &general, "before the end").await.unwrap();
8560
8561 dissolve_community(&bed.relay, &community).await.unwrap();
8562 let sealed = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8563
8564 for err in [
8565 send_message(&bed.relay, &sealed, &general, "after the end").await.unwrap_err(),
8566 send_reaction(&bed.relay, &sealed, &general, &"a".repeat(64), &"b".repeat(64), crate::community::v2::kind::MESSAGE, "+", None)
8567 .await
8568 .unwrap_err(),
8569 send_edit(&bed.relay, &sealed, &general, &"a".repeat(64), "revised").await.unwrap_err(),
8570 ] {
8571 assert!(err.contains("dissolved"), "every write is refused, got: {err}");
8572 }
8573 assert!(
8574 texts_in(&bed.relay, &sealed, &general).await.contains(&"before the end".to_string()),
8575 "but the history still reads"
8576 );
8577 }
8578
8579 #[tokio::test]
8580 async fn only_the_owner_can_dissolve() {
8581 let (bed, owner, member) = TestBed::new();
8582 bed.swap_to(&owner);
8583 let community = create_community(&bed.relay, "Mine", bed.relays.clone(), None).await.unwrap();
8584 bed.swap_to(&member);
8585 assert!(dissolve_community(&bed.relay, &community).await.is_err(), "only the owner can dissolve");
8586 assert!(!is_dissolved(&bed.relay, &community).await, "and no tombstone was published");
8587 }
8588
8589 #[tokio::test]
8590 async fn a_foreign_tombstone_is_not_death() {
8591 let (_tmp, _guard, _owner) = init_test_db();
8594 let relay = MemoryRelay::new();
8595 let community = create_community(&relay, "Safe", vec!["wss://r".into()], None).await.unwrap();
8596 let rogue = Keys::generate();
8597 let rumor = crate::community::v2::dissolution::dissolved_tombstone_rumor(rogue.public_key(), community.id(), 1_000);
8598 let wrap = crate::community::v2::dissolution::seal_dissolved(&rumor, community.id(), &rogue, Timestamp::from_secs(1_000)).unwrap();
8599 relay.publish(&wrap, &community.relays).await.unwrap();
8600 assert!(!is_dissolved(&relay, &community).await, "a foreign-signed tombstone is not death");
8601 }
8602
8603 #[tokio::test]
8604 async fn a_public_channel_reads_history_across_a_refounding() {
8605 let (_tmp, _guard, _owner) = init_test_db();
8609 let relay = MemoryRelay::new();
8610 let community = create_community(&relay, "History", vec!["wss://r".into()], None).await.unwrap();
8611 let general = community.channels[0].id;
8612 send_message(&relay, &community, &general, "before the refounding").await.unwrap();
8613
8614 let refounded = refound_community(&relay, &community, &[]).await.unwrap();
8615 assert_eq!(refounded.root_epoch, Epoch(1), "the epoch advanced");
8616 send_message(&relay, &refounded, &general, "after the refounding").await.unwrap();
8617
8618 let texts = texts_in(&relay, &refounded, &general).await;
8619 assert!(texts.contains(&"before the refounding".to_string()), "the epoch-0 message is still readable");
8620 assert!(texts.contains(&"after the refounding".to_string()), "the epoch-1 message reads too");
8621 }
8622
8623 #[tokio::test]
8624 async fn refounding_aborts_when_control_state_is_withheld() {
8625 let (_tmp, _guard, owner) = init_test_db();
8629 let relay = MemoryRelay::new();
8630 let community = create_community(&relay, "Withheld", vec!["wss://good".into()], None).await.unwrap();
8631 publish_banlist(&relay, &community, &owner, &["cc".repeat(32)], 1).await;
8632 let session = SessionGuard::capture();
8633 follow_control(&relay, &community, &session).await.unwrap(); let mut moved = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8638 moved.relays = vec!["wss://empty".into()];
8639 crate::db::community::save_community_v2(&moved).unwrap();
8640
8641 let err = refound_community(&relay, &moved, &[]).await.unwrap_err();
8642 assert!(err.contains("was not served"), "a withheld control head aborts the refounding: {err}");
8643 assert_eq!(
8644 crate::db::community::load_community_v2(community.id()).unwrap().unwrap().root_epoch,
8645 Epoch(0),
8646 "the epoch did NOT advance (zero published state)"
8647 );
8648 }
8649
8650 #[tokio::test]
8651 async fn refounding_rolls_the_root_and_severs_a_removed_member() {
8652 let (bed, owner, member) = TestBed::new();
8655 bed.swap_to(&owner);
8656 let community = create_community(&bed.relay, "Refound", bed.relays.clone(), None).await.unwrap();
8657 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
8658 let bundle_json = serde_json::to_string(&bundle).unwrap();
8659 bed.swap_to(&member);
8660 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8661
8662 bed.swap_to(&owner);
8663 let refounded = refound_community(&bed.relay, &community, &[member.keys.public_key()]).await.unwrap();
8664 assert_eq!(refounded.root_epoch, Epoch(1), "the epoch advanced");
8665 assert_ne!(refounded.community_root, community.community_root, "the base root rolled");
8666 assert_eq!(
8668 crate::db::community::load_community_v2(community.id()).unwrap().unwrap().root_epoch,
8669 Epoch(1),
8670 "the owner committed the new epoch"
8671 );
8672
8673 bed.swap_to(&member);
8677 let session = SessionGuard::capture();
8678 let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
8679 assert!(follow.self_removed, "the removed member is cut by the re-founding");
8680 }
8681
8682 #[tokio::test]
8683 async fn a_ban_holding_admin_can_re_found_but_not_evict_a_superior() {
8684 let (bed, owner, member) = TestBed::new();
8689 bed.swap_to(&owner);
8690 let community = create_community(&bed.relay, "Guarded", bed.relays.clone(), None).await.unwrap();
8691 let rid = "b0".repeat(32);
8692 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
8693 publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
8694 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
8695 let bundle_json = serde_json::to_string(&bundle).unwrap();
8696 bed.swap_to(&member);
8697 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8698 let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
8701 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
8702 assert!(refound_community(&bed.relay, &joined, &[owner.keys.public_key()]).await.is_err(), "a BAN-holder can't re-found to evict the owner");
8704 assert!(refound_community(&bed.relay, &joined, &[]).await.is_ok(), "a BAN-holding admin can re-found");
8706 }
8707
8708 #[tokio::test]
8709 async fn follow_rekeys_adopts_an_authorized_non_owner_base_rotation() {
8710 let (bed, owner, me) = TestBed::new();
8715 let admin = Keys::generate();
8716 bed.swap_to(&owner);
8717 let community = create_community(&bed.relay, "AdminRefound", bed.relays.clone(), None).await.unwrap();
8718 let rid = "b0".repeat(32);
8719 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
8720 publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid], 1).await;
8721
8722 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
8724 let bundle_json = serde_json::to_string(&bundle).unwrap();
8725 bed.swap_to(&me);
8726 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8727 let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
8728 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
8729
8730 let new_root = [0xC7; 32];
8733 publish_base_rotation(&bed.relay, &joined, &admin, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
8734
8735 let updated = follow_rekeys(&bed.relay, &joined, &SessionGuard::capture()).await.unwrap().updated
8736 .expect("an authorized admin's Refounding is adopted");
8737 assert_eq!(updated.root_epoch, Epoch(1), "advanced past the admin's rotation");
8738 assert_eq!(updated.community_root, new_root, "adopted the admin's fresh root");
8739 }
8740
8741 #[tokio::test]
8742 async fn adopting_someone_elses_rotation_refreshes_my_own_live_links() {
8743 let (bed, owner, me) = TestBed::new();
8751 bed.swap_to(&owner);
8752 let community = create_community(&bed.relay, "LinkHeal", bed.relays.clone(), None).await.unwrap();
8753 let rid = "b1".repeat(32);
8754 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN | Permissions::CREATE_INVITE), 1).await;
8756 publish_grant(&bed.relay, &community, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
8757
8758 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
8759 let bundle_json = serde_json::to_string(&bundle).unwrap();
8760 bed.swap_to(&me);
8761 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8762 let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
8763 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
8764
8765 let minted = mint_public_link(&bed.relay, &joined, "https://x", None, None).await.unwrap();
8767 let vended_before = fetch_public_bundle(&bed.relay, &minted.url).await.unwrap();
8768 assert_eq!(vended_before.root_epoch, 0, "my link vends the epoch I minted it at");
8769
8770 let new_root = [0xD4; 32];
8773 publish_base_rotation(&bed.relay, &joined, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
8774
8775 let updated = follow_rekeys(&bed.relay, &joined, &SessionGuard::capture()).await.unwrap().updated
8776 .expect("the owner's Refounding is adopted");
8777 assert_eq!(updated.root_epoch, Epoch(1), "I advanced to the new epoch");
8778
8779 let vended_after = fetch_public_bundle(&bed.relay, &minted.url).await.unwrap();
8780 assert_eq!(vended_after.root_epoch, 1, "my link must now vend the NEW epoch, not strand its joiners");
8781 assert_eq!(
8782 crate::simd::hex::hex_to_bytes_32(&vended_after.community_root),
8783 new_root,
8784 "and the new root behind the same URL",
8785 );
8786 }
8787
8788 #[tokio::test]
8789 async fn follow_rekeys_refuses_a_refounding_that_excludes_the_owner() {
8790 let (bed, owner, me) = TestBed::new();
8794 let admin = Keys::generate();
8795 bed.swap_to(&owner);
8796 let community = create_community(&bed.relay, "NoCoup", bed.relays.clone(), None).await.unwrap();
8797 let rid = "b0".repeat(32);
8798 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
8799 publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid], 1).await;
8800
8801 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
8802 let bundle_json = serde_json::to_string(&bundle).unwrap();
8803 bed.swap_to(&me);
8804 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8805 let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
8806 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
8807
8808 publish_base_rotation(&bed.relay, &joined, &admin, &[me.keys.public_key()], &[0xEE; 32], &joined.community_root).await;
8810 let follow = follow_rekeys(&bed.relay, &joined, &SessionGuard::capture()).await.unwrap();
8811 assert!(follow.updated.is_none() && !follow.self_removed, "an owner-excluding Refounding is not adopted");
8812 }
8813
8814 #[tokio::test]
8815 async fn follow_rekeys_refuses_a_refounding_that_excludes_a_peer_admin() {
8816 let (bed, owner, me) = TestBed::new();
8820 let admin_a = Keys::generate();
8821 let admin_b = Keys::generate(); bed.swap_to(&owner);
8823 let community = create_community(&bed.relay, "Peers", bed.relays.clone(), None).await.unwrap();
8824 let rid = "b0".repeat(32);
8825 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
8826 publish_grant(&bed.relay, &community, &owner.keys, &admin_a.public_key(), vec![rid.clone()], 1).await;
8828 publish_grant(&bed.relay, &community, &owner.keys, &admin_b.public_key(), vec![rid], 1).await;
8829
8830 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
8831 let bundle_json = serde_json::to_string(&bundle).unwrap();
8832 bed.swap_to(&me);
8833 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8834 let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
8835 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
8836
8837 publish_base_rotation(&bed.relay, &joined, &admin_a, &[owner.keys.public_key(), me.keys.public_key()], &[0xDD; 32], &joined.community_root).await;
8839
8840 let follow = follow_rekeys(&bed.relay, &joined, &SessionGuard::capture()).await.unwrap();
8841 assert!(follow.updated.is_none() && !follow.self_removed, "excluding an equal-rank peer admin is inadmissible");
8842 }
8843
8844 #[tokio::test]
8845 async fn a_retried_refounding_reuses_the_same_root() {
8846 let (_tmp, _guard, _owner) = init_test_db();
8849 let relay = MemoryRelay::new();
8850 let community = create_community(&relay, "Retry", vec!["wss://r".into()], None).await.unwrap();
8851 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
8852 let first = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap();
8853 let second = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap();
8854 assert_eq!(first, second, "a retry reuses the archived root, never double-mints");
8855 }
8856
8857 #[tokio::test]
8858 async fn a_mid_rank_admin_cannot_demote_a_role_that_outranks_them() {
8859 let (bed, owner, attacker) = TestBed::new();
8866 bed.swap_to(&owner);
8867 let community = create_community(&bed.relay, "Ranks", bed.relays.clone(), None).await.unwrap();
8868
8869 let senior = "a1".repeat(32);
8871 let mid = "a5".repeat(32);
8872 publish_role(&bed.relay, &community, &owner.keys,
8873 &Role { role_id: senior.clone(), name: "Senior".into(), position: 1, permissions: Permissions(Permissions::BAN), scope: RoleScope::Server, color: 0 }, 1).await;
8874 publish_role(&bed.relay, &community, &owner.keys,
8875 &Role { role_id: mid.clone(), name: "Mid".into(), position: 5, permissions: Permissions(Permissions::MANAGE_ROLES), scope: RoleScope::Server, color: 0 }, 1).await;
8876 publish_grant(&bed.relay, &community, &owner.keys, &attacker.keys.public_key(), vec![mid.clone()], 1).await;
8877
8878 publish_role(&bed.relay, &community, &attacker.keys,
8880 &Role { role_id: senior.clone(), name: "Senior".into(), position: 9, permissions: Permissions(Permissions::BAN), scope: RoleScope::Server, color: 0 }, 2).await;
8881
8882 let authority = fetch_authority(&bed.relay, &community).await;
8883 let folded_senior = authority.roles.role(&senior).expect("the senior role survives the fold");
8884 assert_eq!(
8885 folded_senior.position, 1,
8886 "a role may only be repositioned by someone who outranks where it STOOD, not just where it lands",
8887 );
8888 }
8889
8890 #[tokio::test]
8891 async fn a_non_owner_admins_edition_cites_its_grant_and_the_owners_does_not() {
8892 let (bed, owner, admin) = TestBed::new();
8899 bed.swap_to(&owner);
8900 let community = create_community(&bed.relay, "Cited", bed.relays.clone(), None).await.unwrap();
8901 let rid = "c1".repeat(32);
8902 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN | Permissions::MANAGE_METADATA), 1).await;
8903 publish_grant(&bed.relay, &community, &owner.keys, &admin.keys.public_key(), vec![rid], 1).await;
8904
8905 let owner_meta = control::CommunityMetadata { name: "By Owner".into(), relays: community.relays.clone(), ..Default::default() };
8907 edit_community_metadata(&bed.relay, &community, &owner_meta).await.unwrap();
8908 let owner_ed = fetch_control(&bed.relay, &community).await.into_iter()
8909 .filter(|e| e.author == owner.keys.public_key() && e.vsk == vsk::COMMUNITY_METADATA)
8910 .max_by_key(|e| e.version).expect("the owner's metadata edition");
8911 assert!(owner_ed.authority.is_none(), "the owner cites nothing — rank comes from the community id");
8912
8913 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
8916 let bundle_json = serde_json::to_string(&bundle).unwrap();
8917 bed.swap_to(&admin);
8918 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8919 let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
8920 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
8921 set_banlist(&bed.relay, &joined, &["ee".repeat(32)]).await.unwrap();
8922
8923 let ban_ed = fetch_control(&bed.relay, &joined).await.into_iter()
8924 .find(|e| e.author == admin.keys.public_key() && e.vsk == vsk::BANLIST)
8925 .expect("the admin's banlist edition");
8926 let cite = ban_ed.authority.as_ref().expect("a non-owner MUST cite its grant");
8927 assert_eq!(
8928 cite.entity_id,
8929 crate::community::v2::derive::grant_locator(community.id(), &admin.keys.public_key().to_bytes()),
8930 "the citation must name the ACTOR'S OWN grant coordinate",
8931 );
8932 assert!(cite.version >= 1, "pinned to a real grant version");
8933 }
8934
8935 #[tokio::test]
8936 async fn a_folded_metadata_edition_cannot_push_the_relay_set_past_the_cap() {
8937 let (_tmp, _guard, _owner) = init_test_db();
8943 let relay = MemoryRelay::new();
8944 let community = create_community(&relay, "Fanout", vec!["wss://a".into()], None).await.unwrap();
8945
8946 let many: Vec<String> = (0..30).map(|i| format!("wss://r{i}")).collect();
8947 let meta = control::CommunityMetadata { name: "Fanout".into(), relays: many, ..Default::default() };
8948 edit_community_metadata(&relay, &community, &meta).await.unwrap();
8949
8950 let updated = follow_control(&relay, &community, &SessionGuard::capture()).await.unwrap()
8951 .expect("the metadata edition is folded");
8952 assert_eq!(
8953 updated.relays.len(),
8954 crate::community::MAX_COMMUNITY_RELAYS,
8955 "a folded relay list must be truncated, never adopted whole",
8956 );
8957
8958 let again = follow_control(&relay, &updated, &SessionGuard::capture()).await.unwrap();
8962 assert!(again.is_none(), "re-folding the same oversize edition must be a no-op");
8963 }
8964
8965 #[tokio::test]
8966 async fn adopting_a_rotation_writes_no_registry_where_i_never_minted() {
8967 let (bed, owner, me) = TestBed::new();
8973 bed.swap_to(&owner);
8974 let host = create_community(&bed.relay, "Host", bed.relays.clone(), None).await.unwrap();
8975 let rid = "b2".repeat(32);
8976 publish_role(&bed.relay, &host, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
8977 publish_grant(&bed.relay, &host, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
8978
8979 let bundle = bundle_of(&host, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
8980 let bundle_json = serde_json::to_string(&bundle).unwrap();
8981 bed.swap_to(&me);
8982 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8983 let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
8984 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
8985
8986 let elsewhere = create_community(&bed.relay, "Elsewhere", bed.relays.clone(), None).await.unwrap();
8989 mint_public_link(&bed.relay, &elsewhere, "https://other", None, None).await.unwrap();
8990
8991 let before = bed.relay.stored_count();
8992 let new_root = [0xE1; 32];
8993 publish_base_rotation(&bed.relay, &joined, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
8994 let rotation_events = bed.relay.stored_count() - before;
8995
8996 let after_adopt = bed.relay.stored_count();
8997 follow_rekeys(&bed.relay, &joined, &SessionGuard::capture()).await.unwrap();
8998 assert_eq!(
8999 bed.relay.stored_count(),
9000 after_adopt,
9001 "adopting a rotation must publish NOTHING when I minted no links here",
9002 );
9003 assert!(rotation_events > 0, "the rotation itself did publish (guards the counter)");
9004 }
9005
9006 #[tokio::test]
9007 async fn an_expired_link_stops_keeping_the_community_public() {
9008 let (_tmp, _guard, _owner) = init_test_db();
9014 let relay = MemoryRelay::new();
9015 let community = create_community(&relay, "Lapsing", vec!["wss://r".into()], None).await.unwrap();
9016
9017 let past = now_ms() - 60_000;
9019 mint_public_link(&relay, &community, "https://x", Some(past), None).await.unwrap();
9020 assert!(
9021 !community_is_public(&relay, &community).await,
9022 "an already-expired link must never read as a live door",
9023 );
9024
9025 mint_public_link(&relay, &community, "https://y", Some(now_ms() + 600_000), None).await.unwrap();
9027 assert!(community_is_public(&relay, &community).await, "an unexpired link is still live");
9028 }
9029
9030 #[tokio::test]
9031 async fn minting_a_link_makes_the_community_public_and_revoke_makes_it_private() {
9032 let (_tmp, _guard, _owner) = init_test_db();
9035 let relay = MemoryRelay::new();
9036 let community = create_community(&relay, "Invitable", vec!["wss://r".into()], None).await.unwrap();
9037 assert!(!community_is_public(&relay, &community).await, "a fresh community is Private");
9038
9039 let minted = mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
9040 assert!(community_is_public(&relay, &community).await, "a live link makes it Public");
9041 let list = fetch_invite_list(&relay, &community.relays).await.unwrap().expect("the 13303 list was published");
9042 assert_eq!(list.entries.len(), 1, "the minted link is recorded across devices");
9043
9044 let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
9045 revoke_public_link(&relay, &community, &token_hex).await.unwrap();
9046 assert!(!community_is_public(&relay, &community).await, "retiring the last link makes it Private again");
9047 let after = fetch_invite_list(&relay, &community.relays).await.unwrap().unwrap();
9048 assert!(after.entries.is_empty() && after.tombstones.len() == 1, "the link is tombstoned in the invite list");
9049 }
9050
9051 #[tokio::test]
9052 async fn the_registry_is_cached_locally_so_public_private_is_a_sync_read() {
9053 let (_tmp, _guard, _owner) = init_test_db();
9057 let relay = MemoryRelay::new();
9058 let community = create_community(&relay, "Cached", vec!["wss://r".into()], None).await.unwrap();
9059 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9060 let cached = || crate::db::community::get_community_invite_registry(&cid_hex).unwrap();
9061 let per_creator = || crate::db::community::get_invite_link_sets(&cid_hex).unwrap();
9064 assert!(cached().is_empty(), "a fresh community caches an empty registry");
9065 assert!(per_creator().is_empty(), "…and no per-creator sets");
9066
9067 let minted = mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
9068 assert!(!cached().is_empty(), "minting caches the registry, so the UI reads Public without folding");
9069 let sets = per_creator();
9070 assert_eq!(sets.len(), 1, "the minting creator gets a set");
9071 assert_eq!(sets[0].locators.len(), 1, "carrying exactly their one live link");
9072
9073 let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
9075 revoke_public_link(&relay, &community, &token_hex).await.unwrap();
9076 assert!(cached().is_empty(), "retiring the last link empties the cache back to Private");
9077 assert!(per_creator().is_empty(), "…and clears the per-creator sets");
9078 }
9079
9080 #[tokio::test]
9081 async fn a_rogue_registry_fork_cannot_retire_the_owners_live_link() {
9082 let (_tmp, _guard, owner) = init_test_db();
9089 let relay = MemoryRelay::new();
9090 let community = create_community(&relay, "Forked", vec!["wss://r".into()], None).await.unwrap();
9091 mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
9092 assert!(community_is_public(&relay, &community).await, "a live link makes it Public");
9093
9094 let cid = community.id();
9095 let control = control_group_key(&community.community_root, cid, community.root_epoch);
9096 let eid = crate::community::v2::derive::invite_links_locator(cid, &owner.public_key().to_bytes());
9097
9098 let query = Query {
9099 kinds: vec![stream::KIND_WRAP],
9100 authors: vec![control.pk_hex()],
9101 limit: Some(FOLLOW_PAGE),
9102 ..Default::default()
9103 };
9104 let wraps = relay.fetch(&query, &community.relays).await.unwrap();
9105 let target = wraps
9106 .iter()
9107 .filter_map(|w| control::open_control_edition(w, &control).ok().map(|(e, _)| e))
9108 .filter(|e| e.entity_id == eid)
9109 .max_by_key(|e| e.version)
9110 .expect("the owner published a registry");
9111
9112 let rogue = Keys::generate();
9115 let mut planted = false;
9116 for n in 0..4_000u64 {
9117 let content = format!("[{{\"token\":\"{n:032x}\",\"url\":\"https://evil\",\"expires_at\":0}}]");
9118 let rumor = control::build_edition_rumor(
9119 rogue.public_key(),
9120 vsk::INVITE_LINKS,
9121 &eid,
9122 target.version,
9123 target.prev_hash.as_ref(),
9124 &content,
9125 9_000,
9126 None,
9127 );
9128 let (w, _) = control::seal_control_edition(&rumor, &control, &rogue, Timestamp::from_secs(9_000)).unwrap();
9129 let (ed, _) = control::open_control_edition(&w, &control).unwrap();
9130 if ed.inner_id < target.inner_id {
9131 relay.publish(&w, &community.relays).await.unwrap();
9132 planted = true;
9133 break;
9134 }
9135 }
9136 assert!(planted, "the test needs a fork that wins the tiebreak");
9137
9138 assert!(
9139 community_is_public(&relay, &community).await,
9140 "an unauthorised fork must not retire the owner's live link"
9141 );
9142 }
9143
9144 #[tokio::test]
9145 async fn a_registry_from_a_non_create_invite_holder_does_not_make_it_public() {
9146 let (_tmp, _guard, owner) = init_test_db();
9148 let relay = MemoryRelay::new();
9149 let community = create_community(&relay, "Gated", vec!["wss://r".into()], None).await.unwrap();
9150 let rogue = Keys::generate();
9151 let eid = crate::community::v2::derive::invite_links_locator(community.id(), &rogue.public_key().to_bytes());
9153 let content = crate::community::v2::invite::build_registry_content(&[Keys::generate().public_key()]);
9154 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
9155 let rumor = control::build_edition_rumor(rogue.public_key(), vsk::INVITE_LINKS, &eid, 1, None, &content, 1_000, None);
9156 let (wrap, _) = control::seal_control_edition(&rumor, &group, &rogue, Timestamp::from_secs(1_000)).unwrap();
9157 relay.publish(&wrap, &community.relays).await.unwrap();
9158 let _ = owner;
9159 assert!(!community_is_public(&relay, &community).await, "a non-CREATE_INVITE registry is ignored");
9160 }
9161
9162 #[tokio::test]
9163 async fn full_lifecycle_e2e() {
9164 let (bed, owner, member) = TestBed::new();
9170
9171 bed.swap_to(&owner);
9172 let community = create_community(&bed.relay, "Lifecycle", bed.relays.clone(), None).await.unwrap();
9173 let general = community.channels[0].id;
9174 send_message(&bed.relay, &community, &general, "owner: welcome").await.unwrap();
9175
9176 let _minted = mint_public_link(&bed.relay, &community, "https://x", None, None).await.unwrap();
9178 assert!(community_is_public(&bed.relay, &community).await, "a live link makes it Public");
9179
9180 let rid = "aa".repeat(32);
9182 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
9183 publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
9184
9185 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
9187 let bundle_json = serde_json::to_string(&bundle).unwrap();
9188 bed.swap_to(&member);
9189 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
9190 assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: welcome"]);
9191 publish_community_meta(&bed.relay, &joined, &member.keys, "Lifecycle Renamed", 2).await;
9193
9194 bed.swap_to(&owner);
9196 let session = SessionGuard::capture();
9197 let updated = follow_control(&bed.relay, &community, &session).await.unwrap().expect("the admin edit folds");
9198 assert_eq!(updated.name, "Lifecycle Renamed", "an authorized admin's metadata edit is honored");
9199
9200 set_banlist(&bed.relay, &updated, &[member.keys.public_key().to_hex()]).await.unwrap();
9202 grant_roles(&bed.relay, &updated, &member.keys.public_key(), vec![]).await.unwrap();
9203 let refounded = refound_community(&bed.relay, &updated, &[member.keys.public_key()]).await.unwrap();
9204 assert_eq!(refounded.root_epoch, Epoch(1), "the ban rolled the root");
9205 let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
9207 assert!(post.banned.contains(&member.keys.public_key().to_hex()), "the ban survives the re-founding");
9208 assert!(
9210 texts_in(&bed.relay, &refounded, &general).await.contains(&"owner: welcome".to_string()),
9211 "pre-refounding history stays readable"
9212 );
9213
9214 bed.swap_to(&member);
9217 let session = SessionGuard::capture();
9218 let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
9219 assert!(follow.self_removed, "the banned member is cryptographically cut");
9220
9221 bed.swap_to(&owner);
9223 dissolve_community(&bed.relay, &refounded).await.unwrap();
9224 assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the community is sealed");
9225 }
9226
9227 #[tokio::test]
9233 async fn a_forged_edition_cannot_suppress_a_role_across_a_refounding() {
9234 let (bed, owner, member) = TestBed::new();
9239 let attacker = Keys::generate();
9240 bed.swap_to(&owner);
9241 let community = create_community(&bed.relay, "NoSuppress", bed.relays.clone(), None).await.unwrap();
9242 let rid = crate::simd::hex::bytes_to_hex_32(&[0xa1; 32]);
9243 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
9244 publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid.clone()], 1).await;
9245 let session = SessionGuard::capture();
9247 follow_control(&bed.relay, &community, &session).await.unwrap();
9248 assert!(fetch_authority(&bed.relay, &community).await.roles.is_admin(&member.keys.public_key().to_hex()), "member is admin pre-attack");
9249
9250 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;
9252
9253 let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
9255 assert_eq!(refounded.root_epoch, Epoch(1), "root rolled");
9256
9257 let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
9259 assert!(post.roles.is_admin(&member.keys.public_key().to_hex()), "the admin role survives the refounding despite the forgery");
9260 }
9261
9262 #[tokio::test]
9263 async fn memberlist_survives_a_refounding_via_the_snapshot() {
9264 let (bed, owner, member) = TestBed::new();
9267 bed.swap_to(&owner);
9268 let community = create_community(&bed.relay, "Snapshot", bed.relays.clone(), None).await.unwrap();
9269
9270 let bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
9272 bed.swap_to(&member);
9273 accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
9274 bed.swap_to(&owner);
9275 assert!(memberlist(&bed.relay, &community).await.unwrap().contains(&member.keys.public_key()), "member present pre-refound");
9276
9277 let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
9279 assert_eq!(refounded.root_epoch, Epoch(1), "the root rolled");
9280
9281 let members = memberlist(&bed.relay, &refounded).await.unwrap();
9283 assert!(members.contains(&member.keys.public_key()), "a silent survivor stays a member after the refounding");
9284 assert!(members.contains(&owner.keys.public_key()), "owner is always a member");
9285 }
9286
9287 #[tokio::test]
9288 async fn e2e_two_accounts_channels_converse_moderate() {
9289 use crate::community::v2::inbound::{apply_chat_to_state, persist_chat};
9290 use nostr_sdk::prelude::ToBech32;
9291 let (bed, a, b) = TestBed::new();
9292 let (a_npub, b_npub) = (a.keys.public_key().to_bech32().unwrap(), b.keys.public_key().to_bech32().unwrap());
9293 let (a_hex, b_hex) = (a.keys.public_key().to_hex(), b.keys.public_key().to_hex());
9294 println!("\n===== Concord v2 deep e2e =====");
9295 println!("[acct] A (owner) = {a_npub}");
9296 println!("[acct] B (member) = {b_npub}");
9297
9298 bed.swap_to(&a);
9300 let mut community = create_community(&bed.relay, "Deep E2E", bed.relays.clone(), None).await.unwrap();
9301 let general = community.channels[0].id;
9302 println!("[create] community {} · #general {}", crate::simd::hex::bytes_to_hex_32(&community.id().0), crate::simd::hex::bytes_to_hex_32(&general.0));
9303
9304 let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
9308 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9309 let priv_ch = community.channel(&priv_id).unwrap();
9310 assert!(priv_ch.private && priv_ch.key.is_some() && priv_ch.epoch == Epoch(1), "born-private: keyed at epoch 1");
9311 println!("[channel] +private #mods {} (native create: key over the rekey plane)", crate::simd::hex::bytes_to_hex_32(&priv_id.0));
9312
9313 let announcements = create_public_channel(&bed.relay, &community, "announcements").await.unwrap();
9315 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9316 let random = create_public_channel(&bed.relay, &community, "random").await.unwrap();
9317 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9318 println!("[channel] +public #announcements {} · #random {}", crate::simd::hex::bytes_to_hex_32(&announcements.0), crate::simd::hex::bytes_to_hex_32(&random.0));
9319 assert_eq!(community.channels.len(), 4, "general + mods + announcements + random");
9320
9321 let m1 = send_message(&bed.relay, &community, &general, "A: welcome to the deep e2e").await.unwrap();
9323 send_message(&bed.relay, &community, &announcements, "A: read the rules").await.unwrap();
9324 send_message(&bed.relay, &community, &priv_id, "A: mods-only channel").await.unwrap();
9325 println!("[msg] A posted in #general / #announcements / #mods");
9326
9327 let admin_rid = crate::simd::hex::bytes_to_hex_32(&[0xa1; 32]);
9329 publish_role(&bed.relay, &community, &a.keys, &admin_role(&admin_rid, Permissions::ADMIN_ALL), 1).await;
9330 publish_grant(&bed.relay, &community, &a.keys, &b.keys.public_key(), vec![admin_rid], 1).await;
9331 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
9332 assert!(community_is_public(&bed.relay, &community).await, "a live link makes it Public");
9333 println!("[invite] granted B @admin · minted link {}", link.url);
9334
9335 grant_channel_access(&bed.relay, &community, &priv_id, &b.keys.public_key()).await.unwrap();
9338 let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Member(b.keys.public_key()), Some(a.keys.public_key()), None, None)).unwrap();
9339 bed.swap_to(&b);
9340 let mut b_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
9341 println!("[join] B joined; sees {} channels", b_view.channels.len());
9342 assert_eq!(b_view.channels.len(), 4, "B receives all four channels (incl. the private one's key) in the bundle");
9343 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");
9344 assert!(texts_in(&bed.relay, &b_view, &general).await.contains(&"A: welcome to the deep e2e".to_string()), "B reads A's #general history");
9345 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");
9346 let session_b = SessionGuard::capture();
9349 if let Some(fresh) = follow_control(&bed.relay, &b_view, &session_b).await.unwrap() {
9350 b_view = fresh;
9351 }
9352 println!("[follow] B folded control (roster persisted: B is @admin)");
9353
9354 send_message(&bed.relay, &b_view, &general, "B: thanks, glad to be here").await.unwrap();
9356 send_message(&bed.relay, &b_view, &priv_id, "B: mods checking in").await.unwrap();
9357 println!("[msg] B replied in #general + #mods");
9358 let my_pk = b.keys.public_key();
9361 let gh = crate::simd::hex::bytes_to_hex_32(&general.0);
9362 for f in fetch_channel(&bed.relay, &b_view, &general, 100).await.unwrap() {
9363 let outcome = { let mut st = crate::state::STATE.lock().await; apply_chat_to_state(&mut st, &f.event, &gh, &my_pk) };
9364 if let Some(o) = outcome { persist_chat(&gh, &o).await; }
9365 }
9366 assert!(crate::db::events::event_exists(&m1).unwrap(), "A's message persisted into B's shared store (get_messages backfill)");
9367 println!("[persist] #general history persisted into the shared events store");
9368
9369 send_reaction(&bed.relay, &b_view, &general, &m1, &a_hex, super::super::kind::MESSAGE, "🔥", None).await.unwrap();
9371 bed.swap_to(&a);
9372 let m_edit = send_message(&bed.relay, &community, &general, "A: this will be edited").await.unwrap();
9373 send_edit(&bed.relay, &community, &general, &m_edit, "A: edited!").await.unwrap();
9374 let m_del = send_message(&bed.relay, &community, &general, "A: this will be deleted").await.unwrap();
9375 send_delete(&bed.relay, &community, &general, &m_del, super::super::kind::MESSAGE).await.unwrap();
9376 println!("[ops] reaction + edit + delete round-tripped");
9377
9378 bed.swap_to(&b);
9380 let bugs = create_public_channel(&bed.relay, &b_view, "bug-reports").await.unwrap();
9381 println!("[channel] B(admin) +public #bug-reports {}", crate::simd::hex::bytes_to_hex_32(&bugs.0));
9382 bed.swap_to(&a);
9383 let session = SessionGuard::capture();
9384 if let Some(updated) = follow_control(&bed.relay, &community, &session).await.unwrap() {
9385 community = updated;
9386 }
9387 assert!(community.channels.iter().any(|c| c.id.0 == bugs.0), "A folds in B's authorized new channel");
9388 println!("[follow] A folded in B's #bug-reports (now {} channels)", community.channels.len());
9389
9390 let vault = create_private_channel(&bed.relay, &community, "vault").await.unwrap();
9396 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9397 send_message(&bed.relay, &community, &vault, "A: vault is open").await.unwrap();
9398 println!("[channel] +private #vault {} (B is unentitled — no delivery)", crate::simd::hex::bytes_to_hex_32(&vault.0));
9399 bed.swap_to(&b);
9400 let session_b2 = SessionGuard::capture();
9401 if let Some(fresh) = follow_control(&bed.relay, &b_view, &session_b2).await.unwrap() {
9402 b_view = fresh;
9403 }
9404 let ch = b_view.channel(&vault).expect("B recorded the announced private channel");
9405 assert!(ch.private && ch.key.is_none() && ch.epoch == Epoch(0), "B's record is keyless at cursor 0");
9406 let rf = follow_rekeys(&bed.relay, &b_view, &session_b2).await.unwrap();
9407 if let Some(fresh) = rf.updated {
9408 b_view = fresh;
9409 }
9410 let ch = b_view.channel(&vault).expect("still recorded");
9411 assert!(ch.key.is_none(), "an unentitled member is never delivered the key");
9412 assert!(
9413 texts_in(&bed.relay, &b_view, &vault).await.is_empty(),
9414 "and reads nothing from it"
9415 );
9416 assert!(
9417 send_message(&bed.relay, &b_view, &vault, "B: in the vault").await.is_err(),
9418 "an unentitled member cannot post into the channel either"
9419 );
9420 bed.swap_to(&a);
9421 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9422 println!("[private] #vault stayed sealed to the unentitled B (no key, no read, no send)");
9423
9424 let members = memberlist(&bed.relay, &community).await.unwrap();
9426 let member_hexes: std::collections::BTreeSet<String> = members.iter().map(|m| m.to_hex()).collect();
9427 assert!(member_hexes.contains(&a_hex) && member_hexes.contains(&b_hex), "A + B both in the memberlist");
9428 println!("[members] {} members: A + B present", members.len());
9429
9430 set_banlist(&bed.relay, &community, &[b_hex.clone()]).await.unwrap();
9432 grant_roles(&bed.relay, &community, &b.keys.public_key(), vec![]).await.unwrap();
9433 let refounded = refound_community(&bed.relay, &community, &[b.keys.public_key()]).await.unwrap();
9434 assert_eq!(refounded.root_epoch, Epoch(1), "the ban rolled the root");
9435 let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
9436 assert!(post.banned.contains(&b_hex), "the ban survives the refounding");
9437 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");
9438 assert!(
9439 texts_in(&bed.relay, &refounded, &priv_id).await.iter().any(|t| t == "A: mods-only channel"),
9440 "PRIVATE history reads across the channel's own rotation (per-channel multi-epoch archive)"
9441 );
9442 println!("[ban] B banned; root rolled to epoch 1; ban survives; pre-ban history intact (public + private)");
9443 bed.swap_to(&b);
9445 let session_b3 = SessionGuard::capture();
9446 assert!(follow_rekeys(&bed.relay, &b_view, &session_b3).await.unwrap().self_removed, "B is cryptographically cut by the ban-refound");
9447 println!("[ban] B's rekey-follow: self_removed = true (severed)");
9448
9449 bed.swap_to(&a);
9451 set_banlist(&bed.relay, &refounded, &[]).await.unwrap();
9452 let after_unban = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
9453 assert!(!after_unban.banned.contains(&b_hex), "the unban clears B from the banlist");
9454 println!("[unban] B removed from the banlist (re-invitable)");
9455
9456 dissolve_community(&bed.relay, &refounded).await.unwrap();
9458 assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the community is sealed");
9459 println!("[dissolve] community sealed (read-only)\n===== e2e PASS =====\n");
9460 }
9461
9462 #[tokio::test]
9468 #[ignore]
9469 async fn live_e2e_two_accounts() {
9470 use crate::community::transport::LiveTransport;
9471 use nostr_sdk::prelude::ToBech32;
9472
9473 let relay = std::env::var("VECTOR_E2E_RELAY").unwrap_or_else(|_| "wss://jskitty.com/nostr".to_string());
9474 let relays = vec![relay.clone()];
9475 let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
9476 crate::db::close_database();
9477 crate::db::clear_id_caches();
9478 let tmp = tempfile::tempdir().unwrap();
9479 crate::db::set_app_data_dir(tmp.path().to_path_buf());
9480
9481 let a = std::env::var("VECTOR_E2E_NSEC_A").ok().and_then(|n| Keys::parse(&n).ok()).unwrap_or_else(Keys::generate);
9483 let b = std::env::var("VECTOR_E2E_NSEC_B").ok().and_then(|n| Keys::parse(&n).ok()).unwrap_or_else(Keys::generate);
9484
9485 let log = |line: String| {
9486 println!("{line}");
9487 if let Ok(p) = std::env::var("VECTOR_E2E_LOG") {
9488 use std::io::Write;
9489 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&p) {
9490 let _ = writeln!(f, "{line}");
9491 }
9492 }
9493 };
9494 log(format!("===== LIVE Concord v2 e2e on {relay} ====="));
9495 log(format!("VECTOR_E2E_NSEC_A={} ({})", a.secret_key().to_bech32().unwrap(), a.public_key().to_bech32().unwrap()));
9496 log(format!("VECTOR_E2E_NSEC_B={} ({})", b.secret_key().to_bech32().unwrap(), b.public_key().to_bech32().unwrap()));
9497
9498 for k in [&a, &b] {
9499 let npub = k.public_key().to_bech32().unwrap();
9500 std::fs::create_dir_all(tmp.path().join(&npub)).unwrap();
9501 crate::db::set_current_account(npub.clone()).unwrap();
9502 crate::db::init_database(&npub).unwrap();
9503 }
9504 let client = crate::nostr_client_builder().build();
9507 client.add_managed_relay(relay.as_str()).await.ok();
9508 client.connect().await;
9509 crate::state::set_nostr_client(client);
9510 let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(15));
9511 let become_acct = |k: &Keys| {
9512 let npub = k.public_key().to_bech32().unwrap();
9513 crate::db::set_current_account(npub.clone()).unwrap();
9514 crate::db::init_database(&npub).unwrap();
9515 crate::db::clear_id_caches();
9516 crate::state::MY_SECRET_KEY.store_from_keys(k, &[]);
9517 crate::state::set_my_public_key(k.public_key());
9518 };
9519 let settle = || tokio::time::sleep(std::time::Duration::from_secs(2));
9520
9521 become_acct(&a);
9523 let mut community = create_community(&transport, "Live E2E", relays.clone(), None).await.expect("create");
9524 let general = community.channels[0].id;
9525 log(format!("[create] community {} · #general {}", crate::simd::hex::bytes_to_hex_32(&community.id().0), crate::simd::hex::bytes_to_hex_32(&general.0)));
9526 send_message(&transport, &community, &general, "A: live hello").await.expect("send");
9527 let ann = create_public_channel(&transport, &community, "announcements").await.expect("channel");
9528 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9529 log(format!("[channel] +public #announcements {}", crate::simd::hex::bytes_to_hex_32(&ann.0)));
9530 grant_admin(&transport, &community, &b.public_key()).await.expect("grant admin");
9531 let link = mint_public_link(&transport, &community, "https://vectorapp.io", None, None).await.expect("mint");
9532 log(format!("[invite] B granted @admin · link {}", link.url));
9533 let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(a.public_key()), None, None)).unwrap();
9534 settle().await;
9535
9536 become_acct(&b);
9538 let b_view = accept_parked_invite(&transport, &bundle_json, None).await.expect("join");
9539 log(format!("[join] B joined; {} channels", b_view.channels.len()));
9540 settle().await;
9541 let page = fetch_channel(&transport, &b_view, &general, 50).await.expect("fetch");
9542 let seen: Vec<String> = page.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
9543 log(format!("[read] B sees #general: {seen:?}"));
9544 assert!(seen.iter().any(|t| t == "A: live hello"), "B reads A's message over the real relay");
9545 send_message(&transport, &b_view, &general, "B: live reply").await.expect("reply");
9546
9547 let hello = page.iter().find(|f| f.event.opened().rumor.content == "A: live hello").expect("A's message");
9551 let hello_id = hello.event.opened().rumor_id.to_hex();
9552 let bkeys = crate::state::MY_SECRET_KEY.to_keys().unwrap();
9553 let cgroup = channel_group_key(&b_view.community_root, &general, b_view.root_epoch);
9554 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());
9555 let (reply_wrap, _) = chat::seal_chat_rumor(&reply_rumor, &cgroup, &bkeys, Timestamp::from_secs(now_ms() / 1000), false).expect("seal 1111");
9556 transport.publish(&reply_wrap, &b_view.relays).await.expect("publish 1111");
9557 log("[thread] B published a kind-1111 threaded reply to A's message".to_string());
9558 settle().await;
9559
9560 become_acct(&a);
9562 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9563 let a_page = fetch_channel(&transport, &community, &general, 50).await.expect("A fetch");
9564 let thread = a_page.iter().find(|f| f.event.opened().rumor.content == "B: threaded reply to hello").expect("A sees the 1111");
9565 if let chat::ChatEvent::Message { reply_to, opened, .. } = &thread.event {
9566 assert_eq!(opened.rumor.kind.as_u16(), super::super::kind::COMMENT, "wire kind preserved as 1111");
9567 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");
9568 } else {
9569 panic!("the 1111 parsed as a Message");
9570 }
9571 log("[thread] A read B's threaded reply, parent resolved — cross-client 1111 interop OK".to_string());
9572 become_acct(&b);
9573 settle().await;
9574
9575 become_acct(&a);
9579 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9580 let vault = create_private_channel(&transport, &community, "vault").await.expect("private channel");
9581 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9582 send_message(&transport, &community, &vault, "A: vault live").await.expect("vault send");
9583 log(format!("[channel] +private #vault {} (key delivered over the rekey plane)", crate::simd::hex::bytes_to_hex_32(&vault.0)));
9584 settle().await;
9585
9586 become_acct(&b);
9587 let session_b = SessionGuard::capture();
9588 let mut b_view = crate::db::community::load_community_v2(b_view.id()).unwrap().unwrap();
9589 if let Some(fresh) = follow_control(&transport, &b_view, &session_b).await.expect("B control follow") {
9590 b_view = fresh;
9591 }
9592 if let Some(fresh) = follow_rekeys(&transport, &b_view, &session_b).await.expect("B rekey follow").updated {
9593 b_view = fresh;
9594 }
9595 let vch = b_view.channel(&vault).expect("B folded the vault");
9596 assert!(vch.key.is_some() && vch.epoch == Epoch(1), "B adopted the vault key from the live rekey plane");
9597 let vseen = texts_in(&transport, &b_view, &vault).await;
9598 log(format!("[read] B sees #vault: {vseen:?}"));
9599 assert!(vseen.iter().any(|t| t == "A: vault live"), "B reads the private channel with the ADOPTED key");
9600 send_message(&transport, &b_view, &vault, "B: in the live vault").await.expect("vault reply");
9601 settle().await;
9602
9603 become_acct(&a);
9604 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9605 assert!(
9606 texts_in(&transport, &community, &vault).await.iter().any(|t| t == "B: in the live vault"),
9607 "A reads B's private reply"
9608 );
9609 log("[private] two-way #vault conversation over the live relay".to_string());
9610
9611 set_banlist(&transport, &community, &[b.public_key().to_hex()]).await.expect("banlist");
9613 grant_roles(&transport, &community, &b.public_key(), vec![]).await.expect("strip");
9614 let refounded = refound_community(&transport, &community, &[b.public_key()]).await.expect("refound");
9615 log(format!("[ban] B banned; root → epoch {}", refounded.root_epoch.0));
9616 settle().await;
9617 dissolve_community(&transport, &refounded).await.expect("dissolve");
9618 log("[dissolve] community sealed".to_string());
9619 log("===== LIVE e2e PASS =====".to_string());
9620 }
9621
9622 #[tokio::test]
9623 async fn an_offline_member_learns_of_a_dissolution_on_catch_up() {
9624 let (bed, owner, member) = TestBed::new();
9628 bed.swap_to(&owner);
9629 let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
9630 let general = community.channels[0].id;
9631 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
9632
9633 bed.swap_to(&member);
9634 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
9635 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
9636
9637 bed.swap_to(&owner);
9639 dissolve_community(&bed.relay, &community).await.unwrap();
9640
9641 bed.swap_to(&member);
9643 let session = SessionGuard::capture();
9644 let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
9645 assert!(follow.dissolved, "the catch-up surfaces the tombstone");
9646 assert!(!follow.self_removed && follow.updated.is_none());
9647 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
9648 assert!(crate::db::community::get_community_dissolved(&cid_hex).unwrap(), "sealed read-only locally");
9649 let err = send_message(&bed.relay, &joined, &general, "into the void").await.unwrap_err();
9650 assert!(err.contains("dissolved"), "sends refuse a grave: {err}");
9651 let again = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
9653 assert!(again.dissolved && again.updated.is_none());
9654 }
9655
9656 #[tokio::test]
9657 async fn a_wide_community_survives_refoundings_and_an_offline_member_converges() {
9658 const PRIV_CHANNELS: usize = 6;
9663 let (bed, owner, member) = TestBed::new();
9664 bed.swap_to(&owner);
9665 let mut community = create_community(&bed.relay, "Wide", bed.relays.clone(), None).await.unwrap();
9666 let mut priv_ids = Vec::new();
9667 for i in 0..PRIV_CHANNELS {
9668 let id = create_private_channel(&bed.relay, &community, &format!("priv{i}")).await.unwrap();
9669 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9670 send_message(&bed.relay, &community, &id, &format!("priv{i} epoch0")).await.unwrap();
9671 priv_ids.push(id);
9672 }
9673 for id in &priv_ids {
9675 grant_channel_access(&bed.relay, &community, id, &member.keys.public_key()).await.unwrap();
9676 }
9677 let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Member(member.keys.public_key()), Some(owner.keys.public_key()), None, None)).unwrap();
9678
9679 bed.swap_to(&member);
9681 let member_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
9682 assert_eq!(member_view.channels.iter().filter(|c| c.private && c.key.is_some()).count(), PRIV_CHANNELS, "joined with all private keys");
9683
9684 bed.swap_to(&owner);
9686 for epoch in 1..=2u64 {
9687 community = refound_community(&bed.relay, &community, &[]).await.unwrap();
9688 assert_eq!(community.root_epoch, Epoch(epoch));
9689 for id in &priv_ids {
9690 send_message(&bed.relay, &community, id, &format!("{} epoch{epoch}", crate::simd::hex::bytes_to_hex_32(&id.0))).await.unwrap();
9691 }
9692 }
9693
9694 bed.swap_to(&member);
9696 let session = SessionGuard::capture();
9697 let mut passes = 0;
9698 loop {
9699 passes += 1;
9700 assert!(passes <= 8, "a wide catch-up must converge, not churn (pass {passes})");
9701 let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
9702 let rk = follow_rekeys(&bed.relay, &cur, &session).await.unwrap();
9703 assert!(!rk.self_removed);
9704 let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
9705 let ctl = follow_control(&bed.relay, &cur, &session).await.unwrap();
9706 if rk.updated.is_none() && ctl.is_none() {
9707 break;
9708 }
9709 }
9710 let caught_up = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
9711 assert_eq!(caught_up.root_epoch, Epoch(2), "walked both refoundings");
9712 for id in &priv_ids {
9714 let mine = caught_up.channel(id).expect("channel survived");
9715 let theirs = community.channel(id).unwrap();
9716 assert_eq!(mine.key, theirs.key, "channel {} converged on the owner key", crate::simd::hex::bytes_to_hex_32(&id.0));
9717 assert_eq!(mine.epoch, theirs.epoch, "…at the same epoch");
9718 let texts = texts_in(&bed.relay, &caught_up, id).await;
9719 let id_hex = crate::simd::hex::bytes_to_hex_32(&id.0);
9720 assert!(texts.iter().any(|t| t.contains("epoch0")), "channel {id_hex} reads epoch-0 history");
9721 for epoch in 1..=2u64 {
9722 assert!(texts.iter().any(|t| t.contains(&format!("epoch{epoch}"))), "channel {id_hex} reads epoch-{epoch} history");
9723 }
9724 }
9725 }
9726
9727 #[tokio::test]
9728 async fn an_offline_member_catches_up_across_three_refoundings() {
9729 use nostr_sdk::prelude::ToBech32;
9734 let (bed, owner, member) = TestBed::new();
9735 bed.swap_to(&owner);
9736 let mut community = create_community(&bed.relay, "Sleeper", bed.relays.clone(), None).await.unwrap();
9737 let general = community.channels[0].id;
9738 let mods = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
9739 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9740 send_message(&bed.relay, &community, &general, "epoch0: hello").await.unwrap();
9741 send_message(&bed.relay, &community, &mods, "epoch0: mods secret").await.unwrap();
9742 grant_channel_access(&bed.relay, &community, &mods, &member.keys.public_key()).await.unwrap();
9744 let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Member(member.keys.public_key()), Some(owner.keys.public_key()), None, None)).unwrap();
9745
9746 bed.swap_to(&member);
9748 let member_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
9749 assert_eq!(member_view.root_epoch, Epoch(0));
9750
9751 bed.swap_to(&owner);
9753 let stranger = Keys::generate();
9754 for epoch in 1..=3u64 {
9755 community = refound_community(&bed.relay, &community, &[]).await.unwrap();
9756 assert_eq!(community.root_epoch, Epoch(epoch));
9757 send_message(&bed.relay, &community, &general, &format!("epoch{epoch}: general news")).await.unwrap();
9758 send_message(&bed.relay, &community, &mods, &format!("epoch{epoch}: mods word")).await.unwrap();
9759 }
9760 let news = create_public_channel(&bed.relay, &community, "news").await.unwrap();
9761 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9762 let vault = create_private_channel(&bed.relay, &community, "vault").await.unwrap();
9763 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9764 grant_channel_access(&bed.relay, &community, &vault, &member.keys.public_key()).await.unwrap();
9767 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9768 send_message(&bed.relay, &community, &vault, "epoch3: vault opened").await.unwrap();
9769 set_banlist(&bed.relay, &community, &[stranger.public_key().to_hex()]).await.unwrap();
9770 let meta = control::CommunityMetadata { name: "Sleeper Reborn".into(), relays: community.relays.clone(), ..Default::default() };
9771 edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
9772
9773 bed.swap_to(&member);
9776 let session = SessionGuard::capture();
9777 let mut passes = 0;
9778 loop {
9779 passes += 1;
9780 assert!(passes <= 6, "catch-up must converge, not churn");
9781 let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
9782 let rekeyed = follow_rekeys(&bed.relay, &cur, &session).await.unwrap();
9783 assert!(!rekeyed.self_removed, "the member was never removed");
9784 let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
9785 let controlled = follow_control(&bed.relay, &cur, &session).await.unwrap();
9786 if rekeyed.updated.is_none() && controlled.is_none() {
9787 break;
9788 }
9789 }
9790 let caught_up = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
9791
9792 assert_eq!(caught_up.root_epoch, Epoch(3), "walked all three refoundings");
9794 assert_eq!(caught_up.community_root, community.community_root, "landed on the owner's root");
9795 assert_eq!(caught_up.name, "Sleeper Reborn");
9796 assert!(caught_up.channels.iter().any(|c| c.id.0 == news.0), "folded the new public channel");
9798 let m = caught_up.channel(&mods).expect("mods survived");
9799 let owner_mods = community.channel(&mods).unwrap();
9800 assert_eq!(m.epoch, owner_mods.epoch, "mods walked every per-refound rotation");
9801 assert_eq!(m.key, owner_mods.key, "…to the owner's exact key");
9802 let v = caught_up.channel(&vault).expect("vault folded in");
9803 assert!(v.private && v.key.is_none(), "vault folds in keyless: entitled, but never delivered");
9808 let cid_hex = crate::simd::hex::bytes_to_hex_32(&caught_up.id().0);
9810 let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap();
9811 assert!(banned.contains(&stranger.public_key().to_hex()), "the ban folded through");
9812 let gen_texts = texts_in(&bed.relay, &caught_up, &general).await;
9815 for epoch in 0..=3u64 {
9816 let needle = if epoch == 0 { "epoch0: hello".to_string() } else { format!("epoch{epoch}: general news") };
9817 assert!(gen_texts.contains(&needle), "general history spans epoch {epoch}: {gen_texts:?}");
9818 }
9819 let mods_texts = texts_in(&bed.relay, &caught_up, &mods).await;
9820 for epoch in 0..=3u64 {
9821 let needle = if epoch == 0 { "epoch0: mods secret".to_string() } else { format!("epoch{epoch}: mods word") };
9822 assert!(mods_texts.contains(&needle), "private history spans epoch {epoch}: {mods_texts:?}");
9823 }
9824 assert!(texts_in(&bed.relay, &caught_up, &vault).await.is_empty());
9827 send_message(&bed.relay, &caught_up, &general, "member: good morning").await.unwrap();
9829 bed.swap_to(&owner);
9830 assert!(
9831 texts_in(&bed.relay, &community, &general).await.contains(&"member: good morning".to_string()),
9832 "the caught-up member converses at the new epoch ({})",
9833 member.keys.public_key().to_bech32().unwrap()
9834 );
9835 }
9836
9837 async fn flood_general(relay: &MemoryRelay, community: &CommunityV2, author: &Keys, n: usize, base_secs: u64) {
9840 let general = community.channels[0].id;
9841 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
9842 for i in 0..n {
9843 let at = base_secs + i as u64;
9844 let rumor = chat::build_message_rumor(author.public_key(), &general, community.root_epoch, &format!("msg {i}"), None, &[], vec![], at * 1000);
9845 let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, author, Timestamp::from_secs(at), false).unwrap();
9846 relay.publish(&wrap, &community.relays).await.unwrap();
9847 }
9848 }
9849
9850 #[tokio::test]
9851 async fn the_history_walk_pages_past_a_multi_page_burst() {
9852 let (_tmp, _guard, owner) = init_test_db();
9855 let relay = MemoryRelay::new();
9856 let community = create_community(&relay, "Burst", vec!["wss://r".into()], None).await.unwrap();
9857 let general = community.channels[0].id;
9858 flood_general(&relay, &community, &owner, 120, 10_000).await;
9859
9860 let all = fetch_channel_history(&relay, &community, &general, 50, 8, None, None, crate::community::transport::Evidence::Quorum, |_| true).await.unwrap();
9861 assert_eq!(all.len(), 120, "the walk pages the whole burst");
9862 let contents: Vec<String> = all.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
9864 assert_eq!(contents.first().map(String::as_str), Some("msg 0"));
9865 assert_eq!(contents.last().map(String::as_str), Some("msg 119"));
9866 let unique: std::collections::HashSet<&String> = contents.iter().collect();
9867 assert_eq!(unique.len(), 120, "wrap-id + rumor-id dedup holds across page boundaries");
9868
9869 let one = fetch_channel(&relay, &community, &general, 50).await.unwrap();
9871 assert_eq!(one.len(), 50, "fetch_channel is one newest page");
9872 assert_eq!(one.last().map(|f| f.event.opened().rumor.content.clone()).as_deref(), Some("msg 119"));
9873 }
9874
9875 #[tokio::test]
9876 async fn a_start_until_cursor_pages_history_from_that_point_backwards() {
9877 let (_tmp, _guard, owner) = init_test_db();
9881 let relay = MemoryRelay::new();
9882 let community = create_community(&relay, "Paged", vec!["wss://r".into()], None).await.unwrap();
9883 let general = community.channels[0].id;
9884 flood_general(&relay, &community, &owner, 120, 10_000).await;
9885
9886 let older = fetch_channel_history(
9887 &relay, &community, &general, 50, 8, None, Some(10_059),
9888 crate::community::transport::Evidence::Quorum, |_| true,
9889 )
9890 .await
9891 .unwrap();
9892 let contents: Vec<String> = older.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
9893 assert_eq!(contents.len(), 60, "everything at-or-before the cursor, nothing after");
9894 assert_eq!(contents.first().map(String::as_str), Some("msg 0"));
9895 assert_eq!(contents.last().map(String::as_str), Some("msg 59"));
9896 }
9897
9898 #[tokio::test]
9899 async fn the_history_walk_stops_when_the_caller_is_caught_up() {
9900 let (_tmp, _guard, owner) = init_test_db();
9901 let relay = MemoryRelay::new();
9902 let community = create_community(&relay, "Caught", vec!["wss://r".into()], None).await.unwrap();
9903 let general = community.channels[0].id;
9904 flood_general(&relay, &community, &owner, 120, 10_000).await;
9905
9906 let mut pages = 0usize;
9908 let got = fetch_channel_history(&relay, &community, &general, 50, 8, None, None, crate::community::transport::Evidence::Quorum, |_| {
9909 pages += 1;
9910 false
9911 })
9912 .await
9913 .unwrap();
9914 assert_eq!(pages, 1, "the early stop is consulted once");
9915 assert_eq!(got.len(), 50, "only the newest page is fetched");
9916 assert_eq!(got.last().map(|f| f.event.opened().rumor.content.clone()).as_deref(), Some("msg 119"));
9917 }
9918
9919 #[tokio::test]
9920 async fn a_same_second_history_wall_terminates_instead_of_looping() {
9921 let (_tmp, _guard, owner) = init_test_db();
9925 let relay = MemoryRelay::new();
9926 let community = create_community(&relay, "Wall", vec!["wss://r".into()], None).await.unwrap();
9927 let general = community.channels[0].id;
9928 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
9929 for i in 0..60usize {
9930 let rumor = chat::build_message_rumor(owner.public_key(), &general, community.root_epoch, &format!("burst {i}"), None, &[], vec![], 5_000_000 + i as u64);
9931 let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, &owner, Timestamp::from_secs(5_000), false).unwrap();
9932 relay.publish(&wrap, &community.relays).await.unwrap();
9933 }
9934 let got = fetch_channel_history(&relay, &community, &general, 25, 8, None, None, crate::community::transport::Evidence::Quorum, |_| true).await.unwrap();
9935 assert!(got.len() >= 25, "at least the relay page is read");
9936 assert!(got.len() <= 60, "sane bound");
9937 }
9939
9940 #[tokio::test]
9941 async fn a_grant_revoke_survives_a_withholding_relay() {
9942 let (_tmp, _guard, owner) = init_test_db();
9945 let relay = MemoryRelay::new();
9946 let community = create_community(&relay, "Revoke", vec!["wss://good".into()], None).await.unwrap();
9947 let admin = Keys::generate();
9948 let rid = "d4".repeat(32);
9949 publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
9950 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
9951 let session = SessionGuard::capture();
9952 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();
9955
9956 inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
9958 let mut stale = community.clone();
9959 stale.relays = vec!["wss://stale".into()];
9960 let floors = load_floors(&community);
9961 let editions = fetch_control(&relay, &stale).await;
9962 let authority = fold_authority(&stale, &editions, &floors);
9963 assert!(
9964 !authority.roles.is_authorized(&admin.public_key().to_hex(), Some(&owner.public_key().to_hex()), Permissions::MANAGE_METADATA),
9965 "the persisted grant floor refuses the rolled-back (re-granted) view"
9966 );
9967 }
9968
9969 fn load_floors(community: &CommunityV2) -> Floors {
9971 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9972 crate::db::community::get_all_edition_heads_full(&cid_hex)
9973 .unwrap_or_default()
9974 .into_iter()
9975 .filter(|(_, f)| f.0 == community.root_epoch.0)
9976 .map(|(e, f)| (e, (f.1, f.2, f.3)))
9977 .collect()
9978 }
9979
9980 async fn fetch_control(relay: &MemoryRelay, community: &CommunityV2) -> Vec<ParsedEdition> {
9982 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
9983 let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
9984 relay
9985 .fetch(&q, &community.relays)
9986 .await
9987 .unwrap_or_default()
9988 .iter()
9989 .filter_map(|w| control::open_control_edition(w, &group).ok().map(|(ed, _)| ed))
9990 .collect()
9991 }
9992
9993 #[tokio::test]
9994 async fn follow_control_is_a_noop_on_a_freshly_created_community() {
9995 let (_tmp, _guard, _owner) = init_test_db();
9996 let relay = MemoryRelay::new();
9997 let community = create_community(&relay, "Fresh", vec!["wss://r".into()], None).await.unwrap();
9998 let session = SessionGuard::capture();
9999 assert!(follow_control(&relay, &community, &session).await.unwrap().is_none());
10001 }
10002
10003 #[tokio::test]
10004 async fn follow_control_adds_a_new_public_channel_and_re_subscribes_it() {
10005 let (_tmp, _guard, owner) = init_test_db();
10006 let relay = MemoryRelay::new();
10007 let community = create_community(&relay, "Grow", vec!["wss://r".into()], None).await.unwrap();
10008 let new_id = ChannelId([0x5a; 32]);
10009 publish_channel_edition(&relay, &community, &owner, &new_id, "announcements", false, 1, false).await;
10010
10011 let session = SessionGuard::capture();
10012 let updated = follow_control(&relay, &community, &session).await.unwrap().expect("a new channel changed the view");
10013 assert_eq!(updated.channels.len(), 2);
10014 let added = updated.channel(&new_id).expect("the new channel folded in");
10015 assert_eq!(added.name, "announcements");
10016 assert!(!added.private);
10017 assert_eq!(added.key, None, "a public channel derives from the root (no stored key)");
10018
10019 let authors = super::super::realtime::plane_authors(std::slice::from_ref(&updated));
10021 let addr = channel_group_key(&updated.community_root, &new_id, updated.root_epoch).pk();
10022 assert!(authors.contains(&addr), "the added channel joins the live subscription");
10023
10024 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10026 assert!(reloaded.channel(&new_id).is_some());
10027 }
10028
10029 #[tokio::test]
10030 async fn follow_control_renames_the_community_and_an_existing_channel() {
10031 let (_tmp, _guard, owner) = init_test_db();
10032 let relay = MemoryRelay::new();
10033 let community = create_community(&relay, "Old Name", vec!["wss://r".into()], None).await.unwrap();
10034 let general = community.channels[0].id;
10035 publish_community_meta(&relay, &community, &owner, "New Name", 2).await;
10037 publish_channel_edition(&relay, &community, &owner, &general, "lobby", false, 2, false).await;
10038
10039 let session = SessionGuard::capture();
10040 let updated = follow_control(&relay, &community, &session).await.unwrap().unwrap();
10041 assert_eq!(updated.name, "New Name");
10042 assert_eq!(updated.channel(&general).unwrap().name, "lobby");
10043 assert_eq!(updated.channels.len(), 1, "a rename doesn't add a channel");
10044 }
10045
10046 #[tokio::test]
10047 async fn follow_control_deletes_a_channel() {
10048 let (_tmp, _guard, owner) = init_test_db();
10049 let relay = MemoryRelay::new();
10050 let community = create_community(&relay, "Prune", vec!["wss://r".into()], None).await.unwrap();
10051 let extra = ChannelId([0x77; 32]);
10052 let session = SessionGuard::capture();
10053
10054 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
10056 let with_extra = follow_control(&relay, &community, &session).await.unwrap().expect("added");
10057 assert!(with_extra.channel(&extra).is_some());
10058
10059 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
10061 let updated = follow_control(&relay, &with_extra, &session).await.unwrap().expect("removed");
10062 assert!(updated.channel(&extra).is_none(), "a deleted channel folds out");
10063 assert_eq!(updated.channels.len(), 1, "only #general remains");
10064 }
10065
10066 async fn inject_stale_prefix(relay: &MemoryRelay, community: &CommunityV2, max_version: u64, stale_relay: &str) {
10070 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
10071 let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
10072 let wraps = relay.fetch(&query, &community.relays).await.unwrap();
10073 for w in &wraps {
10074 if let Ok((ed, _)) = control::open_control_edition(w, &group) {
10075 if ed.version <= max_version {
10076 relay.inject(w, &[stale_relay.to_string()]);
10077 }
10078 }
10079 }
10080 }
10081
10082 #[tokio::test]
10083 async fn a_withholding_relay_cannot_roll_back_a_rename() {
10084 let (_tmp, _guard, owner) = init_test_db();
10087 let relay = MemoryRelay::new();
10088 let community = create_community(&relay, "Original", vec!["wss://good".into()], None).await.unwrap();
10089 publish_community_meta(&relay, &community, &owner, "Renamed", 2).await;
10090
10091 let session = SessionGuard::capture();
10092 let updated = follow_control(&relay, &community, &session).await.unwrap().expect("rename adopted");
10093 assert_eq!(updated.name, "Renamed");
10094
10095 inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
10097 let mut stale_view = updated.clone();
10098 stale_view.relays = vec!["wss://stale".into()];
10099 assert!(
10100 follow_control(&relay, &stale_view, &session).await.unwrap().is_none(),
10101 "a stale-only relay must not change the held view"
10102 );
10103 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10104 assert_eq!(held.name, "Renamed", "the persisted floor refuses the rollback");
10105 }
10106
10107 #[tokio::test]
10108 async fn a_withholding_relay_cannot_resurrect_a_deleted_channel() {
10109 let (_tmp, _guard, owner) = init_test_db();
10110 let relay = MemoryRelay::new();
10111 let community = create_community(&relay, "Prune2", vec!["wss://good".into()], None).await.unwrap();
10112 let extra = ChannelId([0x44; 32]);
10113 let session = SessionGuard::capture();
10114
10115 publish_community_meta(&relay, &community, &owner, "Prune2", 2).await;
10118 assert!(follow_control(&relay, &community, &session).await.unwrap().is_none());
10119
10120 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
10121 let with_extra = follow_control(&relay, &community, &session).await.unwrap().expect("added");
10122 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
10123 let pruned = follow_control(&relay, &with_extra, &session).await.unwrap().expect("removed");
10124 assert!(pruned.channel(&extra).is_none());
10125
10126 inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
10128 let mut stale_view = pruned.clone();
10129 stale_view.relays = vec!["wss://stale".into()];
10130 assert!(
10131 follow_control(&relay, &stale_view, &session).await.unwrap().is_none(),
10132 "the withheld delete must not resurrect the channel"
10133 );
10134 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10135 assert!(held.channel(&extra).is_none(), "the deleted channel stays deleted");
10136 }
10137
10138 #[tokio::test]
10139 async fn a_new_epoch_bootstraps_past_an_old_epoch_floor() {
10140 let (_tmp, _guard, owner) = init_test_db();
10144 let relay = MemoryRelay::new();
10145 let community = create_community(&relay, "Before", vec!["wss://good".into()], None).await.unwrap();
10146 let session = SessionGuard::capture();
10147 publish_community_meta(&relay, &community, &owner, "Edited", 2).await;
10148 let updated = follow_control(&relay, &community, &session).await.unwrap().expect("edit adopted");
10149 assert_eq!(updated.name, "Edited");
10150
10151 let mut refounded = updated.clone();
10154 refounded.root_epoch = crate::community::Epoch(1);
10155 crate::db::community::save_community_v2(&refounded).unwrap();
10156 publish_community_meta(&relay, &refounded, &owner, "Compacted", 5).await;
10157
10158 let adopted = follow_control(&relay, &refounded, &session).await.unwrap().expect("compacted head adopted");
10159 assert_eq!(adopted.name, "Compacted", "a fresh epoch bootstraps despite the dangling prev");
10160 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10162 let heads = crate::db::community::get_all_edition_heads_epoched(&cid_hex).unwrap();
10163 assert!(
10164 heads.get(&cid_hex).is_some_and(|(e, v, _)| *e == 1 && *v == 5),
10165 "the adopted head carries the fold's epoch + version"
10166 );
10167 }
10168
10169 #[tokio::test]
10170 async fn a_same_version_owner_fork_at_the_floor_converges_to_the_deterministic_winner() {
10171 let (_tmp, _guard, owner) = init_test_db();
10176 let relay = MemoryRelay::new();
10177 let community = create_community(&relay, "Fork", vec!["wss://r".into()], None).await.unwrap();
10178 let session = SessionGuard::capture();
10179 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
10180 let genesis_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
10181
10182 publish_community_meta(&relay, &community, &owner, "Ours", 2).await;
10183 let ours = follow_control(&relay, &community, &session).await.unwrap().expect("ours adopted");
10184 assert_eq!(ours.name, "Ours");
10185
10186 let our_inner = {
10188 let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
10189 let wraps = relay.fetch(&q, &community.relays).await.unwrap();
10190 wraps
10191 .iter()
10192 .find_map(|w| {
10193 control::open_control_edition(w, &group)
10194 .ok()
10195 .filter(|(ed, _)| ed.version == 2 && ed.vsk == vsk::COMMUNITY_METADATA)
10196 .map(|(ed, _)| ed.inner_id)
10197 })
10198 .unwrap()
10199 };
10200
10201 let meta = control::CommunityMetadata { name: "Theirs".into(), ..Default::default() };
10204 let content = serde_json::to_string(&meta).unwrap();
10205 let mut ts = 2_000u64;
10206 let fork_wrap = loop {
10207 let rumor = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &content, ts, None);
10208 let inner = rumor.id.unwrap().to_bytes();
10209 if inner < our_inner {
10210 break control::seal_control_edition(&rumor, &group, &owner, Timestamp::from_secs(ts)).unwrap().0;
10211 }
10212 ts += 1;
10213 };
10214 relay.publish(&fork_wrap, &community.relays).await.unwrap();
10215
10216 let converged = follow_control(&relay, &ours, &session).await.unwrap().expect("fork winner adopted");
10217 assert_eq!(converged.name, "Theirs", "the floor converges to the lower-inner-id winner");
10218 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10219 let held = crate::db::community::get_edition_head_inner_id(&cid_hex, &cid_hex).unwrap();
10220 assert!(held.is_some_and(|h| h < our_inner), "the persisted floor's tiebreak key moved to the winner");
10221 }
10222
10223 #[tokio::test]
10224 async fn an_anchored_prefix_applies_while_a_gap_above_awaits_the_missing_link() {
10225 let (_tmp, _guard, owner) = init_test_db();
10229 let relay = MemoryRelay::new();
10230 let community = create_community(&relay, "Prefix", vec!["wss://r".into()], None).await.unwrap();
10231 let session = SessionGuard::capture();
10232 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
10233
10234 publish_community_meta(&relay, &community, &owner, "Two", 2).await;
10235 let v2_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
10236
10237 let c3 = serde_json::to_string(&control::CommunityMetadata { name: "Three".into(), ..Default::default() }).unwrap();
10239 let r3 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 3, Some(&v2_hash), &c3, 3_000, None);
10240 let (w3, _) = control::seal_control_edition(&r3, &group, &owner, Timestamp::from_secs(3_000)).unwrap();
10241 let (ed3, _) = control::open_control_edition(&w3, &group).unwrap();
10242 let c4 = serde_json::to_string(&control::CommunityMetadata { name: "Four".into(), ..Default::default() }).unwrap();
10243 let r4 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 4, Some(&ed3.self_hash), &c4, 4_000, None);
10244 let (w4, _) = control::seal_control_edition(&r4, &group, &owner, Timestamp::from_secs(4_000)).unwrap();
10245 relay.publish(&w4, &community.relays).await.unwrap();
10246
10247 let updated = follow_control(&relay, &community, &session).await.unwrap().expect("the verified prefix applies");
10248 assert_eq!(updated.name, "Two", "the anchored prefix lands; the detached v4 does not");
10249
10250 relay.publish(&w3, &community.relays).await.unwrap();
10251 let healed = follow_control(&relay, &updated, &session).await.unwrap().expect("the chain heals");
10252 assert_eq!(healed.name, "Four", "once the link arrives, the head advances past the prefix");
10253 }
10254
10255 #[tokio::test]
10256 async fn paging_rescues_a_floor_link_evicted_from_the_newest_window() {
10257 let (_tmp, _guard, owner) = init_test_db();
10261 let relay = MemoryRelay::new();
10262 let community = create_community(&relay, "Paged", vec!["wss://r".into()], None).await.unwrap();
10263 let session = SessionGuard::capture();
10264 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
10265
10266 publish_community_meta(&relay, &community, &owner, "Two", 2).await;
10267 let base = follow_control(&relay, &community, &session).await.unwrap().expect("floor at v2");
10268 publish_community_meta(&relay, &base, &owner, "Three", 3).await; let v3_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
10270
10271 let rogue = Keys::generate();
10274 for i in 0..(FOLLOW_PAGE as u64 - 1) {
10275 let rumor = control::build_edition_rumor(rogue.public_key(), vsk::CHANNEL_METADATA, &[0xCC; 32], 1, None, "{\"name\":\"junk\",\"private\":false}", 4_000 + i, None);
10276 let (w, _) = control::seal_control_edition(&rumor, &group, &rogue, Timestamp::from_secs(4_000 + i)).unwrap();
10277 relay.publish(&w, &community.relays).await.unwrap();
10278 }
10279 let c4 = serde_json::to_string(&control::CommunityMetadata { name: "Four".into(), ..Default::default() }).unwrap();
10282 let r4 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 4, Some(&v3_hash), &c4, 10_000, None);
10283 let (w4, _) = control::seal_control_edition(&r4, &group, &owner, Timestamp::from_secs(10_000)).unwrap();
10284 relay.publish(&w4, &community.relays).await.unwrap();
10285
10286 let healed = follow_control(&relay, &base, &session).await.unwrap().expect("paging recovered the chain");
10287 assert_eq!(healed.name, "Four", "the gap paged past the flood to the floor link");
10288 }
10289
10290 #[tokio::test]
10291 async fn a_follow_after_delete_does_not_resurrect_the_community() {
10292 let (_tmp, _guard, owner) = init_test_db();
10295 let relay = MemoryRelay::new();
10296 let community = create_community(&relay, "Gone", vec!["wss://r".into()], None).await.unwrap();
10297 publish_community_meta(&relay, &community, &owner, "Edited", 2).await;
10298 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10299 crate::db::community::delete_community(&cid_hex).unwrap();
10300
10301 let session = SessionGuard::capture();
10302 assert!(
10303 follow_control(&relay, &community, &session).await.unwrap().is_none(),
10304 "a follow racing a delete is a no-op"
10305 );
10306 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none(), "the community stays deleted");
10307 assert!(crate::db::community::edition_head_entity_ids(&cid_hex).unwrap().is_empty(), "no orphan floor rows");
10308 }
10309
10310 #[tokio::test]
10311 async fn a_rekey_follow_after_delete_does_not_resurrect_the_community() {
10312 let (_tmp, _guard, owner) = init_test_db();
10315 let relay = MemoryRelay::new();
10316 let community = create_community(&relay, "GoneKeys", vec!["wss://r".into()], None).await.unwrap();
10317 let new_root = [0xB2; 32];
10318 publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
10319 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10320 crate::db::community::delete_community(&cid_hex).unwrap();
10321
10322 let session = SessionGuard::capture();
10323 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
10324 assert!(follow.updated.is_none() && !follow.self_removed, "a rekey follow racing a delete adopts nothing");
10325 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none(), "the community stays deleted");
10326 }
10327
10328 #[tokio::test]
10329 async fn a_joiner_bootstraps_the_highest_head_across_a_lost_middle_edition() {
10330 let (bed, owner, member) = TestBed::new();
10335 bed.swap_to(&owner);
10336 let community = create_community(&bed.relay, "Skip", bed.relays.clone(), None).await.unwrap();
10337 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
10338 let genesis_hash = head_hash_on_relay(&bed.relay, &community, &community.id().0).await.unwrap();
10339
10340 let c2 = serde_json::to_string(&control::CommunityMetadata { name: "Two".into(), ..Default::default() }).unwrap();
10342 let r2 = control::build_edition_rumor(owner.keys.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &c2, 2_000, None);
10343 let (w2, _) = control::seal_control_edition(&r2, &group, &owner.keys, Timestamp::from_secs(2_000)).unwrap();
10344 let (ed2, _) = control::open_control_edition(&w2, &group).unwrap();
10345 let c3 = serde_json::to_string(&control::CommunityMetadata { name: "Three".into(), ..Default::default() }).unwrap();
10346 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);
10347 let (w3, _) = control::seal_control_edition(&r3, &group, &owner.keys, Timestamp::from_secs(3_000)).unwrap();
10348 bed.relay.publish(&w3, &community.relays).await.unwrap();
10349
10350 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
10351 let bundle_json = serde_json::to_string(&bundle).unwrap();
10352 bed.swap_to(&member);
10353 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
10354 assert_eq!(joined.name, "Three", "the joiner bootstraps the highest signed head, not the anchored stale prefix");
10355 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
10356 let head = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap();
10357 assert!(head.is_some_and(|(v, _)| v == 3), "the seeded floor is the bootstrap head");
10358 }
10359
10360 #[tokio::test]
10361 async fn a_losing_same_version_fork_cannot_replace_the_held_floor() {
10362 let (_tmp, _guard, owner) = init_test_db();
10366 let relay = MemoryRelay::new();
10367 let community = create_community(&relay, "Fork2", vec!["wss://good".into()], None).await.unwrap();
10368 let session = SessionGuard::capture();
10369 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
10370 let genesis_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
10371
10372 publish_community_meta(&relay, &community, &owner, "Ours", 2).await;
10373 let ours = follow_control(&relay, &community, &session).await.unwrap().expect("ours adopted");
10374 assert_eq!(ours.name, "Ours");
10375 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10376 let held_before = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().unwrap();
10377 let our_inner = crate::db::community::get_edition_head_inner_id(&cid_hex, &cid_hex).unwrap().unwrap();
10378
10379 let meta = control::CommunityMetadata { name: "Theirs".into(), ..Default::default() };
10382 let content = serde_json::to_string(&meta).unwrap();
10383 let mut ts = 5_000u64;
10384 let fork_wrap = loop {
10385 let rumor = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &content, ts, None);
10386 if rumor.id.unwrap().to_bytes() > our_inner {
10387 break control::seal_control_edition(&rumor, &group, &owner, Timestamp::from_secs(ts)).unwrap().0;
10388 }
10389 ts += 1;
10390 };
10391 inject_stale_prefix(&relay, &community, 1, "wss://stale").await; relay.inject(&fork_wrap, &["wss://stale".to_string()]);
10393 let mut stale_view = ours.clone();
10394 stale_view.relays = vec!["wss://stale".into()];
10395
10396 assert!(
10397 follow_control(&relay, &stale_view, &session).await.unwrap().is_none(),
10398 "a losing fork served without our floor edition changes nothing"
10399 );
10400 let held_after = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().unwrap();
10401 assert_eq!(held_after, held_before, "the floor row is untouched");
10402 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10403 assert_eq!(held.name, "Ours", "the held state is untouched");
10404 }
10405
10406 #[tokio::test]
10407 async fn follow_control_ignores_a_non_owner_edition() {
10408 let (_tmp, _guard, _owner) = init_test_db();
10412 let relay = MemoryRelay::new();
10413 let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
10414 let rogue = Keys::generate();
10415 let rogue_id = ChannelId([0x99; 32]);
10416 publish_channel_edition(&relay, &community, &rogue, &rogue_id, "backdoor", false, 1, false).await;
10417
10418 let session = SessionGuard::capture();
10419 assert!(
10420 follow_control(&relay, &community, &session).await.unwrap().is_none(),
10421 "a non-owner control edition is not folded"
10422 );
10423 }
10424
10425 #[tokio::test]
10426 async fn follow_control_records_a_new_private_channel_keyless_and_unreadable() {
10427 let (_tmp, _guard, owner) = init_test_db();
10431 let relay = MemoryRelay::new();
10432 let community = create_community(&relay, "Priv", vec!["wss://r".into()], None).await.unwrap();
10433 let priv_id = ChannelId([0x33; 32]);
10434 publish_channel_edition(&relay, &community, &owner, &priv_id, "mods", true, 1, false).await;
10435
10436 let session = SessionGuard::capture();
10437 let updated = follow_control(&relay, &community, &session)
10438 .await
10439 .unwrap()
10440 .expect("the keyless record is a change");
10441 let ch = updated.channel(&priv_id).expect("the private channel is recorded");
10442 assert!(ch.private && ch.key.is_none(), "recorded keyless");
10443 assert_eq!(ch.epoch, Epoch(0), "epoch 0 = the root generation (scan cursor)");
10444 assert!(updated.channel_read_coords(ch).is_empty(), "unreadable until keyed");
10445 assert!(
10446 fetch_channel(&relay, &updated, &priv_id, 50).await.unwrap().is_empty(),
10447 "a keyless fetch returns empty (and never queries the root plane)"
10448 );
10449 assert!(
10450 send_message(&relay, &updated, &priv_id, "nope").await.is_err(),
10451 "a keyless send refuses"
10452 );
10453 let reloaded = crate::db::community::load_community_v2(updated.id()).unwrap().unwrap();
10456 let rch = reloaded.channel(&priv_id).unwrap();
10457 assert!(rch.private && rch.key.is_none() && rch.epoch == Epoch(0), "keyless survives reload");
10458 let bundle = bundle_of(&reloaded, BundleAudience::Member(Keys::generate().public_key()), None, None, None);
10462 assert!(
10463 !bundle.channels.iter().any(|c| c.id == crate::simd::hex::bytes_to_hex_32(&priv_id.0)),
10464 "an ungrantable keyless channel stays out of invite bundles"
10465 );
10466 }
10467
10468 #[tokio::test]
10469 async fn a_link_bundle_never_carries_a_private_channel_key() {
10470 let (_tmp, _guard, _owner) = init_test_db();
10475 let relay = MemoryRelay::new();
10476 let community = create_community(&relay, "Leak", vec!["wss://r".into()], None).await.unwrap();
10477 create_private_channel(&relay, &community, "mods").await.unwrap();
10478 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10479 let priv_hex = held
10480 .channels
10481 .iter()
10482 .find(|c| c.private)
10483 .map(|c| crate::simd::hex::bytes_to_hex_32(&c.id.0))
10484 .expect("the private channel is held WITH its key");
10485
10486 let link = bundle_of(&held, BundleAudience::Link, None, None, None);
10487 assert!(
10488 !link.channels.iter().any(|c| c.id == priv_hex),
10489 "a held private key must never ride a link bundle"
10490 );
10491 assert!(
10492 link.channels.iter().any(|c| c.id != priv_hex),
10493 "the public channels still ride it"
10494 );
10495
10496 let stranger = bundle_of(&held, BundleAudience::Member(Keys::generate().public_key()), None, None, None);
10500 assert!(
10501 !stranger.channels.iter().any(|c| c.id == priv_hex),
10502 "an unentitled member gets no private key"
10503 );
10504 let mine = bundle_of(&held, BundleAudience::Member(me_pk().unwrap()), None, None, None);
10505 assert!(
10506 mine.channels.iter().any(|c| c.id == priv_hex),
10507 "the creator is entitled via the companion access role"
10508 );
10509 }
10510
10511 #[tokio::test]
10512 async fn a_private_channel_mints_its_access_role_and_entitlement_follows_the_grant() {
10513 let (_tmp, _guard, _owner) = init_test_db();
10516 let relay = MemoryRelay::new();
10517 let community = create_community(&relay, "Scoped", vec!["wss://r".into()], None).await.unwrap();
10518 let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
10519 let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
10520 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10521
10522 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
10523 let access = roster.channel_roles(&chan_hex);
10524 assert_eq!(access.len(), 1, "the channel minted exactly one access role");
10525 assert!(
10526 access[0].permissions == crate::community::roles::Permissions::empty(),
10527 "the access role confers READ access (key possession), never authority"
10528 );
10529 assert_eq!(access[0].name, "mods", "named for its channel");
10530
10531 let stranger = Keys::generate().public_key();
10533 let owner_hex = community.owner().unwrap().to_hex();
10534 assert!(!roster.is_entitled(Some(&owner_hex), &stranger.to_hex(), &chan_hex, &[], &[]));
10535
10536 let role_id = access[0].role_id.clone();
10539 assert!(
10540 roster.is_entitled(Some(&owner_hex), &stranger.to_hex(), &chan_hex, std::slice::from_ref(&role_id), &[]),
10541 "the grant overlay entitles before the fold catches up"
10542 );
10543
10544 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10545 grant_channel_access(&relay, &held, &priv_id, &stranger).await.unwrap();
10546 let after = crate::db::community::get_community_roles(&cid_hex).unwrap();
10547 assert!(
10548 after.is_entitled(Some(&owner_hex), &stranger.to_hex(), &chan_hex, &[], &[]),
10549 "the grant landed in the local roster (the fold runs later)"
10550 );
10551 let vend = bundle_of(&held, BundleAudience::Member(stranger), None, None, None);
10552 assert!(
10553 vend.channels.iter().any(|c| c.id == chan_hex),
10554 "a now-entitled member's bundle carries the channel key"
10555 );
10556
10557 revoke_channel_access(&relay, &held, &priv_id, &stranger).await.unwrap();
10558 let after = crate::db::community::get_community_roles(&cid_hex).unwrap();
10559 assert!(
10560 !after.is_entitled(Some(&owner_hex), &stranger.to_hex(), &chan_hex, &[], &[]),
10561 "the revoke dropped the access role"
10562 );
10563 let rotated = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10564 assert_eq!(
10565 rotated.channel(&priv_id).unwrap().epoch,
10566 Epoch(2),
10567 "the revoke rotated the channel — a removal that doesn't rekey severs nobody"
10568 );
10569
10570 let access = crate::VectorCore.channel_access(&cid_hex, &chan_hex).unwrap();
10572 assert_eq!(access["private"], true);
10573 assert_eq!(access["readable"], true, "we minted it, so we hold its key");
10574 assert_eq!(access["roles"].as_array().unwrap().len(), 1, "one access role");
10575 let holders = access["members"].as_array().unwrap();
10576 let me_npub = {
10577 use nostr_sdk::prelude::ToBech32;
10578 me_pk().unwrap().to_bech32().unwrap()
10579 };
10580 assert_eq!(holders.len(), 1, "only the creator holds it — the revoked member is gone");
10581 assert_eq!(holders[0], serde_json::json!(me_npub), "and that holder is the creator");
10582 }
10583
10584 #[tokio::test]
10585 async fn a_public_ban_severs_the_private_channels_the_member_could_read() {
10586 let (_tmp, _guard, _owner) = init_test_db();
10591 let relay = MemoryRelay::new();
10592 let community = create_community(&relay, "Sever", vec!["wss://r".into()], None).await.unwrap();
10593 let mods = create_private_channel(&relay, &community, "mods").await.unwrap();
10594 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10595 let vault = create_private_channel(&relay, &held, "vault").await.unwrap();
10596 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10597
10598 let spammer = Keys::generate().public_key();
10599 let bystander = Keys::generate().public_key();
10600 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10601 grant_channel_access(&relay, &held, &mods, &spammer).await.unwrap();
10602 grant_channel_access(&relay, &held, &mods, &bystander).await.unwrap();
10603
10604 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
10607 let stripped: Vec<String> = roster.roles_of(&spammer.to_hex()).map(|r| r.role_id.clone()).collect();
10608 assert!(!stripped.is_empty(), "the grant landed before the strip");
10609 grant_roles(&relay, &held, &spammer, vec![]).await.unwrap();
10610
10611 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10612 let rotated = sever_banned_private_reads(&relay, &held, &spammer, &stripped).await.unwrap();
10613 assert_eq!(rotated, 1, "exactly the one channel they could read rotated");
10614 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10615 assert_eq!(after.channel(&mods).unwrap().epoch, Epoch(2), "the reachable channel advanced");
10616 assert_eq!(after.channel(&vault).unwrap().epoch, Epoch(1), "the unreachable channel did not");
10617
10618 let stranger = Keys::generate().public_key();
10620 let n = sever_banned_private_reads(&relay, &after, &stranger, &[]).await.unwrap();
10621 assert_eq!(n, 0, "no entitlement, no rotation");
10622 }
10623
10624 #[tokio::test]
10625 async fn ban_severance_refuses_without_manage_channels() {
10626 let (bed, owner, moderator) = TestBed::new();
10630 bed.swap_to(&owner);
10631 let community = create_community(&bed.relay, "Gated", bed.relays.clone(), None).await.unwrap();
10632 create_private_channel(&bed.relay, &community, "mods").await.unwrap();
10633 let rid = "c1".repeat(32);
10634 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
10635 publish_grant(&bed.relay, &community, &owner.keys, &moderator.keys.public_key(), vec![rid], 1).await;
10636 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10637 let bundle = bundle_of(&held, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
10638 let bundle_json = serde_json::to_string(&bundle).unwrap();
10639
10640 bed.swap_to(&moderator);
10641 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
10642 let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
10643 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
10644 let target = Keys::generate().public_key();
10645 let err = sever_banned_private_reads(&bed.relay, &joined, &target, &[]).await.unwrap_err();
10646 assert!(err.contains("permission"), "refused at the gate, not mid-rotation: {err}");
10647 }
10648
10649 #[tokio::test]
10650 async fn set_banlist_refuses_an_author_the_fold_would_reject() {
10651 let (bed, owner, member) = TestBed::new();
10655 bed.swap_to(&owner);
10656 let community = create_community(&bed.relay, "Soap", bed.relays.clone(), None).await.unwrap();
10657 let rid = "c2".repeat(32);
10658 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
10659 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10660 let bundle = bundle_of(&held, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
10661 let bundle_json = serde_json::to_string(&bundle).unwrap();
10662
10663 bed.swap_to(&member);
10665 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
10666 let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
10667 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
10668 let target = Keys::generate().public_key().to_hex();
10669 let err = set_banlist(&bed.relay, &joined, std::slice::from_ref(&target)).await.unwrap_err();
10670 assert!(err.contains("BAN"), "refused for the missing bit: {err}");
10671 assert!(
10672 crate::db::community::get_community_banlist(&crate::simd::hex::bytes_to_hex_32(&joined.id().0)).unwrap().is_empty(),
10673 "no phantom echo cached"
10674 );
10675
10676 bed.swap_to(&owner);
10679 publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid.clone()], 1).await;
10680 let peer = Keys::generate().public_key();
10681 publish_grant(&bed.relay, &community, &owner.keys, &peer, vec![rid], 2).await;
10682 bed.swap_to(&member);
10683 let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
10684 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
10685 let err = set_banlist(&bed.relay, &joined, &[peer.to_hex()]).await.unwrap_err();
10686 assert!(err.contains("outrank"), "an equal is not actionable: {err}");
10687 set_banlist(&bed.relay, &joined, std::slice::from_ref(&target)).await.unwrap();
10688 }
10689
10690 #[tokio::test]
10691 async fn metadata_and_link_minting_refuse_unauthorized_authors() {
10692 let (bed, owner, member) = TestBed::new();
10695 bed.swap_to(&owner);
10696 let community = create_community(&bed.relay, "Locked", bed.relays.clone(), None).await.unwrap();
10697 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10698 let bundle = bundle_of(&held, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
10699 let bundle_json = serde_json::to_string(&bundle).unwrap();
10700
10701 bed.swap_to(&member);
10702 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
10703 let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
10704 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
10705 let meta = control::CommunityMetadata {
10706 name: "Hijacked".into(),
10707 description: None,
10708 relays: vec![],
10709 icon: None,
10710 banner: None,
10711 custom: None,
10712 extra: Default::default(),
10713 };
10714 assert!(edit_community_metadata(&bed.relay, &joined, &meta).await.is_err(), "metadata edit refused");
10715 assert!(
10716 mint_public_link(&bed.relay, &joined, "https://vectorapp.io/i/", None, None).await.is_err(),
10717 "link minting refused"
10718 );
10719 }
10720
10721 #[tokio::test]
10722 async fn a_vended_key_parks_until_the_fold_proves_the_grant_then_adopts() {
10723 let (bed, owner, member) = TestBed::new();
10727 bed.swap_to(&owner);
10728 let community = create_community(&bed.relay, "Vend", bed.relays.clone(), None).await.unwrap();
10729 let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
10730 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10731 let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
10732 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10733 let real_key = held.channel(&priv_id).unwrap().key.unwrap();
10734 let owner_hex = community.owner().unwrap().to_hex();
10735
10736 bed.swap_to(&member);
10739 let me = member.keys.public_key().to_hex();
10740 let mut member_view = held.clone();
10743 if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
10744 c.key = None;
10745 c.epoch = Epoch(0);
10746 }
10747
10748 let empty = crate::community::roles::CommunityRoles::default();
10751 assert!(matches!(
10752 judge_channel_key_vend(&member_view, &empty, &priv_id, Epoch(1), &owner_hex),
10753 VendVerdict::Park(_)
10754 ));
10755
10756 let mut public_view = member_view.clone();
10758 if let Some(c) = public_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
10759 c.private = false;
10760 }
10761 assert!(matches!(
10762 judge_channel_key_vend(&public_view, &empty, &priv_id, Epoch(1), &owner_hex),
10763 VendVerdict::Refuse(_)
10764 ));
10765
10766 assert!(matches!(
10768 judge_channel_key_vend(&member_view, &empty, &ChannelId([0x77; 32]), Epoch(1), &owner_hex),
10769 VendVerdict::Park(_)
10770 ));
10771
10772 crate::db::community::park_channel_key(&cid_hex, &chan_hex, 1, &real_key, &owner_hex).unwrap();
10775 crate::db::community::set_community_roles(&cid_hex, &empty, 0).unwrap();
10776 crate::db::community::save_community_v2(&member_view).unwrap();
10777 let session = SessionGuard::capture();
10778 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10779 assert!(absorb_parked_channel_keys(&reloaded, &session).is_empty(), "unprovable vend adopts nothing");
10780 assert_eq!(
10781 crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().len(),
10782 1,
10783 "and stays parked for the next fold"
10784 );
10785
10786 let access = crate::community::roles::Role {
10788 role_id: "44".repeat(32),
10789 name: "mods".into(),
10790 position: u32::MAX - 1,
10791 permissions: crate::community::roles::Permissions::empty(),
10792 scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
10793 color: 0,
10794 };
10795 let folded = crate::community::roles::CommunityRoles {
10796 grants: vec![crate::community::roles::MemberGrant { member: me.clone(), role_ids: vec![access.role_id.clone()] }],
10797 roles: vec![access],
10798 };
10799 crate::db::community::set_community_roles(&cid_hex, &folded, 1).unwrap();
10800 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10801 let adopted = absorb_parked_channel_keys(&reloaded, &session);
10802 assert_eq!(adopted.len(), 1, "the re-judge adopts once the grant folds");
10803
10804 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10805 let ch = after.channel(&priv_id).unwrap();
10806 assert_eq!(ch.key, Some(real_key), "adopted the vended key");
10807 assert_eq!(ch.epoch, Epoch(1));
10808 assert!(
10809 crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
10810 "and the park is discharged"
10811 );
10812 }
10813
10814 #[tokio::test]
10815 async fn a_vend_at_epoch_zero_is_adopted_onto_a_keyless_channel() {
10816 let (bed, owner, member) = TestBed::new();
10821 bed.swap_to(&owner);
10822 let community = create_community(&bed.relay, "EpochZero", bed.relays.clone(), None).await.unwrap();
10823 let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
10824 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10825 let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
10826 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10827 let vended = [0x5a; 32];
10828 let owner_hex = community.owner().unwrap().to_hex();
10829
10830 bed.swap_to(&member);
10831 let mut member_view = held.clone();
10833 if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
10834 c.key = None;
10835 c.epoch = Epoch(0);
10836 }
10837 crate::db::community::save_community_v2(&member_view).unwrap();
10838
10839 let access = crate::community::roles::Role {
10841 role_id: "77".repeat(32),
10842 name: "mods".into(),
10843 position: u32::MAX - 1,
10844 permissions: crate::community::roles::Permissions::empty(),
10845 scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
10846 color: 0,
10847 };
10848 let roster = crate::community::roles::CommunityRoles {
10849 grants: vec![crate::community::roles::MemberGrant {
10850 member: member.keys.public_key().to_hex(),
10851 role_ids: vec![access.role_id.clone()],
10852 }],
10853 roles: vec![access],
10854 };
10855 crate::db::community::set_community_roles(&cid_hex, &roster, 1).unwrap();
10856 crate::db::community::park_channel_key(&cid_hex, &chan_hex, 0, &vended, &owner_hex).unwrap();
10857
10858 let session = SessionGuard::capture();
10859 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10860 let adopted = absorb_parked_channel_keys(&reloaded, &session);
10861 assert_eq!(adopted.len(), 1, "an epoch-0 vend onto a keyless channel is adopted");
10862
10863 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10864 let ch = after.channel(&priv_id).unwrap();
10865 assert_eq!(ch.key, Some(vended), "the key actually landed on the row");
10866 assert_eq!(ch.epoch, Epoch(0), "at the epoch the vendor named");
10867 assert!(
10868 !after.channel_read_coords(ch).is_empty(),
10869 "and the channel is readable — the whole point"
10870 );
10871 assert!(
10872 crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
10873 "the park is discharged"
10874 );
10875 }
10876
10877 #[tokio::test]
10878 async fn a_wildly_ahead_vend_epoch_is_refused_not_seated() {
10879 let (bed, owner, member) = TestBed::new();
10885 bed.swap_to(&owner);
10886 let community = create_community(&bed.relay, "Poison", bed.relays.clone(), None).await.unwrap();
10887 let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
10888 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10889 let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
10890 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10891 let owner_hex = community.owner().unwrap().to_hex();
10892
10893 bed.swap_to(&member);
10894 let mut member_view = held.clone();
10895 if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
10896 c.key = None;
10897 c.epoch = Epoch(0);
10898 }
10899 crate::db::community::save_community_v2(&member_view).unwrap();
10900 let access = crate::community::roles::Role {
10901 role_id: "99".repeat(32),
10902 name: "mods".into(),
10903 position: u32::MAX - 1,
10904 permissions: crate::community::roles::Permissions::empty(),
10905 scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
10906 color: 0,
10907 };
10908 let roster = crate::community::roles::CommunityRoles {
10909 grants: vec![crate::community::roles::MemberGrant {
10910 member: member.keys.public_key().to_hex(),
10911 role_ids: vec![access.role_id.clone()],
10912 }],
10913 roles: vec![access],
10914 };
10915 crate::db::community::set_community_roles(&cid_hex, &roster, 1).unwrap();
10916 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10917
10918 assert!(matches!(
10920 judge_channel_key_vend(&reloaded, &roster, &priv_id, Epoch(1 << 40), &owner_hex),
10921 VendVerdict::Refuse(_)
10922 ));
10923 crate::db::community::park_channel_key(&cid_hex, &chan_hex, 1 << 40, &[0xEE; 32], &owner_hex).unwrap();
10925 let session = SessionGuard::capture();
10926 assert!(absorb_parked_channel_keys(&reloaded, &session).is_empty(), "a poison epoch adopts nothing");
10927 assert!(
10928 crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
10929 "and the row is discharged rather than parked forever"
10930 );
10931 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10933 assert_eq!(after.channel(&priv_id).unwrap().epoch, Epoch(0), "head never advanced");
10934 assert!(matches!(
10935 judge_channel_key_vend(&after, &roster, &priv_id, Epoch(1), &owner_hex),
10936 VendVerdict::Accept
10937 ));
10938 }
10939
10940 #[tokio::test]
10941 async fn a_channel_rename_lands_locally_without_waiting_for_the_fold() {
10942 let (_tmp, _guard, _owner) = init_test_db();
10945 let relay = MemoryRelay::new();
10946 let community = create_community(&relay, "Renames", vec!["wss://r".into()], None).await.unwrap();
10947 let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
10948 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10949 let key_before = held.channel(&priv_id).unwrap().key;
10950
10951 let mut meta = held.channel(&priv_id).unwrap().metadata();
10952 meta.name = "staff".into();
10953 edit_channel_metadata(&relay, &held, &priv_id, &meta).await.unwrap();
10954
10955 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10956 let ch = after.channel(&priv_id).unwrap();
10957 assert_eq!(ch.name, "staff", "the rename is visible immediately");
10958 assert!(ch.private, "and privacy survives the edit");
10959 assert_eq!(ch.key, key_before, "as does the key — a rename is not a rotation");
10960 }
10961
10962 #[tokio::test]
10963 async fn a_channel_rename_carries_its_companion_access_role() {
10964 let (_tmp, _guard, _owner) = init_test_db();
10965 let relay = MemoryRelay::new();
10966 let community = create_community(&relay, "Renames", vec!["wss://r".into()], None).await.unwrap();
10967 let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
10968 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10969 let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
10970
10971 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
10972 let before = roster.channel_roles(&chan_hex);
10973 assert_eq!(before.len(), 1, "one companion role, minted at create");
10974 assert_eq!(before[0].name, "mods", "named after the channel it gates");
10975 let role_id = before[0].role_id.clone();
10976
10977 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10978 let mut meta = held.channel(&priv_id).unwrap().metadata();
10979 meta.name = "staff".into();
10980 edit_channel_metadata(&relay, &held, &priv_id, &meta).await.unwrap();
10981
10982 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
10983 let after = roster.channel_roles(&chan_hex);
10984 assert_eq!(after.len(), 1, "renamed in place, never duplicated");
10985 assert_eq!(after[0].role_id, role_id, "a rename is a versioned edit of the same id");
10986 assert_eq!(after[0].name, "staff", "the access role followed the channel");
10987 assert_eq!(
10988 after[0].permissions,
10989 crate::community::roles::Permissions::empty(),
10990 "and still confers read access, never authority"
10991 );
10992 }
10993
10994 #[tokio::test]
10995 async fn a_customised_access_role_name_survives_a_channel_rename() {
10996 let (_tmp, _guard, _owner) = init_test_db();
10999 let relay = MemoryRelay::new();
11000 let community = create_community(&relay, "Renames", vec!["wss://r".into()], None).await.unwrap();
11001 let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
11002 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11003 let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
11004
11005 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
11006 let mut role = roster.channel_roles(&chan_hex)[0].clone();
11007 role.name = "Lab Insiders".into();
11008 set_role(&relay, &community, &role).await.unwrap();
11009 merge_local_roster(&cid_hex, Some(&role), None);
11010
11011 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11012 let mut meta = held.channel(&priv_id).unwrap().metadata();
11013 meta.name = "staff".into();
11014 edit_channel_metadata(&relay, &held, &priv_id, &meta).await.unwrap();
11015
11016 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
11017 assert_eq!(
11018 roster.channel_roles(&chan_hex)[0].name,
11019 "Lab Insiders",
11020 "a deliberate name is left alone"
11021 );
11022 }
11023
11024 #[tokio::test]
11025 async fn a_squatted_park_row_cannot_suppress_the_genuine_vend() {
11026 let (bed, owner, member) = TestBed::new();
11032 bed.swap_to(&owner);
11033 let community = create_community(&bed.relay, "Squat", bed.relays.clone(), None).await.unwrap();
11034 let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
11035 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11036 let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
11037 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11038 let real_key = held.channel(&priv_id).unwrap().key.unwrap();
11039 let owner_hex = community.owner().unwrap().to_hex();
11040
11041 bed.swap_to(&member);
11042 let mut member_view = held.clone();
11043 if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
11044 c.key = None;
11045 c.epoch = Epoch(0);
11046 }
11047 crate::db::community::save_community_v2(&member_view).unwrap();
11048 let access = crate::community::roles::Role {
11049 role_id: "aa".repeat(32),
11050 name: "mods".into(),
11051 position: u32::MAX - 1,
11052 permissions: crate::community::roles::Permissions::empty(),
11053 scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
11054 color: 0,
11055 };
11056 let roster = crate::community::roles::CommunityRoles {
11057 grants: vec![crate::community::roles::MemberGrant {
11058 member: member.keys.public_key().to_hex(),
11059 role_ids: vec![access.role_id.clone()],
11060 }],
11061 roles: vec![access],
11062 };
11063 crate::db::community::set_community_roles(&cid_hex, &roster, 1).unwrap();
11064
11065 let stranger = Keys::generate().public_key().to_hex();
11067 crate::db::community::park_channel_key(&cid_hex, &chan_hex, 9, &[0xBA; 32], &stranger).unwrap();
11068 crate::db::community::park_channel_key(&cid_hex, &chan_hex, 1, &real_key, &owner_hex).unwrap();
11070 assert_eq!(
11071 crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().len(),
11072 2,
11073 "the squatter never displaces the genuine vend — both are candidates"
11074 );
11075
11076 let session = SessionGuard::capture();
11077 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11078 let adopted = absorb_parked_channel_keys(&reloaded, &session);
11079 assert_eq!(adopted.len(), 1, "exactly one adoption");
11080
11081 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11082 let ch = after.channel(&priv_id).unwrap();
11083 assert_eq!(ch.key, Some(real_key), "the OWNER's key won, not the squatter's");
11084 assert_eq!(ch.epoch, Epoch(1), "at the genuine epoch");
11085 assert!(
11086 crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
11087 "and every candidate for the channel is discharged"
11088 );
11089 }
11090
11091 #[tokio::test]
11092 async fn revoking_without_a_folded_access_role_refuses_instead_of_evicting_everyone() {
11093 let (_tmp, _guard, _owner) = init_test_db();
11098 let relay = MemoryRelay::new();
11099 let community = create_community(&relay, "NoRole", vec!["wss://r".into()], None).await.unwrap();
11100 let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
11101 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11102 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11103 let before = held.channel(&priv_id).unwrap().epoch;
11104
11105 crate::db::community::set_community_roles(&cid_hex, &crate::community::roles::CommunityRoles::default(), 0).unwrap();
11109 let mut blind = held.clone();
11110 blind.relays = vec!["wss://empty".into()];
11111 let err = revoke_channel_access(&relay, &blind, &priv_id, &Keys::generate().public_key())
11112 .await
11113 .unwrap_err();
11114 assert!(err.contains("has not folded"), "refuses with a retryable reason: {err}");
11115
11116 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11117 assert_eq!(after.channel(&priv_id).unwrap().epoch, before, "and rotates nothing");
11118 }
11119
11120 async fn publish_base_rotation(
11127 relay: &MemoryRelay,
11128 community: &CommunityV2,
11129 rotator: &Keys,
11130 recipients: &[PublicKey],
11131 new_root: &[u8; 32],
11132 prev_key: &[u8; 32],
11133 ) {
11134 let new_epoch = Epoch(community.root_epoch.0 + 1);
11135 let prev_epoch = community.root_epoch;
11136 let prev_commit = super::super::derive::epoch_key_commitment(prev_epoch, prev_key);
11137 let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
11138 let blobs: Vec<_> = recipients
11139 .iter()
11140 .map(|r| rekey::build_blob_local(rotator.secret_key(), &rotator.public_key().to_bytes(), r, RekeyScope::Root, new_epoch, new_root).unwrap())
11141 .collect();
11142 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();
11143 for e in &events {
11144 relay.publish(e, &community.relays).await.unwrap();
11145 }
11146 }
11147
11148 fn add_private_channel(community: &mut CommunityV2, id: ChannelId, key: [u8; 32], epoch: Epoch) {
11150 community.channels.push(ChannelV2 { id, name: "mods".into(), private: true, key: Some(key), epoch, voice: None, meta_custom: None, meta_extra: Default::default() });
11151 crate::db::community::save_community_v2(community).unwrap();
11152 }
11153
11154 #[tokio::test]
11155 async fn follow_rekeys_is_a_noop_without_rotations() {
11156 let (_tmp, _guard, _owner) = init_test_db();
11157 let relay = MemoryRelay::new();
11158 let community = create_community(&relay, "Still", vec!["wss://r".into()], None).await.unwrap();
11159 let session = SessionGuard::capture();
11160 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
11161 assert!(follow.updated.is_none() && !follow.self_removed, "no rotation → nothing to adopt");
11162 }
11163
11164 #[tokio::test]
11165 async fn follow_rekeys_adopts_an_owner_base_rotation() {
11166 let (_tmp, _guard, owner) = init_test_db();
11167 let relay = MemoryRelay::new();
11168 let community = create_community(&relay, "Refound", vec!["wss://r".into()], None).await.unwrap();
11169 let new_root = [0xB1; 32];
11170 publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
11172
11173 let session = SessionGuard::capture();
11174 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopted");
11175 assert_eq!(updated.root_epoch, Epoch(1), "advanced one epoch");
11176 assert_eq!(updated.community_root, new_root, "adopted the fresh root");
11177 let addr = super::super::realtime::plane_authors(std::slice::from_ref(&updated));
11179 let general = updated.channels[0].id;
11180 let new_chat = channel_group_key(&new_root, &general, Epoch(1)).pk();
11181 assert!(addr.contains(&new_chat), "the public channel re-addresses under the new root");
11182 }
11183
11184 #[tokio::test]
11185 async fn follow_rekeys_adopts_an_owner_private_channel_rotation() {
11186 let (_tmp, _guard, owner) = init_test_db();
11187 let relay = MemoryRelay::new();
11188 let mut community = create_community(&relay, "PrivRot", vec!["wss://r".into()], None).await.unwrap();
11189 let priv_id = ChannelId([0x33; 32]);
11190 add_private_channel(&mut community, priv_id, [0x44; 32], Epoch(0));
11191
11192 let new_key = [0x55; 32];
11194 let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &[0x44; 32]);
11195 let group = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(1));
11196 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();
11197 let events = rekey::build_rekey_chunks_local(&owner, &group, RekeyScope::Channel(priv_id), Epoch(1), Epoch(0), &prev_commit, &[blob], 2_000, None).unwrap();
11198 for e in &events {
11199 relay.publish(e, &community.relays).await.unwrap();
11200 }
11201
11202 let session = SessionGuard::capture();
11203 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopted");
11204 let ch = updated.channel(&priv_id).unwrap();
11205 assert_eq!(ch.epoch, Epoch(1), "the private channel advanced an epoch");
11206 assert_eq!(ch.key, Some(new_key), "adopted the fresh channel key");
11207 assert_eq!(updated.root_epoch, Epoch(0), "the base is untouched by a channel rotation");
11208 }
11209
11210 #[tokio::test]
11211 async fn follow_rekeys_ignores_a_non_owner_rotation() {
11212 let (_tmp, _guard, _owner) = init_test_db();
11215 let relay = MemoryRelay::new();
11216 let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
11217 let rogue = Keys::generate();
11218 publish_base_rotation(&relay, &community, &rogue, &[rogue.public_key()], &[0xEE; 32], &community.community_root).await;
11219
11220 let session = SessionGuard::capture();
11221 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
11222 assert!(follow.updated.is_none() && !follow.self_removed, "a non-owner rotation is not adopted");
11223 }
11224
11225 #[tokio::test]
11226 async fn follow_rekeys_ignores_a_rotation_off_the_wrong_prev() {
11227 let (_tmp, _guard, owner) = init_test_db();
11230 let relay = MemoryRelay::new();
11231 let community = create_community(&relay, "Forked", vec!["wss://r".into()], None).await.unwrap();
11232 publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &[0xB2; 32], &[0x00; 32]).await;
11234
11235 let session = SessionGuard::capture();
11236 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
11237 assert!(follow.updated.is_none(), "a fork off the wrong prev is not adopted");
11238 }
11239
11240 #[tokio::test]
11241 async fn follow_rekeys_holds_on_an_incomplete_rotation() {
11242 let (_tmp, _guard, owner) = init_test_db();
11245 let relay = MemoryRelay::new();
11246 let community = create_community(&relay, "Partial", vec!["wss://r".into()], None).await.unwrap();
11247 let new_epoch = Epoch(1);
11248 let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
11249 let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
11250 let other = Keys::generate();
11252 let blob = rekey::build_blob_local(owner.secret_key(), &owner.public_key().to_bytes(), &other.public_key(), RekeyScope::Root, new_epoch, &[0xB3; 32]).unwrap();
11253 let rumor = rekey::build_rekey_rumor(owner.public_key(), RekeyScope::Root, new_epoch, Epoch(0), &prev_commit, &[blob], 1, 2, 2_000, None).unwrap();
11254 let (wrap, _) = rekey::seal_rekey_chunk(&rumor, &group, &owner, Timestamp::from_secs(2_000)).unwrap();
11255 relay.publish(&wrap, &community.relays).await.unwrap();
11256
11257 let session = SessionGuard::capture();
11258 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
11259 assert!(follow.updated.is_none() && !follow.self_removed, "an incomplete rotation neither adopts nor removes");
11260 }
11261
11262 #[tokio::test]
11263 async fn follow_rekeys_removes_a_member_dropped_by_a_base_rotation() {
11264 let (bed, owner, member) = TestBed::new();
11268 bed.swap_to(&owner);
11269 let community = create_community(&bed.relay, "Evict", bed.relays.clone(), None).await.unwrap();
11270 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
11271
11272 bed.swap_to(&member);
11273 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
11274 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
11275
11276 bed.swap_to(&owner);
11278 let stranger = Keys::generate();
11279 publish_base_rotation(&bed.relay, &community, &owner.keys, &[stranger.public_key()], &[0xC4; 32], &community.community_root).await;
11280
11281 bed.swap_to(&member);
11283 let session = SessionGuard::capture();
11284 let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
11285 assert!(follow.self_removed, "a complete base rotation dropping the member removes them");
11286 assert!(follow.updated.is_none(), "a removed member adopts nothing");
11287 }
11288
11289 #[tokio::test]
11290 async fn follow_rekeys_finds_a_channel_rekey_under_an_archived_prior_root() {
11291 let (_tmp, _guard, owner) = init_test_db();
11297 let relay = MemoryRelay::new();
11298 let mut community = create_community(&relay, "Strand", vec!["wss://r".into()], None).await.unwrap();
11299 let root0 = community.community_root;
11300 let priv_id = ChannelId([0x33; 32]);
11301 let key1 = [0x44; 32];
11302 add_private_channel(&mut community, priv_id, key1, Epoch(1));
11303
11304 let key2 = [0x55; 32];
11307 let prev_commit = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
11308 let group = channel_rekey_group_key(&root0, &priv_id, Epoch(2));
11309 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();
11310 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() {
11311 relay.publish(&e, &community.relays).await.unwrap();
11312 }
11313
11314 community.community_root = [0xB7; 32];
11318 community.root_epoch = Epoch(1);
11319 crate::db::community::save_community_v2(&community).unwrap();
11320
11321 let session = SessionGuard::capture();
11322 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the prior-root crate is found");
11323 let ch = updated.channel(&priv_id).unwrap();
11324 assert_eq!(ch.epoch, Epoch(2), "the channel advanced despite the moved base");
11325 assert_eq!(ch.key, Some(key2), "adopted the key delivered under the prior root");
11326 }
11327
11328 #[tokio::test]
11329 async fn follow_rekeys_keyless_cursor_walks_past_an_excluding_rotation_then_adopts() {
11330 let (_tmp, _guard, owner) = init_test_db();
11335 let relay = MemoryRelay::new();
11336 let mut community = create_community(&relay, "Cursor", vec!["wss://r".into()], None).await.unwrap();
11337 let priv_id = ChannelId([0x66; 32]);
11338 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() });
11339 crate::db::community::save_community_v2(&community).unwrap();
11340 let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11341 assert!(community.channel(&priv_id).unwrap().key.is_none(), "keyless survives the round-trip");
11342
11343 let stranger = Keys::generate();
11345 let key1 = [0x71; 32];
11346 let pc1 = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
11347 let g1 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(1));
11348 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();
11349 for e in rekey::build_rekey_chunks_local(&owner, &g1, RekeyScope::Channel(priv_id), Epoch(1), Epoch(0), &pc1, &[b1], 2_000, None).unwrap() {
11350 relay.publish(&e, &community.relays).await.unwrap();
11351 }
11352 let key2 = [0x72; 32];
11355 let pc2 = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
11356 let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
11357 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();
11358 for e in rekey::build_rekey_chunks_local(&owner, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc2, &[b2], 2_100, None).unwrap() {
11359 relay.publish(&e, &community.relays).await.unwrap();
11360 }
11361
11362 let session = SessionGuard::capture();
11365 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the walk lands on the included epoch");
11366 let ch = updated.channel(&priv_id).unwrap();
11367 assert_eq!(ch.epoch, Epoch(2), "cursor walked through the excluding epoch to the included one");
11368 assert_eq!(ch.key, Some(key2), "adopted the delivery that includes us");
11369 }
11370
11371 #[tokio::test]
11372 async fn follow_rekeys_honors_an_admin_channel_rotation_but_never_a_strangers() {
11373 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
11377 let (_tmp, _guard, _owner) = init_test_db();
11378 let relay = MemoryRelay::new();
11379 let mut community = create_community(&relay, "AdminRot", vec!["wss://r".into()], None).await.unwrap();
11380 let priv_id = ChannelId([0x88; 32]);
11381 let key1 = [0x91; 32];
11382 add_private_channel(&mut community, priv_id, key1, Epoch(1));
11383
11384 let admin = Keys::generate();
11386 let role = Role::admin("aa".repeat(32));
11387 let roster = CommunityRoles {
11388 roles: vec![role.clone()],
11389 grants: vec![MemberGrant { member: admin.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }],
11390 };
11391 seed_roster_with_heads(&community, &roster, 1_000);
11392
11393 let key2 = [0x92; 32];
11395 let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
11396 let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
11397 let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
11398 let blob = rekey::build_blob_local(admin.secret_key(), &admin.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
11399 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() {
11400 relay.publish(&e, &community.relays).await.unwrap();
11401 }
11402 let session = SessionGuard::capture();
11403 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("an admin rotation is honored");
11404 assert_eq!(updated.channel(&priv_id).unwrap().key, Some(key2), "adopted the admin's key");
11405
11406 let rogue = Keys::generate();
11408 let key3 = [0x93; 32];
11409 let pc3 = super::super::derive::epoch_key_commitment(Epoch(2), &key2);
11410 let g3 = channel_rekey_group_key(&updated.community_root, &priv_id, Epoch(3));
11411 let rb = rekey::build_blob_local(rogue.secret_key(), &rogue.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(3), &key3).unwrap();
11412 for e in rekey::build_rekey_chunks_local(&rogue, &g3, RekeyScope::Channel(priv_id), Epoch(3), Epoch(2), &pc3, &[rb], 2_100, None).unwrap() {
11413 relay.publish(&e, &updated.relays).await.unwrap();
11414 }
11415 let follow = follow_rekeys(&relay, &updated, &session).await.unwrap();
11416 assert!(follow.updated.is_none(), "a stranger's channel rotation is never adopted");
11417 }
11418
11419 #[tokio::test]
11420 async fn a_non_outranking_admins_rotation_never_concludes_my_removal() {
11421 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
11426 let (bed, owner, member) = TestBed::new();
11427 bed.swap_to(&owner);
11428 let community = create_community(&bed.relay, "Outrank", bed.relays.clone(), None).await.unwrap();
11429
11430 bed.swap_to(&member);
11433 let mut held = community.clone();
11434 let priv_id = ChannelId([0xAB; 32]);
11435 let key1 = [0xA1; 32];
11436 add_private_channel(&mut held, priv_id, key1, Epoch(1));
11437 let peer = Keys::generate();
11438 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11439 let role = Role::admin("bb".repeat(32));
11440 let roster = CommunityRoles {
11441 roles: vec![role.clone()],
11442 grants: vec![
11443 MemberGrant { member: peer.public_key().to_hex(), role_ids: vec![role.role_id.clone()] },
11444 MemberGrant { member: member.keys.public_key().to_hex(), role_ids: vec![role.role_id.clone()] },
11445 ],
11446 };
11447 crate::db::community::set_community_roles(&cid_hex, &roster, 1_000).unwrap();
11448
11449 let key2 = [0xA2; 32];
11451 let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
11452 let g2 = channel_rekey_group_key(&held.community_root, &priv_id, Epoch(2));
11453 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();
11454 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() {
11455 bed.relay.publish(&e, &held.relays).await.unwrap();
11456 }
11457 let session = SessionGuard::capture();
11458 let follow = follow_rekeys(&bed.relay, &held, &session).await.unwrap();
11459 assert!(follow.updated.is_none(), "an equal-rank rotation excluding me is Stay, never my removal");
11460 let reloaded = crate::db::community::load_community_v2(held.id()).unwrap().unwrap();
11461 assert!(reloaded.channel(&priv_id).is_some(), "my channel record survives the peer's rotation");
11462
11463 let key3 = [0xA3; 32];
11465 let stranger = Keys::generate();
11466 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();
11467 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() {
11468 bed.relay.publish(&e, &held.relays).await.unwrap();
11469 }
11470 let follow = follow_rekeys(&bed.relay, &held, &session).await.unwrap();
11471 let updated = follow.updated.expect("the owner's removal folds");
11472 assert!(updated.channel(&priv_id).is_none(), "the owner's exclusion cuts my channel record");
11473 }
11474
11475 #[tokio::test]
11476 async fn converting_a_public_channel_to_private_is_refused() {
11477 let (_tmp, _guard, _owner) = init_test_db();
11480 let relay = MemoryRelay::new();
11481 let community = create_community(&relay, "NoConvert", vec!["wss://r".into()], None).await.unwrap();
11482 let general = community.channels[0].id;
11483 let meta = control::ChannelMetadata { name: "general".into(), private: true, voice: None, deleted: None, custom: None, extra: Default::default() };
11484 let err = edit_channel_metadata(&relay, &community, &general, &meta).await.unwrap_err();
11485 assert!(err.contains("not supported"), "conversion is refused at the producer: {err}");
11486 let meta = control::ChannelMetadata { name: "lobby".into(), private: false, voice: None, deleted: None, custom: None, extra: Default::default() };
11488 edit_channel_metadata(&relay, &community, &general, &meta).await.unwrap();
11489 }
11490
11491 async fn publish_remote_tombstone(relay: &MemoryRelay, me: &Keys, relays: &[String], cid_hex: &str, removed_at: u64) {
11494 let doc = super::super::list::CommunityList {
11495 entries: vec![],
11496 tombstones: vec![super::super::list::Tombstone { community_id: cid_hex.to_string(), removed_at, extra: Default::default() }],
11497 extra: Default::default(),
11498 };
11499 let event = super::super::list::build_list_event(me, &doc).unwrap();
11500 relay.publish(&event, relays).await.unwrap();
11501 }
11502
11503 #[tokio::test]
11504 async fn joining_one_community_does_not_resurrect_a_sibling_left_community() {
11505 let (_tmp, _guard, me) = init_test_db();
11509 let relay = MemoryRelay::new();
11510 let x = create_community(&relay, "X", vec!["wss://r".into()], None).await.unwrap();
11511 let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
11512
11513 publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, now_ms() + 10_000).await;
11515
11516 let y = create_community(&relay, "Y", vec!["wss://r".into()], None).await.unwrap();
11518 republish_community_list(&relay, Some(y.id())).await.unwrap();
11519
11520 let list = fetch_community_list(&relay, &x.relays).await.unwrap().unwrap();
11522 assert!(!list.is_live(&x_hex), "joining Y did not resurrect the sibling-left X");
11523 assert!(list.is_live(&crate::simd::hex::bytes_to_hex_32(&y.id().0)), "Y is live");
11524 }
11525
11526 #[tokio::test]
11527 async fn sync_tears_down_a_community_a_sibling_left() {
11528 let (_tmp, _guard, me) = init_test_db();
11531 let relay = MemoryRelay::new();
11532 let x = create_community(&relay, "Leaveme", vec!["wss://r".into()], None).await.unwrap();
11533 let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
11534 assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_some(), "held before sync");
11535
11536 publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, now_ms() + 10_000).await;
11537 sync_community_list(&relay, &x.relays).await.unwrap();
11538 assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_none(), "the sibling's leave tore X down locally");
11539 }
11540
11541 #[tokio::test]
11542 async fn a_rejoined_community_survives_a_stale_tombstone_on_sync() {
11543 let (_tmp, _guard, me) = init_test_db();
11546 let relay = MemoryRelay::new();
11547 let x = create_community(&relay, "Rejoin", vec!["wss://r".into()], None).await.unwrap();
11548 let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
11549 publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, 1).await;
11551 republish_community_list(&relay, Some(x.id())).await.unwrap();
11553 sync_community_list(&relay, &x.relays).await.unwrap();
11554 assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_some(), "a re-joined community is not torn down by a stale tombstone");
11555 }
11556
11557 #[tokio::test]
11558 async fn a_failed_remote_fetch_never_clobbers_the_published_list() {
11559 let (_tmp, _guard, _me) = init_test_db();
11562 let good = MemoryRelay::new();
11563 let community = create_community(&good, "Seeded", vec!["wss://r".into()], None).await.unwrap();
11564 assert!(fetch_community_list(&good, &community.relays).await.unwrap().is_some());
11565
11566 struct FetchErrors;
11568 #[async_trait::async_trait]
11569 impl Transport for FetchErrors {
11570 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
11571 async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
11572 panic!("republish must NOT publish when the remote fetch failed");
11573 }
11574 async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
11575 Ok(())
11576 }
11577 async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
11578 Err("relay unreachable".to_string())
11579 }
11580 }
11581 republish_community_list(&FetchErrors, Some(community.id())).await.unwrap();
11583 }
11584
11585 #[tokio::test]
11586 async fn a_granted_member_survives_a_refounding_even_with_no_guestbook_join() {
11587 let (_tmp, _guard, owner) = init_test_db();
11593 let relay = MemoryRelay::new();
11594 let community = create_community(&relay, "Backstop", vec!["wss://r".into()], None).await.unwrap();
11595
11596 let lurker = Keys::generate();
11598 let rid = "b1".repeat(32);
11599 publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
11600 publish_grant(&relay, &community, &owner, &lurker.public_key(), vec![rid.clone()], 1).await;
11601
11602 let members = memberlist(&relay, &community).await.unwrap();
11604 assert!(members.contains(&lurker.public_key()), "a granted member with no Join is still a member");
11605
11606 let banned_grantee = Keys::generate();
11608 publish_grant(&relay, &community, &owner, &banned_grantee.public_key(), vec![rid], 1).await;
11609 set_banlist(&relay, &community, &[banned_grantee.public_key().to_hex()]).await.unwrap();
11610 let members = memberlist(&relay, &community).await.unwrap();
11611 assert!(members.contains(&lurker.public_key()), "the honest grantee still counts");
11612 assert!(!members.contains(&banned_grantee.public_key()), "a banned grantee is not re-admitted by the union");
11613
11614 let refounded = refound_community(&relay, &community, &[]).await.unwrap();
11616 assert_eq!(refounded.root_epoch, Epoch(1));
11617 let base_group = base_rekey_group_key(&community.community_root, community.id(), Epoch(1));
11618 let chunks = fetch_rekey_chunks(&relay, &community.relays, &base_group).await.unwrap();
11619 let rotations = rekey::collect_rotations(&chunks);
11620 let lurker_x = lurker.public_key().to_bytes();
11621 let delivered = rotations.iter().any(|r| {
11622 rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &lurker_x, r.scope, r.new_epoch).is_some()
11623 });
11624 assert!(delivered, "the Refounding delivered the new root to the granted lurker");
11625 }
11626
11627 #[tokio::test]
11628 async fn the_memberlist_pages_past_a_guestbook_flood() {
11629 let (_tmp, _guard, _owner) = init_test_db();
11633 let relay = MemoryRelay::new();
11634 let community = create_community(&relay, "GBFlood", vec!["wss://r".into()], None).await.unwrap();
11635 let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
11636
11637 let honest = Keys::generate();
11639 let join = guestbook::build_join_rumor(honest.public_key(), None, 1_000);
11640 let (w, _) = guestbook::seal_guestbook_rumor(&join, &gb, &honest, Timestamp::from_secs(1)).unwrap();
11641 relay.publish(&w, &community.relays).await.unwrap();
11642 for i in 0..600u64 {
11643 let throwaway = Keys::generate();
11644 let j = guestbook::build_join_rumor(throwaway.public_key(), None, 2_000 + i);
11645 let (w, _) = guestbook::seal_guestbook_rumor(&j, &gb, &throwaway, Timestamp::from_secs(2 + i)).unwrap();
11646 relay.publish(&w, &community.relays).await.unwrap();
11647 }
11648
11649 let members = memberlist(&relay, &community).await.unwrap();
11650 assert!(members.contains(&honest.public_key()), "the honest member's aged-out Join is still counted past the flood");
11651 }
11652
11653 #[tokio::test]
11654 async fn a_rekey_plane_flood_cannot_bury_a_genuine_rotation() {
11655 let (_tmp, _guard, owner) = init_test_db();
11660 let relay = MemoryRelay::new();
11661 let community = create_community(&relay, "Flooded", vec!["wss://r".into()], None).await.unwrap();
11662 let new_root = [0xD9; 32];
11663 let new_epoch = Epoch(1);
11664 let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
11665
11666 publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
11668
11669 let rogue = Keys::generate();
11672 let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
11673 for i in 0..260u64 {
11674 let blob = rekey::build_blob_local(rogue.secret_key(), &rogue.public_key().to_bytes(), &rogue.public_key(), RekeyScope::Root, new_epoch, &[0xEE; 32]).unwrap();
11675 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();
11676 let (wrap, _) = rekey::seal_rekey_chunk(&rumor, &group, &rogue, Timestamp::from_secs(3_000 + i)).unwrap();
11677 relay.publish(&wrap, &community.relays).await.unwrap();
11678 }
11679
11680 let session = SessionGuard::capture();
11681 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the genuine rotation is recovered past the flood");
11682 assert_eq!(updated.root_epoch, Epoch(1));
11683 assert_eq!(updated.community_root, new_root, "adopted the owner's root, not a junk one");
11684 }
11685
11686 #[tokio::test]
11687 async fn a_swap_during_create_private_channel_aborts_without_a_write() {
11688 let (bed, owner, _member) = TestBed::new();
11693 bed.swap_to(&owner);
11694 let community = create_community(&bed.relay, "SwapCreate", bed.relays.clone(), None).await.unwrap();
11695 let before = crate::db::community::load_community_v2(community.id()).unwrap().unwrap().channels.len();
11696
11697 let swap_relay = SwapMidPublish { inner: MemoryRelay::new() };
11699 let err = create_private_channel(&swap_relay, &community, "ghost").await.unwrap_err();
11700 assert!(err.contains("account changed"), "a swap mid-create aborts: {err}");
11701 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11702 assert_eq!(after.channels.len(), before, "no channel row was written");
11703 assert!(!after.channels.iter().any(|c| c.name == "ghost"), "the ghost channel never persisted");
11704 }
11705
11706 #[tokio::test]
11707 async fn an_uncited_admin_rotation_is_not_adopted() {
11708 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
11713 let (_tmp, _guard, _owner) = init_test_db();
11714 let relay = MemoryRelay::new();
11715 let mut community = create_community(&relay, "Uncited", vec!["wss://r".into()], None).await.unwrap();
11716 let priv_id = ChannelId([0x8A; 32]);
11717 let key1 = [0x93; 32];
11718 add_private_channel(&mut community, priv_id, key1, Epoch(1));
11719
11720 let admin = Keys::generate();
11721 let role = Role::admin("cf".repeat(32));
11722 let roster = CommunityRoles {
11723 roles: vec![role.clone()],
11724 grants: vec![MemberGrant { member: admin.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }],
11725 };
11726 seed_roster_with_heads(&community, &roster, 1_000);
11727
11728 let key2 = [0x94; 32];
11729 let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
11730 let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
11731 let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
11732 let blob = rekey::build_blob_local(admin.secret_key(), &admin.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
11733 for e in rekey::build_rekey_chunks_local(&admin, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[blob], 2_000, None).unwrap() {
11735 relay.publish(&e, &community.relays).await.unwrap();
11736 }
11737
11738 let out = follow_rekeys(&relay, &community, &SessionGuard::capture()).await.unwrap();
11739 assert!(out.updated.is_none(), "an uncited rotation is not adopted");
11740
11741 let cited = my_authority_citation(&community, &admin.public_key());
11744 assert!(cited.is_some(), "the seeded head yields a citation");
11745 let blob2 = rekey::build_blob_local(admin.secret_key(), &admin.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
11746 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() {
11747 relay.publish(&e, &community.relays).await.unwrap();
11748 }
11749 let out = follow_rekeys(&relay, &community, &SessionGuard::capture()).await.unwrap();
11750 assert!(out.updated.is_some(), "the cited rotation IS adopted");
11751 }
11752
11753 #[tokio::test]
11754 async fn two_admins_racing_a_channel_rotation_converge_on_one_key() {
11755 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
11763 let (_tmp, _guard, _owner) = init_test_db();
11764 let relay = MemoryRelay::new();
11765 let mut community = create_community(&relay, "Race", vec!["wss://r".into()], None).await.unwrap();
11766 let priv_id = ChannelId([0xC0; 32]);
11767 let key1 = [0xC1; 32];
11768 add_private_channel(&mut community, priv_id, key1, Epoch(1));
11769
11770 let (a, b) = (Keys::generate(), Keys::generate());
11772 let role = Role::admin("ce".repeat(32));
11773 let roster = CommunityRoles {
11774 roles: vec![role.clone()],
11775 grants: [&a, &b].iter().map(|k| MemberGrant { member: k.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }).collect(),
11776 };
11777 seed_roster_with_heads(&community, &roster, 1_000);
11778
11779 let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
11782 let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
11783 let group = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
11784 let key_a = [0x0A; 32];
11785 let key_b = [0xFB; 32]; for (signer, k) in [(&a, &key_a), (&b, &key_b)] {
11787 let blob = rekey::build_blob_local(signer.secret_key(), &signer.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), k).unwrap();
11788 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() {
11789 relay.publish(&e, &community.relays).await.unwrap();
11790 }
11791 }
11792
11793 let session = SessionGuard::capture();
11794 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopts a winner");
11795 let adopted = updated.channel(&priv_id).unwrap().key.unwrap();
11796 assert_eq!(adopted, key_a, "converges on the lexicographically lowest key (deterministic across clients)");
11797
11798 let mut peer = community.clone();
11800 if let Some(c) = peer.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
11801 c.key = Some(key1);
11802 c.epoch = Epoch(1);
11803 }
11804 let updated2 = follow_rekeys(&relay, &peer, &session).await.unwrap().updated.expect("peer adopts");
11806 assert_eq!(updated2.channel(&priv_id).unwrap().key.unwrap(), key_a, "every follower lands on the identical key");
11807 }
11808
11809 #[tokio::test]
11810 async fn create_private_channel_refuses_a_member_without_manage_channels() {
11811 let (bed, owner, member) = TestBed::new();
11814 bed.swap_to(&owner);
11815 let community = create_community(&bed.relay, "Gate", bed.relays.clone(), None).await.unwrap();
11816 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
11817
11818 bed.swap_to(&member);
11819 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
11820 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
11821 let err = create_private_channel(&bed.relay, &joined, "sneaky").await.unwrap_err();
11822 assert!(err.contains("MANAGE_CHANNELS"), "refused with the permission it lacks: {err}");
11823 let err = create_public_channel(&bed.relay, &joined, "sneaky-too").await.unwrap_err();
11824 assert!(err.contains("MANAGE_CHANNELS"), "public creation gates identically: {err}");
11825 }
11826
11827 #[tokio::test]
11830 async fn accept_rejects_a_bundle_with_a_forged_community_root() {
11831 let (bed, owner, member) = TestBed::new();
11835 bed.swap_to(&owner);
11836 let community = create_community(&bed.relay, "Real", bed.relays.clone(), None).await.unwrap();
11837
11838 let fake = crate::simd::hex::bytes_to_hex_32(&[0xEE; 32]);
11839 let mut forged = bundle_of(&community, BundleAudience::Link, None, None, None);
11840 forged.community_root = fake.clone();
11841 for ch in &mut forged.channels {
11842 ch.key = fake.clone();
11843 }
11844 let attacker = Keys::generate();
11845 let wrap = invite::build_direct_invite(&attacker, &member.keys.public_key(), &forged).unwrap();
11846 bed.relay.publish(&wrap, &bed.relays).await.unwrap();
11847
11848 bed.swap_to(&member);
11849 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
11850 let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
11851 assert!(err.contains("could not verify"), "a forged root fails the owner-genesis check: {err}");
11852 assert!(
11853 crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
11854 "a rejected join persists nothing"
11855 );
11856 }
11857
11858 #[tokio::test]
11859 async fn accept_verifies_a_rotated_plane_whose_metadata_head_is_admin_signed() {
11860 let (bed, owner, member) = TestBed::new();
11865 bed.swap_to(&owner);
11866 let community = create_community(&bed.relay, "Rotated", bed.relays.clone(), None).await.unwrap();
11867 let general = community.channels[0].id;
11868
11869 let mut rotated = community.clone();
11870 rotated.community_root = [0x5A; 32];
11871 rotated.root_epoch = Epoch(1);
11872 let admin = Keys::generate();
11873 publish_community_meta(&bed.relay, &rotated, &admin, "Rotated", 3).await;
11874 publish_channel_edition(&bed.relay, &rotated, &owner.keys, &general, "general", false, 2, false).await;
11875
11876 bed.swap_to(&member);
11877 let bundle = bundle_of(&rotated, BundleAudience::Link, None, None, None);
11878 let session = SessionGuard::capture();
11879 let joined = accept_bundle(&bed.relay, &session, &bundle, None, true).await.unwrap();
11880 assert_eq!(joined.root_epoch, Epoch(1), "the rotated root is adopted");
11881 }
11882
11883 #[tokio::test]
11884 async fn only_an_actual_join_publishes_a_guestbook_join() {
11885 let (bed, owner, member) = TestBed::new();
11889 bed.swap_to(&owner);
11890 let community = create_community(&bed.relay, "Quiet", bed.relays.clone(), None).await.unwrap();
11891
11892 let gb_pk = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch).pk_hex();
11893 async fn gb_count(relay: &MemoryRelay, gb_pk: &str, relays: &[String]) -> usize {
11894 let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![gb_pk.to_string()], ..Default::default() };
11895 relay.fetch(&q, relays).await.map(|v| v.len()).unwrap_or(0)
11896 }
11897 let baseline = gb_count(&bed.relay, &gb_pk, &bed.relays).await; bed.swap_to(&member);
11900 let bundle = bundle_of(&community, BundleAudience::Link, None, None, None);
11901 let session = SessionGuard::capture();
11902 accept_bundle(&bed.relay, &session, &bundle, None, true).await.unwrap();
11903 assert_eq!(gb_count(&bed.relay, &gb_pk, &bed.relays).await, baseline + 1, "a first join announces exactly once");
11904
11905 accept_bundle(&bed.relay, &session, &bundle, None, true).await.unwrap();
11906 assert_eq!(gb_count(&bed.relay, &gb_pk, &bed.relays).await, baseline + 1, "a re-accept of a held community stays silent");
11907
11908 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11909 crate::db::community::delete_community(&cid_hex).unwrap();
11910 accept_bundle(&bed.relay, &session, &bundle, None, false).await.unwrap();
11911 assert_eq!(gb_count(&bed.relay, &gb_pk, &bed.relays).await, baseline + 1, "a cross-device key sync is not a membership event");
11912 }
11913
11914 #[tokio::test]
11915 async fn accept_refuses_a_rotated_plane_with_no_owner_signed_edition() {
11916 let (bed, owner, member) = TestBed::new();
11919 bed.swap_to(&owner);
11920 let community = create_community(&bed.relay, "NoOwner", bed.relays.clone(), None).await.unwrap();
11921
11922 let mut rotated = community.clone();
11923 rotated.community_root = [0x5B; 32];
11924 rotated.root_epoch = Epoch(1);
11925 let attacker = Keys::generate();
11926 publish_community_meta(&bed.relay, &rotated, &attacker, "NoOwner", 3).await;
11927
11928 bed.swap_to(&member);
11929 let bundle = bundle_of(&rotated, BundleAudience::Link, None, None, None);
11930 let session = SessionGuard::capture();
11931 let err = accept_bundle(&bed.relay, &session, &bundle, None, true).await.unwrap_err();
11932 assert!(err.contains("could not verify"), "no owner-signed edition → refuse: {err}");
11933 }
11934
11935 #[tokio::test]
11936 async fn accept_requires_the_strict_owner_genesis_on_an_epoch_zero_plane() {
11937 let (bed, owner, member) = TestBed::new();
11940 bed.swap_to(&owner);
11941 let community = create_community(&bed.relay, "Strict", bed.relays.clone(), None).await.unwrap();
11942 let general = community.channels[0].id;
11943
11944 let mut fake = community.clone();
11945 fake.community_root = [0x5C; 32]; let admin = Keys::generate();
11947 publish_community_meta(&bed.relay, &fake, &admin, "Strict", 2).await;
11948 publish_channel_edition(&bed.relay, &fake, &owner.keys, &general, "general", false, 2, false).await;
11949
11950 bed.swap_to(&member);
11951 let bundle = bundle_of(&fake, BundleAudience::Link, None, None, None);
11952 let session = SessionGuard::capture();
11953 let err = accept_bundle(&bed.relay, &session, &bundle, None, true).await.unwrap_err();
11954 assert!(err.contains("could not verify"), "epoch 0 demands the owner genesis: {err}");
11955 }
11956
11957 #[tokio::test]
11958 async fn follow_control_heals_a_bundle_misclassified_public_channel() {
11959 let (_tmp, _guard, _owner) = init_test_db();
11963 let relay = MemoryRelay::new();
11964 let community = create_community(&relay, "Heal", vec!["wss://r".into()], None).await.unwrap();
11965 let general = community.channels[0].id;
11966 let mut poisoned = community.clone();
11967 poisoned.channels[0].private = true;
11968 poisoned.channels[0].key = Some([0x66; 32]);
11969 crate::db::community::save_community_v2(&poisoned).unwrap();
11970
11971 let session = SessionGuard::capture();
11972 let healed = follow_control(&relay, &poisoned, &session).await.unwrap().expect("healed");
11973 let ch = healed.channel(&general).unwrap();
11974 assert!(!ch.private, "the owner's public declaration overrides the bundle");
11975 assert_eq!(ch.key, None, "a healed public channel derives from the root");
11976 }
11977
11978 #[tokio::test]
11979 async fn a_deleted_channel_does_not_resurrect_on_reload() {
11980 let (_tmp, _guard, owner) = init_test_db();
11983 let relay = MemoryRelay::new();
11984 let community = create_community(&relay, "Prune", vec!["wss://r".into()], None).await.unwrap();
11985 let extra = ChannelId([0x77; 32]);
11986 let session = SessionGuard::capture();
11987 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
11988 let with_extra = follow_control(&relay, &community, &session).await.unwrap().unwrap();
11989 assert!(with_extra.channel(&extra).is_some());
11990 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
11991 let after = follow_control(&relay, &with_extra, &session).await.unwrap().unwrap();
11992 assert!(after.channel(&extra).is_none());
11993
11994 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11995 assert!(reloaded.channel(&extra).is_none(), "a deleted channel must not resurrect on reload");
11996 assert_eq!(reloaded.channels.len(), 1);
11997 }
11998
11999 #[tokio::test]
12000 async fn a_channel_owned_by_another_community_is_skipped_not_clobbered() {
12001 let (_tmp, _guard, _owner) = init_test_db();
12005 let relay = MemoryRelay::new();
12006 let a = create_community(&relay, "A", vec!["wss://r".into()], None).await.unwrap();
12007 let a_channel = a.channels[0].id;
12008 let mut b = create_community(&relay, "B", vec!["wss://r".into()], None).await.unwrap();
12009 let b_channel = b.channels[0].id;
12010 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() });
12012
12013 crate::db::community::save_community_v2(&b).expect("save succeeds, the phantom is skipped");
12014 let a_reloaded = crate::db::community::load_community_v2(a.id()).unwrap().unwrap();
12016 assert!(!a_reloaded.channels.iter().any(|c| c.private), "A's channel is untouched");
12017 assert_eq!(a_reloaded.channels[0].id.0, a_channel.0);
12018 let b_reloaded = crate::db::community::load_community_v2(b.id()).unwrap().unwrap();
12020 assert!(b_reloaded.channel(&b_channel).is_some(), "B's own channel persists");
12021 assert!(b_reloaded.channel(&a_channel).is_none(), "the foreign-owned channel is skipped, not stolen");
12022 }
12023
12024 struct CappedRelay {
12030 events: Vec<Event>,
12031 cap: usize,
12032 }
12033 #[async_trait::async_trait]
12034 impl Transport for CappedRelay {
12035 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
12036 async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
12037 Ok(())
12038 }
12039 async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
12040 Ok(())
12041 }
12042 async fn fetch(&self, q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
12043 let mut m: Vec<Event> = self
12044 .events
12045 .iter()
12046 .filter(|e| q.authors.is_empty() || q.authors.contains(&e.pubkey.to_hex()))
12047 .filter(|e| q.until.is_none_or(|u| e.created_at.as_secs() <= u))
12048 .cloned()
12049 .collect();
12050 m.sort_by(|a, b| b.created_at.cmp(&a.created_at)); m.truncate(self.cap.min(q.limit.unwrap_or(usize::MAX)));
12052 Ok(m)
12053 }
12054 }
12055
12056 #[tokio::test]
12057 async fn refound_aborts_when_the_control_plane_cannot_be_read_in_full() {
12058 let (_tmp, _guard, _owner) = init_test_db();
12065 let memory = MemoryRelay::new();
12066 let community = create_community(&memory, "Walled", vec!["wss://r".into()], None).await.unwrap();
12067 let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
12068
12069 let rogue = Keys::generate();
12070 let mut events: Vec<Event> = Vec::new();
12071 for i in 0..FOLLOW_PAGE {
12072 let content = format!("{{\"name\":\"junk{i}\",\"private\":false}}");
12073 let rumor = control::build_edition_rumor(
12074 rogue.public_key(),
12075 vsk::CHANNEL_METADATA,
12076 &[0xAB; 32],
12077 1,
12078 None,
12079 &content,
12080 9_000,
12081 None,
12082 );
12083 let (w, _) = control::seal_control_edition(&rumor, &control, &rogue, Timestamp::from_secs(9_000)).unwrap();
12084 events.push(w);
12085 }
12086 let relay = CappedRelay { events, cap: FOLLOW_PAGE };
12087
12088 let err = refound_community(&relay, &community, &[])
12089 .await
12090 .expect_err("a plane that can't be read whole must never be compacted");
12091 assert!(err.contains("too deep to read in full"), "unexpected error: {err}");
12092 }
12093
12094 #[tokio::test]
12095 async fn verify_pages_a_capped_relay_past_a_flood_to_the_genesis() {
12096 let (_tmp, _guard, owner) = init_test_db();
12101 let meta = control::CommunityMetadata { name: "Capped".into(), relays: vec!["wss://r".into()], ..Default::default() };
12102 let g = control::genesis(&owner, meta, 1_000).unwrap();
12103 let community = CommunityV2::from_genesis(&g, "Capped", None, vec!["wss://r".into()], 1_000);
12104
12105 let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
12106 let rogue = Keys::generate();
12107 let mut events: Vec<Event> = g.wraps.to_vec();
12108 for i in 0..250u64 {
12109 let rumor = control::build_edition_rumor(rogue.public_key(), vsk::CHANNEL_METADATA, &[0xAB; 32], 1, None, "{\"name\":\"junk\",\"private\":false}", 1_001 + i, None);
12110 let (wrap, _) = control::seal_control_edition(&rumor, &control, &rogue, Timestamp::from_secs(1_001 + i)).unwrap();
12111 events.push(wrap);
12112 }
12113 let relay = CappedRelay { events, cap: 100 };
12115 let verified = verify_owner_root_and_reconcile(&relay, community.clone()).await;
12116 assert!(verified.is_ok(), "the until-walk pages a capped relay past the flood to the genesis: {:?}", verified.err());
12117 }
12118
12119 #[tokio::test]
12120 async fn accept_parked_invite_joins_from_the_stored_bundle() {
12121 let (bed, owner, member) = TestBed::new();
12124 bed.swap_to(&owner);
12125 let community = create_community(&bed.relay, "Parked", bed.relays.clone(), None).await.unwrap();
12126 let general = community.channels[0].id;
12127 send_message(&bed.relay, &community, &general, "owner: hi").await.unwrap();
12128 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
12129 let bundle_json = serde_json::to_string(&bundle).unwrap();
12130 let inviter_hex = owner.keys.public_key().to_hex();
12131
12132 bed.swap_to(&member);
12133 let joined = accept_parked_invite(&bed.relay, &bundle_json, Some(&inviter_hex)).await.unwrap();
12134 assert_eq!(joined.id().0, community.id().0, "joined the community from the parked bundle");
12135 assert!(joined.identity.verify());
12136 assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: hi"]);
12137 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
12140 assert!(
12141 crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().is_some(),
12142 "the joiner's control floor is seeded from the join-time fold"
12143 );
12144
12145 bed.swap_to(&owner);
12147 let members = memberlist(&bed.relay, &community).await.unwrap();
12148 assert!(members.contains(&member.keys.public_key()), "the parked-invite joiner is a member");
12149 }
12150
12151 #[tokio::test]
12152 async fn accept_parked_invite_rejects_a_forged_root() {
12153 let (_tmp, _guard, _owner) = init_test_db();
12157 let relay = MemoryRelay::new();
12158 let community = create_community(&relay, "Real", vec!["wss://r".into()], None).await.unwrap();
12159 let mut forged = bundle_of(&community, BundleAudience::Link, None, None, None);
12160 let fake = crate::simd::hex::bytes_to_hex_32(&[0xEE; 32]);
12161 forged.community_root = fake.clone();
12162 for ch in &mut forged.channels {
12163 ch.key = fake.clone();
12164 }
12165 let bundle_json = serde_json::to_string(&forged).unwrap();
12166
12167 let err = accept_parked_invite(&relay, &bundle_json, None).await.unwrap_err();
12168 assert!(err.contains("could not verify"), "a forged-root parked bundle fails definitively: {err}");
12169 }
12170
12171 #[test]
12172 fn v2_and_v1_bundles_are_distinguishable_by_parse() {
12173 let owner = Keys::generate();
12177 let identity = super::super::control::CommunityIdentity::mint(&owner.public_key());
12178 let hex = crate::simd::hex::bytes_to_hex_32;
12179 let v2 = invite::CommunityInvite {
12180 community_id: hex(&identity.community_id.0),
12181 owner: hex(&identity.owner_xonly),
12182 owner_salt: hex(&identity.owner_salt),
12183 community_root: hex(&[0x11; 32]),
12184 root_epoch: 0,
12185 channels: vec![],
12186 relays: vec!["wss://r".into()],
12187 name: "V2".into(),
12188 icon: None,
12189 expires_at: None,
12190 creator_npub: None,
12191 label: None,
12192 extra: Default::default(),
12193 };
12194 let v2_json = serde_json::to_string(&v2).unwrap();
12195 assert!(invite::CommunityInvite::from_bundle_json(&v2_json).is_ok(), "a real v2 bundle parses");
12196 let v1_like = r#"{"community_id":"aa","name":"X","relays":[]}"#;
12197 assert!(invite::CommunityInvite::from_bundle_json(v1_like).is_err(), "a v1 bundle is not a v2 bundle");
12198 }
12199
12200 #[tokio::test]
12201 async fn verify_rejects_a_cross_community_owner_edition_replay() {
12202 let (_tmp, _guard, owner) = init_test_db();
12208
12209 let gx = control::genesis(&owner, control::CommunityMetadata { name: "X".into(), ..Default::default() }, 1_000).unwrap();
12211 let x_control = control_group_key(&gx.community_root, &gx.identity.community_id, Epoch(0));
12212 let (_ed, opened) = control::open_control_edition(&gx.wraps[0], &x_control).unwrap();
12213
12214 let t_identity = control::CommunityIdentity::mint(&owner.public_key());
12216 let fake_root = [0xEE; 32];
12217 let t = CommunityV2 {
12218 identity: t_identity,
12219 community_root: fake_root,
12220 root_epoch: Epoch(0),
12221 name: "T".into(),
12222 description: None,
12223 icon: None,
12224 banner: None,
12225 meta_custom: None,
12226 meta_extra: Default::default(),
12227 relays: vec!["wss://r".into()],
12228 channels: vec![],
12229 dissolved: false,
12230 created_at_ms: 0,
12231 };
12232 let t_control = control_group_key(&fake_root, t.id(), t.root_epoch);
12235 let (replayed, _) = stream::rewrap_seal(&opened.seal, &t_control, Timestamp::from_secs(1_000)).unwrap();
12236 let relay = MemoryRelay::new();
12237 relay.publish(&replayed, &t.relays).await.unwrap();
12238
12239 let verified = verify_owner_root_and_reconcile(&relay, t.clone()).await;
12240 assert!(verified.is_err(), "a cross-community owner-edition replay must not authenticate a forged root");
12241 }
12242
12243 #[tokio::test]
12251 #[ignore = "hits a real relay over the network"]
12252 async fn live_smoke_create_send_fetch_on_a_real_relay() {
12253 use crate::community::transport::LiveTransport;
12254 use nostr_sdk::prelude::ToBech32;
12255
12256 let relay = std::env::var("VECTOR_SMOKE_RELAY").unwrap_or_else(|_| "wss://jskitty.com/nostr".to_string());
12257 let relays = vec![relay.clone()];
12258
12259 let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
12261 crate::db::close_database();
12262 crate::db::clear_id_caches();
12263 let tmp = tempfile::tempdir().unwrap();
12264 let keys = match std::env::var("VECTOR_SMOKE_NSEC") {
12267 Ok(n) => Keys::parse(&n).expect("VECTOR_SMOKE_NSEC is not a valid nsec"),
12268 Err(_) => Keys::generate(),
12269 };
12270 let npub = keys.public_key().to_bech32().unwrap();
12271 if std::env::var("VECTOR_SMOKE_PRINT_NSEC").is_ok() {
12274 println!("[smoke] OWNER nsec (throwaway — do NOT reuse): {}", keys.secret_key().to_bech32().unwrap());
12275 }
12276 std::fs::create_dir_all(tmp.path().join(&npub)).unwrap();
12277 crate::db::set_app_data_dir(tmp.path().to_path_buf());
12278 crate::db::set_current_account(npub.clone()).unwrap();
12279 crate::db::init_database(&npub).unwrap();
12280 crate::state::MY_SECRET_KEY.store_from_keys(&keys, &[]);
12281 crate::state::set_my_public_key(keys.public_key());
12282 println!("[smoke] throwaway identity {npub}");
12283
12284 let client = crate::nostr_client_builder().build();
12286 client.add_managed_relay(relay.as_str()).await.ok();
12287 client.connect().await;
12288 crate::state::set_nostr_client(client);
12289 let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(15));
12290
12291 let community = create_community(&transport, "V2 Live Smoke", relays.clone(), None).await.expect("create");
12293 let general = community.channels[0].id;
12294 println!("[smoke] created community {} on {relay}", crate::simd::hex::bytes_to_hex_32(&community.id().0));
12295
12296 let text = "hello from a Vector Concord v2 live smoke test";
12297 let sent_id = send_message(&transport, &community, &general, text).await.expect("send");
12298 println!("[smoke] sent message {sent_id}");
12299
12300 tokio::time::sleep(std::time::Duration::from_secs(3)).await;
12302
12303 let page = fetch_channel(&transport, &community, &general, 50).await.expect("fetch");
12304 let texts: Vec<String> = page
12305 .iter()
12306 .filter_map(|f| match &f.event {
12307 ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
12308 _ => None,
12309 })
12310 .collect();
12311 println!("[smoke] fetched {} message(s) back: {texts:?}", texts.len());
12312 assert!(texts.contains(&text.to_string()), "the message did not round-trip through the real relay");
12313
12314 let link = mint_public_link(&transport, &community, "https://vectorapp.io", None, None).await.expect("mint link");
12316 println!("[smoke] invite link: {}", link.url);
12317 println!("[smoke] PASS — v2 create+send+fetch+invite round-tripped on {relay}");
12318 }
12319
12320 #[tokio::test]
12321 async fn chat_ops_react_edit_delete_round_trip() {
12322 let (bed, owner, _member) = TestBed::new();
12323 bed.swap_to(&owner);
12324 let community = create_community(&bed.relay, "Ops", bed.relays.clone(), None).await.unwrap();
12325 let general = community.channels[0].id;
12326 let me_hex = owner.keys.public_key().to_hex();
12327
12328 let msg_id = send_message(&bed.relay, &community, &general, "original").await.unwrap();
12329 send_reaction(&bed.relay, &community, &general, &msg_id, &me_hex, super::super::kind::MESSAGE, ":fire:", Some(("fire", "https://e/f.png")))
12330 .await
12331 .unwrap();
12332 send_edit(&bed.relay, &community, &general, &msg_id, "edited").await.unwrap();
12333 send_delete(&bed.relay, &community, &general, &msg_id, super::super::kind::MESSAGE).await.unwrap();
12334
12335 let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
12336 let target = crate::simd::hex::hex_to_bytes_32(&msg_id);
12337 let mut saw = (false, false, false);
12338 for f in &page {
12339 match &f.event {
12340 ChatEvent::Reaction { target: t, emoji, emoji_url, .. } if *t == target => {
12341 assert_eq!(emoji, ":fire:");
12342 assert_eq!(emoji_url.as_deref(), Some("https://e/f.png"));
12343 saw.0 = true;
12344 }
12345 ChatEvent::Edit { target: t, new_content, .. } if *t == target => {
12346 assert_eq!(new_content, "edited");
12347 saw.1 = true;
12348 }
12349 ChatEvent::Delete { target: t, .. } if *t == target => saw.2 = true,
12350 _ => {}
12351 }
12352 }
12353 assert!(saw.0 && saw.1 && saw.2, "reaction/edit/delete all round-trip: {saw:?}");
12354 }
12355
12356 #[tokio::test]
12357 async fn a_typing_signal_rides_the_ephemeral_wrap_and_is_never_stored() {
12358 let (bed, owner, _member) = TestBed::new();
12359 bed.swap_to(&owner);
12360 let community = create_community(&bed.relay, "Typ", bed.relays.clone(), None).await.unwrap();
12361 let general = community.channels[0].id;
12362 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
12363
12364 let mut sub = bed.relay.subscribe(Query {
12366 kinds: vec![stream::KIND_WRAP_EPHEMERAL],
12367 authors: vec![group.pk_hex()],
12368 ..Default::default()
12369 });
12370 send_typing(&bed.relay, &community, &general).await.unwrap();
12371 let wrap = sub.try_recv().expect("the typing wrap streams to a live subscriber");
12372 let opened = match chat::open_chat_event(&wrap, &group, &general, community.root_epoch) {
12373 Ok(ChatEvent::Typing { opened }) => opened,
12374 other => panic!("the ephemeral wrap must open as a Typing event, got {other:?}"),
12375 };
12376
12377 let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
12380 assert!(page.iter().all(|f| !matches!(f.event, ChatEvent::Typing { .. })));
12381
12382 assert!(
12384 crate::db::community::get_message_key(&opened.rumor_id.to_hex()).unwrap().is_none(),
12385 "ephemeral sends must not retain scrub keys"
12386 );
12387 }
12388
12389 #[tokio::test]
12390 async fn a_durable_send_retains_the_wrap_scrub_key_and_full_delete_nukes_the_relay_copy() {
12391 let (bed, owner, _member) = TestBed::new();
12392 bed.swap_to(&owner);
12393 let community = create_community(&bed.relay, "Nuke", bed.relays.clone(), None).await.unwrap();
12394 let general = community.channels[0].id;
12395 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
12396
12397 let id = send_message(&bed.relay, &community, &general, "scrub me").await.unwrap();
12398
12399 let (keys, outer_hex, relays) =
12402 crate::db::community::get_message_key(&id).unwrap().expect("a durable send retains its scrub key");
12403 assert_eq!(relays, community.relays);
12404 let wrap_query = Query {
12405 kinds: vec![stream::KIND_WRAP],
12406 authors: vec![group.pk_hex()],
12407 ..Default::default()
12408 };
12409 let wraps = bed.relay.fetch(&wrap_query, &community.relays).await.unwrap();
12410 let wrap = wraps.iter().find(|w| w.id.to_hex() == outer_hex).expect("retained outer id is the published wrap");
12411 assert_eq!(keys.public_key(), wrap.pubkey, "retained key is the wrap's author");
12412
12413 let me_hex = owner.keys.public_key().to_hex();
12415 let rid = send_reaction(&bed.relay, &community, &general, &id, &me_hex, super::super::kind::MESSAGE, "🔥", None)
12416 .await
12417 .unwrap();
12418 assert!(crate::db::community::get_message_key(&rid).unwrap().is_some(), "reaction sends retain too");
12419
12420 crate::community::service::delete_message(&bed.relay, &id).await.unwrap();
12423 assert!(crate::db::community::get_message_key(&id).unwrap().is_none(), "key consumed after the scrub");
12424 let after = bed.relay.fetch(&wrap_query, &community.relays).await.unwrap();
12425 assert!(!after.iter().any(|w| w.id.to_hex() == outer_hex), "wrap scrubbed from the relay");
12426 }
12427
12428 #[tokio::test]
12429 async fn backfill_heals_scrub_keys_for_own_pre_retention_messages_only() {
12430 let (bed, owner, _member) = TestBed::new();
12431 bed.swap_to(&owner);
12432 let community = create_community(&bed.relay, "Heal", bed.relays.clone(), None).await.unwrap();
12433 let general = community.channels[0].id;
12434 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
12435
12436 let id = send_message(&bed.relay, &community, &general, "old send").await.unwrap();
12439 crate::db::community::delete_message_key(&id).unwrap();
12440 assert!(crate::db::community::get_message_key(&id).unwrap().is_none());
12441
12442 let mkeys = Keys::generate();
12444 let rumor = chat::build_message_rumor(mkeys.public_key(), &general, community.root_epoch, "foreign", None, &[], vec![], 6_000);
12445 let foreign_id = rumor.id.unwrap().to_hex();
12446 let (fw, _) = chat::seal_chat_rumor(&rumor, &group, &mkeys, Timestamp::from_secs(6), false).unwrap();
12447 bed.relay.publish(&fw, &community.relays).await.unwrap();
12448
12449 fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
12451 let (keys, _outer, relays) =
12452 crate::db::community::get_message_key(&id).unwrap().expect("backfill heals own unretained rows");
12453 assert_eq!(keys.public_key(), group.pk(), "healed key is the wrap's signing key");
12454 assert_eq!(relays, community.relays);
12455
12456 assert!(crate::db::community::get_message_key(&foreign_id).unwrap().is_none());
12458
12459 crate::community::service::delete_message(&bed.relay, &id).await.unwrap();
12461 let left = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
12462 assert!(
12463 !left.iter().any(|f| f.event.opened().rumor_id.to_hex() == id),
12464 "healed message scrubbed from the relay"
12465 );
12466 }
12467
12468 #[tokio::test]
12469 async fn send_chat_message_threads_the_reply_and_extra_tags() {
12470 let (bed, owner, _member) = TestBed::new();
12471 bed.swap_to(&owner);
12472 let community = create_community(&bed.relay, "Re", bed.relays.clone(), None).await.unwrap();
12473 let general = community.channels[0].id;
12474 let me_hex = owner.keys.public_key().to_hex();
12475
12476 let parent_id = send_message(&bed.relay, &community, &general, "parent").await.unwrap();
12477 let imeta = nostr_sdk::prelude::Tag::custom(
12478 "imeta",
12479 ["url https://e/blob".to_string(), "m image/png".to_string()],
12480 );
12481 let child_id = send_chat_message(
12482 &bed.relay, &community, &general, "child",
12483 Some((parent_id.as_str(), me_hex.as_str())), &[], vec![imeta],
12484 )
12485 .await
12486 .unwrap();
12487
12488 let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
12489 let child = page
12490 .iter()
12491 .find_map(|f| match &f.event {
12492 ChatEvent::Message { opened, reply_to, .. } if opened.rumor_id.to_hex() == child_id => Some((opened, reply_to)),
12493 _ => None,
12494 })
12495 .expect("the reply message round-trips");
12496 let reply = child.1.as_ref().expect("the reply reference is carried");
12497 assert_eq!(crate::simd::hex::bytes_to_hex_32(&reply.id), parent_id);
12498 assert_eq!(reply.author, Some(owner.keys.public_key()));
12499 assert!(
12500 child.0.rumor.tags.iter().any(|t| t.kind() == "imeta"),
12501 "the imeta attachment tag rides the rumor verbatim"
12502 );
12503 }
12504
12505 #[tokio::test]
12506 async fn a_kick_needs_kick_authority_and_removes_the_target() {
12507 let (bed, owner, member) = TestBed::new();
12508 bed.swap_to(&owner);
12509 let community = create_community(&bed.relay, "Kick", bed.relays.clone(), None).await.unwrap();
12510
12511 let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
12513 let join = guestbook::build_join_rumor(member.keys.public_key(), None, 2_000);
12514 let (wrap, _) = guestbook::seal_guestbook_rumor(&join, &gb, &member.keys, Timestamp::from_secs(2)).unwrap();
12515 bed.relay.publish(&wrap, &bed.relays).await.unwrap();
12516 let before = memberlist(&bed.relay, &community).await.unwrap();
12517 assert!(before.contains(&member.keys.public_key()), "the join lands first");
12518
12519 bed.swap_to(&member);
12521 let err = kick_member(&bed.relay, &community, &owner.keys.public_key()).await.unwrap_err();
12522 assert!(err.contains("not authorized"), "unprivileged kick refused: {err}");
12523
12524 bed.swap_to(&owner);
12526 kick_member(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
12527 let after = memberlist(&bed.relay, &community).await.unwrap();
12528 assert!(!after.contains(&member.keys.public_key()), "the kicked member leaves the fold");
12529 assert!(after.contains(&owner.keys.public_key()), "the owner remains");
12530 }
12531
12532 #[tokio::test]
12533 async fn a_rejoin_survives_a_stale_kick_and_an_uncaught_up_store() {
12534 let (bed, owner, member) = TestBed::new();
12539 bed.swap_to(&owner);
12540 let community = create_community(&bed.relay, "Rejoin", bed.relays.clone(), None).await.unwrap();
12541 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12542 let (o, m) = (owner.keys.public_key(), member.keys.public_key());
12543 let join = |at: u64, id: u8| guestbook::GuestbookEvent {
12544 rumor_id: [id; 32],
12545 entry: guestbook::GuestbookEntry::Join { member: m, invited_by: None, at_ms: at },
12546 };
12547 let kick = |at: u64, id: u8| guestbook::GuestbookEvent {
12548 rumor_id: [id; 32],
12549 entry: guestbook::GuestbookEntry::Kick { actor: o, target: m, citation: None, at_ms: at },
12550 };
12551
12552 crate::db::community::set_guestbook(&cid_hex, &[join(1_000, 1), kick(2_000, 2)], 2).unwrap();
12554 assert!(stored_kick_verdict(&community, &m), "an authorized kick after the join is honored");
12555
12556 crate::db::community::set_guestbook(&cid_hex, &[join(1_000, 1), kick(2_000, 2), join(3_000, 3)], 3).unwrap();
12558 assert!(!stored_kick_verdict(&community, &m), "a Join newer than the kick clears the verdict");
12559
12560 crate::db::community::set_guestbook(&cid_hex, &[], 0).unwrap();
12562 assert!(!stored_kick_verdict(&community, &m), "an empty store is not an eviction");
12563
12564 assert!(
12567 !stored_memberlist(&community).unwrap().contains(&m),
12568 "the memberlist excludes an un-caught-up member — why it can't gate a kick"
12569 );
12570 }
12571
12572 fn seed_roster_with_heads(community: &CommunityV2, roster: &crate::community::roles::CommunityRoles, at: i64) {
12577 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12578 crate::db::community::set_community_roles(&cid_hex, roster, at).unwrap();
12579 for g in &roster.grants {
12580 let Some(m) = crate::simd::hex::hex_to_bytes_32_checked(&g.member) else { continue };
12581 let eid = super::super::derive::grant_locator(community.id(), &m);
12582 let entity_hex = crate::simd::hex::bytes_to_hex_32(&eid);
12583 crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, 1, &[0xA1; 32], &[0xA2; 32], community.root_epoch.0).unwrap();
12584 }
12585 }
12586
12587 async fn publish_grant_citing(
12589 relay: &MemoryRelay,
12590 community: &CommunityV2,
12591 signer: &Keys,
12592 member: &PublicKey,
12593 role_ids: Vec<String>,
12594 version: u64,
12595 citation: Option<&crate::community::edition::AuthorityCitation>,
12596 ) {
12597 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
12598 let eid = crate::community::v2::derive::grant_locator(community.id(), &member.to_bytes());
12599 let prev = head_hash_on_relay(relay, community, &eid).await;
12600 let grant = MemberGrant { member: member.to_hex(), role_ids };
12601 let content = crate::community::v2::roles::grant_content_json(&grant).unwrap();
12602 let rumor = control::build_edition_rumor(signer.public_key(), vsk::GRANT, &eid, version, prev.as_ref(), &content, 1_000, citation);
12603 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
12604 relay.publish(&wrap, &community.relays).await.unwrap();
12605 }
12606
12607 #[tokio::test]
12608 async fn an_uncited_admin_edition_is_not_folded_but_a_cited_one_is() {
12609 let (bed, owner, admin) = TestBed::new();
12614 bed.swap_to(&owner);
12615 let community = create_community(&bed.relay, "Cited", bed.relays.clone(), None).await.unwrap();
12616 let admin_pk = admin.keys.public_key();
12617 let rid = "c3".repeat(32);
12618 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::admin().0), 1).await;
12619 publish_grant(&bed.relay, &community, &owner.keys, &admin_pk, vec![rid.clone()], 1).await;
12620
12621 let low_rid = "c4".repeat(32);
12625 let mut low = admin_role(&low_rid, Permissions::admin().0);
12626 low.position = 5;
12627 publish_role(&bed.relay, &community, &owner.keys, &low, 1).await;
12628
12629 let bystander = Keys::generate().public_key();
12630 publish_grant_citing(&bed.relay, &community, &admin.keys, &bystander, vec![low_rid.clone()], 1, None).await;
12631 let view = fetch_authority(&bed.relay, &community).await;
12632 assert!(
12633 !view.roles.grants.iter().any(|g| g.member == bystander.to_hex()),
12634 "an uncited non-owner edition is not folded"
12635 );
12636 assert!(view.roles.is_admin(&admin_pk.to_hex()), "the owner-authored grant folds");
12638
12639 let _ = follow_control(&bed.relay, &community, &SessionGuard::capture()).await;
12642 let entity_id = crate::community::v2::derive::grant_locator(community.id(), &admin_pk.to_bytes());
12643 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12644 let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
12645 let (version, edition_hash) = crate::db::community::get_edition_head(&cid_hex, &entity_hex).unwrap().unwrap();
12646 let cite = crate::community::edition::AuthorityCitation { entity_id, version, edition_hash };
12647 publish_grant_citing(&bed.relay, &community, &admin.keys, &bystander, vec![low_rid], 2, Some(&cite)).await;
12648
12649 let view = fetch_authority(&bed.relay, &community).await;
12650 assert!(
12651 view.roles.grants.iter().any(|g| g.member == bystander.to_hex()),
12652 "the same edition WITH its synced citation folds"
12653 );
12654 }
12655
12656 #[tokio::test]
12657 async fn a_join_landing_inside_the_ban_window_survives_the_unban() {
12658 let (bed, owner, member) = TestBed::new();
12663 bed.swap_to(&owner);
12664 let community = create_community(&bed.relay, "Window", bed.relays.clone(), None).await.unwrap();
12665 let member_pk = member.keys.public_key();
12666 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12667
12668 crate::db::community::set_community_banlist(&cid_hex, &[member_pk.to_hex()], 1_000).unwrap();
12670 crate::db::community::merge_community_ban_marks(&cid_hex, &[(member_pk.to_hex(), 1_000u64)].into_iter().collect()).unwrap();
12671
12672 let join = guestbook::GuestbookEvent {
12674 rumor_id: [9u8; 32],
12675 entry: guestbook::GuestbookEntry::Join { member: member_pk, invited_by: None, at_ms: 1_060_000 },
12676 };
12677 assert!(ingest_guestbook_event(&community, join, 1_060).unwrap(), "stored while banned");
12678 assert!(
12679 !stored_memberlist(&community).unwrap().contains(&member_pk),
12680 "while banned, the fold keeps them out"
12681 );
12682
12683 crate::db::community::set_community_banlist(&cid_hex, &[], 2_000).unwrap();
12685 assert!(
12686 stored_memberlist(&community).unwrap().contains(&member_pk),
12687 "after the unban the raced Join makes them a member again"
12688 );
12689 }
12690
12691 #[tokio::test]
12692 async fn a_stale_root_admin_write_is_refused_not_misdirected() {
12693 let (bed, owner, member) = TestBed::new();
12699 bed.swap_to(&owner);
12700 let community = create_community(&bed.relay, "Race", bed.relays.clone(), None).await.unwrap();
12701 let member_pk = member.keys.public_key();
12702
12703 set_banlist(&bed.relay, &community, &[member_pk.to_hex()]).await.unwrap();
12704 let _rotated = refound_community(&bed.relay, &community, &[member_pk]).await.unwrap();
12705
12706 let err = set_banlist(&bed.relay, &community, &[]).await.unwrap_err();
12708 assert!(err.contains("re-founded"), "unban: {err}");
12709 let err = send_direct_invite(&bed.relay, &community, &member_pk, None, None).await.unwrap_err();
12711 assert!(err.contains("re-founded"), "invite: {err}");
12712 let err = kick_member(&bed.relay, &community, &member_pk).await.unwrap_err();
12714 assert!(err.contains("re-founded"), "kick: {err}");
12715
12716 let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12718 set_banlist(&bed.relay, &fresh, &[]).await.unwrap();
12719 let view = fetch_authority(&bed.relay, &fresh).await;
12720 assert!(view.banned.is_empty(), "the retried unban actually unbans");
12721 }
12722
12723 #[tokio::test]
12724 async fn an_uncited_kick_from_an_admin_is_not_honored() {
12725 let (bed, owner, member) = TestBed::new();
12730 bed.swap_to(&owner);
12731 let community = create_community(&bed.relay, "Uncited", bed.relays.clone(), None).await.unwrap();
12732 let admin = Keys::generate();
12733 let member_pk = member.keys.public_key();
12734 grant_admin(&bed.relay, &community, &admin.public_key()).await.unwrap();
12735
12736 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12737 let view = fetch_authority(&bed.relay, &community).await;
12738 crate::db::community::set_community_roles(&cid_hex, &view.roles, 1_000).unwrap();
12739
12740 let entity_id = super::super::derive::grant_locator(community.id(), &admin.public_key().to_bytes());
12741 let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
12742 let (version, edition_hash) = crate::db::community::get_edition_head(&cid_hex, &entity_hex).unwrap().unwrap();
12743 let cite = crate::community::edition::AuthorityCitation { entity_id, version, edition_hash };
12744
12745 let joined = guestbook::GuestbookEvent {
12746 rumor_id: [1u8; 32],
12747 entry: guestbook::GuestbookEntry::Join { member: member_pk, invited_by: None, at_ms: 1_000 },
12748 };
12749 let kick = |citation, id: u8, at| guestbook::GuestbookEvent {
12750 rumor_id: [id; 32],
12751 entry: guestbook::GuestbookEntry::Kick { actor: admin.public_key(), target: member_pk, citation, at_ms: at },
12752 };
12753 let roles = crate::db::community::get_community_roles(&cid_hex).unwrap();
12754 let empty_bans = std::collections::BTreeSet::new();
12755 let empty_marks = std::collections::BTreeMap::new();
12756 let fold = |evs: &[guestbook::GuestbookEvent]| {
12757 fold_members(&community, evs, Default::default(), &roles, &empty_bans, &empty_marks).unwrap()
12758 };
12759
12760 assert!(
12761 fold(&[joined.clone(), kick(None, 2, 2_000)]).contains(&member_pk),
12762 "an uncited kick from an admin is not honored"
12763 );
12764 assert!(
12765 !fold(&[joined, kick(Some(cite), 3, 3_000)]).contains(&member_pk),
12766 "the same kick WITH its synced citation removes them"
12767 );
12768 }
12769
12770 #[tokio::test]
12771 async fn kicking_an_admin_strips_their_roles_first() {
12772 let (bed, owner, member) = TestBed::new();
12776 bed.swap_to(&owner);
12777 let community = create_community(&bed.relay, "Compose", bed.relays.clone(), None).await.unwrap();
12778 let member_pk = member.keys.public_key();
12779 let member_hex = member_pk.to_hex();
12780 let owner_hex = owner.keys.public_key().to_hex();
12781
12782 grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
12783 assert!(fetch_authority(&bed.relay, &community).await.roles.is_admin(&member_hex));
12784
12785 kick_member(&bed.relay, &community, &member_pk).await.unwrap();
12786
12787 let view = fetch_authority(&bed.relay, &community).await;
12788 assert!(!view.roles.is_admin(&member_hex), "the kick stripped their rank");
12789 assert!(
12790 !view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::MANAGE_ROLES),
12791 "a kicked admin holds no bit"
12792 );
12793 assert!(
12794 !memberlist(&bed.relay, &community).await.unwrap().contains(&member_pk),
12795 "and the directive still removed them"
12796 );
12797 }
12798
12799 #[tokio::test]
12800 async fn grant_admin_mints_one_deterministic_role_and_revoke_strips_it() {
12801 let (bed, owner, member) = TestBed::new();
12802 bed.swap_to(&owner);
12803 let community = create_community(&bed.relay, "Adm", bed.relays.clone(), None).await.unwrap();
12804 let member_pk = member.keys.public_key();
12805 let member_hex = member_pk.to_hex();
12806 let owner_hex = owner.keys.public_key().to_hex();
12807
12808 grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
12809 let view = fetch_authority(&bed.relay, &community).await;
12810 assert!(view.roles.is_admin(&member_hex), "the grant folds as admin");
12811 assert!(view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::MANAGE_ROLES));
12812
12813 let second = Keys::generate().public_key();
12816 grant_admin(&bed.relay, &community, &second).await.unwrap();
12817 grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
12818 let view = fetch_authority(&bed.relay, &community).await;
12819 assert_eq!(view.roles.roles.len(), 1, "one Admin role, never a fork");
12820 assert!(view.roles.is_admin(&member_hex) && view.roles.is_admin(&second.to_hex()));
12821 let grant = view.roles.grants.iter().find(|g| g.member == member_hex).unwrap();
12822 assert_eq!(grant.role_ids.len(), 1, "no duplicate role id in the grant");
12823
12824 revoke_admin(&bed.relay, &community, &member_pk).await.unwrap();
12826 let view = fetch_authority(&bed.relay, &community).await;
12827 assert!(!view.roles.is_admin(&member_hex), "revoked");
12828 assert!(view.roles.is_admin(&second.to_hex()), "the other admin is untouched");
12829 assert!(!view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::KICK));
12830 }
12831
12832 #[tokio::test]
12833 async fn follow_control_persists_the_roster_for_sync_local_reads() {
12834 let (bed, owner, member) = TestBed::new();
12835 bed.swap_to(&owner);
12836 let community = create_community(&bed.relay, "Persist", bed.relays.clone(), None).await.unwrap();
12837 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12838 let member_hex = member.keys.public_key().to_hex();
12839 grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
12840
12841 let session = crate::state::SessionGuard::capture();
12843 follow_control(&bed.relay, &community, &session).await.unwrap();
12844 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
12845 assert!(roster.is_admin(&member_hex), "the persisted roster reads back without a fetch");
12846
12847 let withholding = MemoryRelay::new();
12850 let _ = follow_control(&withholding, &community, &session).await;
12851 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
12852 assert!(roster.is_admin(&member_hex), "withholding never shrinks standing");
12853
12854 revoke_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
12856 follow_control(&bed.relay, &community, &session).await.unwrap();
12857 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
12858 assert!(!roster.is_admin(&member_hex), "the revoke folds + persists");
12859 }
12860
12861 #[tokio::test]
12862 async fn grant_admin_is_refused_for_a_non_owner_and_publishes_nothing() {
12863 let (bed, owner, member) = TestBed::new();
12864 bed.swap_to(&owner);
12865 let community = create_community(&bed.relay, "NoSquat", bed.relays.clone(), None).await.unwrap();
12866
12867 bed.swap_to(&member);
12868 let err = grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap_err();
12869 assert!(err.contains("owner"), "refused before any publish: {err}");
12870
12871 bed.swap_to(&owner);
12874 let view = fetch_authority(&bed.relay, &community).await;
12875 assert!(view.roles.roles.is_empty(), "no role edition landed");
12876 grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
12877 let view = fetch_authority(&bed.relay, &community).await;
12878 assert!(view.roles.is_admin(&member.keys.public_key().to_hex()));
12879 }
12880
12881 #[tokio::test]
12882 async fn grant_admin_merges_other_roles_and_refuses_a_withheld_grant() {
12883 let (bed, owner, member) = TestBed::new();
12884 bed.swap_to(&owner);
12885 let community = create_community(&bed.relay, "Merge", bed.relays.clone(), None).await.unwrap();
12886 let member_pk = member.keys.public_key();
12887
12888 let mod_rid = crate::simd::hex::bytes_to_hex_32(&[0x66; 32]);
12891 set_role(&bed.relay, &community, &admin_role(&mod_rid, Permissions::BAN)).await.unwrap();
12892 grant_roles(&bed.relay, &community, &member_pk, vec![mod_rid.clone()]).await.unwrap();
12893
12894 let withholding = MemoryRelay::new();
12897 let err = grant_admin(&withholding, &community, &member_pk).await.unwrap_err();
12898 assert!(err.contains("could not be fetched"), "withheld grant refused: {err}");
12899
12900 grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
12902 let view = fetch_authority(&bed.relay, &community).await;
12903 let grant = view.roles.grants.iter().find(|g| g.member == member_pk.to_hex()).unwrap();
12904 assert_eq!(grant.role_ids.len(), 2, "admin ADDED to the existing grant, not replacing it");
12905 assert!(grant.role_ids.contains(&mod_rid));
12906 }
12907
12908 #[tokio::test]
12909 async fn fetch_authority_reflects_a_granted_admin() {
12910 let (bed, owner, member) = TestBed::new();
12911 bed.swap_to(&owner);
12912 let community = create_community(&bed.relay, "Auth", bed.relays.clone(), None).await.unwrap();
12913 let rid = crate::simd::hex::bytes_to_hex_32(&[0x5a; 32]);
12914 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
12915 publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
12916
12917 let view = fetch_authority(&bed.relay, &community).await;
12918 let member_hex = member.keys.public_key().to_hex();
12919 assert!(view.roles.is_admin(&member_hex), "the granted member folds as admin");
12920 assert!(
12921 view.roles.is_authorized(&member_hex, Some(&owner.keys.public_key().to_hex()), Permissions::KICK),
12922 "an ADMIN_ALL grant carries KICK"
12923 );
12924 assert!(view.banned.is_empty());
12925 }
12926
12927 #[tokio::test]
12932 async fn a_pin_edition_folds_persists_and_reads_back() {
12933 let (_tmp, _guard, _owner) = init_test_db();
12934 let relay = MemoryRelay::new();
12935 let community = create_community(&relay, "Pinsville", vec!["wss://r".into()], None).await.unwrap();
12936 let general = community.channels[0].id;
12937 let ch_hex = crate::simd::hex::bytes_to_hex_32(&general.0);
12938
12939 send_message(&relay, &community, &general, "pin-worthy").await.unwrap();
12940 let page = fetch_channel(&relay, &community, &general, 10).await.unwrap();
12941 let opened = page
12942 .iter()
12943 .find_map(|f| match &f.event {
12944 ChatEvent::Message { opened, .. } => Some(opened.clone()),
12945 _ => None,
12946 })
12947 .expect("the sent message reads back");
12948
12949 let ch = community.channels[0].clone();
12950 let conv = channel_conv_key_at(&community, &ch, 0).expect("owner holds the public plane key");
12951 let entry = crate::community::v2::pins::build_pin_entry(&opened, &conv, &ch_hex).unwrap();
12952 let content = crate::community::v2::pins::serialize_public_pin_list(&[entry]).unwrap();
12953
12954 let session = crate::state::SessionGuard::capture();
12955 let eid = crate::community::v2::derive::pins_locator(community.id(), &general);
12956 publish_control_edition(&relay, &community, &session, vsk::PINS, &eid, &content).await.unwrap();
12957 follow_control(&relay, &community, &session).await.unwrap();
12958
12959 let read = read_channel_pins(&community, &general).unwrap();
12960 assert!(!read.sealed);
12961 assert!(read.version >= 1, "the folded head persisted");
12962 assert_eq!(read.pins.len(), 1);
12963 assert_eq!(read.pins[0].content, "pin-worthy");
12964 assert_eq!(read.pins[0].rumor_id, opened.rumor_id.to_hex());
12965 }
12966
12967 #[tokio::test]
12970 async fn an_unauthorized_pin_edition_never_folds() {
12971 let (_tmp, _guard, _owner) = init_test_db();
12972 let relay = MemoryRelay::new();
12973 let community = create_community(&relay, "Gated", vec!["wss://r".into()], None).await.unwrap();
12974 let general = community.channels[0].id;
12975
12976 let rogue = Keys::generate();
12977 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
12978 let eid = crate::community::v2::derive::pins_locator(community.id(), &general);
12979 let rumor = control::build_edition_rumor(rogue.public_key(), vsk::PINS, &eid, 1, None, r#"{"entries":[]}"#, 2_000, None);
12980 let (wrap, _) = control::seal_control_edition(&rumor, &group, &rogue, Timestamp::from_secs(2_000)).unwrap();
12981 relay.publish(&wrap, &community.relays).await.unwrap();
12982
12983 let session = crate::state::SessionGuard::capture();
12984 follow_control(&relay, &community, &session).await.unwrap();
12985 let read = read_channel_pins(&community, &general).unwrap();
12986 assert_eq!(read.version, 0, "an unauthorized edition never persists a head");
12987 assert!(read.pins.is_empty());
12988 }
12989
12990 #[tokio::test]
12993 async fn owner_silently_widens_a_legacy_admin_role() {
12994 let (_tmp, _guard, owner) = init_test_db();
12995 let relay = MemoryRelay::new();
12996 let community = create_community(&relay, "Legacy", vec!["wss://r".into()], None).await.unwrap();
12997 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12998 let rid = crate::simd::hex::bytes_to_hex_32(&[0x5d; 32]);
12999
13000 let legacy = Role {
13002 role_id: rid.clone(),
13003 name: "Admin".into(),
13004 position: 1,
13005 permissions: Permissions(Permissions::ADMIN_FOUNDING_MASK),
13006 scope: RoleScope::Server,
13007 color: 0,
13008 };
13009 publish_role(&relay, &community, &owner, &legacy, 1).await;
13010 let session = crate::state::SessionGuard::capture();
13011 follow_control(&relay, &community, &session).await.unwrap();
13012
13013 assert!(upgrade_admin_role_pin_bit(&relay, &community).await.unwrap(), "the widening publishes");
13014 follow_control(&relay, &community, &session).await.unwrap();
13015 let roles = crate::db::community::get_community_roles(&cid_hex).unwrap();
13016 let widened = roles.roles.iter().find(|r| r.role_id == rid).expect("same entity");
13017 assert!(widened.permissions.contains(Permissions::PIN_MESSAGES), "bit 11 landed");
13018 assert!(widened.permissions.contains(Permissions::ADMIN_FOUNDING_MASK), "nothing stripped");
13019
13020 assert!(!upgrade_admin_role_pin_bit(&relay, &community).await.unwrap());
13022 }
13023
13024 #[tokio::test]
13027 async fn the_deletion_duty_omits_a_pinned_message() {
13028 let (_tmp, _guard, _owner) = init_test_db();
13029 let relay = MemoryRelay::new();
13030 let community = create_community(&relay, "Duties", vec!["wss://r".into()], None).await.unwrap();
13031 let general = community.channels[0].id;
13032 let ch_hex = crate::simd::hex::bytes_to_hex_32(&general.0);
13033
13034 let rumor_id = send_message(&relay, &community, &general, "soon deleted").await.unwrap();
13035 let page = fetch_channel(&relay, &community, &general, 10).await.unwrap();
13036 let opened = page
13037 .iter()
13038 .find_map(|f| match &f.event {
13039 ChatEvent::Message { opened, .. } => (opened.rumor_id.to_hex() == rumor_id).then(|| opened.clone()),
13040 _ => None,
13041 })
13042 .unwrap();
13043 let ch = community.channels[0].clone();
13044 let conv = channel_conv_key_at(&community, &ch, 0).unwrap();
13045 let entry = crate::community::v2::pins::build_pin_entry(&opened, &conv, &ch_hex).unwrap();
13046 let session = crate::state::SessionGuard::capture();
13047 publish_pin_list(&relay, &community, &session, &ch, &[entry]).await.unwrap();
13048 assert_eq!(read_channel_pins(&community, &general).unwrap().pins.len(), 1);
13049
13050 run_pin_duty(&relay, &ch_hex, &rumor_id, None, crate::state::SessionGuard::capture()).await.unwrap();
13052 let after = read_channel_pins(&community, &general).unwrap();
13053 assert!(after.pins.is_empty(), "the omitting edition landed");
13054 assert!(after.version >= 2, "a NEW edition, not a local erase");
13055 }
13056
13057 #[tokio::test]
13060 async fn the_edit_duty_refreshes_a_pinned_proof() {
13061 let (_tmp, _guard, _owner) = init_test_db();
13062 let relay = MemoryRelay::new();
13063 let community = create_community(&relay, "Edits", vec!["wss://r".into()], None).await.unwrap();
13064 let general = community.channels[0].id;
13065 let ch_hex = crate::simd::hex::bytes_to_hex_32(&general.0);
13066
13067 let rumor_id = send_message(&relay, &community, &general, "first words").await.unwrap();
13068 let page = fetch_channel(&relay, &community, &general, 10).await.unwrap();
13069 let opened = page
13070 .iter()
13071 .find_map(|f| match &f.event {
13072 ChatEvent::Message { opened, .. } => (opened.rumor_id.to_hex() == rumor_id).then(|| opened.clone()),
13073 _ => None,
13074 })
13075 .unwrap();
13076 let ch = community.channels[0].clone();
13077 let conv = channel_conv_key_at(&community, &ch, 0).unwrap();
13078 let entry = crate::community::v2::pins::build_pin_entry(&opened, &conv, &ch_hex).unwrap();
13079 let session = crate::state::SessionGuard::capture();
13080 publish_pin_list(&relay, &community, &session, &ch, &[entry]).await.unwrap();
13081
13082 send_edit(&relay, &community, &general, &rumor_id, "second thoughts").await.unwrap();
13083 let page = fetch_channel(&relay, &community, &general, 10).await.unwrap();
13084 let edit_opened = page
13085 .iter()
13086 .find_map(|f| match &f.event {
13087 ChatEvent::Edit { opened, .. } => Some(opened.clone()),
13088 _ => None,
13089 })
13090 .expect("the edit reads back");
13091
13092 run_pin_duty(&relay, &ch_hex, &rumor_id, Some(edit_opened.clone()), crate::state::SessionGuard::capture()).await.unwrap();
13093 let after = read_channel_pins(&community, &general).unwrap();
13094 assert_eq!(after.pins.len(), 1);
13095 assert_eq!(
13096 after.pins[0].content, "second thoughts",
13097 "the refreshed bundle proves the revision"
13098 );
13099 let v = after.version;
13100
13101 run_pin_duty(&relay, &ch_hex, &rumor_id, Some(edit_opened), crate::state::SessionGuard::capture()).await.unwrap();
13103 assert_eq!(read_channel_pins(&community, &general).unwrap().version, v, "idempotent");
13104 }
13105
13106 #[tokio::test]
13110 async fn a_rotation_reseals_the_pin_list_under_the_new_epoch() {
13111 let (_tmp, _guard, _owner) = init_test_db();
13112 let relay = MemoryRelay::new();
13113 let community = create_community(&relay, "Reseal", vec!["wss://r".into()], None).await.unwrap();
13114 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
13115 let chan = create_private_channel(&relay, &community, "vault").await.unwrap();
13116 let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
13117 let ch_hex = crate::simd::hex::bytes_to_hex_32(&chan.0);
13118
13119 let rumor_id = send_message(&relay, &community, &chan, "sealed wisdom").await.unwrap();
13120 let page = fetch_channel(&relay, &community, &chan, 10).await.unwrap();
13121 let opened = page
13122 .iter()
13123 .find_map(|f| match &f.event {
13124 ChatEvent::Message { opened, .. } => (opened.rumor_id.to_hex() == rumor_id).then(|| opened.clone()),
13125 _ => None,
13126 })
13127 .unwrap();
13128 let ch = community.channel(&chan).unwrap().clone();
13129 let old_epoch = ch.epoch.0;
13130 let conv = channel_conv_key_at(&community, &ch, old_epoch).unwrap();
13131 let entry = crate::community::v2::pins::build_pin_entry(&opened, &conv, &ch_hex).unwrap();
13132 let session = crate::state::SessionGuard::capture();
13133 publish_pin_list(&relay, &community, &session, &ch, &[entry]).await.unwrap();
13134 let (_, v_before) = crate::db::community::get_community_pins(&cid_hex, &ch_hex).unwrap().unwrap();
13135
13136 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
13138 rekey_channel_excluding(&relay, &community, &chan, &roster, &[], &Keys::generate().public_key())
13139 .await
13140 .unwrap();
13141
13142 let (content, version) = crate::db::community::get_community_pins(&cid_hex, &ch_hex).unwrap().unwrap();
13144 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
13145 assert_eq!(
13146 parsed["epoch"].as_str().unwrap(),
13147 (old_epoch + 1).to_string(),
13148 "the reseal names the rotated epoch"
13149 );
13150 assert!(version > v_before, "a real edition, not a local rewrite");
13151
13152 let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
13154 let read = read_channel_pins(&community, &chan).unwrap();
13155 assert!(!read.sealed);
13156 assert_eq!(read.pins.len(), 1);
13157 assert_eq!(read.pins[0].content, "sealed wisdom");
13158 }
13159
13160 #[tokio::test]
13166 async fn a_published_banlist_echoes_locally_before_any_fold() {
13167 let (_tmp, _guard, _owner) = init_test_db();
13168 let relay = MemoryRelay::new();
13169 let community = create_community(&relay, "Modtown", vec!["wss://r".into()], None).await.unwrap();
13170 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
13171 let spammer_a = "aa".repeat(32);
13172 let spammer_b = "bb".repeat(32);
13173
13174 set_banlist(&relay, &community, &[spammer_a.clone()]).await.unwrap();
13176 assert_eq!(
13177 crate::db::community::get_community_banlist(&cid_hex).unwrap(),
13178 vec![spammer_a.clone()],
13179 "the publish echoes without waiting for a fold"
13180 );
13181
13182 let mut list = crate::db::community::get_community_banlist(&cid_hex).unwrap();
13184 list.push(spammer_b.clone());
13185 set_banlist(&relay, &community, &list).await.unwrap();
13186 let held = crate::db::community::get_community_banlist(&cid_hex).unwrap();
13187 assert!(
13188 held.contains(&spammer_a) && held.contains(&spammer_b),
13189 "sequential bans UNION; the second must not erase the first: {held:?}"
13190 );
13191
13192 let session = crate::state::SessionGuard::capture();
13194 follow_control(&relay, &community, &session).await.unwrap();
13195 let folded = crate::db::community::get_community_banlist(&cid_hex).unwrap();
13196 assert!(folded.contains(&spammer_a) && folded.contains(&spammer_b));
13197 }
13198}