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 let mut token = [0u8; super::derive::TOKEN_LEN];
839 token.copy_from_slice(&super::super::random_32()[..super::derive::TOKEN_LEN]);
840 let link_signer = Keys::generate();
841 let bundle = bundle_of(community, BundleAudience::Link, Some(me_pk()?), expires_at_ms, label.clone());
842 let bundle_key = super::derive::invite_bundle_key(&token);
843 let bundle_event = invite::build_bundle_event(&link_signer, &bundle, &bundle_key).map_err(|e| e.to_string())?;
844 let url = invite::build_invite_url(base, &link_signer.public_key(), &token, &community.relays).map_err(|e| e.to_string())?;
845
846 if !session.is_valid() {
847 return Err("account changed before minting link".to_string());
848 }
849 transport.publish_durable(&bundle_event, &community.relays).await?;
850 let minted = MintedLink { url, bundle_event, link_signer, token, expires_at_ms, label: label.clone() };
851 let _ = record_minted_link(transport, community, &minted).await;
855 if session.is_valid() {
860 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
861 let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
862 let _ = crate::db::community::save_public_invite(&token_hex, &cid_hex, &minted.url, expires_at_ms.map(|e| e as i64), label.as_deref());
863 }
864 Ok(minted)
865}
866
867async fn fetch_invite_list<T: Transport + ?Sized>(
876 transport: &T,
877 relays: &[String],
878) -> Result<Option<invite::InviteList>, String> {
879 let signer = crate::signer::active_signer()?;
880 let my_pk = me_pk()?;
881 let query = Query {
882 kinds: vec![super::kind::INVITE_LIST],
883 authors: vec![my_pk.to_hex()],
884 limit: Some(4),
885 evidence: crate::community::transport::Evidence::Full,
886 ..Default::default()
887 };
888 let events = transport.fetch(&query, relays).await?;
889 let mut best: Option<(u64, invite::InviteList)> = None;
890 for e in events {
891 if let Ok(l) = invite::parse_invite_list_event_signed(&signer, my_pk, &e).await {
892 let at = e.created_at.as_secs();
893 if best.as_ref().map(|(b, _)| at > *b).unwrap_or(true) {
894 best = Some((at, l));
895 }
896 }
897 }
898 Ok(best.map(|(_, l)| l))
899}
900
901fn live_signers_for(list: &invite::InviteList, community_id_hex: &str, now_ms: u64) -> Vec<PublicKey> {
909 let dead: std::collections::HashSet<&str> = list.tombstones.iter().map(|t| t.token.as_str()).collect();
910 list.entries
911 .iter()
912 .filter(|e| e.community_id == community_id_hex && !dead.contains(e.token.as_str()))
913 .filter(|e| !e.expires_at.is_some_and(|exp| now_ms > exp))
914 .filter_map(|e| Keys::parse(&e.signer_sk).ok().map(|k| k.public_key()))
915 .collect()
916}
917
918async fn publish_invite_registry<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, session: &SessionGuard, live_signers: &[PublicKey]) -> Result<(), String> {
922 let my_pk = me_pk()?;
923 let eid = super::derive::invite_links_locator(community.id(), &my_pk.to_bytes());
924 let content = invite::build_registry_content(live_signers);
925 publish_control_edition(transport, community, session, vsk::INVITE_LINKS, &eid, &content).await?;
926 refresh_invite_registry_cache(transport, community, session).await;
930 Ok(())
931}
932
933async fn refresh_invite_registry_cache<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, session: &SessionGuard) {
937 let Ok(owner) = community.owner() else { return };
938 let Some(editions) = fetch_control_plane_whole(transport, community).await else { return };
939 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
940 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
941 .unwrap_or_default()
942 .into_iter()
943 .filter(|(_, f)| f.0 == community.root_epoch.0)
944 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
945 .collect();
946 let authority = fold_authority(community, &editions, &floors);
947 let sets = live_invite_link_sets(community.id(), &owner.to_hex(), &editions, &authority, &floors);
948 if session.is_valid() {
949 let _ = crate::db::community::set_community_invite_registry(&cid_hex, &flatten_link_sets(&sets));
950 let _ = crate::db::community::replace_invite_link_sets(&cid_hex, &sets);
951 }
952}
953
954async fn record_minted_link<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, minted: &MintedLink) -> Result<(), String> {
957 let session = SessionGuard::capture();
958 let signer = crate::signer::active_signer()?;
959 let my_pk = me_pk()?;
960 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
961 let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
962 let mut list = fetch_invite_list(transport, &community.relays).await?.unwrap_or_default();
967 if !list.entries.iter().any(|e| e.token == token_hex) {
968 list.entries.push(invite::InviteEntry {
969 token: token_hex,
970 signer_sk: minted.link_signer.secret_key().to_secret_hex(),
971 community_id: cid_hex.clone(),
972 url: minted.url.clone(),
973 label: minted.label.clone(),
974 created_at: now_ms() / 1000,
975 expires_at: minted.expires_at_ms,
976 extra: Default::default(),
977 });
978 }
979 if !session.is_valid() {
980 return Err("account changed during link record".to_string());
981 }
982 let event = invite::build_invite_list_event_signed(&signer, my_pk, &list).await.map_err(|e| e.to_string())?;
983 transport.publish(&event, &community.relays).await?;
984 let signers = live_signers_for(&list, &cid_hex, now_ms());
985 publish_invite_registry(transport, community, &session, &signers).await
986}
987
988pub async fn revoke_public_link<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, token_hex: &str) -> Result<(), String> {
994 let session = SessionGuard::capture();
995 let signer = crate::signer::active_signer()?;
996 let my_pk = me_pk()?;
997 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
998 let mut list = fetch_invite_list(transport, &community.relays).await?.ok_or("no invite list found to revoke from")?;
999 let entry = list
1000 .entries
1001 .iter()
1002 .find(|e| e.token == token_hex && e.community_id == cid_hex)
1003 .cloned()
1004 .ok_or("no such link in the invite list")?;
1005 let link_signer = Keys::parse(&entry.signer_sk).map_err(|_| "malformed link signer")?;
1007 let revocation = invite::build_revocation(&link_signer).map_err(|e| e.to_string())?;
1008 if !session.is_valid() {
1009 return Err("account changed during revoke".to_string());
1010 }
1011 transport.publish_durable(&revocation, &community.relays).await?;
1012 list.tombstones.push(invite::InviteTombstone { token: token_hex.to_string(), community_id: cid_hex.clone(), extra: Default::default() });
1014 list.entries.retain(|e| e.token != token_hex);
1015 let event = invite::build_invite_list_event_signed(&signer, my_pk, &list).await.map_err(|e| e.to_string())?;
1016 transport.publish(&event, &community.relays).await?;
1017 let signers = live_signers_for(&list, &cid_hex, now_ms());
1018 publish_invite_registry(transport, community, &session, &signers).await?;
1019 if session.is_valid() {
1021 let _ = crate::db::community::delete_public_invite(token_hex);
1022 }
1023 Ok(())
1024}
1025
1026pub async fn refresh_public_links<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
1031 let session = SessionGuard::capture();
1032 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1033 let signer = crate::signer::active_signer()?;
1038 let my_pk = me_pk()?;
1039 let query = Query {
1040 kinds: vec![super::kind::INVITE_LIST],
1041 authors: vec![my_pk.to_hex()],
1042 limit: Some(4),
1043 ..Default::default()
1044 };
1045 let events = transport.fetch(&query, &community.relays).await?;
1046 let mut best: Option<(u64, invite::InviteList)> = None;
1047 for e in events {
1048 if let Ok(l) = invite::parse_invite_list_event_signed(&signer, my_pk, &e).await {
1049 let at = e.created_at.as_secs();
1050 if best.as_ref().map(|(b, _)| at > *b).unwrap_or(true) {
1051 best = Some((at, l));
1052 }
1053 }
1054 }
1055 let Some((_, list)) = best else {
1056 return Ok(());
1057 };
1058 let creator = my_pk;
1059 let now = now_ms();
1060 let dead: std::collections::HashSet<&str> = list.tombstones.iter().map(|t| t.token.as_str()).collect();
1061 for entry in &list.entries {
1062 if entry.community_id != cid_hex || dead.contains(entry.token.as_str()) || entry.token.len() != 2 * super::derive::TOKEN_LEN {
1063 continue;
1064 }
1065 if entry.expires_at.is_some_and(|exp| now > exp) {
1068 continue;
1069 }
1070 let Ok(link_signer) = Keys::parse(&entry.signer_sk) else { continue };
1071 let token = crate::simd::hex::hex_to_bytes_16(&entry.token);
1072 let bundle = bundle_of(community, BundleAudience::Link, Some(creator), entry.expires_at, entry.label.clone());
1073 let bundle_key = super::derive::invite_bundle_key(&token);
1074 if let Ok(event) = invite::build_bundle_event(&link_signer, &bundle, &bundle_key) {
1075 if !session.is_valid() {
1076 return Err("account changed during link refresh".to_string());
1077 }
1078 let _ = transport.publish_durable(&event, &community.relays).await;
1079 }
1080 }
1081 let mine_here = list.entries.iter().any(|e| e.community_id == cid_hex);
1091 if !mine_here {
1092 return Ok(());
1093 }
1094 let signers = live_signers_for(&list, &cid_hex, now);
1095 if !session.is_valid() {
1096 return Err("account changed during link refresh".to_string());
1097 }
1098 let _ = publish_invite_registry(transport, community, &session, &signers).await;
1099 Ok(())
1100}
1101
1102pub async fn community_is_public<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> bool {
1107 let Ok(owner) = community.owner() else { return false };
1108 let Some(editions) = fetch_control_plane_whole(transport, community).await else { return true };
1112 let cid = community.id();
1113 let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
1114 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
1115 .unwrap_or_default()
1116 .into_iter()
1117 .filter(|(_, f)| f.0 == community.root_epoch.0)
1118 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
1119 .collect();
1120 let authority = fold_authority(community, &editions, &floors);
1121 !live_invite_link_sets(cid, &owner.to_hex(), &editions, &authority, &floors).is_empty()
1122}
1123
1124async fn fetch_control_plane_whole<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Option<Vec<ParsedEdition>> {
1130 let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
1131 let mut editions: Vec<ParsedEdition> = Vec::new();
1132 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
1133 let mut oldest: Option<u64> = None;
1134 let mut until: Option<u64> = None;
1135 for page in 0..COMPACT_MAX_PAGES {
1136 let query = Query {
1140 kinds: vec![stream::KIND_WRAP],
1141 authors: vec![control.pk_hex()],
1142 until,
1143 limit: Some(FOLLOW_PAGE),
1144 evidence: crate::community::transport::Evidence::Quorum,
1145 ..Default::default()
1146 };
1147 let Ok(wraps) = transport.fetch(&query, &community.relays).await else { return None };
1148 let mut fresh = 0usize;
1149 for w in &wraps {
1150 if !seen_wraps.insert(w.id) {
1151 continue;
1152 }
1153 fresh += 1;
1154 let at = w.created_at.as_secs();
1155 if oldest.is_none_or(|o| at < o) {
1156 oldest = Some(at);
1157 }
1158 if let Ok((ed, _)) = control::open_control_edition(w, &control) {
1159 editions.push(ed);
1160 }
1161 }
1162 if fresh == 0 {
1163 if wraps.len() >= FOLLOW_PAGE {
1164 return None; }
1166 return Some(editions);
1167 }
1168 until = oldest;
1169 if page + 1 == COMPACT_MAX_PAGES {
1170 return None;
1171 }
1172 }
1173 Some(editions)
1174}
1175
1176fn live_invite_link_sets(
1181 cid: &crate::community::CommunityId,
1182 owner_hex: &str,
1183 editions: &[ParsedEdition],
1184 authority: &AuthoritySet,
1185 floors: &Floors,
1186) -> Vec<crate::db::community::InviteLinkSetRow> {
1187 use crate::community::roles::Permissions;
1188 use std::collections::BTreeMap;
1189 let mut by_eid: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
1190 for e in editions {
1191 if e.vsk == vsk::INVITE_LINKS {
1192 by_eid.entry(e.entity_id).or_default().push(e);
1193 }
1194 }
1195 let mut sets: Vec<crate::db::community::InviteLinkSetRow> = Vec::new();
1196 for (eid, group) in &by_eid {
1197 let authed: Vec<&ParsedEdition> = group
1203 .iter()
1204 .copied()
1205 .filter(|p| {
1206 let author = p.author.to_hex();
1207 !authority.banned.contains(&author)
1209 && authority.roles.is_authorized(&author, Some(owner_hex), Permissions::CREATE_INVITE)
1210 && super::derive::invite_links_locator(cid, &p.author.to_bytes()) == *eid
1211 })
1212 .collect();
1213 if authed.is_empty() {
1214 continue;
1215 }
1216 let fold_eds: Vec<version::Edition> = authed.iter().map(|p| p.to_fold_edition()).collect();
1217 let (Some(hi), _) = fold_head(&fold_eds, floors.get(&crate::simd::hex::bytes_to_hex_32(eid))) else { continue };
1218 if let Ok(signers) = invite::parse_registry_content(&authed[hi].content) {
1219 if signers.is_empty() {
1220 continue; }
1222 sets.push(crate::db::community::InviteLinkSetRow {
1223 creator_hex: authed[hi].author.to_hex(),
1224 locators: signers.iter().map(|p| p.to_hex()).collect(),
1225 });
1226 }
1227 }
1228 sets
1229}
1230
1231fn flatten_link_sets(sets: &[crate::db::community::InviteLinkSetRow]) -> Vec<String> {
1233 let mut flat: Vec<String> = sets.iter().flat_map(|s| s.locators.iter().cloned()).collect();
1234 flat.sort();
1235 flat.dedup();
1236 flat
1237}
1238
1239async fn accept_bundle<T: Transport + ?Sized>(
1245 transport: &T,
1246 session: &SessionGuard,
1247 bundle: &CommunityInvite,
1248 invited_by: Option<PublicKey>,
1249 announce_join: bool,
1250) -> Result<CommunityV2, String> {
1251 let signer = crate::signer::active_signer()?;
1252 let my_pk = me_pk()?;
1253 let at_ms = now_ms();
1254 if bundle.expired(at_ms) {
1256 return Err("this invite has expired".to_string());
1257 }
1258 let community = CommunityV2::from_bundle(bundle, at_ms)?;
1260 let already_held = crate::db::community::load_community_v2(community.id()).ok().flatten().is_some();
1263
1264 let handoff = VERIFIED_PREVIEW.lock().unwrap().take().filter(|v| {
1273 v.session.is_valid()
1274 && v.at.elapsed() < VERIFIED_PREVIEW_TTL
1275 && v.community_id == community.id().0
1276 && v.community_root == community.community_root
1277 });
1278 let (community, join_heads, join_banlist) = match handoff {
1279 Some(v) => {
1280 let mut c = v.folded;
1281 c.created_at_ms = at_ms;
1283 (c, v.heads, v.banned)
1284 }
1285 None => verify_owner_root_and_reconcile(transport, community).await?,
1286 };
1287
1288 if is_dissolved(transport, &community).await {
1290 return Err("this community has been dissolved".to_string());
1291 }
1292
1293 if join_banlist.contains(&my_pk.to_hex()) {
1298 return Err("you are banned from this community".to_string());
1299 }
1300
1301 if !session.is_valid() {
1305 return Err("account changed during join".to_string());
1306 }
1307 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1312 for h in &join_heads {
1313 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)?;
1314 }
1315 crate::db::community::save_community_v2(&community)?;
1316 let _ = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, community.root_epoch.0, &community.community_root);
1319 for ch in &community.channels {
1322 if let (true, Some(key)) = (ch.private, ch.key) {
1323 let _ = crate::db::community::store_epoch_key(&cid_hex, &crate::simd::hex::bytes_to_hex_32(&ch.id.0), ch.epoch.0, &key);
1324 }
1325 }
1326
1327 if announce_join && !already_held {
1333 let attribution = invited_by
1334 .map(|p| p.to_hex())
1335 .or_else(|| bundle.creator_npub.clone())
1336 .zip(Some(bundle.label.clone().unwrap_or_default()));
1337 let attr_ref = attribution.as_ref().map(|(c, l)| (c.as_str(), l.as_str()));
1338 let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1339 let join_rumor = guestbook::build_join_rumor(my_pk, attr_ref, at_ms);
1340 if let Ok((join_wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &join_rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await {
1341 let _ = transport.publish(&join_wrap, &community.relays).await;
1342 }
1343 }
1344
1345 match republish_community_list(transport, Some(community.id())).await {
1349 Ok(true) => {}
1350 Ok(false) => republish_community_list_durable(Some(*community.id())),
1351 Err(e) => {
1352 crate::log_warn!("[CommunityList] failed to record this join across devices ({}) — retrying", e);
1353 republish_community_list_durable(Some(*community.id()));
1354 }
1355 }
1356 Ok(community)
1357}
1358
1359async fn verify_owner_root_and_reconcile<T: Transport + ?Sized>(
1376 transport: &T,
1377 community: CommunityV2,
1378) -> Result<(CommunityV2, Vec<FoldedHead>, std::collections::BTreeSet<String>), String> {
1379 let owner = community.owner()?;
1380 let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
1381 let control_pk = control.pk_hex();
1382
1383 super::streamauth::prime(&community);
1390
1391 const PAGE: usize = 500;
1412 const MAX_PAGES: usize = 4;
1413 const FAR_FUTURE_SECS: u64 = 4_102_444_800; let mut editions: Vec<ParsedEdition> = Vec::new();
1415 let mut all_editions: Vec<ParsedEdition> = Vec::new();
1416 let mut found_genesis = false;
1417 let mut compacted_metadata = false;
1425 crate::log_debug!(
1426 "[JoinVerify] control_pk={} root_epoch={:?} relays={:?}",
1427 &control_pk[..12], community.root_epoch, community.relays
1428 );
1429 let anchored = |found_genesis: bool, compacted_metadata: bool, owner_editions: usize, epoch: Epoch| {
1430 found_genesis || (epoch.0 > 0 && compacted_metadata && owner_editions > 0)
1431 };
1432 for attempt in 0..2 {
1433 editions.clear();
1434 all_editions.clear();
1435 compacted_metadata = false;
1436 let mut until: Option<u64> = Some(FAR_FUTURE_SECS);
1437 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
1438 for page_no in 0..MAX_PAGES {
1439 let query = Query {
1440 kinds: vec![stream::KIND_WRAP],
1441 authors: vec![control_pk.clone()],
1442 until,
1443 limit: Some(PAGE),
1444 evidence: crate::community::transport::Evidence::Full,
1445 ..Default::default()
1446 };
1447 let wraps = transport.fetch(&query, &community.relays).await?;
1448 crate::log_trace!(
1449 "[JoinVerify] attempt {} page {}: fetched {} wraps",
1450 attempt, page_no, wraps.len()
1451 );
1452 let mut oldest = u64::MAX;
1456 let mut fresh = 0usize;
1457 for w in &wraps {
1458 if !seen_wraps.insert(w.id) {
1459 continue;
1460 }
1461 fresh += 1;
1462 oldest = oldest.min(w.created_at.as_secs());
1463 if let Ok((ed, _)) = control::open_control_edition(w, &control) {
1464 crate::log_trace!(
1465 "[JoinVerify] edition vsk={} eid={} owner={} at={}",
1466 ed.vsk, crate::simd::hex::bytes_to_hex_32(&ed.entity_id)[..12].to_string(),
1467 ed.author == owner, w.created_at.as_secs()
1468 );
1469 if ed.vsk == vsk::COMMUNITY_METADATA && ed.entity_id == community.id().0 {
1470 if ed.author == owner {
1471 found_genesis = true;
1472 } else {
1473 compacted_metadata = true;
1474 }
1475 }
1476 if ed.author == owner {
1477 editions.push(ed.clone());
1478 }
1479 all_editions.push(ed);
1482 }
1483 }
1484 crate::log_debug!(
1485 "[JoinVerify] attempt {} page {}: fresh={} opened_owner={} opened_any={} genesis={} compacted={}",
1486 attempt, page_no, fresh, editions.len(), all_editions.len(), found_genesis, compacted_metadata
1487 );
1488 if anchored(found_genesis, compacted_metadata, editions.len(), community.root_epoch) || fresh == 0 {
1489 break; }
1491 until = Some(oldest);
1492 }
1493 if anchored(found_genesis, compacted_metadata, editions.len(), community.root_epoch) {
1494 break;
1495 }
1496 if attempt == 0 {
1497 if let Some(client) = crate::state::nostr_client() {
1504 super::streamauth::prime_auth(&client, &community.relays).await;
1505 }
1506 }
1507 }
1508 if !anchored(found_genesis, compacted_metadata, editions.len(), community.root_epoch) {
1509 return Err(
1510 "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"
1511 .to_string(),
1512 );
1513 }
1514 let empty_floors = Floors::new();
1523 let authority = AuthoritySet::owner_only();
1524 let fold = apply_control_fold(&community, &editions, &empty_floors, &authority);
1525 let join_banlist = fold_authority(&community, &all_editions, &empty_floors).banned;
1530 Ok((fold.updated.unwrap_or(community), fold.heads, join_banlist))
1531}
1532
1533pub async fn accept_direct_invite<T: Transport + ?Sized>(transport: &T, wrap: &Event) -> Result<CommunityV2, String> {
1537 let session = SessionGuard::capture();
1538 let signer = crate::signer::active_signer()?;
1539 let (inviter, bundle) = invite::unwrap_direct_invite_signed(&signer, wrap).await.map_err(|e| e.to_string())?;
1540 accept_bundle(transport, &session, &bundle, Some(inviter), true).await
1541}
1542
1543pub async fn accept_parked_invite<T: Transport + ?Sized>(
1549 transport: &T,
1550 bundle_json: &str,
1551 inviter_hex: Option<&str>,
1552) -> Result<CommunityV2, String> {
1553 let session = SessionGuard::capture();
1554 let bundle = CommunityInvite::from_bundle_json(bundle_json).map_err(|e| e.to_string())?;
1555 let invited_by = inviter_hex.and_then(|h| PublicKey::parse(h).ok());
1556 accept_bundle(transport, &session, &bundle, invited_by, true).await
1557}
1558
1559pub async fn accept_migration_material<T: Transport + ?Sized>(
1565 transport: &T,
1566 jm: &super::list::JoinMaterial,
1567) -> Result<CommunityV2, String> {
1568 let session = SessionGuard::capture();
1569 let bundle = material_to_invite(jm);
1570 accept_bundle(transport, &session, &bundle, None, true).await
1571}
1572
1573pub async fn fetch_public_bundle<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityInvite, String> {
1580 let parsed = invite::parse_invite_link(url).map_err(|e| e.to_string())?;
1581 let query = Query {
1586 kinds: vec![super::kind::INVITE_BUNDLE],
1587 authors: vec![parsed.link_signer.to_hex()],
1588 ..Default::default()
1589 };
1590 let relays = if parsed.bootstrap_relays.is_empty() {
1591 invite::stock_relays()
1592 } else {
1593 parsed.bootstrap_relays.clone()
1594 };
1595 let events = match transport.fetch(&query, &relays).await {
1600 Ok(evs) => evs,
1601 Err(_) => {
1602 wait_for_bootstrap_relay(&relays).await;
1603 transport.fetch(&query, &relays).await?
1604 }
1605 };
1606 let bundle_key = super::derive::invite_bundle_key(&parsed.token);
1607
1608 let mut newest_live: Option<(u64, CommunityInvite)> = None;
1610 for event in &events {
1611 match invite::parse_bundle_event(event, &parsed.link_signer, &bundle_key) {
1612 Ok(invite::BundleState::Revoked) => return Err("this invite link has been revoked".to_string()),
1613 Ok(invite::BundleState::Live(bundle)) => {
1614 let at = event.created_at.as_secs();
1615 if newest_live.as_ref().is_none_or(|(t, _)| at > *t) {
1616 newest_live = Some((at, *bundle));
1617 }
1618 }
1619 Err(_) => {} }
1621 }
1622 newest_live.map(|(_, b)| b).ok_or_else(|| "invite bundle not found on relays".to_string())
1623}
1624
1625async fn wait_for_bootstrap_relay(relays: &[String]) {
1629 let Some(client) = crate::state::nostr_client() else { return };
1630 let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(8);
1631 loop {
1632 for url in relays {
1633 if let Ok(Some(relay)) = client.relay(url).await {
1634 if relay.status() == nostr_sdk::prelude::RelayStatus::Connected {
1635 return;
1636 }
1637 }
1638 }
1639 if tokio::time::Instant::now() >= deadline {
1640 return;
1641 }
1642 tokio::time::sleep(std::time::Duration::from_millis(400)).await;
1643 }
1644}
1645
1646struct VerifiedPreview {
1652 session: SessionGuard,
1653 at: std::time::Instant,
1654 community_id: [u8; 32],
1655 community_root: [u8; 32],
1656 folded: CommunityV2,
1657 heads: Vec<FoldedHead>,
1658 banned: std::collections::BTreeSet<String>,
1661}
1662static VERIFIED_PREVIEW: std::sync::Mutex<Option<VerifiedPreview>> = std::sync::Mutex::new(None);
1663const VERIFIED_PREVIEW_TTL: std::time::Duration = std::time::Duration::from_secs(120);
1664
1665pub async fn preview_public_link<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityV2, String> {
1672 let bundle = fetch_public_bundle(transport, url).await?;
1673 preview_bundle(transport, &bundle).await
1674}
1675
1676pub async fn preview_bundle<T: Transport + ?Sized>(transport: &T, bundle: &CommunityInvite) -> Result<CommunityV2, String> {
1681 let community = CommunityV2::from_bundle(bundle, 0)?;
1682 match verify_owner_root_and_reconcile(transport, community.clone()).await {
1683 Ok((folded, heads, banned)) => {
1684 *VERIFIED_PREVIEW.lock().unwrap() = Some(VerifiedPreview {
1685 session: SessionGuard::capture(),
1686 at: std::time::Instant::now(),
1687 community_id: folded.id().0,
1688 community_root: folded.community_root,
1689 folded: folded.clone(),
1690 heads,
1691 banned,
1692 });
1693 Ok(folded)
1694 }
1695 Err(_) => Ok(community),
1696 }
1697}
1698
1699pub async fn accept_public_link<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityV2, String> {
1701 let session = SessionGuard::capture();
1703 let bundle = fetch_public_bundle(transport, url).await?;
1704 if !session.is_valid() {
1705 return Err("account changed during join".to_string());
1706 }
1707 accept_bundle(transport, &session, &bundle, None, true).await
1708}
1709
1710pub async fn leave_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
1712 let session = SessionGuard::capture();
1713 let signer = crate::signer::active_signer()?;
1714 let my_pk = me_pk()?;
1715 let at_ms = now_ms();
1716 let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1717 let leave_rumor = guestbook::build_leave_rumor(my_pk, at_ms);
1718 if let Ok((wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &leave_rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await {
1719 let _ = transport.publish(&wrap, &community.relays).await;
1720 }
1721 if !session.is_valid() {
1722 return Err("account changed during leave".to_string());
1723 }
1724 let _ = tombstone_community_list(transport, community.id(), &community.relays).await;
1728 if !session.is_valid() {
1730 return Err("account changed during leave".to_string());
1731 }
1732 crate::db::community::delete_community(&crate::simd::hex::bytes_to_hex_32(&community.id().0))?;
1733 Ok(())
1734}
1735
1736pub async fn kick_member<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, target: &PublicKey) -> Result<(), String> {
1741 let session = SessionGuard::capture();
1742 assert_current_root(community)?;
1743 let signer = crate::signer::active_signer()?;
1744 let my_pk = me_pk()?;
1745 let authority = fetch_authority(transport, community).await;
1747 let owner_hex = community.owner()?.to_hex();
1748 if !authority.roles.can_act_on_member(
1749 &my_pk.to_hex(),
1750 Some(&owner_hex),
1751 &target.to_hex(),
1752 crate::community::roles::Permissions::KICK,
1753 ) {
1754 return Err("not authorized to kick this member".to_string());
1755 }
1756 let target_hex = target.to_hex();
1768 let holds_roles = authority.roles.grants.iter().any(|g| g.member == target_hex && !g.role_ids.is_empty());
1769 let may_strip = authority.roles.can_act_on_member(
1770 &my_pk.to_hex(),
1771 Some(&owner_hex),
1772 &target_hex,
1773 crate::community::roles::Permissions::MANAGE_ROLES,
1774 );
1775 if holds_roles && may_strip {
1776 grant_roles(transport, community, target, Vec::new())
1777 .await
1778 .map_err(|e| format!("could not strip this member's roles before kicking: {e}"))?;
1779 if !session.is_valid() {
1780 return Err("account changed during kick".to_string());
1781 }
1782 }
1783 let at_ms = now_ms();
1784 let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1785 let citation = required_authority_citation(community, &my_pk)?;
1788 let rumor = guestbook::build_kick_rumor(my_pk, *target, citation.as_ref(), at_ms);
1789 let (wrap, _) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &rumor, &gb_group, Timestamp::from_secs(at_ms / 1000)).await
1790 .map_err(|e| e.to_string())?;
1791 if !session.is_valid() {
1792 return Err("account changed before send".to_string());
1793 }
1794 transport.publish(&wrap, &community.relays).await?;
1795 Ok(())
1796}
1797
1798pub struct AuthorityView {
1804 pub roles: crate::community::roles::CommunityRoles,
1805 pub banned: std::collections::BTreeSet<String>,
1806 pub gapped: bool,
1808 pub floored: std::collections::BTreeSet<String>,
1810 pub head_entities: std::collections::BTreeSet<String>,
1812 pub banned_at: std::collections::BTreeMap<String, u64>,
1814}
1815
1816pub async fn fetch_authority<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> AuthorityView {
1822 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1823 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
1824 .unwrap_or_default()
1825 .into_iter()
1826 .filter(|(_, f)| f.0 == community.root_epoch.0)
1827 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
1828 .collect();
1829 let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
1830
1831 let mut editions: Vec<ParsedEdition> = Vec::new();
1832 let mut seen: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
1833 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
1834 let mut oldest: Option<u64> = None;
1835 let mut until: Option<u64> = None;
1836 let mut a = fold_authority(community, &[], &floors);
1841 for _ in 0..FOLLOW_MAX_PAGES {
1842 let query = Query {
1846 kinds: vec![stream::KIND_WRAP],
1847 authors: vec![control.pk_hex()],
1848 until,
1849 limit: Some(FOLLOW_PAGE),
1850 evidence: crate::community::transport::Evidence::Quorum,
1851 ..Default::default()
1852 };
1853 let Ok(wraps) = transport.fetch(&query, &community.relays).await else { break };
1854 let mut fresh = 0usize;
1855 for w in &wraps {
1856 if !seen_wraps.insert(w.id) {
1857 continue;
1858 }
1859 fresh += 1;
1860 let at = w.created_at.as_secs();
1861 if oldest.is_none_or(|o| at < o) {
1862 oldest = Some(at);
1863 }
1864 if let Ok((ed, _)) = control::open_control_edition(w, &control) {
1865 if seen.insert(ed.inner_id) {
1866 editions.push(ed);
1867 }
1868 }
1869 }
1870 a = fold_authority(community, &editions, &floors);
1871 if !a.gapped || fresh == 0 {
1872 break;
1873 }
1874 until = oldest;
1875 }
1876 AuthorityView {
1877 roles: a.roles,
1878 banned: a.banned,
1879 gapped: a.gapped,
1880 floored: floors.keys().cloned().collect(),
1881 head_entities: a.heads.iter().map(|h| h.entity_hex.clone()).collect(),
1882 banned_at: a.banned_at,
1883 }
1884}
1885
1886async fn fetch_guestbook_events<T: Transport + ?Sized>(
1898 transport: &T,
1899 community: &CommunityV2,
1900 since_secs: u64,
1901) -> Result<(Vec<guestbook::GuestbookEvent>, u64), String> {
1902 let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1903 const GB_PAGE: usize = 500;
1904 const GB_MAX_PAGES: usize = 12;
1905 let mut events = Vec::new();
1906 let mut newest: u64 = since_secs;
1907 let mut seen: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
1908 let mut until: Option<u64> = None;
1909 let mut oldest: Option<u64> = None;
1910 for _ in 0..GB_MAX_PAGES {
1911 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() };
1914 let wraps = transport.fetch(&query, &community.relays).await?;
1915 let mut fresh = 0usize;
1916 for wrap in &wraps {
1917 if !seen.insert(wrap.id) {
1918 continue;
1919 }
1920 fresh += 1;
1921 let at = wrap.created_at.as_secs();
1922 if oldest.is_none_or(|o| at < o) {
1923 oldest = Some(at);
1924 }
1925 if at > newest {
1926 newest = at;
1927 }
1928 if at < since_secs {
1930 continue;
1931 }
1932 if let Ok(opened) = stream::open_wrap(wrap, &gb_group) {
1933 if let Ok(ev) = guestbook::parse_guestbook_event(&opened) {
1934 events.push(ev);
1935 }
1936 }
1937 }
1938 if fresh == 0 || wraps.len() < GB_PAGE || oldest.is_some_and(|o| o < since_secs) {
1939 break;
1940 }
1941 match oldest {
1942 Some(o) if o > 0 => until = Some(o),
1943 _ => break,
1944 }
1945 }
1946 Ok((events, newest))
1947}
1948
1949fn fold_members(
1954 community: &CommunityV2,
1955 events: &[guestbook::GuestbookEvent],
1956 mut observed: std::collections::BTreeMap<PublicKey, u64>,
1957 roles: &crate::community::roles::CommunityRoles,
1958 banlist: &std::collections::BTreeSet<PublicKey>,
1959 banned_at: &std::collections::BTreeMap<PublicKey, u64>,
1960) -> Result<Vec<PublicKey>, String> {
1961 let owner = community.owner()?;
1962 let owner_hex = owner.to_hex();
1963 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1964
1965 for g in &roles.grants {
1972 if let Some(pk) = PublicKey::from_hex(&g.member).ok().filter(|_| !g.role_ids.is_empty()) {
1973 observed.entry(pk).or_insert(0);
1974 }
1975 }
1976
1977 let snapshot_authority = (community.root_epoch.0 > 0).then_some(&owner);
1986 let can_kick = |actor: &PublicKey, target: &PublicKey, citation: Option<&crate::community::edition::AuthorityCitation>| {
1990 let actor_hex = actor.to_hex();
1991 citation_is_synced(&cid_hex, &owner_hex, &actor_hex, citation)
1992 && roles.can_act_on_member(&actor_hex, Some(&owner_hex), &target.to_hex(), crate::community::roles::Permissions::KICK)
1993 };
1994 let coalesced = guestbook::coalesce(events, now_ms(), snapshot_authority, &can_kick);
1995 let mut members = guestbook::complete_memberlist(&coalesced, &observed, banlist, banned_at);
1996 if !banlist.contains(&owner) {
1998 members.insert(owner);
1999 }
2000 Ok(members.into_iter().collect())
2001}
2002
2003pub fn stored_kick_verdict(community: &CommunityV2, member: &PublicKey) -> bool {
2013 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2014 let Ok((events, _cursor)) = crate::db::community::get_guestbook(&cid_hex) else {
2015 return false;
2016 };
2017 let Ok(owner) = community.owner() else { return false };
2018 let owner_hex = owner.to_hex();
2019 let roles = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
2020 let snapshot_authority = (community.root_epoch.0 > 0).then_some(&owner);
2021 let can_kick = |actor: &PublicKey, target: &PublicKey, citation: Option<&crate::community::edition::AuthorityCitation>| {
2022 let actor_hex = actor.to_hex();
2023 citation_is_synced(&cid_hex, &owner_hex, &actor_hex, citation)
2024 && roles.can_act_on_member(&actor_hex, Some(&owner_hex), &target.to_hex(), crate::community::roles::Permissions::KICK)
2025 };
2026 matches!(
2027 guestbook::coalesce(&events, now_ms(), snapshot_authority, &can_kick).get(member),
2028 Some(st) if st.verdict == guestbook::Verdict::Kicked
2029 )
2030}
2031
2032pub async fn sync_guestbook<T: Transport + ?Sized>(
2037 transport: &T,
2038 community: &CommunityV2,
2039 session: &SessionGuard,
2040) -> Result<Vec<guestbook::GuestbookEvent>, String> {
2041 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2042 let (mut events, cursor) = crate::db::community::get_guestbook(&cid_hex)?;
2043 let since = cursor.saturating_sub(1);
2046 let (fresh, newest) = fetch_guestbook_events(transport, community, since).await?;
2047 if !session.is_valid() {
2048 return Err("account changed during guestbook sync".to_string());
2049 }
2050 let known: std::collections::HashSet<[u8; 32]> = events.iter().map(|e| e.rumor_id).collect();
2051 let mut added = Vec::new();
2052 for ev in fresh {
2053 if !known.contains(&ev.rumor_id) {
2054 events.push(ev.clone());
2055 added.push(ev);
2056 }
2057 }
2058 if !added.is_empty() || newest > cursor {
2059 crate::db::community::set_guestbook(&cid_hex, &events, newest.max(cursor))?;
2060 }
2061 Ok(added)
2062}
2063
2064pub fn ingest_guestbook_event(community: &CommunityV2, ev: guestbook::GuestbookEvent, wrap_secs: u64) -> Result<bool, String> {
2067 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2068 let (mut events, cursor) = crate::db::community::get_guestbook(&cid_hex)?;
2069 if events.iter().any(|e| e.rumor_id == ev.rumor_id) {
2070 return Ok(false);
2071 }
2072 events.push(ev);
2073 crate::db::community::set_guestbook(&cid_hex, &events, cursor.max(wrap_secs))?;
2074 Ok(true)
2075}
2076
2077pub fn stored_memberlist(community: &CommunityV2) -> Result<Vec<PublicKey>, String> {
2084 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2085 let (events, _cursor) = crate::db::community::get_guestbook(&cid_hex)?;
2086 let mut observed: std::collections::BTreeMap<PublicKey, u64> = std::collections::BTreeMap::new();
2087 for (npub, last_active_secs) in crate::db::community::community_member_activity(&cid_hex).unwrap_or_default() {
2088 if let Ok(pk) = PublicKey::parse(&npub) {
2089 observed.insert(pk, last_active_secs.saturating_mul(1000));
2090 }
2091 }
2092 let roles = crate::db::community::get_community_roles(&cid_hex)?;
2093 let banlist: std::collections::BTreeSet<PublicKey> = crate::db::community::get_community_banlist(&cid_hex)
2094 .unwrap_or_default()
2095 .iter()
2096 .filter_map(|h| PublicKey::from_hex(h).ok())
2097 .collect();
2098 let banned_at: std::collections::BTreeMap<PublicKey, u64> = crate::db::community::get_community_ban_marks(&cid_hex)
2101 .unwrap_or_default()
2102 .into_iter()
2103 .filter_map(|(h, at)| PublicKey::from_hex(&h).ok().map(|pk| (pk, at)))
2104 .collect();
2105 fold_members(community, &events, observed, &roles, &banlist, &banned_at)
2106}
2107
2108pub async fn memberlist<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<Vec<PublicKey>, String> {
2114 let (events, _newest) = fetch_guestbook_events(transport, community, 0).await?;
2115 let mut observed: std::collections::BTreeMap<PublicKey, u64> = std::collections::BTreeMap::new();
2118 for ch in &community.channels {
2119 if let Ok(page) = fetch_channel(transport, community, &ch.id, 200).await {
2120 for f in &page {
2121 let e = observed.entry(f.event.opened().author).or_insert(0);
2122 *e = (*e).max(f.event.opened().at_ms);
2123 }
2124 }
2125 }
2126
2127 let authority = fetch_authority(transport, community).await;
2132 let banlist: std::collections::BTreeSet<PublicKey> =
2134 authority.banned.iter().filter_map(|h| PublicKey::from_hex(h).ok()).collect();
2135 let mut banned_at: std::collections::BTreeMap<PublicKey, u64> = crate::db::community::get_community_ban_marks(
2139 &crate::simd::hex::bytes_to_hex_32(&community.id().0),
2140 )
2141 .unwrap_or_default()
2142 .into_iter()
2143 .filter_map(|(h, at)| PublicKey::from_hex(&h).ok().map(|pk| (pk, at)))
2144 .collect();
2145 for (h, at) in &authority.banned_at {
2146 if let Ok(pk) = PublicKey::from_hex(h) {
2147 let slot = banned_at.entry(pk).or_insert(0);
2148 *slot = (*slot).max(*at);
2149 }
2150 }
2151 fold_members(community, &events, observed, &authority.roles, &banlist, &banned_at)
2152}
2153
2154pub async fn dissolve_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
2162 let session = SessionGuard::capture();
2163 let signer = crate::signer::active_signer()?;
2164 let my_pk = me_pk()?;
2165 if community.owner()? != my_pk {
2166 return Err("only the owner can dissolve a community".to_string());
2167 }
2168 let at = now_ms() / 1000;
2169 let rumor = super::dissolution::dissolved_tombstone_rumor(my_pk, community.id(), at);
2170 let wrap = super::dissolution::seal_dissolved_signed(&signer, my_pk, &rumor, community.id(), Timestamp::from_secs(at)).await.map_err(|e| e.to_string())?;
2171 if !session.is_valid() {
2172 return Err("account changed during dissolve".to_string());
2173 }
2174 transport.publish_durable(&wrap, &community.relays).await?;
2176 crate::db::community::set_community_dissolved(&crate::simd::hex::bytes_to_hex_32(&community.id().0))?;
2177 Ok(())
2178}
2179
2180pub async fn is_dissolved<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> bool {
2185 let group = super::derive::dissolved_group_key(community.id());
2186 let query = Query {
2187 kinds: vec![stream::KIND_WRAP],
2188 authors: vec![group.pk_hex()],
2189 limit: Some(20),
2190 ..Default::default()
2191 };
2192 let Ok(wraps) = transport.fetch(&query, &community.relays).await else {
2193 return false;
2194 };
2195 wraps.iter().any(|w| super::dissolution::verify_dissolved(w, &community.identity))
2196}
2197
2198pub async fn refound_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, removed: &[PublicKey]) -> Result<CommunityV2, String> {
2211 let session = SessionGuard::capture();
2212 let cid = community.id();
2213 let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
2214 if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
2216 return Err("this community has been dissolved; it cannot be re-founded".to_string());
2217 }
2218 let signer = crate::signer::active_signer()?;
2219 let my_pk = me_pk()?;
2220 let lock = super::realtime::follow_lock(cid);
2224 let _guard = lock.lock().await;
2225 let fresh = crate::db::community::load_community_v2(cid)?.ok_or("community gone before re-founding")?;
2229 let community = &fresh;
2230 let owner = community.owner()?;
2231
2232 {
2239 let owner_hex = owner.to_hex();
2240 let me_hex = my_pk.to_hex();
2241 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
2247 let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
2248 let authorized = my_pk == owner
2249 || (!banned.contains(&me_hex)
2250 && roster.is_authorized(&me_hex, Some(&owner_hex), crate::community::roles::Permissions::BAN)
2251 && removed.iter().all(|t| {
2252 roster.can_act_on_member(&me_hex, Some(&owner_hex), &t.to_hex(), crate::community::roles::Permissions::BAN)
2253 }));
2254 if !authorized {
2255 return Err("re-founding requires the BAN permission and outranking every removed member".to_string());
2256 }
2257 }
2258
2259 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
2263 .into_iter()
2264 .filter(|(_, f)| f.0 == community.root_epoch.0)
2265 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
2266 .collect();
2267 let current_control = control_group_key(&community.community_root, cid, community.root_epoch);
2268 let mut opened: Vec<(ParsedEdition, super::stream::OpenedStream)> = Vec::new();
2275 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
2276 let mut oldest: Option<u64> = None;
2277 let mut until: Option<u64> = None;
2278 let mut truncated = false;
2282 for page in 0..COMPACT_MAX_PAGES {
2283 let query = Query {
2287 kinds: vec![stream::KIND_WRAP],
2288 authors: vec![current_control.pk_hex()],
2289 until,
2290 limit: Some(FOLLOW_PAGE),
2291 evidence: crate::community::transport::Evidence::Full,
2292 ..Default::default()
2293 };
2294 let wraps = transport.fetch(&query, &community.relays).await?;
2295 let mut fresh = 0usize;
2296 for w in &wraps {
2297 if !seen_wraps.insert(w.id) {
2298 continue;
2299 }
2300 fresh += 1;
2301 let at = w.created_at.as_secs();
2302 if oldest.is_none_or(|o| at < o) {
2303 oldest = Some(at);
2304 }
2305 if let Ok(parsed) = control::open_control_edition(w, ¤t_control) {
2306 opened.push(parsed);
2307 }
2308 }
2309 if fresh == 0 {
2310 truncated = wraps.len() >= FOLLOW_PAGE;
2314 break;
2315 }
2316 until = oldest;
2317 if page + 1 == COMPACT_MAX_PAGES {
2318 truncated = true;
2319 }
2320 }
2321 if truncated {
2322 return Err(
2323 "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(),
2324 );
2325 }
2326
2327 let prev_epoch = community.root_epoch;
2328 let new_epoch = Epoch(prev_epoch.0.checked_add(1).ok_or("root epoch overflow")?);
2329 let prev_commit = super::derive::epoch_key_commitment(prev_epoch, &community.community_root);
2330 if !session.is_valid() {
2336 return Err("account changed during re-founding compaction".to_string());
2337 }
2338 let new_root = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch.0)?;
2339 let new_control = control_group_key(&new_root, cid, new_epoch);
2340 let at = now_ms();
2341 let at_secs = at / 1000;
2342
2343 use std::collections::BTreeMap;
2349 let mut by_eid: BTreeMap<String, Vec<usize>> = BTreeMap::new();
2350 for (i, (e, _)) in opened.iter().enumerate() {
2351 by_eid.entry(crate::simd::hex::bytes_to_hex_32(&e.entity_id)).or_default().push(i);
2352 }
2353 let mut carried: Vec<(FoldedHead, Event)> = Vec::new();
2354 for (floor_key, floor) in &floors {
2355 let head_idx = by_eid
2363 .get(floor_key)
2364 .and_then(|v| v.iter().copied().find(|&i| opened[i].0.self_hash == floor.1));
2365 let Some(head_idx) = head_idx else {
2366 return Err(format!("re-founding aborted: the committed head of control entity {floor_key} (v{}) was not served; no state published", floor.0));
2367 };
2368 let (head_ed, head_os) = &opened[head_idx];
2369 let h = FoldedHead { entity_hex: floor_key.clone(), version: head_ed.version, self_hash: head_ed.self_hash, inner_id: head_ed.inner_id };
2370 let (rewrapped, _) = super::stream::rewrap_seal(&head_os.seal, &new_control, Timestamp::from_secs(at_secs)).map_err(|e| e.to_string())?;
2371 carried.push((h, rewrapped));
2372 }
2373 if !session.is_valid() {
2374 return Err("account changed during re-founding acquire".to_string());
2375 }
2376
2377 let members = memberlist(transport, community).await?;
2379 let removed_set: std::collections::HashSet<[u8; 32]> = removed.iter().map(|p| p.to_bytes()).collect();
2380 let mut recipients: Vec<PublicKey> = members.into_iter().filter(|m| !removed_set.contains(&m.to_bytes())).collect();
2381 if !recipients.iter().any(|p| *p == my_pk) {
2382 recipients.push(my_pk);
2383 }
2384
2385 let mut base_blobs = Vec::new();
2387 for r in &recipients {
2388 base_blobs.push(
2389 super::rekey::build_blob(&signer, &my_pk.to_bytes(), r, super::rekey::RekeyScope::Root, new_epoch, &new_root)
2390 .await
2391 .map_err(|e| e.to_string())?,
2392 );
2393 }
2394 let base_group = super::derive::base_rekey_group_key(&community.community_root, cid, new_epoch);
2395 let base_chunks =
2396 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())
2397 .await
2398 .map_err(|e| e.to_string())?;
2399
2400 let mut roster_for_channels = fetch_authority(transport, community).await.roles;
2412 {
2413 let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
2414 for r in cached.roles {
2415 if !roster_for_channels.roles.iter().any(|x| x.role_id == r.role_id) {
2416 roster_for_channels.roles.push(r);
2417 }
2418 }
2419 for g in cached.grants {
2420 if !roster_for_channels.grants.iter().any(|x| x.member == g.member) {
2421 roster_for_channels.grants.push(g);
2422 }
2423 }
2424 }
2425 if !session.is_valid() {
2426 return Err("account changed during re-founding entitlement fetch".to_string());
2427 }
2428 let owner_hex_for_channels = community.owner().ok().map(|o| o.to_hex());
2429 let mut channel_updates: Vec<(ChannelId, [u8; 32], Epoch)> = Vec::new();
2430 let mut channel_chunk_sets: Vec<Vec<Event>> = Vec::new();
2431 for ch in &community.channels {
2432 let (Some(old_key), true) = (ch.key, ch.private) else { continue };
2433 let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
2434 let entitled: Vec<PublicKey> = recipients
2435 .iter()
2436 .copied()
2437 .filter(|r| {
2438 *r == my_pk
2439 || roster_for_channels.is_entitled(owner_hex_for_channels.as_deref(), &r.to_hex(), &ch_hex, &[], &[])
2440 })
2441 .collect();
2442 let ch_new_epoch = Epoch(ch.epoch.0.checked_add(1).ok_or("channel epoch overflow")?);
2443 if !session.is_valid() {
2447 return Err("account changed during re-founding channel prepare".to_string());
2448 }
2449 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)?;
2450 let ch_prev_commit = super::derive::epoch_key_commitment(ch.epoch, &old_key);
2451 let mut ch_blobs = Vec::new();
2452 for r in &entitled {
2453 ch_blobs.push(
2454 super::rekey::build_blob(&signer, &my_pk.to_bytes(), r, super::rekey::RekeyScope::Channel(ch.id), ch_new_epoch, &ch_new_key)
2455 .await
2456 .map_err(|e| e.to_string())?,
2457 );
2458 }
2459 let ch_group = super::derive::channel_rekey_group_key(&community.community_root, &ch.id, ch_new_epoch);
2460 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())
2461 .await
2462 .map_err(|e| e.to_string())?;
2463 channel_updates.push((ch.id, ch_new_key, ch_new_epoch));
2464 channel_chunk_sets.push(ch_chunks);
2465 }
2466 if !session.is_valid() {
2467 return Err("account changed during re-founding prepare".to_string());
2468 }
2469
2470 for c in &base_chunks {
2473 transport.publish_durable(c, &community.relays).await?;
2474 }
2475 for set in &channel_chunk_sets {
2476 for c in set {
2477 transport.publish_durable(c, &community.relays).await?;
2478 }
2479 }
2480 for (_, wrap) in &carried {
2481 transport.publish_durable(wrap, &community.relays).await?;
2482 }
2483 let gb_group = super::derive::guestbook_group_key(&new_root, cid, new_epoch);
2486 let snap_id = crate::community::random_32();
2487 for rumor in guestbook::build_snapshot_rumors(my_pk, &recipients, snap_id, at) {
2488 if let Ok((wrap, _)) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &rumor, &gb_group, Timestamp::from_secs(at_secs)).await {
2489 let _ = transport.publish(&wrap, &community.relays).await;
2490 }
2491 }
2492
2493 if !session.is_valid() {
2495 return Err("account changed during re-founding commit".to_string());
2496 }
2497 if crate::db::community::community_protocol(cid)?.is_none() {
2498 return Ok(community.clone()); }
2500 let mut updated = community.clone();
2504 updated.community_root = new_root;
2505 updated.root_epoch = new_epoch;
2506 for (id, key, ep) in &channel_updates {
2507 if let Some(c) = updated.channels.iter_mut().find(|c| c.id.0 == id.0) {
2508 c.key = Some(*key);
2509 c.epoch = *ep;
2510 }
2511 }
2512 crate::db::community::save_community_v2(&updated)?;
2513 crate::db::community::advance_server_root_epoch(&cid_hex, new_epoch.0, &new_root)?;
2518 for (h, _) in &carried {
2519 crate::db::community::set_edition_head_at_epoch(&cid_hex, &h.entity_hex, h.version, &h.self_hash, &h.inner_id, new_epoch.0)?;
2520 }
2521 if let Some(client) = crate::state::nostr_client() {
2527 super::realtime::refresh_subscription(&client).await;
2528 }
2529 for attempt in 0..3u8 {
2535 match refresh_public_links(transport, &updated).await {
2536 Ok(()) => break,
2537 Err(_) if !session.is_valid() => break, Err(e) if attempt == 2 => {
2539 crate::log_warn!("v2: post-refounding public-link refresh failed after retries ({e}); live links may serve the prior root until the next refresh");
2540 }
2541 Err(_) => continue,
2542 }
2543 }
2544 Ok(updated)
2545}
2546
2547pub async fn refound_at_birth<T: Transport + ?Sized>(
2563 transport: &T,
2564 community: &CommunityV2,
2565 snapshot_members: &[PublicKey],
2566) -> Result<CommunityV2, String> {
2567 let session = SessionGuard::capture();
2568 let cid = community.id();
2569 let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
2570 if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
2573 return Err("this community has been dissolved; it cannot be birth-refounded".to_string());
2574 }
2575 let signer = crate::signer::active_signer()?;
2576 let my_pk = me_pk()?;
2577 if my_pk != community.owner()? {
2578 return Err("only the owner can birth-refound the migration twin".to_string());
2579 }
2580 let lock = super::realtime::follow_lock(cid);
2581 let _guard = lock.lock().await;
2582 let community = crate::db::community::load_community_v2(cid)?.ok_or("twin gone before birth refound")?;
2583 if community.root_epoch.0 == 1 {
2589 return Ok(community);
2590 }
2591 if community.root_epoch.0 != 0 {
2592 return Err("birth refound only rolls a genesis (epoch 0) twin".to_string());
2593 }
2594 let community = &community;
2595
2596 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
2600 .into_iter()
2601 .filter(|(_, f)| f.0 == community.root_epoch.0)
2602 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
2603 .collect();
2604 let current_control = control_group_key(&community.community_root, cid, community.root_epoch);
2605 let mut opened: Vec<(ParsedEdition, super::stream::OpenedStream)> = Vec::new();
2606 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
2607 let mut oldest: Option<u64> = None;
2608 let mut until: Option<u64> = None;
2609 let mut truncated = false;
2611 for page in 0..COMPACT_MAX_PAGES {
2612 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() };
2613 let wraps = transport.fetch(&query, &community.relays).await?;
2614 let mut fresh = 0usize;
2615 for w in &wraps {
2616 if !seen_wraps.insert(w.id) { continue; }
2617 fresh += 1;
2618 let at = w.created_at.as_secs();
2619 if oldest.is_none_or(|o| at < o) { oldest = Some(at); }
2620 if let Ok(parsed) = control::open_control_edition(w, ¤t_control) {
2621 opened.push(parsed);
2622 }
2623 }
2624 if fresh == 0 {
2625 truncated = wraps.len() >= FOLLOW_PAGE;
2626 break;
2627 }
2628 until = oldest;
2629 if page + 1 == COMPACT_MAX_PAGES { truncated = true; }
2630 }
2631 if truncated {
2632 return Err(
2633 "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(),
2634 );
2635 }
2636
2637 let prev_epoch = community.root_epoch; let new_epoch = Epoch(1);
2639 let prev_commit = super::derive::epoch_key_commitment(prev_epoch, &community.community_root);
2640 if !session.is_valid() {
2641 return Err("account changed during birth-refound compaction".to_string());
2642 }
2643 let new_root = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch.0)?;
2644 let new_control = control_group_key(&new_root, cid, new_epoch);
2645 let at = now_ms();
2646 let at_secs = at / 1000;
2647
2648 use std::collections::BTreeMap;
2649 let mut by_eid: BTreeMap<String, Vec<usize>> = BTreeMap::new();
2650 for (i, (e, _)) in opened.iter().enumerate() {
2651 by_eid.entry(crate::simd::hex::bytes_to_hex_32(&e.entity_id)).or_default().push(i);
2652 }
2653 let mut carried: Vec<(FoldedHead, Event)> = Vec::new();
2654 for (floor_key, floor) in &floors {
2655 let head_idx = by_eid.get(floor_key).and_then(|v| v.iter().copied().find(|&i| opened[i].0.self_hash == floor.1));
2656 let Some(head_idx) = head_idx else {
2657 return Err(format!("birth refound aborted: committed head of entity {floor_key} (v{}) not served; no state published", floor.0));
2658 };
2659 let (head_ed, head_os) = &opened[head_idx];
2660 let h = FoldedHead { entity_hex: floor_key.clone(), version: head_ed.version, self_hash: head_ed.self_hash, inner_id: head_ed.inner_id };
2661 let (rewrapped, _) = super::stream::rewrap_seal(&head_os.seal, &new_control, Timestamp::from_secs(at_secs)).map_err(|e| e.to_string())?;
2662 carried.push((h, rewrapped));
2663 }
2664 if !session.is_valid() {
2665 return Err("account changed during birth-refound acquire".to_string());
2666 }
2667
2668 let base_blobs = vec![
2670 super::rekey::build_blob(&signer, &my_pk.to_bytes(), &my_pk, super::rekey::RekeyScope::Root, new_epoch, &new_root)
2671 .await
2672 .map_err(|e| e.to_string())?,
2673 ];
2674 let base_group = super::derive::base_rekey_group_key(&community.community_root, cid, new_epoch);
2675 let base_chunks =
2676 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())
2677 .await
2678 .map_err(|e| e.to_string())?;
2679 if !session.is_valid() {
2680 return Err("account changed during birth-refound prepare".to_string());
2681 }
2682
2683 for c in &base_chunks {
2685 transport.publish_durable(c, &community.relays).await?;
2686 }
2687 for (_, wrap) in &carried {
2688 transport.publish_durable(wrap, &community.relays).await?;
2689 }
2690 let gb_group = super::derive::guestbook_group_key(&new_root, cid, new_epoch);
2698 let snap_id = crate::community::random_32();
2699 let snapshot_wraps: Vec<Event> = {
2700 let mut out = Vec::new();
2701 for rumor in guestbook::build_snapshot_rumors(my_pk, snapshot_members, snap_id, at) {
2702 let (wrap, _) = guestbook::seal_guestbook_rumor_signed(&signer, my_pk, &rumor, &gb_group, Timestamp::from_secs(at_secs))
2703 .await
2704 .map_err(|e| format!("seal birth snapshot: {e}"))?;
2705 out.push(wrap);
2706 }
2707 out
2708 };
2709 for wrap in &snapshot_wraps {
2710 transport.publish_durable(wrap, &community.relays).await?;
2711 }
2712 let verify_view = {
2721 let mut v = community.clone();
2722 v.community_root = new_root;
2723 v.root_epoch = new_epoch;
2724 v
2725 };
2726 let expected: Vec<PublicKey> = {
2727 let banlist = fetch_authority(transport, &verify_view).await.banned;
2728 snapshot_members.iter().copied()
2729 .filter(|m| *m != my_pk && !banlist.contains(&m.to_hex()))
2730 .collect()
2731 };
2732 if !expected.is_empty() {
2733 let folded = memberlist(transport, &verify_view).await.unwrap_or_default();
2734 let missing = expected.iter().filter(|m| !folded.contains(m)).count();
2735 if missing > 0 {
2736 return Err(format!("birth snapshot verify-back: {missing} seeded member(s) not readable from relays; not committing"));
2737 }
2738 }
2739
2740 if !session.is_valid() {
2742 return Err("account changed during birth-refound commit".to_string());
2743 }
2744 if crate::db::community::community_protocol(cid)?.is_none() {
2745 return Ok(community.clone());
2746 }
2747 let mut updated = community.clone();
2748 updated.community_root = new_root;
2749 updated.root_epoch = new_epoch;
2750 crate::db::community::save_community_v2(&updated)?;
2751 crate::db::community::advance_server_root_epoch(&cid_hex, new_epoch.0, &new_root)?;
2752 for (h, _) in &carried {
2753 crate::db::community::set_edition_head_at_epoch(&cid_hex, &h.entity_hex, h.version, &h.self_hash, &h.inner_id, new_epoch.0)?;
2754 }
2755 Ok(updated)
2756}
2757
2758fn mint_or_reuse_rotation_key(community_id_hex: &str, scope_hex: &str, new_epoch: u64) -> Result<[u8; 32], String> {
2765 if let Some(existing) = crate::db::community::held_epoch_key(community_id_hex, scope_hex, new_epoch)? {
2766 return Ok(existing);
2767 }
2768 let fresh = crate::community::random_32();
2769 crate::db::community::store_epoch_key(community_id_hex, scope_hex, new_epoch, &fresh)?;
2770 Ok(fresh)
2771}
2772
2773fn join_material(community: &CommunityV2) -> super::list::JoinMaterial {
2779 let hex = crate::simd::hex::bytes_to_hex_32;
2780 let channels = community
2781 .channels
2782 .iter()
2783 .filter(|c| c.private)
2784 .filter_map(|c| {
2788 c.key.map(|k| super::list::ChannelKeyRef { id: hex(&c.id.0), key: Some(hex(&k)), epoch: c.epoch.0, name: c.name.clone() })
2789 })
2790 .collect();
2791 super::list::JoinMaterial {
2792 community_id: hex(&community.identity.community_id.0),
2793 owner: hex(&community.identity.owner_xonly),
2794 owner_salt: hex(&community.identity.owner_salt),
2795 community_root: hex(&community.community_root),
2796 root_epoch: community.root_epoch.0,
2797 channels,
2798 relays: community.relays.clone(),
2799 name: community.name.clone(),
2800 extra: Default::default(),
2801 }
2802}
2803
2804fn material_to_invite(jm: &super::list::JoinMaterial) -> CommunityInvite {
2808 let channels = jm
2811 .channels
2812 .iter()
2813 .filter_map(|c| {
2814 c.key.as_ref().map(|k| invite::ChannelGrant { id: c.id.clone(), key: k.clone(), epoch: c.epoch, name: c.name.clone() })
2815 })
2816 .collect();
2817 CommunityInvite {
2818 community_id: jm.community_id.clone(),
2819 owner: jm.owner.clone(),
2820 owner_salt: jm.owner_salt.clone(),
2821 community_root: jm.community_root.clone(),
2822 root_epoch: jm.root_epoch,
2823 channels,
2824 relays: jm.relays.clone(),
2825 name: jm.name.clone(),
2826 icon: None,
2827 expires_at: None,
2828 creator_npub: None,
2829 label: None,
2830 extra: Default::default(),
2831 }
2832}
2833
2834fn held_v2_relays() -> Vec<String> {
2837 let mut set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
2838 if let Ok(ids) = crate::db::community::list_community_ids() {
2839 for id in ids {
2840 if matches!(crate::db::community::community_protocol(&id), Ok(Some(crate::community::ConcordProtocol::V2))) {
2841 if let Ok(Some(c)) = crate::db::community::load_community_v2(&id) {
2842 set.extend(c.relays);
2843 }
2844 }
2845 }
2846 }
2847 set.into_iter().collect()
2848}
2849
2850async fn fetch_community_list<T: Transport + ?Sized>(transport: &T, relays: &[String]) -> Result<Option<super::list::CommunityList>, String> {
2856 let signer = crate::signer::active_signer()?;
2857 let my_pk = me_pk()?;
2858 let query = Query {
2859 kinds: vec![super::kind::COMMUNITY_LIST],
2860 authors: vec![my_pk.to_hex()],
2861 limit: Some(4),
2862 ..Default::default()
2863 };
2864 let events = transport.fetch(&query, relays).await?;
2865 let seen = events.len();
2866 let mut undecryptable = 0usize;
2870 let mut unreadable: Option<(u64, String, usize, String)> = None;
2871 let mut best: Option<(u64, String, super::list::CommunityList)> = None;
2872 for e in events {
2873 let at = e.created_at.as_secs();
2874 let id_hex = e.id.to_hex();
2875 let content_len = e.content.len();
2876 match super::list::parse_list_event_signed(&signer, my_pk, &e).await {
2877 Ok(l) => {
2878 if best.as_ref().map(|(b, _, _)| at > *b).unwrap_or(true) {
2879 best = Some((at, id_hex, l));
2880 }
2881 }
2882 Err(err) => {
2883 undecryptable += 1;
2884 if unreadable.as_ref().map(|(a, _, _, _)| at > *a).unwrap_or(true) {
2885 unreadable = Some((at, id_hex, content_len, err.to_string()));
2886 }
2887 }
2888 }
2889 }
2890 if let Some((at, id, len, err)) = &unreadable {
2894 if best.as_ref().map(|(b, _, _)| at > b).unwrap_or(true) {
2895 crate::log_net_fail!(
2896 "[CommunityList] IGNORED a newer copy {} created_at={at} ({len} content bytes) — falling back to stale membership: {err}",
2897 &id[..8]
2898 );
2899 }
2900 }
2901 if let Some((at, id, l)) = &best {
2902 let bytes = serde_json::to_string(l).map(|s| s.len()).unwrap_or(0);
2903 crate::log_debug!(
2904 "[CommunityList] using {} created_at={at} ({bytes}/{} bytes) of {seen} copies, {undecryptable} unreadable",
2905 &id[..8],
2906 super::stream::NIP44_MAX_PLAINTEXT
2907 );
2908 }
2909 Ok(best.map(|(_, _, l)| l))
2910}
2911
2912pub async fn republish_community_list<T: Transport + ?Sized>(transport: &T, just_joined: Option<&crate::community::CommunityId>) -> Result<bool, String> {
2924 let session = SessionGuard::capture();
2925 let signer = crate::signer::active_signer()?;
2926 let my_pk = me_pk()?;
2927 let relays = held_v2_relays();
2928 if relays.is_empty() {
2929 return Ok(false); }
2931 let remote = match fetch_community_list(transport, &relays).await {
2935 Ok(r) => r.unwrap_or_default(),
2936 Err(e) => {
2937 crate::log_warn!(
2943 "[CommunityList] republish SKIPPED (remote fetch failed: {}){}",
2944 e,
2945 just_joined
2946 .map(|c| format!(" — the join of {} is NOT recorded across devices", &crate::simd::hex::bytes_to_hex_32(&c.0)[..8]))
2947 .unwrap_or_default()
2948 );
2949 return Ok(false);
2950 }
2951 };
2952 let just_joined_hex = just_joined.map(|c| crate::simd::hex::bytes_to_hex_32(&c.0));
2953 let now = now_ms();
2954 let mut local = super::list::CommunityList::default();
2955 for id in crate::db::community::list_community_ids()? {
2956 if !matches!(crate::db::community::community_protocol(&id), Ok(Some(crate::community::ConcordProtocol::V2))) {
2957 continue;
2958 }
2959 let Some(c) = crate::db::community::load_community_v2(&id)? else { continue };
2960 let cid_hex = crate::simd::hex::bytes_to_hex_32(&c.id().0);
2961 let is_join = just_joined_hex.as_deref() == Some(cid_hex.as_str());
2962 let tombstoned_at = remote
2974 .tombstones
2975 .iter()
2976 .find(|t| t.community_id == cid_hex)
2977 .map(|t| t.removed_at)
2978 .unwrap_or(0);
2979 let held_since = c.created_at_ms;
2980 if !is_join && !remote.is_live(&cid_hex) && tombstoned_at > 0 && held_since <= tombstoned_at {
2981 crate::log_warn!(
2982 "[CommunityList] holding {} but NOT recording it: a tombstone at {} post-dates our hold ({}) — treated as a leave from another device",
2983 &cid_hex[..8], tombstoned_at, held_since
2984 );
2985 continue;
2986 }
2987 let added_at = if remote.is_live(&cid_hex) && !is_join {
2992 remote.entries.iter().find(|e| e.community_id == cid_hex).map(|e| e.added_at).unwrap_or(now)
2993 } else if !is_join && tombstoned_at > 0 {
2994 held_since
2995 } else {
2996 now
2997 };
2998 let jm = join_material(&c);
2999 local.entries.push(super::list::CommunityListEntry { community_id: cid_hex, seed: jm.clone(), current: jm, added_at, extra: Default::default() });
3000 }
3001 let merged = remote.merge(&local);
3002 merged.assert_fits().map_err(|e| e.to_string())?;
3003 let event = super::list::build_list_event_signed(&signer, my_pk, &merged).await.map_err(|e| e.to_string())?;
3004 if !session.is_valid() {
3005 return Err("account changed during community-list publish".to_string());
3006 }
3007 if let Err(e) = transport.publish(&event, &relays).await {
3008 crate::log_warn!("[CommunityList] publish FAILED ({}) — memberships stay local-only until the next edit", e);
3009 return Err(e);
3010 }
3011 Ok(true)
3012}
3013
3014const LIST_REPUBLISH_BACKOFF_SECS: [u64; 6] = [2, 5, 15, 45, 120, 300];
3019
3020pub fn republish_community_list_durable(just_joined: Option<crate::community::CommunityId>) {
3033 if crate::state::nostr_client().is_none() {
3034 return;
3035 }
3036 let session = SessionGuard::capture();
3037 tokio::spawn(async move {
3038 for (attempt, wait) in LIST_REPUBLISH_BACKOFF_SECS.iter().enumerate() {
3039 if !session.is_valid() {
3040 return;
3041 }
3042 let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
3043 match republish_community_list(&transport, just_joined.as_ref()).await {
3044 Ok(true) => {
3045 if attempt > 0 {
3046 crate::log_info!("[CommunityList] membership recorded on retry #{}", attempt);
3047 }
3048 return;
3049 }
3050 Ok(false) => {} Err(e) => crate::log_warn!("[CommunityList] republish attempt #{} failed: {}", attempt, e),
3052 }
3053 tokio::time::sleep(std::time::Duration::from_secs(*wait)).await;
3054 }
3055 crate::log_warn!(
3056 "[CommunityList] gave up recording membership after {} attempts — it will re-record on the next join/leave",
3057 LIST_REPUBLISH_BACKOFF_SECS.len()
3058 );
3059 });
3060}
3061
3062async fn tombstone_community_list<T: Transport + ?Sized>(transport: &T, community_id: &crate::community::CommunityId, relays: &[String]) -> Result<(), String> {
3065 let signer = crate::signer::active_signer()?;
3066 let my_pk = me_pk()?;
3067 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community_id.0);
3068 let mut doc = match fetch_community_list(transport, relays).await {
3072 Ok(d) => d.unwrap_or_default(),
3073 Err(e) => return Err(e),
3074 };
3075 let now = now_ms();
3076 doc.tombstones.retain(|t| t.community_id != cid_hex);
3077 doc.tombstones.push(super::list::Tombstone { community_id: cid_hex, removed_at: now, extra: Default::default() });
3078 doc.assert_fits().map_err(|e| e.to_string())?;
3079 let event = super::list::build_list_event_signed(&signer, my_pk, &doc).await.map_err(|e| e.to_string())?;
3080 transport.publish(&event, relays).await
3081}
3082
3083pub struct ListSyncOutcome {
3090 pub joined: Vec<CommunityV2>,
3092 pub removed: Vec<(String, Vec<String>)>,
3099}
3100
3101pub async fn sync_community_list<T: Transport + ?Sized>(transport: &T, bootstrap_relays: &[String]) -> Result<ListSyncOutcome, String> {
3102 let session = SessionGuard::capture();
3103 let mut relays = held_v2_relays();
3104 relays.extend(bootstrap_relays.iter().cloned());
3105 relays.sort();
3106 relays.dedup();
3107 if relays.is_empty() {
3108 return Ok(ListSyncOutcome { joined: vec![], removed: vec![] });
3109 }
3110 let list = match fetch_community_list(transport, &relays).await {
3114 Ok(Some(l)) => {
3115 crate::log_debug!(
3116 "[CommunityList] fetched: {} entries, {} tombstones, across {} relays",
3117 l.entries.len(),
3118 l.tombstones.len(),
3119 relays.len()
3120 );
3121 l
3122 }
3123 Ok(None) => {
3124 crate::log_debug!("[CommunityList] no kind-13302 across {} relays", relays.len());
3128 return Ok(ListSyncOutcome { joined: vec![], removed: vec![] });
3129 }
3130 Err(e) => {
3131 crate::log_net_fail!("[CommunityList] fetch failed across {} relays: {e}", relays.len());
3132 return Ok(ListSyncOutcome { joined: vec![], removed: vec![] });
3133 }
3134 };
3135 let mut removed: Vec<(String, Vec<String>)> = Vec::new();
3141 for t in &list.tombstones {
3142 if list.is_live(&t.community_id) {
3143 continue;
3144 }
3145 let Some(cid) = crate::simd::hex::hex_to_bytes_32_checked(&t.community_id) else { continue };
3146 let id = crate::community::CommunityId(cid);
3147 let Some(held) = crate::db::community::load_community_v2(&id).ok().flatten() else {
3148 continue; };
3150 if held.created_at_ms > t.removed_at {
3156 crate::log_warn!(
3157 "[CommunityList] {} is tombstoned at {} but our hold ({}) post-dates it — treating as a rejoin, not tearing down",
3158 &t.community_id[..8], t.removed_at, held.created_at_ms
3159 );
3160 continue;
3161 }
3162 if !session.is_valid() {
3163 return Err("account changed during community-list sync".to_string());
3164 }
3165 let channel_ids: Vec<String> = held.channels.iter().map(|c| crate::simd::hex::bytes_to_hex_32(&c.id.0)).collect();
3166 let _ = crate::db::community::delete_community(&t.community_id);
3167 removed.push((t.community_id.clone(), channel_ids));
3168 }
3169 let mut joined = Vec::new();
3170 for entry in list.live_entries() {
3171 let Some(cid) = crate::simd::hex::hex_to_bytes_32_checked(&entry.community_id) else { continue };
3172 if crate::db::community::community_exists(&crate::community::CommunityId(cid)).unwrap_or(false) {
3177 continue; }
3179 if !session.is_valid() {
3180 return Err("account changed during community-list sync".to_string());
3181 }
3182 let bundle = material_to_invite(&entry.current);
3187 match accept_bundle(transport, &session, &bundle, None, false).await {
3188 Ok(community) => joined.push(community),
3189 Err(e) => crate::log_net_fail!(
3193 "[CommunityList] {} is listed but adoption failed: {e}",
3194 &entry.community_id[..entry.community_id.len().min(8)]
3195 ),
3196 }
3197 }
3198 Ok(ListSyncOutcome { joined, removed })
3199}
3200
3201pub(super) fn citation_is_synced(
3231 cid_hex: &str,
3232 owner_hex: &str,
3233 actor_hex: &str,
3234 citation: Option<&crate::community::edition::AuthorityCitation>,
3235) -> bool {
3236 if owner_hex == actor_hex {
3237 return true;
3238 }
3239 if citation.is_none() {
3240 return false;
3241 }
3242 let cid_bytes = crate::simd::hex::hex_to_bytes_32(cid_hex);
3243 let actor_bytes = crate::simd::hex::hex_to_bytes_32(actor_hex);
3244 let grant_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(
3245 &crate::community::CommunityId(cid_bytes),
3246 &actor_bytes,
3247 ));
3248 let head: Vec<crate::community::roster::EntityHead> =
3249 crate::db::community::get_edition_head(cid_hex, &grant_hex)
3250 .ok()
3251 .flatten()
3252 .map(|(version, self_hash)| crate::community::roster::EntityHead {
3253 entity_hex: grant_hex.clone(),
3254 version,
3255 self_hash,
3256 inner_id: [0u8; 32],
3257 citation: None,
3258 })
3259 .into_iter()
3260 .collect();
3261 crate::community::roster::authority_citation_satisfied(&head, Some(owner_hex), actor_hex, &grant_hex, citation)
3262}
3263
3264fn required_authority_citation(
3272 community: &CommunityV2,
3273 actor: &PublicKey,
3274) -> Result<Option<crate::community::edition::AuthorityCitation>, String> {
3275 if community.owner().ok().as_ref() == Some(actor) {
3276 return Ok(None); }
3278 my_authority_citation(community, actor).map(Some).ok_or_else(|| {
3279 "your admin rights aren't synced on this device yet — reopen the community and retry".to_string()
3280 })
3281}
3282
3283fn my_authority_citation(
3284 community: &CommunityV2,
3285 actor: &PublicKey,
3286) -> Option<crate::community::edition::AuthorityCitation> {
3287 if community.owner().ok().as_ref() == Some(actor) {
3288 return None;
3289 }
3290 let entity_id = super::derive::grant_locator(community.id(), &actor.to_bytes());
3291 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3292 let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
3293 crate::db::community::get_edition_head(&cid_hex, &entity_hex)
3294 .ok()
3295 .flatten()
3296 .map(|(version, edition_hash)| crate::community::edition::AuthorityCitation { entity_id, version, edition_hash })
3297}
3298
3299fn assert_current_root(community: &CommunityV2) -> Result<(), String> {
3307 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3308 match crate::db::community::get_server_root_epoch(&cid_hex)? {
3309 Some(held) if held != community.root_epoch.0 => Err(format!(
3310 "the community re-founded mid-action (epoch {} -> {held}); retry",
3311 community.root_epoch.0
3312 )),
3313 _ => Ok(()), }
3315}
3316
3317async fn publish_control_edition<T: Transport + ?Sized>(
3318 transport: &T,
3319 community: &CommunityV2,
3320 session: &SessionGuard,
3321 vsk: &str,
3322 entity_id: &[u8; 32],
3323 content: &str,
3324) -> Result<(), String> {
3325 assert_current_root(community)?;
3326 let signer = crate::signer::active_signer()?;
3327 let my_pk = me_pk()?;
3328 let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
3329 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3330 let entity_hex = crate::simd::hex::bytes_to_hex_32(entity_id);
3331 let (version, prev) = match crate::db::community::get_edition_head(&cid_hex, &entity_hex)? {
3332 Some((v, h)) => (v + 1, Some(h)),
3333 None => (1, None),
3334 };
3335 let citation = required_authority_citation(community, &my_pk)?;
3341 let at = now_ms() / 1000;
3342 let rumor = control::build_edition_rumor(my_pk, vsk, entity_id, version, prev.as_ref(), content, at, citation.as_ref());
3343 let (wrap, _) = control::seal_control_edition_signed(&signer, my_pk, &rumor, &control, Timestamp::from_secs(at)).await.map_err(|e| e.to_string())?;
3344 if !session.is_valid() {
3345 return Err("account changed before control publish".to_string());
3346 }
3347 transport.publish(&wrap, &community.relays).await?;
3348 if !session.is_valid() {
3354 return Ok(());
3355 }
3356 if let Ok((ed, _)) = control::open_control_edition(&wrap, &control) {
3357 crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, ed.version, &ed.self_hash, &ed.inner_id, community.root_epoch.0)?;
3358 }
3359 Ok(())
3360}
3361
3362fn merge_local_roster(cid_hex: &str, role: Option<&crate::community::roles::Role>, grant: Option<&crate::community::roles::MemberGrant>) {
3371 let mut roster = crate::db::community::get_community_roles(cid_hex).unwrap_or_default();
3372 if let Some(r) = role {
3373 match roster.roles.iter_mut().find(|x| x.role_id == r.role_id) {
3374 Some(slot) => *slot = r.clone(),
3375 None => roster.roles.push(r.clone()),
3376 }
3377 }
3378 if let Some(g) = grant {
3379 match roster.grants.iter_mut().find(|x| x.member == g.member) {
3380 Some(slot) => *slot = g.clone(),
3381 None => roster.grants.push(g.clone()),
3382 }
3383 }
3384 let at = crate::db::community::get_community_roles_at(cid_hex).unwrap_or(0);
3385 if let Err(e) = crate::db::community::set_community_roles(cid_hex, &roster, at) {
3386 crate::log_warn!("v2: local roster merge failed (heals on the next control fold): {e}");
3387 }
3388}
3389
3390pub async fn set_role<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, role: &crate::community::roles::Role) -> Result<(), String> {
3394 let session = SessionGuard::capture();
3395 super::roles::validate_role(role)?;
3396 let content = super::roles::role_content_json(role)?;
3397 let role_id = crate::simd::hex::hex_to_bytes_32_checked(&role.role_id).ok_or("role_id must be 32-byte hex")?;
3398 publish_control_edition(transport, community, &session, vsk::ROLE, &role_id, &content).await
3399}
3400
3401pub async fn grant_roles<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey, role_ids: Vec<String>) -> Result<(), String> {
3405 let session = SessionGuard::capture();
3406 let grant = crate::community::roles::MemberGrant { member: member.to_hex(), role_ids };
3407 let content = super::roles::grant_content_json(&grant)?;
3408 let eid = super::derive::grant_locator(community.id(), &member.to_bytes());
3409 publish_control_edition(transport, community, &session, vsk::GRANT, &eid, &content).await
3410}
3411
3412pub async fn ensure_admin_role<T: Transport + ?Sized>(
3417 transport: &T,
3418 community: &CommunityV2,
3419 view: &AuthorityView,
3420 create_if_missing: bool,
3421) -> Result<Option<String>, String> {
3422 use crate::community::roles::{Permissions, Role, RoleScope};
3423 if let Some(r) = view
3427 .roles
3428 .roles
3429 .iter()
3430 .find(|r| matches!(r.scope, RoleScope::Server) && r.permissions.contains(Permissions::ADMIN_FOUNDING_MASK))
3431 {
3432 return Ok(Some(r.role_id.clone()));
3433 }
3434 if !create_if_missing {
3435 return Ok(None);
3436 }
3437 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3438 let role_id = crate::crypto::sha256_hex(format!("vector/v2/role/admin/{cid_hex}").as_bytes());
3439 set_role(transport, community, &Role::admin(role_id.clone())).await?;
3440 Ok(Some(role_id))
3441}
3442
3443pub async fn grant_admin<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey) -> Result<(), String> {
3450 let session = SessionGuard::capture();
3453 let my_pk = me_pk()?;
3454 if my_pk != community.owner()? {
3455 return Err("only the community owner can grant @admin".to_string());
3456 }
3457 let view = fetch_authority(transport, community).await;
3458 if !session.is_valid() {
3459 return Err("account changed during grant".to_string());
3460 }
3461 let member_hex = member.to_hex();
3462 require_grant_head(community, &view, &member_hex)?;
3463 let role_id = ensure_admin_role(transport, community, &view, true)
3464 .await?
3465 .expect("create_if_missing yields an id");
3466 let mut role_ids = view
3467 .roles
3468 .grants
3469 .iter()
3470 .find(|g| g.member == member_hex)
3471 .map(|g| g.role_ids.clone())
3472 .unwrap_or_default();
3473 if role_ids.contains(&role_id) {
3474 return Ok(()); }
3476 role_ids.push(role_id);
3477 grant_roles(transport, community, member, role_ids).await
3478}
3479
3480pub async fn revoke_admin<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey) -> Result<(), String> {
3483 let session = SessionGuard::capture();
3484 let my_pk = me_pk()?;
3485 if my_pk != community.owner()? {
3486 return Err("only the community owner can revoke @admin".to_string());
3487 }
3488 let view = fetch_authority(transport, community).await;
3489 if !session.is_valid() {
3490 return Err("account changed during revoke".to_string());
3491 }
3492 let member_hex = member.to_hex();
3493 require_grant_head(community, &view, &member_hex)?;
3494 let Some(role_id) = ensure_admin_role(transport, community, &view, false).await? else {
3495 return Ok(()); };
3497 let mut role_ids = view
3498 .roles
3499 .grants
3500 .iter()
3501 .find(|g| g.member == member_hex)
3502 .map(|g| g.role_ids.clone())
3503 .unwrap_or_default();
3504 let before = role_ids.len();
3505 role_ids.retain(|r| r != &role_id);
3506 if role_ids.len() == before {
3507 return Ok(());
3508 }
3509 grant_roles(transport, community, member, role_ids).await
3510}
3511
3512fn require_grant_head(community: &CommunityV2, view: &AuthorityView, member_hex: &str) -> Result<(), String> {
3516 let Some(member) = crate::simd::hex::hex_to_bytes_32_checked(member_hex) else {
3517 return Err("malformed member key".to_string());
3518 };
3519 let eid_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &member));
3520 if view.floored.contains(&eid_hex) && !view.head_entities.contains(&eid_hex) {
3521 return Err("this member's current grant could not be fetched; try again once relays serve the control plane".to_string());
3522 }
3523 Ok(())
3524}
3525
3526pub async fn set_banlist<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, banned: &[String]) -> Result<(), String> {
3529 let session = SessionGuard::capture();
3530 super::roles::validate_banlist(banned)?;
3531 let content = super::roles::banlist_content_json(banned)?;
3532 let eid = super::derive::banlist_locator(community.id());
3533 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3534 let entity_hex = crate::simd::hex::bytes_to_hex_32(&eid);
3535 let version = match crate::db::community::get_edition_head(&cid_hex, &entity_hex)? {
3537 Some((v, _)) => v + 1,
3538 None => 1,
3539 };
3540 publish_control_edition(transport, community, &session, vsk::BANLIST, &eid, &content).await?;
3541 if session.is_valid() {
3548 let _ = crate::db::community::set_community_banlist(&cid_hex, banned, version as i64);
3549 }
3550 Ok(())
3551}
3552
3553pub async fn edit_community_metadata<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, meta: &control::CommunityMetadata) -> Result<(), String> {
3556 let session = SessionGuard::capture();
3557 control::validate_community_metadata(meta).map_err(|e| e.to_string())?;
3558 let content = serde_json::to_string(meta).map_err(|e| e.to_string())?;
3559 publish_control_edition(transport, community, &session, vsk::COMMUNITY_METADATA, &community.id().0, &content).await
3560}
3561
3562pub async fn persist_community_image(
3567 id: &crate::community::CommunityId,
3568 img: control::ImageRef,
3569 is_banner: bool,
3570 session: &SessionGuard,
3571) -> Option<CommunityV2> {
3572 let lock = super::realtime::follow_lock(id);
3573 let _guard = lock.lock().await;
3574 if !session.is_valid() {
3575 return None;
3576 }
3577 let mut fresh = crate::db::community::load_community_v2(id).ok()??;
3578 if is_banner {
3579 fresh.banner = Some(img);
3580 } else {
3581 fresh.icon = Some(img);
3582 }
3583 crate::db::community::save_community_v2(&fresh).ok()?;
3584 Some(fresh)
3585}
3586
3587pub async fn edit_channel_metadata<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, channel_id: &ChannelId, meta: &control::ChannelMetadata) -> Result<(), String> {
3590 let session = SessionGuard::capture();
3591 let my_pk = me_pk()?;
3592 ensure_channel_manager(community, &my_pk)?;
3593 let old_name = community.channel(channel_id).map(|c| c.name.clone());
3594 if meta.private {
3599 if let Some(held) = community.channel(channel_id) {
3600 if !held.private {
3601 return Err("converting a public channel to private is not supported yet".to_string());
3602 }
3603 }
3604 }
3605 control::validate_channel_metadata(meta).map_err(|e| e.to_string())?;
3606 let content = serde_json::to_string(meta).map_err(|e| e.to_string())?;
3607 publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
3608 if !session.is_valid() {
3612 return Ok(());
3613 }
3614 if let Ok(Some(mut held)) = crate::db::community::load_community_v2(community.id()) {
3615 if let Some(ch) = held.channels.iter_mut().find(|c| c.id.0 == channel_id.0) {
3616 ch.name = meta.name.clone();
3617 ch.private = meta.private;
3618 ch.voice = meta.voice;
3619 ch.meta_custom = meta.custom.clone();
3620 ch.meta_extra = meta.extra.clone();
3621 crate::db::community::save_community_v2(&held)?;
3622 }
3623 }
3624 if meta.private {
3626 if let Some(old) = old_name.filter(|o| *o != meta.name) {
3627 rename_channel_access_role(transport, community, channel_id, &old, &meta.name, &session).await;
3628 }
3629 }
3630 Ok(())
3631}
3632
3633async fn rename_channel_access_role<T: Transport + ?Sized>(
3640 transport: &T,
3641 community: &CommunityV2,
3642 channel_id: &ChannelId,
3643 old_name: &str,
3644 new_name: &str,
3645 session: &SessionGuard,
3646) {
3647 let (Ok(my_pk), Ok(owner)) = (me_pk(), community.owner()) else {
3648 return;
3649 };
3650 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3651 let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
3652 let mut roster = fetch_authority(transport, community).await.roles;
3655 let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
3656 for r in cached.roles {
3657 if !roster.roles.iter().any(|x| x.role_id == r.role_id) {
3658 roster.roles.push(r);
3659 }
3660 }
3661 if !session.is_valid() {
3662 return;
3663 }
3664 let (me_hex, owner_hex) = (my_pk.to_hex(), owner.to_hex());
3668 if !roster.is_authorized_in(&me_hex, Some(&owner_hex), &chan_hex, crate::community::roles::Permissions::MANAGE_ROLES) {
3669 return;
3670 }
3671 let Some(mut role) = roster
3674 .channel_roles(&chan_hex)
3675 .into_iter()
3676 .find(|r| r.permissions == crate::community::roles::Permissions::empty() && r.name == old_name)
3677 .cloned()
3678 else {
3679 return;
3680 };
3681 if !roster.can_act_on_position(&me_hex, Some(&owner_hex), role.position, crate::community::roles::Permissions::MANAGE_ROLES) {
3682 return;
3683 }
3684 role.name = new_name.to_string();
3685 if let Err(e) = set_role(transport, community, &role).await {
3686 crate::log_warn!("v2: channel renamed but its access role did not follow: {e}");
3687 return;
3688 }
3689 if session.is_valid() {
3690 merge_local_roster(&cid_hex, Some(&role), None);
3691 }
3692}
3693
3694fn ensure_channel_manager(community: &CommunityV2, me: &PublicKey) -> Result<(), String> {
3700 let owner = community.owner()?;
3701 if *me == owner {
3702 return Ok(());
3703 }
3704 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3705 let me_hex = me.to_hex();
3706 if crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default().contains(&me_hex) {
3707 return Err("you are banned from this community".to_string());
3708 }
3709 let roster = crate::db::community::get_community_roles(&cid_hex)?;
3710 if roster.is_authorized(&me_hex, Some(&owner.to_hex()), crate::community::roles::Permissions::MANAGE_CHANNELS) {
3711 Ok(())
3712 } else {
3713 Err("managing channels here needs the MANAGE_CHANNELS permission".to_string())
3714 }
3715}
3716
3717pub async fn create_public_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str) -> Result<ChannelId, String> {
3723 let channel_id = ChannelId(super::super::random_32());
3724 create_public_channel_with_id(transport, community, name, channel_id).await?;
3725 Ok(channel_id)
3726}
3727
3728pub async fn create_public_channel_with_id<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str, channel_id: ChannelId) -> Result<(), String> {
3732 let session = SessionGuard::capture();
3733 let lock = super::realtime::follow_lock(community.id());
3737 let _guard = lock.lock().await;
3738 let my_pk = me_pk()?;
3739 ensure_channel_manager(community, &my_pk)?;
3740 assert_channel_id_free(&channel_id, community.id())?;
3741 let meta = control::ChannelMetadata { name: name.to_string(), private: false, voice: None, deleted: None, custom: None, extra: Default::default() };
3742 control::validate_channel_metadata(&meta).map_err(|e| e.to_string())?;
3743 let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
3744 publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
3745 if !session.is_valid() {
3746 return Err("account changed during channel create".to_string());
3747 }
3748 let mut updated = community.clone();
3750 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() });
3751 crate::db::community::save_community_v2(&updated)?;
3752 Ok(())
3753}
3754
3755fn assert_channel_id_free(channel_id: &ChannelId, community_id: &crate::community::CommunityId) -> Result<(), String> {
3760 let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
3761 if let Ok(Some(existing)) = crate::db::community::community_id_for_channel(&ch_hex) {
3762 let mine = crate::simd::hex::bytes_to_hex_32(&community_id.0);
3763 let existing_id = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&existing));
3764 if existing != mine
3765 && matches!(crate::db::community::community_protocol(&existing_id), Ok(Some(crate::community::ConcordProtocol::V2)))
3766 {
3767 return Err("channel id is already live in another v2 community".to_string());
3768 }
3769 }
3770 Ok(())
3771}
3772
3773pub async fn create_private_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str) -> Result<ChannelId, String> {
3785 let channel_id = ChannelId(super::super::random_32());
3786 create_private_channel_with_id(transport, community, name, channel_id).await?;
3787 Ok(channel_id)
3788}
3789
3790pub fn channel_access_role(channel_id: &ChannelId, name: &str) -> crate::community::roles::Role {
3795 use crate::community::roles::{Permissions, Role, RoleScope};
3796 Role {
3797 role_id: crate::simd::hex::bytes_to_hex_32(&super::super::random_32()),
3798 name: name.to_string(),
3799 position: u32::MAX - 1,
3800 permissions: Permissions::empty(),
3801 scope: RoleScope::Channel(crate::simd::hex::bytes_to_hex_32(&channel_id.0)),
3802 color: 0,
3803 }
3804}
3805
3806pub async fn create_private_channel_with_id<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str, channel_id: ChannelId) -> Result<(), String> {
3809 let session = SessionGuard::capture();
3810 let lock = super::realtime::follow_lock(community.id());
3814 let _guard = lock.lock().await;
3815 let signer = crate::signer::active_signer()?;
3816 let my_pk = me_pk()?;
3817 ensure_channel_manager(community, &my_pk)?;
3818 assert_channel_id_free(&channel_id, community.id())?;
3819 let meta = control::ChannelMetadata { name: name.to_string(), private: true, voice: None, deleted: None, custom: None, extra: Default::default() };
3820 control::validate_channel_metadata(&meta).map_err(|e| e.to_string())?;
3821 let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
3822
3823 let channel_key = super::super::random_32();
3824 let epoch = Epoch(1);
3825
3826 let access_role = channel_access_role(&channel_id, name);
3829 let access_role_ids = vec![access_role.role_id.clone()];
3830
3831 let owner = community.owner()?;
3835 let mut recipients = vec![my_pk];
3836 if owner != my_pk {
3837 recipients.push(owner);
3838 }
3839 let prev_commit = super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
3840 let mut blobs = Vec::with_capacity(recipients.len());
3841 for r in &recipients {
3842 blobs.push(
3843 rekey::build_blob(&signer, &my_pk.to_bytes(), r, RekeyScope::Channel(channel_id), epoch, &channel_key)
3844 .await
3845 .map_err(|e| e.to_string())?,
3846 );
3847 }
3848 let group = channel_rekey_group_key(&community.community_root, &channel_id, epoch);
3849 let at_secs = now_ms() / 1000;
3850 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())
3851 .await
3852 .map_err(|e| e.to_string())?;
3853 if !session.is_valid() {
3854 return Err("account changed during channel create".to_string());
3855 }
3856 for c in &chunks {
3857 transport.publish_durable(c, &community.relays).await?;
3858 }
3859 publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
3860 if !session.is_valid() {
3861 return Err("account changed during channel create".to_string());
3862 }
3863 set_role(transport, community, &access_role).await?;
3868 grant_roles(transport, community, &my_pk, access_role_ids.clone()).await?;
3869 if !session.is_valid() {
3870 return Err("account changed during channel create".to_string());
3871 }
3872 merge_local_roster(
3875 &crate::simd::hex::bytes_to_hex_32(&community.id().0),
3876 Some(&access_role),
3877 Some(&crate::community::roles::MemberGrant { member: my_pk.to_hex(), role_ids: access_role_ids }),
3878 );
3879 if crate::db::community::community_protocol(community.id())?.is_none() {
3881 return Err("community removed during channel create".to_string());
3882 }
3883 let mut updated = community.clone();
3884 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() });
3885 crate::db::community::save_community_v2(&updated)?;
3886 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3889 crate::db::community::store_epoch_key(&cid_hex, &crate::simd::hex::bytes_to_hex_32(&channel_id.0), epoch.0, &channel_key)?;
3890 let _ = refresh_public_links(transport, &updated).await;
3893 Ok(())
3894}
3895
3896#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3901pub enum VendVerdict {
3902 Accept,
3904 Park(&'static str),
3908 Refuse(&'static str),
3910}
3911
3912pub fn judge_channel_key_vend(
3924 community: &CommunityV2,
3925 roster: &crate::community::roles::CommunityRoles,
3926 channel_id: &ChannelId,
3927 epoch: Epoch,
3928 sender_hex: &str,
3929) -> VendVerdict {
3930 let me = match me_pk() {
3931 Ok(pk) => pk.to_hex(),
3932 Err(_) => return VendVerdict::Park("no active identity"),
3933 };
3934 let owner_hex = match community.owner() {
3935 Ok(o) => o.to_hex(),
3936 Err(_) => return VendVerdict::Refuse("community has no resolvable owner"),
3937 };
3938 let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
3939
3940 let Some(ch) = community.channel(channel_id) else {
3943 return VendVerdict::Park("channel not in our fold yet");
3944 };
3945 if !ch.private {
3946 return VendVerdict::Refuse("vend names a channel our fold says is public");
3949 }
3950
3951 if ch.key.is_some() && epoch.0 <= ch.epoch.0 {
3959 return VendVerdict::Refuse("superseded: we already hold this epoch or newer");
3960 }
3961 if epoch.0 > ch.epoch.0.saturating_add(MAX_VEND_EPOCH_LEAD) {
3962 return VendVerdict::Refuse("vend epoch is implausibly far ahead of the channel head");
3963 }
3964
3965 if !roster.is_entitled(Some(&owner_hex), &me, &chan_hex, &[], &[]) {
3969 return VendVerdict::Park("our grant for this channel has not folded yet");
3970 }
3971
3972 if sender_hex != owner_hex && !roster.is_entitled(Some(&owner_hex), sender_hex, &chan_hex, &[], &[]) {
3975 return VendVerdict::Park("vendor's entitlement has not folded yet");
3976 }
3977
3978 VendVerdict::Accept
3979}
3980
3981const PARKED_VEND_TTL_SECS: u64 = 30 * 24 * 3600;
3984
3985const MAX_VEND_EPOCH_LEAD: u64 = 1024;
3989
3990pub fn absorb_parked_channel_keys(community: &CommunityV2, session: &SessionGuard) -> Vec<ChannelId> {
3996 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3997 let parked = match crate::db::community::get_pending_channel_keys(&cid_hex) {
3998 Ok(p) if !p.is_empty() => p,
3999 _ => return Vec::new(),
4000 };
4001 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
4002 let mut adopted = Vec::new();
4003 let now = now_ms() / 1000;
4004 for p in parked {
4005 if adopted.iter().any(|c: &ChannelId| crate::simd::hex::bytes_to_hex_32(&c.0) == p.channel_id) {
4009 let _ = crate::db::community::drop_pending_channel_key(p.id);
4010 continue;
4011 }
4012 if now.saturating_sub(p.received_at.max(0) as u64) > PARKED_VEND_TTL_SECS {
4018 let _ = crate::db::community::drop_pending_channel_key(p.id);
4019 continue;
4020 }
4021 let Some(id_bytes) = crate::simd::hex::hex_to_bytes_32_checked(&p.channel_id) else {
4022 let _ = crate::db::community::drop_pending_channel_key(p.id);
4023 continue;
4024 };
4025 let channel_id = ChannelId(id_bytes);
4026 match judge_channel_key_vend(community, &roster, &channel_id, Epoch(p.epoch), &p.sender) {
4027 VendVerdict::Accept => {
4028 if !session.is_valid() {
4029 return adopted;
4030 }
4031 let keyless = community.channel(&channel_id).is_some_and(|c| c.key.is_none());
4036 let seated = if keyless {
4037 crate::db::community::seat_channel_key(&cid_hex, &p.channel_id, p.epoch, &p.key)
4038 } else {
4039 crate::db::community::advance_channel_epoch(&cid_hex, &p.channel_id, p.epoch, &p.key).map(|_| ())
4040 };
4041 if let Err(e) = seated {
4042 crate::log_warn!("v2: adopting a vended channel key failed: {e}");
4043 continue;
4044 }
4045 let _ = crate::db::community::drop_pending_channel_keys_for(&cid_hex, &p.channel_id);
4047 adopted.push(channel_id);
4048 }
4049 VendVerdict::Refuse(why) => {
4050 crate::log_warn!("v2: refused a vended channel key for {}: {why}", p.channel_id);
4051 let _ = crate::db::community::drop_pending_channel_key(p.id);
4053 }
4054 VendVerdict::Park(_) => {}
4056 }
4057 }
4058 adopted
4059}
4060
4061pub async fn grant_channel_access<T: Transport + ?Sized>(
4071 transport: &T,
4072 community: &CommunityV2,
4073 channel_id: &ChannelId,
4074 member: &PublicKey,
4075) -> Result<(), String> {
4076 let session = SessionGuard::capture();
4077 let my_pk = me_pk()?;
4078 let ch = community.channel(channel_id).ok_or("unknown channel")?;
4079 if !ch.private {
4080 return Err("channel is public — every member already reads it".to_string());
4081 }
4082 if ch.key.is_none() {
4083 return Err("we hold no key for this channel, so we cannot vend it".to_string());
4084 }
4085 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4086 let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
4087 let owner_hex = community.owner()?.to_hex();
4088 let mut roster = fetch_authority(transport, community).await.roles;
4094 let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
4095 for r in cached.roles {
4096 if !roster.roles.iter().any(|x| x.role_id == r.role_id) {
4097 roster.roles.push(r);
4098 }
4099 }
4100 for g in cached.grants {
4101 if !roster.grants.iter().any(|x| x.member == g.member) {
4102 roster.grants.push(g);
4103 }
4104 }
4105 if !session.is_valid() {
4106 return Err("account changed during grant".to_string());
4107 }
4108 if !roster.is_authorized_in(&my_pk.to_hex(), Some(&owner_hex), &chan_hex, crate::community::roles::Permissions::MANAGE_ROLES) {
4111 return Err("not authorized to manage this channel's access".to_string());
4112 }
4113 let role_id = roster
4118 .channel_roles(&chan_hex)
4119 .into_iter()
4120 .find(|r| r.permissions == crate::community::roles::Permissions::empty())
4121 .map(|r| r.role_id.clone())
4122 .ok_or("channel has no permission-less access role to grant")?;
4123
4124 let mut role_ids: Vec<String> = roster.roles_of(&member.to_hex()).map(|r| r.role_id.clone()).collect();
4125 if !role_ids.contains(&role_id) {
4126 role_ids.push(role_id.clone());
4127 }
4128 grant_roles(transport, community, member, role_ids.clone()).await?;
4129 if !session.is_valid() {
4130 return Err("account changed during grant".to_string());
4131 }
4132 merge_local_roster(&cid_hex, None, Some(&crate::community::roles::MemberGrant { member: member.to_hex(), role_ids }));
4133 let bundle = bundle_of_with_overlay(
4135 community,
4136 BundleAudience::Member(*member),
4137 Some(my_pk),
4138 None,
4139 None,
4140 std::slice::from_ref(&role_id),
4141 &[],
4142 );
4143 let signer = crate::signer::active_signer()?;
4144 let wrap = invite::build_direct_invite_signed(&signer, my_pk, member, &bundle).await.map_err(|e| e.to_string())?;
4145 if !session.is_valid() {
4146 return Err("account changed before vending the key".to_string());
4147 }
4148 transport.publish(&wrap, &community.relays).await?;
4149 Ok(())
4150}
4151
4152pub async fn revoke_channel_access<T: Transport + ?Sized>(
4158 transport: &T,
4159 community: &CommunityV2,
4160 channel_id: &ChannelId,
4161 member: &PublicKey,
4162) -> Result<(), String> {
4163 let session = SessionGuard::capture();
4164 let my_pk = me_pk()?;
4165 let ch = community.channel(channel_id).ok_or("unknown channel")?;
4166 if !ch.private {
4167 return Err("channel is public — there is no access to revoke".to_string());
4168 }
4169 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4170 let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
4171 let owner_hex = community.owner()?.to_hex();
4172 let mut roster = fetch_authority(transport, community).await.roles;
4175 let cached = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
4176 for r in cached.roles {
4177 if !roster.roles.iter().any(|x| x.role_id == r.role_id) {
4178 roster.roles.push(r);
4179 }
4180 }
4181 for g in cached.grants {
4182 if !roster.grants.iter().any(|x| x.member == g.member) {
4183 roster.grants.push(g);
4184 }
4185 }
4186 if !session.is_valid() {
4187 return Err("account changed during revoke".to_string());
4188 }
4189 if !roster.is_authorized_in(&my_pk.to_hex(), Some(&owner_hex), &chan_hex, crate::community::roles::Permissions::MANAGE_ROLES) {
4190 return Err("not authorized to manage this channel's access".to_string());
4191 }
4192 if *member == community.owner()? {
4193 return Err("the owner is supreme and cannot be removed".to_string());
4194 }
4195 let access_ids = roster.channel_role_ids(&chan_hex);
4196 if access_ids.is_empty() {
4200 return Err("this channel's access role has not folded yet — retry once the control plane serves it".to_string());
4201 }
4202 let remaining: Vec<String> = roster
4203 .roles_of(&member.to_hex())
4204 .map(|r| r.role_id.clone())
4205 .filter(|id| !access_ids.contains(id))
4206 .collect();
4207 grant_roles(transport, community, member, remaining.clone()).await?;
4208 if !session.is_valid() {
4209 return Err("account changed during revoke".to_string());
4210 }
4211 merge_local_roster(&cid_hex, None, Some(&crate::community::roles::MemberGrant { member: member.to_hex(), role_ids: remaining }));
4212 rekey_channel_excluding(transport, community, channel_id, &roster, &access_ids, member).await
4216}
4217
4218async fn rekey_channel_excluding<T: Transport + ?Sized>(
4228 transport: &T,
4229 community: &CommunityV2,
4230 channel_id: &ChannelId,
4231 roster: &crate::community::roles::CommunityRoles,
4232 access_ids: &[String],
4233 removed: &PublicKey,
4234) -> Result<(), String> {
4235 let session = SessionGuard::capture();
4236 let lock = super::realtime::follow_lock(community.id());
4238 let _guard = lock.lock().await;
4239 let signer = crate::signer::active_signer()?;
4240 let my_pk = me_pk()?;
4241 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4242 let chan_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
4243 let ch = community.channel(channel_id).ok_or("unknown channel")?.clone();
4244 let old_key = ch.key.ok_or("we hold no key for this channel, so we cannot rotate it")?;
4245 let new_epoch = Epoch(ch.epoch.0.checked_add(1).ok_or("channel epoch overflow")?);
4246 let owner = community.owner()?;
4247 let owner_hex = owner.to_hex();
4248
4249 let removed_hex = removed.to_hex();
4253 let mut recipients: Vec<PublicKey> = vec![my_pk];
4254 if owner != my_pk {
4255 recipients.push(owner);
4256 }
4257 for g in &roster.grants {
4258 if g.member == removed_hex || g.member == owner_hex {
4259 continue;
4260 }
4261 if !g.role_ids.iter().any(|id| access_ids.contains(id)) {
4262 continue;
4263 }
4264 if let Ok(pk) = PublicKey::parse(&g.member) {
4265 if !recipients.contains(&pk) {
4266 recipients.push(pk);
4267 }
4268 }
4269 }
4270 let new_key = mint_or_reuse_rotation_key(&cid_hex, &chan_hex, new_epoch.0)?;
4273 let prev_commit = super::derive::epoch_key_commitment(ch.epoch, &old_key);
4274 let mut blobs = Vec::with_capacity(recipients.len());
4275 for r in &recipients {
4276 blobs.push(
4277 rekey::build_blob(&signer, &my_pk.to_bytes(), r, RekeyScope::Channel(*channel_id), new_epoch, &new_key)
4278 .await
4279 .map_err(|e| e.to_string())?,
4280 );
4281 }
4282 let group = channel_rekey_group_key(&community.community_root, channel_id, new_epoch);
4283 let at_secs = now_ms() / 1000;
4284 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())
4285 .await
4286 .map_err(|e| e.to_string())?;
4287 if !session.is_valid() {
4288 return Err("account changed during channel rekey".to_string());
4289 }
4290 for c in &chunks {
4291 transport.publish_durable(c, &community.relays).await?;
4292 }
4293 if !session.is_valid() {
4294 return Err("account changed during channel rekey".to_string());
4295 }
4296 if crate::db::community::community_protocol(community.id())?.is_none() {
4297 return Err("community removed during channel rekey".to_string());
4298 }
4299 crate::db::community::advance_channel_epoch(&cid_hex, &chan_hex, new_epoch.0, &new_key)?;
4301 crate::db::community::store_epoch_key(&cid_hex, &chan_hex, new_epoch.0, &new_key)?;
4302
4303 {
4310 let mut rotated = community.clone();
4311 if let Some(c) = rotated.channels.iter_mut().find(|c| c.id == *channel_id) {
4312 c.key = Some(new_key);
4313 c.epoch = new_epoch;
4314 }
4315 match read_channel_pins(&rotated, channel_id) {
4316 Ok(read) if !read.sealed && !read.pins.is_empty() => {
4317 let entries: Vec<super::pins::PinEntry> =
4318 read.pins.iter().map(|p| p.entry.clone()).collect();
4319 if let Some(ch2) = rotated.channel(channel_id).cloned() {
4320 if let Err(e) = publish_pin_list(transport, &rotated, &session, &ch2, &entries).await {
4321 crate::log_warn!("[pins] rotation reseal failed (a curator's next edit heals): {e}");
4322 } else {
4323 crate::log_info!("[pins] resealed {} pin(s) under epoch {}", entries.len(), new_epoch.0);
4324 }
4325 }
4326 }
4327 Ok(read) if read.sealed => {
4328 crate::log_warn!("[pins] rotating a channel whose pin list we cannot read; reseal skipped");
4329 }
4330 _ => {}
4331 }
4332 }
4333 Ok(())
4334}
4335
4336pub async fn delete_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, channel_id: &ChannelId, name: &str) -> Result<(), String> {
4339 let session = SessionGuard::capture();
4340 let lock = super::realtime::follow_lock(community.id());
4342 let _guard = lock.lock().await;
4343 let my_pk = me_pk()?;
4344 ensure_channel_manager(community, &my_pk)?;
4345 let mut meta = community.channel(channel_id).map(|c| c.metadata()).unwrap_or_else(|| control::ChannelMetadata {
4349 name: name.to_string(), private: false, voice: None, deleted: None, custom: None, extra: Default::default(),
4350 });
4351 meta.deleted = Some(true);
4352 let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
4353 publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content).await?;
4354 if !session.is_valid() {
4355 return Err("account changed during channel delete".to_string());
4356 }
4357 let mut updated = community.clone();
4358 updated.channels.retain(|c| c.id.0 != channel_id.0);
4359 crate::db::community::save_community_v2(&updated)?;
4360 Ok(())
4361}
4362
4363pub async fn follow_control<T: Transport + ?Sized>(
4387 transport: &T,
4388 community: &CommunityV2,
4389 session: &SessionGuard,
4390) -> Result<Option<CommunityV2>, String> {
4391 community.owner()?; let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
4393 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4394
4395 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
4401 .into_iter()
4402 .filter(|(_, f)| f.0 == community.root_epoch.0)
4403 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
4404 .collect();
4405
4406 let mut editions: Vec<ParsedEdition> = Vec::new();
4410 let mut seen: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
4411 let mut seen_wraps: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
4412 let mut oldest: Option<u64> = None;
4413 let mut until: Option<u64> = None;
4414 let mut fold = ControlFold { updated: None, heads: Vec::new(), gapped: false, pins_persist: Vec::new() };
4415 let mut authority = AuthoritySet::owner_only();
4416 let mut truncated = true;
4422 for _ in 0..FOLLOW_MAX_PAGES {
4423 let query = Query {
4427 kinds: vec![stream::KIND_WRAP],
4428 authors: vec![control.pk_hex()],
4429 until,
4430 limit: Some(FOLLOW_PAGE),
4431 evidence: crate::community::transport::Evidence::Quorum,
4432 ..Default::default()
4433 };
4434 let wraps = transport.fetch(&query, &community.relays).await?;
4435 let mut fresh = 0usize;
4439 for w in &wraps {
4440 if !seen_wraps.insert(w.id) {
4441 continue;
4442 }
4443 fresh += 1;
4444 let at = w.created_at.as_secs();
4445 if oldest.is_none_or(|o| at < o) {
4446 oldest = Some(at);
4447 }
4448 if let Ok((ed, _)) = control::open_control_edition(w, &control) {
4451 if seen.insert(ed.inner_id) {
4452 editions.push(ed);
4453 }
4454 }
4455 }
4456 authority = fold_authority(community, &editions, &floors);
4459 fold = apply_control_fold(community, &editions, &floors, &authority);
4460 if !(fold.gapped || authority.gapped) {
4461 truncated = false; break;
4463 }
4464 if fresh == 0 {
4465 truncated = wraps.len() >= FOLLOW_PAGE;
4470 break;
4471 }
4472 until = oldest;
4473 }
4474
4475 if !session.is_valid() {
4478 return Err("account changed during control follow".to_string());
4479 }
4480 if crate::db::community::community_protocol(community.id())?.is_none() {
4483 return Ok(None);
4484 }
4485 for h in fold.heads.iter().chain(authority.heads.iter()) {
4491 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)?;
4492 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)?;
4493 }
4494 let mut authority_changed = false;
4497 let _ = crate::db::community::merge_community_ban_marks(&cid_hex, &authority.banned_at);
4501 if let Some((banned, version)) = &authority.banlist_persist {
4502 let mut before = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
4503 crate::db::community::set_community_banlist(&cid_hex, banned, *version as i64)?;
4504 let mut after = banned.clone();
4505 before.sort();
4506 after.sort();
4507 authority_changed |= before != after;
4508 }
4509 let newest_roster_at: i64 = editions
4516 .iter()
4517 .filter(|e| e.vsk == vsk::ROLE || e.vsk == vsk::GRANT || e.vsk == vsk::BANLIST)
4518 .map(|e| e.created_at as i64)
4519 .max()
4520 .unwrap_or(0);
4521 let stored = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
4527 let head_ents: std::collections::HashSet<&str> = authority.heads.iter().map(|h| h.entity_hex.as_str()).collect();
4528 let stored_complete = stored.roles.iter().all(|r| !floors.contains_key(&r.role_id) || head_ents.contains(r.role_id.as_str()))
4529 && stored.grants.iter().all(|g| {
4530 crate::simd::hex::hex_to_bytes_32_checked(&g.member).is_none_or(|m| {
4531 let eid = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &m));
4532 !floors.contains_key(&eid) || head_ents.contains(eid.as_str())
4533 })
4534 });
4535 if !truncated && !authority.gapped && stored_complete && newest_roster_at >= crate::db::community::get_community_roles_at(&cid_hex)? {
4540 authority_changed |= stored != authority.roles;
4541 crate::db::community::set_community_roles(&cid_hex, &authority.roles, newest_roster_at)?;
4542 }
4543 if !truncated && !authority.gapped && !fold.gapped {
4549 if let Ok(owner) = community.owner() {
4550 let sets = live_invite_link_sets(community.id(), &owner.to_hex(), &editions, &authority, &floors);
4551 let live = flatten_link_sets(&sets);
4552 let mut before = crate::db::community::get_community_invite_registry(&cid_hex).unwrap_or_default();
4553 before.sort();
4554 if before != live {
4555 crate::db::community::set_community_invite_registry(&cid_hex, &live)?;
4556 authority_changed = true;
4557 }
4558 crate::db::community::replace_invite_link_sets(&cid_hex, &sets)?;
4561 }
4562 }
4563 for (channel_hex, content, version, author_npub, created_at) in &fold.pins_persist {
4567 match crate::db::community::set_community_pins(&cid_hex, channel_hex, content, *version as i64) {
4568 Ok(true) => {
4569 crate::log_info!("[pins] fold adopted v{} for channel {}", version, &channel_hex[..12]);
4570 crate::emit_event(
4571 "community_pins_updated",
4572 &serde_json::json!({ "community_id": cid_hex, "channel_id": channel_hex }),
4573 );
4574 note_pins_modified(channel_hex, *version, author_npub, *created_at).await;
4575 }
4576 Ok(false) => {}
4577 Err(e) => crate::log_warn!("[pins] fold persist failed: {e}"),
4578 }
4579 }
4580 if authority_changed {
4585 crate::emit_event("community_refreshed", &serde_json::json!({ "community_id": cid_hex }));
4586 }
4587 {
4591 static UPGRADED: std::sync::Mutex<Option<std::collections::HashSet<String>>> = std::sync::Mutex::new(None);
4592 let first = UPGRADED
4593 .lock()
4594 .map(|mut set| set.get_or_insert_with(Default::default).insert(cid_hex.clone()))
4595 .unwrap_or(false);
4596 if first {
4597 let _ = upgrade_admin_role_pin_bit(transport, community).await;
4598 }
4599 }
4600 match fold.updated {
4601 Some(u) => {
4602 crate::db::community::save_community_v2(&u)?;
4603 Ok(Some(u))
4604 }
4605 None => Ok(None),
4606 }
4607}
4608
4609const FOLLOW_MAX_PAGES: usize = 32;
4618const FOLLOW_PAGE: usize = 500;
4619const COMPACT_MAX_PAGES: usize = 512;
4624
4625#[derive(Clone)]
4627struct FoldedHead {
4628 entity_hex: String,
4629 version: u64,
4630 self_hash: [u8; 32],
4631 inner_id: [u8; 32],
4632}
4633
4634struct ControlFold {
4640 updated: Option<CommunityV2>,
4641 heads: Vec<FoldedHead>,
4642 gapped: bool,
4643 pins_persist: Vec<(String, String, u64, String, u64)>,
4647}
4648
4649type Floors = std::collections::HashMap<String, (u64, [u8; 32], Option<[u8; 32]>)>;
4651
4652fn apply_control_fold(community: &CommunityV2, editions: &[ParsedEdition], floors: &Floors, authority: &AuthoritySet) -> ControlFold {
4673 use crate::community::roles::Permissions;
4674 use std::collections::BTreeMap;
4675
4676 let owner_hex = community.owner().ok().map(|o| o.to_hex());
4677
4678 let mut groups: BTreeMap<(String, [u8; 32]), Vec<&ParsedEdition>> = BTreeMap::new();
4679 for e in editions {
4680 groups.entry((e.vsk.clone(), e.entity_id)).or_default().push(e);
4681 }
4682
4683 let mut out = community.clone();
4684 let mut changed = false;
4685 let mut heads = Vec::new();
4686 let mut gapped = false;
4687 let mut pins_persist = Vec::new();
4688 let pins_by_eid: std::collections::HashMap<[u8; 32], String> = community
4693 .channels
4694 .iter()
4695 .map(|ch| (super::derive::pins_locator(community.id(), &ch.id), crate::simd::hex::bytes_to_hex_32(&ch.id.0)))
4696 .collect();
4697 for ((vsk_code, eid), group) in &groups {
4698 let is_meta = vsk_code == vsk::COMMUNITY_METADATA && *eid == community.id().0;
4704 let is_channel = vsk_code == vsk::CHANNEL_METADATA && *eid != community.id().0;
4705 let pins_channel = (vsk_code == vsk::PINS).then(|| pins_by_eid.get(eid)).flatten();
4706 if !is_meta && !is_channel && pins_channel.is_none() {
4707 continue;
4708 }
4709 let required = if is_meta {
4714 Permissions::MANAGE_METADATA
4715 } else if is_channel {
4716 Permissions::MANAGE_CHANNELS
4717 } else {
4718 Permissions::PIN_MESSAGES
4719 };
4720 let authed: Vec<&ParsedEdition> = group
4721 .iter()
4722 .copied()
4723 .filter(|e| {
4724 let author = e.author.to_hex();
4725 !authority.banned.contains(&author)
4728 && authority.roles.is_authorized(&author, owner_hex.as_deref(), required)
4729 && citation_ok_in_fold(community.id(), &authority.heads, owner_hex.as_deref(), &e.author, e.authority.as_ref())
4733 })
4734 .collect();
4735 if authed.is_empty() {
4736 continue;
4737 }
4738 let entity_hex = crate::simd::hex::bytes_to_hex_32(eid);
4739 let fold_eds: Vec<version::Edition> = authed.iter().map(|p| p.to_fold_edition()).collect();
4740 let (hi, entity_gapped) = fold_head(&fold_eds, floors.get(&entity_hex));
4741 gapped |= entity_gapped;
4742 let Some(hi) = hi else { continue };
4743
4744 let head = authed[hi];
4745 heads.push(FoldedHead { entity_hex, version: head.version, self_hash: head.self_hash, inner_id: head.inner_id });
4746 if is_meta {
4747 if let Ok(meta) = serde_json::from_str::<control::CommunityMetadata>(&head.content) {
4748 changed |= apply_community_metadata(&mut out, meta);
4749 }
4750 } else if is_channel {
4751 if let Ok(meta) = serde_json::from_str::<control::ChannelMetadata>(&head.content) {
4752 changed |= apply_channel_metadata(&mut out, ChannelId(*eid), meta);
4756 }
4757 } else if let Some(channel_hex) = pins_channel {
4758 use nostr_sdk::prelude::ToBech32;
4762 let author_npub = head
4763 .author
4764 .to_bech32()
4765 .unwrap_or_else(|_| head.author.to_hex());
4766 pins_persist.push((channel_hex.clone(), head.content.clone(), head.version, author_npub, head.created_at));
4767 }
4768 }
4769 ControlFold { updated: changed.then_some(out), heads, gapped, pins_persist }
4770}
4771
4772fn fold_head(fold_eds: &[version::Edition], floor: Option<&(u64, [u8; 32], Option<[u8; 32]>)>) -> (Option<usize>, bool) {
4779 let floor_v = floor.map(|f| f.0).unwrap_or(0);
4780 if floor_v == 0 {
4781 return (version::bootstrap_head(fold_eds, 0), false);
4782 }
4783 let floor_hash = floor.map(|f| &f.1);
4784 let held_inner = floor.and_then(|f| f.2);
4785 let result = version::fold(fold_eds, floor_v, floor_hash);
4786 if result.anchored {
4787 return (result.head, result.gap); }
4789 if result.head.is_none() && !result.gap {
4790 return (None, false); }
4792 let fork = fold_eds.iter().enumerate().filter(|(_, e)| e.version == floor_v).min_by_key(|(_, e)| e.tiebreak_id);
4796 let win_hash = match fork {
4797 Some((_, w)) if floor_hash != Some(&w.self_hash) && held_inner.is_none_or(|h| w.tiebreak_id < h) => w.self_hash,
4798 _ => return (None, true), };
4800 let re = version::fold(fold_eds, floor_v, Some(&win_hash));
4801 if !re.anchored {
4802 return (None, true);
4803 }
4804 (re.head, re.gap)
4805}
4806
4807struct AuthoritySet {
4812 roles: crate::community::roles::CommunityRoles,
4813 banned: std::collections::BTreeSet<String>,
4814 heads: Vec<FoldedHead>,
4815 gapped: bool,
4816 banlist_persist: Option<(Vec<String>, u64)>,
4820 banned_at: std::collections::BTreeMap<String, u64>,
4824}
4825
4826impl AuthoritySet {
4827 fn owner_only() -> Self {
4830 AuthoritySet {
4831 roles: Default::default(),
4832 banned: Default::default(),
4833 heads: vec![],
4834 gapped: false,
4835 banlist_persist: None,
4836 banned_at: Default::default(),
4837 }
4838 }
4839}
4840
4841fn fold_authority(community: &CommunityV2, editions: &[ParsedEdition], floors: &Floors) -> AuthoritySet {
4848 use crate::community::roles::Permissions;
4849 use std::collections::BTreeMap;
4850
4851 let cid = community.id();
4852 let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
4853 let owner = community.owner().ok();
4854 let owner_hex = owner.map(|o| o.to_hex());
4855 let banlist_eid = super::derive::banlist_locator(cid);
4856 let banlist_hex = crate::simd::hex::bytes_to_hex_32(&banlist_eid);
4857
4858 let mut groups: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
4859 for e in editions {
4860 if e.vsk == vsk::ROLE || e.vsk == vsk::GRANT || e.vsk == vsk::BANLIST {
4861 groups.entry(e.entity_id).or_default().push(e);
4862 }
4863 }
4864
4865 let mut role_cands: BTreeMap<String, Vec<AuthorityCand>> = BTreeMap::new();
4873 let mut grant_cands: BTreeMap<String, Vec<AuthorityCand>> = BTreeMap::new();
4874 let mut gapped = false;
4875
4876 for (eid, group) in &groups {
4877 if *eid == banlist_eid {
4879 continue;
4880 }
4881 let entity_hex = crate::simd::hex::bytes_to_hex_32(eid);
4882 let fold_eds: Vec<version::Edition> = group.iter().map(|p| p.to_fold_edition()).collect();
4883 let (_hi, entity_gapped) = fold_head(&fold_eds, floors.get(&entity_hex));
4884 gapped |= entity_gapped;
4885 let floor_v = floors.get(&entity_hex).map(|f| f.0).unwrap_or(0);
4886
4887 for p in group {
4888 if p.version < floor_v {
4890 continue;
4891 }
4892 let head = FoldedHead { entity_hex: entity_hex.clone(), version: p.version, self_hash: p.self_hash, inner_id: p.inner_id };
4893 match p.vsk.as_str() {
4894 vsk::ROLE => {
4895 if let Some(role) = super::roles::parse_role_content(&p.content) {
4897 if role.role_id == entity_hex && role.position != 0 {
4898 role_cands.entry(entity_hex.clone()).or_default().push(AuthorityCand { role: Some(role), grant: None, author: p.author, head, citation: p.authority.clone() });
4899 }
4900 }
4901 }
4902 vsk::GRANT => {
4903 if let Some(mut grant) = super::roles::parse_grant_content(&p.content) {
4904 if let Some(member) = crate::simd::hex::hex_to_bytes_32_checked(&grant.member) {
4905 if super::derive::grant_locator(cid, &member) == *eid {
4906 grant.role_ids.truncate(super::roles::MAX_ROLES_PER_MEMBER);
4907 grant_cands.entry(entity_hex.clone()).or_default().push(AuthorityCand { role: None, grant: Some(grant), author: p.author, head, citation: p.authority.clone() });
4908 }
4909 }
4910 }
4911 }
4912 _ => {}
4913 }
4914 }
4915 }
4916 for cands in role_cands.values_mut().chain(grant_cands.values_mut()) {
4917 cands.sort_by(|a, b| b.head.version.cmp(&a.head.version).then(a.head.inner_id.cmp(&b.head.inner_id)));
4918 }
4919
4920 let empty: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
4921 let (prelim, prelim_heads) = select_authorized(cid, &role_cands, &grant_cands, owner_hex.as_deref(), &empty);
4924
4925 let persisted_banned: Vec<String> = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
4936 let banned_authors: std::collections::HashSet<&str> = persisted_banned.iter().map(String::as_str).collect();
4941 let banlist_authored: Vec<&ParsedEdition> = groups
4942 .get(&banlist_eid)
4943 .map(|g| {
4944 g.iter()
4945 .copied()
4946 .filter(|e| {
4947 let ah = e.author.to_hex();
4948 !banned_authors.contains(ah.as_str())
4949 && prelim.is_authorized(&ah, owner_hex.as_deref(), Permissions::BAN)
4950 && citation_ok_in_fold(cid, &prelim_heads, owner_hex.as_deref(), &e.author, e.authority.as_ref())
4951 })
4952 .collect()
4953 })
4954 .unwrap_or_default();
4955 let mut banned_at: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();
4961 for p in &banlist_authored {
4962 for t in super::roles::parse_banlist_content(&p.content).unwrap_or_default() {
4963 if owner_hex.as_deref() == Some(t.as_str()) {
4964 continue;
4965 }
4966 let slot = banned_at.entry(t).or_insert(0);
4967 *slot = (*slot).max(p.created_at);
4968 }
4969 }
4970 let mut banlist_persist: Option<(Vec<String>, u64)> = None;
4971 let mut banlist_head: Option<FoldedHead> = None;
4972 let banned: std::collections::BTreeSet<String> = if banlist_authored.is_empty() {
4973 persisted_banned.into_iter().collect()
4974 } else {
4975 let fold_eds: Vec<version::Edition> = banlist_authored.iter().map(|p| p.to_fold_edition()).collect();
4976 let (hi, g) = fold_head(&fold_eds, floors.get(&banlist_hex));
4977 gapped |= g;
4978 match hi {
4979 Some(hi) => {
4980 let head = banlist_authored[hi];
4981 let ah = head.author.to_hex();
4982 let list: Vec<String> = super::roles::parse_banlist_content(&head.content)
4983 .unwrap_or_default()
4984 .into_iter()
4985 .filter(|t| prelim.can_act_on_member(&ah, owner_hex.as_deref(), t, Permissions::BAN))
4986 .take(super::roles::MAX_BANLIST)
4987 .collect();
4988 banlist_head = Some(FoldedHead { entity_hex: banlist_hex.clone(), version: head.version, self_hash: head.self_hash, inner_id: head.inner_id });
4989 banlist_persist = Some((list.clone(), head.version));
4990 list.into_iter().collect()
4991 }
4992 None => persisted_banned.into_iter().collect(),
4993 }
4994 };
4995
4996 let (mut authorized, mut heads) = select_authorized(cid, &role_cands, &grant_cands, owner_hex.as_deref(), &banned);
5000 if let Some(bh) = banlist_head {
5001 heads.push(bh);
5002 }
5003
5004 if authorized.roles.len() > super::roles::MAX_ROLES_PER_COMMUNITY {
5009 authorized.roles.sort_by(|a, b| a.role_id.cmp(&b.role_id));
5010 authorized.roles.truncate(super::roles::MAX_ROLES_PER_COMMUNITY);
5011 let kept: std::collections::HashSet<&str> = authorized.roles.iter().map(|r| r.role_id.as_str()).collect();
5012 authorized.grants.iter_mut().for_each(|g| g.role_ids.retain(|rid| kept.contains(rid.as_str())));
5013 authorized.grants.retain(|g| !g.role_ids.is_empty());
5014 }
5015
5016 AuthoritySet { roles: authorized, banned, heads, gapped, banlist_persist, banned_at }
5017}
5018
5019struct AuthorityCand {
5022 role: Option<crate::community::roles::Role>,
5023 grant: Option<crate::community::roles::MemberGrant>,
5024 author: PublicKey,
5025 head: FoldedHead,
5026 citation: Option<crate::community::edition::AuthorityCitation>,
5029}
5030
5031fn citation_ok_in_fold(
5040 cid: &crate::community::CommunityId,
5041 heads: &[FoldedHead],
5042 owner_hex: Option<&str>,
5043 author: &PublicKey,
5044 citation: Option<&crate::community::edition::AuthorityCitation>,
5045) -> bool {
5046 let actor_hex = author.to_hex();
5047 if owner_hex == Some(actor_hex.as_str()) {
5048 return true;
5049 }
5050 let grant_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(cid, &author.to_bytes()));
5051 let as_entity: Vec<crate::community::roster::EntityHead> = heads
5052 .iter()
5053 .map(|h| crate::community::roster::EntityHead {
5054 entity_hex: h.entity_hex.clone(),
5055 version: h.version,
5056 self_hash: h.self_hash,
5057 inner_id: h.inner_id,
5058 citation: None,
5059 })
5060 .collect();
5061 crate::community::roster::authority_citation_satisfied(&as_entity, owner_hex, &actor_hex, &grant_hex, citation)
5062}
5063
5064fn select_authorized(
5073 cid: &crate::community::CommunityId,
5074 role_cands: &std::collections::BTreeMap<String, Vec<AuthorityCand>>,
5075 grant_cands: &std::collections::BTreeMap<String, Vec<AuthorityCand>>,
5076 owner_hex: Option<&str>,
5077 excluded: &std::collections::BTreeSet<String>,
5078) -> (crate::community::roles::CommunityRoles, Vec<FoldedHead>) {
5079 use crate::community::roles::{CommunityRoles, Permissions};
5080 let mut accepted = CommunityRoles::default();
5081 let mut heads: Vec<FoldedHead> = Vec::new();
5082 let bound = 2 * (role_cands.len() + grant_cands.len()) + 8;
5087 for _ in 0..bound {
5088 let mut next = CommunityRoles::default();
5089 let mut next_heads: Vec<FoldedHead> = Vec::new();
5090
5091 for cands in role_cands.values() {
5092 let mut admissible: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
5105 let mut standing: Option<u32> = None;
5106 let mut i = cands.len();
5107 while i > 0 {
5108 let hi = i;
5109 let ver = cands[i - 1].head.version;
5110 while i > 0 && cands[i - 1].head.version == ver {
5111 i -= 1;
5112 }
5113 for c in cands[i..hi].iter().rev() {
5115 let Some(role) = &c.role else { continue };
5116 let ah = c.author.to_hex();
5117 if excluded.contains(&ah) || role.position == 0 {
5118 continue;
5119 }
5120 if !accepted.can_act_on_position(&ah, owner_hex, role.position, Permissions::MANAGE_ROLES) {
5121 continue;
5122 }
5123 if let Some(prev) = standing {
5124 if !accepted.can_act_on_position(&ah, owner_hex, prev, Permissions::MANAGE_ROLES) {
5125 continue;
5126 }
5127 }
5128 if !citation_ok_in_fold(cid, &heads, owner_hex, &c.author, c.citation.as_ref()) {
5129 continue;
5130 }
5131 admissible.insert(c.head.self_hash);
5132 standing = Some(role.position);
5133 break;
5134 }
5135 }
5136 for c in cands {
5137 let Some(role) = &c.role else { continue };
5138 if !admissible.contains(&c.head.self_hash) {
5139 continue;
5140 }
5141 next.roles.push(role.clone());
5142 next_heads.push(c.head.clone());
5143 break; }
5145 }
5146 for cands in grant_cands.values() {
5147 for c in cands {
5148 let Some(grant) = &c.grant else { continue };
5149 let ah = c.author.to_hex();
5150 if excluded.contains(&ah) || excluded.contains(&grant.member) {
5151 continue;
5152 }
5153 let positions: Option<Vec<u32>> = grant.role_ids.iter().map(|rid| accepted.role(rid).map(|r| r.position)).collect();
5156 let Some(positions) = positions else { continue };
5157 if !citation_ok_in_fold(cid, &heads, owner_hex, &c.author, c.citation.as_ref()) {
5158 continue;
5159 }
5160 if positions.iter().all(|p| accepted.can_act_on_position(&ah, owner_hex, *p, Permissions::MANAGE_ROLES))
5161 && accepted.can_act_on_member(&ah, owner_hex, &grant.member, Permissions::MANAGE_ROLES)
5162 {
5163 next_heads.push(c.head.clone());
5167 if !grant.role_ids.is_empty() {
5168 next.grants.push(grant.clone());
5169 }
5170 break;
5171 }
5172 }
5173 }
5174
5175 let converged = next.roles == accepted.roles && next.grants == accepted.grants;
5176 accepted = next;
5177 heads = next_heads;
5178 if converged {
5179 break;
5180 }
5181 }
5182 (accepted, heads)
5183}
5184
5185fn apply_community_metadata(out: &mut CommunityV2, meta: control::CommunityMetadata) -> bool {
5189 let mut changed = false;
5190 if out.name != meta.name {
5191 out.name = meta.name;
5192 changed = true;
5193 }
5194 if out.description != meta.description {
5195 out.description = meta.description;
5196 changed = true;
5197 }
5198 if out.icon != meta.icon {
5202 out.icon = meta.icon;
5203 changed = true;
5204 }
5205 if out.banner != meta.banner {
5206 out.banner = meta.banner;
5207 changed = true;
5208 }
5209 if out.meta_custom != meta.custom {
5212 out.meta_custom = meta.custom;
5213 changed = true;
5214 }
5215 if out.meta_extra != meta.extra {
5216 out.meta_extra = meta.extra;
5217 changed = true;
5218 }
5219 let relays = crate::community::cap_relays(meta.relays);
5227 if !relays.is_empty() && out.relays != relays {
5228 out.relays = relays;
5229 changed = true;
5230 }
5231 changed
5232}
5233
5234fn apply_channel_metadata(out: &mut CommunityV2, id: ChannelId, meta: control::ChannelMetadata) -> bool {
5239 let deleted = meta.deleted.unwrap_or(false);
5240 if deleted {
5241 let before = out.channels.len();
5242 out.channels.retain(|c| c.id.0 != id.0);
5243 return out.channels.len() != before;
5244 }
5245 match out.channels.iter_mut().find(|c| c.id.0 == id.0) {
5246 Some(existing) => {
5247 let mut changed = false;
5248 if existing.name != meta.name {
5249 existing.name = meta.name;
5250 changed = true;
5251 }
5252 if existing.voice != meta.voice {
5255 existing.voice = meta.voice;
5256 changed = true;
5257 }
5258 if existing.meta_custom != meta.custom {
5259 existing.meta_custom = meta.custom;
5260 changed = true;
5261 }
5262 if existing.meta_extra != meta.extra {
5263 existing.meta_extra = meta.extra;
5264 changed = true;
5265 }
5266 if !meta.private && (existing.private || existing.key.is_some()) {
5275 existing.private = false;
5276 existing.key = None;
5277 changed = true;
5278 }
5279 changed
5280 }
5281 None if !meta.private => {
5282 out.channels.push(ChannelV2 {
5285 id,
5286 name: meta.name,
5287 private: false,
5288 key: None,
5289 epoch: out.root_epoch,
5290 voice: meta.voice,
5291 meta_custom: meta.custom,
5292 meta_extra: meta.extra,
5293 });
5294 true
5295 }
5296 None => {
5297 out.channels.push(ChannelV2 {
5303 id,
5304 name: meta.name,
5305 private: true,
5306 key: None,
5307 epoch: Epoch(0),
5308 voice: meta.voice,
5309 meta_custom: meta.custom,
5310 meta_extra: meta.extra,
5311 });
5312 true
5313 }
5314 }
5315}
5316
5317pub struct RekeyFollow {
5321 pub updated: Option<CommunityV2>,
5324 pub self_removed: bool,
5327 pub dissolved: bool,
5330}
5331
5332const MAX_ADDRESSING_ROOTS: usize = 8;
5339
5340pub(crate) fn channel_rekey_addressing_roots(cur_root: [u8; 32], cid_hex: &str) -> Vec<[u8; 32]> {
5349 let mut roots: Vec<[u8; 32]> = vec![cur_root];
5350 let mut archived = crate::db::community::held_epoch_keys(cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX)
5351 .unwrap_or_default();
5352 archived.sort_by(|a, b| b.0 .0.cmp(&a.0 .0));
5353 for (_, r) in archived {
5354 if !roots.contains(&r) {
5355 roots.push(r);
5356 }
5357 }
5358 roots.truncate(MAX_ADDRESSING_ROOTS);
5359 roots
5360}
5361
5362#[cfg(debug_assertions)]
5397pub async fn debug_explain_base_rekey<T: Transport + ?Sized>(
5398 transport: &T,
5399 community: &CommunityV2,
5400) -> Result<serde_json::Value, String> {
5401 let my_xonly = me_pk()?.to_bytes();
5402 let owner = community.owner()?;
5403 let owner_hex = owner.to_hex();
5404 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5405 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
5406 let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
5407 let held_epoch = community.root_epoch;
5408 let held_key = community.community_root;
5409 let next = Epoch(held_epoch.0.saturating_add(1));
5410 let group = base_rekey_group_key(&held_key, community.id(), next);
5411 let chunks = fetch_rekey_chunks(transport, &community.relays, &group).await?;
5412 let rotations = rekey::collect_rotations(&chunks);
5413
5414 let reports: Vec<serde_json::Value> = rotations
5415 .iter()
5416 .map(|r| {
5417 let rotator_is_owner = r.rotator == owner;
5418 let rotator_authorized = rotator_is_owner
5421 || (!banned.contains(&r.rotator.to_hex())
5422 && roster.is_authorized(&r.rotator.to_hex(), Some(&owner_hex), crate::community::roles::Permissions::BAN));
5423 let scope_ok = r.scope.id32() == rekey::RekeyScope::Root.id32();
5424 let epoch_ok = r.new_epoch.0 == next.0;
5425 let complete = r.is_complete();
5426 let continuity = format!("{:?}", r.continuity(held_epoch, &held_key));
5427 let has_my_blob = rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &my_xonly, r.scope, r.new_epoch).is_some();
5428 let owner_kept = r.rotator == owner
5433 || rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &owner.to_bytes(), r.scope, r.new_epoch).is_some();
5434 let verdict = if !rotator_authorized {
5436 "REJECTED: rotator holds no BAN authority in the folded roster"
5437 } else if !scope_ok {
5438 "REJECTED: scope is not Root"
5439 } else if !epoch_ok {
5440 "REJECTED: new_epoch != held+1"
5441 } else if !complete {
5442 "WAIT: rotation incomplete (missing chunk) — never concludes removal"
5443 } else if continuity != "Extends" {
5444 "REJECTED: continuity does not extend my held root (FORK/GAP)"
5445 } else if has_my_blob {
5446 "ADOPT: authorized + complete + continuous + my blob present"
5447 } else {
5448 "REMOVED: complete authorized rotation with no blob for me"
5449 };
5450 serde_json::json!({
5451 "rotator": r.rotator.to_hex(),
5452 "rotator_is_recorded_owner": rotator_is_owner,
5453 "rotator_authorized_ban": rotator_authorized,
5454 "scope_is_root": scope_ok,
5455 "new_epoch": r.new_epoch.0,
5456 "prev_epoch": r.prev_epoch.0,
5457 "declared_chunks": r.declared_chunks,
5458 "held_chunks": r.held_chunks.iter().copied().collect::<Vec<_>>(),
5459 "is_complete": complete,
5460 "continuity_vs_held_root": continuity,
5461 "my_blob_present": has_my_blob,
5462 "owner_kept": owner_kept,
5463 "blob_count": r.blobs.len(),
5464 "verdict": verdict,
5465 })
5466 })
5467 .collect();
5468
5469 Ok(serde_json::json!({
5470 "recorded_owner": owner.to_hex(),
5471 "held_root_epoch": held_epoch.0,
5472 "probing_next_epoch": next.0,
5473 "base_plane_pk": group.pk_hex(),
5474 "raw_chunks_parsed": chunks.len(),
5475 "rotations_found": rotations.len(),
5476 "rotations": reports,
5477 }))
5478}
5479
5480pub async fn follow_rekeys<T: Transport + ?Sized>(
5481 transport: &T,
5482 community: &CommunityV2,
5483 session: &SessionGuard,
5484) -> Result<RekeyFollow, String> {
5485 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5488 if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
5489 return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: true });
5490 }
5491 if is_dissolved(transport, community).await {
5497 if session.is_valid() {
5498 let _ = crate::db::community::set_community_dissolved(&cid_hex);
5499 }
5500 return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: true });
5501 }
5502 let signer = crate::signer::active_signer()?;
5503 let my_pk = me_pk()?;
5504 let my_xonly = my_pk.to_bytes();
5505 let owner = community.owner()?;
5506 let owner_hex = owner.to_hex();
5507 let mut cur = community.clone();
5508 let mut changed = false;
5509
5510 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
5522 let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
5523 let me_hex = my_pk.to_hex();
5524 let cited_ok = |rot: &rekey::Rotation| -> bool {
5533 citation_is_synced(&cid_hex, &owner_hex, &rot.rotator.to_hex(), rot.citation.as_ref())
5534 };
5535 let channel_rotator_ok = |rotator: &PublicKey| -> bool {
5536 if *rotator == owner {
5537 return true;
5538 }
5539 let rh = rotator.to_hex();
5540 !banned.contains(&rh) && roster.is_authorized(&rh, Some(&owner_hex), crate::community::roles::Permissions::MANAGE_CHANNELS)
5541 };
5542 let channel_rotator_outranks_me = |rotator: &PublicKey| -> bool {
5547 if *rotator == owner {
5548 return true;
5549 }
5550 let rh = rotator.to_hex();
5551 !banned.contains(&rh) && roster.can_act_on_member(&rh, Some(&owner_hex), &me_hex, crate::community::roles::Permissions::MANAGE_CHANNELS)
5552 };
5553 let base_rotator_ok = |rotator: &PublicKey| -> bool {
5559 if *rotator == owner {
5560 return true;
5561 }
5562 let rh = rotator.to_hex();
5563 !banned.contains(&rh) && roster.is_authorized(&rh, Some(&owner_hex), crate::community::roles::Permissions::BAN)
5564 };
5565 let base_rotator_outranks_me = |rotator: &PublicKey| -> bool {
5571 if *rotator == owner {
5572 return true;
5573 }
5574 let rh = rotator.to_hex();
5575 !banned.contains(&rh) && roster.can_act_on_member(&rh, Some(&owner_hex), &me_hex, crate::community::roles::Permissions::BAN)
5576 };
5577
5578 const MAX_STEPS: usize = 128;
5582 for _ in 0..MAX_STEPS {
5583 let mut advanced = false;
5584
5585 let addressing_roots = channel_rekey_addressing_roots(cur.community_root, &cid_hex);
5590
5591 let channel_ids: Vec<ChannelId> = cur.channels.iter().filter(|c| c.private).map(|c| c.id).collect();
5594 for cid in channel_ids {
5595 let (held_key, held_epoch) = match cur.channel(&cid) {
5596 Some(ch) => (ch.key, ch.epoch),
5597 None => continue,
5598 };
5599 let next = Epoch(held_epoch.0.saturating_add(1));
5600 let ch_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
5601 let mut batches: Vec<(Vec<rekey::RekeyChunk>, Option<(Epoch, [u8; 32])>)> = Vec::new();
5602 for (ri, root) in addressing_roots.iter().enumerate() {
5605 let group = channel_rekey_group_key(root, &cid, next);
5606 let chunks = match fetch_rekey_chunks(transport, &cur.relays, &group).await {
5607 Ok(c) => c,
5608 Err(e) => {
5609 crate::log_warn!(
5610 "[v2:follow {}] ch {} next e{} root#{}/{}: rekey plane fetch failed: {}",
5611 &cid_hex[..8], &ch_hex[..8], next.0, ri, addressing_roots.len(), e
5612 );
5613 return Err(e);
5614 }
5615 };
5616 if chunks.is_empty() {
5617 continue;
5618 }
5619 crate::log_debug!(
5620 "[v2:follow {}] ch {} next e{} root#{}/{}: {} rekey chunk(s)",
5621 &cid_hex[..8], &ch_hex[..8], next.0, ri, addressing_roots.len(), chunks.len()
5622 );
5623 batches.push((chunks, held_key.map(|k| (held_epoch, k))));
5624 }
5625 match advance_scope(&batches, RekeyScope::Channel(cid), &channel_rotator_ok, &channel_rotator_outranks_me, &cited_ok, &signer, &my_xonly, next).await {
5632 Advance::Adopt { new_key } => {
5633 if let Some(ch) = cur.channels.iter_mut().find(|c| c.id.0 == cid.0) {
5634 ch.key = Some(new_key);
5635 ch.epoch = next;
5636 }
5637 crate::log_debug!("[v2:follow {}] ch {} ADOPTED e{}", &cid_hex[..8], &ch_hex[..8], next.0);
5638 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) {
5643 crate::log_warn!("v2: channel epoch-key archive failed (history across this rotation may not read back): {e}");
5644 }
5645 advanced = true;
5646 changed = true;
5647 }
5648 Advance::Removed => {
5649 match held_key {
5650 Some(_) => {
5652 cur.channels.retain(|c| c.id.0 != cid.0);
5653 }
5654 None => {
5659 if let Some(ch) = cur.channels.iter_mut().find(|c| c.id.0 == cid.0) {
5660 ch.epoch = next;
5661 }
5662 }
5663 }
5664 advanced = true;
5665 changed = true;
5666 }
5667 Advance::Stay => {}
5668 }
5669 }
5670
5671 {
5675 let held_epoch = cur.root_epoch;
5676 let held_key = cur.community_root;
5677 let next = Epoch(held_epoch.0.saturating_add(1));
5678 let group = base_rekey_group_key(&cur.community_root, cur.id(), next);
5679 let chunks = fetch_rekey_chunks(transport, &cur.relays, &group).await?;
5680 let batches = vec![(chunks, Some((held_epoch, held_key)))];
5681 let base_admissible = |r: &rekey::Rotation| -> bool {
5688 if r.rotator == owner {
5689 return true; }
5691 if !cited_ok(r) {
5695 return false;
5696 }
5697 let rotator_hex = r.rotator.to_hex();
5698 let has_blob = |xonly: &[u8; 32]| {
5699 rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), xonly, r.scope, r.new_epoch).is_some()
5700 };
5701 if !has_blob(&owner.to_bytes()) {
5703 return false;
5704 }
5705 for g in &roster.grants {
5706 if g.member == rotator_hex || g.member == owner_hex || banned.contains(&g.member) {
5707 continue; }
5709 if !roster.can_act_on_member(&rotator_hex, Some(&owner_hex), &g.member, crate::community::roles::Permissions::BAN) {
5711 if let Ok(pk) = PublicKey::from_hex(&g.member) {
5712 if !has_blob(&pk.to_bytes()) {
5713 return false; }
5715 }
5716 }
5717 }
5718 true
5719 };
5720 match advance_scope(&batches, RekeyScope::Root, &base_rotator_ok, &base_rotator_outranks_me, &base_admissible, &signer, &my_xonly, next).await {
5721 Advance::Adopt { new_key } => {
5722 cur.community_root = new_key;
5723 cur.root_epoch = next;
5724 if let Err(e) = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, next.0, &new_key) {
5728 crate::log_warn!("v2: base epoch-key archive failed (this epoch's history may not read back after the next rotation): {e}");
5729 }
5730 advanced = true;
5731 changed = true;
5732 }
5733 Advance::Removed => {
5734 if !session.is_valid() {
5735 return Err("account changed during rekey follow".to_string());
5736 }
5737 return Ok(RekeyFollow { updated: None, self_removed: true, dissolved: false });
5738 }
5739 Advance::Stay => {}
5740 }
5741 }
5742
5743 if !advanced {
5744 break;
5745 }
5746 }
5747
5748 if !changed {
5749 return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: false });
5750 }
5751 if !session.is_valid() {
5752 return Err("account changed during rekey follow".to_string());
5753 }
5754 if crate::db::community::community_protocol(community.id())?.is_none() {
5757 return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: false });
5758 }
5759 crate::db::community::save_community_v2(&cur)?;
5760 let _ = refresh_public_links(transport, &cur).await;
5768 Ok(RekeyFollow { updated: Some(cur), self_removed: false, dissolved: false })
5769}
5770
5771enum Advance {
5774 Adopt { new_key: [u8; 32] },
5776 Removed,
5778 Stay,
5780}
5781
5782async fn fetch_rekey_chunks<T: Transport + ?Sized>(
5784 transport: &T,
5785 relays: &[String],
5786 group: &GroupKey,
5787) -> Result<Vec<rekey::RekeyChunk>, String> {
5788 const REKEY_PAGE: usize = 200;
5796 const REKEY_MAX_PAGES: usize = 6;
5797 let mut out = Vec::new();
5798 let mut seen: std::collections::HashSet<nostr_sdk::prelude::EventId> = std::collections::HashSet::new();
5799 let mut until: Option<u64> = None;
5800 let mut oldest: Option<u64> = None;
5801 for _ in 0..REKEY_MAX_PAGES {
5802 let query = Query {
5803 kinds: vec![stream::KIND_WRAP],
5804 authors: vec![group.pk_hex()],
5805 until,
5806 limit: Some(REKEY_PAGE),
5807 ..Default::default()
5808 };
5809 let wraps = transport.fetch_plane(group.keys(), &query, relays).await?;
5814 let mut fresh = 0usize;
5815 for w in &wraps {
5816 if !seen.insert(w.id) {
5817 continue;
5818 }
5819 fresh += 1;
5820 let at = w.created_at.as_secs();
5821 if oldest.is_none_or(|o| at < o) {
5822 oldest = Some(at);
5823 }
5824 if let Ok(opened) = stream::open_wrap(w, group) {
5825 if let Ok(chunk) = rekey::parse_rekey_chunk(&opened) {
5826 out.push(chunk);
5827 }
5828 }
5829 }
5830 if fresh == 0 || wraps.len() < REKEY_PAGE {
5833 break;
5834 }
5835 match oldest {
5836 Some(o) if o > 0 => until = Some(o),
5837 _ => break,
5838 }
5839 }
5840 Ok(out)
5841}
5842
5843async fn advance_scope<S: crate::signer::VectorSigner + ?Sized>(
5857 batches: &[(Vec<rekey::RekeyChunk>, Option<(Epoch, [u8; 32])>)],
5858 scope: RekeyScope,
5859 rotator_ok: &(dyn Fn(&PublicKey) -> bool + Sync),
5860 rotator_may_remove_me: &(dyn Fn(&PublicKey) -> bool + Sync),
5861 admissible: &(dyn Fn(&rekey::Rotation) -> bool + Sync),
5862 signer: &S,
5863 my_xonly: &[u8; 32],
5864 next_epoch: Epoch,
5865) -> Advance {
5866 let mut winners: Vec<[u8; 32]> = Vec::new();
5867 let mut saw_complete_candidate = false;
5868 let mut saw_outranking_candidate = false;
5869 let keyed = batches.iter().any(|(_, held)| held.is_some());
5870 for (chunks, held) in batches {
5871 let rotations = rekey::collect_rotations(chunks);
5872 for r in &rotations {
5873 if !rotator_ok(&r.rotator) || r.scope.id32() != scope.id32() || r.new_epoch.0 != next_epoch.0 || !r.is_complete() {
5874 continue;
5875 }
5876 if let Some((held_epoch, held_key)) = held {
5877 if r.continuity(*held_epoch, held_key) != Continuity::Extends {
5878 continue;
5879 }
5880 }
5881 if !admissible(r) {
5887 continue;
5888 }
5889 saw_complete_candidate = true;
5890 saw_outranking_candidate |= rotator_may_remove_me(&r.rotator);
5891 if let Some(blob) = rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), my_xonly, r.scope, r.new_epoch) {
5892 if let Ok(k) = rekey::open_blob(signer, &r.rotator, r.scope, r.new_epoch, blob).await {
5893 winners.push(k);
5894 }
5895 }
5896 }
5897 }
5898 if !winners.is_empty() {
5899 let idx = rekey::lowest_key_winner(&winners).expect("winners is non-empty");
5907 return Advance::Adopt { new_key: winners[idx] };
5908 }
5909 if saw_complete_candidate && (!keyed || saw_outranking_candidate) {
5910 Advance::Removed
5911 } else {
5912 Advance::Stay
5913 }
5914}
5915
5916#[derive(Debug, serde::Serialize)]
5920pub struct ChannelPins {
5921 pub pins: Vec<super::pins::VerifiedPin>,
5923 pub sealed: bool,
5927 pub version: u64,
5929}
5930
5931fn channel_conv_key_at(community: &CommunityV2, ch: &ChannelV2, epoch: u64) -> Option<[u8; 32]> {
5935 let ikm = channel_conv_ikm(community, ch, epoch).ok()?;
5936 if ch.private && ikm == community.community_root {
5939 return None;
5940 }
5941 let group = channel_group_key(&ikm, &ch.id, Epoch(epoch));
5942 group.conv_key().as_bytes().try_into().ok()
5943}
5944
5945pub fn read_channel_pins(community: &CommunityV2, channel_id: &ChannelId) -> Result<ChannelPins, String> {
5949 let ch = community.channel(channel_id).ok_or("no such channel in this community")?;
5950 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5951 let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
5952 let Some((content, version)) = crate::db::community::get_community_pins(&cid_hex, &ch_hex)? else {
5953 return Ok(ChannelPins { pins: Vec::new(), sealed: false, version: 0 });
5954 };
5955 let read = super::pins::read_pin_list(&content, |epoch| channel_conv_key_at(community, ch, epoch));
5956 let pins = read
5957 .entries
5958 .iter()
5959 .filter_map(|e| super::pins::verify_pin_entry(e, &ch_hex))
5960 .collect();
5961 Ok(ChannelPins { pins, sealed: read.sealed, version: version.max(0) as u64 })
5962}
5963
5964async fn publish_pin_list<T: Transport + ?Sized>(
5968 transport: &T,
5969 community: &CommunityV2,
5970 session: &SessionGuard,
5971 ch: &ChannelV2,
5972 entries: &[super::pins::PinEntry],
5973) -> Result<(), String> {
5974 let content = if ch.private {
5975 let key = ch.key.ok_or("this private channel's key has not arrived yet")?;
5976 let group = channel_group_key(&key, &ch.id, ch.epoch);
5977 super::pins::serialize_sealed_pin_list(entries, group.conv_key(), ch.epoch.0)?
5978 } else {
5979 super::pins::serialize_public_pin_list(entries)?
5980 };
5981 let eid = super::derive::pins_locator(community.id(), &ch.id);
5982 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5983 let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
5984 let entity_hex = crate::simd::hex::bytes_to_hex_32(&eid);
5985 let version = match crate::db::community::get_edition_head(&cid_hex, &entity_hex)? {
5987 Some((v, _)) => v + 1,
5988 None => 1,
5989 };
5990 crate::log_info!("[pins] publishing v{} with {} entries for channel {}", version, entries.len(), &ch_hex[..12]);
5991 publish_control_edition(transport, community, session, vsk::PINS, &eid, &content).await?;
5992 if session.is_valid() {
5993 let _ = crate::db::community::set_community_pins(&cid_hex, &ch_hex, &content, version as i64);
5994 crate::emit_event(
5995 "community_pins_updated",
5996 &serde_json::json!({ "community_id": cid_hex, "channel_id": ch_hex }),
5997 );
5998 if let Ok(me) = me_pk() {
5999 use nostr_sdk::prelude::ToBech32;
6000 let me_npub = me.to_bech32().unwrap_or_else(|_| me.to_hex());
6001 note_pins_modified(&ch_hex, version, &me_npub, now_ms() / 1000).await;
6002 }
6003 }
6004 Ok(())
6005}
6006
6007async fn note_pins_modified(channel_hex: &str, version: u64, actor_npub: &str, at_secs: u64) {
6012 let event_id = format!("pins-mod-{}-v{}", &channel_hex[..16], version);
6013 let inserted = crate::db::events::save_system_event_at(
6014 &event_id,
6015 channel_hex,
6016 crate::stored_event::SystemEventType::PinsModified,
6017 actor_npub,
6018 None,
6019 at_secs,
6020 None,
6021 None,
6022 )
6023 .await
6024 .unwrap_or(false);
6025 if inserted {
6026 crate::emit_event(
6027 "system_event",
6028 &serde_json::json!({
6029 "conversation_id": channel_hex,
6030 "event_id": event_id,
6031 "event_type": crate::stored_event::SystemEventType::PinsModified.as_u8(),
6032 "member_pubkey": actor_npub,
6033 }),
6034 );
6035 }
6036}
6037
6038fn writable_pin_entries(community: &CommunityV2, channel_id: &ChannelId) -> Result<Vec<super::pins::PinEntry>, String> {
6042 let current = read_channel_pins(community, channel_id)?;
6043 if current.sealed {
6044 return Err("this channel's pins are sealed under a key you don't hold; pinning would erase them".to_string());
6045 }
6046 Ok(current.pins.into_iter().map(|p| p.entry).collect())
6047}
6048
6049pub async fn pin_message<T: Transport + ?Sized>(
6054 transport: &T,
6055 community: &CommunityV2,
6056 channel_id: &ChannelId,
6057 rumor_id_hex: &str,
6058) -> Result<(), String> {
6059 let session = SessionGuard::capture();
6060 let ch = community.channel(channel_id).ok_or("no such channel in this community")?.clone();
6061 let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
6062
6063 let mut entries = writable_pin_entries(community, channel_id)?;
6064 if entries.len() >= super::pins::PIN_MAX_ENTRIES {
6065 return Err(format!("this channel already holds {} pins; unpin one first", super::pins::PIN_MAX_ENTRIES));
6066 }
6067 if entries
6069 .iter()
6070 .filter_map(|e| super::pins::verify_pin_entry(e, &ch_hex))
6071 .any(|v| v.rumor_id == rumor_id_hex)
6072 {
6073 return Ok(());
6074 }
6075
6076 let (wrap_id, _tags) = crate::db::events::get_event_wrap_context(rumor_id_hex)?
6077 .ok_or("message not found in this device's history")?;
6078 let wrap_id = wrap_id.ok_or("this message's original wrap id was not recorded")?;
6079
6080 let wraps = transport
6083 .fetch(
6084 &Query {
6085 ids: vec![wrap_id.clone()],
6086 kinds: vec![super::stream::KIND_WRAP],
6087 limit: Some(1),
6088 evidence: crate::community::transport::Evidence::Full,
6089 ..Default::default()
6090 },
6091 &community.relays,
6092 )
6093 .await?;
6094 let wrap = wraps
6095 .iter()
6096 .find(|w| w.id.to_hex() == wrap_id)
6097 .ok_or("the message's wrap is no longer served by this community's relays")?;
6098
6099 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6105 let mut coords: Vec<(u64, [u8; 32])> = Vec::new();
6106 if ch.private {
6107 if let Some(k) = ch.key {
6108 coords.push((ch.epoch.0, k));
6109 }
6110 let held = crate::db::community::held_epoch_keys(&cid_hex, &ch_hex).unwrap_or_default();
6111 coords.extend(held.into_iter().filter(|(_, k)| *k != community.community_root).map(|(ep, k)| (ep.0, k)));
6112 } else {
6113 coords.push((community.root_epoch.0, community.community_root));
6114 let held = crate::db::community::held_epoch_keys(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap_or_default();
6115 coords.extend(held.into_iter().map(|(ep, k)| (ep.0, k)));
6116 }
6117 coords.dedup();
6118
6119 let mut found: Option<(super::stream::OpenedStream, [u8; 32])> = None;
6120 for (epoch, ikm) in coords {
6121 let group = channel_group_key(&ikm, &ch.id, Epoch(epoch));
6122 if let Ok(super::chat::ChatEvent::Message { opened, .. }) =
6123 super::chat::open_chat_event(wrap, &group, channel_id, Epoch(epoch))
6124 {
6125 if opened.rumor_id.to_hex() == rumor_id_hex {
6126 let conv: [u8; 32] = group
6127 .conv_key()
6128 .as_bytes()
6129 .try_into()
6130 .map_err(|_| "conversation key size".to_string())?;
6131 found = Some((opened, conv));
6132 break;
6133 }
6134 }
6135 }
6136 let Some((opened, conv_key)) = found else {
6137 return Err("that message is from a key epoch this device no longer holds".to_string());
6138 };
6139
6140 let entry = super::pins::build_pin_entry(&opened, &conv_key, &ch_hex).map_err(|e| match e {
6141 super::pins::PinBuildFailure::NotEncrypted => "this message's seal form cannot be pinned".to_string(),
6142 super::pins::PinBuildFailure::BadPayload => "that message is from a key epoch this device no longer holds".to_string(),
6143 super::pins::PinBuildFailure::Unverifiable => "this message's proof did not verify".to_string(),
6144 })?;
6145 entries.push(entry);
6146
6147 if !session.is_valid() {
6148 return Err("account changed before the pin was published".to_string());
6149 }
6150 publish_pin_list(transport, community, &session, &ch, &entries).await
6151}
6152
6153fn channel_conv_ikm(community: &CommunityV2, ch: &ChannelV2, epoch: u64) -> Result<[u8; 32], String> {
6156 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6157 if ch.private {
6158 if ch.epoch.0 == epoch {
6159 return ch.key.ok_or("this private channel's key has not arrived yet".to_string());
6160 }
6161 let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
6162 crate::db::community::held_epoch_keys(&cid_hex, &ch_hex)
6163 .unwrap_or_default()
6164 .into_iter()
6165 .find(|(ep, _)| ep.0 == epoch)
6166 .map(|(_, k)| k)
6167 .ok_or("that message is from a key epoch this device no longer holds".to_string())
6168 } else if community.root_epoch.0 == epoch {
6169 Ok(community.community_root)
6170 } else {
6171 crate::db::community::held_epoch_keys(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX)
6172 .unwrap_or_default()
6173 .into_iter()
6174 .find(|(ep, _)| ep.0 == epoch)
6175 .map(|(_, k)| k)
6176 .ok_or("that message is from a root epoch this device no longer holds".to_string())
6177 }
6178}
6179
6180pub async fn unpin_message<T: Transport + ?Sized>(
6182 transport: &T,
6183 community: &CommunityV2,
6184 channel_id: &ChannelId,
6185 rumor_id_hex: &str,
6186) -> Result<(), String> {
6187 let session = SessionGuard::capture();
6188 let ch = community.channel(channel_id).ok_or("no such channel in this community")?.clone();
6189 let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
6190 let entries = writable_pin_entries(community, channel_id)?;
6191 let kept: Vec<super::pins::PinEntry> = entries
6192 .into_iter()
6193 .filter(|e| {
6194 super::pins::verify_pin_entry(e, &ch_hex)
6195 .map(|v| v.rumor_id != rumor_id_hex)
6196 .unwrap_or(true)
6199 })
6200 .collect();
6201 publish_pin_list(transport, community, &session, &ch, &kept).await
6202}
6203
6204pub(crate) fn spawn_pin_duty(channel_hex: &str, target_rumor_hex: &str, edit: Option<super::stream::OpenedStream>) {
6208 let channel_hex = channel_hex.to_string();
6209 let target = target_rumor_hex.to_string();
6210 let session = SessionGuard::capture();
6211 tokio::spawn(async move {
6212 let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
6213 let _ = run_pin_duty(&transport, &channel_hex, &target, edit, session).await;
6214 });
6215}
6216
6217async fn run_pin_duty<T: Transport + ?Sized>(
6224 transport: &T,
6225 channel_hex: &str,
6226 target: &str,
6227 edit: Option<super::stream::OpenedStream>,
6228 session: SessionGuard,
6229) -> Result<(), String> {
6230 use crate::community::roles::Permissions;
6231 let Some(cid_hex) = crate::db::community::community_id_for_channel(channel_hex)? else {
6232 return Ok(());
6233 };
6234 let cid = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&cid_hex));
6235 let Some(community) = crate::db::community::load_community_v2(&cid)? else {
6236 return Ok(());
6237 };
6238 let channel_id = ChannelId(crate::simd::hex::hex_to_bytes_32(channel_hex));
6239
6240 let read = read_channel_pins(&community, &channel_id)?;
6242 if read.sealed {
6243 return Ok(());
6244 }
6245 let Some(hit) = read.pins.iter().find(|p| p.rumor_id == target) else {
6246 return Ok(());
6247 };
6248 if let Some(ed) = &edit {
6249 if hit.edited.as_ref().is_some_and(|held| held.ms >= ed.at_ms) {
6251 return Ok(());
6252 }
6253 }
6254 let me = me_pk()?;
6255 let me_hex = me.to_hex();
6256 let owner_hex = community.owner()?.to_hex();
6257 let roster = crate::db::community::get_community_roles(&cid_hex)?;
6258 if !roster.is_authorized(&me_hex, Some(&owner_hex), Permissions::PIN_MESSAGES) {
6259 return Ok(());
6260 }
6261
6262 if hit.author != me_hex {
6263 let mut h: u32 = 0;
6264 for b in me_hex.bytes().chain(target.bytes()) {
6265 h = h.wrapping_mul(31).wrapping_add(u32::from(b));
6266 }
6267 tokio::time::sleep(std::time::Duration::from_secs(5 + u64::from(h % 21))).await;
6268 if !session.is_valid() {
6269 return Ok(());
6270 }
6271 }
6272
6273 let read = read_channel_pins(&community, &channel_id)?;
6275 if read.sealed {
6276 return Ok(());
6277 }
6278 let Some(hit) = read.pins.iter().find(|p| p.rumor_id == target) else {
6279 return Ok(());
6280 };
6281 let ch = community.channel(&channel_id).ok_or("no such channel")?.clone();
6282
6283 let entries: Vec<super::pins::PinEntry> = match &edit {
6284 None => read
6286 .pins
6287 .iter()
6288 .filter(|p| p.rumor_id != target)
6289 .map(|p| p.entry.clone())
6290 .collect(),
6291 Some(ed) => {
6293 if hit.edited.as_ref().is_some_and(|held| held.ms >= ed.at_ms) {
6294 return Ok(());
6295 }
6296 let ch_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
6297 let mut bundle = None;
6300 let mut epochs: Vec<u64> = vec![if ch.private { ch.epoch.0 } else { community.root_epoch.0 }];
6301 let scope = if ch.private { ch_hex.clone() } else { crate::community::SERVER_ROOT_SCOPE_HEX.to_string() };
6302 epochs.extend(
6303 crate::db::community::held_epoch_keys(&cid_hex, &scope)
6304 .unwrap_or_default()
6305 .into_iter()
6306 .map(|(ep, _)| ep.0),
6307 );
6308 epochs.dedup();
6309 for epoch in epochs {
6310 let Some(conv) = channel_conv_key_at(&community, &ch, epoch) else { continue };
6311 if let Ok(b) = super::pins::build_pin_edit_bundle(ed, &conv, &hit.author, target, &ch_hex) {
6312 bundle = Some(b);
6313 break;
6314 }
6315 }
6316 let Some(bundle) = bundle else { return Ok(()) };
6317 read.pins
6318 .iter()
6319 .map(|p| {
6320 let mut entry = p.entry.clone();
6321 if p.rumor_id == target {
6322 entry.edit = Some(bundle.clone());
6323 }
6324 entry
6325 })
6326 .collect()
6327 }
6328 };
6329
6330 if !session.is_valid() {
6331 return Ok(());
6332 }
6333 crate::log_info!(
6334 "[pins] duty {} for target {} in channel {}",
6335 if edit.is_some() { "edit-refresh" } else { "omission" },
6336 &target[..12],
6337 &channel_hex[..12]
6338 );
6339 publish_pin_list(transport, &community, &session, &ch, &entries).await
6340}
6341
6342pub async fn upgrade_admin_role_pin_bit<T: Transport + ?Sized>(
6347 transport: &T,
6348 community: &CommunityV2,
6349) -> Result<bool, String> {
6350 use crate::community::roles::{Permissions, RoleScope};
6351 let my_pk = me_pk()?;
6352 if community.owner()? != my_pk {
6353 return Ok(false);
6354 }
6355 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6356 let roles = crate::db::community::get_community_roles(&cid_hex)?;
6357 let Some(role) = roles.roles.iter().find(|r| {
6358 matches!(r.scope, RoleScope::Server)
6359 && r.permissions.contains(Permissions::ADMIN_FOUNDING_MASK)
6360 && !r.permissions.contains(Permissions::PIN_MESSAGES)
6361 }) else {
6362 return Ok(false);
6363 };
6364 let mut widened = role.clone();
6365 widened.permissions.0 |= Permissions::PIN_MESSAGES;
6366 set_role(transport, community, &widened).await?;
6367 Ok(true)
6368}
6369
6370#[cfg(test)]
6371mod tests {
6372 use crate::ClientRelayExt;
6373 use nostr_sdk::prelude::FinalizeEvent;
6374 use super::super::super::transport::memory::MemoryRelay;
6375 use super::*;
6376 use crate::community::roles::{MemberGrant, Permissions, Role, RoleScope};
6377
6378 fn account_name(n: u32) -> String {
6380 const B: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
6381 let mut acct = String::from("npub1");
6382 let mut v = n as usize;
6383 for _ in 0..58 {
6384 acct.push(B[v % 32] as char);
6385 v = v / 32 + 7;
6386 }
6387 acct
6388 }
6389
6390 struct Actor {
6392 keys: Keys,
6393 account: String,
6394 }
6395
6396 struct TestBed {
6402 _tmp: tempfile::TempDir,
6403 _guard: std::sync::MutexGuard<'static, ()>,
6404 relay: MemoryRelay,
6405 relays: Vec<String>,
6406 }
6407
6408 impl TestBed {
6409 fn new() -> (TestBed, Actor, Actor) {
6410 static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(70_000);
6411 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
6412 crate::db::close_database();
6413 crate::db::clear_id_caches();
6414 let tmp = tempfile::tempdir().unwrap();
6415 crate::db::set_app_data_dir(tmp.path().to_path_buf());
6416
6417 let mk = || {
6418 let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6419 let account = account_name(n);
6420 std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
6421 crate::db::set_current_account(account.clone()).unwrap();
6422 crate::db::init_database(&account).unwrap();
6423 Actor { keys: Keys::generate(), account }
6424 };
6425 let owner = mk();
6426 let member = mk();
6427 let _ = crate::state::take_nostr_client();
6428 let bed = TestBed {
6429 _tmp: tmp,
6430 _guard: guard,
6431 relay: MemoryRelay::new(),
6432 relays: vec!["wss://r".to_string()],
6433 };
6434 (bed, owner, member)
6435 }
6436
6437 fn swap_to(&self, actor: &Actor) {
6442 crate::state::bump_session_generation();
6443 crate::db::set_current_account(actor.account.clone()).unwrap();
6444 crate::db::init_database(&actor.account).unwrap();
6445 crate::db::clear_id_caches();
6446 crate::state::MY_SECRET_KEY.store_from_keys(&actor.keys, &[]);
6447 crate::state::set_my_public_key(actor.keys.public_key());
6448 }
6449 }
6450
6451 fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>, Keys) {
6453 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
6454 crate::db::close_database();
6455 crate::db::clear_id_caches();
6456 let tmp = tempfile::tempdir().unwrap();
6457 static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(50_000);
6458 let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6459 let acct = account_name(n);
6460 std::fs::create_dir_all(tmp.path().join(&acct)).unwrap();
6461 crate::db::set_app_data_dir(tmp.path().to_path_buf());
6462 crate::db::set_current_account(acct.clone()).unwrap();
6463 crate::db::init_database(&acct).unwrap();
6464 let _ = crate::state::take_nostr_client();
6465 let owner = Keys::generate();
6466 crate::state::MY_SECRET_KEY.store_from_keys(&owner, &[]);
6467 crate::state::set_my_public_key(owner.public_key());
6468 (tmp, guard, owner)
6469 }
6470
6471 struct SwapMidFetch {
6474 inner: MemoryRelay,
6475 }
6476 #[async_trait::async_trait]
6477 impl Transport for SwapMidFetch {
6478 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
6479 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
6480 self.inner.publish(e, r).await
6481 }
6482 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
6483 self.inner.publish_durable(e, r).await
6484 }
6485 async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> {
6486 let out = self.inner.fetch(q, r).await;
6487 crate::state::bump_session_generation();
6488 out
6489 }
6490 }
6491
6492 struct SwapMidPublish {
6495 inner: MemoryRelay,
6496 }
6497 #[async_trait::async_trait]
6498 impl Transport for SwapMidPublish {
6499 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
6500 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
6501 self.inner.publish(e, r).await
6502 }
6503 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
6504 let out = self.inner.publish_durable(e, r).await;
6505 crate::state::bump_session_generation();
6506 out
6507 }
6508 async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> {
6509 self.inner.fetch(q, r).await
6510 }
6511 }
6512
6513 struct FixedFetch {
6518 events: Vec<Event>,
6519 }
6520 #[async_trait::async_trait]
6521 impl Transport for FixedFetch {
6522 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
6523 async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
6524 Ok(())
6525 }
6526 async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
6527 Ok(())
6528 }
6529 async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
6530 Ok(self.events.clone())
6531 }
6532 }
6533
6534 async fn fetch_direct_invite(relay: &MemoryRelay, relays: &[String], me: &PublicKey) -> Event {
6537 let q = Query {
6538 kinds: vec![stream::KIND_WRAP],
6539 p_tags: vec![me.to_hex()],
6540 k_tags: vec!["3313".to_string()],
6541 ..Default::default()
6542 };
6543 relay.fetch(&q, relays).await.unwrap().into_iter().next().expect("a direct invite is waiting")
6544 }
6545
6546 #[tokio::test]
6547 async fn create_persists_and_reloads_a_v2_community() {
6548 let (_tmp, _guard, owner) = init_test_db();
6549 let relay = MemoryRelay::new();
6550 let relays = vec!["wss://r".to_string()];
6551
6552 let created = create_community(&relay, "Vectorville", relays.clone(), Some("hi".into())).await.unwrap();
6553 assert!(created.identity.verify());
6554 assert_eq!(created.owner().unwrap(), owner.public_key());
6555 assert_eq!(created.channels.len(), 1);
6556
6557 assert_eq!(
6559 crate::db::community::community_protocol(created.id()).unwrap(),
6560 Some(crate::community::ConcordProtocol::V2)
6561 );
6562 let loaded = crate::db::community::load_community_v2(created.id()).unwrap().expect("reloads");
6563 assert_eq!(loaded.name, "Vectorville");
6564 assert_eq!(loaded.community_root, created.community_root);
6565 assert_eq!(loaded.identity, created.identity);
6566 assert_eq!(loaded.channels[0].id.0, created.channels[0].id.0);
6567 assert!(!loaded.channels[0].private);
6568
6569 assert!(relay.count_on("wss://r") >= 3, "2 genesis editions + 1 guestbook join");
6571 }
6572
6573 #[tokio::test]
6574 async fn owner_sends_and_reads_back_a_message() {
6575 let (_tmp, _guard, _owner) = init_test_db();
6576 let relay = MemoryRelay::new();
6577 let community = create_community(&relay, "Chat", vec!["wss://r".into()], None).await.unwrap();
6578 let general = community.channels[0].id;
6579
6580 let id1 = send_message(&relay, &community, &general, "hello world").await.unwrap();
6581 let id2 = send_message(&relay, &community, &general, "second message").await.unwrap();
6582 assert_ne!(id1, id2);
6583
6584 let page = fetch_channel(&relay, &community, &general, 100).await.unwrap();
6585 let texts: Vec<String> = page
6586 .iter()
6587 .filter_map(|f| match &f.event {
6588 ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
6589 _ => None,
6590 })
6591 .collect();
6592 assert_eq!(texts, vec!["hello world", "second message"], "messages round-trip in ms order");
6593 }
6594
6595 #[tokio::test]
6596 async fn a_second_member_reads_the_public_channel_from_the_root() {
6597 let (_tmp, _guard, _owner) = init_test_db();
6601 let relay = MemoryRelay::new();
6602 let community = create_community(&relay, "Public", vec!["wss://r".into()], None).await.unwrap();
6603 let general = community.channels[0].id;
6604 send_message(&relay, &community, &general, "everyone can read this").await.unwrap();
6605
6606 let member_view = community.clone();
6608 let page = fetch_channel(&relay, &member_view, &general, 100).await.unwrap();
6609 assert_eq!(page.len(), 1);
6610 assert!(matches!(&page[0].event, ChatEvent::Message { .. }));
6611 assert_eq!(page[0].event.opened().rumor.content, "everyone can read this");
6612 }
6613
6614 async fn texts_in<T: crate::community::transport::Transport + ?Sized>(relay: &T, community: &CommunityV2, channel: &ChannelId) -> Vec<String> {
6617 fetch_channel(relay, community, channel, 100)
6618 .await
6619 .unwrap()
6620 .iter()
6621 .filter_map(|f| match &f.event {
6622 ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
6623 _ => None,
6624 })
6625 .collect()
6626 }
6627
6628 #[tokio::test]
6629 async fn direct_invite_full_loop_owner_and_member_converse() {
6630 let (bed, owner, member) = TestBed::new();
6631
6632 bed.swap_to(&owner);
6634 let community = create_community(&bed.relay, "Guild", bed.relays.clone(), None).await.unwrap();
6635 let general = community.channels[0].id;
6636 send_message(&bed.relay, &community, &general, "owner: welcome!").await.unwrap();
6637 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
6638
6639 bed.swap_to(&member);
6641 assert!(
6642 crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
6643 "the member does not hold the community before joining"
6644 );
6645 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
6646 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
6647 assert_eq!(joined.id().0, community.id().0, "joined the same community");
6648 assert!(joined.identity.verify(), "the joiner independently verifies the owner commitment");
6649 assert_eq!(joined.owner().unwrap(), owner.keys.public_key());
6650
6651 assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: welcome!"]);
6653 send_message(&bed.relay, &joined, &general, "member: thanks for the invite").await.unwrap();
6654
6655 bed.swap_to(&owner);
6657 assert_eq!(
6658 texts_in(&bed.relay, &community, &general).await,
6659 vec!["owner: welcome!", "member: thanks for the invite"],
6660 "both actors' messages interleave in ms order on the shared channel"
6661 );
6662
6663 let members = memberlist(&bed.relay, &community).await.unwrap();
6665 assert!(members.contains(&owner.keys.public_key()), "owner is a member (genesis Join)");
6666 assert!(members.contains(&member.keys.public_key()), "member is a member (invite Join)");
6667 assert_eq!(members.len(), 2);
6668 }
6669
6670 #[tokio::test]
6674 async fn a_banned_member_is_refused_at_join_time() {
6675 let (bed, owner, member) = TestBed::new();
6676
6677 bed.swap_to(&owner);
6678 let community = create_community(&bed.relay, "NoEntry", bed.relays.clone(), None).await.unwrap();
6679 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
6680 set_banlist(&bed.relay, &community, &[member.keys.public_key().to_hex()]).await.unwrap();
6681
6682 bed.swap_to(&member);
6683 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
6684 let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
6685 assert!(err.contains("banned"), "refusal names the reason: {err}");
6686 assert!(
6687 crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
6688 "a refused join persists nothing"
6689 );
6690
6691 bed.swap_to(&owner);
6694 set_banlist(&bed.relay, &community, &[]).await.unwrap();
6695 bed.swap_to(&member);
6696 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
6697 assert_eq!(joined.id().0, community.id().0, "unban restores joinability");
6698 }
6699
6700 #[tokio::test]
6704 async fn member_migrates_v1_to_v2_from_the_dissolution_payload() {
6705 use crate::community::migration;
6706 let (bed, owner, member) = TestBed::new();
6707
6708 bed.swap_to(&owner);
6710 let v2 = create_community(&bed.relay, "Guild v2", bed.relays.clone(), None).await.unwrap();
6711 let v2_hex = crate::simd::hex::bytes_to_hex_32(&v2.identity.community_id.0);
6712 let jm = join_material(&v2);
6713
6714 bed.swap_to(&member);
6717 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6718 let v1_cid = v1.id.to_hex();
6719 v1.owner_attestation = Some({
6720 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
6721 .finalize(&owner.keys).unwrap().as_json()
6722 });
6723 crate::db::community::save_community(&v1).unwrap();
6724 let v1_channel = v1.channels[0].id.to_hex();
6725
6726 let m = migration::seal_m(v1.server_root_key.as_bytes(), &serde_json::to_vec(&jm).unwrap()).unwrap();
6728 let signpost = migration::MigrationSignpost {
6729 v2_community_id: v2_hex.clone(),
6730 owner_xonly: owner.keys.public_key().to_hex(),
6731 owner_salt: crate::simd::hex::bytes_to_hex_32(&v2.identity.owner_salt),
6732 relays: bed.relays.clone(),
6733 name: "Guild".into(),
6734 primary_channel: v1_channel.clone(),
6735 root_epoch: 0,
6736 };
6737 let content = migration::build_migration_content(&signpost, Some(m)).unwrap();
6738 crate::db::community::set_migration_pointer(&v1_cid, &content).unwrap();
6739
6740 let flipped = migration::drive_migration(&bed.relay, &v1).await.unwrap();
6742 assert_eq!(flipped.as_deref(), Some(v2_hex.as_str()), "the flip completed to the v2 twin");
6743
6744 assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()));
6746 assert!(crate::db::community::get_community_dissolved(&v1_cid).unwrap(), "flip also seals v1 (fence layer 0)");
6747 assert!(crate::db::community::load_community_v2(&v2.identity.community_id).unwrap().is_some(), "v2 twin held");
6748 let _ = v1_channel;
6749
6750 assert_eq!(migration::drive_migration(&bed.relay, &v1).await.unwrap(), None);
6752 }
6753
6754 #[tokio::test]
6759 async fn owner_wizard_then_member_migrate_and_stitch() {
6760 use crate::community::migration;
6761 let (bed, owner, member) = TestBed::new();
6762 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
6763
6764 bed.swap_to(&owner);
6766 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6767 let v1_cid = v1.id.to_hex();
6768 let v1_channel = v1.channels[0].id.to_hex();
6769 v1.owner_attestation = Some({
6770 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
6771 .finalize(&owner.keys).unwrap().as_json()
6772 });
6773 crate::db::community::save_community(&v1).unwrap();
6774
6775 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
6777 assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()),
6778 "owner's own client flipped to v2");
6779 assert_eq!(crate::db::community::community_id_for_channel(&v1_channel).unwrap().as_deref(), Some(v2_hex.as_str()),
6782 "owner channel stitched to v2");
6783
6784 bed.swap_to(&member);
6786 let mut m_v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6787 m_v1.id = v1.id;
6789 m_v1.server_root_key = v1.server_root_key.clone();
6790 m_v1.channels[0].id = v1.channels[0].id;
6791 m_v1.owner_attestation = v1.owner_attestation.clone();
6792 crate::db::community::save_community(&m_v1).unwrap();
6793
6794 crate::community::service::fetch_and_apply_control(&bed.relay, &m_v1).await.unwrap();
6797 assert!(crate::db::community::get_community_dissolved(&v1_cid).unwrap(), "member sees v1 sealed");
6798 assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()),
6799 "the FOLD ITSELF flipped the member (auto-drive)");
6800 assert!(crate::db::community::load_community_v2(&crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&v2_hex))).unwrap().is_some(),
6801 "member holds the v2 twin");
6802 assert_eq!(migration::drive_migration(&bed.relay, &m_v1).await.unwrap(), None);
6804 }
6805
6806 #[tokio::test]
6811 async fn wizard_publishes_the_twin_to_the_cross_device_list() {
6812 use crate::community::migration;
6813 let (bed, owner, _member) = TestBed::new();
6814 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
6815
6816 bed.swap_to(&owner);
6817 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6818 let v1_cid = v1.id.to_hex();
6819 v1.owner_attestation = Some({
6820 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
6821 .finalize(&owner.keys).unwrap().as_json()
6822 });
6823 crate::db::community::save_community(&v1).unwrap();
6824
6825 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
6826
6827 let list = fetch_community_list(&bed.relay, &bed.relays).await.unwrap()
6829 .expect("the wizard published a community list");
6830 assert!(list.is_live(&v2_hex), "the twin must be live in the cross-device list");
6831 assert!(
6835 !list.tombstones.iter().any(|t| t.community_id == v1_cid),
6836 "migration must not tombstone the v1 community"
6837 );
6838 }
6839
6840 #[tokio::test]
6845 async fn wizard_refuses_while_a_drive_holds_the_claim() {
6846 use crate::community::migration;
6847 let (bed, owner, _member) = TestBed::new();
6848 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
6849
6850 bed.swap_to(&owner);
6851 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6852 let v1_cid = v1.id.to_hex();
6853 v1.owner_attestation = Some({
6854 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
6855 .finalize(&owner.keys).unwrap().as_json()
6856 });
6857 crate::db::community::save_community(&v1).unwrap();
6858
6859 migration::test_hold_drive_claim(&v1_cid);
6861 let err = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap_err();
6862 assert!(err.contains("already in progress"), "second wizard refused, got: {err}");
6863 assert!(crate::db::community::get_migration_ledger(&v1_cid).unwrap().is_none(), "no ledger row was written");
6865 assert!(crate::db::community::get_migrated_to(&v1_cid).unwrap().is_none(), "no flip happened");
6866
6867 migration::test_release_drive_claim(&v1_cid);
6869 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
6870 assert_eq!(crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(), Some(v2_hex.as_str()));
6871 }
6872
6873 #[tokio::test]
6879 async fn wizard_flip_waits_for_an_in_flight_follow_pass() {
6880 use crate::community::migration;
6881 let (bed, owner, _member) = TestBed::new();
6882 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
6883 let relay = std::sync::Arc::new(MemoryRelay::new());
6885
6886 bed.swap_to(&owner);
6887 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6888 let v1_cid = v1.id.to_hex();
6889 let v1_channel = v1.channels[0].id.to_hex();
6890 v1.owner_attestation = Some({
6891 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
6892 .finalize(&owner.keys).unwrap().as_json()
6893 });
6894 crate::db::community::save_community(&v1).unwrap();
6895
6896 let twin = create_migration_twin(
6899 &*relay, "Guild", bed.relays.clone(), None,
6900 (v1.channels[0].id, "general".to_string()),
6901 ).await.unwrap();
6902 let v2_id = twin.identity.community_id;
6903 let v2_hex = crate::simd::hex::bytes_to_hex_32(&v2_id.0);
6904 crate::db::community::set_migration_ledger(&v1_cid, &v2_hex, migration::PHASE_TWIN_MINTED, "").unwrap();
6905
6906 let held = crate::community::v2::realtime::follow_lock(&v2_id).lock_owned().await;
6908
6909 let wizard = tokio::spawn({
6910 let relay = relay.clone();
6911 let v1 = v1.clone();
6912 async move { migration::migrate_community_to_v2(&*relay, &v1, unlocked).await }
6913 });
6914
6915 tokio::time::sleep(std::time::Duration::from_millis(300)).await;
6917 assert!(!wizard.is_finished(), "the flip must wait for the in-flight follow pass");
6918 assert!(
6919 crate::db::community::get_migrated_to(&v1_cid).unwrap().is_none(),
6920 "the fence must not be stamped while the follow lock is held"
6921 );
6922
6923 drop(held);
6925 let flipped = wizard.await.unwrap().unwrap();
6926 assert_eq!(flipped, v2_hex, "the wizard completed onto the SAME twin (resumed, never re-minted)");
6927 assert_eq!(
6928 crate::db::community::community_id_for_channel(&v1_channel).unwrap().as_deref(),
6929 Some(v2_hex.as_str()),
6930 "the channel row is stitched to the twin, not pruned"
6931 );
6932 }
6933
6934 #[tokio::test]
6939 async fn banned_never_cut_member_opens_m_but_cannot_migrate() {
6940 use crate::community::migration;
6941 let (bed, owner, banned) = TestBed::new();
6942 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
6943
6944 bed.swap_to(&owner);
6946 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6947 let v1_cid = v1.id.to_hex();
6948 v1.owner_attestation = Some({
6949 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
6950 .finalize(&owner.keys).unwrap().as_json()
6951 });
6952 crate::db::community::save_community(&v1).unwrap();
6953 crate::db::community::set_community_banlist(&v1_cid, &[banned.keys.public_key().to_hex()], 1_000).unwrap();
6954
6955 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
6956
6957 bed.swap_to(&banned);
6959 let mut m_v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6960 m_v1.id = v1.id;
6961 m_v1.server_root_key = v1.server_root_key.clone();
6962 m_v1.channels[0].id = v1.channels[0].id;
6963 m_v1.owner_attestation = v1.owner_attestation.clone();
6964 crate::db::community::save_community(&m_v1).unwrap();
6965 crate::community::service::fetch_and_apply_control(&bed.relay, &m_v1).await.unwrap();
6966
6967 let raw = crate::db::community::get_migration_pointer(&v1_cid).unwrap().expect("pointer lands");
6969 let payload = migration::parse_migration_payload(&raw).unwrap();
6970 assert!(payload.m.is_some());
6971 let err = migration::drive_migration(&bed.relay, &m_v1).await.unwrap_err();
6972 assert!(err.contains("banned"), "refused at the join-time ban gate: {err}");
6973 assert!(crate::db::community::get_migrated_to(&v1_cid).unwrap().is_none(), "no flip");
6974 assert!(
6975 crate::db::community::load_community_v2(&crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&v2_hex))).unwrap().is_none(),
6976 "banned member never acquires the v2 twin"
6977 );
6978 }
6979
6980 #[tokio::test]
6985 async fn wizard_resume_continues_on_the_same_twin() {
6986 use crate::community::migration;
6987 let (bed, owner, banned) = TestBed::new();
6988 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
6989
6990 bed.swap_to(&owner);
6991 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
6992 let mut sibling = v1.channels[0].clone();
6994 sibling.id = crate::community::ChannelId(crate::community::random_32());
6995 sibling.name = "offtopic".into();
6996 v1.channels.push(sibling.clone());
6997 let v1_cid = v1.id.to_hex();
6998 v1.owner_attestation = Some({
6999 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
7000 .finalize(&owner.keys).unwrap().as_json()
7001 });
7002 crate::db::community::save_community(&v1).unwrap();
7003 crate::db::community::set_community_banlist(&v1_cid, &[banned.keys.public_key().to_hex()], 1_000).unwrap();
7004
7005 let twin = create_migration_twin(&bed.relay, &v1.name, bed.relays.clone(), None, (v1.channels[0].id, "general".into())).await.unwrap();
7008 let minted_hex = crate::simd::hex::bytes_to_hex_32(&twin.identity.community_id.0);
7009 crate::db::community::set_migration_ledger(&v1_cid, &minted_hex, migration::PHASE_TWIN_MINTED, "").unwrap();
7010
7011 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
7013 assert_eq!(v2_hex, minted_hex, "no second twin was minted");
7014 let (ledger_v2, phase, _) = crate::db::community::get_migration_ledger(&v1_cid).unwrap().unwrap();
7015 assert_eq!(ledger_v2, minted_hex);
7016 assert_eq!(phase, migration::PHASE_FLIPPED);
7017 assert_eq!(
7020 crate::db::community::community_id_for_channel(&sibling.id.to_hex()).unwrap().as_deref(),
7021 Some(minted_hex.as_str()),
7022 "sibling channel re-parented by the resumed run"
7023 );
7024 let twin_reloaded = crate::db::community::load_community_v2(&twin.identity.community_id).unwrap().unwrap();
7025 let (_, _, wire_banlist) = verify_owner_root_and_reconcile(&bed.relay, twin_reloaded.clone())
7026 .await
7027 .map(|(c, h, b)| (c, h, b))
7028 .unwrap();
7029 assert!(wire_banlist.contains(&banned.keys.public_key().to_hex()),
7030 "the resumed banlist clone is folded from the twin's wire control plane");
7031
7032 crate::db::community::set_migration_ledger(&v1_cid, &minted_hex, migration::PHASE_CARRIER_PUBLISHED, "").unwrap();
7035 let healed = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
7036 assert_eq!(healed, minted_hex);
7037 let (_, phase, _) = crate::db::community::get_migration_ledger(&v1_cid).unwrap().unwrap();
7038 assert_eq!(phase, migration::PHASE_FLIPPED, "ledger healed to FLIPPED");
7039
7040 let mut v1b = crate::community::Community::create("Guild2", "general", bed.relays.clone());
7046 let v1b_cid = v1b.id.to_hex();
7047 v1b.owner_attestation = Some({
7048 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1b_cid)
7049 .finalize(&owner.keys).unwrap().as_json()
7050 });
7051 crate::db::community::save_community(&v1b).unwrap();
7052 let twin2 = create_migration_twin(&bed.relay, &v1b.name, bed.relays.clone(), None, (v1b.channels[0].id, "general".into())).await.unwrap();
7053 let twin2_hex = crate::simd::hex::bytes_to_hex_32(&twin2.identity.community_id.0);
7054 crate::db::community::set_migration_ledger(&v1b_cid, &twin2_hex, migration::PHASE_CARRIER_PUBLISHED, "").unwrap();
7055 crate::db::community::set_community_dissolved(&v1b_cid).unwrap(); let resumed = migration::migrate_community_to_v2(&bed.relay, &v1b, unlocked).await.unwrap();
7057 assert_eq!(resumed, twin2_hex, "resume past a self-seal completes, not false-terminal");
7058 assert_eq!(crate::db::community::get_migrated_to(&v1b_cid).unwrap().as_deref(), Some(twin2_hex.as_str()));
7059 }
7060
7061 #[tokio::test]
7066 async fn birth_refound_seeds_an_explicit_roster() {
7067 let (bed, owner, _m) = TestBed::new();
7068 bed.swap_to(&owner);
7069 let twin = create_migration_twin(&bed.relay, "Guild", bed.relays.clone(), None,
7070 (ChannelId(crate::community::random_32()), "general".into())).await.unwrap();
7071 assert_eq!(twin.root_epoch, Epoch(0), "twin starts at genesis");
7072 let ghost_a = Keys::generate().public_key();
7074 let ghost_b = Keys::generate().public_key();
7075
7076 let rolled = refound_at_birth(&bed.relay, &twin, &[owner.keys.public_key(), ghost_a, ghost_b]).await.unwrap();
7077 assert_eq!(rolled.root_epoch, Epoch(1), "birth refound advanced the twin to epoch 1");
7078
7079 let members = memberlist(&bed.relay, &rolled).await.unwrap();
7082 assert!(members.contains(&owner.keys.public_key()), "owner in the roster");
7083 assert!(members.contains(&ghost_a) && members.contains(&ghost_b), "never-joined members are seeded (no ghost town)");
7084
7085 let (_, _, _banlist) = verify_owner_root_and_reconcile(&bed.relay, rolled.clone()).await
7089 .expect("the epoch-1 twin verifies from its compacted control plane");
7090
7091 let again = refound_at_birth(&bed.relay, &rolled, &[owner.keys.public_key(), ghost_a, ghost_b]).await.unwrap();
7094 assert_eq!(again.root_epoch, Epoch(1), "re-running the birth refound does not advance past epoch 1");
7095 assert_eq!(crate::db::community::load_community_v2(&twin.identity.community_id).unwrap().unwrap().root_epoch, Epoch(1));
7096 }
7097
7098 #[tokio::test]
7102 async fn birth_refound_ignores_a_banned_seed_entry() {
7103 let (bed, owner, _m) = TestBed::new();
7104 bed.swap_to(&owner);
7105 let twin = create_migration_twin(&bed.relay, "Guild", bed.relays.clone(), None,
7106 (ChannelId(crate::community::random_32()), "general".into())).await.unwrap();
7107 let good = Keys::generate().public_key();
7108 let banned = Keys::generate();
7109 set_banlist(&bed.relay, &twin, &[banned.public_key().to_hex()]).await.unwrap();
7111 let twin = crate::db::community::load_community_v2(&twin.identity.community_id).unwrap().unwrap();
7112
7113 let rolled = refound_at_birth(&bed.relay, &twin, &[owner.keys.public_key(), good, banned.public_key()]).await
7114 .expect("a banned seed entry is filtered, not a permanent verify-back wedge");
7115 assert_eq!(rolled.root_epoch, Epoch(1));
7116 let members = memberlist(&bed.relay, &rolled).await.unwrap();
7117 assert!(members.contains(&good), "the non-banned seed lands");
7118 assert!(!members.contains(&banned.public_key()), "the banned seed is not a member");
7119 }
7120
7121 #[tokio::test]
7127 async fn a_seeded_member_receives_a_later_refound_rekey() {
7128 let (bed, owner, _m) = TestBed::new();
7129 bed.swap_to(&owner);
7130 let twin = create_migration_twin(&bed.relay, "Guild", bed.relays.clone(), None,
7131 (ChannelId(crate::community::random_32()), "general".into())).await.unwrap();
7132 let ghost = Keys::generate();
7133 let rolled = refound_at_birth(&bed.relay, &twin, &[owner.keys.public_key(), ghost.public_key()]).await.unwrap();
7135 assert!(memberlist(&bed.relay, &rolled).await.unwrap().contains(&ghost.public_key()), "ghost is seeded");
7136
7137 let refounded = refound_community(&bed.relay, &rolled, &[]).await.unwrap();
7143 assert_eq!(refounded.root_epoch, Epoch(2), "the later refound advanced the epoch");
7144 assert!(
7145 memberlist(&bed.relay, &refounded).await.unwrap().contains(&ghost.public_key()),
7146 "a seeded member is a recipient of + re-seeded by a later refound (never misses an epoch)"
7147 );
7148 }
7149
7150 #[tokio::test]
7153 async fn v1_admin_stays_admin_across_migration() {
7154 use crate::community::migration;
7155 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
7156 let (bed, owner, admin) = TestBed::new();
7157 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
7158
7159 bed.swap_to(&owner);
7160 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
7161 let v1_cid = v1.id.to_hex();
7162 v1.owner_attestation = Some({
7163 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
7164 .finalize(&owner.keys).unwrap().as_json()
7165 });
7166 crate::db::community::save_community(&v1).unwrap();
7167 let admin_role = Role::admin("a1".repeat(32));
7169 let roles = CommunityRoles {
7170 roles: vec![admin_role.clone()],
7171 grants: vec![MemberGrant { member: admin.keys.public_key().to_hex(), role_ids: vec![admin_role.role_id.clone()] }],
7172 };
7173 crate::db::community::set_community_roles(&v1_cid, &roles, 1_000).unwrap();
7174
7175 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
7176 let twin = crate::db::community::load_community_v2(&crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&v2_hex))).unwrap().unwrap();
7177
7178 let authority = fetch_authority(&bed.relay, &twin).await;
7180 assert!(
7181 authority.roles.is_authorized(&admin.keys.public_key().to_hex(), Some(&owner.keys.public_key().to_hex()), Permissions::MANAGE_ROLES),
7182 "the v1 admin is an admin on the v2 twin"
7183 );
7184 assert!(
7185 !authority.roles.is_authorized(&Keys::generate().public_key().to_hex(), Some(&owner.keys.public_key().to_hex()), Permissions::MANAGE_ROLES),
7186 "a non-admin gains no authority"
7187 );
7188 }
7189
7190 #[tokio::test]
7199 async fn an_unsealed_v1_that_was_migrated_away_still_heals() {
7200 use crate::community::migration;
7201 let (bed, owner, member) = TestBed::new();
7202 bed.swap_to(&owner);
7203
7204 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
7205 let v1_cid = v1.id.to_hex();
7206 v1.owner_attestation = Some({
7207 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
7208 .finalize(&owner.keys).unwrap().as_json()
7209 });
7210 crate::db::community::save_community(&v1).unwrap();
7211
7212 let unlocked = migration::MIGRATION_UNLOCK_AT + 1;
7214 let v2_hex = migration::migrate_community_to_v2(&bed.relay, &v1, unlocked).await.unwrap();
7215
7216 bed.swap_to(&member);
7219 crate::db::community::save_community(&v1).unwrap();
7220 assert!(
7221 !crate::db::community::get_community_dissolved(&v1_cid).unwrap(),
7222 "precondition: the wedged device has NOT sealed its v1 row"
7223 );
7224
7225 assert!(
7227 crate::db::community::migration_sweep_candidates().unwrap().contains(&v1_cid),
7228 "an unsealed, migrated-away v1 must be probed"
7229 );
7230
7231 migration::sweep_dissolved_for_migration(&bed.relay).await;
7232
7233 assert!(crate::db::community::get_community_dissolved(&v1_cid).unwrap(), "sealed by the sweep");
7235 assert_eq!(
7236 crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(),
7237 Some(v2_hex.as_str()),
7238 "flipped to the same twin the first device produced"
7239 );
7240 assert!(
7241 !crate::db::community::migration_sweep_candidates().unwrap().contains(&v1_cid),
7242 "and the sweep converges — no re-probing forever"
7243 );
7244 }
7245
7246 #[tokio::test]
7251 async fn sweep_marks_checked_only_on_an_owner_tombstone() {
7252 use crate::community::migration;
7253 let (bed, owner, stranger) = TestBed::new();
7254
7255 bed.swap_to(&owner);
7256 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
7257 let v1_cid = v1.id.to_hex();
7258 v1.owner_attestation = Some({
7259 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1_cid)
7260 .finalize(&owner.keys).unwrap().as_json()
7261 });
7262 crate::db::community::save_community(&v1).unwrap();
7263
7264 let inner = crate::community::roster::build_group_dissolved_edition(&stranger.keys, &v1.id, 500).unwrap();
7267 let outer = crate::community::roster::seal_dissolved_edition(&Keys::generate(), &inner, &v1.id).unwrap();
7268 bed.relay.publish_durable(&outer, &bed.relays).await.unwrap();
7269 crate::db::community::set_community_dissolved(&v1_cid).unwrap();
7270
7271 migration::sweep_dissolved_for_migration(&bed.relay).await;
7273 assert!(
7274 crate::db::community::migration_sweep_candidates().unwrap().contains(&v1_cid),
7275 "a stranger-only probe must not converge the sweep"
7276 );
7277
7278 let owner_inner = crate::community::roster::build_group_dissolved_edition(&owner.keys, &v1.id, 600).unwrap();
7280 let owner_outer = crate::community::roster::seal_dissolved_edition(&Keys::generate(), &owner_inner, &v1.id).unwrap();
7281 bed.relay.publish_durable(&owner_outer, &bed.relays).await.unwrap();
7282 migration::sweep_dissolved_for_migration(&bed.relay).await;
7283 assert!(
7284 !crate::db::community::migration_sweep_candidates().unwrap().contains(&v1_cid),
7285 "an owner plain-dissolution converges the sweep"
7286 );
7287 }
7288
7289 #[tokio::test]
7291 async fn wizard_preflight_gates_timelock_and_ownership() {
7292 use crate::community::migration;
7293 let (bed, owner, _member) = TestBed::new();
7294 bed.swap_to(&owner);
7295 let mut v1 = crate::community::Community::create("Guild", "general", bed.relays.clone());
7296 v1.owner_attestation = Some({
7297 crate::community::owner::build_owner_attestation_unsigned(owner.keys.public_key(), &v1.id.to_hex())
7298 .finalize(&owner.keys).unwrap().as_json()
7299 });
7300 crate::db::community::save_community(&v1).unwrap();
7301
7302 let err = migration::migrate_community_to_v2(&bed.relay, &v1, migration::MIGRATION_UNLOCK_AT - 1).await.unwrap_err();
7304 assert!(err.contains("not unlocked"), "{err}");
7305 assert!(crate::db::community::get_migration_ledger(&v1.id.to_hex()).unwrap().is_none(), "no ledger row before unlock");
7306 }
7307
7308 #[tokio::test]
7309 async fn public_link_full_loop() {
7310 let (bed, owner, member) = TestBed::new();
7311
7312 bed.swap_to(&owner);
7313 let community = create_community(&bed.relay, "Public Guild", bed.relays.clone(), None).await.unwrap();
7314 let general = community.channels[0].id;
7315 send_message(&bed.relay, &community, &general, "come on in").await.unwrap();
7316 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
7318 assert!(link.url.starts_with("https://vectorapp.io/invite/"));
7319 assert!(link.url.contains('#'), "the fragment carries the token");
7320
7321 bed.swap_to(&member);
7323 let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
7324 assert_eq!(joined.id().0, community.id().0);
7325 assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["come on in"]);
7326 }
7327
7328 #[test]
7329 fn bundle_of_snapshots_the_held_icon() {
7330 let owner = Keys::generate();
7331 let g = control::genesis(&owner, control::CommunityMetadata { name: "Logo".into(), ..Default::default() }, 1_000).unwrap();
7332 let mut c = CommunityV2::from_genesis(&g, "Logo", None, vec!["wss://r".into()], 0);
7333 let icon = control::ImageRef { url: "https://blossom.example/i".into(), key: "k".into(), nonce: "n".into(), hash: "h".into(), extra: Default::default() };
7334 c.icon = Some(icon.clone());
7335 let bundle = bundle_of(&c, BundleAudience::Link, None, None, None);
7336 assert_eq!(bundle.icon, Some(icon), "a parked invite renders the real logo from the mint-time snapshot");
7337 }
7338
7339 #[test]
7340 fn addressing_roots_fan_current_plus_archived_bounded_and_deduped() {
7341 let (_tmp, _guard, _owner) = init_test_db();
7346 let cur_root = [9u8; 32];
7347 let cid = crate::community::CommunityId([1u8; 32]);
7348 let cid_hex = cid.to_hex();
7349
7350 let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
7352 assert_eq!(roots, vec![cur_root], "with no archived roots the fan is the current root alone");
7353
7354 crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 0, &[1u8; 32]).unwrap();
7356 crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1, &[2u8; 32]).unwrap();
7357 let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
7358 assert_eq!(roots[0], cur_root, "current root leads");
7359 assert!(roots.contains(&[1u8; 32]) && roots.contains(&[2u8; 32]), "both archived roots are in the fan");
7360 assert_eq!(roots.len(), 3, "current + 2 archived, no dupes");
7361 assert_eq!(roots[1], [2u8; 32], "higher archived epoch is addressed before the lower");
7363
7364 crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 2, &cur_root).unwrap();
7366 let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
7367 assert_eq!(roots.iter().filter(|r| **r == cur_root).count(), 1, "the current root is never duplicated");
7368
7369 for e in 3..20u64 {
7371 crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, e, &[e as u8; 32]).unwrap();
7372 }
7373 let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
7374 assert_eq!(roots.len(), MAX_ADDRESSING_ROOTS, "the fan is bounded so a relay can't feed an unbounded walk");
7375 }
7376
7377 #[tokio::test]
7378 async fn public_link_preview_shows_live_name_and_icon_without_joining() {
7379 let (bed, owner, member) = TestBed::new();
7380
7381 bed.swap_to(&owner);
7382 let community = create_community(&bed.relay, "Soapbox", bed.relays.clone(), None).await.unwrap();
7383 let icon = control::ImageRef {
7386 url: "https://blossom.example/soap".into(),
7387 key: "k".into(),
7388 nonce: "n".into(),
7389 hash: "h".into(),
7390 extra: Default::default(),
7391 };
7392 let mut meta = community.metadata();
7393 meta.icon = Some(icon.clone());
7394 edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
7395 let link = mint_public_link(&bed.relay, &community, "https://armada.buzz", None, None).await.unwrap();
7397
7398 bed.swap_to(&member);
7400 let preview = preview_public_link(&bed.relay, &link.url).await.unwrap();
7401 assert_eq!(preview.name, "Soapbox");
7402 assert_eq!(preview.icon, Some(icon), "the icon folds from the live Control Plane, not the bundle");
7403 assert!(
7404 crate::db::community::load_community_v2(preview.id()).unwrap().is_none(),
7405 "previewing must not persist a membership"
7406 );
7407 }
7408
7409 #[tokio::test]
7410 async fn a_previewed_join_reuses_the_verified_fold() {
7411 let (bed, owner, member) = TestBed::new();
7412 bed.swap_to(&owner);
7413 let community = create_community(&bed.relay, "FastJoin", bed.relays.clone(), None).await.unwrap();
7414 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
7415
7416 bed.swap_to(&member);
7417 let _ = preview_public_link(&bed.relay, &link.url).await.unwrap();
7418 let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
7419 assert_eq!(joined.id().0, community.id().0);
7420 assert!(joined.created_at_ms > 0, "the handoff stamps the JOIN's acquisition time, not the preview's");
7421 assert!(VERIFIED_PREVIEW.lock().unwrap().is_none(), "the handoff slot must be consumed by the join");
7424 }
7425
7426 #[tokio::test]
7427 async fn guestbook_store_seeds_syncs_incrementally_and_matches_the_live_fold() {
7428 let (bed, owner, member) = TestBed::new();
7429 bed.swap_to(&owner);
7430 let community = create_community(&bed.relay, "GB", bed.relays.clone(), None).await.unwrap();
7431 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
7432
7433 bed.swap_to(&member);
7434 let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
7435
7436 let session = SessionGuard::capture();
7438 assert!(!sync_guestbook(&bed.relay, &joined, &session).await.unwrap().is_empty(), "the seed folds fresh events");
7439 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
7440 let (_, cursor) = crate::db::community::get_guestbook(&cid_hex).unwrap();
7441 assert!(cursor > 0, "the cursor advanced past zero");
7442 let stored: std::collections::BTreeSet<_> = stored_memberlist(&joined).unwrap().into_iter().collect();
7443 let live: std::collections::BTreeSet<_> = memberlist(&bed.relay, &joined).await.unwrap().into_iter().collect();
7444 assert_eq!(stored, live, "stored fold == live fold after the seed");
7445 assert!(stored.contains(&member.keys.public_key()));
7446
7447 assert!(sync_guestbook(&bed.relay, &joined, &session).await.unwrap().is_empty());
7449
7450 bed.swap_to(&owner);
7452 kick_member(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
7453 bed.swap_to(&member);
7454 let session = SessionGuard::capture();
7455 assert!(!sync_guestbook(&bed.relay, &joined, &session).await.unwrap().is_empty(), "the kick lands incrementally");
7456 assert!(
7457 !stored_memberlist(&joined).unwrap().contains(&member.keys.public_key()),
7458 "an owner kick removes the member from the stored fold"
7459 );
7460 }
7461
7462 #[tokio::test]
7463 async fn a_preview_then_revoke_still_refuses_the_join() {
7464 let (bed, owner, member) = TestBed::new();
7465 bed.swap_to(&owner);
7466 let community = create_community(&bed.relay, "RevokeRace", bed.relays.clone(), None).await.unwrap();
7467 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
7468
7469 bed.swap_to(&member);
7471 let p = preview_public_link(&bed.relay, &link.url).await.unwrap();
7472 assert_eq!(p.name, "RevokeRace");
7473 bed.swap_to(&owner);
7474 let tombstone = invite::build_revocation(&link.link_signer).unwrap();
7475 bed.relay.publish_durable(&tombstone, &bed.relays).await.unwrap();
7476
7477 bed.swap_to(&member);
7480 let err = accept_public_link(&bed.relay, &link.url).await.unwrap_err();
7481 assert!(err.contains("revoked"), "got: {err}");
7482 }
7483
7484 #[tokio::test]
7485 async fn a_revoked_link_refuses_to_join() {
7486 let (bed, owner, member) = TestBed::new();
7487 bed.swap_to(&owner);
7488 let community = create_community(&bed.relay, "Revoked", bed.relays.clone(), None).await.unwrap();
7489 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
7490 let tombstone = invite::build_revocation(&link.link_signer).unwrap();
7492 bed.relay.publish_durable(&tombstone, &bed.relays).await.unwrap();
7493
7494 bed.swap_to(&member);
7495 let err = accept_public_link(&bed.relay, &link.url).await.unwrap_err();
7496 assert!(err.contains("revoked"), "a retired link finds the grave, not keys: {err}");
7497 }
7498
7499 #[tokio::test]
7500 async fn an_expired_direct_invite_refuses_to_join() {
7501 let (bed, owner, member) = TestBed::new();
7502 bed.swap_to(&owner);
7503 let community = create_community(&bed.relay, "Expired", bed.relays.clone(), None).await.unwrap();
7504 let inviter = owner.keys.clone();
7506 let mut bundle = bundle_of(&community, BundleAudience::Link, Some(inviter.public_key()), Some(1_000), None);
7507 bundle.expires_at = Some(1_000); let wrap = invite::build_direct_invite(&inviter, &member.keys.public_key(), &bundle).unwrap();
7509 bed.relay.publish(&wrap, &bed.relays).await.unwrap();
7510
7511 bed.swap_to(&member);
7512 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
7513 let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
7514 assert!(err.contains("expired"), "a past-expiry invite refuses to join: {err}");
7515 }
7516
7517 #[tokio::test]
7518 async fn a_tombstone_beats_a_live_bundle_regardless_of_fetch_order() {
7519 let (bed, owner, member) = TestBed::new();
7523 bed.swap_to(&owner);
7524 let community = create_community(&bed.relay, "Rev", bed.relays.clone(), None).await.unwrap();
7525 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
7526 let tombstone = invite::build_revocation(&link.link_signer).unwrap();
7527
7528 let union = FixedFetch { events: vec![link.bundle_event.clone(), tombstone] };
7531
7532 bed.swap_to(&member);
7533 let err = accept_public_link(&union, &link.url).await.unwrap_err();
7534 assert!(err.contains("revoked"), "a tombstone must beat a Live returned first: {err}");
7535 }
7536
7537 #[test]
7538 fn from_bundle_refuses_an_over_cap_bundle_before_allocating() {
7539 let owner = Keys::generate();
7545 let identity = super::super::control::CommunityIdentity::mint(&owner.public_key());
7546 let hex = crate::simd::hex::bytes_to_hex_32;
7547 let root = [0x11u8; 32];
7548 let mut bundle = CommunityInvite {
7549 community_id: hex(&identity.community_id.0),
7550 owner: hex(&identity.owner_xonly),
7551 owner_salt: hex(&identity.owner_salt),
7552 community_root: hex(&root),
7553 root_epoch: 0,
7554 channels: vec![],
7555 relays: vec!["wss://r".into()],
7556 name: "X".into(),
7557 icon: None,
7558 expires_at: None,
7559 creator_npub: None,
7560 label: None,
7561 extra: Default::default(),
7562 };
7563 bundle.channels = (0..=invite::MAX_BUNDLE_CHANNELS)
7564 .map(|i| {
7565 let mut id = [0u8; 32];
7566 id[..8].copy_from_slice(&(i as u64).to_be_bytes());
7567 invite::ChannelGrant { id: hex(&id), key: hex(&root), epoch: 0, name: "x".into() }
7568 })
7569 .collect();
7570 assert!(CommunityV2::from_bundle(&bundle, 0).is_err(), "an over-cap bundle is refused before allocating");
7571 }
7572
7573 #[tokio::test]
7574 async fn a_join_swap_between_fetch_and_save_aborts_and_leaves_the_other_account_clean() {
7575 let (bed, owner, member) = TestBed::new();
7580 bed.swap_to(&owner);
7581 let community = create_community(&bed.relay, "Straddle", bed.relays.clone(), None).await.unwrap();
7582 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
7583 let swap_relay = SwapMidFetch { inner: MemoryRelay::new() };
7585 swap_relay.inner.publish_durable(&link.bundle_event, &bed.relays).await.unwrap();
7586
7587 bed.swap_to(&member);
7588 let err = accept_public_link(&swap_relay, &link.url).await.unwrap_err();
7589 assert!(err.contains("account changed"), "a swap mid-join must abort: {err}");
7590 assert!(
7591 crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
7592 "the aborted join wrote nothing to the (member) account DB"
7593 );
7594 }
7595
7596 #[tokio::test]
7597 async fn the_owner_is_a_member_even_without_a_fetched_genesis_join() {
7598 let (_tmp, _guard, owner) = init_test_db();
7601 let relay = MemoryRelay::new();
7602 let community = create_community(&relay, "Owned", vec!["wss://r".into()], None).await.unwrap();
7603 let empty = MemoryRelay::new();
7606 let members = memberlist(&empty, &community).await.unwrap();
7607 assert_eq!(members, vec![owner.public_key()], "owner present with no fetched Join");
7608 }
7609
7610 #[tokio::test]
7611 async fn an_expiring_minted_invite_refuses_after_the_deadline() {
7612 let (bed, owner, member) = TestBed::new();
7615 bed.swap_to(&owner);
7616 let community = create_community(&bed.relay, "Timed", bed.relays.clone(), None).await.unwrap();
7617 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), Some(1_000), Some("beta".into()))
7618 .await
7619 .unwrap();
7620
7621 bed.swap_to(&member);
7622 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
7623 assert!(
7624 accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err().contains("expired"),
7625 "a minted expiring invite refuses past its deadline"
7626 );
7627 }
7628
7629 #[tokio::test]
7630 async fn a_member_who_leaves_drops_from_the_memberlist() {
7631 let (bed, owner, member) = TestBed::new();
7632 bed.swap_to(&owner);
7633 let community = create_community(&bed.relay, "Leaving", bed.relays.clone(), None).await.unwrap();
7634 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
7635
7636 bed.swap_to(&member);
7637 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
7638 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
7639 tokio::time::sleep(std::time::Duration::from_millis(2)).await;
7641 leave_community(&bed.relay, &joined).await.unwrap();
7642
7643 bed.swap_to(&owner);
7644 let members = memberlist(&bed.relay, &community).await.unwrap();
7645 assert!(members.contains(&owner.keys.public_key()));
7646 assert!(!members.contains(&member.keys.public_key()), "a member who left drops from the list");
7647 }
7648
7649 #[tokio::test]
7650 async fn a_swapped_member_cannot_see_the_owners_community_until_joining() {
7651 let (bed, owner, member) = TestBed::new();
7654 bed.swap_to(&owner);
7655 let community = create_community(&bed.relay, "Private-so-far", bed.relays.clone(), None).await.unwrap();
7656 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_some());
7657
7658 bed.swap_to(&member);
7659 assert!(
7660 crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
7661 "the owner's community must be invisible in the member's account DB"
7662 );
7663 assert_eq!(crate::db::community::list_community_ids().unwrap().len(), 0);
7664 }
7665
7666 async fn head_hash_on_relay(relay: &MemoryRelay, community: &CommunityV2, entity_id: &[u8; 32]) -> Option<[u8; 32]> {
7674 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
7675 let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
7676 let wraps = relay.fetch(&query, &community.relays).await.ok()?;
7677 let mut head: Option<(u64, [u8; 32])> = None;
7678 for w in &wraps {
7679 if let Ok((ed, _)) = control::open_control_edition(w, &group) {
7680 if ed.entity_id == *entity_id && head.is_none_or(|(v, _)| ed.version > v) {
7681 head = Some((ed.version, ed.self_hash));
7682 }
7683 }
7684 }
7685 head.map(|(_, h)| h)
7686 }
7687
7688 async fn cite_on_relay(
7693 relay: &MemoryRelay,
7694 community: &CommunityV2,
7695 signer: &Keys,
7696 ) -> Option<crate::community::edition::AuthorityCitation> {
7697 if community.owner().ok() == Some(signer.public_key()) {
7698 return None;
7699 }
7700 let entity_id = crate::community::v2::derive::grant_locator(community.id(), &signer.public_key().to_bytes());
7701 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
7702 let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
7703 let wraps = relay.fetch(&query, &community.relays).await.ok()?;
7704 let mut head: Option<(u64, [u8; 32])> = None;
7705 for w in &wraps {
7706 if let Ok((ed, _)) = control::open_control_edition(w, &group) {
7707 if ed.entity_id == entity_id && head.is_none_or(|(v, _)| ed.version > v) {
7708 head = Some((ed.version, ed.self_hash));
7709 }
7710 }
7711 }
7712 head.map(|(version, edition_hash)| crate::community::edition::AuthorityCitation { entity_id, version, edition_hash })
7713 }
7714
7715 async fn publish_channel_edition(
7716 relay: &MemoryRelay,
7717 community: &CommunityV2,
7718 signer: &Keys,
7719 channel_id: &ChannelId,
7720 name: &str,
7721 private: bool,
7722 version: u64,
7723 deleted: bool,
7724 ) {
7725 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
7726 let prev = head_hash_on_relay(relay, community, &channel_id.0).await;
7727 let meta = control::ChannelMetadata { name: name.into(), private, deleted: deleted.then_some(true), ..Default::default() };
7728 let content = serde_json::to_string(&meta).unwrap();
7729 let rumor = control::build_edition_rumor(signer.public_key(), vsk::CHANNEL_METADATA, &channel_id.0, version, prev.as_ref(), &content, 1_000, None);
7730 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
7731 relay.publish(&wrap, &community.relays).await.unwrap();
7732 }
7733
7734 async fn publish_community_meta(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, name: &str, version: u64) {
7737 publish_community_meta_at(relay, community, signer, name, version, 1_000).await;
7738 }
7739
7740 async fn publish_community_meta_at(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, name: &str, version: u64, at_secs: u64) {
7743 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
7744 let prev = head_hash_on_relay(relay, community, &community.id().0).await;
7745 let meta = control::CommunityMetadata { name: name.into(), ..Default::default() };
7746 let content = serde_json::to_string(&meta).unwrap();
7747 let cite = cite_on_relay(relay, community, signer).await;
7748 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());
7749 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(at_secs)).unwrap();
7750 relay.publish(&wrap, &community.relays).await.unwrap();
7751 }
7752
7753 #[test]
7754 fn metadata_apply_captures_undriven_fields_for_republish() {
7755 let owner = Keys::generate();
7756 let g = control::genesis(&owner, control::CommunityMetadata { name: "A".into(), ..Default::default() }, 1_000).unwrap();
7757 let mut held = CommunityV2::from_genesis(&g, "A", None, vec!["wss://r".into()], 0);
7758 let general = held.channels[0].id;
7759
7760 let mut custom = serde_json::Map::new();
7762 custom.insert("accent".into(), serde_json::Value::from("#89f0b6"));
7763 let mut extra = serde_json::Map::new();
7764 extra.insert("vnd_flag".into(), serde_json::Value::Bool(true));
7765 let meta = control::CommunityMetadata { name: "A".into(), custom: Some(custom.clone()), extra: extra.clone(), ..Default::default() };
7766 assert!(apply_community_metadata(&mut held, meta), "gaining custom/extra is a change");
7767 assert_eq!(held.meta_custom, Some(custom.clone()));
7768 assert_eq!(held.meta_extra, extra);
7769 assert_eq!(held.metadata().custom, Some(custom));
7771 assert_eq!(held.metadata().extra, held.meta_extra);
7772
7773 let mut ch_custom = serde_json::Map::new();
7775 ch_custom.insert("slowmode".into(), serde_json::Value::from(30));
7776 let ch_meta = control::ChannelMetadata {
7777 name: "general".into(),
7778 private: false,
7779 voice: Some(true),
7780 deleted: None,
7781 custom: Some(ch_custom.clone()),
7782 extra: Default::default(),
7783 };
7784 assert!(apply_channel_metadata(&mut held, general, ch_meta), "gaining voice/custom is a change");
7785 let ch = held.channel(&general).unwrap();
7786 assert_eq!(ch.voice, Some(true));
7787 assert_eq!(ch.meta_custom, Some(ch_custom.clone()));
7788 let rename = { let mut d = ch.metadata(); d.name = "lounge".into(); d };
7789 assert_eq!(rename.voice, Some(true), "our rename edition carries the foreign voice flag");
7790 assert_eq!(rename.custom, Some(ch_custom));
7791 }
7792
7793 #[test]
7794 fn community_metadata_apply_sets_and_clears_images() {
7795 let owner = Keys::generate();
7796 let g = control::genesis(&owner, control::CommunityMetadata { name: "A".into(), ..Default::default() }, 1_000).unwrap();
7797 let mut held = CommunityV2::from_genesis(&g, "A", None, vec!["wss://r".into()], 0);
7798
7799 let icon = control::ImageRef {
7800 url: "https://blossom.example/i".into(),
7801 key: "k".into(),
7802 nonce: "n".into(),
7803 hash: "h".into(),
7804 extra: Default::default(),
7805 };
7806 let with_icon = control::CommunityMetadata { name: "A".into(), icon: Some(icon.clone()), ..Default::default() };
7807 assert!(apply_community_metadata(&mut held, with_icon), "gaining an icon is a change");
7808 assert_eq!(held.icon.as_ref(), Some(&icon));
7809
7810 let without = control::CommunityMetadata { name: "A".into(), ..Default::default() };
7812 assert!(apply_community_metadata(&mut held, without), "losing the icon is a change");
7813 assert_eq!(held.icon, None);
7814 }
7815
7816 async fn publish_role(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, role: &Role, version: u64) {
7818 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
7819 let role_id = crate::simd::hex::hex_to_bytes_32_checked(&role.role_id).unwrap();
7820 let prev = head_hash_on_relay(relay, community, &role_id).await;
7821 let content = crate::community::v2::roles::role_content_json(role).unwrap();
7822 let cite = cite_on_relay(relay, community, signer).await;
7823 let rumor = control::build_edition_rumor(signer.public_key(), vsk::ROLE, &role_id, version, prev.as_ref(), &content, 1_000, cite.as_ref());
7824 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
7825 relay.publish(&wrap, &community.relays).await.unwrap();
7826 }
7827
7828 async fn publish_grant(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, member: &PublicKey, role_ids: Vec<String>, version: u64) {
7830 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
7831 let eid = crate::community::v2::derive::grant_locator(community.id(), &member.to_bytes());
7832 let prev = head_hash_on_relay(relay, community, &eid).await;
7833 let grant = MemberGrant { member: member.to_hex(), role_ids };
7834 let content = crate::community::v2::roles::grant_content_json(&grant).unwrap();
7835 let cite = cite_on_relay(relay, community, signer).await;
7836 let rumor = control::build_edition_rumor(signer.public_key(), vsk::GRANT, &eid, version, prev.as_ref(), &content, 1_000, cite.as_ref());
7837 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
7838 relay.publish(&wrap, &community.relays).await.unwrap();
7839 }
7840
7841 async fn publish_banlist(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, banned: &[String], version: u64) {
7843 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
7844 let eid = crate::community::v2::derive::banlist_locator(community.id());
7845 let prev = head_hash_on_relay(relay, community, &eid).await;
7846 let content = crate::community::v2::roles::banlist_content_json(banned).unwrap();
7847 let cite = cite_on_relay(relay, community, signer).await;
7848 let rumor = control::build_edition_rumor(signer.public_key(), vsk::BANLIST, &eid, version, prev.as_ref(), &content, 1_000, cite.as_ref());
7849 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
7850 relay.publish(&wrap, &community.relays).await.unwrap();
7851 }
7852
7853 fn admin_role(role_id: &str, perms: u64) -> Role {
7854 Role { role_id: role_id.into(), name: "Admin".into(), position: 1, permissions: Permissions(perms), scope: RoleScope::Server, color: 0 }
7855 }
7856
7857 #[tokio::test]
7863 async fn a_non_owner_cannot_suppress_the_admin_role_by_forging_a_higher_version() {
7864 let (bed, owner, attacker) = TestBed::new();
7865 bed.swap_to(&owner);
7866 let community = create_community(&bed.relay, "AttackA", bed.relays.clone(), None).await.unwrap();
7867 let victim = Keys::generate().public_key();
7868 grant_admin(&bed.relay, &community, &victim).await.unwrap();
7869
7870 let admin_rid = fetch_authority(&bed.relay, &community)
7872 .await
7873 .roles
7874 .roles
7875 .iter()
7876 .find(|r| r.permissions.contains(Permissions::ADMIN_ALL))
7877 .unwrap()
7878 .role_id
7879 .clone();
7880 publish_role(
7882 &bed.relay,
7883 &community,
7884 &attacker.keys,
7885 &Role { role_id: admin_rid.clone(), name: "pwned".into(), position: 1, permissions: Permissions(0), scope: RoleScope::Server, color: 0 },
7886 2,
7887 )
7888 .await;
7889
7890 let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
7891 assert!(authority.roles.is_admin(&victim.to_hex()), "the forged strip is DROPPED; the owner's admin role survives beneath it");
7892 assert!(
7893 authority.heads.iter().any(|h| h.entity_hex == admin_rid && h.version == 1),
7894 "the floor advances only to the AUTHORIZED head (owner v1)"
7895 );
7896 assert!(!authority.heads.iter().any(|h| h.version == 2), "the forged v2 never poisons the floor");
7897 }
7898
7899 #[tokio::test]
7900 async fn a_non_owner_cannot_strip_a_members_grant_by_forging_a_higher_version() {
7901 let (bed, owner, attacker) = TestBed::new();
7902 bed.swap_to(&owner);
7903 let community = create_community(&bed.relay, "AttackC", bed.relays.clone(), None).await.unwrap();
7904 let victim = Keys::generate();
7905 grant_admin(&bed.relay, &community, &victim.public_key()).await.unwrap();
7906
7907 publish_grant(&bed.relay, &community, &attacker.keys, &victim.public_key(), vec![], 9).await;
7909
7910 let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
7911 assert!(
7912 authority.roles.is_admin(&victim.public_key().to_hex()),
7913 "the forged strip is dropped; the owner's grant survives and the victim keeps admin"
7914 );
7915 }
7916
7917 #[tokio::test]
7918 async fn forged_low_id_roles_by_a_non_owner_never_enter_the_authorized_roster() {
7919 let (bed, owner, attacker) = TestBed::new();
7920 bed.swap_to(&owner);
7921 let community = create_community(&bed.relay, "AttackB", bed.relays.clone(), None).await.unwrap();
7922 let victim = Keys::generate().public_key();
7923 grant_admin(&bed.relay, &community, &victim).await.unwrap();
7924
7925 for i in 0u8..6 {
7928 let rid = crate::simd::hex::bytes_to_hex_32(&[i; 32]);
7929 publish_role(&bed.relay, &community, &attacker.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
7930 }
7931
7932 let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
7933 assert!(authority.roles.is_admin(&victim.to_hex()), "the legit admin survives the forged flood");
7934 assert_eq!(authority.roles.roles.len(), 1, "only the owner's admin role is authorized; every forgery is dropped");
7935 }
7936
7937 fn authority_fingerprint(a: &AuthoritySet) -> String {
7940 let mut roles = a.roles.roles.clone();
7941 roles.sort_by(|x, y| x.role_id.cmp(&y.role_id));
7942 let mut grants = a.roles.grants.clone();
7943 for g in &mut grants {
7944 g.role_ids.sort();
7945 }
7946 grants.sort_by(|x, y| x.member.cmp(&y.member));
7947 let banned: Vec<&String> = a.banned.iter().collect();
7948 serde_json::json!({ "roles": roles, "grants": grants, "banned": banned }).to_string()
7949 }
7950
7951 #[tokio::test]
7952 async fn the_v2_authority_fold_is_order_independent() {
7953 let (bed, owner, _a) = TestBed::new();
7959 bed.swap_to(&owner);
7960 let community = create_community(&bed.relay, "Determinism", bed.relays.clone(), None).await.unwrap();
7961
7962 let admin1 = Keys::generate().public_key();
7965 let admin2 = Keys::generate().public_key();
7966 grant_admin(&bed.relay, &community, &admin1).await.unwrap();
7967 grant_admin(&bed.relay, &community, &admin2).await.unwrap();
7968 let mod_rid = "5c".repeat(32);
7969 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&mod_rid, Permissions::KICK | Permissions::MANAGE_MESSAGES), 1).await;
7970 let member = Keys::generate().public_key();
7971 publish_grant(&bed.relay, &community, &owner.keys, &member, vec![mod_rid.clone()], 1).await;
7972 let banned_member = Keys::generate().public_key();
7973 publish_grant(&bed.relay, &community, &owner.keys, &banned_member, vec![mod_rid], 1).await;
7974 set_banlist(&bed.relay, &community, &[banned_member.to_hex()]).await.unwrap();
7975 let meta = control::CommunityMetadata { name: "Renamed".into(), relays: community.relays.clone(), ..Default::default() };
7976 edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
7977 create_public_channel(&bed.relay, &community, "extra").await.unwrap();
7978
7979 let editions = fetch_control(&bed.relay, &community).await;
7980 let floors = load_floors(&community);
7981 assert!(editions.len() >= 6, "a rich plane was built ({} editions)", editions.len());
7982
7983 let baseline = authority_fingerprint(&fold_authority(&community, &editions, &floors));
7984
7985 let mut orders: Vec<Vec<ParsedEdition>> = Vec::new();
7988 let mut rev = editions.clone();
7989 rev.reverse();
7990 orders.push(rev);
7991 for shift in [1usize, 3, 5, 7] {
7992 let n = editions.len();
7993 orders.push((0..n).map(|i| editions[(i + shift) % n].clone()).collect());
7994 }
7995 let mut zip = Vec::with_capacity(editions.len());
7997 let (mut lo, mut hi) = (0isize, editions.len() as isize - 1);
7998 while lo <= hi {
7999 zip.push(editions[lo as usize].clone());
8000 if lo != hi {
8001 zip.push(editions[hi as usize].clone());
8002 }
8003 lo += 1;
8004 hi -= 1;
8005 }
8006 orders.push(zip);
8007
8008 for (i, order) in orders.iter().enumerate() {
8009 let got = authority_fingerprint(&fold_authority(&community, order, &floors));
8010 assert_eq!(got, baseline, "arrival order #{i} must resolve the identical authority (consensus)");
8011 }
8012 assert!(baseline.contains(&admin1.to_hex()) || baseline.contains(&member.to_hex()), "grants are present in the fingerprint");
8015 assert!(baseline.contains(&banned_member.to_hex()), "the banlist entry is in the fingerprint");
8016 }
8017
8018 struct FetchErrors(MemoryRelay);
8020 #[async_trait::async_trait]
8021 impl crate::community::transport::Transport for FetchErrors {
8022 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
8023 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
8024 self.0.publish(e, r).await
8025 }
8026 async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
8027 Err("relay down".to_string())
8028 }
8029 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
8030 self.0.publish_durable(e, r).await
8031 }
8032 }
8033
8034 #[tokio::test]
8035 async fn fetch_authority_retains_the_persisted_banlist_on_a_transport_error() {
8036 let (bed, owner, victim) = TestBed::new();
8037 bed.swap_to(&owner);
8038 let community = create_community(&bed.relay, "BanRetain", bed.relays.clone(), None).await.unwrap();
8039 let victim_hex = victim.keys.public_key().to_hex();
8040 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
8042 crate::db::community::set_community_banlist(&cid_hex, &[victim_hex.clone()], 1).unwrap();
8043
8044 let down = FetchErrors(MemoryRelay::new());
8047 let view = fetch_authority(&down, &community).await;
8048 assert!(view.banned.contains(&victim_hex), "a transport error retains the persisted banlist");
8049 }
8050
8051 #[tokio::test]
8052 async fn follow_control_retains_the_roster_when_a_floored_role_ages_out() {
8053 let (bed, owner, _m) = TestBed::new();
8054 bed.swap_to(&owner);
8055 let community = create_community(&bed.relay, "Complete", bed.relays.clone(), None).await.unwrap();
8056 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
8057 let (a, b) = (Keys::generate().public_key(), Keys::generate().public_key());
8058 let rid = crate::simd::hex::bytes_to_hex_32(&[0x7c; 32]);
8059
8060 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
8062 publish_grant(&bed.relay, &community, &owner.keys, &a, vec![rid.clone()], 1).await;
8063 publish_grant(&bed.relay, &community, &owner.keys, &b, vec![rid.clone()], 1).await;
8064 let session = crate::state::SessionGuard::capture();
8065 follow_control(&bed.relay, &community, &session).await.unwrap();
8066 assert!(crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&a.to_hex()), "seeded");
8067
8068 let relay2 = MemoryRelay::new();
8072 publish_grant(&relay2, &community, &owner.keys, &a, vec![rid.clone()], 1).await;
8073 follow_control(&relay2, &community, &session).await.unwrap();
8074 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
8075 assert!(roster.is_admin(&a.to_hex()) && roster.is_admin(&b.to_hex()), "a floored-but-unfetched role retains the stored roster");
8076 }
8077
8078 #[tokio::test]
8079 async fn an_uncited_metadata_or_banlist_edition_is_dropped() {
8080 let (_tmp, _guard, owner) = init_test_db();
8085 let relay = MemoryRelay::new();
8086 let community = create_community(&relay, "Base", vec!["wss://r".into()], None).await.unwrap();
8087 let admin = Keys::generate();
8088 let rid = "a7".repeat(32);
8089 publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA | Permissions::BAN), 1).await;
8090 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid], 1).await;
8091
8092 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
8094 let meta = control::CommunityMetadata { name: "Uncited Rename".into(), ..Default::default() };
8095 let rumor = control::build_edition_rumor(
8096 admin.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2,
8097 head_hash_on_relay(&relay, &community, &community.id().0).await.as_ref(),
8098 &serde_json::to_string(&meta).unwrap(), 1_000, None,
8099 );
8100 let (wrap, _) = control::seal_control_edition(&rumor, &group, &admin, Timestamp::from_secs(1_000)).unwrap();
8101 relay.publish(&wrap, &community.relays).await.unwrap();
8102
8103 let ban_eid = crate::community::v2::derive::banlist_locator(community.id());
8104 let victim = Keys::generate().public_key().to_hex();
8105 let ban_rumor = control::build_edition_rumor(
8106 admin.public_key(), vsk::BANLIST, &ban_eid, 1, None,
8107 &serde_json::to_string(&vec![victim.clone()]).unwrap(), 1_000, None,
8108 );
8109 let (ban_wrap, _) = control::seal_control_edition(&ban_rumor, &group, &admin, Timestamp::from_secs(1_000)).unwrap();
8110 relay.publish(&ban_wrap, &community.relays).await.unwrap();
8111
8112 let session = SessionGuard::capture();
8113 let updated = follow_control(&relay, &community, &session).await.unwrap();
8114 assert!(
8115 updated.as_ref().is_none_or(|c| c.name != "Uncited Rename"),
8116 "an uncited metadata edit must not be honored",
8117 );
8118 let authority = fetch_authority(&relay, &community).await;
8119 assert!(!authority.banned.contains(&victim), "an uncited banlist edition must not be honored");
8120 }
8127
8128 #[tokio::test]
8129 async fn an_authorized_admin_edits_metadata_but_a_demoted_one_cannot() {
8130 let (_tmp, _guard, owner) = init_test_db();
8134 let relay = MemoryRelay::new();
8135 let community = create_community(&relay, "Base", vec!["wss://r".into()], None).await.unwrap();
8136 let admin = Keys::generate();
8137 let rid = "a1".repeat(32);
8138 publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
8139 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
8140 publish_community_meta(&relay, &community, &admin, "Admin Rename", 2).await;
8141
8142 let session = SessionGuard::capture();
8143 let updated = follow_control(&relay, &community, &session).await.unwrap().expect("admin edit authorized");
8144 assert_eq!(updated.name, "Admin Rename", "an admin with MANAGE_METADATA renames");
8145
8146 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![], 2).await; publish_community_meta(&relay, &community, &admin, "Demoted Rename", 3).await;
8148 let _ = follow_control(&relay, &community, &session).await.unwrap();
8149 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8150 assert_eq!(held.name, "Admin Rename", "a demoted admin's edit is dropped; the name holds");
8151 }
8152
8153 #[tokio::test]
8154 async fn a_roleless_member_cannot_edit_metadata() {
8155 let (_tmp, _guard, _owner) = init_test_db();
8156 let relay = MemoryRelay::new();
8157 let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
8158 let stranger = Keys::generate();
8159 publish_community_meta(&relay, &community, &stranger, "Hijacked", 2).await;
8160 let session = SessionGuard::capture();
8161 assert!(
8162 follow_control(&relay, &community, &session).await.unwrap().is_none(),
8163 "a roleless member's metadata edit never folds"
8164 );
8165 }
8166
8167 #[tokio::test]
8168 async fn a_self_signed_grant_is_not_authority() {
8169 let (_tmp, _guard, _owner) = init_test_db();
8173 let relay = MemoryRelay::new();
8174 let community = create_community(&relay, "NoSelfPromo", vec!["wss://r".into()], None).await.unwrap();
8175 let rogue = Keys::generate();
8176 let rid = "b2".repeat(32);
8177 publish_role(&relay, &community, &rogue, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
8178 publish_grant(&relay, &community, &rogue, &rogue.public_key(), vec![rid.clone()], 1).await;
8179 publish_community_meta(&relay, &community, &rogue, "Seized", 2).await;
8180 let session = SessionGuard::capture();
8181 assert!(
8182 follow_control(&relay, &community, &session).await.unwrap().is_none(),
8183 "a self-signed grant confers no authority"
8184 );
8185 }
8186
8187 #[tokio::test]
8188 async fn the_banlist_is_enforced_only_from_a_ban_holder() {
8189 let (_tmp, _guard, owner) = init_test_db();
8190 let relay = MemoryRelay::new();
8191 let community = create_community(&relay, "Bans", vec!["wss://r".into()], None).await.unwrap();
8192 let target = "cc".repeat(32);
8193
8194 let rogue = Keys::generate();
8196 publish_banlist(&relay, &community, &rogue, &[target.clone()], 1).await;
8197 let floors = load_floors(&community);
8198 let editions = fetch_control(&relay, &community).await;
8199 let authority = fold_authority(&community, &editions, &floors);
8200 assert!(authority.banned.is_empty(), "a non-owner (no BAN) banlist is not enforced");
8201
8202 publish_banlist(&relay, &community, &owner, &[target.clone()], 2).await;
8204 let editions = fetch_control(&relay, &community).await;
8205 let authority = fold_authority(&community, &editions, &floors);
8206 assert!(authority.banned.contains(&target), "the owner's banlist is enforced");
8207 }
8208
8209 #[tokio::test]
8210 async fn a_banned_admin_loses_all_authority() {
8211 let (_tmp, _guard, owner) = init_test_db();
8214 let relay = MemoryRelay::new();
8215 let community = create_community(&relay, "BanAuth", vec!["wss://r".into()], None).await.unwrap();
8216 let admin = Keys::generate();
8217 let rid = "e5".repeat(32);
8218 publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
8219 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
8220 publish_banlist(&relay, &community, &owner, &[admin.public_key().to_hex()], 1).await; publish_community_meta(&relay, &community, &admin, "Banned Rename", 2).await;
8222
8223 let session = SessionGuard::capture();
8224 assert!(
8225 follow_control(&relay, &community, &session).await.unwrap().is_none(),
8226 "a banned admin's edit is dropped even with an unstripped grant"
8227 );
8228 let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
8229 assert!(authority.banned.contains(&admin.public_key().to_hex()));
8230 assert!(
8231 !authority.roles.is_authorized(&admin.public_key().to_hex(), Some(&owner.public_key().to_hex()), Permissions::MANAGE_METADATA),
8232 "a banned admin holds no bit"
8233 );
8234 }
8235
8236 #[tokio::test]
8237 async fn a_ban_holder_cannot_ban_a_superior_or_the_owner() {
8238 let (_tmp, _guard, owner) = init_test_db();
8242 let relay = MemoryRelay::new();
8243 let community = create_community(&relay, "Ranks", vec!["wss://r".into()], None).await.unwrap();
8244 let admin = Keys::generate();
8245 let moder = Keys::generate();
8246 let stranger = Keys::generate();
8247 let (admin_rid, mod_rid) = ("a1".repeat(32), "b2".repeat(32));
8248 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;
8249 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;
8250 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![admin_rid], 1).await;
8251 publish_grant(&relay, &community, &owner, &moder.public_key(), vec![mod_rid], 1).await;
8252 publish_banlist(&relay, &community, &moder, &[admin.public_key().to_hex(), owner.public_key().to_hex(), stranger.public_key().to_hex()], 1).await;
8253
8254 let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
8255 assert!(!authority.banned.contains(&admin.public_key().to_hex()), "a mod cannot ban a superior admin");
8256 assert!(!authority.banned.contains(&owner.public_key().to_hex()), "nobody can ban the owner");
8257 assert!(authority.banned.contains(&stranger.public_key().to_hex()), "the mod CAN ban a lower-ranked member");
8258 }
8259
8260 #[tokio::test]
8261 async fn an_unauthorized_higher_banlist_cannot_unban() {
8262 let (_tmp, _guard, owner) = init_test_db();
8266 let relay = MemoryRelay::new();
8267 let community = create_community(&relay, "NoUnban", vec!["wss://r".into()], None).await.unwrap();
8268 let target = "cc".repeat(32);
8269 publish_banlist(&relay, &community, &owner, &[target.clone()], 1).await;
8270 let session = SessionGuard::capture();
8271 follow_control(&relay, &community, &session).await.unwrap(); let rogue = Keys::generate();
8274 publish_banlist(&relay, &community, &rogue, &[], 2).await; let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
8276 assert!(authority.banned.contains(&target), "an unauthorized higher banlist cannot un-ban");
8277 }
8278
8279 #[tokio::test]
8280 async fn the_community_list_syncs_a_membership_to_a_fresh_device() {
8281 let (_tmp, _guard, _owner) = init_test_db();
8284 let relay = MemoryRelay::new();
8285 let relays = vec!["wss://r".to_string()];
8286 let community = create_community(&relay, "Synced", relays.clone(), None).await.unwrap();
8287 crate::db::community::delete_community(&crate::simd::hex::bytes_to_hex_32(&community.id().0)).unwrap();
8288 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none());
8289
8290 let rehydrated = sync_community_list(&relay, &relays).await.unwrap().joined;
8291 assert_eq!(rehydrated.len(), 1, "the left-behind membership rehydrates");
8292 assert_eq!(rehydrated[0].id().0, community.id().0);
8293 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_some(), "and is now held locally");
8294 }
8295
8296 #[tokio::test]
8297 async fn a_leave_tombstones_the_membership_so_sync_does_not_rejoin() {
8298 let (_tmp, _guard, _owner) = init_test_db();
8299 let relay = MemoryRelay::new();
8300 let relays = vec!["wss://r".to_string()];
8301 let community = create_community(&relay, "Left", relays.clone(), None).await.unwrap();
8302 leave_community(&relay, &community).await.unwrap(); let rehydrated = sync_community_list(&relay, &relays).await.unwrap().joined;
8305 assert!(rehydrated.is_empty(), "a tombstoned membership is not rejoined on sync");
8306 }
8307
8308 #[tokio::test]
8309 async fn accepting_the_same_bundle_twice_is_idempotent() {
8310 let (bed, owner, member) = TestBed::new();
8314 bed.swap_to(&owner);
8315 let community = create_community(&bed.relay, "Idem", bed.relays.clone(), None).await.unwrap();
8316 create_public_channel(&bed.relay, &community, "extra").await.unwrap();
8317 let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8318 let bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
8319
8320 bed.swap_to(&member);
8321 let first = accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
8322 let channels_after_first = first.channels.len();
8323 let root_after_first = first.community_root;
8324
8325 let second = accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
8327 assert_eq!(second.id().0, first.id().0, "same community_id");
8328 assert_eq!(second.channels.len(), channels_after_first, "no duplicate channels on re-accept");
8329 assert_eq!(second.community_root, root_after_first, "root unchanged");
8330
8331 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8333 assert_eq!(reloaded.channels.len(), channels_after_first, "the DB holds one clean channel set");
8334 assert_eq!(crate::db::community::list_community_ids().unwrap().iter().filter(|id| id.0 == community.id().0).count(), 1, "exactly one community row");
8335 }
8336
8337 #[tokio::test]
8338 async fn a_severed_member_can_be_unbanned_and_re_admitted() {
8339 let (bed, owner, member) = TestBed::new();
8344 bed.swap_to(&owner);
8345 let mut community = create_community(&bed.relay, "Redeemable", bed.relays.clone(), None).await.unwrap();
8346 let general = community.channels[0].id;
8347 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
8348 send_message(&bed.relay, &community, &general, "owner: welcome").await.unwrap();
8349
8350 bed.swap_to(&member);
8351 let invite = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
8352 let joined = accept_direct_invite(&bed.relay, &invite).await.unwrap();
8353 assert!(texts_in(&bed.relay, &joined, &general).await.contains(&"owner: welcome".to_string()));
8354
8355 bed.swap_to(&owner);
8357 set_banlist(&bed.relay, &community, &[member.keys.public_key().to_hex()]).await.unwrap();
8358 grant_roles(&bed.relay, &community, &member.keys.public_key(), vec![]).await.unwrap();
8359 community = refound_community(&bed.relay, &community, &[member.keys.public_key()]).await.unwrap();
8360 assert_eq!(community.root_epoch, Epoch(1));
8361 send_message(&bed.relay, &community, &general, "owner: after the ban").await.unwrap();
8362
8363 bed.swap_to(&member);
8365 let session = SessionGuard::capture();
8366 assert!(follow_rekeys(&bed.relay, &joined, &session).await.unwrap().self_removed, "the member is cryptographically severed");
8367
8368 bed.swap_to(&owner);
8372 set_banlist(&bed.relay, &community, &[]).await.unwrap();
8373 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8374 assert_eq!(community.root_epoch, Epoch(1), "the owner's bundle carries epoch 1");
8375 let fresh_bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
8376
8377 bed.swap_to(&member);
8379 let rejoined = accept_parked_invite(&bed.relay, &fresh_bundle, None).await.unwrap();
8380 assert_eq!(rejoined.root_epoch, Epoch(1), "rejoined at the current epoch");
8381 assert_eq!(rejoined.community_root, community.community_root, "holds the NEW root");
8382 let seen = texts_in(&bed.relay, &rejoined, &general).await;
8383 assert!(seen.contains(&"owner: after the ban".to_string()), "reads post-ban history with the new root");
8384 send_message(&bed.relay, &rejoined, &general, "member: i am back").await.unwrap();
8385
8386 bed.swap_to(&owner);
8387 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8388 assert!(
8389 texts_in(&bed.relay, &community, &general).await.contains(&"member: i am back".to_string()),
8390 "the re-admitted member converses again at the new epoch"
8391 );
8392 let members = memberlist(&bed.relay, &community).await.unwrap();
8394 assert!(members.contains(&member.keys.public_key()), "the re-admitted member is in the list");
8395 }
8396
8397 #[tokio::test]
8398 async fn dissolution_blocks_a_join() {
8399 let (bed, owner, member) = TestBed::new();
8402 bed.swap_to(&owner);
8403 let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
8404 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
8405 let bundle_json = serde_json::to_string(&bundle).unwrap();
8406 dissolve_community(&bed.relay, &community).await.unwrap();
8407 assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the owner's local hold is sealed");
8408
8409 bed.swap_to(&member);
8410 let err = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap_err();
8411 assert!(err.contains("dissolved"), "a join refuses a dissolved community: {err}");
8412 }
8413
8414 #[tokio::test]
8415 async fn dissolution_seals_writes_but_not_reads() {
8416 let (bed, owner, _member) = TestBed::new();
8419 bed.swap_to(&owner);
8420 let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
8421 let general = community.channels[0].id;
8422 send_message(&bed.relay, &community, &general, "before the end").await.unwrap();
8423
8424 dissolve_community(&bed.relay, &community).await.unwrap();
8425 let sealed = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8426
8427 for err in [
8428 send_message(&bed.relay, &sealed, &general, "after the end").await.unwrap_err(),
8429 send_reaction(&bed.relay, &sealed, &general, &"a".repeat(64), &"b".repeat(64), crate::community::v2::kind::MESSAGE, "+", None)
8430 .await
8431 .unwrap_err(),
8432 send_edit(&bed.relay, &sealed, &general, &"a".repeat(64), "revised").await.unwrap_err(),
8433 ] {
8434 assert!(err.contains("dissolved"), "every write is refused, got: {err}");
8435 }
8436 assert!(
8437 texts_in(&bed.relay, &sealed, &general).await.contains(&"before the end".to_string()),
8438 "but the history still reads"
8439 );
8440 }
8441
8442 #[tokio::test]
8443 async fn only_the_owner_can_dissolve() {
8444 let (bed, owner, member) = TestBed::new();
8445 bed.swap_to(&owner);
8446 let community = create_community(&bed.relay, "Mine", bed.relays.clone(), None).await.unwrap();
8447 bed.swap_to(&member);
8448 assert!(dissolve_community(&bed.relay, &community).await.is_err(), "only the owner can dissolve");
8449 assert!(!is_dissolved(&bed.relay, &community).await, "and no tombstone was published");
8450 }
8451
8452 #[tokio::test]
8453 async fn a_foreign_tombstone_is_not_death() {
8454 let (_tmp, _guard, _owner) = init_test_db();
8457 let relay = MemoryRelay::new();
8458 let community = create_community(&relay, "Safe", vec!["wss://r".into()], None).await.unwrap();
8459 let rogue = Keys::generate();
8460 let rumor = crate::community::v2::dissolution::dissolved_tombstone_rumor(rogue.public_key(), community.id(), 1_000);
8461 let wrap = crate::community::v2::dissolution::seal_dissolved(&rumor, community.id(), &rogue, Timestamp::from_secs(1_000)).unwrap();
8462 relay.publish(&wrap, &community.relays).await.unwrap();
8463 assert!(!is_dissolved(&relay, &community).await, "a foreign-signed tombstone is not death");
8464 }
8465
8466 #[tokio::test]
8467 async fn a_public_channel_reads_history_across_a_refounding() {
8468 let (_tmp, _guard, _owner) = init_test_db();
8472 let relay = MemoryRelay::new();
8473 let community = create_community(&relay, "History", vec!["wss://r".into()], None).await.unwrap();
8474 let general = community.channels[0].id;
8475 send_message(&relay, &community, &general, "before the refounding").await.unwrap();
8476
8477 let refounded = refound_community(&relay, &community, &[]).await.unwrap();
8478 assert_eq!(refounded.root_epoch, Epoch(1), "the epoch advanced");
8479 send_message(&relay, &refounded, &general, "after the refounding").await.unwrap();
8480
8481 let texts = texts_in(&relay, &refounded, &general).await;
8482 assert!(texts.contains(&"before the refounding".to_string()), "the epoch-0 message is still readable");
8483 assert!(texts.contains(&"after the refounding".to_string()), "the epoch-1 message reads too");
8484 }
8485
8486 #[tokio::test]
8487 async fn refounding_aborts_when_control_state_is_withheld() {
8488 let (_tmp, _guard, owner) = init_test_db();
8492 let relay = MemoryRelay::new();
8493 let community = create_community(&relay, "Withheld", vec!["wss://good".into()], None).await.unwrap();
8494 publish_banlist(&relay, &community, &owner, &["cc".repeat(32)], 1).await;
8495 let session = SessionGuard::capture();
8496 follow_control(&relay, &community, &session).await.unwrap(); let mut moved = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
8501 moved.relays = vec!["wss://empty".into()];
8502 crate::db::community::save_community_v2(&moved).unwrap();
8503
8504 let err = refound_community(&relay, &moved, &[]).await.unwrap_err();
8505 assert!(err.contains("was not served"), "a withheld control head aborts the refounding: {err}");
8506 assert_eq!(
8507 crate::db::community::load_community_v2(community.id()).unwrap().unwrap().root_epoch,
8508 Epoch(0),
8509 "the epoch did NOT advance (zero published state)"
8510 );
8511 }
8512
8513 #[tokio::test]
8514 async fn refounding_rolls_the_root_and_severs_a_removed_member() {
8515 let (bed, owner, member) = TestBed::new();
8518 bed.swap_to(&owner);
8519 let community = create_community(&bed.relay, "Refound", bed.relays.clone(), None).await.unwrap();
8520 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
8521 let bundle_json = serde_json::to_string(&bundle).unwrap();
8522 bed.swap_to(&member);
8523 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8524
8525 bed.swap_to(&owner);
8526 let refounded = refound_community(&bed.relay, &community, &[member.keys.public_key()]).await.unwrap();
8527 assert_eq!(refounded.root_epoch, Epoch(1), "the epoch advanced");
8528 assert_ne!(refounded.community_root, community.community_root, "the base root rolled");
8529 assert_eq!(
8531 crate::db::community::load_community_v2(community.id()).unwrap().unwrap().root_epoch,
8532 Epoch(1),
8533 "the owner committed the new epoch"
8534 );
8535
8536 bed.swap_to(&member);
8540 let session = SessionGuard::capture();
8541 let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
8542 assert!(follow.self_removed, "the removed member is cut by the re-founding");
8543 }
8544
8545 #[tokio::test]
8546 async fn a_ban_holding_admin_can_re_found_but_not_evict_a_superior() {
8547 let (bed, owner, member) = TestBed::new();
8552 bed.swap_to(&owner);
8553 let community = create_community(&bed.relay, "Guarded", bed.relays.clone(), None).await.unwrap();
8554 let rid = "b0".repeat(32);
8555 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
8556 publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
8557 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
8558 let bundle_json = serde_json::to_string(&bundle).unwrap();
8559 bed.swap_to(&member);
8560 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8561 let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
8564 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
8565 assert!(refound_community(&bed.relay, &joined, &[owner.keys.public_key()]).await.is_err(), "a BAN-holder can't re-found to evict the owner");
8567 assert!(refound_community(&bed.relay, &joined, &[]).await.is_ok(), "a BAN-holding admin can re-found");
8569 }
8570
8571 #[tokio::test]
8572 async fn follow_rekeys_adopts_an_authorized_non_owner_base_rotation() {
8573 let (bed, owner, me) = TestBed::new();
8578 let admin = Keys::generate();
8579 bed.swap_to(&owner);
8580 let community = create_community(&bed.relay, "AdminRefound", bed.relays.clone(), None).await.unwrap();
8581 let rid = "b0".repeat(32);
8582 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
8583 publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid], 1).await;
8584
8585 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
8587 let bundle_json = serde_json::to_string(&bundle).unwrap();
8588 bed.swap_to(&me);
8589 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8590 let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
8591 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
8592
8593 let new_root = [0xC7; 32];
8596 publish_base_rotation(&bed.relay, &joined, &admin, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
8597
8598 let updated = follow_rekeys(&bed.relay, &joined, &SessionGuard::capture()).await.unwrap().updated
8599 .expect("an authorized admin's Refounding is adopted");
8600 assert_eq!(updated.root_epoch, Epoch(1), "advanced past the admin's rotation");
8601 assert_eq!(updated.community_root, new_root, "adopted the admin's fresh root");
8602 }
8603
8604 #[tokio::test]
8605 async fn adopting_someone_elses_rotation_refreshes_my_own_live_links() {
8606 let (bed, owner, me) = TestBed::new();
8614 bed.swap_to(&owner);
8615 let community = create_community(&bed.relay, "LinkHeal", bed.relays.clone(), None).await.unwrap();
8616 let rid = "b1".repeat(32);
8617 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
8618 publish_grant(&bed.relay, &community, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
8619
8620 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
8621 let bundle_json = serde_json::to_string(&bundle).unwrap();
8622 bed.swap_to(&me);
8623 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8624 let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
8625 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
8626
8627 let minted = mint_public_link(&bed.relay, &joined, "https://x", None, None).await.unwrap();
8629 let vended_before = fetch_public_bundle(&bed.relay, &minted.url).await.unwrap();
8630 assert_eq!(vended_before.root_epoch, 0, "my link vends the epoch I minted it at");
8631
8632 let new_root = [0xD4; 32];
8635 publish_base_rotation(&bed.relay, &joined, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
8636
8637 let updated = follow_rekeys(&bed.relay, &joined, &SessionGuard::capture()).await.unwrap().updated
8638 .expect("the owner's Refounding is adopted");
8639 assert_eq!(updated.root_epoch, Epoch(1), "I advanced to the new epoch");
8640
8641 let vended_after = fetch_public_bundle(&bed.relay, &minted.url).await.unwrap();
8642 assert_eq!(vended_after.root_epoch, 1, "my link must now vend the NEW epoch, not strand its joiners");
8643 assert_eq!(
8644 crate::simd::hex::hex_to_bytes_32(&vended_after.community_root),
8645 new_root,
8646 "and the new root behind the same URL",
8647 );
8648 }
8649
8650 #[tokio::test]
8651 async fn follow_rekeys_refuses_a_refounding_that_excludes_the_owner() {
8652 let (bed, owner, me) = TestBed::new();
8656 let admin = Keys::generate();
8657 bed.swap_to(&owner);
8658 let community = create_community(&bed.relay, "NoCoup", bed.relays.clone(), None).await.unwrap();
8659 let rid = "b0".repeat(32);
8660 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
8661 publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid], 1).await;
8662
8663 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
8664 let bundle_json = serde_json::to_string(&bundle).unwrap();
8665 bed.swap_to(&me);
8666 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8667 let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
8668 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
8669
8670 publish_base_rotation(&bed.relay, &joined, &admin, &[me.keys.public_key()], &[0xEE; 32], &joined.community_root).await;
8672 let follow = follow_rekeys(&bed.relay, &joined, &SessionGuard::capture()).await.unwrap();
8673 assert!(follow.updated.is_none() && !follow.self_removed, "an owner-excluding Refounding is not adopted");
8674 }
8675
8676 #[tokio::test]
8677 async fn follow_rekeys_refuses_a_refounding_that_excludes_a_peer_admin() {
8678 let (bed, owner, me) = TestBed::new();
8682 let admin_a = Keys::generate();
8683 let admin_b = Keys::generate(); bed.swap_to(&owner);
8685 let community = create_community(&bed.relay, "Peers", bed.relays.clone(), None).await.unwrap();
8686 let rid = "b0".repeat(32);
8687 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
8688 publish_grant(&bed.relay, &community, &owner.keys, &admin_a.public_key(), vec![rid.clone()], 1).await;
8690 publish_grant(&bed.relay, &community, &owner.keys, &admin_b.public_key(), vec![rid], 1).await;
8691
8692 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
8693 let bundle_json = serde_json::to_string(&bundle).unwrap();
8694 bed.swap_to(&me);
8695 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8696 let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
8697 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
8698
8699 publish_base_rotation(&bed.relay, &joined, &admin_a, &[owner.keys.public_key(), me.keys.public_key()], &[0xDD; 32], &joined.community_root).await;
8701
8702 let follow = follow_rekeys(&bed.relay, &joined, &SessionGuard::capture()).await.unwrap();
8703 assert!(follow.updated.is_none() && !follow.self_removed, "excluding an equal-rank peer admin is inadmissible");
8704 }
8705
8706 #[tokio::test]
8707 async fn a_retried_refounding_reuses_the_same_root() {
8708 let (_tmp, _guard, _owner) = init_test_db();
8711 let relay = MemoryRelay::new();
8712 let community = create_community(&relay, "Retry", vec!["wss://r".into()], None).await.unwrap();
8713 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
8714 let first = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap();
8715 let second = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap();
8716 assert_eq!(first, second, "a retry reuses the archived root, never double-mints");
8717 }
8718
8719 #[tokio::test]
8720 async fn a_mid_rank_admin_cannot_demote_a_role_that_outranks_them() {
8721 let (bed, owner, attacker) = TestBed::new();
8728 bed.swap_to(&owner);
8729 let community = create_community(&bed.relay, "Ranks", bed.relays.clone(), None).await.unwrap();
8730
8731 let senior = "a1".repeat(32);
8733 let mid = "a5".repeat(32);
8734 publish_role(&bed.relay, &community, &owner.keys,
8735 &Role { role_id: senior.clone(), name: "Senior".into(), position: 1, permissions: Permissions(Permissions::BAN), scope: RoleScope::Server, color: 0 }, 1).await;
8736 publish_role(&bed.relay, &community, &owner.keys,
8737 &Role { role_id: mid.clone(), name: "Mid".into(), position: 5, permissions: Permissions(Permissions::MANAGE_ROLES), scope: RoleScope::Server, color: 0 }, 1).await;
8738 publish_grant(&bed.relay, &community, &owner.keys, &attacker.keys.public_key(), vec![mid.clone()], 1).await;
8739
8740 publish_role(&bed.relay, &community, &attacker.keys,
8742 &Role { role_id: senior.clone(), name: "Senior".into(), position: 9, permissions: Permissions(Permissions::BAN), scope: RoleScope::Server, color: 0 }, 2).await;
8743
8744 let authority = fetch_authority(&bed.relay, &community).await;
8745 let folded_senior = authority.roles.role(&senior).expect("the senior role survives the fold");
8746 assert_eq!(
8747 folded_senior.position, 1,
8748 "a role may only be repositioned by someone who outranks where it STOOD, not just where it lands",
8749 );
8750 }
8751
8752 #[tokio::test]
8753 async fn a_non_owner_admins_edition_cites_its_grant_and_the_owners_does_not() {
8754 let (bed, owner, admin) = TestBed::new();
8761 bed.swap_to(&owner);
8762 let community = create_community(&bed.relay, "Cited", bed.relays.clone(), None).await.unwrap();
8763 let rid = "c1".repeat(32);
8764 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN | Permissions::MANAGE_METADATA), 1).await;
8765 publish_grant(&bed.relay, &community, &owner.keys, &admin.keys.public_key(), vec![rid], 1).await;
8766
8767 let owner_meta = control::CommunityMetadata { name: "By Owner".into(), relays: community.relays.clone(), ..Default::default() };
8769 edit_community_metadata(&bed.relay, &community, &owner_meta).await.unwrap();
8770 let owner_ed = fetch_control(&bed.relay, &community).await.into_iter()
8771 .filter(|e| e.author == owner.keys.public_key() && e.vsk == vsk::COMMUNITY_METADATA)
8772 .max_by_key(|e| e.version).expect("the owner's metadata edition");
8773 assert!(owner_ed.authority.is_none(), "the owner cites nothing — rank comes from the community id");
8774
8775 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
8778 let bundle_json = serde_json::to_string(&bundle).unwrap();
8779 bed.swap_to(&admin);
8780 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8781 let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
8782 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
8783 set_banlist(&bed.relay, &joined, &["ee".repeat(32)]).await.unwrap();
8784
8785 let ban_ed = fetch_control(&bed.relay, &joined).await.into_iter()
8786 .find(|e| e.author == admin.keys.public_key() && e.vsk == vsk::BANLIST)
8787 .expect("the admin's banlist edition");
8788 let cite = ban_ed.authority.as_ref().expect("a non-owner MUST cite its grant");
8789 assert_eq!(
8790 cite.entity_id,
8791 crate::community::v2::derive::grant_locator(community.id(), &admin.keys.public_key().to_bytes()),
8792 "the citation must name the ACTOR'S OWN grant coordinate",
8793 );
8794 assert!(cite.version >= 1, "pinned to a real grant version");
8795 }
8796
8797 #[tokio::test]
8798 async fn a_folded_metadata_edition_cannot_push_the_relay_set_past_the_cap() {
8799 let (_tmp, _guard, _owner) = init_test_db();
8805 let relay = MemoryRelay::new();
8806 let community = create_community(&relay, "Fanout", vec!["wss://a".into()], None).await.unwrap();
8807
8808 let many: Vec<String> = (0..30).map(|i| format!("wss://r{i}")).collect();
8809 let meta = control::CommunityMetadata { name: "Fanout".into(), relays: many, ..Default::default() };
8810 edit_community_metadata(&relay, &community, &meta).await.unwrap();
8811
8812 let updated = follow_control(&relay, &community, &SessionGuard::capture()).await.unwrap()
8813 .expect("the metadata edition is folded");
8814 assert_eq!(
8815 updated.relays.len(),
8816 crate::community::MAX_COMMUNITY_RELAYS,
8817 "a folded relay list must be truncated, never adopted whole",
8818 );
8819
8820 let again = follow_control(&relay, &updated, &SessionGuard::capture()).await.unwrap();
8824 assert!(again.is_none(), "re-folding the same oversize edition must be a no-op");
8825 }
8826
8827 #[tokio::test]
8828 async fn adopting_a_rotation_writes_no_registry_where_i_never_minted() {
8829 let (bed, owner, me) = TestBed::new();
8835 bed.swap_to(&owner);
8836 let host = create_community(&bed.relay, "Host", bed.relays.clone(), None).await.unwrap();
8837 let elsewhere = create_community(&bed.relay, "Elsewhere", bed.relays.clone(), None).await.unwrap();
8838 let rid = "b2".repeat(32);
8839 publish_role(&bed.relay, &host, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
8840 publish_grant(&bed.relay, &host, &owner.keys, &me.keys.public_key(), vec![rid], 1).await;
8841
8842 let bundle = bundle_of(&host, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
8843 let bundle_json = serde_json::to_string(&bundle).unwrap();
8844 bed.swap_to(&me);
8845 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
8846 let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
8847 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
8848
8849 mint_public_link(&bed.relay, &elsewhere, "https://other", None, None).await.unwrap();
8851
8852 let before = bed.relay.stored_count();
8853 let new_root = [0xE1; 32];
8854 publish_base_rotation(&bed.relay, &joined, &owner.keys, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
8855 let rotation_events = bed.relay.stored_count() - before;
8856
8857 let after_adopt = bed.relay.stored_count();
8858 follow_rekeys(&bed.relay, &joined, &SessionGuard::capture()).await.unwrap();
8859 assert_eq!(
8860 bed.relay.stored_count(),
8861 after_adopt,
8862 "adopting a rotation must publish NOTHING when I minted no links here",
8863 );
8864 assert!(rotation_events > 0, "the rotation itself did publish (guards the counter)");
8865 }
8866
8867 #[tokio::test]
8868 async fn an_expired_link_stops_keeping_the_community_public() {
8869 let (_tmp, _guard, _owner) = init_test_db();
8875 let relay = MemoryRelay::new();
8876 let community = create_community(&relay, "Lapsing", vec!["wss://r".into()], None).await.unwrap();
8877
8878 let past = now_ms() - 60_000;
8880 mint_public_link(&relay, &community, "https://x", Some(past), None).await.unwrap();
8881 assert!(
8882 !community_is_public(&relay, &community).await,
8883 "an already-expired link must never read as a live door",
8884 );
8885
8886 mint_public_link(&relay, &community, "https://y", Some(now_ms() + 600_000), None).await.unwrap();
8888 assert!(community_is_public(&relay, &community).await, "an unexpired link is still live");
8889 }
8890
8891 #[tokio::test]
8892 async fn minting_a_link_makes_the_community_public_and_revoke_makes_it_private() {
8893 let (_tmp, _guard, _owner) = init_test_db();
8896 let relay = MemoryRelay::new();
8897 let community = create_community(&relay, "Invitable", vec!["wss://r".into()], None).await.unwrap();
8898 assert!(!community_is_public(&relay, &community).await, "a fresh community is Private");
8899
8900 let minted = mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
8901 assert!(community_is_public(&relay, &community).await, "a live link makes it Public");
8902 let list = fetch_invite_list(&relay, &community.relays).await.unwrap().expect("the 13303 list was published");
8903 assert_eq!(list.entries.len(), 1, "the minted link is recorded across devices");
8904
8905 let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
8906 revoke_public_link(&relay, &community, &token_hex).await.unwrap();
8907 assert!(!community_is_public(&relay, &community).await, "retiring the last link makes it Private again");
8908 let after = fetch_invite_list(&relay, &community.relays).await.unwrap().unwrap();
8909 assert!(after.entries.is_empty() && after.tombstones.len() == 1, "the link is tombstoned in the invite list");
8910 }
8911
8912 #[tokio::test]
8913 async fn the_registry_is_cached_locally_so_public_private_is_a_sync_read() {
8914 let (_tmp, _guard, _owner) = init_test_db();
8918 let relay = MemoryRelay::new();
8919 let community = create_community(&relay, "Cached", vec!["wss://r".into()], None).await.unwrap();
8920 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
8921 let cached = || crate::db::community::get_community_invite_registry(&cid_hex).unwrap();
8922 let per_creator = || crate::db::community::get_invite_link_sets(&cid_hex).unwrap();
8925 assert!(cached().is_empty(), "a fresh community caches an empty registry");
8926 assert!(per_creator().is_empty(), "…and no per-creator sets");
8927
8928 let minted = mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
8929 assert!(!cached().is_empty(), "minting caches the registry, so the UI reads Public without folding");
8930 let sets = per_creator();
8931 assert_eq!(sets.len(), 1, "the minting creator gets a set");
8932 assert_eq!(sets[0].locators.len(), 1, "carrying exactly their one live link");
8933
8934 let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
8936 revoke_public_link(&relay, &community, &token_hex).await.unwrap();
8937 assert!(cached().is_empty(), "retiring the last link empties the cache back to Private");
8938 assert!(per_creator().is_empty(), "…and clears the per-creator sets");
8939 }
8940
8941 #[tokio::test]
8942 async fn a_rogue_registry_fork_cannot_retire_the_owners_live_link() {
8943 let (_tmp, _guard, owner) = init_test_db();
8950 let relay = MemoryRelay::new();
8951 let community = create_community(&relay, "Forked", vec!["wss://r".into()], None).await.unwrap();
8952 mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
8953 assert!(community_is_public(&relay, &community).await, "a live link makes it Public");
8954
8955 let cid = community.id();
8956 let control = control_group_key(&community.community_root, cid, community.root_epoch);
8957 let eid = crate::community::v2::derive::invite_links_locator(cid, &owner.public_key().to_bytes());
8958
8959 let query = Query {
8960 kinds: vec![stream::KIND_WRAP],
8961 authors: vec![control.pk_hex()],
8962 limit: Some(FOLLOW_PAGE),
8963 ..Default::default()
8964 };
8965 let wraps = relay.fetch(&query, &community.relays).await.unwrap();
8966 let target = wraps
8967 .iter()
8968 .filter_map(|w| control::open_control_edition(w, &control).ok().map(|(e, _)| e))
8969 .filter(|e| e.entity_id == eid)
8970 .max_by_key(|e| e.version)
8971 .expect("the owner published a registry");
8972
8973 let rogue = Keys::generate();
8976 let mut planted = false;
8977 for n in 0..4_000u64 {
8978 let content = format!("[{{\"token\":\"{n:032x}\",\"url\":\"https://evil\",\"expires_at\":0}}]");
8979 let rumor = control::build_edition_rumor(
8980 rogue.public_key(),
8981 vsk::INVITE_LINKS,
8982 &eid,
8983 target.version,
8984 target.prev_hash.as_ref(),
8985 &content,
8986 9_000,
8987 None,
8988 );
8989 let (w, _) = control::seal_control_edition(&rumor, &control, &rogue, Timestamp::from_secs(9_000)).unwrap();
8990 let (ed, _) = control::open_control_edition(&w, &control).unwrap();
8991 if ed.inner_id < target.inner_id {
8992 relay.publish(&w, &community.relays).await.unwrap();
8993 planted = true;
8994 break;
8995 }
8996 }
8997 assert!(planted, "the test needs a fork that wins the tiebreak");
8998
8999 assert!(
9000 community_is_public(&relay, &community).await,
9001 "an unauthorised fork must not retire the owner's live link"
9002 );
9003 }
9004
9005 #[tokio::test]
9006 async fn a_registry_from_a_non_create_invite_holder_does_not_make_it_public() {
9007 let (_tmp, _guard, owner) = init_test_db();
9009 let relay = MemoryRelay::new();
9010 let community = create_community(&relay, "Gated", vec!["wss://r".into()], None).await.unwrap();
9011 let rogue = Keys::generate();
9012 let eid = crate::community::v2::derive::invite_links_locator(community.id(), &rogue.public_key().to_bytes());
9014 let content = crate::community::v2::invite::build_registry_content(&[Keys::generate().public_key()]);
9015 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
9016 let rumor = control::build_edition_rumor(rogue.public_key(), vsk::INVITE_LINKS, &eid, 1, None, &content, 1_000, None);
9017 let (wrap, _) = control::seal_control_edition(&rumor, &group, &rogue, Timestamp::from_secs(1_000)).unwrap();
9018 relay.publish(&wrap, &community.relays).await.unwrap();
9019 let _ = owner;
9020 assert!(!community_is_public(&relay, &community).await, "a non-CREATE_INVITE registry is ignored");
9021 }
9022
9023 #[tokio::test]
9024 async fn full_lifecycle_e2e() {
9025 let (bed, owner, member) = TestBed::new();
9031
9032 bed.swap_to(&owner);
9033 let community = create_community(&bed.relay, "Lifecycle", bed.relays.clone(), None).await.unwrap();
9034 let general = community.channels[0].id;
9035 send_message(&bed.relay, &community, &general, "owner: welcome").await.unwrap();
9036
9037 let _minted = mint_public_link(&bed.relay, &community, "https://x", None, None).await.unwrap();
9039 assert!(community_is_public(&bed.relay, &community).await, "a live link makes it Public");
9040
9041 let rid = "aa".repeat(32);
9043 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
9044 publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
9045
9046 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
9048 let bundle_json = serde_json::to_string(&bundle).unwrap();
9049 bed.swap_to(&member);
9050 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
9051 assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: welcome"]);
9052 publish_community_meta(&bed.relay, &joined, &member.keys, "Lifecycle Renamed", 2).await;
9054
9055 bed.swap_to(&owner);
9057 let session = SessionGuard::capture();
9058 let updated = follow_control(&bed.relay, &community, &session).await.unwrap().expect("the admin edit folds");
9059 assert_eq!(updated.name, "Lifecycle Renamed", "an authorized admin's metadata edit is honored");
9060
9061 set_banlist(&bed.relay, &updated, &[member.keys.public_key().to_hex()]).await.unwrap();
9063 grant_roles(&bed.relay, &updated, &member.keys.public_key(), vec![]).await.unwrap();
9064 let refounded = refound_community(&bed.relay, &updated, &[member.keys.public_key()]).await.unwrap();
9065 assert_eq!(refounded.root_epoch, Epoch(1), "the ban rolled the root");
9066 let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
9068 assert!(post.banned.contains(&member.keys.public_key().to_hex()), "the ban survives the re-founding");
9069 assert!(
9071 texts_in(&bed.relay, &refounded, &general).await.contains(&"owner: welcome".to_string()),
9072 "pre-refounding history stays readable"
9073 );
9074
9075 bed.swap_to(&member);
9078 let session = SessionGuard::capture();
9079 let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
9080 assert!(follow.self_removed, "the banned member is cryptographically cut");
9081
9082 bed.swap_to(&owner);
9084 dissolve_community(&bed.relay, &refounded).await.unwrap();
9085 assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the community is sealed");
9086 }
9087
9088 #[tokio::test]
9094 async fn a_forged_edition_cannot_suppress_a_role_across_a_refounding() {
9095 let (bed, owner, member) = TestBed::new();
9100 let attacker = Keys::generate();
9101 bed.swap_to(&owner);
9102 let community = create_community(&bed.relay, "NoSuppress", bed.relays.clone(), None).await.unwrap();
9103 let rid = crate::simd::hex::bytes_to_hex_32(&[0xa1; 32]);
9104 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
9105 publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid.clone()], 1).await;
9106 let session = SessionGuard::capture();
9108 follow_control(&bed.relay, &community, &session).await.unwrap();
9109 assert!(fetch_authority(&bed.relay, &community).await.roles.is_admin(&member.keys.public_key().to_hex()), "member is admin pre-attack");
9110
9111 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;
9113
9114 let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
9116 assert_eq!(refounded.root_epoch, Epoch(1), "root rolled");
9117
9118 let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
9120 assert!(post.roles.is_admin(&member.keys.public_key().to_hex()), "the admin role survives the refounding despite the forgery");
9121 }
9122
9123 #[tokio::test]
9124 async fn memberlist_survives_a_refounding_via_the_snapshot() {
9125 let (bed, owner, member) = TestBed::new();
9128 bed.swap_to(&owner);
9129 let community = create_community(&bed.relay, "Snapshot", bed.relays.clone(), None).await.unwrap();
9130
9131 let bundle = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None)).unwrap();
9133 bed.swap_to(&member);
9134 accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
9135 bed.swap_to(&owner);
9136 assert!(memberlist(&bed.relay, &community).await.unwrap().contains(&member.keys.public_key()), "member present pre-refound");
9137
9138 let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
9140 assert_eq!(refounded.root_epoch, Epoch(1), "the root rolled");
9141
9142 let members = memberlist(&bed.relay, &refounded).await.unwrap();
9144 assert!(members.contains(&member.keys.public_key()), "a silent survivor stays a member after the refounding");
9145 assert!(members.contains(&owner.keys.public_key()), "owner is always a member");
9146 }
9147
9148 #[tokio::test]
9149 async fn e2e_two_accounts_channels_converse_moderate() {
9150 use crate::community::v2::inbound::{apply_chat_to_state, persist_chat};
9151 use nostr_sdk::prelude::ToBech32;
9152 let (bed, a, b) = TestBed::new();
9153 let (a_npub, b_npub) = (a.keys.public_key().to_bech32().unwrap(), b.keys.public_key().to_bech32().unwrap());
9154 let (a_hex, b_hex) = (a.keys.public_key().to_hex(), b.keys.public_key().to_hex());
9155 println!("\n===== Concord v2 deep e2e =====");
9156 println!("[acct] A (owner) = {a_npub}");
9157 println!("[acct] B (member) = {b_npub}");
9158
9159 bed.swap_to(&a);
9161 let mut community = create_community(&bed.relay, "Deep E2E", bed.relays.clone(), None).await.unwrap();
9162 let general = community.channels[0].id;
9163 println!("[create] community {} · #general {}", crate::simd::hex::bytes_to_hex_32(&community.id().0), crate::simd::hex::bytes_to_hex_32(&general.0));
9164
9165 let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
9169 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9170 let priv_ch = community.channel(&priv_id).unwrap();
9171 assert!(priv_ch.private && priv_ch.key.is_some() && priv_ch.epoch == Epoch(1), "born-private: keyed at epoch 1");
9172 println!("[channel] +private #mods {} (native create: key over the rekey plane)", crate::simd::hex::bytes_to_hex_32(&priv_id.0));
9173
9174 let announcements = create_public_channel(&bed.relay, &community, "announcements").await.unwrap();
9176 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9177 let random = create_public_channel(&bed.relay, &community, "random").await.unwrap();
9178 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9179 println!("[channel] +public #announcements {} · #random {}", crate::simd::hex::bytes_to_hex_32(&announcements.0), crate::simd::hex::bytes_to_hex_32(&random.0));
9180 assert_eq!(community.channels.len(), 4, "general + mods + announcements + random");
9181
9182 let m1 = send_message(&bed.relay, &community, &general, "A: welcome to the deep e2e").await.unwrap();
9184 send_message(&bed.relay, &community, &announcements, "A: read the rules").await.unwrap();
9185 send_message(&bed.relay, &community, &priv_id, "A: mods-only channel").await.unwrap();
9186 println!("[msg] A posted in #general / #announcements / #mods");
9187
9188 let admin_rid = crate::simd::hex::bytes_to_hex_32(&[0xa1; 32]);
9190 publish_role(&bed.relay, &community, &a.keys, &admin_role(&admin_rid, Permissions::ADMIN_ALL), 1).await;
9191 publish_grant(&bed.relay, &community, &a.keys, &b.keys.public_key(), vec![admin_rid], 1).await;
9192 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
9193 assert!(community_is_public(&bed.relay, &community).await, "a live link makes it Public");
9194 println!("[invite] granted B @admin · minted link {}", link.url);
9195
9196 grant_channel_access(&bed.relay, &community, &priv_id, &b.keys.public_key()).await.unwrap();
9199 let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Member(b.keys.public_key()), Some(a.keys.public_key()), None, None)).unwrap();
9200 bed.swap_to(&b);
9201 let mut b_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
9202 println!("[join] B joined; sees {} channels", b_view.channels.len());
9203 assert_eq!(b_view.channels.len(), 4, "B receives all four channels (incl. the private one's key) in the bundle");
9204 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");
9205 assert!(texts_in(&bed.relay, &b_view, &general).await.contains(&"A: welcome to the deep e2e".to_string()), "B reads A's #general history");
9206 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");
9207 let session_b = SessionGuard::capture();
9210 if let Some(fresh) = follow_control(&bed.relay, &b_view, &session_b).await.unwrap() {
9211 b_view = fresh;
9212 }
9213 println!("[follow] B folded control (roster persisted: B is @admin)");
9214
9215 send_message(&bed.relay, &b_view, &general, "B: thanks, glad to be here").await.unwrap();
9217 send_message(&bed.relay, &b_view, &priv_id, "B: mods checking in").await.unwrap();
9218 println!("[msg] B replied in #general + #mods");
9219 let my_pk = b.keys.public_key();
9222 let gh = crate::simd::hex::bytes_to_hex_32(&general.0);
9223 for f in fetch_channel(&bed.relay, &b_view, &general, 100).await.unwrap() {
9224 let outcome = { let mut st = crate::state::STATE.lock().await; apply_chat_to_state(&mut st, &f.event, &gh, &my_pk) };
9225 if let Some(o) = outcome { persist_chat(&gh, &o).await; }
9226 }
9227 assert!(crate::db::events::event_exists(&m1).unwrap(), "A's message persisted into B's shared store (get_messages backfill)");
9228 println!("[persist] #general history persisted into the shared events store");
9229
9230 send_reaction(&bed.relay, &b_view, &general, &m1, &a_hex, super::super::kind::MESSAGE, "🔥", None).await.unwrap();
9232 bed.swap_to(&a);
9233 let m_edit = send_message(&bed.relay, &community, &general, "A: this will be edited").await.unwrap();
9234 send_edit(&bed.relay, &community, &general, &m_edit, "A: edited!").await.unwrap();
9235 let m_del = send_message(&bed.relay, &community, &general, "A: this will be deleted").await.unwrap();
9236 send_delete(&bed.relay, &community, &general, &m_del, super::super::kind::MESSAGE).await.unwrap();
9237 println!("[ops] reaction + edit + delete round-tripped");
9238
9239 bed.swap_to(&b);
9241 let bugs = create_public_channel(&bed.relay, &b_view, "bug-reports").await.unwrap();
9242 println!("[channel] B(admin) +public #bug-reports {}", crate::simd::hex::bytes_to_hex_32(&bugs.0));
9243 bed.swap_to(&a);
9244 let session = SessionGuard::capture();
9245 if let Some(updated) = follow_control(&bed.relay, &community, &session).await.unwrap() {
9246 community = updated;
9247 }
9248 assert!(community.channels.iter().any(|c| c.id.0 == bugs.0), "A folds in B's authorized new channel");
9249 println!("[follow] A folded in B's #bug-reports (now {} channels)", community.channels.len());
9250
9251 let vault = create_private_channel(&bed.relay, &community, "vault").await.unwrap();
9257 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9258 send_message(&bed.relay, &community, &vault, "A: vault is open").await.unwrap();
9259 println!("[channel] +private #vault {} (B is unentitled — no delivery)", crate::simd::hex::bytes_to_hex_32(&vault.0));
9260 bed.swap_to(&b);
9261 let session_b2 = SessionGuard::capture();
9262 if let Some(fresh) = follow_control(&bed.relay, &b_view, &session_b2).await.unwrap() {
9263 b_view = fresh;
9264 }
9265 let ch = b_view.channel(&vault).expect("B recorded the announced private channel");
9266 assert!(ch.private && ch.key.is_none() && ch.epoch == Epoch(0), "B's record is keyless at cursor 0");
9267 let rf = follow_rekeys(&bed.relay, &b_view, &session_b2).await.unwrap();
9268 if let Some(fresh) = rf.updated {
9269 b_view = fresh;
9270 }
9271 let ch = b_view.channel(&vault).expect("still recorded");
9272 assert!(ch.key.is_none(), "an unentitled member is never delivered the key");
9273 assert!(
9274 texts_in(&bed.relay, &b_view, &vault).await.is_empty(),
9275 "and reads nothing from it"
9276 );
9277 assert!(
9278 send_message(&bed.relay, &b_view, &vault, "B: in the vault").await.is_err(),
9279 "an unentitled member cannot post into the channel either"
9280 );
9281 bed.swap_to(&a);
9282 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9283 println!("[private] #vault stayed sealed to the unentitled B (no key, no read, no send)");
9284
9285 let members = memberlist(&bed.relay, &community).await.unwrap();
9287 let member_hexes: std::collections::BTreeSet<String> = members.iter().map(|m| m.to_hex()).collect();
9288 assert!(member_hexes.contains(&a_hex) && member_hexes.contains(&b_hex), "A + B both in the memberlist");
9289 println!("[members] {} members: A + B present", members.len());
9290
9291 set_banlist(&bed.relay, &community, &[b_hex.clone()]).await.unwrap();
9293 grant_roles(&bed.relay, &community, &b.keys.public_key(), vec![]).await.unwrap();
9294 let refounded = refound_community(&bed.relay, &community, &[b.keys.public_key()]).await.unwrap();
9295 assert_eq!(refounded.root_epoch, Epoch(1), "the ban rolled the root");
9296 let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
9297 assert!(post.banned.contains(&b_hex), "the ban survives the refounding");
9298 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");
9299 assert!(
9300 texts_in(&bed.relay, &refounded, &priv_id).await.iter().any(|t| t == "A: mods-only channel"),
9301 "PRIVATE history reads across the channel's own rotation (per-channel multi-epoch archive)"
9302 );
9303 println!("[ban] B banned; root rolled to epoch 1; ban survives; pre-ban history intact (public + private)");
9304 bed.swap_to(&b);
9306 let session_b3 = SessionGuard::capture();
9307 assert!(follow_rekeys(&bed.relay, &b_view, &session_b3).await.unwrap().self_removed, "B is cryptographically cut by the ban-refound");
9308 println!("[ban] B's rekey-follow: self_removed = true (severed)");
9309
9310 bed.swap_to(&a);
9312 set_banlist(&bed.relay, &refounded, &[]).await.unwrap();
9313 let after_unban = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
9314 assert!(!after_unban.banned.contains(&b_hex), "the unban clears B from the banlist");
9315 println!("[unban] B removed from the banlist (re-invitable)");
9316
9317 dissolve_community(&bed.relay, &refounded).await.unwrap();
9319 assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the community is sealed");
9320 println!("[dissolve] community sealed (read-only)\n===== e2e PASS =====\n");
9321 }
9322
9323 #[tokio::test]
9329 #[ignore]
9330 async fn live_e2e_two_accounts() {
9331 use crate::community::transport::LiveTransport;
9332 use nostr_sdk::prelude::ToBech32;
9333
9334 let relay = std::env::var("VECTOR_E2E_RELAY").unwrap_or_else(|_| "wss://jskitty.com/nostr".to_string());
9335 let relays = vec![relay.clone()];
9336 let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
9337 crate::db::close_database();
9338 crate::db::clear_id_caches();
9339 let tmp = tempfile::tempdir().unwrap();
9340 crate::db::set_app_data_dir(tmp.path().to_path_buf());
9341
9342 let a = std::env::var("VECTOR_E2E_NSEC_A").ok().and_then(|n| Keys::parse(&n).ok()).unwrap_or_else(Keys::generate);
9344 let b = std::env::var("VECTOR_E2E_NSEC_B").ok().and_then(|n| Keys::parse(&n).ok()).unwrap_or_else(Keys::generate);
9345
9346 let log = |line: String| {
9347 println!("{line}");
9348 if let Ok(p) = std::env::var("VECTOR_E2E_LOG") {
9349 use std::io::Write;
9350 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&p) {
9351 let _ = writeln!(f, "{line}");
9352 }
9353 }
9354 };
9355 log(format!("===== LIVE Concord v2 e2e on {relay} ====="));
9356 log(format!("VECTOR_E2E_NSEC_A={} ({})", a.secret_key().to_bech32().unwrap(), a.public_key().to_bech32().unwrap()));
9357 log(format!("VECTOR_E2E_NSEC_B={} ({})", b.secret_key().to_bech32().unwrap(), b.public_key().to_bech32().unwrap()));
9358
9359 for k in [&a, &b] {
9360 let npub = k.public_key().to_bech32().unwrap();
9361 std::fs::create_dir_all(tmp.path().join(&npub)).unwrap();
9362 crate::db::set_current_account(npub.clone()).unwrap();
9363 crate::db::init_database(&npub).unwrap();
9364 }
9365 let client = crate::nostr_client_builder().build();
9368 client.add_managed_relay(relay.as_str()).await.ok();
9369 client.connect().await;
9370 crate::state::set_nostr_client(client);
9371 let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(15));
9372 let become_acct = |k: &Keys| {
9373 let npub = k.public_key().to_bech32().unwrap();
9374 crate::db::set_current_account(npub.clone()).unwrap();
9375 crate::db::init_database(&npub).unwrap();
9376 crate::db::clear_id_caches();
9377 crate::state::MY_SECRET_KEY.store_from_keys(k, &[]);
9378 crate::state::set_my_public_key(k.public_key());
9379 };
9380 let settle = || tokio::time::sleep(std::time::Duration::from_secs(2));
9381
9382 become_acct(&a);
9384 let mut community = create_community(&transport, "Live E2E", relays.clone(), None).await.expect("create");
9385 let general = community.channels[0].id;
9386 log(format!("[create] community {} · #general {}", crate::simd::hex::bytes_to_hex_32(&community.id().0), crate::simd::hex::bytes_to_hex_32(&general.0)));
9387 send_message(&transport, &community, &general, "A: live hello").await.expect("send");
9388 let ann = create_public_channel(&transport, &community, "announcements").await.expect("channel");
9389 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9390 log(format!("[channel] +public #announcements {}", crate::simd::hex::bytes_to_hex_32(&ann.0)));
9391 grant_admin(&transport, &community, &b.public_key()).await.expect("grant admin");
9392 let link = mint_public_link(&transport, &community, "https://vectorapp.io", None, None).await.expect("mint");
9393 log(format!("[invite] B granted @admin · link {}", link.url));
9394 let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Link, Some(a.public_key()), None, None)).unwrap();
9395 settle().await;
9396
9397 become_acct(&b);
9399 let b_view = accept_parked_invite(&transport, &bundle_json, None).await.expect("join");
9400 log(format!("[join] B joined; {} channels", b_view.channels.len()));
9401 settle().await;
9402 let page = fetch_channel(&transport, &b_view, &general, 50).await.expect("fetch");
9403 let seen: Vec<String> = page.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
9404 log(format!("[read] B sees #general: {seen:?}"));
9405 assert!(seen.iter().any(|t| t == "A: live hello"), "B reads A's message over the real relay");
9406 send_message(&transport, &b_view, &general, "B: live reply").await.expect("reply");
9407
9408 let hello = page.iter().find(|f| f.event.opened().rumor.content == "A: live hello").expect("A's message");
9412 let hello_id = hello.event.opened().rumor_id.to_hex();
9413 let bkeys = crate::state::MY_SECRET_KEY.to_keys().unwrap();
9414 let cgroup = channel_group_key(&b_view.community_root, &general, b_view.root_epoch);
9415 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());
9416 let (reply_wrap, _) = chat::seal_chat_rumor(&reply_rumor, &cgroup, &bkeys, Timestamp::from_secs(now_ms() / 1000), false).expect("seal 1111");
9417 transport.publish(&reply_wrap, &b_view.relays).await.expect("publish 1111");
9418 log("[thread] B published a kind-1111 threaded reply to A's message".to_string());
9419 settle().await;
9420
9421 become_acct(&a);
9423 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9424 let a_page = fetch_channel(&transport, &community, &general, 50).await.expect("A fetch");
9425 let thread = a_page.iter().find(|f| f.event.opened().rumor.content == "B: threaded reply to hello").expect("A sees the 1111");
9426 if let chat::ChatEvent::Message { reply_to, opened, .. } = &thread.event {
9427 assert_eq!(opened.rumor.kind.as_u16(), super::super::kind::COMMENT, "wire kind preserved as 1111");
9428 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");
9429 } else {
9430 panic!("the 1111 parsed as a Message");
9431 }
9432 log("[thread] A read B's threaded reply, parent resolved — cross-client 1111 interop OK".to_string());
9433 become_acct(&b);
9434 settle().await;
9435
9436 become_acct(&a);
9440 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9441 let vault = create_private_channel(&transport, &community, "vault").await.expect("private channel");
9442 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9443 send_message(&transport, &community, &vault, "A: vault live").await.expect("vault send");
9444 log(format!("[channel] +private #vault {} (key delivered over the rekey plane)", crate::simd::hex::bytes_to_hex_32(&vault.0)));
9445 settle().await;
9446
9447 become_acct(&b);
9448 let session_b = SessionGuard::capture();
9449 let mut b_view = crate::db::community::load_community_v2(b_view.id()).unwrap().unwrap();
9450 if let Some(fresh) = follow_control(&transport, &b_view, &session_b).await.expect("B control follow") {
9451 b_view = fresh;
9452 }
9453 if let Some(fresh) = follow_rekeys(&transport, &b_view, &session_b).await.expect("B rekey follow").updated {
9454 b_view = fresh;
9455 }
9456 let vch = b_view.channel(&vault).expect("B folded the vault");
9457 assert!(vch.key.is_some() && vch.epoch == Epoch(1), "B adopted the vault key from the live rekey plane");
9458 let vseen = texts_in(&transport, &b_view, &vault).await;
9459 log(format!("[read] B sees #vault: {vseen:?}"));
9460 assert!(vseen.iter().any(|t| t == "A: vault live"), "B reads the private channel with the ADOPTED key");
9461 send_message(&transport, &b_view, &vault, "B: in the live vault").await.expect("vault reply");
9462 settle().await;
9463
9464 become_acct(&a);
9465 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9466 assert!(
9467 texts_in(&transport, &community, &vault).await.iter().any(|t| t == "B: in the live vault"),
9468 "A reads B's private reply"
9469 );
9470 log("[private] two-way #vault conversation over the live relay".to_string());
9471
9472 set_banlist(&transport, &community, &[b.public_key().to_hex()]).await.expect("banlist");
9474 grant_roles(&transport, &community, &b.public_key(), vec![]).await.expect("strip");
9475 let refounded = refound_community(&transport, &community, &[b.public_key()]).await.expect("refound");
9476 log(format!("[ban] B banned; root → epoch {}", refounded.root_epoch.0));
9477 settle().await;
9478 dissolve_community(&transport, &refounded).await.expect("dissolve");
9479 log("[dissolve] community sealed".to_string());
9480 log("===== LIVE e2e PASS =====".to_string());
9481 }
9482
9483 #[tokio::test]
9484 async fn an_offline_member_learns_of_a_dissolution_on_catch_up() {
9485 let (bed, owner, member) = TestBed::new();
9489 bed.swap_to(&owner);
9490 let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
9491 let general = community.channels[0].id;
9492 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
9493
9494 bed.swap_to(&member);
9495 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
9496 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
9497
9498 bed.swap_to(&owner);
9500 dissolve_community(&bed.relay, &community).await.unwrap();
9501
9502 bed.swap_to(&member);
9504 let session = SessionGuard::capture();
9505 let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
9506 assert!(follow.dissolved, "the catch-up surfaces the tombstone");
9507 assert!(!follow.self_removed && follow.updated.is_none());
9508 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
9509 assert!(crate::db::community::get_community_dissolved(&cid_hex).unwrap(), "sealed read-only locally");
9510 let err = send_message(&bed.relay, &joined, &general, "into the void").await.unwrap_err();
9511 assert!(err.contains("dissolved"), "sends refuse a grave: {err}");
9512 let again = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
9514 assert!(again.dissolved && again.updated.is_none());
9515 }
9516
9517 #[tokio::test]
9518 async fn a_wide_community_survives_refoundings_and_an_offline_member_converges() {
9519 const PRIV_CHANNELS: usize = 6;
9524 let (bed, owner, member) = TestBed::new();
9525 bed.swap_to(&owner);
9526 let mut community = create_community(&bed.relay, "Wide", bed.relays.clone(), None).await.unwrap();
9527 let mut priv_ids = Vec::new();
9528 for i in 0..PRIV_CHANNELS {
9529 let id = create_private_channel(&bed.relay, &community, &format!("priv{i}")).await.unwrap();
9530 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9531 send_message(&bed.relay, &community, &id, &format!("priv{i} epoch0")).await.unwrap();
9532 priv_ids.push(id);
9533 }
9534 for id in &priv_ids {
9536 grant_channel_access(&bed.relay, &community, id, &member.keys.public_key()).await.unwrap();
9537 }
9538 let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Member(member.keys.public_key()), Some(owner.keys.public_key()), None, None)).unwrap();
9539
9540 bed.swap_to(&member);
9542 let member_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
9543 assert_eq!(member_view.channels.iter().filter(|c| c.private && c.key.is_some()).count(), PRIV_CHANNELS, "joined with all private keys");
9544
9545 bed.swap_to(&owner);
9547 for epoch in 1..=2u64 {
9548 community = refound_community(&bed.relay, &community, &[]).await.unwrap();
9549 assert_eq!(community.root_epoch, Epoch(epoch));
9550 for id in &priv_ids {
9551 send_message(&bed.relay, &community, id, &format!("{} epoch{epoch}", crate::simd::hex::bytes_to_hex_32(&id.0))).await.unwrap();
9552 }
9553 }
9554
9555 bed.swap_to(&member);
9557 let session = SessionGuard::capture();
9558 let mut passes = 0;
9559 loop {
9560 passes += 1;
9561 assert!(passes <= 8, "a wide catch-up must converge, not churn (pass {passes})");
9562 let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
9563 let rk = follow_rekeys(&bed.relay, &cur, &session).await.unwrap();
9564 assert!(!rk.self_removed);
9565 let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
9566 let ctl = follow_control(&bed.relay, &cur, &session).await.unwrap();
9567 if rk.updated.is_none() && ctl.is_none() {
9568 break;
9569 }
9570 }
9571 let caught_up = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
9572 assert_eq!(caught_up.root_epoch, Epoch(2), "walked both refoundings");
9573 for id in &priv_ids {
9575 let mine = caught_up.channel(id).expect("channel survived");
9576 let theirs = community.channel(id).unwrap();
9577 assert_eq!(mine.key, theirs.key, "channel {} converged on the owner key", crate::simd::hex::bytes_to_hex_32(&id.0));
9578 assert_eq!(mine.epoch, theirs.epoch, "…at the same epoch");
9579 let texts = texts_in(&bed.relay, &caught_up, id).await;
9580 let id_hex = crate::simd::hex::bytes_to_hex_32(&id.0);
9581 assert!(texts.iter().any(|t| t.contains("epoch0")), "channel {id_hex} reads epoch-0 history");
9582 for epoch in 1..=2u64 {
9583 assert!(texts.iter().any(|t| t.contains(&format!("epoch{epoch}"))), "channel {id_hex} reads epoch-{epoch} history");
9584 }
9585 }
9586 }
9587
9588 #[tokio::test]
9589 async fn an_offline_member_catches_up_across_three_refoundings() {
9590 use nostr_sdk::prelude::ToBech32;
9595 let (bed, owner, member) = TestBed::new();
9596 bed.swap_to(&owner);
9597 let mut community = create_community(&bed.relay, "Sleeper", bed.relays.clone(), None).await.unwrap();
9598 let general = community.channels[0].id;
9599 let mods = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
9600 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9601 send_message(&bed.relay, &community, &general, "epoch0: hello").await.unwrap();
9602 send_message(&bed.relay, &community, &mods, "epoch0: mods secret").await.unwrap();
9603 grant_channel_access(&bed.relay, &community, &mods, &member.keys.public_key()).await.unwrap();
9605 let bundle_json = serde_json::to_string(&bundle_of(&community, BundleAudience::Member(member.keys.public_key()), Some(owner.keys.public_key()), None, None)).unwrap();
9606
9607 bed.swap_to(&member);
9609 let member_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
9610 assert_eq!(member_view.root_epoch, Epoch(0));
9611
9612 bed.swap_to(&owner);
9614 let stranger = Keys::generate();
9615 for epoch in 1..=3u64 {
9616 community = refound_community(&bed.relay, &community, &[]).await.unwrap();
9617 assert_eq!(community.root_epoch, Epoch(epoch));
9618 send_message(&bed.relay, &community, &general, &format!("epoch{epoch}: general news")).await.unwrap();
9619 send_message(&bed.relay, &community, &mods, &format!("epoch{epoch}: mods word")).await.unwrap();
9620 }
9621 let news = create_public_channel(&bed.relay, &community, "news").await.unwrap();
9622 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9623 let vault = create_private_channel(&bed.relay, &community, "vault").await.unwrap();
9624 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9625 grant_channel_access(&bed.relay, &community, &vault, &member.keys.public_key()).await.unwrap();
9628 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9629 send_message(&bed.relay, &community, &vault, "epoch3: vault opened").await.unwrap();
9630 set_banlist(&bed.relay, &community, &[stranger.public_key().to_hex()]).await.unwrap();
9631 let meta = control::CommunityMetadata { name: "Sleeper Reborn".into(), relays: community.relays.clone(), ..Default::default() };
9632 edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
9633
9634 bed.swap_to(&member);
9637 let session = SessionGuard::capture();
9638 let mut passes = 0;
9639 loop {
9640 passes += 1;
9641 assert!(passes <= 6, "catch-up must converge, not churn");
9642 let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
9643 let rekeyed = follow_rekeys(&bed.relay, &cur, &session).await.unwrap();
9644 assert!(!rekeyed.self_removed, "the member was never removed");
9645 let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
9646 let controlled = follow_control(&bed.relay, &cur, &session).await.unwrap();
9647 if rekeyed.updated.is_none() && controlled.is_none() {
9648 break;
9649 }
9650 }
9651 let caught_up = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
9652
9653 assert_eq!(caught_up.root_epoch, Epoch(3), "walked all three refoundings");
9655 assert_eq!(caught_up.community_root, community.community_root, "landed on the owner's root");
9656 assert_eq!(caught_up.name, "Sleeper Reborn");
9657 assert!(caught_up.channels.iter().any(|c| c.id.0 == news.0), "folded the new public channel");
9659 let m = caught_up.channel(&mods).expect("mods survived");
9660 let owner_mods = community.channel(&mods).unwrap();
9661 assert_eq!(m.epoch, owner_mods.epoch, "mods walked every per-refound rotation");
9662 assert_eq!(m.key, owner_mods.key, "…to the owner's exact key");
9663 let v = caught_up.channel(&vault).expect("vault folded in");
9664 assert!(v.private && v.key.is_none(), "vault folds in keyless: entitled, but never delivered");
9669 let cid_hex = crate::simd::hex::bytes_to_hex_32(&caught_up.id().0);
9671 let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap();
9672 assert!(banned.contains(&stranger.public_key().to_hex()), "the ban folded through");
9673 let gen_texts = texts_in(&bed.relay, &caught_up, &general).await;
9676 for epoch in 0..=3u64 {
9677 let needle = if epoch == 0 { "epoch0: hello".to_string() } else { format!("epoch{epoch}: general news") };
9678 assert!(gen_texts.contains(&needle), "general history spans epoch {epoch}: {gen_texts:?}");
9679 }
9680 let mods_texts = texts_in(&bed.relay, &caught_up, &mods).await;
9681 for epoch in 0..=3u64 {
9682 let needle = if epoch == 0 { "epoch0: mods secret".to_string() } else { format!("epoch{epoch}: mods word") };
9683 assert!(mods_texts.contains(&needle), "private history spans epoch {epoch}: {mods_texts:?}");
9684 }
9685 assert!(texts_in(&bed.relay, &caught_up, &vault).await.is_empty());
9688 send_message(&bed.relay, &caught_up, &general, "member: good morning").await.unwrap();
9690 bed.swap_to(&owner);
9691 assert!(
9692 texts_in(&bed.relay, &community, &general).await.contains(&"member: good morning".to_string()),
9693 "the caught-up member converses at the new epoch ({})",
9694 member.keys.public_key().to_bech32().unwrap()
9695 );
9696 }
9697
9698 async fn flood_general(relay: &MemoryRelay, community: &CommunityV2, author: &Keys, n: usize, base_secs: u64) {
9701 let general = community.channels[0].id;
9702 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
9703 for i in 0..n {
9704 let at = base_secs + i as u64;
9705 let rumor = chat::build_message_rumor(author.public_key(), &general, community.root_epoch, &format!("msg {i}"), None, &[], vec![], at * 1000);
9706 let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, author, Timestamp::from_secs(at), false).unwrap();
9707 relay.publish(&wrap, &community.relays).await.unwrap();
9708 }
9709 }
9710
9711 #[tokio::test]
9712 async fn the_history_walk_pages_past_a_multi_page_burst() {
9713 let (_tmp, _guard, owner) = init_test_db();
9716 let relay = MemoryRelay::new();
9717 let community = create_community(&relay, "Burst", vec!["wss://r".into()], None).await.unwrap();
9718 let general = community.channels[0].id;
9719 flood_general(&relay, &community, &owner, 120, 10_000).await;
9720
9721 let all = fetch_channel_history(&relay, &community, &general, 50, 8, None, None, crate::community::transport::Evidence::Quorum, |_| true).await.unwrap();
9722 assert_eq!(all.len(), 120, "the walk pages the whole burst");
9723 let contents: Vec<String> = all.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
9725 assert_eq!(contents.first().map(String::as_str), Some("msg 0"));
9726 assert_eq!(contents.last().map(String::as_str), Some("msg 119"));
9727 let unique: std::collections::HashSet<&String> = contents.iter().collect();
9728 assert_eq!(unique.len(), 120, "wrap-id + rumor-id dedup holds across page boundaries");
9729
9730 let one = fetch_channel(&relay, &community, &general, 50).await.unwrap();
9732 assert_eq!(one.len(), 50, "fetch_channel is one newest page");
9733 assert_eq!(one.last().map(|f| f.event.opened().rumor.content.clone()).as_deref(), Some("msg 119"));
9734 }
9735
9736 #[tokio::test]
9737 async fn a_start_until_cursor_pages_history_from_that_point_backwards() {
9738 let (_tmp, _guard, owner) = init_test_db();
9742 let relay = MemoryRelay::new();
9743 let community = create_community(&relay, "Paged", vec!["wss://r".into()], None).await.unwrap();
9744 let general = community.channels[0].id;
9745 flood_general(&relay, &community, &owner, 120, 10_000).await;
9746
9747 let older = fetch_channel_history(
9748 &relay, &community, &general, 50, 8, None, Some(10_059),
9749 crate::community::transport::Evidence::Quorum, |_| true,
9750 )
9751 .await
9752 .unwrap();
9753 let contents: Vec<String> = older.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
9754 assert_eq!(contents.len(), 60, "everything at-or-before the cursor, nothing after");
9755 assert_eq!(contents.first().map(String::as_str), Some("msg 0"));
9756 assert_eq!(contents.last().map(String::as_str), Some("msg 59"));
9757 }
9758
9759 #[tokio::test]
9760 async fn the_history_walk_stops_when_the_caller_is_caught_up() {
9761 let (_tmp, _guard, owner) = init_test_db();
9762 let relay = MemoryRelay::new();
9763 let community = create_community(&relay, "Caught", vec!["wss://r".into()], None).await.unwrap();
9764 let general = community.channels[0].id;
9765 flood_general(&relay, &community, &owner, 120, 10_000).await;
9766
9767 let mut pages = 0usize;
9769 let got = fetch_channel_history(&relay, &community, &general, 50, 8, None, None, crate::community::transport::Evidence::Quorum, |_| {
9770 pages += 1;
9771 false
9772 })
9773 .await
9774 .unwrap();
9775 assert_eq!(pages, 1, "the early stop is consulted once");
9776 assert_eq!(got.len(), 50, "only the newest page is fetched");
9777 assert_eq!(got.last().map(|f| f.event.opened().rumor.content.clone()).as_deref(), Some("msg 119"));
9778 }
9779
9780 #[tokio::test]
9781 async fn a_same_second_history_wall_terminates_instead_of_looping() {
9782 let (_tmp, _guard, owner) = init_test_db();
9786 let relay = MemoryRelay::new();
9787 let community = create_community(&relay, "Wall", vec!["wss://r".into()], None).await.unwrap();
9788 let general = community.channels[0].id;
9789 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
9790 for i in 0..60usize {
9791 let rumor = chat::build_message_rumor(owner.public_key(), &general, community.root_epoch, &format!("burst {i}"), None, &[], vec![], 5_000_000 + i as u64);
9792 let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, &owner, Timestamp::from_secs(5_000), false).unwrap();
9793 relay.publish(&wrap, &community.relays).await.unwrap();
9794 }
9795 let got = fetch_channel_history(&relay, &community, &general, 25, 8, None, None, crate::community::transport::Evidence::Quorum, |_| true).await.unwrap();
9796 assert!(got.len() >= 25, "at least the relay page is read");
9797 assert!(got.len() <= 60, "sane bound");
9798 }
9800
9801 #[tokio::test]
9802 async fn a_grant_revoke_survives_a_withholding_relay() {
9803 let (_tmp, _guard, owner) = init_test_db();
9806 let relay = MemoryRelay::new();
9807 let community = create_community(&relay, "Revoke", vec!["wss://good".into()], None).await.unwrap();
9808 let admin = Keys::generate();
9809 let rid = "d4".repeat(32);
9810 publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
9811 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
9812 let session = SessionGuard::capture();
9813 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();
9816
9817 inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
9819 let mut stale = community.clone();
9820 stale.relays = vec!["wss://stale".into()];
9821 let floors = load_floors(&community);
9822 let editions = fetch_control(&relay, &stale).await;
9823 let authority = fold_authority(&stale, &editions, &floors);
9824 assert!(
9825 !authority.roles.is_authorized(&admin.public_key().to_hex(), Some(&owner.public_key().to_hex()), Permissions::MANAGE_METADATA),
9826 "the persisted grant floor refuses the rolled-back (re-granted) view"
9827 );
9828 }
9829
9830 fn load_floors(community: &CommunityV2) -> Floors {
9832 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
9833 crate::db::community::get_all_edition_heads_full(&cid_hex)
9834 .unwrap_or_default()
9835 .into_iter()
9836 .filter(|(_, f)| f.0 == community.root_epoch.0)
9837 .map(|(e, f)| (e, (f.1, f.2, f.3)))
9838 .collect()
9839 }
9840
9841 async fn fetch_control(relay: &MemoryRelay, community: &CommunityV2) -> Vec<ParsedEdition> {
9843 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
9844 let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
9845 relay
9846 .fetch(&q, &community.relays)
9847 .await
9848 .unwrap_or_default()
9849 .iter()
9850 .filter_map(|w| control::open_control_edition(w, &group).ok().map(|(ed, _)| ed))
9851 .collect()
9852 }
9853
9854 #[tokio::test]
9855 async fn follow_control_is_a_noop_on_a_freshly_created_community() {
9856 let (_tmp, _guard, _owner) = init_test_db();
9857 let relay = MemoryRelay::new();
9858 let community = create_community(&relay, "Fresh", vec!["wss://r".into()], None).await.unwrap();
9859 let session = SessionGuard::capture();
9860 assert!(follow_control(&relay, &community, &session).await.unwrap().is_none());
9862 }
9863
9864 #[tokio::test]
9865 async fn follow_control_adds_a_new_public_channel_and_re_subscribes_it() {
9866 let (_tmp, _guard, owner) = init_test_db();
9867 let relay = MemoryRelay::new();
9868 let community = create_community(&relay, "Grow", vec!["wss://r".into()], None).await.unwrap();
9869 let new_id = ChannelId([0x5a; 32]);
9870 publish_channel_edition(&relay, &community, &owner, &new_id, "announcements", false, 1, false).await;
9871
9872 let session = SessionGuard::capture();
9873 let updated = follow_control(&relay, &community, &session).await.unwrap().expect("a new channel changed the view");
9874 assert_eq!(updated.channels.len(), 2);
9875 let added = updated.channel(&new_id).expect("the new channel folded in");
9876 assert_eq!(added.name, "announcements");
9877 assert!(!added.private);
9878 assert_eq!(added.key, None, "a public channel derives from the root (no stored key)");
9879
9880 let authors = super::super::realtime::plane_authors(std::slice::from_ref(&updated));
9882 let addr = channel_group_key(&updated.community_root, &new_id, updated.root_epoch).pk();
9883 assert!(authors.contains(&addr), "the added channel joins the live subscription");
9884
9885 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9887 assert!(reloaded.channel(&new_id).is_some());
9888 }
9889
9890 #[tokio::test]
9891 async fn follow_control_renames_the_community_and_an_existing_channel() {
9892 let (_tmp, _guard, owner) = init_test_db();
9893 let relay = MemoryRelay::new();
9894 let community = create_community(&relay, "Old Name", vec!["wss://r".into()], None).await.unwrap();
9895 let general = community.channels[0].id;
9896 publish_community_meta(&relay, &community, &owner, "New Name", 2).await;
9898 publish_channel_edition(&relay, &community, &owner, &general, "lobby", false, 2, false).await;
9899
9900 let session = SessionGuard::capture();
9901 let updated = follow_control(&relay, &community, &session).await.unwrap().unwrap();
9902 assert_eq!(updated.name, "New Name");
9903 assert_eq!(updated.channel(&general).unwrap().name, "lobby");
9904 assert_eq!(updated.channels.len(), 1, "a rename doesn't add a channel");
9905 }
9906
9907 #[tokio::test]
9908 async fn follow_control_deletes_a_channel() {
9909 let (_tmp, _guard, owner) = init_test_db();
9910 let relay = MemoryRelay::new();
9911 let community = create_community(&relay, "Prune", vec!["wss://r".into()], None).await.unwrap();
9912 let extra = ChannelId([0x77; 32]);
9913 let session = SessionGuard::capture();
9914
9915 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
9917 let with_extra = follow_control(&relay, &community, &session).await.unwrap().expect("added");
9918 assert!(with_extra.channel(&extra).is_some());
9919
9920 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
9922 let updated = follow_control(&relay, &with_extra, &session).await.unwrap().expect("removed");
9923 assert!(updated.channel(&extra).is_none(), "a deleted channel folds out");
9924 assert_eq!(updated.channels.len(), 1, "only #general remains");
9925 }
9926
9927 async fn inject_stale_prefix(relay: &MemoryRelay, community: &CommunityV2, max_version: u64, stale_relay: &str) {
9931 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
9932 let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
9933 let wraps = relay.fetch(&query, &community.relays).await.unwrap();
9934 for w in &wraps {
9935 if let Ok((ed, _)) = control::open_control_edition(w, &group) {
9936 if ed.version <= max_version {
9937 relay.inject(w, &[stale_relay.to_string()]);
9938 }
9939 }
9940 }
9941 }
9942
9943 #[tokio::test]
9944 async fn a_withholding_relay_cannot_roll_back_a_rename() {
9945 let (_tmp, _guard, owner) = init_test_db();
9948 let relay = MemoryRelay::new();
9949 let community = create_community(&relay, "Original", vec!["wss://good".into()], None).await.unwrap();
9950 publish_community_meta(&relay, &community, &owner, "Renamed", 2).await;
9951
9952 let session = SessionGuard::capture();
9953 let updated = follow_control(&relay, &community, &session).await.unwrap().expect("rename adopted");
9954 assert_eq!(updated.name, "Renamed");
9955
9956 inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
9958 let mut stale_view = updated.clone();
9959 stale_view.relays = vec!["wss://stale".into()];
9960 assert!(
9961 follow_control(&relay, &stale_view, &session).await.unwrap().is_none(),
9962 "a stale-only relay must not change the held view"
9963 );
9964 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9965 assert_eq!(held.name, "Renamed", "the persisted floor refuses the rollback");
9966 }
9967
9968 #[tokio::test]
9969 async fn a_withholding_relay_cannot_resurrect_a_deleted_channel() {
9970 let (_tmp, _guard, owner) = init_test_db();
9971 let relay = MemoryRelay::new();
9972 let community = create_community(&relay, "Prune2", vec!["wss://good".into()], None).await.unwrap();
9973 let extra = ChannelId([0x44; 32]);
9974 let session = SessionGuard::capture();
9975
9976 publish_community_meta(&relay, &community, &owner, "Prune2", 2).await;
9979 assert!(follow_control(&relay, &community, &session).await.unwrap().is_none());
9980
9981 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
9982 let with_extra = follow_control(&relay, &community, &session).await.unwrap().expect("added");
9983 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
9984 let pruned = follow_control(&relay, &with_extra, &session).await.unwrap().expect("removed");
9985 assert!(pruned.channel(&extra).is_none());
9986
9987 inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
9989 let mut stale_view = pruned.clone();
9990 stale_view.relays = vec!["wss://stale".into()];
9991 assert!(
9992 follow_control(&relay, &stale_view, &session).await.unwrap().is_none(),
9993 "the withheld delete must not resurrect the channel"
9994 );
9995 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
9996 assert!(held.channel(&extra).is_none(), "the deleted channel stays deleted");
9997 }
9998
9999 #[tokio::test]
10000 async fn a_new_epoch_bootstraps_past_an_old_epoch_floor() {
10001 let (_tmp, _guard, owner) = init_test_db();
10005 let relay = MemoryRelay::new();
10006 let community = create_community(&relay, "Before", vec!["wss://good".into()], None).await.unwrap();
10007 let session = SessionGuard::capture();
10008 publish_community_meta(&relay, &community, &owner, "Edited", 2).await;
10009 let updated = follow_control(&relay, &community, &session).await.unwrap().expect("edit adopted");
10010 assert_eq!(updated.name, "Edited");
10011
10012 let mut refounded = updated.clone();
10015 refounded.root_epoch = crate::community::Epoch(1);
10016 crate::db::community::save_community_v2(&refounded).unwrap();
10017 publish_community_meta(&relay, &refounded, &owner, "Compacted", 5).await;
10018
10019 let adopted = follow_control(&relay, &refounded, &session).await.unwrap().expect("compacted head adopted");
10020 assert_eq!(adopted.name, "Compacted", "a fresh epoch bootstraps despite the dangling prev");
10021 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10023 let heads = crate::db::community::get_all_edition_heads_epoched(&cid_hex).unwrap();
10024 assert!(
10025 heads.get(&cid_hex).is_some_and(|(e, v, _)| *e == 1 && *v == 5),
10026 "the adopted head carries the fold's epoch + version"
10027 );
10028 }
10029
10030 #[tokio::test]
10031 async fn a_same_version_owner_fork_at_the_floor_converges_to_the_deterministic_winner() {
10032 let (_tmp, _guard, owner) = init_test_db();
10037 let relay = MemoryRelay::new();
10038 let community = create_community(&relay, "Fork", vec!["wss://r".into()], None).await.unwrap();
10039 let session = SessionGuard::capture();
10040 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
10041 let genesis_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
10042
10043 publish_community_meta(&relay, &community, &owner, "Ours", 2).await;
10044 let ours = follow_control(&relay, &community, &session).await.unwrap().expect("ours adopted");
10045 assert_eq!(ours.name, "Ours");
10046
10047 let our_inner = {
10049 let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
10050 let wraps = relay.fetch(&q, &community.relays).await.unwrap();
10051 wraps
10052 .iter()
10053 .find_map(|w| {
10054 control::open_control_edition(w, &group)
10055 .ok()
10056 .filter(|(ed, _)| ed.version == 2 && ed.vsk == vsk::COMMUNITY_METADATA)
10057 .map(|(ed, _)| ed.inner_id)
10058 })
10059 .unwrap()
10060 };
10061
10062 let meta = control::CommunityMetadata { name: "Theirs".into(), ..Default::default() };
10065 let content = serde_json::to_string(&meta).unwrap();
10066 let mut ts = 2_000u64;
10067 let fork_wrap = loop {
10068 let rumor = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &content, ts, None);
10069 let inner = rumor.id.unwrap().to_bytes();
10070 if inner < our_inner {
10071 break control::seal_control_edition(&rumor, &group, &owner, Timestamp::from_secs(ts)).unwrap().0;
10072 }
10073 ts += 1;
10074 };
10075 relay.publish(&fork_wrap, &community.relays).await.unwrap();
10076
10077 let converged = follow_control(&relay, &ours, &session).await.unwrap().expect("fork winner adopted");
10078 assert_eq!(converged.name, "Theirs", "the floor converges to the lower-inner-id winner");
10079 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10080 let held = crate::db::community::get_edition_head_inner_id(&cid_hex, &cid_hex).unwrap();
10081 assert!(held.is_some_and(|h| h < our_inner), "the persisted floor's tiebreak key moved to the winner");
10082 }
10083
10084 #[tokio::test]
10085 async fn an_anchored_prefix_applies_while_a_gap_above_awaits_the_missing_link() {
10086 let (_tmp, _guard, owner) = init_test_db();
10090 let relay = MemoryRelay::new();
10091 let community = create_community(&relay, "Prefix", vec!["wss://r".into()], None).await.unwrap();
10092 let session = SessionGuard::capture();
10093 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
10094
10095 publish_community_meta(&relay, &community, &owner, "Two", 2).await;
10096 let v2_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
10097
10098 let c3 = serde_json::to_string(&control::CommunityMetadata { name: "Three".into(), ..Default::default() }).unwrap();
10100 let r3 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 3, Some(&v2_hash), &c3, 3_000, None);
10101 let (w3, _) = control::seal_control_edition(&r3, &group, &owner, Timestamp::from_secs(3_000)).unwrap();
10102 let (ed3, _) = control::open_control_edition(&w3, &group).unwrap();
10103 let c4 = serde_json::to_string(&control::CommunityMetadata { name: "Four".into(), ..Default::default() }).unwrap();
10104 let r4 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 4, Some(&ed3.self_hash), &c4, 4_000, None);
10105 let (w4, _) = control::seal_control_edition(&r4, &group, &owner, Timestamp::from_secs(4_000)).unwrap();
10106 relay.publish(&w4, &community.relays).await.unwrap();
10107
10108 let updated = follow_control(&relay, &community, &session).await.unwrap().expect("the verified prefix applies");
10109 assert_eq!(updated.name, "Two", "the anchored prefix lands; the detached v4 does not");
10110
10111 relay.publish(&w3, &community.relays).await.unwrap();
10112 let healed = follow_control(&relay, &updated, &session).await.unwrap().expect("the chain heals");
10113 assert_eq!(healed.name, "Four", "once the link arrives, the head advances past the prefix");
10114 }
10115
10116 #[tokio::test]
10117 async fn paging_rescues_a_floor_link_evicted_from_the_newest_window() {
10118 let (_tmp, _guard, owner) = init_test_db();
10122 let relay = MemoryRelay::new();
10123 let community = create_community(&relay, "Paged", vec!["wss://r".into()], None).await.unwrap();
10124 let session = SessionGuard::capture();
10125 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
10126
10127 publish_community_meta(&relay, &community, &owner, "Two", 2).await;
10128 let base = follow_control(&relay, &community, &session).await.unwrap().expect("floor at v2");
10129 publish_community_meta(&relay, &base, &owner, "Three", 3).await; let v3_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
10131
10132 let rogue = Keys::generate();
10135 for i in 0..(FOLLOW_PAGE as u64 - 1) {
10136 let rumor = control::build_edition_rumor(rogue.public_key(), vsk::CHANNEL_METADATA, &[0xCC; 32], 1, None, "{\"name\":\"junk\",\"private\":false}", 4_000 + i, None);
10137 let (w, _) = control::seal_control_edition(&rumor, &group, &rogue, Timestamp::from_secs(4_000 + i)).unwrap();
10138 relay.publish(&w, &community.relays).await.unwrap();
10139 }
10140 let c4 = serde_json::to_string(&control::CommunityMetadata { name: "Four".into(), ..Default::default() }).unwrap();
10143 let r4 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 4, Some(&v3_hash), &c4, 10_000, None);
10144 let (w4, _) = control::seal_control_edition(&r4, &group, &owner, Timestamp::from_secs(10_000)).unwrap();
10145 relay.publish(&w4, &community.relays).await.unwrap();
10146
10147 let healed = follow_control(&relay, &base, &session).await.unwrap().expect("paging recovered the chain");
10148 assert_eq!(healed.name, "Four", "the gap paged past the flood to the floor link");
10149 }
10150
10151 #[tokio::test]
10152 async fn a_follow_after_delete_does_not_resurrect_the_community() {
10153 let (_tmp, _guard, owner) = init_test_db();
10156 let relay = MemoryRelay::new();
10157 let community = create_community(&relay, "Gone", vec!["wss://r".into()], None).await.unwrap();
10158 publish_community_meta(&relay, &community, &owner, "Edited", 2).await;
10159 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10160 crate::db::community::delete_community(&cid_hex).unwrap();
10161
10162 let session = SessionGuard::capture();
10163 assert!(
10164 follow_control(&relay, &community, &session).await.unwrap().is_none(),
10165 "a follow racing a delete is a no-op"
10166 );
10167 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none(), "the community stays deleted");
10168 assert!(crate::db::community::edition_head_entity_ids(&cid_hex).unwrap().is_empty(), "no orphan floor rows");
10169 }
10170
10171 #[tokio::test]
10172 async fn a_rekey_follow_after_delete_does_not_resurrect_the_community() {
10173 let (_tmp, _guard, owner) = init_test_db();
10176 let relay = MemoryRelay::new();
10177 let community = create_community(&relay, "GoneKeys", vec!["wss://r".into()], None).await.unwrap();
10178 let new_root = [0xB2; 32];
10179 publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
10180 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10181 crate::db::community::delete_community(&cid_hex).unwrap();
10182
10183 let session = SessionGuard::capture();
10184 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
10185 assert!(follow.updated.is_none() && !follow.self_removed, "a rekey follow racing a delete adopts nothing");
10186 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none(), "the community stays deleted");
10187 }
10188
10189 #[tokio::test]
10190 async fn a_joiner_bootstraps_the_highest_head_across_a_lost_middle_edition() {
10191 let (bed, owner, member) = TestBed::new();
10196 bed.swap_to(&owner);
10197 let community = create_community(&bed.relay, "Skip", bed.relays.clone(), None).await.unwrap();
10198 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
10199 let genesis_hash = head_hash_on_relay(&bed.relay, &community, &community.id().0).await.unwrap();
10200
10201 let c2 = serde_json::to_string(&control::CommunityMetadata { name: "Two".into(), ..Default::default() }).unwrap();
10203 let r2 = control::build_edition_rumor(owner.keys.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &c2, 2_000, None);
10204 let (w2, _) = control::seal_control_edition(&r2, &group, &owner.keys, Timestamp::from_secs(2_000)).unwrap();
10205 let (ed2, _) = control::open_control_edition(&w2, &group).unwrap();
10206 let c3 = serde_json::to_string(&control::CommunityMetadata { name: "Three".into(), ..Default::default() }).unwrap();
10207 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);
10208 let (w3, _) = control::seal_control_edition(&r3, &group, &owner.keys, Timestamp::from_secs(3_000)).unwrap();
10209 bed.relay.publish(&w3, &community.relays).await.unwrap();
10210
10211 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
10212 let bundle_json = serde_json::to_string(&bundle).unwrap();
10213 bed.swap_to(&member);
10214 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
10215 assert_eq!(joined.name, "Three", "the joiner bootstraps the highest signed head, not the anchored stale prefix");
10216 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
10217 let head = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap();
10218 assert!(head.is_some_and(|(v, _)| v == 3), "the seeded floor is the bootstrap head");
10219 }
10220
10221 #[tokio::test]
10222 async fn a_losing_same_version_fork_cannot_replace_the_held_floor() {
10223 let (_tmp, _guard, owner) = init_test_db();
10227 let relay = MemoryRelay::new();
10228 let community = create_community(&relay, "Fork2", vec!["wss://good".into()], None).await.unwrap();
10229 let session = SessionGuard::capture();
10230 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
10231 let genesis_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
10232
10233 publish_community_meta(&relay, &community, &owner, "Ours", 2).await;
10234 let ours = follow_control(&relay, &community, &session).await.unwrap().expect("ours adopted");
10235 assert_eq!(ours.name, "Ours");
10236 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10237 let held_before = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().unwrap();
10238 let our_inner = crate::db::community::get_edition_head_inner_id(&cid_hex, &cid_hex).unwrap().unwrap();
10239
10240 let meta = control::CommunityMetadata { name: "Theirs".into(), ..Default::default() };
10243 let content = serde_json::to_string(&meta).unwrap();
10244 let mut ts = 5_000u64;
10245 let fork_wrap = loop {
10246 let rumor = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &content, ts, None);
10247 if rumor.id.unwrap().to_bytes() > our_inner {
10248 break control::seal_control_edition(&rumor, &group, &owner, Timestamp::from_secs(ts)).unwrap().0;
10249 }
10250 ts += 1;
10251 };
10252 inject_stale_prefix(&relay, &community, 1, "wss://stale").await; relay.inject(&fork_wrap, &["wss://stale".to_string()]);
10254 let mut stale_view = ours.clone();
10255 stale_view.relays = vec!["wss://stale".into()];
10256
10257 assert!(
10258 follow_control(&relay, &stale_view, &session).await.unwrap().is_none(),
10259 "a losing fork served without our floor edition changes nothing"
10260 );
10261 let held_after = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().unwrap();
10262 assert_eq!(held_after, held_before, "the floor row is untouched");
10263 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10264 assert_eq!(held.name, "Ours", "the held state is untouched");
10265 }
10266
10267 #[tokio::test]
10268 async fn follow_control_ignores_a_non_owner_edition() {
10269 let (_tmp, _guard, _owner) = init_test_db();
10273 let relay = MemoryRelay::new();
10274 let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
10275 let rogue = Keys::generate();
10276 let rogue_id = ChannelId([0x99; 32]);
10277 publish_channel_edition(&relay, &community, &rogue, &rogue_id, "backdoor", false, 1, false).await;
10278
10279 let session = SessionGuard::capture();
10280 assert!(
10281 follow_control(&relay, &community, &session).await.unwrap().is_none(),
10282 "a non-owner control edition is not folded"
10283 );
10284 }
10285
10286 #[tokio::test]
10287 async fn follow_control_records_a_new_private_channel_keyless_and_unreadable() {
10288 let (_tmp, _guard, owner) = init_test_db();
10292 let relay = MemoryRelay::new();
10293 let community = create_community(&relay, "Priv", vec!["wss://r".into()], None).await.unwrap();
10294 let priv_id = ChannelId([0x33; 32]);
10295 publish_channel_edition(&relay, &community, &owner, &priv_id, "mods", true, 1, false).await;
10296
10297 let session = SessionGuard::capture();
10298 let updated = follow_control(&relay, &community, &session)
10299 .await
10300 .unwrap()
10301 .expect("the keyless record is a change");
10302 let ch = updated.channel(&priv_id).expect("the private channel is recorded");
10303 assert!(ch.private && ch.key.is_none(), "recorded keyless");
10304 assert_eq!(ch.epoch, Epoch(0), "epoch 0 = the root generation (scan cursor)");
10305 assert!(updated.channel_read_coords(ch).is_empty(), "unreadable until keyed");
10306 assert!(
10307 fetch_channel(&relay, &updated, &priv_id, 50).await.unwrap().is_empty(),
10308 "a keyless fetch returns empty (and never queries the root plane)"
10309 );
10310 assert!(
10311 send_message(&relay, &updated, &priv_id, "nope").await.is_err(),
10312 "a keyless send refuses"
10313 );
10314 let reloaded = crate::db::community::load_community_v2(updated.id()).unwrap().unwrap();
10317 let rch = reloaded.channel(&priv_id).unwrap();
10318 assert!(rch.private && rch.key.is_none() && rch.epoch == Epoch(0), "keyless survives reload");
10319 let bundle = bundle_of(&reloaded, BundleAudience::Member(Keys::generate().public_key()), None, None, None);
10323 assert!(
10324 !bundle.channels.iter().any(|c| c.id == crate::simd::hex::bytes_to_hex_32(&priv_id.0)),
10325 "an ungrantable keyless channel stays out of invite bundles"
10326 );
10327 }
10328
10329 #[tokio::test]
10330 async fn a_link_bundle_never_carries_a_private_channel_key() {
10331 let (_tmp, _guard, _owner) = init_test_db();
10336 let relay = MemoryRelay::new();
10337 let community = create_community(&relay, "Leak", vec!["wss://r".into()], None).await.unwrap();
10338 create_private_channel(&relay, &community, "mods").await.unwrap();
10339 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10340 let priv_hex = held
10341 .channels
10342 .iter()
10343 .find(|c| c.private)
10344 .map(|c| crate::simd::hex::bytes_to_hex_32(&c.id.0))
10345 .expect("the private channel is held WITH its key");
10346
10347 let link = bundle_of(&held, BundleAudience::Link, None, None, None);
10348 assert!(
10349 !link.channels.iter().any(|c| c.id == priv_hex),
10350 "a held private key must never ride a link bundle"
10351 );
10352 assert!(
10353 link.channels.iter().any(|c| c.id != priv_hex),
10354 "the public channels still ride it"
10355 );
10356
10357 let stranger = bundle_of(&held, BundleAudience::Member(Keys::generate().public_key()), None, None, None);
10361 assert!(
10362 !stranger.channels.iter().any(|c| c.id == priv_hex),
10363 "an unentitled member gets no private key"
10364 );
10365 let mine = bundle_of(&held, BundleAudience::Member(me_pk().unwrap()), None, None, None);
10366 assert!(
10367 mine.channels.iter().any(|c| c.id == priv_hex),
10368 "the creator is entitled via the companion access role"
10369 );
10370 }
10371
10372 #[tokio::test]
10373 async fn a_private_channel_mints_its_access_role_and_entitlement_follows_the_grant() {
10374 let (_tmp, _guard, _owner) = init_test_db();
10377 let relay = MemoryRelay::new();
10378 let community = create_community(&relay, "Scoped", vec!["wss://r".into()], None).await.unwrap();
10379 let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
10380 let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
10381 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10382
10383 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
10384 let access = roster.channel_roles(&chan_hex);
10385 assert_eq!(access.len(), 1, "the channel minted exactly one access role");
10386 assert!(
10387 access[0].permissions == crate::community::roles::Permissions::empty(),
10388 "the access role confers READ access (key possession), never authority"
10389 );
10390 assert_eq!(access[0].name, "mods", "named for its channel");
10391
10392 let stranger = Keys::generate().public_key();
10394 let owner_hex = community.owner().unwrap().to_hex();
10395 assert!(!roster.is_entitled(Some(&owner_hex), &stranger.to_hex(), &chan_hex, &[], &[]));
10396
10397 let role_id = access[0].role_id.clone();
10400 assert!(
10401 roster.is_entitled(Some(&owner_hex), &stranger.to_hex(), &chan_hex, std::slice::from_ref(&role_id), &[]),
10402 "the grant overlay entitles before the fold catches up"
10403 );
10404
10405 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10406 grant_channel_access(&relay, &held, &priv_id, &stranger).await.unwrap();
10407 let after = crate::db::community::get_community_roles(&cid_hex).unwrap();
10408 assert!(
10409 after.is_entitled(Some(&owner_hex), &stranger.to_hex(), &chan_hex, &[], &[]),
10410 "the grant landed in the local roster (the fold runs later)"
10411 );
10412 let vend = bundle_of(&held, BundleAudience::Member(stranger), None, None, None);
10413 assert!(
10414 vend.channels.iter().any(|c| c.id == chan_hex),
10415 "a now-entitled member's bundle carries the channel key"
10416 );
10417
10418 revoke_channel_access(&relay, &held, &priv_id, &stranger).await.unwrap();
10419 let after = crate::db::community::get_community_roles(&cid_hex).unwrap();
10420 assert!(
10421 !after.is_entitled(Some(&owner_hex), &stranger.to_hex(), &chan_hex, &[], &[]),
10422 "the revoke dropped the access role"
10423 );
10424 let rotated = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10425 assert_eq!(
10426 rotated.channel(&priv_id).unwrap().epoch,
10427 Epoch(2),
10428 "the revoke rotated the channel — a removal that doesn't rekey severs nobody"
10429 );
10430
10431 let access = crate::VectorCore.channel_access(&cid_hex, &chan_hex).unwrap();
10433 assert_eq!(access["private"], true);
10434 assert_eq!(access["readable"], true, "we minted it, so we hold its key");
10435 assert_eq!(access["roles"].as_array().unwrap().len(), 1, "one access role");
10436 let holders = access["members"].as_array().unwrap();
10437 let me_npub = {
10438 use nostr_sdk::prelude::ToBech32;
10439 me_pk().unwrap().to_bech32().unwrap()
10440 };
10441 assert_eq!(holders.len(), 1, "only the creator holds it — the revoked member is gone");
10442 assert_eq!(holders[0], serde_json::json!(me_npub), "and that holder is the creator");
10443 }
10444
10445 #[tokio::test]
10446 async fn a_vended_key_parks_until_the_fold_proves_the_grant_then_adopts() {
10447 let (bed, owner, member) = TestBed::new();
10451 bed.swap_to(&owner);
10452 let community = create_community(&bed.relay, "Vend", bed.relays.clone(), None).await.unwrap();
10453 let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
10454 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10455 let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
10456 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10457 let real_key = held.channel(&priv_id).unwrap().key.unwrap();
10458 let owner_hex = community.owner().unwrap().to_hex();
10459
10460 bed.swap_to(&member);
10463 let me = member.keys.public_key().to_hex();
10464 let mut member_view = held.clone();
10467 if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
10468 c.key = None;
10469 c.epoch = Epoch(0);
10470 }
10471
10472 let empty = crate::community::roles::CommunityRoles::default();
10475 assert!(matches!(
10476 judge_channel_key_vend(&member_view, &empty, &priv_id, Epoch(1), &owner_hex),
10477 VendVerdict::Park(_)
10478 ));
10479
10480 let mut public_view = member_view.clone();
10482 if let Some(c) = public_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
10483 c.private = false;
10484 }
10485 assert!(matches!(
10486 judge_channel_key_vend(&public_view, &empty, &priv_id, Epoch(1), &owner_hex),
10487 VendVerdict::Refuse(_)
10488 ));
10489
10490 assert!(matches!(
10492 judge_channel_key_vend(&member_view, &empty, &ChannelId([0x77; 32]), Epoch(1), &owner_hex),
10493 VendVerdict::Park(_)
10494 ));
10495
10496 crate::db::community::park_channel_key(&cid_hex, &chan_hex, 1, &real_key, &owner_hex).unwrap();
10499 crate::db::community::set_community_roles(&cid_hex, &empty, 0).unwrap();
10500 crate::db::community::save_community_v2(&member_view).unwrap();
10501 let session = SessionGuard::capture();
10502 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10503 assert!(absorb_parked_channel_keys(&reloaded, &session).is_empty(), "unprovable vend adopts nothing");
10504 assert_eq!(
10505 crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().len(),
10506 1,
10507 "and stays parked for the next fold"
10508 );
10509
10510 let access = crate::community::roles::Role {
10512 role_id: "44".repeat(32),
10513 name: "mods".into(),
10514 position: u32::MAX - 1,
10515 permissions: crate::community::roles::Permissions::empty(),
10516 scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
10517 color: 0,
10518 };
10519 let folded = crate::community::roles::CommunityRoles {
10520 grants: vec![crate::community::roles::MemberGrant { member: me.clone(), role_ids: vec![access.role_id.clone()] }],
10521 roles: vec![access],
10522 };
10523 crate::db::community::set_community_roles(&cid_hex, &folded, 1).unwrap();
10524 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10525 let adopted = absorb_parked_channel_keys(&reloaded, &session);
10526 assert_eq!(adopted.len(), 1, "the re-judge adopts once the grant folds");
10527
10528 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10529 let ch = after.channel(&priv_id).unwrap();
10530 assert_eq!(ch.key, Some(real_key), "adopted the vended key");
10531 assert_eq!(ch.epoch, Epoch(1));
10532 assert!(
10533 crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
10534 "and the park is discharged"
10535 );
10536 }
10537
10538 #[tokio::test]
10539 async fn a_vend_at_epoch_zero_is_adopted_onto_a_keyless_channel() {
10540 let (bed, owner, member) = TestBed::new();
10545 bed.swap_to(&owner);
10546 let community = create_community(&bed.relay, "EpochZero", bed.relays.clone(), None).await.unwrap();
10547 let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
10548 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10549 let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
10550 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10551 let vended = [0x5a; 32];
10552 let owner_hex = community.owner().unwrap().to_hex();
10553
10554 bed.swap_to(&member);
10555 let mut member_view = held.clone();
10557 if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
10558 c.key = None;
10559 c.epoch = Epoch(0);
10560 }
10561 crate::db::community::save_community_v2(&member_view).unwrap();
10562
10563 let access = crate::community::roles::Role {
10565 role_id: "77".repeat(32),
10566 name: "mods".into(),
10567 position: u32::MAX - 1,
10568 permissions: crate::community::roles::Permissions::empty(),
10569 scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
10570 color: 0,
10571 };
10572 let roster = crate::community::roles::CommunityRoles {
10573 grants: vec![crate::community::roles::MemberGrant {
10574 member: member.keys.public_key().to_hex(),
10575 role_ids: vec![access.role_id.clone()],
10576 }],
10577 roles: vec![access],
10578 };
10579 crate::db::community::set_community_roles(&cid_hex, &roster, 1).unwrap();
10580 crate::db::community::park_channel_key(&cid_hex, &chan_hex, 0, &vended, &owner_hex).unwrap();
10581
10582 let session = SessionGuard::capture();
10583 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10584 let adopted = absorb_parked_channel_keys(&reloaded, &session);
10585 assert_eq!(adopted.len(), 1, "an epoch-0 vend onto a keyless channel is adopted");
10586
10587 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10588 let ch = after.channel(&priv_id).unwrap();
10589 assert_eq!(ch.key, Some(vended), "the key actually landed on the row");
10590 assert_eq!(ch.epoch, Epoch(0), "at the epoch the vendor named");
10591 assert!(
10592 !after.channel_read_coords(ch).is_empty(),
10593 "and the channel is readable — the whole point"
10594 );
10595 assert!(
10596 crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
10597 "the park is discharged"
10598 );
10599 }
10600
10601 #[tokio::test]
10602 async fn a_wildly_ahead_vend_epoch_is_refused_not_seated() {
10603 let (bed, owner, member) = TestBed::new();
10609 bed.swap_to(&owner);
10610 let community = create_community(&bed.relay, "Poison", bed.relays.clone(), None).await.unwrap();
10611 let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
10612 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10613 let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
10614 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10615 let owner_hex = community.owner().unwrap().to_hex();
10616
10617 bed.swap_to(&member);
10618 let mut member_view = held.clone();
10619 if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
10620 c.key = None;
10621 c.epoch = Epoch(0);
10622 }
10623 crate::db::community::save_community_v2(&member_view).unwrap();
10624 let access = crate::community::roles::Role {
10625 role_id: "99".repeat(32),
10626 name: "mods".into(),
10627 position: u32::MAX - 1,
10628 permissions: crate::community::roles::Permissions::empty(),
10629 scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
10630 color: 0,
10631 };
10632 let roster = crate::community::roles::CommunityRoles {
10633 grants: vec![crate::community::roles::MemberGrant {
10634 member: member.keys.public_key().to_hex(),
10635 role_ids: vec![access.role_id.clone()],
10636 }],
10637 roles: vec![access],
10638 };
10639 crate::db::community::set_community_roles(&cid_hex, &roster, 1).unwrap();
10640 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10641
10642 assert!(matches!(
10644 judge_channel_key_vend(&reloaded, &roster, &priv_id, Epoch(1 << 40), &owner_hex),
10645 VendVerdict::Refuse(_)
10646 ));
10647 crate::db::community::park_channel_key(&cid_hex, &chan_hex, 1 << 40, &[0xEE; 32], &owner_hex).unwrap();
10649 let session = SessionGuard::capture();
10650 assert!(absorb_parked_channel_keys(&reloaded, &session).is_empty(), "a poison epoch adopts nothing");
10651 assert!(
10652 crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
10653 "and the row is discharged rather than parked forever"
10654 );
10655 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10657 assert_eq!(after.channel(&priv_id).unwrap().epoch, Epoch(0), "head never advanced");
10658 assert!(matches!(
10659 judge_channel_key_vend(&after, &roster, &priv_id, Epoch(1), &owner_hex),
10660 VendVerdict::Accept
10661 ));
10662 }
10663
10664 #[tokio::test]
10665 async fn a_channel_rename_lands_locally_without_waiting_for_the_fold() {
10666 let (_tmp, _guard, _owner) = init_test_db();
10669 let relay = MemoryRelay::new();
10670 let community = create_community(&relay, "Renames", vec!["wss://r".into()], None).await.unwrap();
10671 let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
10672 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10673 let key_before = held.channel(&priv_id).unwrap().key;
10674
10675 let mut meta = held.channel(&priv_id).unwrap().metadata();
10676 meta.name = "staff".into();
10677 edit_channel_metadata(&relay, &held, &priv_id, &meta).await.unwrap();
10678
10679 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10680 let ch = after.channel(&priv_id).unwrap();
10681 assert_eq!(ch.name, "staff", "the rename is visible immediately");
10682 assert!(ch.private, "and privacy survives the edit");
10683 assert_eq!(ch.key, key_before, "as does the key — a rename is not a rotation");
10684 }
10685
10686 #[tokio::test]
10687 async fn a_channel_rename_carries_its_companion_access_role() {
10688 let (_tmp, _guard, _owner) = init_test_db();
10689 let relay = MemoryRelay::new();
10690 let community = create_community(&relay, "Renames", vec!["wss://r".into()], None).await.unwrap();
10691 let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
10692 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10693 let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
10694
10695 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
10696 let before = roster.channel_roles(&chan_hex);
10697 assert_eq!(before.len(), 1, "one companion role, minted at create");
10698 assert_eq!(before[0].name, "mods", "named after the channel it gates");
10699 let role_id = before[0].role_id.clone();
10700
10701 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10702 let mut meta = held.channel(&priv_id).unwrap().metadata();
10703 meta.name = "staff".into();
10704 edit_channel_metadata(&relay, &held, &priv_id, &meta).await.unwrap();
10705
10706 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
10707 let after = roster.channel_roles(&chan_hex);
10708 assert_eq!(after.len(), 1, "renamed in place, never duplicated");
10709 assert_eq!(after[0].role_id, role_id, "a rename is a versioned edit of the same id");
10710 assert_eq!(after[0].name, "staff", "the access role followed the channel");
10711 assert_eq!(
10712 after[0].permissions,
10713 crate::community::roles::Permissions::empty(),
10714 "and still confers read access, never authority"
10715 );
10716 }
10717
10718 #[tokio::test]
10719 async fn a_customised_access_role_name_survives_a_channel_rename() {
10720 let (_tmp, _guard, _owner) = init_test_db();
10723 let relay = MemoryRelay::new();
10724 let community = create_community(&relay, "Renames", vec!["wss://r".into()], None).await.unwrap();
10725 let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
10726 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10727 let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
10728
10729 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
10730 let mut role = roster.channel_roles(&chan_hex)[0].clone();
10731 role.name = "Lab Insiders".into();
10732 set_role(&relay, &community, &role).await.unwrap();
10733 merge_local_roster(&cid_hex, Some(&role), None);
10734
10735 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10736 let mut meta = held.channel(&priv_id).unwrap().metadata();
10737 meta.name = "staff".into();
10738 edit_channel_metadata(&relay, &held, &priv_id, &meta).await.unwrap();
10739
10740 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
10741 assert_eq!(
10742 roster.channel_roles(&chan_hex)[0].name,
10743 "Lab Insiders",
10744 "a deliberate name is left alone"
10745 );
10746 }
10747
10748 #[tokio::test]
10749 async fn a_squatted_park_row_cannot_suppress_the_genuine_vend() {
10750 let (bed, owner, member) = TestBed::new();
10756 bed.swap_to(&owner);
10757 let community = create_community(&bed.relay, "Squat", bed.relays.clone(), None).await.unwrap();
10758 let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
10759 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10760 let chan_hex = crate::simd::hex::bytes_to_hex_32(&priv_id.0);
10761 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10762 let real_key = held.channel(&priv_id).unwrap().key.unwrap();
10763 let owner_hex = community.owner().unwrap().to_hex();
10764
10765 bed.swap_to(&member);
10766 let mut member_view = held.clone();
10767 if let Some(c) = member_view.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
10768 c.key = None;
10769 c.epoch = Epoch(0);
10770 }
10771 crate::db::community::save_community_v2(&member_view).unwrap();
10772 let access = crate::community::roles::Role {
10773 role_id: "aa".repeat(32),
10774 name: "mods".into(),
10775 position: u32::MAX - 1,
10776 permissions: crate::community::roles::Permissions::empty(),
10777 scope: crate::community::roles::RoleScope::Channel(chan_hex.clone()),
10778 color: 0,
10779 };
10780 let roster = crate::community::roles::CommunityRoles {
10781 grants: vec![crate::community::roles::MemberGrant {
10782 member: member.keys.public_key().to_hex(),
10783 role_ids: vec![access.role_id.clone()],
10784 }],
10785 roles: vec![access],
10786 };
10787 crate::db::community::set_community_roles(&cid_hex, &roster, 1).unwrap();
10788
10789 let stranger = Keys::generate().public_key().to_hex();
10791 crate::db::community::park_channel_key(&cid_hex, &chan_hex, 9, &[0xBA; 32], &stranger).unwrap();
10792 crate::db::community::park_channel_key(&cid_hex, &chan_hex, 1, &real_key, &owner_hex).unwrap();
10794 assert_eq!(
10795 crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().len(),
10796 2,
10797 "the squatter never displaces the genuine vend — both are candidates"
10798 );
10799
10800 let session = SessionGuard::capture();
10801 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10802 let adopted = absorb_parked_channel_keys(&reloaded, &session);
10803 assert_eq!(adopted.len(), 1, "exactly one adoption");
10804
10805 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10806 let ch = after.channel(&priv_id).unwrap();
10807 assert_eq!(ch.key, Some(real_key), "the OWNER's key won, not the squatter's");
10808 assert_eq!(ch.epoch, Epoch(1), "at the genuine epoch");
10809 assert!(
10810 crate::db::community::get_pending_channel_keys(&cid_hex).unwrap().is_empty(),
10811 "and every candidate for the channel is discharged"
10812 );
10813 }
10814
10815 #[tokio::test]
10816 async fn revoking_without_a_folded_access_role_refuses_instead_of_evicting_everyone() {
10817 let (_tmp, _guard, _owner) = init_test_db();
10822 let relay = MemoryRelay::new();
10823 let community = create_community(&relay, "NoRole", vec!["wss://r".into()], None).await.unwrap();
10824 let priv_id = create_private_channel(&relay, &community, "mods").await.unwrap();
10825 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
10826 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10827 let before = held.channel(&priv_id).unwrap().epoch;
10828
10829 crate::db::community::set_community_roles(&cid_hex, &crate::community::roles::CommunityRoles::default(), 0).unwrap();
10833 let mut blind = held.clone();
10834 blind.relays = vec!["wss://empty".into()];
10835 let err = revoke_channel_access(&relay, &blind, &priv_id, &Keys::generate().public_key())
10836 .await
10837 .unwrap_err();
10838 assert!(err.contains("has not folded"), "refuses with a retryable reason: {err}");
10839
10840 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
10841 assert_eq!(after.channel(&priv_id).unwrap().epoch, before, "and rotates nothing");
10842 }
10843
10844 async fn publish_base_rotation(
10851 relay: &MemoryRelay,
10852 community: &CommunityV2,
10853 rotator: &Keys,
10854 recipients: &[PublicKey],
10855 new_root: &[u8; 32],
10856 prev_key: &[u8; 32],
10857 ) {
10858 let new_epoch = Epoch(community.root_epoch.0 + 1);
10859 let prev_epoch = community.root_epoch;
10860 let prev_commit = super::super::derive::epoch_key_commitment(prev_epoch, prev_key);
10861 let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
10862 let blobs: Vec<_> = recipients
10863 .iter()
10864 .map(|r| rekey::build_blob_local(rotator.secret_key(), &rotator.public_key().to_bytes(), r, RekeyScope::Root, new_epoch, new_root).unwrap())
10865 .collect();
10866 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();
10867 for e in &events {
10868 relay.publish(e, &community.relays).await.unwrap();
10869 }
10870 }
10871
10872 fn add_private_channel(community: &mut CommunityV2, id: ChannelId, key: [u8; 32], epoch: Epoch) {
10874 community.channels.push(ChannelV2 { id, name: "mods".into(), private: true, key: Some(key), epoch, voice: None, meta_custom: None, meta_extra: Default::default() });
10875 crate::db::community::save_community_v2(community).unwrap();
10876 }
10877
10878 #[tokio::test]
10879 async fn follow_rekeys_is_a_noop_without_rotations() {
10880 let (_tmp, _guard, _owner) = init_test_db();
10881 let relay = MemoryRelay::new();
10882 let community = create_community(&relay, "Still", vec!["wss://r".into()], None).await.unwrap();
10883 let session = SessionGuard::capture();
10884 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
10885 assert!(follow.updated.is_none() && !follow.self_removed, "no rotation → nothing to adopt");
10886 }
10887
10888 #[tokio::test]
10889 async fn follow_rekeys_adopts_an_owner_base_rotation() {
10890 let (_tmp, _guard, owner) = init_test_db();
10891 let relay = MemoryRelay::new();
10892 let community = create_community(&relay, "Refound", vec!["wss://r".into()], None).await.unwrap();
10893 let new_root = [0xB1; 32];
10894 publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
10896
10897 let session = SessionGuard::capture();
10898 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopted");
10899 assert_eq!(updated.root_epoch, Epoch(1), "advanced one epoch");
10900 assert_eq!(updated.community_root, new_root, "adopted the fresh root");
10901 let addr = super::super::realtime::plane_authors(std::slice::from_ref(&updated));
10903 let general = updated.channels[0].id;
10904 let new_chat = channel_group_key(&new_root, &general, Epoch(1)).pk();
10905 assert!(addr.contains(&new_chat), "the public channel re-addresses under the new root");
10906 }
10907
10908 #[tokio::test]
10909 async fn follow_rekeys_adopts_an_owner_private_channel_rotation() {
10910 let (_tmp, _guard, owner) = init_test_db();
10911 let relay = MemoryRelay::new();
10912 let mut community = create_community(&relay, "PrivRot", vec!["wss://r".into()], None).await.unwrap();
10913 let priv_id = ChannelId([0x33; 32]);
10914 add_private_channel(&mut community, priv_id, [0x44; 32], Epoch(0));
10915
10916 let new_key = [0x55; 32];
10918 let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &[0x44; 32]);
10919 let group = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(1));
10920 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();
10921 let events = rekey::build_rekey_chunks_local(&owner, &group, RekeyScope::Channel(priv_id), Epoch(1), Epoch(0), &prev_commit, &[blob], 2_000, None).unwrap();
10922 for e in &events {
10923 relay.publish(e, &community.relays).await.unwrap();
10924 }
10925
10926 let session = SessionGuard::capture();
10927 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopted");
10928 let ch = updated.channel(&priv_id).unwrap();
10929 assert_eq!(ch.epoch, Epoch(1), "the private channel advanced an epoch");
10930 assert_eq!(ch.key, Some(new_key), "adopted the fresh channel key");
10931 assert_eq!(updated.root_epoch, Epoch(0), "the base is untouched by a channel rotation");
10932 }
10933
10934 #[tokio::test]
10935 async fn follow_rekeys_ignores_a_non_owner_rotation() {
10936 let (_tmp, _guard, _owner) = init_test_db();
10939 let relay = MemoryRelay::new();
10940 let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
10941 let rogue = Keys::generate();
10942 publish_base_rotation(&relay, &community, &rogue, &[rogue.public_key()], &[0xEE; 32], &community.community_root).await;
10943
10944 let session = SessionGuard::capture();
10945 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
10946 assert!(follow.updated.is_none() && !follow.self_removed, "a non-owner rotation is not adopted");
10947 }
10948
10949 #[tokio::test]
10950 async fn follow_rekeys_ignores_a_rotation_off_the_wrong_prev() {
10951 let (_tmp, _guard, owner) = init_test_db();
10954 let relay = MemoryRelay::new();
10955 let community = create_community(&relay, "Forked", vec!["wss://r".into()], None).await.unwrap();
10956 publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &[0xB2; 32], &[0x00; 32]).await;
10958
10959 let session = SessionGuard::capture();
10960 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
10961 assert!(follow.updated.is_none(), "a fork off the wrong prev is not adopted");
10962 }
10963
10964 #[tokio::test]
10965 async fn follow_rekeys_holds_on_an_incomplete_rotation() {
10966 let (_tmp, _guard, owner) = init_test_db();
10969 let relay = MemoryRelay::new();
10970 let community = create_community(&relay, "Partial", vec!["wss://r".into()], None).await.unwrap();
10971 let new_epoch = Epoch(1);
10972 let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
10973 let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
10974 let other = Keys::generate();
10976 let blob = rekey::build_blob_local(owner.secret_key(), &owner.public_key().to_bytes(), &other.public_key(), RekeyScope::Root, new_epoch, &[0xB3; 32]).unwrap();
10977 let rumor = rekey::build_rekey_rumor(owner.public_key(), RekeyScope::Root, new_epoch, Epoch(0), &prev_commit, &[blob], 1, 2, 2_000, None).unwrap();
10978 let (wrap, _) = rekey::seal_rekey_chunk(&rumor, &group, &owner, Timestamp::from_secs(2_000)).unwrap();
10979 relay.publish(&wrap, &community.relays).await.unwrap();
10980
10981 let session = SessionGuard::capture();
10982 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
10983 assert!(follow.updated.is_none() && !follow.self_removed, "an incomplete rotation neither adopts nor removes");
10984 }
10985
10986 #[tokio::test]
10987 async fn follow_rekeys_removes_a_member_dropped_by_a_base_rotation() {
10988 let (bed, owner, member) = TestBed::new();
10992 bed.swap_to(&owner);
10993 let community = create_community(&bed.relay, "Evict", bed.relays.clone(), None).await.unwrap();
10994 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
10995
10996 bed.swap_to(&member);
10997 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
10998 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
10999
11000 bed.swap_to(&owner);
11002 let stranger = Keys::generate();
11003 publish_base_rotation(&bed.relay, &community, &owner.keys, &[stranger.public_key()], &[0xC4; 32], &community.community_root).await;
11004
11005 bed.swap_to(&member);
11007 let session = SessionGuard::capture();
11008 let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
11009 assert!(follow.self_removed, "a complete base rotation dropping the member removes them");
11010 assert!(follow.updated.is_none(), "a removed member adopts nothing");
11011 }
11012
11013 #[tokio::test]
11014 async fn follow_rekeys_finds_a_channel_rekey_under_an_archived_prior_root() {
11015 let (_tmp, _guard, owner) = init_test_db();
11021 let relay = MemoryRelay::new();
11022 let mut community = create_community(&relay, "Strand", vec!["wss://r".into()], None).await.unwrap();
11023 let root0 = community.community_root;
11024 let priv_id = ChannelId([0x33; 32]);
11025 let key1 = [0x44; 32];
11026 add_private_channel(&mut community, priv_id, key1, Epoch(1));
11027
11028 let key2 = [0x55; 32];
11031 let prev_commit = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
11032 let group = channel_rekey_group_key(&root0, &priv_id, Epoch(2));
11033 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();
11034 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() {
11035 relay.publish(&e, &community.relays).await.unwrap();
11036 }
11037
11038 community.community_root = [0xB7; 32];
11042 community.root_epoch = Epoch(1);
11043 crate::db::community::save_community_v2(&community).unwrap();
11044
11045 let session = SessionGuard::capture();
11046 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the prior-root crate is found");
11047 let ch = updated.channel(&priv_id).unwrap();
11048 assert_eq!(ch.epoch, Epoch(2), "the channel advanced despite the moved base");
11049 assert_eq!(ch.key, Some(key2), "adopted the key delivered under the prior root");
11050 }
11051
11052 #[tokio::test]
11053 async fn follow_rekeys_keyless_cursor_walks_past_an_excluding_rotation_then_adopts() {
11054 let (_tmp, _guard, owner) = init_test_db();
11059 let relay = MemoryRelay::new();
11060 let mut community = create_community(&relay, "Cursor", vec!["wss://r".into()], None).await.unwrap();
11061 let priv_id = ChannelId([0x66; 32]);
11062 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() });
11063 crate::db::community::save_community_v2(&community).unwrap();
11064 let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11065 assert!(community.channel(&priv_id).unwrap().key.is_none(), "keyless survives the round-trip");
11066
11067 let stranger = Keys::generate();
11069 let key1 = [0x71; 32];
11070 let pc1 = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
11071 let g1 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(1));
11072 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();
11073 for e in rekey::build_rekey_chunks_local(&owner, &g1, RekeyScope::Channel(priv_id), Epoch(1), Epoch(0), &pc1, &[b1], 2_000, None).unwrap() {
11074 relay.publish(&e, &community.relays).await.unwrap();
11075 }
11076 let key2 = [0x72; 32];
11079 let pc2 = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
11080 let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
11081 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();
11082 for e in rekey::build_rekey_chunks_local(&owner, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc2, &[b2], 2_100, None).unwrap() {
11083 relay.publish(&e, &community.relays).await.unwrap();
11084 }
11085
11086 let session = SessionGuard::capture();
11089 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the walk lands on the included epoch");
11090 let ch = updated.channel(&priv_id).unwrap();
11091 assert_eq!(ch.epoch, Epoch(2), "cursor walked through the excluding epoch to the included one");
11092 assert_eq!(ch.key, Some(key2), "adopted the delivery that includes us");
11093 }
11094
11095 #[tokio::test]
11096 async fn follow_rekeys_honors_an_admin_channel_rotation_but_never_a_strangers() {
11097 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
11101 let (_tmp, _guard, _owner) = init_test_db();
11102 let relay = MemoryRelay::new();
11103 let mut community = create_community(&relay, "AdminRot", vec!["wss://r".into()], None).await.unwrap();
11104 let priv_id = ChannelId([0x88; 32]);
11105 let key1 = [0x91; 32];
11106 add_private_channel(&mut community, priv_id, key1, Epoch(1));
11107
11108 let admin = Keys::generate();
11110 let role = Role::admin("aa".repeat(32));
11111 let roster = CommunityRoles {
11112 roles: vec![role.clone()],
11113 grants: vec![MemberGrant { member: admin.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }],
11114 };
11115 seed_roster_with_heads(&community, &roster, 1_000);
11116
11117 let key2 = [0x92; 32];
11119 let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
11120 let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
11121 let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
11122 let blob = rekey::build_blob_local(admin.secret_key(), &admin.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
11123 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() {
11124 relay.publish(&e, &community.relays).await.unwrap();
11125 }
11126 let session = SessionGuard::capture();
11127 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("an admin rotation is honored");
11128 assert_eq!(updated.channel(&priv_id).unwrap().key, Some(key2), "adopted the admin's key");
11129
11130 let rogue = Keys::generate();
11132 let key3 = [0x93; 32];
11133 let pc3 = super::super::derive::epoch_key_commitment(Epoch(2), &key2);
11134 let g3 = channel_rekey_group_key(&updated.community_root, &priv_id, Epoch(3));
11135 let rb = rekey::build_blob_local(rogue.secret_key(), &rogue.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(3), &key3).unwrap();
11136 for e in rekey::build_rekey_chunks_local(&rogue, &g3, RekeyScope::Channel(priv_id), Epoch(3), Epoch(2), &pc3, &[rb], 2_100, None).unwrap() {
11137 relay.publish(&e, &updated.relays).await.unwrap();
11138 }
11139 let follow = follow_rekeys(&relay, &updated, &session).await.unwrap();
11140 assert!(follow.updated.is_none(), "a stranger's channel rotation is never adopted");
11141 }
11142
11143 #[tokio::test]
11144 async fn a_non_outranking_admins_rotation_never_concludes_my_removal() {
11145 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
11150 let (bed, owner, member) = TestBed::new();
11151 bed.swap_to(&owner);
11152 let community = create_community(&bed.relay, "Outrank", bed.relays.clone(), None).await.unwrap();
11153
11154 bed.swap_to(&member);
11157 let mut held = community.clone();
11158 let priv_id = ChannelId([0xAB; 32]);
11159 let key1 = [0xA1; 32];
11160 add_private_channel(&mut held, priv_id, key1, Epoch(1));
11161 let peer = Keys::generate();
11162 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11163 let role = Role::admin("bb".repeat(32));
11164 let roster = CommunityRoles {
11165 roles: vec![role.clone()],
11166 grants: vec![
11167 MemberGrant { member: peer.public_key().to_hex(), role_ids: vec![role.role_id.clone()] },
11168 MemberGrant { member: member.keys.public_key().to_hex(), role_ids: vec![role.role_id.clone()] },
11169 ],
11170 };
11171 crate::db::community::set_community_roles(&cid_hex, &roster, 1_000).unwrap();
11172
11173 let key2 = [0xA2; 32];
11175 let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
11176 let g2 = channel_rekey_group_key(&held.community_root, &priv_id, Epoch(2));
11177 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();
11178 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() {
11179 bed.relay.publish(&e, &held.relays).await.unwrap();
11180 }
11181 let session = SessionGuard::capture();
11182 let follow = follow_rekeys(&bed.relay, &held, &session).await.unwrap();
11183 assert!(follow.updated.is_none(), "an equal-rank rotation excluding me is Stay, never my removal");
11184 let reloaded = crate::db::community::load_community_v2(held.id()).unwrap().unwrap();
11185 assert!(reloaded.channel(&priv_id).is_some(), "my channel record survives the peer's rotation");
11186
11187 let key3 = [0xA3; 32];
11189 let stranger = Keys::generate();
11190 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();
11191 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() {
11192 bed.relay.publish(&e, &held.relays).await.unwrap();
11193 }
11194 let follow = follow_rekeys(&bed.relay, &held, &session).await.unwrap();
11195 let updated = follow.updated.expect("the owner's removal folds");
11196 assert!(updated.channel(&priv_id).is_none(), "the owner's exclusion cuts my channel record");
11197 }
11198
11199 #[tokio::test]
11200 async fn converting_a_public_channel_to_private_is_refused() {
11201 let (_tmp, _guard, _owner) = init_test_db();
11204 let relay = MemoryRelay::new();
11205 let community = create_community(&relay, "NoConvert", vec!["wss://r".into()], None).await.unwrap();
11206 let general = community.channels[0].id;
11207 let meta = control::ChannelMetadata { name: "general".into(), private: true, voice: None, deleted: None, custom: None, extra: Default::default() };
11208 let err = edit_channel_metadata(&relay, &community, &general, &meta).await.unwrap_err();
11209 assert!(err.contains("not supported"), "conversion is refused at the producer: {err}");
11210 let meta = control::ChannelMetadata { name: "lobby".into(), private: false, voice: None, deleted: None, custom: None, extra: Default::default() };
11212 edit_channel_metadata(&relay, &community, &general, &meta).await.unwrap();
11213 }
11214
11215 async fn publish_remote_tombstone(relay: &MemoryRelay, me: &Keys, relays: &[String], cid_hex: &str, removed_at: u64) {
11218 let doc = super::super::list::CommunityList {
11219 entries: vec![],
11220 tombstones: vec![super::super::list::Tombstone { community_id: cid_hex.to_string(), removed_at, extra: Default::default() }],
11221 extra: Default::default(),
11222 };
11223 let event = super::super::list::build_list_event(me, &doc).unwrap();
11224 relay.publish(&event, relays).await.unwrap();
11225 }
11226
11227 #[tokio::test]
11228 async fn joining_one_community_does_not_resurrect_a_sibling_left_community() {
11229 let (_tmp, _guard, me) = init_test_db();
11233 let relay = MemoryRelay::new();
11234 let x = create_community(&relay, "X", vec!["wss://r".into()], None).await.unwrap();
11235 let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
11236
11237 publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, now_ms() + 10_000).await;
11239
11240 let y = create_community(&relay, "Y", vec!["wss://r".into()], None).await.unwrap();
11242 republish_community_list(&relay, Some(y.id())).await.unwrap();
11243
11244 let list = fetch_community_list(&relay, &x.relays).await.unwrap().unwrap();
11246 assert!(!list.is_live(&x_hex), "joining Y did not resurrect the sibling-left X");
11247 assert!(list.is_live(&crate::simd::hex::bytes_to_hex_32(&y.id().0)), "Y is live");
11248 }
11249
11250 #[tokio::test]
11251 async fn sync_tears_down_a_community_a_sibling_left() {
11252 let (_tmp, _guard, me) = init_test_db();
11255 let relay = MemoryRelay::new();
11256 let x = create_community(&relay, "Leaveme", vec!["wss://r".into()], None).await.unwrap();
11257 let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
11258 assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_some(), "held before sync");
11259
11260 publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, now_ms() + 10_000).await;
11261 sync_community_list(&relay, &x.relays).await.unwrap();
11262 assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_none(), "the sibling's leave tore X down locally");
11263 }
11264
11265 #[tokio::test]
11266 async fn a_rejoined_community_survives_a_stale_tombstone_on_sync() {
11267 let (_tmp, _guard, me) = init_test_db();
11270 let relay = MemoryRelay::new();
11271 let x = create_community(&relay, "Rejoin", vec!["wss://r".into()], None).await.unwrap();
11272 let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
11273 publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, 1).await;
11275 republish_community_list(&relay, Some(x.id())).await.unwrap();
11277 sync_community_list(&relay, &x.relays).await.unwrap();
11278 assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_some(), "a re-joined community is not torn down by a stale tombstone");
11279 }
11280
11281 #[tokio::test]
11282 async fn a_failed_remote_fetch_never_clobbers_the_published_list() {
11283 let (_tmp, _guard, _me) = init_test_db();
11286 let good = MemoryRelay::new();
11287 let community = create_community(&good, "Seeded", vec!["wss://r".into()], None).await.unwrap();
11288 assert!(fetch_community_list(&good, &community.relays).await.unwrap().is_some());
11289
11290 struct FetchErrors;
11292 #[async_trait::async_trait]
11293 impl Transport for FetchErrors {
11294 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
11295 async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
11296 panic!("republish must NOT publish when the remote fetch failed");
11297 }
11298 async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
11299 Ok(())
11300 }
11301 async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
11302 Err("relay unreachable".to_string())
11303 }
11304 }
11305 republish_community_list(&FetchErrors, Some(community.id())).await.unwrap();
11307 }
11308
11309 #[tokio::test]
11310 async fn a_granted_member_survives_a_refounding_even_with_no_guestbook_join() {
11311 let (_tmp, _guard, owner) = init_test_db();
11317 let relay = MemoryRelay::new();
11318 let community = create_community(&relay, "Backstop", vec!["wss://r".into()], None).await.unwrap();
11319
11320 let lurker = Keys::generate();
11322 let rid = "b1".repeat(32);
11323 publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
11324 publish_grant(&relay, &community, &owner, &lurker.public_key(), vec![rid.clone()], 1).await;
11325
11326 let members = memberlist(&relay, &community).await.unwrap();
11328 assert!(members.contains(&lurker.public_key()), "a granted member with no Join is still a member");
11329
11330 let banned_grantee = Keys::generate();
11332 publish_grant(&relay, &community, &owner, &banned_grantee.public_key(), vec![rid], 1).await;
11333 set_banlist(&relay, &community, &[banned_grantee.public_key().to_hex()]).await.unwrap();
11334 let members = memberlist(&relay, &community).await.unwrap();
11335 assert!(members.contains(&lurker.public_key()), "the honest grantee still counts");
11336 assert!(!members.contains(&banned_grantee.public_key()), "a banned grantee is not re-admitted by the union");
11337
11338 let refounded = refound_community(&relay, &community, &[]).await.unwrap();
11340 assert_eq!(refounded.root_epoch, Epoch(1));
11341 let base_group = base_rekey_group_key(&community.community_root, community.id(), Epoch(1));
11342 let chunks = fetch_rekey_chunks(&relay, &community.relays, &base_group).await.unwrap();
11343 let rotations = rekey::collect_rotations(&chunks);
11344 let lurker_x = lurker.public_key().to_bytes();
11345 let delivered = rotations.iter().any(|r| {
11346 rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &lurker_x, r.scope, r.new_epoch).is_some()
11347 });
11348 assert!(delivered, "the Refounding delivered the new root to the granted lurker");
11349 }
11350
11351 #[tokio::test]
11352 async fn the_memberlist_pages_past_a_guestbook_flood() {
11353 let (_tmp, _guard, _owner) = init_test_db();
11357 let relay = MemoryRelay::new();
11358 let community = create_community(&relay, "GBFlood", vec!["wss://r".into()], None).await.unwrap();
11359 let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
11360
11361 let honest = Keys::generate();
11363 let join = guestbook::build_join_rumor(honest.public_key(), None, 1_000);
11364 let (w, _) = guestbook::seal_guestbook_rumor(&join, &gb, &honest, Timestamp::from_secs(1)).unwrap();
11365 relay.publish(&w, &community.relays).await.unwrap();
11366 for i in 0..600u64 {
11367 let throwaway = Keys::generate();
11368 let j = guestbook::build_join_rumor(throwaway.public_key(), None, 2_000 + i);
11369 let (w, _) = guestbook::seal_guestbook_rumor(&j, &gb, &throwaway, Timestamp::from_secs(2 + i)).unwrap();
11370 relay.publish(&w, &community.relays).await.unwrap();
11371 }
11372
11373 let members = memberlist(&relay, &community).await.unwrap();
11374 assert!(members.contains(&honest.public_key()), "the honest member's aged-out Join is still counted past the flood");
11375 }
11376
11377 #[tokio::test]
11378 async fn a_rekey_plane_flood_cannot_bury_a_genuine_rotation() {
11379 let (_tmp, _guard, owner) = init_test_db();
11384 let relay = MemoryRelay::new();
11385 let community = create_community(&relay, "Flooded", vec!["wss://r".into()], None).await.unwrap();
11386 let new_root = [0xD9; 32];
11387 let new_epoch = Epoch(1);
11388 let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
11389
11390 publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
11392
11393 let rogue = Keys::generate();
11396 let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
11397 for i in 0..260u64 {
11398 let blob = rekey::build_blob_local(rogue.secret_key(), &rogue.public_key().to_bytes(), &rogue.public_key(), RekeyScope::Root, new_epoch, &[0xEE; 32]).unwrap();
11399 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();
11400 let (wrap, _) = rekey::seal_rekey_chunk(&rumor, &group, &rogue, Timestamp::from_secs(3_000 + i)).unwrap();
11401 relay.publish(&wrap, &community.relays).await.unwrap();
11402 }
11403
11404 let session = SessionGuard::capture();
11405 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the genuine rotation is recovered past the flood");
11406 assert_eq!(updated.root_epoch, Epoch(1));
11407 assert_eq!(updated.community_root, new_root, "adopted the owner's root, not a junk one");
11408 }
11409
11410 #[tokio::test]
11411 async fn a_swap_during_create_private_channel_aborts_without_a_write() {
11412 let (bed, owner, _member) = TestBed::new();
11417 bed.swap_to(&owner);
11418 let community = create_community(&bed.relay, "SwapCreate", bed.relays.clone(), None).await.unwrap();
11419 let before = crate::db::community::load_community_v2(community.id()).unwrap().unwrap().channels.len();
11420
11421 let swap_relay = SwapMidPublish { inner: MemoryRelay::new() };
11423 let err = create_private_channel(&swap_relay, &community, "ghost").await.unwrap_err();
11424 assert!(err.contains("account changed"), "a swap mid-create aborts: {err}");
11425 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11426 assert_eq!(after.channels.len(), before, "no channel row was written");
11427 assert!(!after.channels.iter().any(|c| c.name == "ghost"), "the ghost channel never persisted");
11428 }
11429
11430 #[tokio::test]
11431 async fn an_uncited_admin_rotation_is_not_adopted() {
11432 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
11437 let (_tmp, _guard, _owner) = init_test_db();
11438 let relay = MemoryRelay::new();
11439 let mut community = create_community(&relay, "Uncited", vec!["wss://r".into()], None).await.unwrap();
11440 let priv_id = ChannelId([0x8A; 32]);
11441 let key1 = [0x93; 32];
11442 add_private_channel(&mut community, priv_id, key1, Epoch(1));
11443
11444 let admin = Keys::generate();
11445 let role = Role::admin("cf".repeat(32));
11446 let roster = CommunityRoles {
11447 roles: vec![role.clone()],
11448 grants: vec![MemberGrant { member: admin.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }],
11449 };
11450 seed_roster_with_heads(&community, &roster, 1_000);
11451
11452 let key2 = [0x94; 32];
11453 let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
11454 let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
11455 let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
11456 let blob = rekey::build_blob_local(admin.secret_key(), &admin.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
11457 for e in rekey::build_rekey_chunks_local(&admin, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[blob], 2_000, None).unwrap() {
11459 relay.publish(&e, &community.relays).await.unwrap();
11460 }
11461
11462 let out = follow_rekeys(&relay, &community, &SessionGuard::capture()).await.unwrap();
11463 assert!(out.updated.is_none(), "an uncited rotation is not adopted");
11464
11465 let cited = my_authority_citation(&community, &admin.public_key());
11468 assert!(cited.is_some(), "the seeded head yields a citation");
11469 let blob2 = rekey::build_blob_local(admin.secret_key(), &admin.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
11470 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() {
11471 relay.publish(&e, &community.relays).await.unwrap();
11472 }
11473 let out = follow_rekeys(&relay, &community, &SessionGuard::capture()).await.unwrap();
11474 assert!(out.updated.is_some(), "the cited rotation IS adopted");
11475 }
11476
11477 #[tokio::test]
11478 async fn two_admins_racing_a_channel_rotation_converge_on_one_key() {
11479 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
11487 let (_tmp, _guard, _owner) = init_test_db();
11488 let relay = MemoryRelay::new();
11489 let mut community = create_community(&relay, "Race", vec!["wss://r".into()], None).await.unwrap();
11490 let priv_id = ChannelId([0xC0; 32]);
11491 let key1 = [0xC1; 32];
11492 add_private_channel(&mut community, priv_id, key1, Epoch(1));
11493
11494 let (a, b) = (Keys::generate(), Keys::generate());
11496 let role = Role::admin("ce".repeat(32));
11497 let roster = CommunityRoles {
11498 roles: vec![role.clone()],
11499 grants: [&a, &b].iter().map(|k| MemberGrant { member: k.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }).collect(),
11500 };
11501 seed_roster_with_heads(&community, &roster, 1_000);
11502
11503 let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
11506 let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
11507 let group = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
11508 let key_a = [0x0A; 32];
11509 let key_b = [0xFB; 32]; for (signer, k) in [(&a, &key_a), (&b, &key_b)] {
11511 let blob = rekey::build_blob_local(signer.secret_key(), &signer.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), k).unwrap();
11512 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() {
11513 relay.publish(&e, &community.relays).await.unwrap();
11514 }
11515 }
11516
11517 let session = SessionGuard::capture();
11518 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopts a winner");
11519 let adopted = updated.channel(&priv_id).unwrap().key.unwrap();
11520 assert_eq!(adopted, key_a, "converges on the lexicographically lowest key (deterministic across clients)");
11521
11522 let mut peer = community.clone();
11524 if let Some(c) = peer.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
11525 c.key = Some(key1);
11526 c.epoch = Epoch(1);
11527 }
11528 let updated2 = follow_rekeys(&relay, &peer, &session).await.unwrap().updated.expect("peer adopts");
11530 assert_eq!(updated2.channel(&priv_id).unwrap().key.unwrap(), key_a, "every follower lands on the identical key");
11531 }
11532
11533 #[tokio::test]
11534 async fn create_private_channel_refuses_a_member_without_manage_channels() {
11535 let (bed, owner, member) = TestBed::new();
11538 bed.swap_to(&owner);
11539 let community = create_community(&bed.relay, "Gate", bed.relays.clone(), None).await.unwrap();
11540 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
11541
11542 bed.swap_to(&member);
11543 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
11544 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
11545 let err = create_private_channel(&bed.relay, &joined, "sneaky").await.unwrap_err();
11546 assert!(err.contains("MANAGE_CHANNELS"), "refused with the permission it lacks: {err}");
11547 let err = create_public_channel(&bed.relay, &joined, "sneaky-too").await.unwrap_err();
11548 assert!(err.contains("MANAGE_CHANNELS"), "public creation gates identically: {err}");
11549 }
11550
11551 #[tokio::test]
11554 async fn accept_rejects_a_bundle_with_a_forged_community_root() {
11555 let (bed, owner, member) = TestBed::new();
11559 bed.swap_to(&owner);
11560 let community = create_community(&bed.relay, "Real", bed.relays.clone(), None).await.unwrap();
11561
11562 let fake = crate::simd::hex::bytes_to_hex_32(&[0xEE; 32]);
11563 let mut forged = bundle_of(&community, BundleAudience::Link, None, None, None);
11564 forged.community_root = fake.clone();
11565 for ch in &mut forged.channels {
11566 ch.key = fake.clone();
11567 }
11568 let attacker = Keys::generate();
11569 let wrap = invite::build_direct_invite(&attacker, &member.keys.public_key(), &forged).unwrap();
11570 bed.relay.publish(&wrap, &bed.relays).await.unwrap();
11571
11572 bed.swap_to(&member);
11573 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
11574 let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
11575 assert!(err.contains("could not verify"), "a forged root fails the owner-genesis check: {err}");
11576 assert!(
11577 crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
11578 "a rejected join persists nothing"
11579 );
11580 }
11581
11582 #[tokio::test]
11583 async fn accept_verifies_a_rotated_plane_whose_metadata_head_is_admin_signed() {
11584 let (bed, owner, member) = TestBed::new();
11589 bed.swap_to(&owner);
11590 let community = create_community(&bed.relay, "Rotated", bed.relays.clone(), None).await.unwrap();
11591 let general = community.channels[0].id;
11592
11593 let mut rotated = community.clone();
11594 rotated.community_root = [0x5A; 32];
11595 rotated.root_epoch = Epoch(1);
11596 let admin = Keys::generate();
11597 publish_community_meta(&bed.relay, &rotated, &admin, "Rotated", 3).await;
11598 publish_channel_edition(&bed.relay, &rotated, &owner.keys, &general, "general", false, 2, false).await;
11599
11600 bed.swap_to(&member);
11601 let bundle = bundle_of(&rotated, BundleAudience::Link, None, None, None);
11602 let session = SessionGuard::capture();
11603 let joined = accept_bundle(&bed.relay, &session, &bundle, None, true).await.unwrap();
11604 assert_eq!(joined.root_epoch, Epoch(1), "the rotated root is adopted");
11605 }
11606
11607 #[tokio::test]
11608 async fn only_an_actual_join_publishes_a_guestbook_join() {
11609 let (bed, owner, member) = TestBed::new();
11613 bed.swap_to(&owner);
11614 let community = create_community(&bed.relay, "Quiet", bed.relays.clone(), None).await.unwrap();
11615
11616 let gb_pk = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch).pk_hex();
11617 async fn gb_count(relay: &MemoryRelay, gb_pk: &str, relays: &[String]) -> usize {
11618 let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![gb_pk.to_string()], ..Default::default() };
11619 relay.fetch(&q, relays).await.map(|v| v.len()).unwrap_or(0)
11620 }
11621 let baseline = gb_count(&bed.relay, &gb_pk, &bed.relays).await; bed.swap_to(&member);
11624 let bundle = bundle_of(&community, BundleAudience::Link, None, None, None);
11625 let session = SessionGuard::capture();
11626 accept_bundle(&bed.relay, &session, &bundle, None, true).await.unwrap();
11627 assert_eq!(gb_count(&bed.relay, &gb_pk, &bed.relays).await, baseline + 1, "a first join announces exactly once");
11628
11629 accept_bundle(&bed.relay, &session, &bundle, None, true).await.unwrap();
11630 assert_eq!(gb_count(&bed.relay, &gb_pk, &bed.relays).await, baseline + 1, "a re-accept of a held community stays silent");
11631
11632 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
11633 crate::db::community::delete_community(&cid_hex).unwrap();
11634 accept_bundle(&bed.relay, &session, &bundle, None, false).await.unwrap();
11635 assert_eq!(gb_count(&bed.relay, &gb_pk, &bed.relays).await, baseline + 1, "a cross-device key sync is not a membership event");
11636 }
11637
11638 #[tokio::test]
11639 async fn accept_refuses_a_rotated_plane_with_no_owner_signed_edition() {
11640 let (bed, owner, member) = TestBed::new();
11643 bed.swap_to(&owner);
11644 let community = create_community(&bed.relay, "NoOwner", bed.relays.clone(), None).await.unwrap();
11645
11646 let mut rotated = community.clone();
11647 rotated.community_root = [0x5B; 32];
11648 rotated.root_epoch = Epoch(1);
11649 let attacker = Keys::generate();
11650 publish_community_meta(&bed.relay, &rotated, &attacker, "NoOwner", 3).await;
11651
11652 bed.swap_to(&member);
11653 let bundle = bundle_of(&rotated, BundleAudience::Link, None, None, None);
11654 let session = SessionGuard::capture();
11655 let err = accept_bundle(&bed.relay, &session, &bundle, None, true).await.unwrap_err();
11656 assert!(err.contains("could not verify"), "no owner-signed edition → refuse: {err}");
11657 }
11658
11659 #[tokio::test]
11660 async fn accept_requires_the_strict_owner_genesis_on_an_epoch_zero_plane() {
11661 let (bed, owner, member) = TestBed::new();
11664 bed.swap_to(&owner);
11665 let community = create_community(&bed.relay, "Strict", bed.relays.clone(), None).await.unwrap();
11666 let general = community.channels[0].id;
11667
11668 let mut fake = community.clone();
11669 fake.community_root = [0x5C; 32]; let admin = Keys::generate();
11671 publish_community_meta(&bed.relay, &fake, &admin, "Strict", 2).await;
11672 publish_channel_edition(&bed.relay, &fake, &owner.keys, &general, "general", false, 2, false).await;
11673
11674 bed.swap_to(&member);
11675 let bundle = bundle_of(&fake, BundleAudience::Link, None, None, None);
11676 let session = SessionGuard::capture();
11677 let err = accept_bundle(&bed.relay, &session, &bundle, None, true).await.unwrap_err();
11678 assert!(err.contains("could not verify"), "epoch 0 demands the owner genesis: {err}");
11679 }
11680
11681 #[tokio::test]
11682 async fn follow_control_heals_a_bundle_misclassified_public_channel() {
11683 let (_tmp, _guard, _owner) = init_test_db();
11687 let relay = MemoryRelay::new();
11688 let community = create_community(&relay, "Heal", vec!["wss://r".into()], None).await.unwrap();
11689 let general = community.channels[0].id;
11690 let mut poisoned = community.clone();
11691 poisoned.channels[0].private = true;
11692 poisoned.channels[0].key = Some([0x66; 32]);
11693 crate::db::community::save_community_v2(&poisoned).unwrap();
11694
11695 let session = SessionGuard::capture();
11696 let healed = follow_control(&relay, &poisoned, &session).await.unwrap().expect("healed");
11697 let ch = healed.channel(&general).unwrap();
11698 assert!(!ch.private, "the owner's public declaration overrides the bundle");
11699 assert_eq!(ch.key, None, "a healed public channel derives from the root");
11700 }
11701
11702 #[tokio::test]
11703 async fn a_deleted_channel_does_not_resurrect_on_reload() {
11704 let (_tmp, _guard, owner) = init_test_db();
11707 let relay = MemoryRelay::new();
11708 let community = create_community(&relay, "Prune", vec!["wss://r".into()], None).await.unwrap();
11709 let extra = ChannelId([0x77; 32]);
11710 let session = SessionGuard::capture();
11711 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
11712 let with_extra = follow_control(&relay, &community, &session).await.unwrap().unwrap();
11713 assert!(with_extra.channel(&extra).is_some());
11714 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
11715 let after = follow_control(&relay, &with_extra, &session).await.unwrap().unwrap();
11716 assert!(after.channel(&extra).is_none());
11717
11718 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
11719 assert!(reloaded.channel(&extra).is_none(), "a deleted channel must not resurrect on reload");
11720 assert_eq!(reloaded.channels.len(), 1);
11721 }
11722
11723 #[tokio::test]
11724 async fn a_channel_owned_by_another_community_is_skipped_not_clobbered() {
11725 let (_tmp, _guard, _owner) = init_test_db();
11729 let relay = MemoryRelay::new();
11730 let a = create_community(&relay, "A", vec!["wss://r".into()], None).await.unwrap();
11731 let a_channel = a.channels[0].id;
11732 let mut b = create_community(&relay, "B", vec!["wss://r".into()], None).await.unwrap();
11733 let b_channel = b.channels[0].id;
11734 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() });
11736
11737 crate::db::community::save_community_v2(&b).expect("save succeeds, the phantom is skipped");
11738 let a_reloaded = crate::db::community::load_community_v2(a.id()).unwrap().unwrap();
11740 assert!(!a_reloaded.channels.iter().any(|c| c.private), "A's channel is untouched");
11741 assert_eq!(a_reloaded.channels[0].id.0, a_channel.0);
11742 let b_reloaded = crate::db::community::load_community_v2(b.id()).unwrap().unwrap();
11744 assert!(b_reloaded.channel(&b_channel).is_some(), "B's own channel persists");
11745 assert!(b_reloaded.channel(&a_channel).is_none(), "the foreign-owned channel is skipped, not stolen");
11746 }
11747
11748 struct CappedRelay {
11754 events: Vec<Event>,
11755 cap: usize,
11756 }
11757 #[async_trait::async_trait]
11758 impl Transport for CappedRelay {
11759 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
11760 async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
11761 Ok(())
11762 }
11763 async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
11764 Ok(())
11765 }
11766 async fn fetch(&self, q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
11767 let mut m: Vec<Event> = self
11768 .events
11769 .iter()
11770 .filter(|e| q.authors.is_empty() || q.authors.contains(&e.pubkey.to_hex()))
11771 .filter(|e| q.until.is_none_or(|u| e.created_at.as_secs() <= u))
11772 .cloned()
11773 .collect();
11774 m.sort_by(|a, b| b.created_at.cmp(&a.created_at)); m.truncate(self.cap.min(q.limit.unwrap_or(usize::MAX)));
11776 Ok(m)
11777 }
11778 }
11779
11780 #[tokio::test]
11781 async fn refound_aborts_when_the_control_plane_cannot_be_read_in_full() {
11782 let (_tmp, _guard, _owner) = init_test_db();
11789 let memory = MemoryRelay::new();
11790 let community = create_community(&memory, "Walled", vec!["wss://r".into()], None).await.unwrap();
11791 let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
11792
11793 let rogue = Keys::generate();
11794 let mut events: Vec<Event> = Vec::new();
11795 for i in 0..FOLLOW_PAGE {
11796 let content = format!("{{\"name\":\"junk{i}\",\"private\":false}}");
11797 let rumor = control::build_edition_rumor(
11798 rogue.public_key(),
11799 vsk::CHANNEL_METADATA,
11800 &[0xAB; 32],
11801 1,
11802 None,
11803 &content,
11804 9_000,
11805 None,
11806 );
11807 let (w, _) = control::seal_control_edition(&rumor, &control, &rogue, Timestamp::from_secs(9_000)).unwrap();
11808 events.push(w);
11809 }
11810 let relay = CappedRelay { events, cap: FOLLOW_PAGE };
11811
11812 let err = refound_community(&relay, &community, &[])
11813 .await
11814 .expect_err("a plane that can't be read whole must never be compacted");
11815 assert!(err.contains("too deep to read in full"), "unexpected error: {err}");
11816 }
11817
11818 #[tokio::test]
11819 async fn verify_pages_a_capped_relay_past_a_flood_to_the_genesis() {
11820 let (_tmp, _guard, owner) = init_test_db();
11825 let meta = control::CommunityMetadata { name: "Capped".into(), relays: vec!["wss://r".into()], ..Default::default() };
11826 let g = control::genesis(&owner, meta, 1_000).unwrap();
11827 let community = CommunityV2::from_genesis(&g, "Capped", None, vec!["wss://r".into()], 1_000);
11828
11829 let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
11830 let rogue = Keys::generate();
11831 let mut events: Vec<Event> = g.wraps.to_vec();
11832 for i in 0..250u64 {
11833 let rumor = control::build_edition_rumor(rogue.public_key(), vsk::CHANNEL_METADATA, &[0xAB; 32], 1, None, "{\"name\":\"junk\",\"private\":false}", 1_001 + i, None);
11834 let (wrap, _) = control::seal_control_edition(&rumor, &control, &rogue, Timestamp::from_secs(1_001 + i)).unwrap();
11835 events.push(wrap);
11836 }
11837 let relay = CappedRelay { events, cap: 100 };
11839 let verified = verify_owner_root_and_reconcile(&relay, community.clone()).await;
11840 assert!(verified.is_ok(), "the until-walk pages a capped relay past the flood to the genesis: {:?}", verified.err());
11841 }
11842
11843 #[tokio::test]
11844 async fn accept_parked_invite_joins_from_the_stored_bundle() {
11845 let (bed, owner, member) = TestBed::new();
11848 bed.swap_to(&owner);
11849 let community = create_community(&bed.relay, "Parked", bed.relays.clone(), None).await.unwrap();
11850 let general = community.channels[0].id;
11851 send_message(&bed.relay, &community, &general, "owner: hi").await.unwrap();
11852 let bundle = bundle_of(&community, BundleAudience::Link, Some(owner.keys.public_key()), None, None);
11853 let bundle_json = serde_json::to_string(&bundle).unwrap();
11854 let inviter_hex = owner.keys.public_key().to_hex();
11855
11856 bed.swap_to(&member);
11857 let joined = accept_parked_invite(&bed.relay, &bundle_json, Some(&inviter_hex)).await.unwrap();
11858 assert_eq!(joined.id().0, community.id().0, "joined the community from the parked bundle");
11859 assert!(joined.identity.verify());
11860 assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: hi"]);
11861 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
11864 assert!(
11865 crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().is_some(),
11866 "the joiner's control floor is seeded from the join-time fold"
11867 );
11868
11869 bed.swap_to(&owner);
11871 let members = memberlist(&bed.relay, &community).await.unwrap();
11872 assert!(members.contains(&member.keys.public_key()), "the parked-invite joiner is a member");
11873 }
11874
11875 #[tokio::test]
11876 async fn accept_parked_invite_rejects_a_forged_root() {
11877 let (_tmp, _guard, _owner) = init_test_db();
11881 let relay = MemoryRelay::new();
11882 let community = create_community(&relay, "Real", vec!["wss://r".into()], None).await.unwrap();
11883 let mut forged = bundle_of(&community, BundleAudience::Link, None, None, None);
11884 let fake = crate::simd::hex::bytes_to_hex_32(&[0xEE; 32]);
11885 forged.community_root = fake.clone();
11886 for ch in &mut forged.channels {
11887 ch.key = fake.clone();
11888 }
11889 let bundle_json = serde_json::to_string(&forged).unwrap();
11890
11891 let err = accept_parked_invite(&relay, &bundle_json, None).await.unwrap_err();
11892 assert!(err.contains("could not verify"), "a forged-root parked bundle fails definitively: {err}");
11893 }
11894
11895 #[test]
11896 fn v2_and_v1_bundles_are_distinguishable_by_parse() {
11897 let owner = Keys::generate();
11901 let identity = super::super::control::CommunityIdentity::mint(&owner.public_key());
11902 let hex = crate::simd::hex::bytes_to_hex_32;
11903 let v2 = invite::CommunityInvite {
11904 community_id: hex(&identity.community_id.0),
11905 owner: hex(&identity.owner_xonly),
11906 owner_salt: hex(&identity.owner_salt),
11907 community_root: hex(&[0x11; 32]),
11908 root_epoch: 0,
11909 channels: vec![],
11910 relays: vec!["wss://r".into()],
11911 name: "V2".into(),
11912 icon: None,
11913 expires_at: None,
11914 creator_npub: None,
11915 label: None,
11916 extra: Default::default(),
11917 };
11918 let v2_json = serde_json::to_string(&v2).unwrap();
11919 assert!(invite::CommunityInvite::from_bundle_json(&v2_json).is_ok(), "a real v2 bundle parses");
11920 let v1_like = r#"{"community_id":"aa","name":"X","relays":[]}"#;
11921 assert!(invite::CommunityInvite::from_bundle_json(v1_like).is_err(), "a v1 bundle is not a v2 bundle");
11922 }
11923
11924 #[tokio::test]
11925 async fn verify_rejects_a_cross_community_owner_edition_replay() {
11926 let (_tmp, _guard, owner) = init_test_db();
11932
11933 let gx = control::genesis(&owner, control::CommunityMetadata { name: "X".into(), ..Default::default() }, 1_000).unwrap();
11935 let x_control = control_group_key(&gx.community_root, &gx.identity.community_id, Epoch(0));
11936 let (_ed, opened) = control::open_control_edition(&gx.wraps[0], &x_control).unwrap();
11937
11938 let t_identity = control::CommunityIdentity::mint(&owner.public_key());
11940 let fake_root = [0xEE; 32];
11941 let t = CommunityV2 {
11942 identity: t_identity,
11943 community_root: fake_root,
11944 root_epoch: Epoch(0),
11945 name: "T".into(),
11946 description: None,
11947 icon: None,
11948 banner: None,
11949 meta_custom: None,
11950 meta_extra: Default::default(),
11951 relays: vec!["wss://r".into()],
11952 channels: vec![],
11953 dissolved: false,
11954 created_at_ms: 0,
11955 };
11956 let t_control = control_group_key(&fake_root, t.id(), t.root_epoch);
11959 let (replayed, _) = stream::rewrap_seal(&opened.seal, &t_control, Timestamp::from_secs(1_000)).unwrap();
11960 let relay = MemoryRelay::new();
11961 relay.publish(&replayed, &t.relays).await.unwrap();
11962
11963 let verified = verify_owner_root_and_reconcile(&relay, t.clone()).await;
11964 assert!(verified.is_err(), "a cross-community owner-edition replay must not authenticate a forged root");
11965 }
11966
11967 #[tokio::test]
11975 #[ignore = "hits a real relay over the network"]
11976 async fn live_smoke_create_send_fetch_on_a_real_relay() {
11977 use crate::community::transport::LiveTransport;
11978 use nostr_sdk::prelude::ToBech32;
11979
11980 let relay = std::env::var("VECTOR_SMOKE_RELAY").unwrap_or_else(|_| "wss://jskitty.com/nostr".to_string());
11981 let relays = vec![relay.clone()];
11982
11983 let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
11985 crate::db::close_database();
11986 crate::db::clear_id_caches();
11987 let tmp = tempfile::tempdir().unwrap();
11988 let keys = match std::env::var("VECTOR_SMOKE_NSEC") {
11991 Ok(n) => Keys::parse(&n).expect("VECTOR_SMOKE_NSEC is not a valid nsec"),
11992 Err(_) => Keys::generate(),
11993 };
11994 let npub = keys.public_key().to_bech32().unwrap();
11995 if std::env::var("VECTOR_SMOKE_PRINT_NSEC").is_ok() {
11998 println!("[smoke] OWNER nsec (throwaway — do NOT reuse): {}", keys.secret_key().to_bech32().unwrap());
11999 }
12000 std::fs::create_dir_all(tmp.path().join(&npub)).unwrap();
12001 crate::db::set_app_data_dir(tmp.path().to_path_buf());
12002 crate::db::set_current_account(npub.clone()).unwrap();
12003 crate::db::init_database(&npub).unwrap();
12004 crate::state::MY_SECRET_KEY.store_from_keys(&keys, &[]);
12005 crate::state::set_my_public_key(keys.public_key());
12006 println!("[smoke] throwaway identity {npub}");
12007
12008 let client = crate::nostr_client_builder().build();
12010 client.add_managed_relay(relay.as_str()).await.ok();
12011 client.connect().await;
12012 crate::state::set_nostr_client(client);
12013 let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(15));
12014
12015 let community = create_community(&transport, "V2 Live Smoke", relays.clone(), None).await.expect("create");
12017 let general = community.channels[0].id;
12018 println!("[smoke] created community {} on {relay}", crate::simd::hex::bytes_to_hex_32(&community.id().0));
12019
12020 let text = "hello from a Vector Concord v2 live smoke test";
12021 let sent_id = send_message(&transport, &community, &general, text).await.expect("send");
12022 println!("[smoke] sent message {sent_id}");
12023
12024 tokio::time::sleep(std::time::Duration::from_secs(3)).await;
12026
12027 let page = fetch_channel(&transport, &community, &general, 50).await.expect("fetch");
12028 let texts: Vec<String> = page
12029 .iter()
12030 .filter_map(|f| match &f.event {
12031 ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
12032 _ => None,
12033 })
12034 .collect();
12035 println!("[smoke] fetched {} message(s) back: {texts:?}", texts.len());
12036 assert!(texts.contains(&text.to_string()), "the message did not round-trip through the real relay");
12037
12038 let link = mint_public_link(&transport, &community, "https://vectorapp.io", None, None).await.expect("mint link");
12040 println!("[smoke] invite link: {}", link.url);
12041 println!("[smoke] PASS — v2 create+send+fetch+invite round-tripped on {relay}");
12042 }
12043
12044 #[tokio::test]
12045 async fn chat_ops_react_edit_delete_round_trip() {
12046 let (bed, owner, _member) = TestBed::new();
12047 bed.swap_to(&owner);
12048 let community = create_community(&bed.relay, "Ops", bed.relays.clone(), None).await.unwrap();
12049 let general = community.channels[0].id;
12050 let me_hex = owner.keys.public_key().to_hex();
12051
12052 let msg_id = send_message(&bed.relay, &community, &general, "original").await.unwrap();
12053 send_reaction(&bed.relay, &community, &general, &msg_id, &me_hex, super::super::kind::MESSAGE, ":fire:", Some(("fire", "https://e/f.png")))
12054 .await
12055 .unwrap();
12056 send_edit(&bed.relay, &community, &general, &msg_id, "edited").await.unwrap();
12057 send_delete(&bed.relay, &community, &general, &msg_id, super::super::kind::MESSAGE).await.unwrap();
12058
12059 let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
12060 let target = crate::simd::hex::hex_to_bytes_32(&msg_id);
12061 let mut saw = (false, false, false);
12062 for f in &page {
12063 match &f.event {
12064 ChatEvent::Reaction { target: t, emoji, emoji_url, .. } if *t == target => {
12065 assert_eq!(emoji, ":fire:");
12066 assert_eq!(emoji_url.as_deref(), Some("https://e/f.png"));
12067 saw.0 = true;
12068 }
12069 ChatEvent::Edit { target: t, new_content, .. } if *t == target => {
12070 assert_eq!(new_content, "edited");
12071 saw.1 = true;
12072 }
12073 ChatEvent::Delete { target: t, .. } if *t == target => saw.2 = true,
12074 _ => {}
12075 }
12076 }
12077 assert!(saw.0 && saw.1 && saw.2, "reaction/edit/delete all round-trip: {saw:?}");
12078 }
12079
12080 #[tokio::test]
12081 async fn a_typing_signal_rides_the_ephemeral_wrap_and_is_never_stored() {
12082 let (bed, owner, _member) = TestBed::new();
12083 bed.swap_to(&owner);
12084 let community = create_community(&bed.relay, "Typ", bed.relays.clone(), None).await.unwrap();
12085 let general = community.channels[0].id;
12086 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
12087
12088 let mut sub = bed.relay.subscribe(Query {
12090 kinds: vec![stream::KIND_WRAP_EPHEMERAL],
12091 authors: vec![group.pk_hex()],
12092 ..Default::default()
12093 });
12094 send_typing(&bed.relay, &community, &general).await.unwrap();
12095 let wrap = sub.try_recv().expect("the typing wrap streams to a live subscriber");
12096 let opened = match chat::open_chat_event(&wrap, &group, &general, community.root_epoch) {
12097 Ok(ChatEvent::Typing { opened }) => opened,
12098 other => panic!("the ephemeral wrap must open as a Typing event, got {other:?}"),
12099 };
12100
12101 let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
12104 assert!(page.iter().all(|f| !matches!(f.event, ChatEvent::Typing { .. })));
12105
12106 assert!(
12108 crate::db::community::get_message_key(&opened.rumor_id.to_hex()).unwrap().is_none(),
12109 "ephemeral sends must not retain scrub keys"
12110 );
12111 }
12112
12113 #[tokio::test]
12114 async fn a_durable_send_retains_the_wrap_scrub_key_and_full_delete_nukes_the_relay_copy() {
12115 let (bed, owner, _member) = TestBed::new();
12116 bed.swap_to(&owner);
12117 let community = create_community(&bed.relay, "Nuke", bed.relays.clone(), None).await.unwrap();
12118 let general = community.channels[0].id;
12119 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
12120
12121 let id = send_message(&bed.relay, &community, &general, "scrub me").await.unwrap();
12122
12123 let (keys, outer_hex, relays) =
12126 crate::db::community::get_message_key(&id).unwrap().expect("a durable send retains its scrub key");
12127 assert_eq!(relays, community.relays);
12128 let wrap_query = Query {
12129 kinds: vec![stream::KIND_WRAP],
12130 authors: vec![group.pk_hex()],
12131 ..Default::default()
12132 };
12133 let wraps = bed.relay.fetch(&wrap_query, &community.relays).await.unwrap();
12134 let wrap = wraps.iter().find(|w| w.id.to_hex() == outer_hex).expect("retained outer id is the published wrap");
12135 assert_eq!(keys.public_key(), wrap.pubkey, "retained key is the wrap's author");
12136
12137 let me_hex = owner.keys.public_key().to_hex();
12139 let rid = send_reaction(&bed.relay, &community, &general, &id, &me_hex, super::super::kind::MESSAGE, "🔥", None)
12140 .await
12141 .unwrap();
12142 assert!(crate::db::community::get_message_key(&rid).unwrap().is_some(), "reaction sends retain too");
12143
12144 crate::community::service::delete_message(&bed.relay, &id).await.unwrap();
12147 assert!(crate::db::community::get_message_key(&id).unwrap().is_none(), "key consumed after the scrub");
12148 let after = bed.relay.fetch(&wrap_query, &community.relays).await.unwrap();
12149 assert!(!after.iter().any(|w| w.id.to_hex() == outer_hex), "wrap scrubbed from the relay");
12150 }
12151
12152 #[tokio::test]
12153 async fn backfill_heals_scrub_keys_for_own_pre_retention_messages_only() {
12154 let (bed, owner, _member) = TestBed::new();
12155 bed.swap_to(&owner);
12156 let community = create_community(&bed.relay, "Heal", bed.relays.clone(), None).await.unwrap();
12157 let general = community.channels[0].id;
12158 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
12159
12160 let id = send_message(&bed.relay, &community, &general, "old send").await.unwrap();
12163 crate::db::community::delete_message_key(&id).unwrap();
12164 assert!(crate::db::community::get_message_key(&id).unwrap().is_none());
12165
12166 let mkeys = Keys::generate();
12168 let rumor = chat::build_message_rumor(mkeys.public_key(), &general, community.root_epoch, "foreign", None, &[], vec![], 6_000);
12169 let foreign_id = rumor.id.unwrap().to_hex();
12170 let (fw, _) = chat::seal_chat_rumor(&rumor, &group, &mkeys, Timestamp::from_secs(6), false).unwrap();
12171 bed.relay.publish(&fw, &community.relays).await.unwrap();
12172
12173 fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
12175 let (keys, _outer, relays) =
12176 crate::db::community::get_message_key(&id).unwrap().expect("backfill heals own unretained rows");
12177 assert_eq!(keys.public_key(), group.pk(), "healed key is the wrap's signing key");
12178 assert_eq!(relays, community.relays);
12179
12180 assert!(crate::db::community::get_message_key(&foreign_id).unwrap().is_none());
12182
12183 crate::community::service::delete_message(&bed.relay, &id).await.unwrap();
12185 let left = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
12186 assert!(
12187 !left.iter().any(|f| f.event.opened().rumor_id.to_hex() == id),
12188 "healed message scrubbed from the relay"
12189 );
12190 }
12191
12192 #[tokio::test]
12193 async fn send_chat_message_threads_the_reply_and_extra_tags() {
12194 let (bed, owner, _member) = TestBed::new();
12195 bed.swap_to(&owner);
12196 let community = create_community(&bed.relay, "Re", bed.relays.clone(), None).await.unwrap();
12197 let general = community.channels[0].id;
12198 let me_hex = owner.keys.public_key().to_hex();
12199
12200 let parent_id = send_message(&bed.relay, &community, &general, "parent").await.unwrap();
12201 let imeta = nostr_sdk::prelude::Tag::custom(
12202 "imeta",
12203 ["url https://e/blob".to_string(), "m image/png".to_string()],
12204 );
12205 let child_id = send_chat_message(
12206 &bed.relay, &community, &general, "child",
12207 Some((parent_id.as_str(), me_hex.as_str())), &[], vec![imeta],
12208 )
12209 .await
12210 .unwrap();
12211
12212 let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
12213 let child = page
12214 .iter()
12215 .find_map(|f| match &f.event {
12216 ChatEvent::Message { opened, reply_to, .. } if opened.rumor_id.to_hex() == child_id => Some((opened, reply_to)),
12217 _ => None,
12218 })
12219 .expect("the reply message round-trips");
12220 let reply = child.1.as_ref().expect("the reply reference is carried");
12221 assert_eq!(crate::simd::hex::bytes_to_hex_32(&reply.id), parent_id);
12222 assert_eq!(reply.author, Some(owner.keys.public_key()));
12223 assert!(
12224 child.0.rumor.tags.iter().any(|t| t.kind() == "imeta"),
12225 "the imeta attachment tag rides the rumor verbatim"
12226 );
12227 }
12228
12229 #[tokio::test]
12230 async fn a_kick_needs_kick_authority_and_removes_the_target() {
12231 let (bed, owner, member) = TestBed::new();
12232 bed.swap_to(&owner);
12233 let community = create_community(&bed.relay, "Kick", bed.relays.clone(), None).await.unwrap();
12234
12235 let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
12237 let join = guestbook::build_join_rumor(member.keys.public_key(), None, 2_000);
12238 let (wrap, _) = guestbook::seal_guestbook_rumor(&join, &gb, &member.keys, Timestamp::from_secs(2)).unwrap();
12239 bed.relay.publish(&wrap, &bed.relays).await.unwrap();
12240 let before = memberlist(&bed.relay, &community).await.unwrap();
12241 assert!(before.contains(&member.keys.public_key()), "the join lands first");
12242
12243 bed.swap_to(&member);
12245 let err = kick_member(&bed.relay, &community, &owner.keys.public_key()).await.unwrap_err();
12246 assert!(err.contains("not authorized"), "unprivileged kick refused: {err}");
12247
12248 bed.swap_to(&owner);
12250 kick_member(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
12251 let after = memberlist(&bed.relay, &community).await.unwrap();
12252 assert!(!after.contains(&member.keys.public_key()), "the kicked member leaves the fold");
12253 assert!(after.contains(&owner.keys.public_key()), "the owner remains");
12254 }
12255
12256 #[tokio::test]
12257 async fn a_rejoin_survives_a_stale_kick_and_an_uncaught_up_store() {
12258 let (bed, owner, member) = TestBed::new();
12263 bed.swap_to(&owner);
12264 let community = create_community(&bed.relay, "Rejoin", bed.relays.clone(), None).await.unwrap();
12265 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12266 let (o, m) = (owner.keys.public_key(), member.keys.public_key());
12267 let join = |at: u64, id: u8| guestbook::GuestbookEvent {
12268 rumor_id: [id; 32],
12269 entry: guestbook::GuestbookEntry::Join { member: m, invited_by: None, at_ms: at },
12270 };
12271 let kick = |at: u64, id: u8| guestbook::GuestbookEvent {
12272 rumor_id: [id; 32],
12273 entry: guestbook::GuestbookEntry::Kick { actor: o, target: m, citation: None, at_ms: at },
12274 };
12275
12276 crate::db::community::set_guestbook(&cid_hex, &[join(1_000, 1), kick(2_000, 2)], 2).unwrap();
12278 assert!(stored_kick_verdict(&community, &m), "an authorized kick after the join is honored");
12279
12280 crate::db::community::set_guestbook(&cid_hex, &[join(1_000, 1), kick(2_000, 2), join(3_000, 3)], 3).unwrap();
12282 assert!(!stored_kick_verdict(&community, &m), "a Join newer than the kick clears the verdict");
12283
12284 crate::db::community::set_guestbook(&cid_hex, &[], 0).unwrap();
12286 assert!(!stored_kick_verdict(&community, &m), "an empty store is not an eviction");
12287
12288 assert!(
12291 !stored_memberlist(&community).unwrap().contains(&m),
12292 "the memberlist excludes an un-caught-up member — why it can't gate a kick"
12293 );
12294 }
12295
12296 fn seed_roster_with_heads(community: &CommunityV2, roster: &crate::community::roles::CommunityRoles, at: i64) {
12301 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12302 crate::db::community::set_community_roles(&cid_hex, roster, at).unwrap();
12303 for g in &roster.grants {
12304 let Some(m) = crate::simd::hex::hex_to_bytes_32_checked(&g.member) else { continue };
12305 let eid = super::super::derive::grant_locator(community.id(), &m);
12306 let entity_hex = crate::simd::hex::bytes_to_hex_32(&eid);
12307 crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, 1, &[0xA1; 32], &[0xA2; 32], community.root_epoch.0).unwrap();
12308 }
12309 }
12310
12311 async fn publish_grant_citing(
12313 relay: &MemoryRelay,
12314 community: &CommunityV2,
12315 signer: &Keys,
12316 member: &PublicKey,
12317 role_ids: Vec<String>,
12318 version: u64,
12319 citation: Option<&crate::community::edition::AuthorityCitation>,
12320 ) {
12321 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
12322 let eid = crate::community::v2::derive::grant_locator(community.id(), &member.to_bytes());
12323 let prev = head_hash_on_relay(relay, community, &eid).await;
12324 let grant = MemberGrant { member: member.to_hex(), role_ids };
12325 let content = crate::community::v2::roles::grant_content_json(&grant).unwrap();
12326 let rumor = control::build_edition_rumor(signer.public_key(), vsk::GRANT, &eid, version, prev.as_ref(), &content, 1_000, citation);
12327 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
12328 relay.publish(&wrap, &community.relays).await.unwrap();
12329 }
12330
12331 #[tokio::test]
12332 async fn an_uncited_admin_edition_is_not_folded_but_a_cited_one_is() {
12333 let (bed, owner, admin) = TestBed::new();
12338 bed.swap_to(&owner);
12339 let community = create_community(&bed.relay, "Cited", bed.relays.clone(), None).await.unwrap();
12340 let admin_pk = admin.keys.public_key();
12341 let rid = "c3".repeat(32);
12342 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::admin().0), 1).await;
12343 publish_grant(&bed.relay, &community, &owner.keys, &admin_pk, vec![rid.clone()], 1).await;
12344
12345 let low_rid = "c4".repeat(32);
12349 let mut low = admin_role(&low_rid, Permissions::admin().0);
12350 low.position = 5;
12351 publish_role(&bed.relay, &community, &owner.keys, &low, 1).await;
12352
12353 let bystander = Keys::generate().public_key();
12354 publish_grant_citing(&bed.relay, &community, &admin.keys, &bystander, vec![low_rid.clone()], 1, None).await;
12355 let view = fetch_authority(&bed.relay, &community).await;
12356 assert!(
12357 !view.roles.grants.iter().any(|g| g.member == bystander.to_hex()),
12358 "an uncited non-owner edition is not folded"
12359 );
12360 assert!(view.roles.is_admin(&admin_pk.to_hex()), "the owner-authored grant folds");
12362
12363 let _ = follow_control(&bed.relay, &community, &SessionGuard::capture()).await;
12366 let entity_id = crate::community::v2::derive::grant_locator(community.id(), &admin_pk.to_bytes());
12367 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12368 let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
12369 let (version, edition_hash) = crate::db::community::get_edition_head(&cid_hex, &entity_hex).unwrap().unwrap();
12370 let cite = crate::community::edition::AuthorityCitation { entity_id, version, edition_hash };
12371 publish_grant_citing(&bed.relay, &community, &admin.keys, &bystander, vec![low_rid], 2, Some(&cite)).await;
12372
12373 let view = fetch_authority(&bed.relay, &community).await;
12374 assert!(
12375 view.roles.grants.iter().any(|g| g.member == bystander.to_hex()),
12376 "the same edition WITH its synced citation folds"
12377 );
12378 }
12379
12380 #[tokio::test]
12381 async fn a_join_landing_inside_the_ban_window_survives_the_unban() {
12382 let (bed, owner, member) = TestBed::new();
12387 bed.swap_to(&owner);
12388 let community = create_community(&bed.relay, "Window", bed.relays.clone(), None).await.unwrap();
12389 let member_pk = member.keys.public_key();
12390 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12391
12392 crate::db::community::set_community_banlist(&cid_hex, &[member_pk.to_hex()], 1_000).unwrap();
12394 crate::db::community::merge_community_ban_marks(&cid_hex, &[(member_pk.to_hex(), 1_000u64)].into_iter().collect()).unwrap();
12395
12396 let join = guestbook::GuestbookEvent {
12398 rumor_id: [9u8; 32],
12399 entry: guestbook::GuestbookEntry::Join { member: member_pk, invited_by: None, at_ms: 1_060_000 },
12400 };
12401 assert!(ingest_guestbook_event(&community, join, 1_060).unwrap(), "stored while banned");
12402 assert!(
12403 !stored_memberlist(&community).unwrap().contains(&member_pk),
12404 "while banned, the fold keeps them out"
12405 );
12406
12407 crate::db::community::set_community_banlist(&cid_hex, &[], 2_000).unwrap();
12409 assert!(
12410 stored_memberlist(&community).unwrap().contains(&member_pk),
12411 "after the unban the raced Join makes them a member again"
12412 );
12413 }
12414
12415 #[tokio::test]
12416 async fn a_stale_root_admin_write_is_refused_not_misdirected() {
12417 let (bed, owner, member) = TestBed::new();
12423 bed.swap_to(&owner);
12424 let community = create_community(&bed.relay, "Race", bed.relays.clone(), None).await.unwrap();
12425 let member_pk = member.keys.public_key();
12426
12427 set_banlist(&bed.relay, &community, &[member_pk.to_hex()]).await.unwrap();
12428 let _rotated = refound_community(&bed.relay, &community, &[member_pk]).await.unwrap();
12429
12430 let err = set_banlist(&bed.relay, &community, &[]).await.unwrap_err();
12432 assert!(err.contains("re-founded"), "unban: {err}");
12433 let err = send_direct_invite(&bed.relay, &community, &member_pk, None, None).await.unwrap_err();
12435 assert!(err.contains("re-founded"), "invite: {err}");
12436 let err = kick_member(&bed.relay, &community, &member_pk).await.unwrap_err();
12438 assert!(err.contains("re-founded"), "kick: {err}");
12439
12440 let fresh = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12442 set_banlist(&bed.relay, &fresh, &[]).await.unwrap();
12443 let view = fetch_authority(&bed.relay, &fresh).await;
12444 assert!(view.banned.is_empty(), "the retried unban actually unbans");
12445 }
12446
12447 #[tokio::test]
12448 async fn an_uncited_kick_from_an_admin_is_not_honored() {
12449 let (bed, owner, member) = TestBed::new();
12454 bed.swap_to(&owner);
12455 let community = create_community(&bed.relay, "Uncited", bed.relays.clone(), None).await.unwrap();
12456 let admin = Keys::generate();
12457 let member_pk = member.keys.public_key();
12458 grant_admin(&bed.relay, &community, &admin.public_key()).await.unwrap();
12459
12460 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12461 let view = fetch_authority(&bed.relay, &community).await;
12462 crate::db::community::set_community_roles(&cid_hex, &view.roles, 1_000).unwrap();
12463
12464 let entity_id = super::super::derive::grant_locator(community.id(), &admin.public_key().to_bytes());
12465 let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
12466 let (version, edition_hash) = crate::db::community::get_edition_head(&cid_hex, &entity_hex).unwrap().unwrap();
12467 let cite = crate::community::edition::AuthorityCitation { entity_id, version, edition_hash };
12468
12469 let joined = guestbook::GuestbookEvent {
12470 rumor_id: [1u8; 32],
12471 entry: guestbook::GuestbookEntry::Join { member: member_pk, invited_by: None, at_ms: 1_000 },
12472 };
12473 let kick = |citation, id: u8, at| guestbook::GuestbookEvent {
12474 rumor_id: [id; 32],
12475 entry: guestbook::GuestbookEntry::Kick { actor: admin.public_key(), target: member_pk, citation, at_ms: at },
12476 };
12477 let roles = crate::db::community::get_community_roles(&cid_hex).unwrap();
12478 let empty_bans = std::collections::BTreeSet::new();
12479 let empty_marks = std::collections::BTreeMap::new();
12480 let fold = |evs: &[guestbook::GuestbookEvent]| {
12481 fold_members(&community, evs, Default::default(), &roles, &empty_bans, &empty_marks).unwrap()
12482 };
12483
12484 assert!(
12485 fold(&[joined.clone(), kick(None, 2, 2_000)]).contains(&member_pk),
12486 "an uncited kick from an admin is not honored"
12487 );
12488 assert!(
12489 !fold(&[joined, kick(Some(cite), 3, 3_000)]).contains(&member_pk),
12490 "the same kick WITH its synced citation removes them"
12491 );
12492 }
12493
12494 #[tokio::test]
12495 async fn kicking_an_admin_strips_their_roles_first() {
12496 let (bed, owner, member) = TestBed::new();
12500 bed.swap_to(&owner);
12501 let community = create_community(&bed.relay, "Compose", bed.relays.clone(), None).await.unwrap();
12502 let member_pk = member.keys.public_key();
12503 let member_hex = member_pk.to_hex();
12504 let owner_hex = owner.keys.public_key().to_hex();
12505
12506 grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
12507 assert!(fetch_authority(&bed.relay, &community).await.roles.is_admin(&member_hex));
12508
12509 kick_member(&bed.relay, &community, &member_pk).await.unwrap();
12510
12511 let view = fetch_authority(&bed.relay, &community).await;
12512 assert!(!view.roles.is_admin(&member_hex), "the kick stripped their rank");
12513 assert!(
12514 !view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::MANAGE_ROLES),
12515 "a kicked admin holds no bit"
12516 );
12517 assert!(
12518 !memberlist(&bed.relay, &community).await.unwrap().contains(&member_pk),
12519 "and the directive still removed them"
12520 );
12521 }
12522
12523 #[tokio::test]
12524 async fn grant_admin_mints_one_deterministic_role_and_revoke_strips_it() {
12525 let (bed, owner, member) = TestBed::new();
12526 bed.swap_to(&owner);
12527 let community = create_community(&bed.relay, "Adm", bed.relays.clone(), None).await.unwrap();
12528 let member_pk = member.keys.public_key();
12529 let member_hex = member_pk.to_hex();
12530 let owner_hex = owner.keys.public_key().to_hex();
12531
12532 grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
12533 let view = fetch_authority(&bed.relay, &community).await;
12534 assert!(view.roles.is_admin(&member_hex), "the grant folds as admin");
12535 assert!(view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::MANAGE_ROLES));
12536
12537 let second = Keys::generate().public_key();
12540 grant_admin(&bed.relay, &community, &second).await.unwrap();
12541 grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
12542 let view = fetch_authority(&bed.relay, &community).await;
12543 assert_eq!(view.roles.roles.len(), 1, "one Admin role, never a fork");
12544 assert!(view.roles.is_admin(&member_hex) && view.roles.is_admin(&second.to_hex()));
12545 let grant = view.roles.grants.iter().find(|g| g.member == member_hex).unwrap();
12546 assert_eq!(grant.role_ids.len(), 1, "no duplicate role id in the grant");
12547
12548 revoke_admin(&bed.relay, &community, &member_pk).await.unwrap();
12550 let view = fetch_authority(&bed.relay, &community).await;
12551 assert!(!view.roles.is_admin(&member_hex), "revoked");
12552 assert!(view.roles.is_admin(&second.to_hex()), "the other admin is untouched");
12553 assert!(!view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::KICK));
12554 }
12555
12556 #[tokio::test]
12557 async fn follow_control_persists_the_roster_for_sync_local_reads() {
12558 let (bed, owner, member) = TestBed::new();
12559 bed.swap_to(&owner);
12560 let community = create_community(&bed.relay, "Persist", bed.relays.clone(), None).await.unwrap();
12561 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12562 let member_hex = member.keys.public_key().to_hex();
12563 grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
12564
12565 let session = crate::state::SessionGuard::capture();
12567 follow_control(&bed.relay, &community, &session).await.unwrap();
12568 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
12569 assert!(roster.is_admin(&member_hex), "the persisted roster reads back without a fetch");
12570
12571 let withholding = MemoryRelay::new();
12574 let _ = follow_control(&withholding, &community, &session).await;
12575 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
12576 assert!(roster.is_admin(&member_hex), "withholding never shrinks standing");
12577
12578 revoke_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
12580 follow_control(&bed.relay, &community, &session).await.unwrap();
12581 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
12582 assert!(!roster.is_admin(&member_hex), "the revoke folds + persists");
12583 }
12584
12585 #[tokio::test]
12586 async fn grant_admin_is_refused_for_a_non_owner_and_publishes_nothing() {
12587 let (bed, owner, member) = TestBed::new();
12588 bed.swap_to(&owner);
12589 let community = create_community(&bed.relay, "NoSquat", bed.relays.clone(), None).await.unwrap();
12590
12591 bed.swap_to(&member);
12592 let err = grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap_err();
12593 assert!(err.contains("owner"), "refused before any publish: {err}");
12594
12595 bed.swap_to(&owner);
12598 let view = fetch_authority(&bed.relay, &community).await;
12599 assert!(view.roles.roles.is_empty(), "no role edition landed");
12600 grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
12601 let view = fetch_authority(&bed.relay, &community).await;
12602 assert!(view.roles.is_admin(&member.keys.public_key().to_hex()));
12603 }
12604
12605 #[tokio::test]
12606 async fn grant_admin_merges_other_roles_and_refuses_a_withheld_grant() {
12607 let (bed, owner, member) = TestBed::new();
12608 bed.swap_to(&owner);
12609 let community = create_community(&bed.relay, "Merge", bed.relays.clone(), None).await.unwrap();
12610 let member_pk = member.keys.public_key();
12611
12612 let mod_rid = crate::simd::hex::bytes_to_hex_32(&[0x66; 32]);
12615 set_role(&bed.relay, &community, &admin_role(&mod_rid, Permissions::BAN)).await.unwrap();
12616 grant_roles(&bed.relay, &community, &member_pk, vec![mod_rid.clone()]).await.unwrap();
12617
12618 let withholding = MemoryRelay::new();
12621 let err = grant_admin(&withholding, &community, &member_pk).await.unwrap_err();
12622 assert!(err.contains("could not be fetched"), "withheld grant refused: {err}");
12623
12624 grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
12626 let view = fetch_authority(&bed.relay, &community).await;
12627 let grant = view.roles.grants.iter().find(|g| g.member == member_pk.to_hex()).unwrap();
12628 assert_eq!(grant.role_ids.len(), 2, "admin ADDED to the existing grant, not replacing it");
12629 assert!(grant.role_ids.contains(&mod_rid));
12630 }
12631
12632 #[tokio::test]
12633 async fn fetch_authority_reflects_a_granted_admin() {
12634 let (bed, owner, member) = TestBed::new();
12635 bed.swap_to(&owner);
12636 let community = create_community(&bed.relay, "Auth", bed.relays.clone(), None).await.unwrap();
12637 let rid = crate::simd::hex::bytes_to_hex_32(&[0x5a; 32]);
12638 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
12639 publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
12640
12641 let view = fetch_authority(&bed.relay, &community).await;
12642 let member_hex = member.keys.public_key().to_hex();
12643 assert!(view.roles.is_admin(&member_hex), "the granted member folds as admin");
12644 assert!(
12645 view.roles.is_authorized(&member_hex, Some(&owner.keys.public_key().to_hex()), Permissions::KICK),
12646 "an ADMIN_ALL grant carries KICK"
12647 );
12648 assert!(view.banned.is_empty());
12649 }
12650
12651 #[tokio::test]
12656 async fn a_pin_edition_folds_persists_and_reads_back() {
12657 let (_tmp, _guard, _owner) = init_test_db();
12658 let relay = MemoryRelay::new();
12659 let community = create_community(&relay, "Pinsville", vec!["wss://r".into()], None).await.unwrap();
12660 let general = community.channels[0].id;
12661 let ch_hex = crate::simd::hex::bytes_to_hex_32(&general.0);
12662
12663 send_message(&relay, &community, &general, "pin-worthy").await.unwrap();
12664 let page = fetch_channel(&relay, &community, &general, 10).await.unwrap();
12665 let opened = page
12666 .iter()
12667 .find_map(|f| match &f.event {
12668 ChatEvent::Message { opened, .. } => Some(opened.clone()),
12669 _ => None,
12670 })
12671 .expect("the sent message reads back");
12672
12673 let ch = community.channels[0].clone();
12674 let conv = channel_conv_key_at(&community, &ch, 0).expect("owner holds the public plane key");
12675 let entry = crate::community::v2::pins::build_pin_entry(&opened, &conv, &ch_hex).unwrap();
12676 let content = crate::community::v2::pins::serialize_public_pin_list(&[entry]).unwrap();
12677
12678 let session = crate::state::SessionGuard::capture();
12679 let eid = crate::community::v2::derive::pins_locator(community.id(), &general);
12680 publish_control_edition(&relay, &community, &session, vsk::PINS, &eid, &content).await.unwrap();
12681 follow_control(&relay, &community, &session).await.unwrap();
12682
12683 let read = read_channel_pins(&community, &general).unwrap();
12684 assert!(!read.sealed);
12685 assert!(read.version >= 1, "the folded head persisted");
12686 assert_eq!(read.pins.len(), 1);
12687 assert_eq!(read.pins[0].content, "pin-worthy");
12688 assert_eq!(read.pins[0].rumor_id, opened.rumor_id.to_hex());
12689 }
12690
12691 #[tokio::test]
12694 async fn an_unauthorized_pin_edition_never_folds() {
12695 let (_tmp, _guard, _owner) = init_test_db();
12696 let relay = MemoryRelay::new();
12697 let community = create_community(&relay, "Gated", vec!["wss://r".into()], None).await.unwrap();
12698 let general = community.channels[0].id;
12699
12700 let rogue = Keys::generate();
12701 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
12702 let eid = crate::community::v2::derive::pins_locator(community.id(), &general);
12703 let rumor = control::build_edition_rumor(rogue.public_key(), vsk::PINS, &eid, 1, None, r#"{"entries":[]}"#, 2_000, None);
12704 let (wrap, _) = control::seal_control_edition(&rumor, &group, &rogue, Timestamp::from_secs(2_000)).unwrap();
12705 relay.publish(&wrap, &community.relays).await.unwrap();
12706
12707 let session = crate::state::SessionGuard::capture();
12708 follow_control(&relay, &community, &session).await.unwrap();
12709 let read = read_channel_pins(&community, &general).unwrap();
12710 assert_eq!(read.version, 0, "an unauthorized edition never persists a head");
12711 assert!(read.pins.is_empty());
12712 }
12713
12714 #[tokio::test]
12717 async fn owner_silently_widens_a_legacy_admin_role() {
12718 let (_tmp, _guard, owner) = init_test_db();
12719 let relay = MemoryRelay::new();
12720 let community = create_community(&relay, "Legacy", vec!["wss://r".into()], None).await.unwrap();
12721 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12722 let rid = crate::simd::hex::bytes_to_hex_32(&[0x5d; 32]);
12723
12724 let legacy = Role {
12726 role_id: rid.clone(),
12727 name: "Admin".into(),
12728 position: 1,
12729 permissions: Permissions(Permissions::ADMIN_FOUNDING_MASK),
12730 scope: RoleScope::Server,
12731 color: 0,
12732 };
12733 publish_role(&relay, &community, &owner, &legacy, 1).await;
12734 let session = crate::state::SessionGuard::capture();
12735 follow_control(&relay, &community, &session).await.unwrap();
12736
12737 assert!(upgrade_admin_role_pin_bit(&relay, &community).await.unwrap(), "the widening publishes");
12738 follow_control(&relay, &community, &session).await.unwrap();
12739 let roles = crate::db::community::get_community_roles(&cid_hex).unwrap();
12740 let widened = roles.roles.iter().find(|r| r.role_id == rid).expect("same entity");
12741 assert!(widened.permissions.contains(Permissions::PIN_MESSAGES), "bit 11 landed");
12742 assert!(widened.permissions.contains(Permissions::ADMIN_FOUNDING_MASK), "nothing stripped");
12743
12744 assert!(!upgrade_admin_role_pin_bit(&relay, &community).await.unwrap());
12746 }
12747
12748 #[tokio::test]
12751 async fn the_deletion_duty_omits_a_pinned_message() {
12752 let (_tmp, _guard, _owner) = init_test_db();
12753 let relay = MemoryRelay::new();
12754 let community = create_community(&relay, "Duties", vec!["wss://r".into()], None).await.unwrap();
12755 let general = community.channels[0].id;
12756 let ch_hex = crate::simd::hex::bytes_to_hex_32(&general.0);
12757
12758 let rumor_id = send_message(&relay, &community, &general, "soon deleted").await.unwrap();
12759 let page = fetch_channel(&relay, &community, &general, 10).await.unwrap();
12760 let opened = page
12761 .iter()
12762 .find_map(|f| match &f.event {
12763 ChatEvent::Message { opened, .. } => (opened.rumor_id.to_hex() == rumor_id).then(|| opened.clone()),
12764 _ => None,
12765 })
12766 .unwrap();
12767 let ch = community.channels[0].clone();
12768 let conv = channel_conv_key_at(&community, &ch, 0).unwrap();
12769 let entry = crate::community::v2::pins::build_pin_entry(&opened, &conv, &ch_hex).unwrap();
12770 let session = crate::state::SessionGuard::capture();
12771 publish_pin_list(&relay, &community, &session, &ch, &[entry]).await.unwrap();
12772 assert_eq!(read_channel_pins(&community, &general).unwrap().pins.len(), 1);
12773
12774 run_pin_duty(&relay, &ch_hex, &rumor_id, None, crate::state::SessionGuard::capture()).await.unwrap();
12776 let after = read_channel_pins(&community, &general).unwrap();
12777 assert!(after.pins.is_empty(), "the omitting edition landed");
12778 assert!(after.version >= 2, "a NEW edition, not a local erase");
12779 }
12780
12781 #[tokio::test]
12784 async fn the_edit_duty_refreshes_a_pinned_proof() {
12785 let (_tmp, _guard, _owner) = init_test_db();
12786 let relay = MemoryRelay::new();
12787 let community = create_community(&relay, "Edits", vec!["wss://r".into()], None).await.unwrap();
12788 let general = community.channels[0].id;
12789 let ch_hex = crate::simd::hex::bytes_to_hex_32(&general.0);
12790
12791 let rumor_id = send_message(&relay, &community, &general, "first words").await.unwrap();
12792 let page = fetch_channel(&relay, &community, &general, 10).await.unwrap();
12793 let opened = page
12794 .iter()
12795 .find_map(|f| match &f.event {
12796 ChatEvent::Message { opened, .. } => (opened.rumor_id.to_hex() == rumor_id).then(|| opened.clone()),
12797 _ => None,
12798 })
12799 .unwrap();
12800 let ch = community.channels[0].clone();
12801 let conv = channel_conv_key_at(&community, &ch, 0).unwrap();
12802 let entry = crate::community::v2::pins::build_pin_entry(&opened, &conv, &ch_hex).unwrap();
12803 let session = crate::state::SessionGuard::capture();
12804 publish_pin_list(&relay, &community, &session, &ch, &[entry]).await.unwrap();
12805
12806 send_edit(&relay, &community, &general, &rumor_id, "second thoughts").await.unwrap();
12807 let page = fetch_channel(&relay, &community, &general, 10).await.unwrap();
12808 let edit_opened = page
12809 .iter()
12810 .find_map(|f| match &f.event {
12811 ChatEvent::Edit { opened, .. } => Some(opened.clone()),
12812 _ => None,
12813 })
12814 .expect("the edit reads back");
12815
12816 run_pin_duty(&relay, &ch_hex, &rumor_id, Some(edit_opened.clone()), crate::state::SessionGuard::capture()).await.unwrap();
12817 let after = read_channel_pins(&community, &general).unwrap();
12818 assert_eq!(after.pins.len(), 1);
12819 assert_eq!(
12820 after.pins[0].content, "second thoughts",
12821 "the refreshed bundle proves the revision"
12822 );
12823 let v = after.version;
12824
12825 run_pin_duty(&relay, &ch_hex, &rumor_id, Some(edit_opened), crate::state::SessionGuard::capture()).await.unwrap();
12827 assert_eq!(read_channel_pins(&community, &general).unwrap().version, v, "idempotent");
12828 }
12829
12830 #[tokio::test]
12834 async fn a_rotation_reseals_the_pin_list_under_the_new_epoch() {
12835 let (_tmp, _guard, _owner) = init_test_db();
12836 let relay = MemoryRelay::new();
12837 let community = create_community(&relay, "Reseal", vec!["wss://r".into()], None).await.unwrap();
12838 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12839 let chan = create_private_channel(&relay, &community, "vault").await.unwrap();
12840 let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12841 let ch_hex = crate::simd::hex::bytes_to_hex_32(&chan.0);
12842
12843 let rumor_id = send_message(&relay, &community, &chan, "sealed wisdom").await.unwrap();
12844 let page = fetch_channel(&relay, &community, &chan, 10).await.unwrap();
12845 let opened = page
12846 .iter()
12847 .find_map(|f| match &f.event {
12848 ChatEvent::Message { opened, .. } => (opened.rumor_id.to_hex() == rumor_id).then(|| opened.clone()),
12849 _ => None,
12850 })
12851 .unwrap();
12852 let ch = community.channel(&chan).unwrap().clone();
12853 let old_epoch = ch.epoch.0;
12854 let conv = channel_conv_key_at(&community, &ch, old_epoch).unwrap();
12855 let entry = crate::community::v2::pins::build_pin_entry(&opened, &conv, &ch_hex).unwrap();
12856 let session = crate::state::SessionGuard::capture();
12857 publish_pin_list(&relay, &community, &session, &ch, &[entry]).await.unwrap();
12858 let (_, v_before) = crate::db::community::get_community_pins(&cid_hex, &ch_hex).unwrap().unwrap();
12859
12860 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
12862 rekey_channel_excluding(&relay, &community, &chan, &roster, &[], &Keys::generate().public_key())
12863 .await
12864 .unwrap();
12865
12866 let (content, version) = crate::db::community::get_community_pins(&cid_hex, &ch_hex).unwrap().unwrap();
12868 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
12869 assert_eq!(
12870 parsed["epoch"].as_str().unwrap(),
12871 (old_epoch + 1).to_string(),
12872 "the reseal names the rotated epoch"
12873 );
12874 assert!(version > v_before, "a real edition, not a local rewrite");
12875
12876 let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
12878 let read = read_channel_pins(&community, &chan).unwrap();
12879 assert!(!read.sealed);
12880 assert_eq!(read.pins.len(), 1);
12881 assert_eq!(read.pins[0].content, "sealed wisdom");
12882 }
12883
12884 #[tokio::test]
12890 async fn a_published_banlist_echoes_locally_before_any_fold() {
12891 let (_tmp, _guard, _owner) = init_test_db();
12892 let relay = MemoryRelay::new();
12893 let community = create_community(&relay, "Modtown", vec!["wss://r".into()], None).await.unwrap();
12894 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
12895 let spammer_a = "aa".repeat(32);
12896 let spammer_b = "bb".repeat(32);
12897
12898 set_banlist(&relay, &community, &[spammer_a.clone()]).await.unwrap();
12900 assert_eq!(
12901 crate::db::community::get_community_banlist(&cid_hex).unwrap(),
12902 vec![spammer_a.clone()],
12903 "the publish echoes without waiting for a fold"
12904 );
12905
12906 let mut list = crate::db::community::get_community_banlist(&cid_hex).unwrap();
12908 list.push(spammer_b.clone());
12909 set_banlist(&relay, &community, &list).await.unwrap();
12910 let held = crate::db::community::get_community_banlist(&cid_hex).unwrap();
12911 assert!(
12912 held.contains(&spammer_a) && held.contains(&spammer_b),
12913 "sequential bans UNION; the second must not erase the first: {held:?}"
12914 );
12915
12916 let session = crate::state::SessionGuard::capture();
12918 follow_control(&relay, &community, &session).await.unwrap();
12919 let folded = crate::db::community::get_community_banlist(&cid_hex).unwrap();
12920 assert!(folded.contains(&spammer_a) && folded.contains(&spammer_b));
12921 }
12922}