1use nostr_sdk::prelude::{Event, Keys, PublicKey, SecretKey, Timestamp};
14
15use super::super::transport::{Query, Transport};
16use super::super::{version, ChannelId, Epoch};
17use super::chat::{self, ChatEvent};
18use super::community::{ChannelV2, CommunityV2};
19use super::control;
20use super::derive::{base_rekey_group_key, channel_group_key, channel_rekey_group_key, control_group_key, GroupKey};
21use super::invite::{self, CommunityInvite};
22use super::rekey::{self, Continuity, RekeyScope};
23use super::{guestbook, stream, vsk};
24use crate::community::edition::ParsedEdition;
25use crate::state::SessionGuard;
26
27fn local_keys() -> Result<Keys, String> {
30 crate::state::MY_SECRET_KEY
31 .to_keys()
32 .ok_or_else(|| "Concord v2 needs a local identity key (bunker create/send is not wired yet)".to_string())
33}
34
35fn now_ms() -> u64 {
36 std::time::SystemTime::now()
37 .duration_since(std::time::UNIX_EPOCH)
38 .map(|d| d.as_millis() as u64)
39 .unwrap_or(0)
40}
41
42pub async fn create_community<T: Transport + ?Sized>(
47 transport: &T,
48 name: &str,
49 relays: Vec<String>,
50 description: Option<String>,
51) -> Result<CommunityV2, String> {
52 let session = SessionGuard::capture();
53 let owner = local_keys()?;
54 let at_ms = now_ms();
55
56 let meta = control::CommunityMetadata {
57 name: name.to_string(),
58 description: description.clone(),
59 relays: relays.clone(),
60 ..Default::default()
61 };
62 let genesis = control::genesis(&owner, meta, at_ms / 1000).map_err(|e| e.to_string())?;
63 let community = CommunityV2::from_genesis(&genesis, name, description, relays.clone(), at_ms);
64
65 if !session.is_valid() {
70 return Err("account changed during community creation".to_string());
71 }
72 let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
78 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
79 for wrap in &genesis.wraps {
80 if let Ok((ed, _)) = control::open_control_edition(wrap, &control) {
81 let entity_hex = crate::simd::hex::bytes_to_hex_32(&ed.entity_id);
82 crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, ed.version, &ed.self_hash, &ed.inner_id, community.root_epoch.0)?;
83 }
84 }
85 crate::db::community::save_community_v2(&community)?;
86 let _ = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, community.root_epoch.0, &community.community_root);
89
90 for wrap in &genesis.wraps {
95 transport.publish_durable(wrap, &community.relays).await?;
96 }
97
98 let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
102 let join_rumor = guestbook::build_join_rumor(owner.public_key(), None, at_ms);
103 if let Ok((join_wrap, _)) = guestbook::seal_guestbook_rumor(&join_rumor, &gb_group, &owner, Timestamp::from_secs(at_ms / 1000)) {
104 let _ = transport.publish_durable(&join_wrap, &community.relays).await;
105 }
106
107 let _ = republish_community_list(transport, Some(community.id())).await;
109 Ok(community)
110}
111
112pub async fn send_message<T: Transport + ?Sized>(
116 transport: &T,
117 community: &CommunityV2,
118 channel_id: &ChannelId,
119 content: &str,
120) -> Result<String, String> {
121 send_chat_message(transport, community, channel_id, content, None, &[], vec![]).await
122}
123
124pub async fn send_chat_message<T: Transport + ?Sized>(
128 transport: &T,
129 community: &CommunityV2,
130 channel_id: &ChannelId,
131 content: &str,
132 reply_to: Option<(&str, &str)>,
133 emoji: &[(&str, &str)],
134 extra_tags: Vec<nostr_sdk::prelude::Tag>,
135) -> Result<String, String> {
136 send_chat_message_at(transport, community, channel_id, content, reply_to, emoji, extra_tags, now_ms()).await
137}
138
139#[allow(clippy::too_many_arguments)]
144pub async fn send_chat_message_at<T: Transport + ?Sized>(
145 transport: &T,
146 community: &CommunityV2,
147 channel_id: &ChannelId,
148 content: &str,
149 reply_to: Option<(&str, &str)>,
150 emoji: &[(&str, &str)],
151 extra_tags: Vec<nostr_sdk::prelude::Tag>,
152 at_ms: u64,
153) -> Result<String, String> {
154 let (author, group, epoch, session) = chat_send_context(community, channel_id)?;
155 let rumor = chat::build_message_rumor(author.public_key(), channel_id, epoch, content, reply_to, emoji, extra_tags, at_ms);
156 publish_chat(transport, community, &session, &group, &author, channel_id, epoch, rumor, at_ms, false).await
157}
158
159#[allow(clippy::too_many_arguments)]
164pub async fn send_reaction<T: Transport + ?Sized>(
165 transport: &T,
166 community: &CommunityV2,
167 channel_id: &ChannelId,
168 target_id_hex: &str,
169 target_author_hex: &str,
170 target_kind: u16,
171 emoji_content: &str,
172 emoji: Option<(&str, &str)>,
173) -> Result<String, String> {
174 let (author, group, epoch, session) = chat_send_context(community, channel_id)?;
175 let at_ms = now_ms();
176 let rumor =
177 chat::build_reaction_rumor(author.public_key(), channel_id, epoch, target_id_hex, target_author_hex, target_kind, emoji_content, emoji, at_ms);
178 publish_chat(transport, community, &session, &group, &author, channel_id, epoch, rumor, at_ms, false).await
179}
180
181pub async fn send_edit<T: Transport + ?Sized>(
185 transport: &T,
186 community: &CommunityV2,
187 channel_id: &ChannelId,
188 target_id_hex: &str,
189 new_content: &str,
190) -> Result<String, String> {
191 let (author, group, epoch, session) = chat_send_context(community, channel_id)?;
192 let at_ms = now_ms();
193 let rumor = chat::build_edit_rumor(author.public_key(), channel_id, epoch, target_id_hex, new_content, at_ms);
194 publish_chat(transport, community, &session, &group, &author, channel_id, epoch, rumor, at_ms, false).await
195}
196
197pub async fn send_delete<T: Transport + ?Sized>(
201 transport: &T,
202 community: &CommunityV2,
203 channel_id: &ChannelId,
204 target_id_hex: &str,
205 target_kind: u16,
206) -> Result<String, String> {
207 let (author, group, epoch, session) = chat_send_context(community, channel_id)?;
208 let at_ms = now_ms();
209 let rumor = chat::build_delete_rumor(author.public_key(), channel_id, epoch, target_id_hex, target_kind, at_ms);
210 publish_chat(transport, community, &session, &group, &author, channel_id, epoch, rumor, at_ms, false).await
211}
212
213pub async fn send_webxdc_signal<T: Transport + ?Sized>(
220 transport: &T,
221 community: &CommunityV2,
222 channel_id: &ChannelId,
223 topic_id: &str,
224 node_addr: Option<&str>,
225) -> Result<(), String> {
226 let (author, group, epoch, session) = chat_send_context(community, channel_id)?;
227 let at_ms = now_ms();
228 let content = crate::webxdc::peer_signal_content(topic_id, node_addr);
229 let rumor = chat::build_webxdc_rumor(author.public_key(), channel_id, epoch, &content, vec![], at_ms);
230 publish_chat(transport, community, &session, &group, &author, channel_id, epoch, rumor, at_ms, false).await.map(|_| ())
231}
232
233pub async fn send_typing<T: Transport + ?Sized>(
235 transport: &T,
236 community: &CommunityV2,
237 channel_id: &ChannelId,
238) -> Result<(), String> {
239 let (author, group, epoch, session) = chat_send_context(community, channel_id)?;
240 let at_ms = now_ms();
241 let rumor = chat::build_typing_rumor(author.public_key(), channel_id, epoch, at_ms);
242 publish_chat(transport, community, &session, &group, &author, channel_id, epoch, rumor, at_ms, true).await.map(|_| ())
243}
244
245fn chat_send_context(community: &CommunityV2, channel_id: &ChannelId) -> Result<(Keys, GroupKey, Epoch, SessionGuard), String> {
251 let session = SessionGuard::capture();
252 let author = local_keys()?;
253 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
254 if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
255 return Err("this community has been dissolved".to_string());
256 }
257 if crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default().contains(&author.public_key().to_hex()) {
261 return Err("you are banned from this community".to_string());
262 }
263 let ch = community.channel(channel_id).ok_or("no such channel in this community")?;
264 if ch.private && ch.key.is_none() {
265 return Err("this private channel has no key yet (awaiting rekey delivery)".to_string());
266 }
267 let (secret, epoch) = community.channel_secret(ch);
268 Ok((author, channel_group_key(&secret, channel_id, epoch), epoch, session))
269}
270
271#[allow(clippy::too_many_arguments)]
274async fn publish_chat<T: Transport + ?Sized>(
275 transport: &T,
276 community: &CommunityV2,
277 session: &SessionGuard,
278 group: &GroupKey,
279 author: &Keys,
280 channel_id: &ChannelId,
281 epoch: Epoch,
282 rumor: nostr_sdk::prelude::UnsignedEvent,
283 at_ms: u64,
284 ephemeral: bool,
285) -> Result<String, String> {
286 let rumor_id = rumor.id.ok_or("rumor has no id")?.to_hex();
287 let (wrap, _p_tag_keys) = chat::seal_chat_rumor(&rumor, group, author, Timestamp::from_secs(at_ms / 1000), ephemeral)
288 .map_err(|e| e.to_string())?;
289 if !session.is_valid() {
290 return Err("account changed before send".to_string());
291 }
292 transport.publish(&wrap, &community.relays).await?;
293 if !ephemeral {
299 if !session.is_valid() {
300 return Ok(rumor_id);
301 }
302 crate::db::community::store_message_key(&rumor_id, &wrap.id.to_hex(), group.keys(), &community.relays)?;
303 }
304 if !ephemeral {
309 if let Ok(event) = chat::open_chat_event(&wrap, group, channel_id, epoch) {
310 let channel_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
311 let outcome = {
312 let mut st = crate::state::STATE.lock().await;
313 if !session.is_valid() {
314 return Ok(rumor_id); }
316 super::inbound::apply_chat_to_state(&mut st, &event, &channel_hex, &author.public_key())
317 };
318 if let Some(outcome) = outcome {
319 if !session.is_valid() {
320 return Ok(rumor_id);
321 }
322 super::inbound::persist_chat(&channel_hex, &outcome).await;
323 }
324 }
325 }
326 Ok(rumor_id)
327}
328
329pub struct FetchedEvent {
332 pub event: ChatEvent,
333 pub epoch: Epoch,
334}
335
336fn heal_own_wrap_key(event: &ChatEvent, group: &GroupKey, relays: &[String]) {
343 if !matches!(event, ChatEvent::Message { .. } | ChatEvent::Reaction { .. }) {
344 return;
345 }
346 let opened = event.opened();
347 if crate::state::my_public_key() != Some(opened.author) {
348 return;
349 }
350 let rumor_hex = opened.rumor_id.to_hex();
351 if !matches!(crate::db::community::get_message_key(&rumor_hex), Ok(None)) {
354 return;
355 }
356 if crate::db::community::store_message_key(&rumor_hex, &opened.wrapper_id.to_hex(), group.keys(), relays).is_ok() {
357 crate::traits::emit_event("message_delete_meta_changed", &serde_json::json!({ "id": rumor_hex }));
360 }
361}
362
363pub async fn fetch_channel<T: Transport + ?Sized>(
367 transport: &T,
368 community: &CommunityV2,
369 channel_id: &ChannelId,
370 limit: usize,
371) -> Result<Vec<FetchedEvent>, String> {
372 fetch_channel_history(transport, community, channel_id, limit, 1, |_| true).await
373}
374
375pub async fn fetch_channel_history<T: Transport + ?Sized>(
389 transport: &T,
390 community: &CommunityV2,
391 channel_id: &ChannelId,
392 page: usize,
393 max_pages: usize,
394 mut keep_paging: impl FnMut(&[FetchedEvent]) -> bool,
395) -> Result<Vec<FetchedEvent>, String> {
396 let session = SessionGuard::capture();
399 let ch = community.channel(channel_id).ok_or("no such channel in this community")?;
400 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
405 let coords: Vec<([u8; 32], Epoch)> = if ch.private {
406 let Some(current) = ch.key else {
407 return Ok(Vec::new());
408 };
409 let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
410 let mut held = crate::db::community::held_epoch_keys(&cid_hex, &ch_hex).unwrap_or_default();
411 if !held.iter().any(|(ep, _)| *ep == ch.epoch) {
412 held.push((ch.epoch, current));
413 }
414 held.into_iter().filter(|(_, k)| *k != community.community_root).map(|(ep, k)| (k, ep)).collect()
417 } else {
418 let mut roots = crate::db::community::held_epoch_keys(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap_or_default();
419 if !roots.iter().any(|(ep, _)| *ep == community.root_epoch) {
420 roots.push((community.root_epoch, community.community_root));
421 }
422 roots.into_iter().map(|(ep, root)| (root, ep)).collect()
423 };
424 if coords.is_empty() {
425 return Ok(Vec::new());
426 }
427
428 let authors: Vec<String> = coords
430 .iter()
431 .map(|(secret, epoch)| channel_group_key(secret, channel_id, *epoch).pk_hex())
432 .collect();
433
434 let mut seen_wraps: std::collections::HashSet<nostr_sdk::EventId> = std::collections::HashSet::new();
435 let mut seen_rumors = std::collections::HashSet::new();
436 let mut out: Vec<(u64, FetchedEvent)> = Vec::new();
437 let mut until: Option<u64> = None;
438 let mut oldest: Option<u64> = None;
439 for _ in 0..max_pages {
440 let query = Query {
441 kinds: vec![stream::KIND_WRAP],
442 authors: authors.clone(),
443 until,
444 limit: Some(page),
445 ..Default::default()
446 };
447 let wraps = transport.fetch(&query, &community.relays).await?;
448 if wraps.is_empty() {
449 break;
450 }
451 let mut fresh = 0usize;
452 let mut page_events: Vec<FetchedEvent> = Vec::new();
453 for wrap in &wraps {
454 if !seen_wraps.insert(wrap.id) {
455 continue;
456 }
457 fresh += 1;
458 let at = wrap.created_at.as_secs();
459 if oldest.is_none_or(|o| at < o) {
460 oldest = Some(at);
461 }
462 for (secret, epoch) in &coords {
464 let group = channel_group_key(secret, channel_id, *epoch);
465 if wrap.pubkey != group.pk() {
466 continue;
467 }
468 if let Ok(event) = chat::open_chat_event(wrap, &group, channel_id, *epoch) {
469 let id = event.opened().rumor_id;
470 if seen_rumors.insert(id) {
471 if session.is_valid() {
472 heal_own_wrap_key(&event, &group, &community.relays);
473 }
474 page_events.push(FetchedEvent { event, epoch: *epoch });
475 }
476 }
477 break;
478 }
479 }
480 if fresh == 0 {
481 if wraps.len() < page {
482 break; }
484 let Some(o) = oldest else { break };
488 if o == 0 {
489 break;
490 }
491 crate::log_warn!("v2: same-second history wall at {o} — stepping past it (messages beyond the relay page cap in that second are unreachable)");
492 until = Some(o - 1);
493 continue;
494 }
495 let stop = !page_events.is_empty() && !keep_paging(&page_events);
496 out.extend(page_events.into_iter().map(|e| (e.event.opened().at_ms, e)));
497 if stop {
498 break; }
500 until = oldest; }
502 out.sort_by_key(|(ms, _)| *ms);
503 Ok(out.into_iter().map(|(_, e)| e).collect())
504}
505
506pub fn bundle_of(
513 community: &CommunityV2,
514 creator: Option<PublicKey>,
515 expires_at_ms: Option<u64>,
516 label: Option<String>,
517) -> CommunityInvite {
518 let hex = crate::simd::hex::bytes_to_hex_32;
519 let channels = community
520 .channels
521 .iter()
522 .filter(|c| !(c.private && c.key.is_none()))
527 .map(|c| invite::ChannelGrant {
528 id: hex(&c.id.0),
529 key: hex(&c.key.unwrap_or(community.community_root)),
530 epoch: c.epoch.0,
531 name: c.name.clone(),
532 })
533 .collect();
534 CommunityInvite {
535 community_id: hex(&community.identity.community_id.0),
536 owner: hex(&community.identity.owner_xonly),
537 owner_salt: hex(&community.identity.owner_salt),
538 community_root: hex(&community.community_root),
539 root_epoch: community.root_epoch.0,
540 channels,
541 relays: community.relays.clone(),
542 name: community.name.clone(),
543 icon: community.icon.clone(),
546 expires_at: expires_at_ms,
547 creator_npub: creator.map(|p| p.to_hex()),
548 label,
549 extra: Default::default(),
550 }
551}
552
553pub async fn send_direct_invite<T: Transport + ?Sized>(
559 transport: &T,
560 community: &CommunityV2,
561 recipient: &PublicKey,
562 expires_at_ms: Option<u64>,
563 label: Option<String>,
564) -> Result<Event, String> {
565 let session = SessionGuard::capture();
566 let inviter = local_keys()?;
567 let bundle = bundle_of(community, Some(inviter.public_key()), expires_at_ms, label);
568 let wrap = invite::build_direct_invite(&inviter, recipient, &bundle).map_err(|e| e.to_string())?;
569 if !session.is_valid() {
570 return Err("account changed before sending invite".to_string());
571 }
572 transport.publish(&wrap, &community.relays).await?;
573 Ok(wrap)
574}
575
576pub struct MintedLink {
580 pub url: String,
581 pub bundle_event: Event,
582 pub link_signer: Keys,
583 pub token: [u8; super::derive::TOKEN_LEN],
584}
585
586pub async fn mint_public_link<T: Transport + ?Sized>(
592 transport: &T,
593 community: &CommunityV2,
594 base: &str,
595 expires_at_ms: Option<u64>,
596 label: Option<String>,
597) -> Result<MintedLink, String> {
598 let session = SessionGuard::capture();
599 let mut token = [0u8; super::derive::TOKEN_LEN];
600 token.copy_from_slice(&super::super::random_32()[..super::derive::TOKEN_LEN]);
601 let link_signer = Keys::generate();
602 let bundle = bundle_of(community, Some(local_keys()?.public_key()), expires_at_ms, label.clone());
603 let bundle_key = super::derive::invite_bundle_key(&token);
604 let bundle_event = invite::build_bundle_event(&link_signer, &bundle, &bundle_key).map_err(|e| e.to_string())?;
605 let url = invite::build_invite_url(base, &link_signer.public_key(), &token, &community.relays).map_err(|e| e.to_string())?;
606
607 if !session.is_valid() {
608 return Err("account changed before minting link".to_string());
609 }
610 transport.publish_durable(&bundle_event, &community.relays).await?;
611 let minted = MintedLink { url, bundle_event, link_signer, token };
612 let _ = record_minted_link(transport, community, &minted).await;
616 if session.is_valid() {
621 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
622 let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
623 let _ = crate::db::community::save_public_invite(&token_hex, &cid_hex, &minted.url, expires_at_ms.map(|e| e as i64), label.as_deref());
624 }
625 Ok(minted)
626}
627
628async fn fetch_invite_list<T: Transport + ?Sized>(transport: &T, relays: &[String]) -> Option<invite::InviteList> {
633 let me = local_keys().ok()?;
634 let query = Query {
635 kinds: vec![super::kind::INVITE_LIST],
636 authors: vec![me.public_key().to_hex()],
637 limit: Some(4),
638 ..Default::default()
639 };
640 let events = transport.fetch(&query, relays).await.ok()?;
641 events
642 .into_iter()
643 .filter_map(|e| invite::parse_invite_list_event(&e, &me).ok().map(|l| (e.created_at.as_secs(), l)))
644 .max_by_key(|(at, _)| *at)
645 .map(|(_, l)| l)
646}
647
648fn live_signers_for(list: &invite::InviteList, community_id_hex: &str) -> Vec<PublicKey> {
651 let dead: std::collections::HashSet<&str> = list.tombstones.iter().map(|t| t.token.as_str()).collect();
652 list.entries
653 .iter()
654 .filter(|e| e.community_id == community_id_hex && !dead.contains(e.token.as_str()))
655 .filter_map(|e| Keys::parse(&e.signer_sk).ok().map(|k| k.public_key()))
656 .collect()
657}
658
659async fn publish_invite_registry<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, session: &SessionGuard, live_signers: &[PublicKey]) -> Result<(), String> {
663 let me = local_keys()?;
664 let eid = super::derive::invite_links_locator(community.id(), &me.public_key().to_bytes());
665 let content = invite::build_registry_content(live_signers);
666 publish_control_edition(transport, community, session, vsk::INVITE_LINKS, &eid, &content, None).await
667}
668
669async fn record_minted_link<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, minted: &MintedLink) -> Result<(), String> {
672 let session = SessionGuard::capture();
673 let me = local_keys()?;
674 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
675 let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
676 let mut list = fetch_invite_list(transport, &community.relays).await.unwrap_or_default();
677 if !list.entries.iter().any(|e| e.token == token_hex) {
678 list.entries.push(invite::InviteEntry {
679 token: token_hex,
680 signer_sk: minted.link_signer.secret_key().to_secret_hex(),
681 community_id: cid_hex.clone(),
682 url: minted.url.clone(),
683 label: None,
684 created_at: now_ms() / 1000,
685 expires_at: None,
686 extra: Default::default(),
687 });
688 }
689 if !session.is_valid() {
690 return Err("account changed during link record".to_string());
691 }
692 let event = invite::build_invite_list_event(&me, &list).map_err(|e| e.to_string())?;
693 transport.publish(&event, &community.relays).await?;
694 let signers = live_signers_for(&list, &cid_hex);
695 publish_invite_registry(transport, community, &session, &signers).await
696}
697
698pub async fn revoke_public_link<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, token_hex: &str) -> Result<(), String> {
704 let session = SessionGuard::capture();
705 let me = local_keys()?;
706 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
707 let mut list = fetch_invite_list(transport, &community.relays).await.ok_or("no invite list found to revoke from")?;
708 let entry = list
709 .entries
710 .iter()
711 .find(|e| e.token == token_hex && e.community_id == cid_hex)
712 .cloned()
713 .ok_or("no such link in the invite list")?;
714 let link_signer = Keys::parse(&entry.signer_sk).map_err(|_| "malformed link signer")?;
716 let revocation = invite::build_revocation(&link_signer).map_err(|e| e.to_string())?;
717 if !session.is_valid() {
718 return Err("account changed during revoke".to_string());
719 }
720 transport.publish_durable(&revocation, &community.relays).await?;
721 list.tombstones.push(invite::InviteTombstone { token: token_hex.to_string(), community_id: cid_hex.clone(), extra: Default::default() });
723 list.entries.retain(|e| e.token != token_hex);
724 let event = invite::build_invite_list_event(&me, &list).map_err(|e| e.to_string())?;
725 transport.publish(&event, &community.relays).await?;
726 let signers = live_signers_for(&list, &cid_hex);
727 publish_invite_registry(transport, community, &session, &signers).await?;
728 if session.is_valid() {
730 let _ = crate::db::community::delete_public_invite(token_hex);
731 }
732 Ok(())
733}
734
735pub async fn refresh_public_links<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
740 let session = SessionGuard::capture();
741 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
742 let me = local_keys()?;
747 let query = Query {
748 kinds: vec![super::kind::INVITE_LIST],
749 authors: vec![me.public_key().to_hex()],
750 limit: Some(4),
751 ..Default::default()
752 };
753 let events = transport.fetch(&query, &community.relays).await?;
754 let list = events
755 .into_iter()
756 .filter_map(|e| invite::parse_invite_list_event(&e, &me).ok().map(|l| (e.created_at.as_secs(), l)))
757 .max_by_key(|(at, _)| *at)
758 .map(|(_, l)| l);
759 let Some(list) = list else {
760 return Ok(());
761 };
762 let creator = local_keys()?.public_key();
763 let dead: std::collections::HashSet<&str> = list.tombstones.iter().map(|t| t.token.as_str()).collect();
764 for entry in &list.entries {
765 if entry.community_id != cid_hex || dead.contains(entry.token.as_str()) || entry.token.len() != 2 * super::derive::TOKEN_LEN {
766 continue;
767 }
768 let Ok(link_signer) = Keys::parse(&entry.signer_sk) else { continue };
769 let token = crate::simd::hex::hex_to_bytes_16(&entry.token);
770 let bundle = bundle_of(community, Some(creator), entry.expires_at, entry.label.clone());
771 let bundle_key = super::derive::invite_bundle_key(&token);
772 if let Ok(event) = invite::build_bundle_event(&link_signer, &bundle, &bundle_key) {
773 if !session.is_valid() {
774 return Err("account changed during link refresh".to_string());
775 }
776 let _ = transport.publish_durable(&event, &community.relays).await;
777 }
778 }
779 Ok(())
780}
781
782pub async fn community_is_public<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> bool {
787 use crate::community::roles::Permissions;
788 use std::collections::BTreeMap;
789 let Ok(owner) = community.owner() else { return false };
790 let owner_hex = owner.to_hex();
791 let cid = community.id();
792 let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
793 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
794 .unwrap_or_default()
795 .into_iter()
796 .filter(|(_, f)| f.0 == community.root_epoch.0)
797 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
798 .collect();
799 let control = control_group_key(&community.community_root, cid, community.root_epoch);
800 let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![control.pk_hex()], limit: Some(FOLLOW_PAGE), ..Default::default() };
801 let editions: Vec<ParsedEdition> = transport
802 .fetch(&query, &community.relays)
803 .await
804 .unwrap_or_default()
805 .iter()
806 .filter_map(|w| control::open_control_edition(w, &control).ok().map(|(ed, _)| ed))
807 .collect();
808 let authority = fold_authority(community, &editions, &floors);
809
810 let mut by_eid: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
811 for e in &editions {
812 if e.vsk == vsk::INVITE_LINKS {
813 by_eid.entry(e.entity_id).or_default().push(e);
814 }
815 }
816 for (eid, group) in &by_eid {
817 let fold_eds: Vec<version::Edition> = group.iter().map(|p| p.to_fold_edition()).collect();
818 let (Some(hi), _) = fold_head(&fold_eds, floors.get(&crate::simd::hex::bytes_to_hex_32(eid))) else { continue };
819 let head = group[hi];
820 if !authority.roles.is_authorized(&head.author.to_hex(), Some(&owner_hex), Permissions::CREATE_INVITE) {
822 continue;
823 }
824 if super::derive::invite_links_locator(cid, &head.author.to_bytes()) != *eid {
825 continue;
826 }
827 if invite::parse_registry_content(&head.content).map(|s| !s.is_empty()).unwrap_or(false) {
828 return true;
829 }
830 }
831 false
832}
833
834async fn accept_bundle<T: Transport + ?Sized>(
840 transport: &T,
841 session: &SessionGuard,
842 bundle: &CommunityInvite,
843 invited_by: Option<PublicKey>,
844) -> Result<CommunityV2, String> {
845 let me = local_keys()?;
846 let at_ms = now_ms();
847 if bundle.expired(at_ms) {
849 return Err("this invite has expired".to_string());
850 }
851 let community = CommunityV2::from_bundle(bundle, at_ms)?;
853
854 let handoff = VERIFIED_PREVIEW.lock().unwrap().take().filter(|v| {
863 v.session.is_valid()
864 && v.at.elapsed() < VERIFIED_PREVIEW_TTL
865 && v.community_id == community.id().0
866 && v.community_root == community.community_root
867 });
868 let (community, join_heads) = match handoff {
869 Some(v) => {
870 let mut c = v.folded;
871 c.created_at_ms = at_ms;
873 (c, v.heads)
874 }
875 None => verify_owner_root_and_reconcile(transport, community).await?,
876 };
877
878 if is_dissolved(transport, &community).await {
880 return Err("this community has been dissolved".to_string());
881 }
882
883 if !session.is_valid() {
887 return Err("account changed during join".to_string());
888 }
889 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
894 for h in &join_heads {
895 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)?;
896 }
897 crate::db::community::save_community_v2(&community)?;
898 let _ = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, community.root_epoch.0, &community.community_root);
901 for ch in &community.channels {
904 if let (true, Some(key)) = (ch.private, ch.key) {
905 let _ = crate::db::community::store_epoch_key(&cid_hex, &crate::simd::hex::bytes_to_hex_32(&ch.id.0), ch.epoch.0, &key);
906 }
907 }
908
909 let attribution = invited_by
911 .map(|p| p.to_hex())
912 .or_else(|| bundle.creator_npub.clone())
913 .zip(Some(bundle.label.clone().unwrap_or_default()));
914 let attr_ref = attribution.as_ref().map(|(c, l)| (c.as_str(), l.as_str()));
915 let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
916 let join_rumor = guestbook::build_join_rumor(me.public_key(), attr_ref, at_ms);
917 if let Ok((join_wrap, _)) = guestbook::seal_guestbook_rumor(&join_rumor, &gb_group, &me, Timestamp::from_secs(at_ms / 1000)) {
918 let _ = transport.publish(&join_wrap, &community.relays).await;
919 }
920
921 let _ = republish_community_list(transport, Some(community.id())).await;
923 Ok(community)
924}
925
926async fn verify_owner_root_and_reconcile<T: Transport + ?Sized>(
939 transport: &T,
940 community: CommunityV2,
941) -> Result<(CommunityV2, Vec<FoldedHead>), String> {
942 let owner = community.owner()?;
943 let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
944 let control_pk = control.pk_hex();
945
946 super::streamauth::prime(&community);
953
954 const PAGE: usize = 500;
974 const MAX_PAGES: usize = 4;
975 const FAR_FUTURE_SECS: u64 = 4_102_444_800; let mut editions: Vec<ParsedEdition> = Vec::new();
977 let mut found_genesis = false;
978 let mut until: Option<u64> = Some(FAR_FUTURE_SECS);
979 let mut seen_wraps: std::collections::HashSet<nostr_sdk::EventId> = std::collections::HashSet::new();
980 for _ in 0..MAX_PAGES {
981 let query = Query {
982 kinds: vec![stream::KIND_WRAP],
983 authors: vec![control_pk.clone()],
984 until,
985 limit: Some(PAGE),
986 ..Default::default()
987 };
988 let wraps = transport.fetch(&query, &community.relays).await?;
989 let mut oldest = u64::MAX;
993 let mut fresh = 0usize;
994 for w in &wraps {
995 if !seen_wraps.insert(w.id) {
996 continue;
997 }
998 fresh += 1;
999 oldest = oldest.min(w.created_at.as_secs());
1000 if let Ok((ed, _)) = control::open_control_edition(w, &control) {
1001 if ed.author == owner {
1002 if ed.vsk == vsk::COMMUNITY_METADATA && ed.entity_id == community.id().0 {
1003 found_genesis = true;
1004 }
1005 editions.push(ed);
1006 }
1007 }
1008 }
1009 if found_genesis || fresh == 0 {
1010 break; }
1012 until = Some(oldest);
1013 }
1014 if !found_genesis {
1015 return Err(
1016 "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"
1017 .to_string(),
1018 );
1019 }
1020 let empty_floors = Floors::new();
1029 let authority = AuthoritySet::owner_only();
1030 let fold = apply_control_fold(&community, &editions, &empty_floors, &authority);
1031 Ok((fold.updated.unwrap_or(community), fold.heads))
1032}
1033
1034pub async fn accept_direct_invite<T: Transport + ?Sized>(transport: &T, wrap: &Event) -> Result<CommunityV2, String> {
1038 let session = SessionGuard::capture();
1039 let me = local_keys()?;
1040 let (inviter, bundle) = invite::unwrap_direct_invite(wrap, &me).map_err(|e| e.to_string())?;
1041 accept_bundle(transport, &session, &bundle, Some(inviter)).await
1042}
1043
1044pub async fn accept_parked_invite<T: Transport + ?Sized>(
1050 transport: &T,
1051 bundle_json: &str,
1052 inviter_hex: Option<&str>,
1053) -> Result<CommunityV2, String> {
1054 let session = SessionGuard::capture();
1055 let bundle = CommunityInvite::from_bundle_json(bundle_json).map_err(|e| e.to_string())?;
1056 let invited_by = inviter_hex.and_then(|h| PublicKey::parse(h).ok());
1057 accept_bundle(transport, &session, &bundle, invited_by).await
1058}
1059
1060pub async fn fetch_public_bundle<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityInvite, String> {
1067 let parsed = invite::parse_invite_link(url).map_err(|e| e.to_string())?;
1068 let query = Query {
1073 kinds: vec![super::kind::INVITE_BUNDLE],
1074 authors: vec![parsed.link_signer.to_hex()],
1075 ..Default::default()
1076 };
1077 let relays = if parsed.bootstrap_relays.is_empty() {
1078 invite::stock_relays()
1079 } else {
1080 parsed.bootstrap_relays.clone()
1081 };
1082 let events = transport.fetch(&query, &relays).await?;
1083 let bundle_key = super::derive::invite_bundle_key(&parsed.token);
1084
1085 let mut newest_live: Option<(u64, CommunityInvite)> = None;
1087 for event in &events {
1088 match invite::parse_bundle_event(event, &parsed.link_signer, &bundle_key) {
1089 Ok(invite::BundleState::Revoked) => return Err("this invite link has been revoked".to_string()),
1090 Ok(invite::BundleState::Live(bundle)) => {
1091 let at = event.created_at.as_secs();
1092 if newest_live.as_ref().is_none_or(|(t, _)| at > *t) {
1093 newest_live = Some((at, *bundle));
1094 }
1095 }
1096 Err(_) => {} }
1098 }
1099 newest_live.map(|(_, b)| b).ok_or_else(|| "invite bundle not found on relays".to_string())
1100}
1101
1102struct VerifiedPreview {
1108 session: SessionGuard,
1109 at: std::time::Instant,
1110 community_id: [u8; 32],
1111 community_root: [u8; 32],
1112 folded: CommunityV2,
1113 heads: Vec<FoldedHead>,
1114}
1115static VERIFIED_PREVIEW: std::sync::Mutex<Option<VerifiedPreview>> = std::sync::Mutex::new(None);
1116const VERIFIED_PREVIEW_TTL: std::time::Duration = std::time::Duration::from_secs(120);
1117
1118pub async fn preview_public_link<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityV2, String> {
1125 let bundle = fetch_public_bundle(transport, url).await?;
1126 preview_bundle(transport, &bundle).await
1127}
1128
1129pub async fn preview_bundle<T: Transport + ?Sized>(transport: &T, bundle: &CommunityInvite) -> Result<CommunityV2, String> {
1134 let community = CommunityV2::from_bundle(bundle, 0)?;
1135 match verify_owner_root_and_reconcile(transport, community.clone()).await {
1136 Ok((folded, heads)) => {
1137 *VERIFIED_PREVIEW.lock().unwrap() = Some(VerifiedPreview {
1138 session: SessionGuard::capture(),
1139 at: std::time::Instant::now(),
1140 community_id: folded.id().0,
1141 community_root: folded.community_root,
1142 folded: folded.clone(),
1143 heads,
1144 });
1145 Ok(folded)
1146 }
1147 Err(_) => Ok(community),
1148 }
1149}
1150
1151pub async fn accept_public_link<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityV2, String> {
1153 let session = SessionGuard::capture();
1155 let bundle = fetch_public_bundle(transport, url).await?;
1156 if !session.is_valid() {
1157 return Err("account changed during join".to_string());
1158 }
1159 accept_bundle(transport, &session, &bundle, None).await
1160}
1161
1162pub async fn leave_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
1164 let session = SessionGuard::capture();
1165 let me = local_keys()?;
1166 let at_ms = now_ms();
1167 let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1168 let leave_rumor = guestbook::build_leave_rumor(me.public_key(), at_ms);
1169 if let Ok((wrap, _)) = guestbook::seal_guestbook_rumor(&leave_rumor, &gb_group, &me, Timestamp::from_secs(at_ms / 1000)) {
1170 let _ = transport.publish(&wrap, &community.relays).await;
1171 }
1172 if !session.is_valid() {
1173 return Err("account changed during leave".to_string());
1174 }
1175 let _ = tombstone_community_list(transport, community.id(), &community.relays).await;
1179 if !session.is_valid() {
1181 return Err("account changed during leave".to_string());
1182 }
1183 crate::db::community::delete_community(&crate::simd::hex::bytes_to_hex_32(&community.id().0))?;
1184 Ok(())
1185}
1186
1187pub async fn kick_member<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, target: &PublicKey) -> Result<(), String> {
1192 let session = SessionGuard::capture();
1193 let me = local_keys()?;
1194 let authority = fetch_authority(transport, community).await;
1197 let owner_hex = community.owner()?.to_hex();
1198 if !authority.roles.can_act_on_member(
1199 &me.public_key().to_hex(),
1200 Some(&owner_hex),
1201 &target.to_hex(),
1202 crate::community::roles::Permissions::KICK,
1203 ) {
1204 return Err("not authorized to kick this member".to_string());
1205 }
1206 let at_ms = now_ms();
1207 let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1208 let rumor = guestbook::build_kick_rumor(me.public_key(), *target, None, at_ms);
1209 let (wrap, _) = guestbook::seal_guestbook_rumor(&rumor, &gb_group, &me, Timestamp::from_secs(at_ms / 1000))
1210 .map_err(|e| e.to_string())?;
1211 if !session.is_valid() {
1212 return Err("account changed before send".to_string());
1213 }
1214 transport.publish(&wrap, &community.relays).await?;
1215 Ok(())
1216}
1217
1218pub struct AuthorityView {
1224 pub roles: crate::community::roles::CommunityRoles,
1225 pub banned: std::collections::BTreeSet<String>,
1226 pub gapped: bool,
1228 pub floored: std::collections::BTreeSet<String>,
1230 pub head_entities: std::collections::BTreeSet<String>,
1232}
1233
1234pub async fn fetch_authority<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> AuthorityView {
1240 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1241 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
1242 .unwrap_or_default()
1243 .into_iter()
1244 .filter(|(_, f)| f.0 == community.root_epoch.0)
1245 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
1246 .collect();
1247 let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
1248
1249 let mut editions: Vec<ParsedEdition> = Vec::new();
1250 let mut seen: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
1251 let mut seen_wraps: std::collections::HashSet<nostr_sdk::EventId> = std::collections::HashSet::new();
1252 let mut oldest: Option<u64> = None;
1253 let mut until: Option<u64> = None;
1254 let mut a = fold_authority(community, &[], &floors);
1259 for _ in 0..FOLLOW_MAX_PAGES {
1260 let query = Query {
1261 kinds: vec![stream::KIND_WRAP],
1262 authors: vec![control.pk_hex()],
1263 until,
1264 limit: Some(FOLLOW_PAGE),
1265 ..Default::default()
1266 };
1267 let Ok(wraps) = transport.fetch(&query, &community.relays).await else { break };
1268 let mut fresh = 0usize;
1269 for w in &wraps {
1270 if !seen_wraps.insert(w.id) {
1271 continue;
1272 }
1273 fresh += 1;
1274 let at = w.created_at.as_secs();
1275 if oldest.is_none_or(|o| at < o) {
1276 oldest = Some(at);
1277 }
1278 if let Ok((ed, _)) = control::open_control_edition(w, &control) {
1279 if seen.insert(ed.inner_id) {
1280 editions.push(ed);
1281 }
1282 }
1283 }
1284 a = fold_authority(community, &editions, &floors);
1285 if !a.gapped || fresh == 0 {
1286 break;
1287 }
1288 until = oldest;
1289 }
1290 AuthorityView {
1291 roles: a.roles,
1292 banned: a.banned,
1293 gapped: a.gapped,
1294 floored: floors.keys().cloned().collect(),
1295 head_entities: a.heads.iter().map(|h| h.entity_hex.clone()).collect(),
1296 }
1297}
1298
1299async fn fetch_guestbook_events<T: Transport + ?Sized>(
1311 transport: &T,
1312 community: &CommunityV2,
1313 since_secs: u64,
1314) -> Result<(Vec<guestbook::GuestbookEvent>, u64), String> {
1315 let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1316 const GB_PAGE: usize = 500;
1317 const GB_MAX_PAGES: usize = 12;
1318 let mut events = Vec::new();
1319 let mut newest: u64 = since_secs;
1320 let mut seen: std::collections::HashSet<nostr_sdk::EventId> = std::collections::HashSet::new();
1321 let mut until: Option<u64> = None;
1322 let mut oldest: Option<u64> = None;
1323 for _ in 0..GB_MAX_PAGES {
1324 let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![gb_group.pk_hex()], until, limit: Some(GB_PAGE), ..Default::default() };
1325 let wraps = transport.fetch(&query, &community.relays).await?;
1326 let mut fresh = 0usize;
1327 for wrap in &wraps {
1328 if !seen.insert(wrap.id) {
1329 continue;
1330 }
1331 fresh += 1;
1332 let at = wrap.created_at.as_secs();
1333 if oldest.is_none_or(|o| at < o) {
1334 oldest = Some(at);
1335 }
1336 if at > newest {
1337 newest = at;
1338 }
1339 if at < since_secs {
1341 continue;
1342 }
1343 if let Ok(opened) = stream::open_wrap(wrap, &gb_group) {
1344 if let Ok(ev) = guestbook::parse_guestbook_event(&opened) {
1345 events.push(ev);
1346 }
1347 }
1348 }
1349 if fresh == 0 || wraps.len() < GB_PAGE || oldest.is_some_and(|o| o < since_secs) {
1350 break;
1351 }
1352 match oldest {
1353 Some(o) if o > 0 => until = Some(o),
1354 _ => break,
1355 }
1356 }
1357 Ok((events, newest))
1358}
1359
1360fn fold_members(
1365 community: &CommunityV2,
1366 events: &[guestbook::GuestbookEvent],
1367 mut observed: std::collections::BTreeMap<PublicKey, u64>,
1368 roles: &crate::community::roles::CommunityRoles,
1369 banlist: &std::collections::BTreeSet<PublicKey>,
1370) -> Result<Vec<PublicKey>, String> {
1371 let owner = community.owner()?;
1372 let owner_hex = owner.to_hex();
1373
1374 for g in &roles.grants {
1381 if let Some(pk) = PublicKey::from_hex(&g.member).ok().filter(|_| !g.role_ids.is_empty()) {
1382 observed.entry(pk).or_insert(0);
1383 }
1384 }
1385
1386 let snapshot_authority = (community.root_epoch.0 > 0).then_some(&owner);
1392 let can_kick = |actor: &PublicKey, target: &PublicKey| {
1395 roles.can_act_on_member(&actor.to_hex(), Some(&owner_hex), &target.to_hex(), crate::community::roles::Permissions::KICK)
1396 };
1397 let coalesced = guestbook::coalesce(events, now_ms(), snapshot_authority, &can_kick);
1398 let mut members = guestbook::complete_memberlist(&coalesced, &observed, banlist);
1399 if !banlist.contains(&owner) {
1401 members.insert(owner);
1402 }
1403 Ok(members.into_iter().collect())
1404}
1405
1406pub async fn sync_guestbook<T: Transport + ?Sized>(
1411 transport: &T,
1412 community: &CommunityV2,
1413 session: &SessionGuard,
1414) -> Result<Vec<guestbook::GuestbookEvent>, String> {
1415 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1416 let (mut events, cursor) = crate::db::community::get_guestbook(&cid_hex)?;
1417 let since = cursor.saturating_sub(1);
1420 let (fresh, newest) = fetch_guestbook_events(transport, community, since).await?;
1421 if !session.is_valid() {
1422 return Err("account changed during guestbook sync".to_string());
1423 }
1424 let known: std::collections::HashSet<[u8; 32]> = events.iter().map(|e| e.rumor_id).collect();
1425 let mut added = Vec::new();
1426 for ev in fresh {
1427 if !known.contains(&ev.rumor_id) {
1428 events.push(ev.clone());
1429 added.push(ev);
1430 }
1431 }
1432 if !added.is_empty() || newest > cursor {
1433 crate::db::community::set_guestbook(&cid_hex, &events, newest.max(cursor))?;
1434 }
1435 Ok(added)
1436}
1437
1438pub fn ingest_guestbook_event(community: &CommunityV2, ev: guestbook::GuestbookEvent, wrap_secs: u64) -> Result<bool, String> {
1441 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1442 let (mut events, cursor) = crate::db::community::get_guestbook(&cid_hex)?;
1443 if events.iter().any(|e| e.rumor_id == ev.rumor_id) {
1444 return Ok(false);
1445 }
1446 events.push(ev);
1447 crate::db::community::set_guestbook(&cid_hex, &events, cursor.max(wrap_secs))?;
1448 Ok(true)
1449}
1450
1451pub fn stored_memberlist(community: &CommunityV2) -> Result<Vec<PublicKey>, String> {
1458 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1459 let (events, _cursor) = crate::db::community::get_guestbook(&cid_hex)?;
1460 let mut observed: std::collections::BTreeMap<PublicKey, u64> = std::collections::BTreeMap::new();
1461 for (npub, last_active_secs) in crate::db::community::community_member_activity(&cid_hex).unwrap_or_default() {
1462 if let Ok(pk) = PublicKey::parse(&npub) {
1463 observed.insert(pk, last_active_secs.saturating_mul(1000));
1464 }
1465 }
1466 let roles = crate::db::community::get_community_roles(&cid_hex)?;
1467 let banlist: std::collections::BTreeSet<PublicKey> = crate::db::community::get_community_banlist(&cid_hex)
1468 .unwrap_or_default()
1469 .iter()
1470 .filter_map(|h| PublicKey::from_hex(h).ok())
1471 .collect();
1472 fold_members(community, &events, observed, &roles, &banlist)
1473}
1474
1475pub async fn memberlist<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<Vec<PublicKey>, String> {
1481 let (events, _newest) = fetch_guestbook_events(transport, community, 0).await?;
1482 let mut observed: std::collections::BTreeMap<PublicKey, u64> = std::collections::BTreeMap::new();
1485 for ch in &community.channels {
1486 if let Ok(page) = fetch_channel(transport, community, &ch.id, 200).await {
1487 for f in &page {
1488 let e = observed.entry(f.event.opened().author).or_insert(0);
1489 *e = (*e).max(f.event.opened().at_ms);
1490 }
1491 }
1492 }
1493
1494 let authority = fetch_authority(transport, community).await;
1499 let banlist: std::collections::BTreeSet<PublicKey> =
1501 authority.banned.iter().filter_map(|h| PublicKey::from_hex(h).ok()).collect();
1502 fold_members(community, &events, observed, &authority.roles, &banlist)
1503}
1504
1505pub async fn dissolve_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
1513 let session = SessionGuard::capture();
1514 let me = local_keys()?;
1515 if community.owner()? != me.public_key() {
1516 return Err("only the owner can dissolve a community".to_string());
1517 }
1518 let at = now_ms() / 1000;
1519 let rumor = super::dissolution::dissolved_tombstone_rumor(me.public_key(), community.id(), at);
1520 let wrap = super::dissolution::seal_dissolved(&rumor, community.id(), &me, Timestamp::from_secs(at)).map_err(|e| e.to_string())?;
1521 if !session.is_valid() {
1522 return Err("account changed during dissolve".to_string());
1523 }
1524 transport.publish_durable(&wrap, &community.relays).await?;
1526 crate::db::community::set_community_dissolved(&crate::simd::hex::bytes_to_hex_32(&community.id().0))?;
1527 Ok(())
1528}
1529
1530pub async fn is_dissolved<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> bool {
1535 let group = super::derive::dissolved_group_key(community.id());
1536 let query = Query {
1537 kinds: vec![stream::KIND_WRAP],
1538 authors: vec![group.pk_hex()],
1539 limit: Some(20),
1540 ..Default::default()
1541 };
1542 let Ok(wraps) = transport.fetch(&query, &community.relays).await else {
1543 return false;
1544 };
1545 wraps.iter().any(|w| super::dissolution::verify_dissolved(w, &community.identity))
1546}
1547
1548pub async fn refound_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, removed: &[PublicKey]) -> Result<CommunityV2, String> {
1561 let session = SessionGuard::capture();
1562 let cid = community.id();
1563 let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
1564 if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
1566 return Err("this community has been dissolved; it cannot be re-founded".to_string());
1567 }
1568 let me = local_keys()?;
1569 let lock = super::realtime::follow_lock(cid);
1573 let _guard = lock.lock().await;
1574 let fresh = crate::db::community::load_community_v2(cid)?.ok_or("community gone before re-founding")?;
1578 let community = &fresh;
1579 let owner = community.owner()?;
1580
1581 if me.public_key() != owner {
1588 return Err("only the owner can re-found (the cryptographic read-cut)".to_string());
1589 }
1590
1591 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
1595 .into_iter()
1596 .filter(|(_, f)| f.0 == community.root_epoch.0)
1597 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
1598 .collect();
1599 let current_control = control_group_key(&community.community_root, cid, community.root_epoch);
1600 let mut opened: Vec<(ParsedEdition, super::stream::OpenedStream)> = Vec::new();
1607 let mut seen_wraps: std::collections::HashSet<nostr_sdk::EventId> = std::collections::HashSet::new();
1608 let mut oldest: Option<u64> = None;
1609 let mut until: Option<u64> = None;
1610 for _ in 0..FOLLOW_MAX_PAGES {
1611 let query = Query {
1612 kinds: vec![stream::KIND_WRAP],
1613 authors: vec![current_control.pk_hex()],
1614 until,
1615 limit: Some(FOLLOW_PAGE),
1616 ..Default::default()
1617 };
1618 let wraps = transport.fetch(&query, &community.relays).await?;
1619 let mut fresh = 0usize;
1620 for w in &wraps {
1621 if !seen_wraps.insert(w.id) {
1622 continue;
1623 }
1624 fresh += 1;
1625 let at = w.created_at.as_secs();
1626 if oldest.is_none_or(|o| at < o) {
1627 oldest = Some(at);
1628 }
1629 if let Ok(parsed) = control::open_control_edition(w, ¤t_control) {
1630 opened.push(parsed);
1631 }
1632 }
1633 let present: std::collections::HashSet<String> =
1636 opened.iter().map(|(e, _)| crate::simd::hex::bytes_to_hex_32(&e.entity_id)).collect();
1637 if floors.keys().all(|k| present.contains(k)) || fresh == 0 {
1638 break;
1639 }
1640 until = oldest;
1641 }
1642
1643 let prev_epoch = community.root_epoch;
1644 let new_epoch = Epoch(prev_epoch.0.checked_add(1).ok_or("root epoch overflow")?);
1645 let prev_commit = super::derive::epoch_key_commitment(prev_epoch, &community.community_root);
1646 let new_root = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch.0)?;
1651 let new_control = control_group_key(&new_root, cid, new_epoch);
1652 let at = now_ms();
1653 let at_secs = at / 1000;
1654
1655 use std::collections::BTreeMap;
1661 let mut by_eid: BTreeMap<String, Vec<usize>> = BTreeMap::new();
1662 for (i, (e, _)) in opened.iter().enumerate() {
1663 by_eid.entry(crate::simd::hex::bytes_to_hex_32(&e.entity_id)).or_default().push(i);
1664 }
1665 let mut carried: Vec<(FoldedHead, Event)> = Vec::new();
1666 for (floor_key, floor) in &floors {
1667 let head_idx = by_eid
1675 .get(floor_key)
1676 .and_then(|v| v.iter().copied().find(|&i| opened[i].0.self_hash == floor.1));
1677 let Some(head_idx) = head_idx else {
1678 return Err(format!("re-founding aborted: the committed head of control entity {floor_key} (v{}) was not served; no state published", floor.0));
1679 };
1680 let (head_ed, head_os) = &opened[head_idx];
1681 let h = FoldedHead { entity_hex: floor_key.clone(), version: head_ed.version, self_hash: head_ed.self_hash, inner_id: head_ed.inner_id };
1682 let (rewrapped, _) = super::stream::rewrap_seal(&head_os.seal, &new_control, Timestamp::from_secs(at_secs)).map_err(|e| e.to_string())?;
1683 carried.push((h, rewrapped));
1684 }
1685 if !session.is_valid() {
1686 return Err("account changed during re-founding acquire".to_string());
1687 }
1688
1689 let members = memberlist(transport, community).await?;
1691 let removed_set: std::collections::HashSet<[u8; 32]> = removed.iter().map(|p| p.to_bytes()).collect();
1692 let mut recipients: Vec<PublicKey> = members.into_iter().filter(|m| !removed_set.contains(&m.to_bytes())).collect();
1693 if !recipients.iter().any(|p| *p == me.public_key()) {
1694 recipients.push(me.public_key());
1695 }
1696
1697 let mut base_blobs = Vec::new();
1699 for r in &recipients {
1700 base_blobs.push(
1701 super::rekey::build_blob_local(me.secret_key(), &me.public_key().to_bytes(), r, super::rekey::RekeyScope::Root, new_epoch, &new_root)
1702 .map_err(|e| e.to_string())?,
1703 );
1704 }
1705 let base_group = super::derive::base_rekey_group_key(&community.community_root, cid, new_epoch);
1706 let base_chunks =
1707 super::rekey::build_rekey_chunks_local(&me, &base_group, super::rekey::RekeyScope::Root, new_epoch, prev_epoch, &prev_commit, &base_blobs, at_secs)
1708 .map_err(|e| e.to_string())?;
1709
1710 let mut channel_updates: Vec<(ChannelId, [u8; 32], Epoch)> = Vec::new();
1713 let mut channel_chunk_sets: Vec<Vec<Event>> = Vec::new();
1714 for ch in &community.channels {
1715 let (Some(old_key), true) = (ch.key, ch.private) else { continue };
1716 let ch_new_epoch = Epoch(ch.epoch.0.checked_add(1).ok_or("channel epoch overflow")?);
1717 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)?;
1720 let ch_prev_commit = super::derive::epoch_key_commitment(ch.epoch, &old_key);
1721 let mut ch_blobs = Vec::new();
1722 for r in &recipients {
1723 ch_blobs.push(
1724 super::rekey::build_blob_local(me.secret_key(), &me.public_key().to_bytes(), r, super::rekey::RekeyScope::Channel(ch.id), ch_new_epoch, &ch_new_key)
1725 .map_err(|e| e.to_string())?,
1726 );
1727 }
1728 let ch_group = super::derive::channel_rekey_group_key(&community.community_root, &ch.id, ch_new_epoch);
1729 let ch_chunks = super::rekey::build_rekey_chunks_local(&me, &ch_group, super::rekey::RekeyScope::Channel(ch.id), ch_new_epoch, ch.epoch, &ch_prev_commit, &ch_blobs, at_secs)
1730 .map_err(|e| e.to_string())?;
1731 channel_updates.push((ch.id, ch_new_key, ch_new_epoch));
1732 channel_chunk_sets.push(ch_chunks);
1733 }
1734 if !session.is_valid() {
1735 return Err("account changed during re-founding prepare".to_string());
1736 }
1737
1738 for c in &base_chunks {
1741 transport.publish_durable(c, &community.relays).await?;
1742 }
1743 for set in &channel_chunk_sets {
1744 for c in set {
1745 transport.publish_durable(c, &community.relays).await?;
1746 }
1747 }
1748 for (_, wrap) in &carried {
1749 transport.publish_durable(wrap, &community.relays).await?;
1750 }
1751 let gb_group = super::derive::guestbook_group_key(&new_root, cid, new_epoch);
1754 let snap_id = crate::community::random_32();
1755 for rumor in guestbook::build_snapshot_rumors(me.public_key(), &recipients, snap_id, at) {
1756 if let Ok((wrap, _)) = guestbook::seal_guestbook_rumor(&rumor, &gb_group, &me, Timestamp::from_secs(at_secs)) {
1757 let _ = transport.publish(&wrap, &community.relays).await;
1758 }
1759 }
1760
1761 if !session.is_valid() {
1763 return Err("account changed during re-founding commit".to_string());
1764 }
1765 if crate::db::community::community_protocol(cid)?.is_none() {
1766 return Ok(community.clone()); }
1768 let mut updated = community.clone();
1772 updated.community_root = new_root;
1773 updated.root_epoch = new_epoch;
1774 for (id, key, ep) in &channel_updates {
1775 if let Some(c) = updated.channels.iter_mut().find(|c| c.id.0 == id.0) {
1776 c.key = Some(*key);
1777 c.epoch = *ep;
1778 }
1779 }
1780 crate::db::community::save_community_v2(&updated)?;
1781 crate::db::community::advance_server_root_epoch(&cid_hex, new_epoch.0, &new_root)?;
1786 for (h, _) in &carried {
1787 crate::db::community::set_edition_head_at_epoch(&cid_hex, &h.entity_hex, h.version, &h.self_hash, &h.inner_id, new_epoch.0)?;
1788 }
1789 for attempt in 0..3u8 {
1795 match refresh_public_links(transport, &updated).await {
1796 Ok(()) => break,
1797 Err(_) if !session.is_valid() => break, Err(e) if attempt == 2 => {
1799 crate::log_warn!("v2: post-refounding public-link refresh failed after retries ({e}); live links may serve the prior root until the next refresh");
1800 }
1801 Err(_) => continue,
1802 }
1803 }
1804 Ok(updated)
1805}
1806
1807fn mint_or_reuse_rotation_key(community_id_hex: &str, scope_hex: &str, new_epoch: u64) -> Result<[u8; 32], String> {
1814 if let Some(existing) = crate::db::community::held_epoch_key(community_id_hex, scope_hex, new_epoch)? {
1815 return Ok(existing);
1816 }
1817 let fresh = crate::community::random_32();
1818 crate::db::community::store_epoch_key(community_id_hex, scope_hex, new_epoch, &fresh)?;
1819 Ok(fresh)
1820}
1821
1822fn join_material(community: &CommunityV2) -> super::list::JoinMaterial {
1828 let hex = crate::simd::hex::bytes_to_hex_32;
1829 let channels = community
1830 .channels
1831 .iter()
1832 .filter(|c| c.private)
1833 .filter_map(|c| {
1834 c.key.map(|k| super::list::ChannelKeyRef { id: hex(&c.id.0), key: hex(&k), epoch: c.epoch.0, name: c.name.clone() })
1835 })
1836 .collect();
1837 super::list::JoinMaterial {
1838 community_id: hex(&community.identity.community_id.0),
1839 owner: hex(&community.identity.owner_xonly),
1840 owner_salt: hex(&community.identity.owner_salt),
1841 community_root: hex(&community.community_root),
1842 root_epoch: community.root_epoch.0,
1843 channels,
1844 relays: community.relays.clone(),
1845 name: community.name.clone(),
1846 extra: Default::default(),
1847 }
1848}
1849
1850fn material_to_invite(jm: &super::list::JoinMaterial) -> CommunityInvite {
1854 let channels = jm
1855 .channels
1856 .iter()
1857 .map(|c| invite::ChannelGrant { id: c.id.clone(), key: c.key.clone(), epoch: c.epoch, name: c.name.clone() })
1858 .collect();
1859 CommunityInvite {
1860 community_id: jm.community_id.clone(),
1861 owner: jm.owner.clone(),
1862 owner_salt: jm.owner_salt.clone(),
1863 community_root: jm.community_root.clone(),
1864 root_epoch: jm.root_epoch,
1865 channels,
1866 relays: jm.relays.clone(),
1867 name: jm.name.clone(),
1868 icon: None,
1869 expires_at: None,
1870 creator_npub: None,
1871 label: None,
1872 extra: Default::default(),
1873 }
1874}
1875
1876fn held_v2_relays() -> Vec<String> {
1879 let mut set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
1880 if let Ok(ids) = crate::db::community::list_community_ids() {
1881 for id in ids {
1882 if matches!(crate::db::community::community_protocol(&id), Ok(Some(crate::community::ConcordProtocol::V2))) {
1883 if let Ok(Some(c)) = crate::db::community::load_community_v2(&id) {
1884 set.extend(c.relays);
1885 }
1886 }
1887 }
1888 }
1889 set.into_iter().collect()
1890}
1891
1892async fn fetch_community_list<T: Transport + ?Sized>(transport: &T, relays: &[String]) -> Result<Option<super::list::CommunityList>, String> {
1898 let me = local_keys()?;
1899 let query = Query {
1900 kinds: vec![super::kind::COMMUNITY_LIST],
1901 authors: vec![me.public_key().to_hex()],
1902 limit: Some(4),
1903 ..Default::default()
1904 };
1905 let events = transport.fetch(&query, relays).await?;
1906 Ok(events
1907 .into_iter()
1908 .filter_map(|e| super::list::parse_list_event(&e, &me).ok().map(|l| (e.created_at.as_secs(), l)))
1909 .max_by_key(|(at, _)| *at)
1910 .map(|(_, l)| l))
1911}
1912
1913pub async fn republish_community_list<T: Transport + ?Sized>(transport: &T, just_joined: Option<&crate::community::CommunityId>) -> Result<(), String> {
1922 let session = SessionGuard::capture();
1923 let me = local_keys()?;
1924 let relays = held_v2_relays();
1925 if relays.is_empty() {
1926 return Ok(()); }
1928 let remote = match fetch_community_list(transport, &relays).await {
1932 Ok(r) => r.unwrap_or_default(),
1933 Err(_) => return Ok(()),
1934 };
1935 let just_joined_hex = just_joined.map(|c| crate::simd::hex::bytes_to_hex_32(&c.0));
1936 let now = now_ms();
1937 let mut local = super::list::CommunityList::default();
1938 for id in crate::db::community::list_community_ids()? {
1939 if !matches!(crate::db::community::community_protocol(&id), Ok(Some(crate::community::ConcordProtocol::V2))) {
1940 continue;
1941 }
1942 let Some(c) = crate::db::community::load_community_v2(&id)? else { continue };
1943 let cid_hex = crate::simd::hex::bytes_to_hex_32(&c.id().0);
1944 let is_join = just_joined_hex.as_deref() == Some(cid_hex.as_str());
1945 if !is_join && !remote.is_live(&cid_hex) && remote.tombstones.iter().any(|t| t.community_id == cid_hex) {
1949 continue;
1950 }
1951 let added_at = if remote.is_live(&cid_hex) && !is_join {
1954 remote.entries.iter().find(|e| e.community_id == cid_hex).map(|e| e.added_at).unwrap_or(now)
1955 } else {
1956 now
1957 };
1958 let jm = join_material(&c);
1959 local.entries.push(super::list::CommunityListEntry { community_id: cid_hex, seed: jm.clone(), current: jm, added_at, extra: Default::default() });
1960 }
1961 let merged = remote.merge(&local);
1962 merged.assert_fits().map_err(|e| e.to_string())?;
1963 let event = super::list::build_list_event(&me, &merged).map_err(|e| e.to_string())?;
1964 if !session.is_valid() {
1965 return Err("account changed during community-list publish".to_string());
1966 }
1967 transport.publish(&event, &relays).await
1968}
1969
1970async fn tombstone_community_list<T: Transport + ?Sized>(transport: &T, community_id: &crate::community::CommunityId, relays: &[String]) -> Result<(), String> {
1973 let me = local_keys()?;
1974 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community_id.0);
1975 let mut doc = match fetch_community_list(transport, relays).await {
1979 Ok(d) => d.unwrap_or_default(),
1980 Err(e) => return Err(e),
1981 };
1982 let now = now_ms();
1983 doc.tombstones.retain(|t| t.community_id != cid_hex);
1984 doc.tombstones.push(super::list::Tombstone { community_id: cid_hex, removed_at: now, extra: Default::default() });
1985 doc.assert_fits().map_err(|e| e.to_string())?;
1986 let event = super::list::build_list_event(&me, &doc).map_err(|e| e.to_string())?;
1987 transport.publish(&event, relays).await
1988}
1989
1990pub async fn sync_community_list<T: Transport + ?Sized>(transport: &T, bootstrap_relays: &[String]) -> Result<Vec<CommunityV2>, String> {
1996 let session = SessionGuard::capture();
1997 let mut relays = held_v2_relays();
1998 relays.extend(bootstrap_relays.iter().cloned());
1999 relays.sort();
2000 relays.dedup();
2001 if relays.is_empty() {
2002 return Ok(vec![]);
2003 }
2004 let list = match fetch_community_list(transport, &relays).await {
2005 Ok(Some(l)) => l,
2006 Ok(None) | Err(_) => return Ok(vec![]),
2007 };
2008 for t in &list.tombstones {
2014 if list.is_live(&t.community_id) {
2015 continue;
2016 }
2017 let Some(cid) = crate::simd::hex::hex_to_bytes_32_checked(&t.community_id) else { continue };
2018 let id = crate::community::CommunityId(cid);
2019 if crate::db::community::load_community_v2(&id).ok().flatten().is_none() {
2020 continue; }
2022 if !session.is_valid() {
2023 return Err("account changed during community-list sync".to_string());
2024 }
2025 let _ = crate::db::community::delete_community(&t.community_id);
2026 }
2027 let mut joined = Vec::new();
2028 for entry in list.live_entries() {
2029 let Some(cid) = crate::simd::hex::hex_to_bytes_32_checked(&entry.community_id) else { continue };
2030 if crate::db::community::load_community_v2(&crate::community::CommunityId(cid)).ok().flatten().is_some() {
2031 continue; }
2033 if !session.is_valid() {
2034 return Err("account changed during community-list sync".to_string());
2035 }
2036 let bundle = material_to_invite(&entry.current);
2039 if let Ok(community) = accept_bundle(transport, &session, &bundle, None).await {
2040 joined.push(community);
2041 }
2042 }
2043 Ok(joined)
2044}
2045
2046async fn publish_control_edition<T: Transport + ?Sized>(
2055 transport: &T,
2056 community: &CommunityV2,
2057 session: &SessionGuard,
2058 vsk: &str,
2059 entity_id: &[u8; 32],
2060 content: &str,
2061 citation: Option<&crate::community::edition::AuthorityCitation>,
2062) -> Result<(), String> {
2063 let me = local_keys()?;
2064 let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
2065 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2066 let entity_hex = crate::simd::hex::bytes_to_hex_32(entity_id);
2067 let (version, prev) = match crate::db::community::get_edition_head(&cid_hex, &entity_hex)? {
2068 Some((v, h)) => (v + 1, Some(h)),
2069 None => (1, None),
2070 };
2071 let at = now_ms() / 1000;
2072 let rumor = control::build_edition_rumor(me.public_key(), vsk, entity_id, version, prev.as_ref(), content, at, citation);
2073 let (wrap, _) = control::seal_control_edition(&rumor, &control, &me, Timestamp::from_secs(at)).map_err(|e| e.to_string())?;
2074 if !session.is_valid() {
2075 return Err("account changed before control publish".to_string());
2076 }
2077 transport.publish(&wrap, &community.relays).await?;
2078 if !session.is_valid() {
2084 return Ok(());
2085 }
2086 if let Ok((ed, _)) = control::open_control_edition(&wrap, &control) {
2087 crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, ed.version, &ed.self_hash, &ed.inner_id, community.root_epoch.0)?;
2088 }
2089 Ok(())
2090}
2091
2092pub async fn set_role<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, role: &crate::community::roles::Role) -> Result<(), String> {
2096 let session = SessionGuard::capture();
2097 super::roles::validate_role(role)?;
2098 let content = super::roles::role_content_json(role)?;
2099 let role_id = crate::simd::hex::hex_to_bytes_32_checked(&role.role_id).ok_or("role_id must be 32-byte hex")?;
2100 publish_control_edition(transport, community, &session, vsk::ROLE, &role_id, &content, None).await
2101}
2102
2103pub async fn grant_roles<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey, role_ids: Vec<String>) -> Result<(), String> {
2107 let session = SessionGuard::capture();
2108 let grant = crate::community::roles::MemberGrant { member: member.to_hex(), role_ids };
2109 let content = super::roles::grant_content_json(&grant)?;
2110 let eid = super::derive::grant_locator(community.id(), &member.to_bytes());
2111 publish_control_edition(transport, community, &session, vsk::GRANT, &eid, &content, None).await
2112}
2113
2114pub async fn ensure_admin_role<T: Transport + ?Sized>(
2119 transport: &T,
2120 community: &CommunityV2,
2121 view: &AuthorityView,
2122 create_if_missing: bool,
2123) -> Result<Option<String>, String> {
2124 use crate::community::roles::{Permissions, Role, RoleScope};
2125 if let Some(r) = view
2126 .roles
2127 .roles
2128 .iter()
2129 .find(|r| matches!(r.scope, RoleScope::Server) && r.permissions.contains(Permissions::ADMIN_ALL))
2130 {
2131 return Ok(Some(r.role_id.clone()));
2132 }
2133 if !create_if_missing {
2134 return Ok(None);
2135 }
2136 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2137 let role_id = crate::crypto::sha256_hex(format!("vector/v2/role/admin/{cid_hex}").as_bytes());
2138 set_role(transport, community, &Role::admin(role_id.clone())).await?;
2139 Ok(Some(role_id))
2140}
2141
2142pub async fn grant_admin<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey) -> Result<(), String> {
2149 let session = SessionGuard::capture();
2152 let me = local_keys()?;
2153 if me.public_key() != community.owner()? {
2154 return Err("only the community owner can grant @admin".to_string());
2155 }
2156 let view = fetch_authority(transport, community).await;
2157 if !session.is_valid() {
2158 return Err("account changed during grant".to_string());
2159 }
2160 let member_hex = member.to_hex();
2161 require_grant_head(community, &view, &member_hex)?;
2162 let role_id = ensure_admin_role(transport, community, &view, true)
2163 .await?
2164 .expect("create_if_missing yields an id");
2165 let mut role_ids = view
2166 .roles
2167 .grants
2168 .iter()
2169 .find(|g| g.member == member_hex)
2170 .map(|g| g.role_ids.clone())
2171 .unwrap_or_default();
2172 if role_ids.contains(&role_id) {
2173 return Ok(()); }
2175 role_ids.push(role_id);
2176 grant_roles(transport, community, member, role_ids).await
2177}
2178
2179pub async fn revoke_admin<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey) -> Result<(), String> {
2182 let session = SessionGuard::capture();
2183 let me = local_keys()?;
2184 if me.public_key() != community.owner()? {
2185 return Err("only the community owner can revoke @admin".to_string());
2186 }
2187 let view = fetch_authority(transport, community).await;
2188 if !session.is_valid() {
2189 return Err("account changed during revoke".to_string());
2190 }
2191 let member_hex = member.to_hex();
2192 require_grant_head(community, &view, &member_hex)?;
2193 let Some(role_id) = ensure_admin_role(transport, community, &view, false).await? else {
2194 return Ok(()); };
2196 let mut role_ids = view
2197 .roles
2198 .grants
2199 .iter()
2200 .find(|g| g.member == member_hex)
2201 .map(|g| g.role_ids.clone())
2202 .unwrap_or_default();
2203 let before = role_ids.len();
2204 role_ids.retain(|r| r != &role_id);
2205 if role_ids.len() == before {
2206 return Ok(());
2207 }
2208 grant_roles(transport, community, member, role_ids).await
2209}
2210
2211fn require_grant_head(community: &CommunityV2, view: &AuthorityView, member_hex: &str) -> Result<(), String> {
2215 let Some(member) = crate::simd::hex::hex_to_bytes_32_checked(member_hex) else {
2216 return Err("malformed member key".to_string());
2217 };
2218 let eid_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &member));
2219 if view.floored.contains(&eid_hex) && !view.head_entities.contains(&eid_hex) {
2220 return Err("this member's current grant could not be fetched; try again once relays serve the control plane".to_string());
2221 }
2222 Ok(())
2223}
2224
2225pub async fn set_banlist<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, banned: &[String]) -> Result<(), String> {
2228 let session = SessionGuard::capture();
2229 super::roles::validate_banlist(banned)?;
2230 let content = super::roles::banlist_content_json(banned)?;
2231 let eid = super::derive::banlist_locator(community.id());
2232 publish_control_edition(transport, community, &session, vsk::BANLIST, &eid, &content, None).await
2233}
2234
2235pub async fn edit_community_metadata<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, meta: &control::CommunityMetadata) -> Result<(), String> {
2238 let session = SessionGuard::capture();
2239 control::validate_community_metadata(meta).map_err(|e| e.to_string())?;
2240 let content = serde_json::to_string(meta).map_err(|e| e.to_string())?;
2241 publish_control_edition(transport, community, &session, vsk::COMMUNITY_METADATA, &community.id().0, &content, None).await
2242}
2243
2244pub async fn edit_channel_metadata<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, channel_id: &ChannelId, meta: &control::ChannelMetadata) -> Result<(), String> {
2247 let session = SessionGuard::capture();
2248 let me = local_keys()?;
2249 ensure_channel_manager(community, &me.public_key())?;
2250 if meta.private {
2255 if let Some(held) = community.channel(channel_id) {
2256 if !held.private {
2257 return Err("converting a public channel to private is not supported yet".to_string());
2258 }
2259 }
2260 }
2261 control::validate_channel_metadata(meta).map_err(|e| e.to_string())?;
2262 let content = serde_json::to_string(meta).map_err(|e| e.to_string())?;
2263 publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content, None).await
2264}
2265
2266fn ensure_channel_manager(community: &CommunityV2, me: &PublicKey) -> Result<(), String> {
2272 let owner = community.owner()?;
2273 if *me == owner {
2274 return Ok(());
2275 }
2276 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2277 let me_hex = me.to_hex();
2278 if crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default().contains(&me_hex) {
2279 return Err("you are banned from this community".to_string());
2280 }
2281 let roster = crate::db::community::get_community_roles(&cid_hex)?;
2282 if roster.is_authorized(&me_hex, Some(&owner.to_hex()), crate::community::roles::Permissions::MANAGE_CHANNELS) {
2283 Ok(())
2284 } else {
2285 Err("managing channels here needs the MANAGE_CHANNELS permission".to_string())
2286 }
2287}
2288
2289pub async fn create_public_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str) -> Result<ChannelId, String> {
2295 let session = SessionGuard::capture();
2296 let lock = super::realtime::follow_lock(community.id());
2300 let _guard = lock.lock().await;
2301 let me = local_keys()?;
2302 ensure_channel_manager(community, &me.public_key())?;
2303 let channel_id = ChannelId(super::super::random_32());
2304 let meta = control::ChannelMetadata { name: name.to_string(), private: false, voice: None, deleted: None, custom: None, extra: Default::default() };
2305 control::validate_channel_metadata(&meta).map_err(|e| e.to_string())?;
2306 let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
2307 publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content, None).await?;
2308 if !session.is_valid() {
2309 return Err("account changed during channel create".to_string());
2310 }
2311 let mut updated = community.clone();
2313 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() });
2314 crate::db::community::save_community_v2(&updated)?;
2315 Ok(channel_id)
2316}
2317
2318pub async fn create_private_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str) -> Result<ChannelId, String> {
2329 let session = SessionGuard::capture();
2330 let lock = super::realtime::follow_lock(community.id());
2334 let _guard = lock.lock().await;
2335 let me = local_keys()?;
2336 ensure_channel_manager(community, &me.public_key())?;
2337 let meta = control::ChannelMetadata { name: name.to_string(), private: true, voice: None, deleted: None, custom: None, extra: Default::default() };
2338 control::validate_channel_metadata(&meta).map_err(|e| e.to_string())?;
2339 let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
2340
2341 let channel_id = ChannelId(super::super::random_32());
2342 let channel_key = super::super::random_32();
2343 let epoch = Epoch(1);
2344
2345 let mut recipients = memberlist(transport, community).await?;
2347 if !recipients.iter().any(|p| *p == me.public_key()) {
2348 recipients.push(me.public_key());
2349 }
2350 let prev_commit = super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
2351 let mut blobs = Vec::with_capacity(recipients.len());
2352 for r in &recipients {
2353 blobs.push(
2354 rekey::build_blob_local(me.secret_key(), &me.public_key().to_bytes(), r, RekeyScope::Channel(channel_id), epoch, &channel_key)
2355 .map_err(|e| e.to_string())?,
2356 );
2357 }
2358 let group = channel_rekey_group_key(&community.community_root, &channel_id, epoch);
2359 let at_secs = now_ms() / 1000;
2360 let chunks = rekey::build_rekey_chunks_local(&me, &group, RekeyScope::Channel(channel_id), epoch, Epoch(0), &prev_commit, &blobs, at_secs)
2361 .map_err(|e| e.to_string())?;
2362 if !session.is_valid() {
2363 return Err("account changed during channel create".to_string());
2364 }
2365 for c in &chunks {
2366 transport.publish_durable(c, &community.relays).await?;
2367 }
2368 publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content, None).await?;
2369 if !session.is_valid() {
2370 return Err("account changed during channel create".to_string());
2371 }
2372 if crate::db::community::community_protocol(community.id())?.is_none() {
2374 return Err("community removed during channel create".to_string());
2375 }
2376 let mut updated = community.clone();
2377 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() });
2378 crate::db::community::save_community_v2(&updated)?;
2379 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2382 crate::db::community::store_epoch_key(&cid_hex, &crate::simd::hex::bytes_to_hex_32(&channel_id.0), epoch.0, &channel_key)?;
2383 let _ = refresh_public_links(transport, &updated).await;
2385 Ok(channel_id)
2386}
2387
2388pub async fn delete_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, channel_id: &ChannelId, name: &str) -> Result<(), String> {
2391 let session = SessionGuard::capture();
2392 let lock = super::realtime::follow_lock(community.id());
2394 let _guard = lock.lock().await;
2395 let me = local_keys()?;
2396 ensure_channel_manager(community, &me.public_key())?;
2397 let mut meta = community.channel(channel_id).map(|c| c.metadata()).unwrap_or_else(|| control::ChannelMetadata {
2401 name: name.to_string(), private: false, voice: None, deleted: None, custom: None, extra: Default::default(),
2402 });
2403 meta.deleted = Some(true);
2404 let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
2405 publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content, None).await?;
2406 if !session.is_valid() {
2407 return Err("account changed during channel delete".to_string());
2408 }
2409 let mut updated = community.clone();
2410 updated.channels.retain(|c| c.id.0 != channel_id.0);
2411 crate::db::community::save_community_v2(&updated)?;
2412 Ok(())
2413}
2414
2415pub async fn follow_control<T: Transport + ?Sized>(
2439 transport: &T,
2440 community: &CommunityV2,
2441 session: &SessionGuard,
2442) -> Result<Option<CommunityV2>, String> {
2443 community.owner()?; let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
2445 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2446
2447 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
2453 .into_iter()
2454 .filter(|(_, f)| f.0 == community.root_epoch.0)
2455 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
2456 .collect();
2457
2458 let mut editions: Vec<ParsedEdition> = Vec::new();
2462 let mut seen: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
2463 let mut seen_wraps: std::collections::HashSet<nostr_sdk::EventId> = std::collections::HashSet::new();
2464 let mut oldest: Option<u64> = None;
2465 let mut until: Option<u64> = None;
2466 let mut fold = ControlFold { updated: None, heads: Vec::new(), gapped: false };
2467 let mut authority = AuthoritySet::owner_only();
2468 for _ in 0..FOLLOW_MAX_PAGES {
2469 let query = Query {
2470 kinds: vec![stream::KIND_WRAP],
2471 authors: vec![control.pk_hex()],
2472 until,
2473 limit: Some(FOLLOW_PAGE),
2474 ..Default::default()
2475 };
2476 let wraps = transport.fetch(&query, &community.relays).await?;
2477 let mut fresh = 0usize;
2481 for w in &wraps {
2482 if !seen_wraps.insert(w.id) {
2483 continue;
2484 }
2485 fresh += 1;
2486 let at = w.created_at.as_secs();
2487 if oldest.is_none_or(|o| at < o) {
2488 oldest = Some(at);
2489 }
2490 if let Ok((ed, _)) = control::open_control_edition(w, &control) {
2493 if seen.insert(ed.inner_id) {
2494 editions.push(ed);
2495 }
2496 }
2497 }
2498 authority = fold_authority(community, &editions, &floors);
2501 fold = apply_control_fold(community, &editions, &floors, &authority);
2502 if !(fold.gapped || authority.gapped) || fresh == 0 {
2503 break;
2504 }
2505 until = oldest;
2506 }
2507
2508 if !session.is_valid() {
2511 return Err("account changed during control follow".to_string());
2512 }
2513 if crate::db::community::community_protocol(community.id())?.is_none() {
2516 return Ok(None);
2517 }
2518 for h in fold.heads.iter().chain(authority.heads.iter()) {
2524 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)?;
2525 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)?;
2526 }
2527 if let Some((banned, version)) = &authority.banlist_persist {
2530 crate::db::community::set_community_banlist(&cid_hex, banned, *version as i64)?;
2531 }
2532 let newest_roster_at: i64 = editions
2539 .iter()
2540 .filter(|e| e.vsk == vsk::ROLE || e.vsk == vsk::GRANT || e.vsk == vsk::BANLIST)
2541 .map(|e| e.created_at as i64)
2542 .max()
2543 .unwrap_or(0);
2544 let stored = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
2550 let head_ents: std::collections::HashSet<&str> = authority.heads.iter().map(|h| h.entity_hex.as_str()).collect();
2551 let stored_complete = stored.roles.iter().all(|r| !floors.contains_key(&r.role_id) || head_ents.contains(r.role_id.as_str()))
2552 && stored.grants.iter().all(|g| {
2553 crate::simd::hex::hex_to_bytes_32_checked(&g.member).is_none_or(|m| {
2554 let eid = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &m));
2555 !floors.contains_key(&eid) || head_ents.contains(eid.as_str())
2556 })
2557 });
2558 if !authority.gapped && stored_complete && newest_roster_at >= crate::db::community::get_community_roles_at(&cid_hex)? {
2559 crate::db::community::set_community_roles(&cid_hex, &authority.roles, newest_roster_at)?;
2560 }
2561 match fold.updated {
2562 Some(u) => {
2563 crate::db::community::save_community_v2(&u)?;
2564 Ok(Some(u))
2565 }
2566 None => Ok(None),
2567 }
2568}
2569
2570const FOLLOW_MAX_PAGES: usize = 4;
2573const FOLLOW_PAGE: usize = 500;
2574
2575#[derive(Clone)]
2577struct FoldedHead {
2578 entity_hex: String,
2579 version: u64,
2580 self_hash: [u8; 32],
2581 inner_id: [u8; 32],
2582}
2583
2584struct ControlFold {
2590 updated: Option<CommunityV2>,
2591 heads: Vec<FoldedHead>,
2592 gapped: bool,
2593}
2594
2595type Floors = std::collections::HashMap<String, (u64, [u8; 32], Option<[u8; 32]>)>;
2597
2598fn apply_control_fold(community: &CommunityV2, editions: &[ParsedEdition], floors: &Floors, authority: &AuthoritySet) -> ControlFold {
2619 use crate::community::roles::Permissions;
2620 use std::collections::BTreeMap;
2621
2622 let owner_hex = community.owner().ok().map(|o| o.to_hex());
2623
2624 let mut groups: BTreeMap<(String, [u8; 32]), Vec<&ParsedEdition>> = BTreeMap::new();
2625 for e in editions {
2626 groups.entry((e.vsk.clone(), e.entity_id)).or_default().push(e);
2627 }
2628
2629 let mut out = community.clone();
2630 let mut changed = false;
2631 let mut heads = Vec::new();
2632 let mut gapped = false;
2633 for ((vsk_code, eid), group) in &groups {
2634 let is_meta = vsk_code == vsk::COMMUNITY_METADATA && *eid == community.id().0;
2639 let is_channel = vsk_code == vsk::CHANNEL_METADATA && *eid != community.id().0;
2640 if !is_meta && !is_channel {
2641 continue;
2642 }
2643 let required = if is_meta { Permissions::MANAGE_METADATA } else { Permissions::MANAGE_CHANNELS };
2648 let authed: Vec<&ParsedEdition> = group
2649 .iter()
2650 .copied()
2651 .filter(|e| {
2652 let author = e.author.to_hex();
2653 !authority.banned.contains(&author) && authority.roles.is_authorized(&author, owner_hex.as_deref(), required)
2656 })
2657 .collect();
2658 if authed.is_empty() {
2659 continue;
2660 }
2661 let entity_hex = crate::simd::hex::bytes_to_hex_32(eid);
2662 let fold_eds: Vec<version::Edition> = authed.iter().map(|p| p.to_fold_edition()).collect();
2663 let (hi, entity_gapped) = fold_head(&fold_eds, floors.get(&entity_hex));
2664 gapped |= entity_gapped;
2665 let Some(hi) = hi else { continue };
2666
2667 let head = authed[hi];
2668 heads.push(FoldedHead { entity_hex, version: head.version, self_hash: head.self_hash, inner_id: head.inner_id });
2669 if is_meta {
2670 if let Ok(meta) = serde_json::from_str::<control::CommunityMetadata>(&head.content) {
2671 changed |= apply_community_metadata(&mut out, meta);
2672 }
2673 } else if let Ok(meta) = serde_json::from_str::<control::ChannelMetadata>(&head.content) {
2674 changed |= apply_channel_metadata(&mut out, ChannelId(*eid), meta);
2678 }
2679 }
2680 ControlFold { updated: changed.then_some(out), heads, gapped }
2681}
2682
2683fn fold_head(fold_eds: &[version::Edition], floor: Option<&(u64, [u8; 32], Option<[u8; 32]>)>) -> (Option<usize>, bool) {
2690 let floor_v = floor.map(|f| f.0).unwrap_or(0);
2691 if floor_v == 0 {
2692 return (version::bootstrap_head(fold_eds, 0), false);
2693 }
2694 let floor_hash = floor.map(|f| &f.1);
2695 let held_inner = floor.and_then(|f| f.2);
2696 let result = version::fold(fold_eds, floor_v, floor_hash);
2697 if result.anchored {
2698 return (result.head, result.gap); }
2700 if result.head.is_none() && !result.gap {
2701 return (None, false); }
2703 let fork = fold_eds.iter().enumerate().filter(|(_, e)| e.version == floor_v).min_by_key(|(_, e)| e.tiebreak_id);
2707 let win_hash = match fork {
2708 Some((_, w)) if floor_hash != Some(&w.self_hash) && held_inner.is_none_or(|h| w.tiebreak_id < h) => w.self_hash,
2709 _ => return (None, true), };
2711 let re = version::fold(fold_eds, floor_v, Some(&win_hash));
2712 if !re.anchored {
2713 return (None, true);
2714 }
2715 (re.head, re.gap)
2716}
2717
2718struct AuthoritySet {
2723 roles: crate::community::roles::CommunityRoles,
2724 banned: std::collections::BTreeSet<String>,
2725 heads: Vec<FoldedHead>,
2726 gapped: bool,
2727 banlist_persist: Option<(Vec<String>, u64)>,
2731}
2732
2733impl AuthoritySet {
2734 fn owner_only() -> Self {
2737 AuthoritySet { roles: Default::default(), banned: Default::default(), heads: vec![], gapped: false, banlist_persist: None }
2738 }
2739}
2740
2741fn fold_authority(community: &CommunityV2, editions: &[ParsedEdition], floors: &Floors) -> AuthoritySet {
2748 use crate::community::roles::Permissions;
2749 use std::collections::BTreeMap;
2750
2751 let cid = community.id();
2752 let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
2753 let owner = community.owner().ok();
2754 let owner_hex = owner.map(|o| o.to_hex());
2755 let banlist_eid = super::derive::banlist_locator(cid);
2756 let banlist_hex = crate::simd::hex::bytes_to_hex_32(&banlist_eid);
2757
2758 let mut groups: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
2759 for e in editions {
2760 if e.vsk == vsk::ROLE || e.vsk == vsk::GRANT || e.vsk == vsk::BANLIST {
2761 groups.entry(e.entity_id).or_default().push(e);
2762 }
2763 }
2764
2765 let mut role_cands: BTreeMap<String, Vec<AuthorityCand>> = BTreeMap::new();
2773 let mut grant_cands: BTreeMap<String, Vec<AuthorityCand>> = BTreeMap::new();
2774 let mut gapped = false;
2775
2776 for (eid, group) in &groups {
2777 if *eid == banlist_eid {
2779 continue;
2780 }
2781 let entity_hex = crate::simd::hex::bytes_to_hex_32(eid);
2782 let fold_eds: Vec<version::Edition> = group.iter().map(|p| p.to_fold_edition()).collect();
2783 let (_hi, entity_gapped) = fold_head(&fold_eds, floors.get(&entity_hex));
2784 gapped |= entity_gapped;
2785 let floor_v = floors.get(&entity_hex).map(|f| f.0).unwrap_or(0);
2786
2787 for p in group {
2788 if p.version < floor_v {
2790 continue;
2791 }
2792 let head = FoldedHead { entity_hex: entity_hex.clone(), version: p.version, self_hash: p.self_hash, inner_id: p.inner_id };
2793 match p.vsk.as_str() {
2794 vsk::ROLE => {
2795 if let Some(role) = super::roles::parse_role_content(&p.content) {
2797 if role.role_id == entity_hex && role.position != 0 {
2798 role_cands.entry(entity_hex.clone()).or_default().push(AuthorityCand { role: Some(role), grant: None, author: p.author, head });
2799 }
2800 }
2801 }
2802 vsk::GRANT => {
2803 if let Some(mut grant) = super::roles::parse_grant_content(&p.content) {
2804 if let Some(member) = crate::simd::hex::hex_to_bytes_32_checked(&grant.member) {
2805 if super::derive::grant_locator(cid, &member) == *eid {
2806 grant.role_ids.truncate(super::roles::MAX_ROLES_PER_MEMBER);
2807 grant_cands.entry(entity_hex.clone()).or_default().push(AuthorityCand { role: None, grant: Some(grant), author: p.author, head });
2808 }
2809 }
2810 }
2811 }
2812 _ => {}
2813 }
2814 }
2815 }
2816 for cands in role_cands.values_mut().chain(grant_cands.values_mut()) {
2817 cands.sort_by(|a, b| b.head.version.cmp(&a.head.version).then(a.head.inner_id.cmp(&b.head.inner_id)));
2818 }
2819
2820 let empty: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
2821 let (prelim, _) = select_authorized(&role_cands, &grant_cands, owner_hex.as_deref(), &empty);
2824
2825 let persisted_banned: Vec<String> = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
2836 let banned_authors: std::collections::HashSet<&str> = persisted_banned.iter().map(String::as_str).collect();
2841 let banlist_authored: Vec<&ParsedEdition> = groups
2842 .get(&banlist_eid)
2843 .map(|g| {
2844 g.iter()
2845 .copied()
2846 .filter(|e| {
2847 let ah = e.author.to_hex();
2848 !banned_authors.contains(ah.as_str()) && prelim.is_authorized(&ah, owner_hex.as_deref(), Permissions::BAN)
2849 })
2850 .collect()
2851 })
2852 .unwrap_or_default();
2853 let mut banlist_persist: Option<(Vec<String>, u64)> = None;
2854 let mut banlist_head: Option<FoldedHead> = None;
2855 let banned: std::collections::BTreeSet<String> = if banlist_authored.is_empty() {
2856 persisted_banned.into_iter().collect()
2857 } else {
2858 let fold_eds: Vec<version::Edition> = banlist_authored.iter().map(|p| p.to_fold_edition()).collect();
2859 let (hi, g) = fold_head(&fold_eds, floors.get(&banlist_hex));
2860 gapped |= g;
2861 match hi {
2862 Some(hi) => {
2863 let head = banlist_authored[hi];
2864 let ah = head.author.to_hex();
2865 let list: Vec<String> = super::roles::parse_banlist_content(&head.content)
2866 .unwrap_or_default()
2867 .into_iter()
2868 .filter(|t| prelim.can_act_on_member(&ah, owner_hex.as_deref(), t, Permissions::BAN))
2869 .take(super::roles::MAX_BANLIST)
2870 .collect();
2871 banlist_head = Some(FoldedHead { entity_hex: banlist_hex.clone(), version: head.version, self_hash: head.self_hash, inner_id: head.inner_id });
2872 banlist_persist = Some((list.clone(), head.version));
2873 list.into_iter().collect()
2874 }
2875 None => persisted_banned.into_iter().collect(),
2876 }
2877 };
2878
2879 let (mut authorized, mut heads) = select_authorized(&role_cands, &grant_cands, owner_hex.as_deref(), &banned);
2883 if let Some(bh) = banlist_head {
2884 heads.push(bh);
2885 }
2886
2887 if authorized.roles.len() > super::roles::MAX_ROLES_PER_COMMUNITY {
2892 authorized.roles.sort_by(|a, b| a.role_id.cmp(&b.role_id));
2893 authorized.roles.truncate(super::roles::MAX_ROLES_PER_COMMUNITY);
2894 let kept: std::collections::HashSet<&str> = authorized.roles.iter().map(|r| r.role_id.as_str()).collect();
2895 authorized.grants.iter_mut().for_each(|g| g.role_ids.retain(|rid| kept.contains(rid.as_str())));
2896 authorized.grants.retain(|g| !g.role_ids.is_empty());
2897 }
2898
2899 AuthoritySet { roles: authorized, banned, heads, gapped, banlist_persist }
2900}
2901
2902struct AuthorityCand {
2905 role: Option<crate::community::roles::Role>,
2906 grant: Option<crate::community::roles::MemberGrant>,
2907 author: PublicKey,
2908 head: FoldedHead,
2909}
2910
2911fn select_authorized(
2920 role_cands: &std::collections::BTreeMap<String, Vec<AuthorityCand>>,
2921 grant_cands: &std::collections::BTreeMap<String, Vec<AuthorityCand>>,
2922 owner_hex: Option<&str>,
2923 excluded: &std::collections::BTreeSet<String>,
2924) -> (crate::community::roles::CommunityRoles, Vec<FoldedHead>) {
2925 use crate::community::roles::{CommunityRoles, Permissions};
2926 let mut accepted = CommunityRoles::default();
2927 let mut heads: Vec<FoldedHead> = Vec::new();
2928 let bound = 2 * (role_cands.len() + grant_cands.len()) + 8;
2933 for _ in 0..bound {
2934 let mut next = CommunityRoles::default();
2935 let mut next_heads: Vec<FoldedHead> = Vec::new();
2936
2937 for cands in role_cands.values() {
2938 for c in cands {
2939 let Some(role) = &c.role else { continue };
2940 let ah = c.author.to_hex();
2941 if excluded.contains(&ah) {
2942 continue;
2943 }
2944 if role.position != 0 && accepted.can_act_on_position(&ah, owner_hex, role.position, Permissions::MANAGE_ROLES) {
2945 next.roles.push(role.clone());
2946 next_heads.push(c.head.clone());
2947 break; }
2949 }
2950 }
2951 for cands in grant_cands.values() {
2952 for c in cands {
2953 let Some(grant) = &c.grant else { continue };
2954 let ah = c.author.to_hex();
2955 if excluded.contains(&ah) || excluded.contains(&grant.member) {
2956 continue;
2957 }
2958 let positions: Option<Vec<u32>> = grant.role_ids.iter().map(|rid| accepted.role(rid).map(|r| r.position)).collect();
2961 let Some(positions) = positions else { continue };
2962 if positions.iter().all(|p| accepted.can_act_on_position(&ah, owner_hex, *p, Permissions::MANAGE_ROLES))
2963 && accepted.can_act_on_member(&ah, owner_hex, &grant.member, Permissions::MANAGE_ROLES)
2964 {
2965 next_heads.push(c.head.clone());
2969 if !grant.role_ids.is_empty() {
2970 next.grants.push(grant.clone());
2971 }
2972 break;
2973 }
2974 }
2975 }
2976
2977 let converged = next.roles == accepted.roles && next.grants == accepted.grants;
2978 accepted = next;
2979 heads = next_heads;
2980 if converged {
2981 break;
2982 }
2983 }
2984 (accepted, heads)
2985}
2986
2987fn apply_community_metadata(out: &mut CommunityV2, meta: control::CommunityMetadata) -> bool {
2991 let mut changed = false;
2992 if out.name != meta.name {
2993 out.name = meta.name;
2994 changed = true;
2995 }
2996 if out.description != meta.description {
2997 out.description = meta.description;
2998 changed = true;
2999 }
3000 if out.icon != meta.icon {
3004 out.icon = meta.icon;
3005 changed = true;
3006 }
3007 if out.banner != meta.banner {
3008 out.banner = meta.banner;
3009 changed = true;
3010 }
3011 if out.meta_custom != meta.custom {
3014 out.meta_custom = meta.custom;
3015 changed = true;
3016 }
3017 if out.meta_extra != meta.extra {
3018 out.meta_extra = meta.extra;
3019 changed = true;
3020 }
3021 if !meta.relays.is_empty() && out.relays != meta.relays {
3022 out.relays = meta.relays;
3023 changed = true;
3024 }
3025 changed
3026}
3027
3028fn apply_channel_metadata(out: &mut CommunityV2, id: ChannelId, meta: control::ChannelMetadata) -> bool {
3033 let deleted = meta.deleted.unwrap_or(false);
3034 if deleted {
3035 let before = out.channels.len();
3036 out.channels.retain(|c| c.id.0 != id.0);
3037 return out.channels.len() != before;
3038 }
3039 match out.channels.iter_mut().find(|c| c.id.0 == id.0) {
3040 Some(existing) => {
3041 let mut changed = false;
3042 if existing.name != meta.name {
3043 existing.name = meta.name;
3044 changed = true;
3045 }
3046 if existing.voice != meta.voice {
3049 existing.voice = meta.voice;
3050 changed = true;
3051 }
3052 if existing.meta_custom != meta.custom {
3053 existing.meta_custom = meta.custom;
3054 changed = true;
3055 }
3056 if existing.meta_extra != meta.extra {
3057 existing.meta_extra = meta.extra;
3058 changed = true;
3059 }
3060 if !meta.private && (existing.private || existing.key.is_some()) {
3069 existing.private = false;
3070 existing.key = None;
3071 changed = true;
3072 }
3073 changed
3074 }
3075 None if !meta.private => {
3076 out.channels.push(ChannelV2 {
3079 id,
3080 name: meta.name,
3081 private: false,
3082 key: None,
3083 epoch: out.root_epoch,
3084 voice: meta.voice,
3085 meta_custom: meta.custom,
3086 meta_extra: meta.extra,
3087 });
3088 true
3089 }
3090 None => {
3091 out.channels.push(ChannelV2 {
3097 id,
3098 name: meta.name,
3099 private: true,
3100 key: None,
3101 epoch: Epoch(0),
3102 voice: meta.voice,
3103 meta_custom: meta.custom,
3104 meta_extra: meta.extra,
3105 });
3106 true
3107 }
3108 }
3109}
3110
3111pub struct RekeyFollow {
3115 pub updated: Option<CommunityV2>,
3118 pub self_removed: bool,
3121 pub dissolved: bool,
3124}
3125
3126const MAX_ADDRESSING_ROOTS: usize = 8;
3133
3134pub async fn follow_rekeys<T: Transport + ?Sized>(
3164 transport: &T,
3165 community: &CommunityV2,
3166 session: &SessionGuard,
3167) -> Result<RekeyFollow, String> {
3168 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3171 if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
3172 return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: true });
3173 }
3174 if is_dissolved(transport, community).await {
3180 if session.is_valid() {
3181 let _ = crate::db::community::set_community_dissolved(&cid_hex);
3182 }
3183 return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: true });
3184 }
3185 let me = local_keys()?;
3186 let my_xonly = me.public_key().to_bytes();
3187 let owner = community.owner()?;
3188 let owner_hex = owner.to_hex();
3189 let mut cur = community.clone();
3190 let mut changed = false;
3191
3192 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
3196 let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
3197 let me_hex = me.public_key().to_hex();
3198 let channel_rotator_ok = |rotator: &PublicKey| -> bool {
3199 if *rotator == owner {
3200 return true;
3201 }
3202 let rh = rotator.to_hex();
3203 !banned.contains(&rh) && roster.is_authorized(&rh, Some(&owner_hex), crate::community::roles::Permissions::MANAGE_CHANNELS)
3204 };
3205 let channel_rotator_outranks_me = |rotator: &PublicKey| -> bool {
3210 if *rotator == owner {
3211 return true;
3212 }
3213 let rh = rotator.to_hex();
3214 !banned.contains(&rh) && roster.can_act_on_member(&rh, Some(&owner_hex), &me_hex, crate::community::roles::Permissions::MANAGE_CHANNELS)
3215 };
3216 let base_rotator_ok = |rotator: &PublicKey| -> bool { *rotator == owner };
3217
3218 const MAX_STEPS: usize = 128;
3222 for _ in 0..MAX_STEPS {
3223 let mut advanced = false;
3224
3225 let mut addressing_roots: Vec<[u8; 32]> = vec![cur.community_root];
3229 let mut archived = crate::db::community::held_epoch_keys(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap_or_default();
3230 archived.sort_by(|a, b| b.0 .0.cmp(&a.0 .0));
3231 for (_, r) in archived {
3232 if !addressing_roots.contains(&r) {
3233 addressing_roots.push(r);
3234 }
3235 }
3236 addressing_roots.truncate(MAX_ADDRESSING_ROOTS);
3237
3238 let channel_ids: Vec<ChannelId> = cur.channels.iter().filter(|c| c.private).map(|c| c.id).collect();
3241 for cid in channel_ids {
3242 let (held_key, held_epoch) = match cur.channel(&cid) {
3243 Some(ch) => (ch.key, ch.epoch),
3244 None => continue,
3245 };
3246 let next = Epoch(held_epoch.0.saturating_add(1));
3247 let mut batches: Vec<(Vec<rekey::RekeyChunk>, Option<(Epoch, [u8; 32])>)> = Vec::new();
3248 for root in &addressing_roots {
3249 let group = channel_rekey_group_key(root, &cid, next);
3250 let chunks = fetch_rekey_chunks(transport, &cur.relays, &group).await?;
3251 if chunks.is_empty() {
3252 continue;
3253 }
3254 batches.push((chunks, held_key.map(|k| (held_epoch, k))));
3255 }
3256 match advance_scope(&batches, RekeyScope::Channel(cid), &channel_rotator_ok, &channel_rotator_outranks_me, me.secret_key(), &my_xonly, next) {
3263 Advance::Adopt { new_key } => {
3264 if let Some(ch) = cur.channels.iter_mut().find(|c| c.id.0 == cid.0) {
3265 ch.key = Some(new_key);
3266 ch.epoch = next;
3267 }
3268 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) {
3273 crate::log_warn!("v2: channel epoch-key archive failed (history across this rotation may not read back): {e}");
3274 }
3275 advanced = true;
3276 changed = true;
3277 }
3278 Advance::Removed => {
3279 match held_key {
3280 Some(_) => {
3282 cur.channels.retain(|c| c.id.0 != cid.0);
3283 }
3284 None => {
3289 if let Some(ch) = cur.channels.iter_mut().find(|c| c.id.0 == cid.0) {
3290 ch.epoch = next;
3291 }
3292 }
3293 }
3294 advanced = true;
3295 changed = true;
3296 }
3297 Advance::Stay => {}
3298 }
3299 }
3300
3301 {
3305 let held_epoch = cur.root_epoch;
3306 let held_key = cur.community_root;
3307 let next = Epoch(held_epoch.0.saturating_add(1));
3308 let group = base_rekey_group_key(&cur.community_root, cur.id(), next);
3309 let chunks = fetch_rekey_chunks(transport, &cur.relays, &group).await?;
3310 let batches = vec![(chunks, Some((held_epoch, held_key)))];
3311 match advance_scope(&batches, RekeyScope::Root, &base_rotator_ok, &base_rotator_ok, me.secret_key(), &my_xonly, next) {
3312 Advance::Adopt { new_key } => {
3313 cur.community_root = new_key;
3314 cur.root_epoch = next;
3315 if let Err(e) = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, next.0, &new_key) {
3319 crate::log_warn!("v2: base epoch-key archive failed (this epoch's history may not read back after the next rotation): {e}");
3320 }
3321 advanced = true;
3322 changed = true;
3323 }
3324 Advance::Removed => {
3325 if !session.is_valid() {
3326 return Err("account changed during rekey follow".to_string());
3327 }
3328 return Ok(RekeyFollow { updated: None, self_removed: true, dissolved: false });
3329 }
3330 Advance::Stay => {}
3331 }
3332 }
3333
3334 if !advanced {
3335 break;
3336 }
3337 }
3338
3339 if !changed {
3340 return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: false });
3341 }
3342 if !session.is_valid() {
3343 return Err("account changed during rekey follow".to_string());
3344 }
3345 if crate::db::community::community_protocol(community.id())?.is_none() {
3348 return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: false });
3349 }
3350 crate::db::community::save_community_v2(&cur)?;
3351 Ok(RekeyFollow { updated: Some(cur), self_removed: false, dissolved: false })
3352}
3353
3354enum Advance {
3357 Adopt { new_key: [u8; 32] },
3359 Removed,
3361 Stay,
3363}
3364
3365async fn fetch_rekey_chunks<T: Transport + ?Sized>(
3367 transport: &T,
3368 relays: &[String],
3369 group: &GroupKey,
3370) -> Result<Vec<rekey::RekeyChunk>, String> {
3371 const REKEY_PAGE: usize = 200;
3379 const REKEY_MAX_PAGES: usize = 6;
3380 let mut out = Vec::new();
3381 let mut seen: std::collections::HashSet<nostr_sdk::EventId> = std::collections::HashSet::new();
3382 let mut until: Option<u64> = None;
3383 let mut oldest: Option<u64> = None;
3384 for _ in 0..REKEY_MAX_PAGES {
3385 let query = Query {
3386 kinds: vec![stream::KIND_WRAP],
3387 authors: vec![group.pk_hex()],
3388 until,
3389 limit: Some(REKEY_PAGE),
3390 ..Default::default()
3391 };
3392 let wraps = transport.fetch(&query, relays).await?;
3393 let mut fresh = 0usize;
3394 for w in &wraps {
3395 if !seen.insert(w.id) {
3396 continue;
3397 }
3398 fresh += 1;
3399 let at = w.created_at.as_secs();
3400 if oldest.is_none_or(|o| at < o) {
3401 oldest = Some(at);
3402 }
3403 if let Ok(opened) = stream::open_wrap(w, group) {
3404 if let Ok(chunk) = rekey::parse_rekey_chunk(&opened) {
3405 out.push(chunk);
3406 }
3407 }
3408 }
3409 if fresh == 0 || wraps.len() < REKEY_PAGE {
3412 break;
3413 }
3414 match oldest {
3415 Some(o) if o > 0 => until = Some(o),
3416 _ => break,
3417 }
3418 }
3419 Ok(out)
3420}
3421
3422fn advance_scope(
3436 batches: &[(Vec<rekey::RekeyChunk>, Option<(Epoch, [u8; 32])>)],
3437 scope: RekeyScope,
3438 rotator_ok: &dyn Fn(&PublicKey) -> bool,
3439 rotator_may_remove_me: &dyn Fn(&PublicKey) -> bool,
3440 my_sk: &SecretKey,
3441 my_xonly: &[u8; 32],
3442 next_epoch: Epoch,
3443) -> Advance {
3444 let mut winners: Vec<[u8; 32]> = Vec::new();
3445 let mut saw_complete_candidate = false;
3446 let mut saw_outranking_candidate = false;
3447 let keyed = batches.iter().any(|(_, held)| held.is_some());
3448 for (chunks, held) in batches {
3449 let rotations = rekey::collect_rotations(chunks);
3450 for r in &rotations {
3451 if !rotator_ok(&r.rotator) || r.scope.id32() != scope.id32() || r.new_epoch.0 != next_epoch.0 || !r.is_complete() {
3452 continue;
3453 }
3454 if let Some((held_epoch, held_key)) = held {
3455 if r.continuity(*held_epoch, held_key) != Continuity::Extends {
3456 continue;
3457 }
3458 }
3459 saw_complete_candidate = true;
3460 saw_outranking_candidate |= rotator_may_remove_me(&r.rotator);
3461 if let Some(blob) = rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), my_xonly, r.scope, r.new_epoch) {
3462 if let Ok(k) = rekey::open_blob_local(my_sk, &r.rotator, r.scope, r.new_epoch, blob) {
3463 winners.push(k);
3464 }
3465 }
3466 }
3467 }
3468 if !winners.is_empty() {
3469 let idx = rekey::lowest_key_winner(&winners).expect("winners is non-empty");
3477 return Advance::Adopt { new_key: winners[idx] };
3478 }
3479 if saw_complete_candidate && (!keyed || saw_outranking_candidate) {
3480 Advance::Removed
3481 } else {
3482 Advance::Stay
3483 }
3484}
3485
3486#[cfg(test)]
3487mod tests {
3488 use super::super::super::transport::memory::MemoryRelay;
3489 use super::*;
3490 use crate::community::roles::{MemberGrant, Permissions, Role, RoleScope};
3491
3492 fn account_name(n: u32) -> String {
3494 const B: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
3495 let mut acct = String::from("npub1");
3496 let mut v = n as usize;
3497 for _ in 0..58 {
3498 acct.push(B[v % 32] as char);
3499 v = v / 32 + 7;
3500 }
3501 acct
3502 }
3503
3504 struct Actor {
3506 keys: Keys,
3507 account: String,
3508 }
3509
3510 struct TestBed {
3516 _tmp: tempfile::TempDir,
3517 _guard: std::sync::MutexGuard<'static, ()>,
3518 relay: MemoryRelay,
3519 relays: Vec<String>,
3520 }
3521
3522 impl TestBed {
3523 fn new() -> (TestBed, Actor, Actor) {
3524 static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(70_000);
3525 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
3526 crate::db::close_database();
3527 crate::db::clear_id_caches();
3528 let tmp = tempfile::tempdir().unwrap();
3529 crate::db::set_app_data_dir(tmp.path().to_path_buf());
3530
3531 let mut mk = || {
3532 let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3533 let account = account_name(n);
3534 std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
3535 crate::db::set_current_account(account.clone()).unwrap();
3536 crate::db::init_database(&account).unwrap();
3537 Actor { keys: Keys::generate(), account }
3538 };
3539 let owner = mk();
3540 let member = mk();
3541 let _ = crate::state::take_nostr_client();
3542 let bed = TestBed {
3543 _tmp: tmp,
3544 _guard: guard,
3545 relay: MemoryRelay::new(),
3546 relays: vec!["wss://r".to_string()],
3547 };
3548 (bed, owner, member)
3549 }
3550
3551 fn swap_to(&self, actor: &Actor) {
3553 crate::db::set_current_account(actor.account.clone()).unwrap();
3554 crate::db::init_database(&actor.account).unwrap();
3555 crate::db::clear_id_caches();
3556 crate::state::MY_SECRET_KEY.store_from_keys(&actor.keys, &[]);
3557 crate::state::set_my_public_key(actor.keys.public_key());
3558 }
3559 }
3560
3561 fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>, Keys) {
3563 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
3564 crate::db::close_database();
3565 crate::db::clear_id_caches();
3566 let tmp = tempfile::tempdir().unwrap();
3567 static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(50_000);
3568 let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3569 let acct = account_name(n);
3570 std::fs::create_dir_all(tmp.path().join(&acct)).unwrap();
3571 crate::db::set_app_data_dir(tmp.path().to_path_buf());
3572 crate::db::set_current_account(acct.clone()).unwrap();
3573 crate::db::init_database(&acct).unwrap();
3574 let _ = crate::state::take_nostr_client();
3575 let owner = Keys::generate();
3576 crate::state::MY_SECRET_KEY.store_from_keys(&owner, &[]);
3577 crate::state::set_my_public_key(owner.public_key());
3578 (tmp, guard, owner)
3579 }
3580
3581 struct SwapMidFetch {
3584 inner: MemoryRelay,
3585 }
3586 #[async_trait::async_trait]
3587 impl Transport for SwapMidFetch {
3588 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
3589 self.inner.publish(e, r).await
3590 }
3591 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
3592 self.inner.publish_durable(e, r).await
3593 }
3594 async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> {
3595 let out = self.inner.fetch(q, r).await;
3596 crate::state::bump_session_generation();
3597 out
3598 }
3599 }
3600
3601 struct FixedFetch {
3606 events: Vec<Event>,
3607 }
3608 #[async_trait::async_trait]
3609 impl Transport for FixedFetch {
3610 async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
3611 Ok(())
3612 }
3613 async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
3614 Ok(())
3615 }
3616 async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
3617 Ok(self.events.clone())
3618 }
3619 }
3620
3621 async fn fetch_direct_invite(relay: &MemoryRelay, relays: &[String], me: &PublicKey) -> Event {
3624 let q = Query {
3625 kinds: vec![stream::KIND_WRAP],
3626 p_tags: vec![me.to_hex()],
3627 k_tags: vec!["3313".to_string()],
3628 ..Default::default()
3629 };
3630 relay.fetch(&q, relays).await.unwrap().into_iter().next().expect("a direct invite is waiting")
3631 }
3632
3633 #[tokio::test]
3634 async fn create_persists_and_reloads_a_v2_community() {
3635 let (_tmp, _guard, owner) = init_test_db();
3636 let relay = MemoryRelay::new();
3637 let relays = vec!["wss://r".to_string()];
3638
3639 let created = create_community(&relay, "Vectorville", relays.clone(), Some("hi".into())).await.unwrap();
3640 assert!(created.identity.verify());
3641 assert_eq!(created.owner().unwrap(), owner.public_key());
3642 assert_eq!(created.channels.len(), 1);
3643
3644 assert_eq!(
3646 crate::db::community::community_protocol(created.id()).unwrap(),
3647 Some(crate::community::ConcordProtocol::V2)
3648 );
3649 let loaded = crate::db::community::load_community_v2(created.id()).unwrap().expect("reloads");
3650 assert_eq!(loaded.name, "Vectorville");
3651 assert_eq!(loaded.community_root, created.community_root);
3652 assert_eq!(loaded.identity, created.identity);
3653 assert_eq!(loaded.channels[0].id.0, created.channels[0].id.0);
3654 assert!(!loaded.channels[0].private);
3655
3656 assert!(relay.count_on("wss://r") >= 3, "2 genesis editions + 1 guestbook join");
3658 }
3659
3660 #[tokio::test]
3661 async fn owner_sends_and_reads_back_a_message() {
3662 let (_tmp, _guard, _owner) = init_test_db();
3663 let relay = MemoryRelay::new();
3664 let community = create_community(&relay, "Chat", vec!["wss://r".into()], None).await.unwrap();
3665 let general = community.channels[0].id;
3666
3667 let id1 = send_message(&relay, &community, &general, "hello world").await.unwrap();
3668 let id2 = send_message(&relay, &community, &general, "second message").await.unwrap();
3669 assert_ne!(id1, id2);
3670
3671 let page = fetch_channel(&relay, &community, &general, 100).await.unwrap();
3672 let texts: Vec<String> = page
3673 .iter()
3674 .filter_map(|f| match &f.event {
3675 ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
3676 _ => None,
3677 })
3678 .collect();
3679 assert_eq!(texts, vec!["hello world", "second message"], "messages round-trip in ms order");
3680 }
3681
3682 #[tokio::test]
3683 async fn a_second_member_reads_the_public_channel_from_the_root() {
3684 let (_tmp, _guard, _owner) = init_test_db();
3688 let relay = MemoryRelay::new();
3689 let community = create_community(&relay, "Public", vec!["wss://r".into()], None).await.unwrap();
3690 let general = community.channels[0].id;
3691 send_message(&relay, &community, &general, "everyone can read this").await.unwrap();
3692
3693 let member_view = community.clone();
3695 let page = fetch_channel(&relay, &member_view, &general, 100).await.unwrap();
3696 assert_eq!(page.len(), 1);
3697 assert!(matches!(&page[0].event, ChatEvent::Message { .. }));
3698 assert_eq!(page[0].event.opened().rumor.content, "everyone can read this");
3699 }
3700
3701 async fn texts_in<T: crate::community::transport::Transport + ?Sized>(relay: &T, community: &CommunityV2, channel: &ChannelId) -> Vec<String> {
3704 fetch_channel(relay, community, channel, 100)
3705 .await
3706 .unwrap()
3707 .iter()
3708 .filter_map(|f| match &f.event {
3709 ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
3710 _ => None,
3711 })
3712 .collect()
3713 }
3714
3715 #[tokio::test]
3716 async fn direct_invite_full_loop_owner_and_member_converse() {
3717 let (bed, owner, member) = TestBed::new();
3718
3719 bed.swap_to(&owner);
3721 let community = create_community(&bed.relay, "Guild", bed.relays.clone(), None).await.unwrap();
3722 let general = community.channels[0].id;
3723 send_message(&bed.relay, &community, &general, "owner: welcome!").await.unwrap();
3724 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
3725
3726 bed.swap_to(&member);
3728 assert!(
3729 crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
3730 "the member does not hold the community before joining"
3731 );
3732 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
3733 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
3734 assert_eq!(joined.id().0, community.id().0, "joined the same community");
3735 assert!(joined.identity.verify(), "the joiner independently verifies the owner commitment");
3736 assert_eq!(joined.owner().unwrap(), owner.keys.public_key());
3737
3738 assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: welcome!"]);
3740 send_message(&bed.relay, &joined, &general, "member: thanks for the invite").await.unwrap();
3741
3742 bed.swap_to(&owner);
3744 assert_eq!(
3745 texts_in(&bed.relay, &community, &general).await,
3746 vec!["owner: welcome!", "member: thanks for the invite"],
3747 "both actors' messages interleave in ms order on the shared channel"
3748 );
3749
3750 let members = memberlist(&bed.relay, &community).await.unwrap();
3752 assert!(members.contains(&owner.keys.public_key()), "owner is a member (genesis Join)");
3753 assert!(members.contains(&member.keys.public_key()), "member is a member (invite Join)");
3754 assert_eq!(members.len(), 2);
3755 }
3756
3757 #[tokio::test]
3758 async fn public_link_full_loop() {
3759 let (bed, owner, member) = TestBed::new();
3760
3761 bed.swap_to(&owner);
3762 let community = create_community(&bed.relay, "Public Guild", bed.relays.clone(), None).await.unwrap();
3763 let general = community.channels[0].id;
3764 send_message(&bed.relay, &community, &general, "come on in").await.unwrap();
3765 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
3767 assert!(link.url.starts_with("https://vectorapp.io/invite/"));
3768 assert!(link.url.contains('#'), "the fragment carries the token");
3769
3770 bed.swap_to(&member);
3772 let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
3773 assert_eq!(joined.id().0, community.id().0);
3774 assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["come on in"]);
3775 }
3776
3777 #[test]
3778 fn bundle_of_snapshots_the_held_icon() {
3779 let owner = Keys::generate();
3780 let g = control::genesis(&owner, control::CommunityMetadata { name: "Logo".into(), ..Default::default() }, 1_000).unwrap();
3781 let mut c = CommunityV2::from_genesis(&g, "Logo", None, vec!["wss://r".into()], 0);
3782 let icon = control::ImageRef { url: "https://blossom.example/i".into(), key: "k".into(), nonce: "n".into(), hash: "h".into(), extra: Default::default() };
3783 c.icon = Some(icon.clone());
3784 let bundle = bundle_of(&c, None, None, None);
3785 assert_eq!(bundle.icon, Some(icon), "a parked invite renders the real logo from the mint-time snapshot");
3786 }
3787
3788 #[tokio::test]
3789 async fn public_link_preview_shows_live_name_and_icon_without_joining() {
3790 let (bed, owner, member) = TestBed::new();
3791
3792 bed.swap_to(&owner);
3793 let community = create_community(&bed.relay, "Soapbox", bed.relays.clone(), None).await.unwrap();
3794 let icon = control::ImageRef {
3797 url: "https://blossom.example/soap".into(),
3798 key: "k".into(),
3799 nonce: "n".into(),
3800 hash: "h".into(),
3801 extra: Default::default(),
3802 };
3803 let mut meta = community.metadata();
3804 meta.icon = Some(icon.clone());
3805 edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
3806 let link = mint_public_link(&bed.relay, &community, "https://armada.buzz", None, None).await.unwrap();
3808
3809 bed.swap_to(&member);
3811 let preview = preview_public_link(&bed.relay, &link.url).await.unwrap();
3812 assert_eq!(preview.name, "Soapbox");
3813 assert_eq!(preview.icon, Some(icon), "the icon folds from the live Control Plane, not the bundle");
3814 assert!(
3815 crate::db::community::load_community_v2(preview.id()).unwrap().is_none(),
3816 "previewing must not persist a membership"
3817 );
3818 }
3819
3820 #[tokio::test]
3821 async fn a_previewed_join_reuses_the_verified_fold() {
3822 let (bed, owner, member) = TestBed::new();
3823 bed.swap_to(&owner);
3824 let community = create_community(&bed.relay, "FastJoin", bed.relays.clone(), None).await.unwrap();
3825 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
3826
3827 bed.swap_to(&member);
3828 let _ = preview_public_link(&bed.relay, &link.url).await.unwrap();
3829 let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
3830 assert_eq!(joined.id().0, community.id().0);
3831 assert!(joined.created_at_ms > 0, "the handoff stamps the JOIN's acquisition time, not the preview's");
3832 assert!(VERIFIED_PREVIEW.lock().unwrap().is_none(), "the handoff slot must be consumed by the join");
3835 }
3836
3837 #[tokio::test]
3838 async fn guestbook_store_seeds_syncs_incrementally_and_matches_the_live_fold() {
3839 let (bed, owner, member) = TestBed::new();
3840 bed.swap_to(&owner);
3841 let community = create_community(&bed.relay, "GB", bed.relays.clone(), None).await.unwrap();
3842 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
3843
3844 bed.swap_to(&member);
3845 let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
3846
3847 let session = SessionGuard::capture();
3849 assert!(!sync_guestbook(&bed.relay, &joined, &session).await.unwrap().is_empty(), "the seed folds fresh events");
3850 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
3851 let (_, cursor) = crate::db::community::get_guestbook(&cid_hex).unwrap();
3852 assert!(cursor > 0, "the cursor advanced past zero");
3853 let stored: std::collections::BTreeSet<_> = stored_memberlist(&joined).unwrap().into_iter().collect();
3854 let live: std::collections::BTreeSet<_> = memberlist(&bed.relay, &joined).await.unwrap().into_iter().collect();
3855 assert_eq!(stored, live, "stored fold == live fold after the seed");
3856 assert!(stored.contains(&member.keys.public_key()));
3857
3858 assert!(sync_guestbook(&bed.relay, &joined, &session).await.unwrap().is_empty());
3860
3861 bed.swap_to(&owner);
3863 kick_member(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
3864 bed.swap_to(&member);
3865 let session = SessionGuard::capture();
3866 assert!(!sync_guestbook(&bed.relay, &joined, &session).await.unwrap().is_empty(), "the kick lands incrementally");
3867 assert!(
3868 !stored_memberlist(&joined).unwrap().contains(&member.keys.public_key()),
3869 "an owner kick removes the member from the stored fold"
3870 );
3871 }
3872
3873 #[tokio::test]
3874 async fn a_preview_then_revoke_still_refuses_the_join() {
3875 let (bed, owner, member) = TestBed::new();
3876 bed.swap_to(&owner);
3877 let community = create_community(&bed.relay, "RevokeRace", bed.relays.clone(), None).await.unwrap();
3878 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
3879
3880 bed.swap_to(&member);
3882 let p = preview_public_link(&bed.relay, &link.url).await.unwrap();
3883 assert_eq!(p.name, "RevokeRace");
3884 bed.swap_to(&owner);
3885 let tombstone = invite::build_revocation(&link.link_signer).unwrap();
3886 bed.relay.publish_durable(&tombstone, &bed.relays).await.unwrap();
3887
3888 bed.swap_to(&member);
3891 let err = accept_public_link(&bed.relay, &link.url).await.unwrap_err();
3892 assert!(err.contains("revoked"), "got: {err}");
3893 }
3894
3895 #[tokio::test]
3896 async fn a_revoked_link_refuses_to_join() {
3897 let (bed, owner, member) = TestBed::new();
3898 bed.swap_to(&owner);
3899 let community = create_community(&bed.relay, "Revoked", bed.relays.clone(), None).await.unwrap();
3900 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
3901 let tombstone = invite::build_revocation(&link.link_signer).unwrap();
3903 bed.relay.publish_durable(&tombstone, &bed.relays).await.unwrap();
3904
3905 bed.swap_to(&member);
3906 let err = accept_public_link(&bed.relay, &link.url).await.unwrap_err();
3907 assert!(err.contains("revoked"), "a retired link finds the grave, not keys: {err}");
3908 }
3909
3910 #[tokio::test]
3911 async fn an_expired_direct_invite_refuses_to_join() {
3912 let (bed, owner, member) = TestBed::new();
3913 bed.swap_to(&owner);
3914 let community = create_community(&bed.relay, "Expired", bed.relays.clone(), None).await.unwrap();
3915 let inviter = owner.keys.clone();
3917 let mut bundle = bundle_of(&community, Some(inviter.public_key()), Some(1_000), None);
3918 bundle.expires_at = Some(1_000); let wrap = invite::build_direct_invite(&inviter, &member.keys.public_key(), &bundle).unwrap();
3920 bed.relay.publish(&wrap, &bed.relays).await.unwrap();
3921
3922 bed.swap_to(&member);
3923 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
3924 let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
3925 assert!(err.contains("expired"), "a past-expiry invite refuses to join: {err}");
3926 }
3927
3928 #[tokio::test]
3929 async fn a_tombstone_beats_a_live_bundle_regardless_of_fetch_order() {
3930 let (bed, owner, member) = TestBed::new();
3934 bed.swap_to(&owner);
3935 let community = create_community(&bed.relay, "Rev", bed.relays.clone(), None).await.unwrap();
3936 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
3937 let tombstone = invite::build_revocation(&link.link_signer).unwrap();
3938
3939 let union = FixedFetch { events: vec![link.bundle_event.clone(), tombstone] };
3942
3943 bed.swap_to(&member);
3944 let err = accept_public_link(&union, &link.url).await.unwrap_err();
3945 assert!(err.contains("revoked"), "a tombstone must beat a Live returned first: {err}");
3946 }
3947
3948 #[test]
3949 fn from_bundle_refuses_an_over_cap_bundle_before_allocating() {
3950 let owner = Keys::generate();
3956 let identity = super::super::control::CommunityIdentity::mint(&owner.public_key());
3957 let hex = crate::simd::hex::bytes_to_hex_32;
3958 let root = [0x11u8; 32];
3959 let mut bundle = CommunityInvite {
3960 community_id: hex(&identity.community_id.0),
3961 owner: hex(&identity.owner_xonly),
3962 owner_salt: hex(&identity.owner_salt),
3963 community_root: hex(&root),
3964 root_epoch: 0,
3965 channels: vec![],
3966 relays: vec!["wss://r".into()],
3967 name: "X".into(),
3968 icon: None,
3969 expires_at: None,
3970 creator_npub: None,
3971 label: None,
3972 extra: Default::default(),
3973 };
3974 bundle.channels = (0..=invite::MAX_BUNDLE_CHANNELS)
3975 .map(|i| {
3976 let mut id = [0u8; 32];
3977 id[..8].copy_from_slice(&(i as u64).to_be_bytes());
3978 invite::ChannelGrant { id: hex(&id), key: hex(&root), epoch: 0, name: "x".into() }
3979 })
3980 .collect();
3981 assert!(CommunityV2::from_bundle(&bundle, 0).is_err(), "an over-cap bundle is refused before allocating");
3982 }
3983
3984 #[tokio::test]
3985 async fn a_join_swap_between_fetch_and_save_aborts_and_leaves_the_other_account_clean() {
3986 let (bed, owner, member) = TestBed::new();
3991 bed.swap_to(&owner);
3992 let community = create_community(&bed.relay, "Straddle", bed.relays.clone(), None).await.unwrap();
3993 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
3994 let swap_relay = SwapMidFetch { inner: MemoryRelay::new() };
3996 swap_relay.inner.publish_durable(&link.bundle_event, &bed.relays).await.unwrap();
3997
3998 bed.swap_to(&member);
3999 let err = accept_public_link(&swap_relay, &link.url).await.unwrap_err();
4000 assert!(err.contains("account changed"), "a swap mid-join must abort: {err}");
4001 assert!(
4002 crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
4003 "the aborted join wrote nothing to the (member) account DB"
4004 );
4005 }
4006
4007 #[tokio::test]
4008 async fn the_owner_is_a_member_even_without_a_fetched_genesis_join() {
4009 let (_tmp, _guard, owner) = init_test_db();
4012 let relay = MemoryRelay::new();
4013 let community = create_community(&relay, "Owned", vec!["wss://r".into()], None).await.unwrap();
4014 let empty = MemoryRelay::new();
4017 let members = memberlist(&empty, &community).await.unwrap();
4018 assert_eq!(members, vec![owner.public_key()], "owner present with no fetched Join");
4019 }
4020
4021 #[tokio::test]
4022 async fn an_expiring_minted_invite_refuses_after_the_deadline() {
4023 let (bed, owner, member) = TestBed::new();
4026 bed.swap_to(&owner);
4027 let community = create_community(&bed.relay, "Timed", bed.relays.clone(), None).await.unwrap();
4028 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), Some(1_000), Some("beta".into()))
4029 .await
4030 .unwrap();
4031
4032 bed.swap_to(&member);
4033 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
4034 assert!(
4035 accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err().contains("expired"),
4036 "a minted expiring invite refuses past its deadline"
4037 );
4038 }
4039
4040 #[tokio::test]
4041 async fn a_member_who_leaves_drops_from_the_memberlist() {
4042 let (bed, owner, member) = TestBed::new();
4043 bed.swap_to(&owner);
4044 let community = create_community(&bed.relay, "Leaving", bed.relays.clone(), None).await.unwrap();
4045 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
4046
4047 bed.swap_to(&member);
4048 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
4049 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
4050 tokio::time::sleep(std::time::Duration::from_millis(2)).await;
4052 leave_community(&bed.relay, &joined).await.unwrap();
4053
4054 bed.swap_to(&owner);
4055 let members = memberlist(&bed.relay, &community).await.unwrap();
4056 assert!(members.contains(&owner.keys.public_key()));
4057 assert!(!members.contains(&member.keys.public_key()), "a member who left drops from the list");
4058 }
4059
4060 #[tokio::test]
4061 async fn a_swapped_member_cannot_see_the_owners_community_until_joining() {
4062 let (bed, owner, member) = TestBed::new();
4065 bed.swap_to(&owner);
4066 let community = create_community(&bed.relay, "Private-so-far", bed.relays.clone(), None).await.unwrap();
4067 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_some());
4068
4069 bed.swap_to(&member);
4070 assert!(
4071 crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
4072 "the owner's community must be invisible in the member's account DB"
4073 );
4074 assert_eq!(crate::db::community::list_community_ids().unwrap().len(), 0);
4075 }
4076
4077 async fn head_hash_on_relay(relay: &MemoryRelay, community: &CommunityV2, entity_id: &[u8; 32]) -> Option<[u8; 32]> {
4085 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
4086 let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
4087 let wraps = relay.fetch(&query, &community.relays).await.ok()?;
4088 let mut head: Option<(u64, [u8; 32])> = None;
4089 for w in &wraps {
4090 if let Ok((ed, _)) = control::open_control_edition(w, &group) {
4091 if ed.entity_id == *entity_id && head.is_none_or(|(v, _)| ed.version > v) {
4092 head = Some((ed.version, ed.self_hash));
4093 }
4094 }
4095 }
4096 head.map(|(_, h)| h)
4097 }
4098
4099 async fn publish_channel_edition(
4100 relay: &MemoryRelay,
4101 community: &CommunityV2,
4102 signer: &Keys,
4103 channel_id: &ChannelId,
4104 name: &str,
4105 private: bool,
4106 version: u64,
4107 deleted: bool,
4108 ) {
4109 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
4110 let prev = head_hash_on_relay(relay, community, &channel_id.0).await;
4111 let meta = control::ChannelMetadata { name: name.into(), private, deleted: deleted.then_some(true), ..Default::default() };
4112 let content = serde_json::to_string(&meta).unwrap();
4113 let rumor = control::build_edition_rumor(signer.public_key(), vsk::CHANNEL_METADATA, &channel_id.0, version, prev.as_ref(), &content, 1_000, None);
4114 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
4115 relay.publish(&wrap, &community.relays).await.unwrap();
4116 }
4117
4118 async fn publish_community_meta(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, name: &str, version: u64) {
4121 publish_community_meta_at(relay, community, signer, name, version, 1_000).await;
4122 }
4123
4124 async fn publish_community_meta_at(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, name: &str, version: u64, at_secs: u64) {
4127 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
4128 let prev = head_hash_on_relay(relay, community, &community.id().0).await;
4129 let meta = control::CommunityMetadata { name: name.into(), ..Default::default() };
4130 let content = serde_json::to_string(&meta).unwrap();
4131 let rumor = control::build_edition_rumor(signer.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, version, prev.as_ref(), &content, at_secs, None);
4132 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(at_secs)).unwrap();
4133 relay.publish(&wrap, &community.relays).await.unwrap();
4134 }
4135
4136 #[test]
4137 fn metadata_apply_captures_undriven_fields_for_republish() {
4138 let owner = Keys::generate();
4139 let g = control::genesis(&owner, control::CommunityMetadata { name: "A".into(), ..Default::default() }, 1_000).unwrap();
4140 let mut held = CommunityV2::from_genesis(&g, "A", None, vec!["wss://r".into()], 0);
4141 let general = held.channels[0].id;
4142
4143 let mut custom = serde_json::Map::new();
4145 custom.insert("accent".into(), serde_json::Value::from("#89f0b6"));
4146 let mut extra = serde_json::Map::new();
4147 extra.insert("vnd_flag".into(), serde_json::Value::Bool(true));
4148 let meta = control::CommunityMetadata { name: "A".into(), custom: Some(custom.clone()), extra: extra.clone(), ..Default::default() };
4149 assert!(apply_community_metadata(&mut held, meta), "gaining custom/extra is a change");
4150 assert_eq!(held.meta_custom, Some(custom.clone()));
4151 assert_eq!(held.meta_extra, extra);
4152 assert_eq!(held.metadata().custom, Some(custom));
4154 assert_eq!(held.metadata().extra, held.meta_extra);
4155
4156 let mut ch_custom = serde_json::Map::new();
4158 ch_custom.insert("slowmode".into(), serde_json::Value::from(30));
4159 let ch_meta = control::ChannelMetadata {
4160 name: "general".into(),
4161 private: false,
4162 voice: Some(true),
4163 deleted: None,
4164 custom: Some(ch_custom.clone()),
4165 extra: Default::default(),
4166 };
4167 assert!(apply_channel_metadata(&mut held, general, ch_meta), "gaining voice/custom is a change");
4168 let ch = held.channel(&general).unwrap();
4169 assert_eq!(ch.voice, Some(true));
4170 assert_eq!(ch.meta_custom, Some(ch_custom.clone()));
4171 let rename = { let mut d = ch.metadata(); d.name = "lounge".into(); d };
4172 assert_eq!(rename.voice, Some(true), "our rename edition carries the foreign voice flag");
4173 assert_eq!(rename.custom, Some(ch_custom));
4174 }
4175
4176 #[test]
4177 fn community_metadata_apply_sets_and_clears_images() {
4178 let owner = Keys::generate();
4179 let g = control::genesis(&owner, control::CommunityMetadata { name: "A".into(), ..Default::default() }, 1_000).unwrap();
4180 let mut held = CommunityV2::from_genesis(&g, "A", None, vec!["wss://r".into()], 0);
4181
4182 let icon = control::ImageRef {
4183 url: "https://blossom.example/i".into(),
4184 key: "k".into(),
4185 nonce: "n".into(),
4186 hash: "h".into(),
4187 extra: Default::default(),
4188 };
4189 let with_icon = control::CommunityMetadata { name: "A".into(), icon: Some(icon.clone()), ..Default::default() };
4190 assert!(apply_community_metadata(&mut held, with_icon), "gaining an icon is a change");
4191 assert_eq!(held.icon.as_ref(), Some(&icon));
4192
4193 let without = control::CommunityMetadata { name: "A".into(), ..Default::default() };
4195 assert!(apply_community_metadata(&mut held, without), "losing the icon is a change");
4196 assert_eq!(held.icon, None);
4197 }
4198
4199 async fn publish_role(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, role: &Role, version: u64) {
4201 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
4202 let role_id = crate::simd::hex::hex_to_bytes_32_checked(&role.role_id).unwrap();
4203 let prev = head_hash_on_relay(relay, community, &role_id).await;
4204 let content = crate::community::v2::roles::role_content_json(role).unwrap();
4205 let rumor = control::build_edition_rumor(signer.public_key(), vsk::ROLE, &role_id, version, prev.as_ref(), &content, 1_000, None);
4206 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
4207 relay.publish(&wrap, &community.relays).await.unwrap();
4208 }
4209
4210 async fn publish_grant(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, member: &PublicKey, role_ids: Vec<String>, version: u64) {
4212 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
4213 let eid = crate::community::v2::derive::grant_locator(community.id(), &member.to_bytes());
4214 let prev = head_hash_on_relay(relay, community, &eid).await;
4215 let grant = MemberGrant { member: member.to_hex(), role_ids };
4216 let content = crate::community::v2::roles::grant_content_json(&grant).unwrap();
4217 let rumor = control::build_edition_rumor(signer.public_key(), vsk::GRANT, &eid, version, prev.as_ref(), &content, 1_000, None);
4218 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
4219 relay.publish(&wrap, &community.relays).await.unwrap();
4220 }
4221
4222 async fn publish_banlist(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, banned: &[String], version: u64) {
4224 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
4225 let eid = crate::community::v2::derive::banlist_locator(community.id());
4226 let prev = head_hash_on_relay(relay, community, &eid).await;
4227 let content = crate::community::v2::roles::banlist_content_json(banned).unwrap();
4228 let rumor = control::build_edition_rumor(signer.public_key(), vsk::BANLIST, &eid, version, prev.as_ref(), &content, 1_000, None);
4229 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
4230 relay.publish(&wrap, &community.relays).await.unwrap();
4231 }
4232
4233 fn admin_role(role_id: &str, perms: u64) -> Role {
4234 Role { role_id: role_id.into(), name: "Admin".into(), position: 1, permissions: Permissions(perms), scope: RoleScope::Server, color: 0 }
4235 }
4236
4237 #[tokio::test]
4243 async fn a_non_owner_cannot_suppress_the_admin_role_by_forging_a_higher_version() {
4244 let (bed, owner, attacker) = TestBed::new();
4245 bed.swap_to(&owner);
4246 let community = create_community(&bed.relay, "AttackA", bed.relays.clone(), None).await.unwrap();
4247 let victim = Keys::generate().public_key();
4248 grant_admin(&bed.relay, &community, &victim).await.unwrap();
4249
4250 let admin_rid = fetch_authority(&bed.relay, &community)
4252 .await
4253 .roles
4254 .roles
4255 .iter()
4256 .find(|r| r.permissions.contains(Permissions::ADMIN_ALL))
4257 .unwrap()
4258 .role_id
4259 .clone();
4260 publish_role(
4262 &bed.relay,
4263 &community,
4264 &attacker.keys,
4265 &Role { role_id: admin_rid.clone(), name: "pwned".into(), position: 1, permissions: Permissions(0), scope: RoleScope::Server, color: 0 },
4266 2,
4267 )
4268 .await;
4269
4270 let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
4271 assert!(authority.roles.is_admin(&victim.to_hex()), "the forged strip is DROPPED; the owner's admin role survives beneath it");
4272 assert!(
4273 authority.heads.iter().any(|h| h.entity_hex == admin_rid && h.version == 1),
4274 "the floor advances only to the AUTHORIZED head (owner v1)"
4275 );
4276 assert!(!authority.heads.iter().any(|h| h.version == 2), "the forged v2 never poisons the floor");
4277 }
4278
4279 #[tokio::test]
4280 async fn a_non_owner_cannot_strip_a_members_grant_by_forging_a_higher_version() {
4281 let (bed, owner, attacker) = TestBed::new();
4282 bed.swap_to(&owner);
4283 let community = create_community(&bed.relay, "AttackC", bed.relays.clone(), None).await.unwrap();
4284 let victim = Keys::generate();
4285 grant_admin(&bed.relay, &community, &victim.public_key()).await.unwrap();
4286
4287 publish_grant(&bed.relay, &community, &attacker.keys, &victim.public_key(), vec![], 9).await;
4289
4290 let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
4291 assert!(
4292 authority.roles.is_admin(&victim.public_key().to_hex()),
4293 "the forged strip is dropped; the owner's grant survives and the victim keeps admin"
4294 );
4295 }
4296
4297 #[tokio::test]
4298 async fn forged_low_id_roles_by_a_non_owner_never_enter_the_authorized_roster() {
4299 let (bed, owner, attacker) = TestBed::new();
4300 bed.swap_to(&owner);
4301 let community = create_community(&bed.relay, "AttackB", bed.relays.clone(), None).await.unwrap();
4302 let victim = Keys::generate().public_key();
4303 grant_admin(&bed.relay, &community, &victim).await.unwrap();
4304
4305 for i in 0u8..6 {
4308 let rid = crate::simd::hex::bytes_to_hex_32(&[i; 32]);
4309 publish_role(&bed.relay, &community, &attacker.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
4310 }
4311
4312 let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
4313 assert!(authority.roles.is_admin(&victim.to_hex()), "the legit admin survives the forged flood");
4314 assert_eq!(authority.roles.roles.len(), 1, "only the owner's admin role is authorized; every forgery is dropped");
4315 }
4316
4317 fn authority_fingerprint(a: &AuthoritySet) -> String {
4320 let mut roles = a.roles.roles.clone();
4321 roles.sort_by(|x, y| x.role_id.cmp(&y.role_id));
4322 let mut grants = a.roles.grants.clone();
4323 for g in &mut grants {
4324 g.role_ids.sort();
4325 }
4326 grants.sort_by(|x, y| x.member.cmp(&y.member));
4327 let banned: Vec<&String> = a.banned.iter().collect();
4328 serde_json::json!({ "roles": roles, "grants": grants, "banned": banned }).to_string()
4329 }
4330
4331 #[tokio::test]
4332 async fn the_v2_authority_fold_is_order_independent() {
4333 let (bed, owner, _a) = TestBed::new();
4339 bed.swap_to(&owner);
4340 let community = create_community(&bed.relay, "Determinism", bed.relays.clone(), None).await.unwrap();
4341
4342 let admin1 = Keys::generate().public_key();
4345 let admin2 = Keys::generate().public_key();
4346 grant_admin(&bed.relay, &community, &admin1).await.unwrap();
4347 grant_admin(&bed.relay, &community, &admin2).await.unwrap();
4348 let mod_rid = "5c".repeat(32);
4349 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&mod_rid, Permissions::KICK | Permissions::MANAGE_MESSAGES), 1).await;
4350 let member = Keys::generate().public_key();
4351 publish_grant(&bed.relay, &community, &owner.keys, &member, vec![mod_rid.clone()], 1).await;
4352 let banned_member = Keys::generate().public_key();
4353 publish_grant(&bed.relay, &community, &owner.keys, &banned_member, vec![mod_rid], 1).await;
4354 set_banlist(&bed.relay, &community, &[banned_member.to_hex()]).await.unwrap();
4355 let meta = control::CommunityMetadata { name: "Renamed".into(), relays: community.relays.clone(), ..Default::default() };
4356 edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
4357 create_public_channel(&bed.relay, &community, "extra").await.unwrap();
4358
4359 let editions = fetch_control(&bed.relay, &community).await;
4360 let floors = load_floors(&community);
4361 assert!(editions.len() >= 6, "a rich plane was built ({} editions)", editions.len());
4362
4363 let baseline = authority_fingerprint(&fold_authority(&community, &editions, &floors));
4364
4365 let mut orders: Vec<Vec<ParsedEdition>> = Vec::new();
4368 let mut rev = editions.clone();
4369 rev.reverse();
4370 orders.push(rev);
4371 for shift in [1usize, 3, 5, 7] {
4372 let n = editions.len();
4373 orders.push((0..n).map(|i| editions[(i + shift) % n].clone()).collect());
4374 }
4375 let mut zip = Vec::with_capacity(editions.len());
4377 let (mut lo, mut hi) = (0isize, editions.len() as isize - 1);
4378 while lo <= hi {
4379 zip.push(editions[lo as usize].clone());
4380 if lo != hi {
4381 zip.push(editions[hi as usize].clone());
4382 }
4383 lo += 1;
4384 hi -= 1;
4385 }
4386 orders.push(zip);
4387
4388 for (i, order) in orders.iter().enumerate() {
4389 let got = authority_fingerprint(&fold_authority(&community, order, &floors));
4390 assert_eq!(got, baseline, "arrival order #{i} must resolve the identical authority (consensus)");
4391 }
4392 assert!(baseline.contains(&admin1.to_hex()) || baseline.contains(&member.to_hex()), "grants are present in the fingerprint");
4395 assert!(baseline.contains(&banned_member.to_hex()), "the banlist entry is in the fingerprint");
4396 }
4397
4398 struct FetchErrors(MemoryRelay);
4400 #[async_trait::async_trait]
4401 impl crate::community::transport::Transport for FetchErrors {
4402 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
4403 self.0.publish(e, r).await
4404 }
4405 async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
4406 Err("relay down".to_string())
4407 }
4408 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
4409 self.0.publish_durable(e, r).await
4410 }
4411 }
4412
4413 #[tokio::test]
4414 async fn fetch_authority_retains_the_persisted_banlist_on_a_transport_error() {
4415 let (bed, owner, victim) = TestBed::new();
4416 bed.swap_to(&owner);
4417 let community = create_community(&bed.relay, "BanRetain", bed.relays.clone(), None).await.unwrap();
4418 let victim_hex = victim.keys.public_key().to_hex();
4419 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4421 crate::db::community::set_community_banlist(&cid_hex, &[victim_hex.clone()], 1).unwrap();
4422
4423 let down = FetchErrors(MemoryRelay::new());
4426 let view = fetch_authority(&down, &community).await;
4427 assert!(view.banned.contains(&victim_hex), "a transport error retains the persisted banlist");
4428 }
4429
4430 #[tokio::test]
4431 async fn follow_control_retains_the_roster_when_a_floored_role_ages_out() {
4432 let (bed, owner, _m) = TestBed::new();
4433 bed.swap_to(&owner);
4434 let community = create_community(&bed.relay, "Complete", bed.relays.clone(), None).await.unwrap();
4435 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4436 let (a, b) = (Keys::generate().public_key(), Keys::generate().public_key());
4437 let rid = crate::simd::hex::bytes_to_hex_32(&[0x7c; 32]);
4438
4439 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
4441 publish_grant(&bed.relay, &community, &owner.keys, &a, vec![rid.clone()], 1).await;
4442 publish_grant(&bed.relay, &community, &owner.keys, &b, vec![rid.clone()], 1).await;
4443 let session = crate::state::SessionGuard::capture();
4444 follow_control(&bed.relay, &community, &session).await.unwrap();
4445 assert!(crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&a.to_hex()), "seeded");
4446
4447 let relay2 = MemoryRelay::new();
4451 publish_grant(&relay2, &community, &owner.keys, &a, vec![rid.clone()], 1).await;
4452 follow_control(&relay2, &community, &session).await.unwrap();
4453 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
4454 assert!(roster.is_admin(&a.to_hex()) && roster.is_admin(&b.to_hex()), "a floored-but-unfetched role retains the stored roster");
4455 }
4456
4457 #[tokio::test]
4458 async fn an_authorized_admin_edits_metadata_but_a_demoted_one_cannot() {
4459 let (_tmp, _guard, owner) = init_test_db();
4463 let relay = MemoryRelay::new();
4464 let community = create_community(&relay, "Base", vec!["wss://r".into()], None).await.unwrap();
4465 let admin = Keys::generate();
4466 let rid = "a1".repeat(32);
4467 publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
4468 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
4469 publish_community_meta(&relay, &community, &admin, "Admin Rename", 2).await;
4470
4471 let session = SessionGuard::capture();
4472 let updated = follow_control(&relay, &community, &session).await.unwrap().expect("admin edit authorized");
4473 assert_eq!(updated.name, "Admin Rename", "an admin with MANAGE_METADATA renames");
4474
4475 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![], 2).await; publish_community_meta(&relay, &community, &admin, "Demoted Rename", 3).await;
4477 let _ = follow_control(&relay, &community, &session).await.unwrap();
4478 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4479 assert_eq!(held.name, "Admin Rename", "a demoted admin's edit is dropped; the name holds");
4480 }
4481
4482 #[tokio::test]
4483 async fn a_roleless_member_cannot_edit_metadata() {
4484 let (_tmp, _guard, _owner) = init_test_db();
4485 let relay = MemoryRelay::new();
4486 let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
4487 let stranger = Keys::generate();
4488 publish_community_meta(&relay, &community, &stranger, "Hijacked", 2).await;
4489 let session = SessionGuard::capture();
4490 assert!(
4491 follow_control(&relay, &community, &session).await.unwrap().is_none(),
4492 "a roleless member's metadata edit never folds"
4493 );
4494 }
4495
4496 #[tokio::test]
4497 async fn a_self_signed_grant_is_not_authority() {
4498 let (_tmp, _guard, _owner) = init_test_db();
4502 let relay = MemoryRelay::new();
4503 let community = create_community(&relay, "NoSelfPromo", vec!["wss://r".into()], None).await.unwrap();
4504 let rogue = Keys::generate();
4505 let rid = "b2".repeat(32);
4506 publish_role(&relay, &community, &rogue, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
4507 publish_grant(&relay, &community, &rogue, &rogue.public_key(), vec![rid.clone()], 1).await;
4508 publish_community_meta(&relay, &community, &rogue, "Seized", 2).await;
4509 let session = SessionGuard::capture();
4510 assert!(
4511 follow_control(&relay, &community, &session).await.unwrap().is_none(),
4512 "a self-signed grant confers no authority"
4513 );
4514 }
4515
4516 #[tokio::test]
4517 async fn the_banlist_is_enforced_only_from_a_ban_holder() {
4518 let (_tmp, _guard, owner) = init_test_db();
4519 let relay = MemoryRelay::new();
4520 let community = create_community(&relay, "Bans", vec!["wss://r".into()], None).await.unwrap();
4521 let target = "cc".repeat(32);
4522
4523 let rogue = Keys::generate();
4525 publish_banlist(&relay, &community, &rogue, &[target.clone()], 1).await;
4526 let floors = load_floors(&community);
4527 let editions = fetch_control(&relay, &community).await;
4528 let authority = fold_authority(&community, &editions, &floors);
4529 assert!(authority.banned.is_empty(), "a non-owner (no BAN) banlist is not enforced");
4530
4531 publish_banlist(&relay, &community, &owner, &[target.clone()], 2).await;
4533 let editions = fetch_control(&relay, &community).await;
4534 let authority = fold_authority(&community, &editions, &floors);
4535 assert!(authority.banned.contains(&target), "the owner's banlist is enforced");
4536 }
4537
4538 #[tokio::test]
4539 async fn a_banned_admin_loses_all_authority() {
4540 let (_tmp, _guard, owner) = init_test_db();
4543 let relay = MemoryRelay::new();
4544 let community = create_community(&relay, "BanAuth", vec!["wss://r".into()], None).await.unwrap();
4545 let admin = Keys::generate();
4546 let rid = "e5".repeat(32);
4547 publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
4548 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
4549 publish_banlist(&relay, &community, &owner, &[admin.public_key().to_hex()], 1).await; publish_community_meta(&relay, &community, &admin, "Banned Rename", 2).await;
4551
4552 let session = SessionGuard::capture();
4553 assert!(
4554 follow_control(&relay, &community, &session).await.unwrap().is_none(),
4555 "a banned admin's edit is dropped even with an unstripped grant"
4556 );
4557 let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
4558 assert!(authority.banned.contains(&admin.public_key().to_hex()));
4559 assert!(
4560 !authority.roles.is_authorized(&admin.public_key().to_hex(), Some(&owner.public_key().to_hex()), Permissions::MANAGE_METADATA),
4561 "a banned admin holds no bit"
4562 );
4563 }
4564
4565 #[tokio::test]
4566 async fn a_ban_holder_cannot_ban_a_superior_or_the_owner() {
4567 let (_tmp, _guard, owner) = init_test_db();
4571 let relay = MemoryRelay::new();
4572 let community = create_community(&relay, "Ranks", vec!["wss://r".into()], None).await.unwrap();
4573 let admin = Keys::generate();
4574 let moder = Keys::generate();
4575 let stranger = Keys::generate();
4576 let (admin_rid, mod_rid) = ("a1".repeat(32), "b2".repeat(32));
4577 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;
4578 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;
4579 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![admin_rid], 1).await;
4580 publish_grant(&relay, &community, &owner, &moder.public_key(), vec![mod_rid], 1).await;
4581 publish_banlist(&relay, &community, &moder, &[admin.public_key().to_hex(), owner.public_key().to_hex(), stranger.public_key().to_hex()], 1).await;
4582
4583 let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
4584 assert!(!authority.banned.contains(&admin.public_key().to_hex()), "a mod cannot ban a superior admin");
4585 assert!(!authority.banned.contains(&owner.public_key().to_hex()), "nobody can ban the owner");
4586 assert!(authority.banned.contains(&stranger.public_key().to_hex()), "the mod CAN ban a lower-ranked member");
4587 }
4588
4589 #[tokio::test]
4590 async fn an_unauthorized_higher_banlist_cannot_unban() {
4591 let (_tmp, _guard, owner) = init_test_db();
4595 let relay = MemoryRelay::new();
4596 let community = create_community(&relay, "NoUnban", vec!["wss://r".into()], None).await.unwrap();
4597 let target = "cc".repeat(32);
4598 publish_banlist(&relay, &community, &owner, &[target.clone()], 1).await;
4599 let session = SessionGuard::capture();
4600 follow_control(&relay, &community, &session).await.unwrap(); let rogue = Keys::generate();
4603 publish_banlist(&relay, &community, &rogue, &[], 2).await; let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
4605 assert!(authority.banned.contains(&target), "an unauthorized higher banlist cannot un-ban");
4606 }
4607
4608 #[tokio::test]
4609 async fn the_community_list_syncs_a_membership_to_a_fresh_device() {
4610 let (_tmp, _guard, _owner) = init_test_db();
4613 let relay = MemoryRelay::new();
4614 let relays = vec!["wss://r".to_string()];
4615 let community = create_community(&relay, "Synced", relays.clone(), None).await.unwrap();
4616 crate::db::community::delete_community(&crate::simd::hex::bytes_to_hex_32(&community.id().0)).unwrap();
4617 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none());
4618
4619 let rehydrated = sync_community_list(&relay, &relays).await.unwrap();
4620 assert_eq!(rehydrated.len(), 1, "the left-behind membership rehydrates");
4621 assert_eq!(rehydrated[0].id().0, community.id().0);
4622 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_some(), "and is now held locally");
4623 }
4624
4625 #[tokio::test]
4626 async fn a_leave_tombstones_the_membership_so_sync_does_not_rejoin() {
4627 let (_tmp, _guard, _owner) = init_test_db();
4628 let relay = MemoryRelay::new();
4629 let relays = vec!["wss://r".to_string()];
4630 let community = create_community(&relay, "Left", relays.clone(), None).await.unwrap();
4631 leave_community(&relay, &community).await.unwrap(); let rehydrated = sync_community_list(&relay, &relays).await.unwrap();
4634 assert!(rehydrated.is_empty(), "a tombstoned membership is not rejoined on sync");
4635 }
4636
4637 #[tokio::test]
4638 async fn accepting_the_same_bundle_twice_is_idempotent() {
4639 let (bed, owner, member) = TestBed::new();
4643 bed.swap_to(&owner);
4644 let community = create_community(&bed.relay, "Idem", bed.relays.clone(), None).await.unwrap();
4645 create_public_channel(&bed.relay, &community, "extra").await.unwrap();
4646 let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4647 let bundle = serde_json::to_string(&bundle_of(&community, Some(owner.keys.public_key()), None, None)).unwrap();
4648
4649 bed.swap_to(&member);
4650 let first = accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
4651 let channels_after_first = first.channels.len();
4652 let root_after_first = first.community_root;
4653
4654 let second = accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
4656 assert_eq!(second.id().0, first.id().0, "same community_id");
4657 assert_eq!(second.channels.len(), channels_after_first, "no duplicate channels on re-accept");
4658 assert_eq!(second.community_root, root_after_first, "root unchanged");
4659
4660 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4662 assert_eq!(reloaded.channels.len(), channels_after_first, "the DB holds one clean channel set");
4663 assert_eq!(crate::db::community::list_community_ids().unwrap().iter().filter(|id| id.0 == community.id().0).count(), 1, "exactly one community row");
4664 }
4665
4666 #[tokio::test]
4667 async fn a_severed_member_can_be_unbanned_and_re_admitted() {
4668 let (bed, owner, member) = TestBed::new();
4673 bed.swap_to(&owner);
4674 let mut community = create_community(&bed.relay, "Redeemable", bed.relays.clone(), None).await.unwrap();
4675 let general = community.channels[0].id;
4676 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
4677 send_message(&bed.relay, &community, &general, "owner: welcome").await.unwrap();
4678
4679 bed.swap_to(&member);
4680 let invite = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
4681 let joined = accept_direct_invite(&bed.relay, &invite).await.unwrap();
4682 assert!(texts_in(&bed.relay, &joined, &general).await.contains(&"owner: welcome".to_string()));
4683
4684 bed.swap_to(&owner);
4686 set_banlist(&bed.relay, &community, &[member.keys.public_key().to_hex()]).await.unwrap();
4687 grant_roles(&bed.relay, &community, &member.keys.public_key(), vec![]).await.unwrap();
4688 community = refound_community(&bed.relay, &community, &[member.keys.public_key()]).await.unwrap();
4689 assert_eq!(community.root_epoch, Epoch(1));
4690 send_message(&bed.relay, &community, &general, "owner: after the ban").await.unwrap();
4691
4692 bed.swap_to(&member);
4694 let session = SessionGuard::capture();
4695 assert!(follow_rekeys(&bed.relay, &joined, &session).await.unwrap().self_removed, "the member is cryptographically severed");
4696
4697 bed.swap_to(&owner);
4701 set_banlist(&bed.relay, &community, &[]).await.unwrap();
4702 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4703 assert_eq!(community.root_epoch, Epoch(1), "the owner's bundle carries epoch 1");
4704 let fresh_bundle = serde_json::to_string(&bundle_of(&community, Some(owner.keys.public_key()), None, None)).unwrap();
4705
4706 bed.swap_to(&member);
4708 let rejoined = accept_parked_invite(&bed.relay, &fresh_bundle, None).await.unwrap();
4709 assert_eq!(rejoined.root_epoch, Epoch(1), "rejoined at the current epoch");
4710 assert_eq!(rejoined.community_root, community.community_root, "holds the NEW root");
4711 let seen = texts_in(&bed.relay, &rejoined, &general).await;
4712 assert!(seen.contains(&"owner: after the ban".to_string()), "reads post-ban history with the new root");
4713 send_message(&bed.relay, &rejoined, &general, "member: i am back").await.unwrap();
4714
4715 bed.swap_to(&owner);
4716 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4717 assert!(
4718 texts_in(&bed.relay, &community, &general).await.contains(&"member: i am back".to_string()),
4719 "the re-admitted member converses again at the new epoch"
4720 );
4721 let members = memberlist(&bed.relay, &community).await.unwrap();
4723 assert!(members.contains(&member.keys.public_key()), "the re-admitted member is in the list");
4724 }
4725
4726 #[tokio::test]
4727 async fn dissolution_blocks_a_join() {
4728 let (bed, owner, member) = TestBed::new();
4731 bed.swap_to(&owner);
4732 let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
4733 let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
4734 let bundle_json = serde_json::to_string(&bundle).unwrap();
4735 dissolve_community(&bed.relay, &community).await.unwrap();
4736 assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the owner's local hold is sealed");
4737
4738 bed.swap_to(&member);
4739 let err = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap_err();
4740 assert!(err.contains("dissolved"), "a join refuses a dissolved community: {err}");
4741 }
4742
4743 #[tokio::test]
4744 async fn only_the_owner_can_dissolve() {
4745 let (bed, owner, member) = TestBed::new();
4746 bed.swap_to(&owner);
4747 let community = create_community(&bed.relay, "Mine", bed.relays.clone(), None).await.unwrap();
4748 bed.swap_to(&member);
4749 assert!(dissolve_community(&bed.relay, &community).await.is_err(), "only the owner can dissolve");
4750 assert!(!is_dissolved(&bed.relay, &community).await, "and no tombstone was published");
4751 }
4752
4753 #[tokio::test]
4754 async fn a_foreign_tombstone_is_not_death() {
4755 let (_tmp, _guard, _owner) = init_test_db();
4758 let relay = MemoryRelay::new();
4759 let community = create_community(&relay, "Safe", vec!["wss://r".into()], None).await.unwrap();
4760 let rogue = Keys::generate();
4761 let rumor = crate::community::v2::dissolution::dissolved_tombstone_rumor(rogue.public_key(), community.id(), 1_000);
4762 let wrap = crate::community::v2::dissolution::seal_dissolved(&rumor, community.id(), &rogue, Timestamp::from_secs(1_000)).unwrap();
4763 relay.publish(&wrap, &community.relays).await.unwrap();
4764 assert!(!is_dissolved(&relay, &community).await, "a foreign-signed tombstone is not death");
4765 }
4766
4767 #[tokio::test]
4768 async fn a_public_channel_reads_history_across_a_refounding() {
4769 let (_tmp, _guard, _owner) = init_test_db();
4773 let relay = MemoryRelay::new();
4774 let community = create_community(&relay, "History", vec!["wss://r".into()], None).await.unwrap();
4775 let general = community.channels[0].id;
4776 send_message(&relay, &community, &general, "before the refounding").await.unwrap();
4777
4778 let refounded = refound_community(&relay, &community, &[]).await.unwrap();
4779 assert_eq!(refounded.root_epoch, Epoch(1), "the epoch advanced");
4780 send_message(&relay, &refounded, &general, "after the refounding").await.unwrap();
4781
4782 let texts = texts_in(&relay, &refounded, &general).await;
4783 assert!(texts.contains(&"before the refounding".to_string()), "the epoch-0 message is still readable");
4784 assert!(texts.contains(&"after the refounding".to_string()), "the epoch-1 message reads too");
4785 }
4786
4787 #[tokio::test]
4788 async fn refounding_aborts_when_control_state_is_withheld() {
4789 let (_tmp, _guard, owner) = init_test_db();
4793 let relay = MemoryRelay::new();
4794 let community = create_community(&relay, "Withheld", vec!["wss://good".into()], None).await.unwrap();
4795 publish_banlist(&relay, &community, &owner, &["cc".repeat(32)], 1).await;
4796 let session = SessionGuard::capture();
4797 follow_control(&relay, &community, &session).await.unwrap(); let mut moved = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4802 moved.relays = vec!["wss://empty".into()];
4803 crate::db::community::save_community_v2(&moved).unwrap();
4804
4805 let err = refound_community(&relay, &moved, &[]).await.unwrap_err();
4806 assert!(err.contains("was not served"), "a withheld control head aborts the refounding: {err}");
4807 assert_eq!(
4808 crate::db::community::load_community_v2(community.id()).unwrap().unwrap().root_epoch,
4809 Epoch(0),
4810 "the epoch did NOT advance (zero published state)"
4811 );
4812 }
4813
4814 #[tokio::test]
4815 async fn refounding_rolls_the_root_and_severs_a_removed_member() {
4816 let (bed, owner, member) = TestBed::new();
4819 bed.swap_to(&owner);
4820 let community = create_community(&bed.relay, "Refound", bed.relays.clone(), None).await.unwrap();
4821 let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
4822 let bundle_json = serde_json::to_string(&bundle).unwrap();
4823 bed.swap_to(&member);
4824 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
4825
4826 bed.swap_to(&owner);
4827 let refounded = refound_community(&bed.relay, &community, &[member.keys.public_key()]).await.unwrap();
4828 assert_eq!(refounded.root_epoch, Epoch(1), "the epoch advanced");
4829 assert_ne!(refounded.community_root, community.community_root, "the base root rolled");
4830 let session = SessionGuard::capture();
4832 assert_eq!(
4833 crate::db::community::load_community_v2(community.id()).unwrap().unwrap().root_epoch,
4834 Epoch(1),
4835 "the owner committed the new epoch"
4836 );
4837
4838 bed.swap_to(&member);
4840 let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
4841 assert!(follow.self_removed, "the removed member is cut by the re-founding");
4842 }
4843
4844 #[tokio::test]
4845 async fn only_the_owner_re_founds_even_a_ban_holding_admin_cannot() {
4846 let (bed, owner, member) = TestBed::new();
4850 bed.swap_to(&owner);
4851 let community = create_community(&bed.relay, "Guarded", bed.relays.clone(), None).await.unwrap();
4852 let rid = "b0".repeat(32);
4853 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
4854 publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
4855 let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
4856 let bundle_json = serde_json::to_string(&bundle).unwrap();
4857 bed.swap_to(&member);
4858 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
4859 assert!(refound_community(&bed.relay, &joined, &[owner.keys.public_key()]).await.is_err(), "a non-owner BAN-holder can't re-found");
4860 }
4861
4862 #[tokio::test]
4863 async fn a_retried_refounding_reuses_the_same_root() {
4864 let (_tmp, _guard, _owner) = init_test_db();
4867 let relay = MemoryRelay::new();
4868 let community = create_community(&relay, "Retry", vec!["wss://r".into()], None).await.unwrap();
4869 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4870 let first = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap();
4871 let second = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap();
4872 assert_eq!(first, second, "a retry reuses the archived root, never double-mints");
4873 }
4874
4875 #[tokio::test]
4876 async fn minting_a_link_makes_the_community_public_and_revoke_makes_it_private() {
4877 let (_tmp, _guard, _owner) = init_test_db();
4880 let relay = MemoryRelay::new();
4881 let community = create_community(&relay, "Invitable", vec!["wss://r".into()], None).await.unwrap();
4882 assert!(!community_is_public(&relay, &community).await, "a fresh community is Private");
4883
4884 let minted = mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
4885 assert!(community_is_public(&relay, &community).await, "a live link makes it Public");
4886 let list = fetch_invite_list(&relay, &community.relays).await.expect("the 13303 list was published");
4887 assert_eq!(list.entries.len(), 1, "the minted link is recorded across devices");
4888
4889 let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
4890 revoke_public_link(&relay, &community, &token_hex).await.unwrap();
4891 assert!(!community_is_public(&relay, &community).await, "retiring the last link makes it Private again");
4892 let after = fetch_invite_list(&relay, &community.relays).await.unwrap();
4893 assert!(after.entries.is_empty() && after.tombstones.len() == 1, "the link is tombstoned in the invite list");
4894 }
4895
4896 #[tokio::test]
4897 async fn a_registry_from_a_non_create_invite_holder_does_not_make_it_public() {
4898 let (_tmp, _guard, owner) = init_test_db();
4900 let relay = MemoryRelay::new();
4901 let community = create_community(&relay, "Gated", vec!["wss://r".into()], None).await.unwrap();
4902 let rogue = Keys::generate();
4903 let eid = crate::community::v2::derive::invite_links_locator(community.id(), &rogue.public_key().to_bytes());
4905 let content = crate::community::v2::invite::build_registry_content(&[Keys::generate().public_key()]);
4906 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
4907 let rumor = control::build_edition_rumor(rogue.public_key(), vsk::INVITE_LINKS, &eid, 1, None, &content, 1_000, None);
4908 let (wrap, _) = control::seal_control_edition(&rumor, &group, &rogue, Timestamp::from_secs(1_000)).unwrap();
4909 relay.publish(&wrap, &community.relays).await.unwrap();
4910 let _ = owner;
4911 assert!(!community_is_public(&relay, &community).await, "a non-CREATE_INVITE registry is ignored");
4912 }
4913
4914 #[tokio::test]
4915 async fn full_lifecycle_e2e() {
4916 let (bed, owner, member) = TestBed::new();
4922
4923 bed.swap_to(&owner);
4924 let community = create_community(&bed.relay, "Lifecycle", bed.relays.clone(), None).await.unwrap();
4925 let general = community.channels[0].id;
4926 send_message(&bed.relay, &community, &general, "owner: welcome").await.unwrap();
4927
4928 let _minted = mint_public_link(&bed.relay, &community, "https://x", None, None).await.unwrap();
4930 assert!(community_is_public(&bed.relay, &community).await, "a live link makes it Public");
4931
4932 let rid = "aa".repeat(32);
4934 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
4935 publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
4936
4937 let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
4939 let bundle_json = serde_json::to_string(&bundle).unwrap();
4940 bed.swap_to(&member);
4941 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
4942 assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: welcome"]);
4943 publish_community_meta(&bed.relay, &joined, &member.keys, "Lifecycle Renamed", 2).await;
4945
4946 bed.swap_to(&owner);
4948 let session = SessionGuard::capture();
4949 let updated = follow_control(&bed.relay, &community, &session).await.unwrap().expect("the admin edit folds");
4950 assert_eq!(updated.name, "Lifecycle Renamed", "an authorized admin's metadata edit is honored");
4951
4952 set_banlist(&bed.relay, &updated, &[member.keys.public_key().to_hex()]).await.unwrap();
4954 grant_roles(&bed.relay, &updated, &member.keys.public_key(), vec![]).await.unwrap();
4955 let refounded = refound_community(&bed.relay, &updated, &[member.keys.public_key()]).await.unwrap();
4956 assert_eq!(refounded.root_epoch, Epoch(1), "the ban rolled the root");
4957 let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
4959 assert!(post.banned.contains(&member.keys.public_key().to_hex()), "the ban survives the re-founding");
4960 assert!(
4962 texts_in(&bed.relay, &refounded, &general).await.contains(&"owner: welcome".to_string()),
4963 "pre-refounding history stays readable"
4964 );
4965
4966 bed.swap_to(&member);
4968 let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
4969 assert!(follow.self_removed, "the banned member is cryptographically cut");
4970
4971 bed.swap_to(&owner);
4973 dissolve_community(&bed.relay, &refounded).await.unwrap();
4974 assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the community is sealed");
4975 }
4976
4977 #[tokio::test]
4983 async fn a_forged_edition_cannot_suppress_a_role_across_a_refounding() {
4984 let (bed, owner, member) = TestBed::new();
4989 let attacker = Keys::generate();
4990 bed.swap_to(&owner);
4991 let community = create_community(&bed.relay, "NoSuppress", bed.relays.clone(), None).await.unwrap();
4992 let rid = crate::simd::hex::bytes_to_hex_32(&[0xa1; 32]);
4993 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
4994 publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid.clone()], 1).await;
4995 let session = SessionGuard::capture();
4997 follow_control(&bed.relay, &community, &session).await.unwrap();
4998 assert!(fetch_authority(&bed.relay, &community).await.roles.is_admin(&member.keys.public_key().to_hex()), "member is admin pre-attack");
4999
5000 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;
5002
5003 let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
5005 assert_eq!(refounded.root_epoch, Epoch(1), "root rolled");
5006
5007 let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
5009 assert!(post.roles.is_admin(&member.keys.public_key().to_hex()), "the admin role survives the refounding despite the forgery");
5010 }
5011
5012 #[tokio::test]
5013 async fn memberlist_survives_a_refounding_via_the_snapshot() {
5014 let (bed, owner, member) = TestBed::new();
5017 bed.swap_to(&owner);
5018 let community = create_community(&bed.relay, "Snapshot", bed.relays.clone(), None).await.unwrap();
5019
5020 let bundle = serde_json::to_string(&bundle_of(&community, Some(owner.keys.public_key()), None, None)).unwrap();
5022 bed.swap_to(&member);
5023 accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
5024 bed.swap_to(&owner);
5025 assert!(memberlist(&bed.relay, &community).await.unwrap().contains(&member.keys.public_key()), "member present pre-refound");
5026
5027 let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
5029 assert_eq!(refounded.root_epoch, Epoch(1), "the root rolled");
5030
5031 let members = memberlist(&bed.relay, &refounded).await.unwrap();
5033 assert!(members.contains(&member.keys.public_key()), "a silent survivor stays a member after the refounding");
5034 assert!(members.contains(&owner.keys.public_key()), "owner is always a member");
5035 }
5036
5037 #[tokio::test]
5038 async fn e2e_two_accounts_channels_converse_moderate() {
5039 use crate::community::v2::inbound::{apply_chat_to_state, persist_chat};
5040 use nostr_sdk::prelude::ToBech32;
5041 let (bed, a, b) = TestBed::new();
5042 let (a_npub, b_npub) = (a.keys.public_key().to_bech32().unwrap(), b.keys.public_key().to_bech32().unwrap());
5043 let (a_hex, b_hex) = (a.keys.public_key().to_hex(), b.keys.public_key().to_hex());
5044 println!("\n===== Concord v2 deep e2e =====");
5045 println!("[acct] A (owner) = {a_npub}");
5046 println!("[acct] B (member) = {b_npub}");
5047
5048 bed.swap_to(&a);
5050 let mut community = create_community(&bed.relay, "Deep E2E", bed.relays.clone(), None).await.unwrap();
5051 let general = community.channels[0].id;
5052 println!("[create] community {} · #general {}", crate::simd::hex::bytes_to_hex_32(&community.id().0), crate::simd::hex::bytes_to_hex_32(&general.0));
5053
5054 let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
5058 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5059 let priv_ch = community.channel(&priv_id).unwrap();
5060 assert!(priv_ch.private && priv_ch.key.is_some() && priv_ch.epoch == Epoch(1), "born-private: keyed at epoch 1");
5061 println!("[channel] +private #mods {} (native create: key over the rekey plane)", crate::simd::hex::bytes_to_hex_32(&priv_id.0));
5062
5063 let announcements = create_public_channel(&bed.relay, &community, "announcements").await.unwrap();
5065 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5066 let random = create_public_channel(&bed.relay, &community, "random").await.unwrap();
5067 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5068 println!("[channel] +public #announcements {} · #random {}", crate::simd::hex::bytes_to_hex_32(&announcements.0), crate::simd::hex::bytes_to_hex_32(&random.0));
5069 assert_eq!(community.channels.len(), 4, "general + mods + announcements + random");
5070
5071 let m1 = send_message(&bed.relay, &community, &general, "A: welcome to the deep e2e").await.unwrap();
5073 send_message(&bed.relay, &community, &announcements, "A: read the rules").await.unwrap();
5074 send_message(&bed.relay, &community, &priv_id, "A: mods-only channel").await.unwrap();
5075 println!("[msg] A posted in #general / #announcements / #mods");
5076
5077 let admin_rid = crate::simd::hex::bytes_to_hex_32(&[0xa1; 32]);
5079 publish_role(&bed.relay, &community, &a.keys, &admin_role(&admin_rid, Permissions::ADMIN_ALL), 1).await;
5080 publish_grant(&bed.relay, &community, &a.keys, &b.keys.public_key(), vec![admin_rid], 1).await;
5081 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
5082 assert!(community_is_public(&bed.relay, &community).await, "a live link makes it Public");
5083 println!("[invite] granted B @admin · minted link {}", link.url);
5084
5085 let bundle_json = serde_json::to_string(&bundle_of(&community, Some(a.keys.public_key()), None, None)).unwrap();
5086 bed.swap_to(&b);
5087 let mut b_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
5088 println!("[join] B joined; sees {} channels", b_view.channels.len());
5089 assert_eq!(b_view.channels.len(), 4, "B receives all four channels (incl. the private one's key) in the bundle");
5090 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");
5091 assert!(texts_in(&bed.relay, &b_view, &general).await.contains(&"A: welcome to the deep e2e".to_string()), "B reads A's #general history");
5092 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");
5093 let session_b = SessionGuard::capture();
5096 if let Some(fresh) = follow_control(&bed.relay, &b_view, &session_b).await.unwrap() {
5097 b_view = fresh;
5098 }
5099 println!("[follow] B folded control (roster persisted: B is @admin)");
5100
5101 send_message(&bed.relay, &b_view, &general, "B: thanks, glad to be here").await.unwrap();
5103 send_message(&bed.relay, &b_view, &priv_id, "B: mods checking in").await.unwrap();
5104 println!("[msg] B replied in #general + #mods");
5105 let my_pk = b.keys.public_key();
5108 let gh = crate::simd::hex::bytes_to_hex_32(&general.0);
5109 for f in fetch_channel(&bed.relay, &b_view, &general, 100).await.unwrap() {
5110 let outcome = { let mut st = crate::state::STATE.lock().await; apply_chat_to_state(&mut st, &f.event, &gh, &my_pk) };
5111 if let Some(o) = outcome { persist_chat(&gh, &o).await; }
5112 }
5113 assert!(crate::db::events::event_exists(&m1).unwrap(), "A's message persisted into B's shared store (get_messages backfill)");
5114 println!("[persist] #general history persisted into the shared events store");
5115
5116 send_reaction(&bed.relay, &b_view, &general, &m1, &a_hex, super::super::kind::MESSAGE, "🔥", None).await.unwrap();
5118 bed.swap_to(&a);
5119 let m_edit = send_message(&bed.relay, &community, &general, "A: this will be edited").await.unwrap();
5120 send_edit(&bed.relay, &community, &general, &m_edit, "A: edited!").await.unwrap();
5121 let m_del = send_message(&bed.relay, &community, &general, "A: this will be deleted").await.unwrap();
5122 send_delete(&bed.relay, &community, &general, &m_del, super::super::kind::MESSAGE).await.unwrap();
5123 println!("[ops] reaction + edit + delete round-tripped");
5124
5125 bed.swap_to(&b);
5127 let bugs = create_public_channel(&bed.relay, &b_view, "bug-reports").await.unwrap();
5128 println!("[channel] B(admin) +public #bug-reports {}", crate::simd::hex::bytes_to_hex_32(&bugs.0));
5129 bed.swap_to(&a);
5130 let session = SessionGuard::capture();
5131 if let Some(updated) = follow_control(&bed.relay, &community, &session).await.unwrap() {
5132 community = updated;
5133 }
5134 assert!(community.channels.iter().any(|c| c.id.0 == bugs.0), "A folds in B's authorized new channel");
5135 println!("[follow] A folded in B's #bug-reports (now {} channels)", community.channels.len());
5136
5137 let vault = create_private_channel(&bed.relay, &community, "vault").await.unwrap();
5141 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5142 send_message(&bed.relay, &community, &vault, "A: vault is open").await.unwrap();
5143 println!("[channel] +private #vault {} (B is a live member — delivery via rekey plane)", crate::simd::hex::bytes_to_hex_32(&vault.0));
5144 bed.swap_to(&b);
5145 let session_b2 = SessionGuard::capture();
5146 if let Some(fresh) = follow_control(&bed.relay, &b_view, &session_b2).await.unwrap() {
5147 b_view = fresh;
5148 }
5149 let ch = b_view.channel(&vault).expect("B recorded the announced private channel");
5150 assert!(ch.private && ch.key.is_none() && ch.epoch == Epoch(0), "B's record is keyless at cursor 0");
5151 let rf = follow_rekeys(&bed.relay, &b_view, &session_b2).await.unwrap();
5152 b_view = rf.updated.expect("the rekey walk adopts the creation delivery");
5153 let ch = b_view.channel(&vault).expect("still recorded");
5154 assert!(ch.key.is_some() && ch.epoch == Epoch(1), "B adopted the epoch-1 key from the creation crate");
5155 assert!(
5156 texts_in(&bed.relay, &b_view, &vault).await.contains(&"A: vault is open".to_string()),
5157 "B reads the private history with the ADOPTED key"
5158 );
5159 send_message(&bed.relay, &b_view, &vault, "B: in the vault").await.unwrap();
5160 bed.swap_to(&a);
5161 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5162 assert!(
5163 texts_in(&bed.relay, &community, &vault).await.contains(&"B: in the vault".to_string()),
5164 "A reads B's reply on the natively-created private channel"
5165 );
5166 println!("[private] B adopted #vault via rekey plane; two-way private conversation verified");
5167
5168 let members = memberlist(&bed.relay, &community).await.unwrap();
5170 let member_hexes: std::collections::BTreeSet<String> = members.iter().map(|m| m.to_hex()).collect();
5171 assert!(member_hexes.contains(&a_hex) && member_hexes.contains(&b_hex), "A + B both in the memberlist");
5172 println!("[members] {} members: A + B present", members.len());
5173
5174 set_banlist(&bed.relay, &community, &[b_hex.clone()]).await.unwrap();
5176 grant_roles(&bed.relay, &community, &b.keys.public_key(), vec![]).await.unwrap();
5177 let refounded = refound_community(&bed.relay, &community, &[b.keys.public_key()]).await.unwrap();
5178 assert_eq!(refounded.root_epoch, Epoch(1), "the ban rolled the root");
5179 let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
5180 assert!(post.banned.contains(&b_hex), "the ban survives the refounding");
5181 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");
5182 assert!(
5183 texts_in(&bed.relay, &refounded, &priv_id).await.iter().any(|t| t == "A: mods-only channel"),
5184 "PRIVATE history reads across the channel's own rotation (per-channel multi-epoch archive)"
5185 );
5186 println!("[ban] B banned; root rolled to epoch 1; ban survives; pre-ban history intact (public + private)");
5187 bed.swap_to(&b);
5189 let session_b3 = SessionGuard::capture();
5190 assert!(follow_rekeys(&bed.relay, &b_view, &session_b3).await.unwrap().self_removed, "B is cryptographically cut by the ban-refound");
5191 println!("[ban] B's rekey-follow: self_removed = true (severed)");
5192
5193 bed.swap_to(&a);
5195 set_banlist(&bed.relay, &refounded, &[]).await.unwrap();
5196 let after_unban = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
5197 assert!(!after_unban.banned.contains(&b_hex), "the unban clears B from the banlist");
5198 println!("[unban] B removed from the banlist (re-invitable)");
5199
5200 dissolve_community(&bed.relay, &refounded).await.unwrap();
5202 assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the community is sealed");
5203 println!("[dissolve] community sealed (read-only)\n===== e2e PASS =====\n");
5204 }
5205
5206 #[tokio::test]
5212 #[ignore]
5213 async fn live_e2e_two_accounts() {
5214 use crate::community::transport::LiveTransport;
5215 use nostr_sdk::prelude::{ClientBuilder, RelayOptions, ToBech32};
5216
5217 let relay = std::env::var("VECTOR_E2E_RELAY").unwrap_or_else(|_| "wss://jskitty.com/nostr".to_string());
5218 let relays = vec![relay.clone()];
5219 let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
5220 crate::db::close_database();
5221 crate::db::clear_id_caches();
5222 let tmp = tempfile::tempdir().unwrap();
5223 crate::db::set_app_data_dir(tmp.path().to_path_buf());
5224
5225 let a = std::env::var("VECTOR_E2E_NSEC_A").ok().and_then(|n| Keys::parse(&n).ok()).unwrap_or_else(Keys::generate);
5227 let b = std::env::var("VECTOR_E2E_NSEC_B").ok().and_then(|n| Keys::parse(&n).ok()).unwrap_or_else(Keys::generate);
5228
5229 let log = |line: String| {
5230 println!("{line}");
5231 if let Ok(p) = std::env::var("VECTOR_E2E_LOG") {
5232 use std::io::Write;
5233 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&p) {
5234 let _ = writeln!(f, "{line}");
5235 }
5236 }
5237 };
5238 log(format!("===== LIVE Concord v2 e2e on {relay} ====="));
5239 log(format!("VECTOR_E2E_NSEC_A={} ({})", a.secret_key().to_bech32().unwrap(), a.public_key().to_bech32().unwrap()));
5240 log(format!("VECTOR_E2E_NSEC_B={} ({})", b.secret_key().to_bech32().unwrap(), b.public_key().to_bech32().unwrap()));
5241
5242 for k in [&a, &b] {
5243 let npub = k.public_key().to_bech32().unwrap();
5244 std::fs::create_dir_all(tmp.path().join(&npub)).unwrap();
5245 crate::db::set_current_account(npub.clone()).unwrap();
5246 crate::db::init_database(&npub).unwrap();
5247 }
5248 let client = ClientBuilder::new().signer(a.clone()).build();
5251 client.pool().add_relay(relay.as_str(), RelayOptions::default()).await.ok();
5252 client.connect().await;
5253 crate::state::set_nostr_client(client);
5254 let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(15));
5255 let become_acct = |k: &Keys| {
5256 let npub = k.public_key().to_bech32().unwrap();
5257 crate::db::set_current_account(npub.clone()).unwrap();
5258 crate::db::init_database(&npub).unwrap();
5259 crate::db::clear_id_caches();
5260 crate::state::MY_SECRET_KEY.store_from_keys(k, &[]);
5261 crate::state::set_my_public_key(k.public_key());
5262 };
5263 let settle = || tokio::time::sleep(std::time::Duration::from_secs(2));
5264
5265 become_acct(&a);
5267 let mut community = create_community(&transport, "Live E2E", relays.clone(), None).await.expect("create");
5268 let general = community.channels[0].id;
5269 log(format!("[create] community {} · #general {}", crate::simd::hex::bytes_to_hex_32(&community.id().0), crate::simd::hex::bytes_to_hex_32(&general.0)));
5270 send_message(&transport, &community, &general, "A: live hello").await.expect("send");
5271 let ann = create_public_channel(&transport, &community, "announcements").await.expect("channel");
5272 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5273 log(format!("[channel] +public #announcements {}", crate::simd::hex::bytes_to_hex_32(&ann.0)));
5274 grant_admin(&transport, &community, &b.public_key()).await.expect("grant admin");
5275 let link = mint_public_link(&transport, &community, "https://vectorapp.io", None, None).await.expect("mint");
5276 log(format!("[invite] B granted @admin · link {}", link.url));
5277 let bundle_json = serde_json::to_string(&bundle_of(&community, Some(a.public_key()), None, None)).unwrap();
5278 settle().await;
5279
5280 become_acct(&b);
5282 let b_view = accept_parked_invite(&transport, &bundle_json, None).await.expect("join");
5283 log(format!("[join] B joined; {} channels", b_view.channels.len()));
5284 settle().await;
5285 let page = fetch_channel(&transport, &b_view, &general, 50).await.expect("fetch");
5286 let seen: Vec<String> = page.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
5287 log(format!("[read] B sees #general: {seen:?}"));
5288 assert!(seen.iter().any(|t| t == "A: live hello"), "B reads A's message over the real relay");
5289 send_message(&transport, &b_view, &general, "B: live reply").await.expect("reply");
5290
5291 let hello = page.iter().find(|f| f.event.opened().rumor.content == "A: live hello").expect("A's message");
5295 let hello_id = hello.event.opened().rumor_id.to_hex();
5296 let bkeys = crate::state::MY_SECRET_KEY.to_keys().unwrap();
5297 let cgroup = channel_group_key(&b_view.community_root, &general, b_view.root_epoch);
5298 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());
5299 let (reply_wrap, _) = chat::seal_chat_rumor(&reply_rumor, &cgroup, &bkeys, Timestamp::from_secs(now_ms() / 1000), false).expect("seal 1111");
5300 transport.publish(&reply_wrap, &b_view.relays).await.expect("publish 1111");
5301 log("[thread] B published a kind-1111 threaded reply to A's message".to_string());
5302 settle().await;
5303
5304 become_acct(&a);
5306 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5307 let a_page = fetch_channel(&transport, &community, &general, 50).await.expect("A fetch");
5308 let thread = a_page.iter().find(|f| f.event.opened().rumor.content == "B: threaded reply to hello").expect("A sees the 1111");
5309 if let chat::ChatEvent::Message { reply_to, opened, .. } = &thread.event {
5310 assert_eq!(opened.rumor.kind.as_u16(), super::super::kind::COMMENT, "wire kind preserved as 1111");
5311 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");
5312 } else {
5313 panic!("the 1111 parsed as a Message");
5314 }
5315 log("[thread] A read B's threaded reply, parent resolved — cross-client 1111 interop OK".to_string());
5316 become_acct(&b);
5317 settle().await;
5318
5319 become_acct(&a);
5323 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5324 let vault = create_private_channel(&transport, &community, "vault").await.expect("private channel");
5325 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5326 send_message(&transport, &community, &vault, "A: vault live").await.expect("vault send");
5327 log(format!("[channel] +private #vault {} (key delivered over the rekey plane)", crate::simd::hex::bytes_to_hex_32(&vault.0)));
5328 settle().await;
5329
5330 become_acct(&b);
5331 let session_b = SessionGuard::capture();
5332 let mut b_view = crate::db::community::load_community_v2(b_view.id()).unwrap().unwrap();
5333 if let Some(fresh) = follow_control(&transport, &b_view, &session_b).await.expect("B control follow") {
5334 b_view = fresh;
5335 }
5336 if let Some(fresh) = follow_rekeys(&transport, &b_view, &session_b).await.expect("B rekey follow").updated {
5337 b_view = fresh;
5338 }
5339 let vch = b_view.channel(&vault).expect("B folded the vault");
5340 assert!(vch.key.is_some() && vch.epoch == Epoch(1), "B adopted the vault key from the live rekey plane");
5341 let vseen = texts_in(&transport, &b_view, &vault).await;
5342 log(format!("[read] B sees #vault: {vseen:?}"));
5343 assert!(vseen.iter().any(|t| t == "A: vault live"), "B reads the private channel with the ADOPTED key");
5344 send_message(&transport, &b_view, &vault, "B: in the live vault").await.expect("vault reply");
5345 settle().await;
5346
5347 become_acct(&a);
5348 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5349 assert!(
5350 texts_in(&transport, &community, &vault).await.iter().any(|t| t == "B: in the live vault"),
5351 "A reads B's private reply"
5352 );
5353 log("[private] two-way #vault conversation over the live relay".to_string());
5354
5355 set_banlist(&transport, &community, &[b.public_key().to_hex()]).await.expect("banlist");
5357 grant_roles(&transport, &community, &b.public_key(), vec![]).await.expect("strip");
5358 let refounded = refound_community(&transport, &community, &[b.public_key()]).await.expect("refound");
5359 log(format!("[ban] B banned; root → epoch {}", refounded.root_epoch.0));
5360 settle().await;
5361 dissolve_community(&transport, &refounded).await.expect("dissolve");
5362 log("[dissolve] community sealed".to_string());
5363 log("===== LIVE e2e PASS =====".to_string());
5364 }
5365
5366 #[tokio::test]
5367 async fn an_offline_member_learns_of_a_dissolution_on_catch_up() {
5368 let (bed, owner, member) = TestBed::new();
5372 bed.swap_to(&owner);
5373 let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
5374 let general = community.channels[0].id;
5375 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
5376
5377 bed.swap_to(&member);
5378 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
5379 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
5380
5381 bed.swap_to(&owner);
5383 dissolve_community(&bed.relay, &community).await.unwrap();
5384
5385 bed.swap_to(&member);
5387 let session = SessionGuard::capture();
5388 let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
5389 assert!(follow.dissolved, "the catch-up surfaces the tombstone");
5390 assert!(!follow.self_removed && follow.updated.is_none());
5391 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
5392 assert!(crate::db::community::get_community_dissolved(&cid_hex).unwrap(), "sealed read-only locally");
5393 let err = send_message(&bed.relay, &joined, &general, "into the void").await.unwrap_err();
5394 assert!(err.contains("dissolved"), "sends refuse a grave: {err}");
5395 let again = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
5397 assert!(again.dissolved && again.updated.is_none());
5398 }
5399
5400 #[tokio::test]
5401 async fn a_wide_community_survives_refoundings_and_an_offline_member_converges() {
5402 const PRIV_CHANNELS: usize = 6;
5407 let (bed, owner, member) = TestBed::new();
5408 bed.swap_to(&owner);
5409 let mut community = create_community(&bed.relay, "Wide", bed.relays.clone(), None).await.unwrap();
5410 let mut priv_ids = Vec::new();
5411 for i in 0..PRIV_CHANNELS {
5412 let id = create_private_channel(&bed.relay, &community, &format!("priv{i}")).await.unwrap();
5413 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5414 send_message(&bed.relay, &community, &id, &format!("priv{i} epoch0")).await.unwrap();
5415 priv_ids.push(id);
5416 }
5417 let bundle_json = serde_json::to_string(&bundle_of(&community, Some(owner.keys.public_key()), None, None)).unwrap();
5418
5419 bed.swap_to(&member);
5421 let member_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
5422 assert_eq!(member_view.channels.iter().filter(|c| c.private && c.key.is_some()).count(), PRIV_CHANNELS, "joined with all private keys");
5423
5424 bed.swap_to(&owner);
5426 for epoch in 1..=2u64 {
5427 community = refound_community(&bed.relay, &community, &[]).await.unwrap();
5428 assert_eq!(community.root_epoch, Epoch(epoch));
5429 for id in &priv_ids {
5430 send_message(&bed.relay, &community, id, &format!("{} epoch{epoch}", crate::simd::hex::bytes_to_hex_32(&id.0))).await.unwrap();
5431 }
5432 }
5433
5434 bed.swap_to(&member);
5436 let session = SessionGuard::capture();
5437 let mut passes = 0;
5438 loop {
5439 passes += 1;
5440 assert!(passes <= 8, "a wide catch-up must converge, not churn (pass {passes})");
5441 let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
5442 let rk = follow_rekeys(&bed.relay, &cur, &session).await.unwrap();
5443 assert!(!rk.self_removed);
5444 let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
5445 let ctl = follow_control(&bed.relay, &cur, &session).await.unwrap();
5446 if rk.updated.is_none() && ctl.is_none() {
5447 break;
5448 }
5449 }
5450 let caught_up = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
5451 assert_eq!(caught_up.root_epoch, Epoch(2), "walked both refoundings");
5452 for id in &priv_ids {
5454 let mine = caught_up.channel(id).expect("channel survived");
5455 let theirs = community.channel(id).unwrap();
5456 assert_eq!(mine.key, theirs.key, "channel {} converged on the owner key", crate::simd::hex::bytes_to_hex_32(&id.0));
5457 assert_eq!(mine.epoch, theirs.epoch, "…at the same epoch");
5458 let texts = texts_in(&bed.relay, &caught_up, id).await;
5459 let id_hex = crate::simd::hex::bytes_to_hex_32(&id.0);
5460 assert!(texts.iter().any(|t| t.contains("epoch0")), "channel {id_hex} reads epoch-0 history");
5461 for epoch in 1..=2u64 {
5462 assert!(texts.iter().any(|t| t.contains(&format!("epoch{epoch}"))), "channel {id_hex} reads epoch-{epoch} history");
5463 }
5464 }
5465 }
5466
5467 #[tokio::test]
5468 async fn an_offline_member_catches_up_across_three_refoundings() {
5469 use nostr_sdk::prelude::ToBech32;
5474 let (bed, owner, member) = TestBed::new();
5475 bed.swap_to(&owner);
5476 let mut community = create_community(&bed.relay, "Sleeper", bed.relays.clone(), None).await.unwrap();
5477 let general = community.channels[0].id;
5478 let mods = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
5479 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5480 send_message(&bed.relay, &community, &general, "epoch0: hello").await.unwrap();
5481 send_message(&bed.relay, &community, &mods, "epoch0: mods secret").await.unwrap();
5482 let bundle_json = serde_json::to_string(&bundle_of(&community, Some(owner.keys.public_key()), None, None)).unwrap();
5483
5484 bed.swap_to(&member);
5486 let member_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
5487 assert_eq!(member_view.root_epoch, Epoch(0));
5488
5489 bed.swap_to(&owner);
5491 let stranger = Keys::generate();
5492 for epoch in 1..=3u64 {
5493 community = refound_community(&bed.relay, &community, &[]).await.unwrap();
5494 assert_eq!(community.root_epoch, Epoch(epoch));
5495 send_message(&bed.relay, &community, &general, &format!("epoch{epoch}: general news")).await.unwrap();
5496 send_message(&bed.relay, &community, &mods, &format!("epoch{epoch}: mods word")).await.unwrap();
5497 }
5498 let news = create_public_channel(&bed.relay, &community, "news").await.unwrap();
5499 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5500 let vault = create_private_channel(&bed.relay, &community, "vault").await.unwrap();
5501 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5502 send_message(&bed.relay, &community, &vault, "epoch3: vault opened").await.unwrap();
5503 set_banlist(&bed.relay, &community, &[stranger.public_key().to_hex()]).await.unwrap();
5504 let meta = control::CommunityMetadata { name: "Sleeper Reborn".into(), relays: community.relays.clone(), ..Default::default() };
5505 edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
5506
5507 bed.swap_to(&member);
5510 let session = SessionGuard::capture();
5511 let mut passes = 0;
5512 loop {
5513 passes += 1;
5514 assert!(passes <= 6, "catch-up must converge, not churn");
5515 let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
5516 let rekeyed = follow_rekeys(&bed.relay, &cur, &session).await.unwrap();
5517 assert!(!rekeyed.self_removed, "the member was never removed");
5518 let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
5519 let controlled = follow_control(&bed.relay, &cur, &session).await.unwrap();
5520 if rekeyed.updated.is_none() && controlled.is_none() {
5521 break;
5522 }
5523 }
5524 let caught_up = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
5525
5526 assert_eq!(caught_up.root_epoch, Epoch(3), "walked all three refoundings");
5528 assert_eq!(caught_up.community_root, community.community_root, "landed on the owner's root");
5529 assert_eq!(caught_up.name, "Sleeper Reborn");
5530 assert!(caught_up.channels.iter().any(|c| c.id.0 == news.0), "folded the new public channel");
5532 let m = caught_up.channel(&mods).expect("mods survived");
5533 let owner_mods = community.channel(&mods).unwrap();
5534 assert_eq!(m.epoch, owner_mods.epoch, "mods walked every per-refound rotation");
5535 assert_eq!(m.key, owner_mods.key, "…to the owner's exact key");
5536 let v = caught_up.channel(&vault).expect("vault folded in");
5537 assert_eq!(v.key, community.channel(&vault).unwrap().key, "adopted the mid-sleep private channel's key");
5538 let cid_hex = crate::simd::hex::bytes_to_hex_32(&caught_up.id().0);
5540 let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap();
5541 assert!(banned.contains(&stranger.public_key().to_hex()), "the ban folded through");
5542 let gen_texts = texts_in(&bed.relay, &caught_up, &general).await;
5545 for epoch in 0..=3u64 {
5546 let needle = if epoch == 0 { "epoch0: hello".to_string() } else { format!("epoch{epoch}: general news") };
5547 assert!(gen_texts.contains(&needle), "general history spans epoch {epoch}: {gen_texts:?}");
5548 }
5549 let mods_texts = texts_in(&bed.relay, &caught_up, &mods).await;
5550 for epoch in 0..=3u64 {
5551 let needle = if epoch == 0 { "epoch0: mods secret".to_string() } else { format!("epoch{epoch}: mods word") };
5552 assert!(mods_texts.contains(&needle), "private history spans epoch {epoch}: {mods_texts:?}");
5553 }
5554 assert!(texts_in(&bed.relay, &caught_up, &vault).await.contains(&"epoch3: vault opened".to_string()));
5555 send_message(&bed.relay, &caught_up, &general, "member: good morning").await.unwrap();
5557 bed.swap_to(&owner);
5558 assert!(
5559 texts_in(&bed.relay, &community, &general).await.contains(&"member: good morning".to_string()),
5560 "the caught-up member converses at the new epoch ({})",
5561 member.keys.public_key().to_bech32().unwrap()
5562 );
5563 }
5564
5565 async fn flood_general(relay: &MemoryRelay, community: &CommunityV2, author: &Keys, n: usize, base_secs: u64) {
5568 let general = community.channels[0].id;
5569 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
5570 for i in 0..n {
5571 let at = base_secs + i as u64;
5572 let rumor = chat::build_message_rumor(author.public_key(), &general, community.root_epoch, &format!("msg {i}"), None, &[], vec![], at * 1000);
5573 let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, author, Timestamp::from_secs(at), false).unwrap();
5574 relay.publish(&wrap, &community.relays).await.unwrap();
5575 }
5576 }
5577
5578 #[tokio::test]
5579 async fn the_history_walk_pages_past_a_multi_page_burst() {
5580 let (_tmp, _guard, owner) = init_test_db();
5583 let relay = MemoryRelay::new();
5584 let community = create_community(&relay, "Burst", vec!["wss://r".into()], None).await.unwrap();
5585 let general = community.channels[0].id;
5586 flood_general(&relay, &community, &owner, 120, 10_000).await;
5587
5588 let all = fetch_channel_history(&relay, &community, &general, 50, 8, |_| true).await.unwrap();
5589 assert_eq!(all.len(), 120, "the walk pages the whole burst");
5590 let contents: Vec<String> = all.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
5592 assert_eq!(contents.first().map(String::as_str), Some("msg 0"));
5593 assert_eq!(contents.last().map(String::as_str), Some("msg 119"));
5594 let unique: std::collections::HashSet<&String> = contents.iter().collect();
5595 assert_eq!(unique.len(), 120, "wrap-id + rumor-id dedup holds across page boundaries");
5596
5597 let one = fetch_channel(&relay, &community, &general, 50).await.unwrap();
5599 assert_eq!(one.len(), 50, "fetch_channel is one newest page");
5600 assert_eq!(one.last().map(|f| f.event.opened().rumor.content.clone()).as_deref(), Some("msg 119"));
5601 }
5602
5603 #[tokio::test]
5604 async fn the_history_walk_stops_when_the_caller_is_caught_up() {
5605 let (_tmp, _guard, owner) = init_test_db();
5606 let relay = MemoryRelay::new();
5607 let community = create_community(&relay, "Caught", vec!["wss://r".into()], None).await.unwrap();
5608 let general = community.channels[0].id;
5609 flood_general(&relay, &community, &owner, 120, 10_000).await;
5610
5611 let mut pages = 0usize;
5613 let got = fetch_channel_history(&relay, &community, &general, 50, 8, |_| {
5614 pages += 1;
5615 false
5616 })
5617 .await
5618 .unwrap();
5619 assert_eq!(pages, 1, "the early stop is consulted once");
5620 assert_eq!(got.len(), 50, "only the newest page is fetched");
5621 assert_eq!(got.last().map(|f| f.event.opened().rumor.content.clone()).as_deref(), Some("msg 119"));
5622 }
5623
5624 #[tokio::test]
5625 async fn a_same_second_history_wall_terminates_instead_of_looping() {
5626 let (_tmp, _guard, owner) = init_test_db();
5630 let relay = MemoryRelay::new();
5631 let community = create_community(&relay, "Wall", vec!["wss://r".into()], None).await.unwrap();
5632 let general = community.channels[0].id;
5633 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
5634 for i in 0..60usize {
5635 let rumor = chat::build_message_rumor(owner.public_key(), &general, community.root_epoch, &format!("burst {i}"), None, &[], vec![], 5_000_000 + i as u64);
5636 let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, &owner, Timestamp::from_secs(5_000), false).unwrap();
5637 relay.publish(&wrap, &community.relays).await.unwrap();
5638 }
5639 let got = fetch_channel_history(&relay, &community, &general, 25, 8, |_| true).await.unwrap();
5640 assert!(got.len() >= 25, "at least the relay page is read");
5641 assert!(got.len() <= 60, "sane bound");
5642 }
5644
5645 #[tokio::test]
5646 async fn a_grant_revoke_survives_a_withholding_relay() {
5647 let (_tmp, _guard, owner) = init_test_db();
5650 let relay = MemoryRelay::new();
5651 let community = create_community(&relay, "Revoke", vec!["wss://good".into()], None).await.unwrap();
5652 let admin = Keys::generate();
5653 let rid = "d4".repeat(32);
5654 publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
5655 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
5656 let session = SessionGuard::capture();
5657 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();
5660
5661 inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
5663 let mut stale = community.clone();
5664 stale.relays = vec!["wss://stale".into()];
5665 let floors = load_floors(&community);
5666 let editions = fetch_control(&relay, &stale).await;
5667 let authority = fold_authority(&stale, &editions, &floors);
5668 assert!(
5669 !authority.roles.is_authorized(&admin.public_key().to_hex(), Some(&owner.public_key().to_hex()), Permissions::MANAGE_METADATA),
5670 "the persisted grant floor refuses the rolled-back (re-granted) view"
5671 );
5672 }
5673
5674 fn load_floors(community: &CommunityV2) -> Floors {
5676 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5677 crate::db::community::get_all_edition_heads_full(&cid_hex)
5678 .unwrap_or_default()
5679 .into_iter()
5680 .filter(|(_, f)| f.0 == community.root_epoch.0)
5681 .map(|(e, f)| (e, (f.1, f.2, f.3)))
5682 .collect()
5683 }
5684
5685 async fn fetch_control(relay: &MemoryRelay, community: &CommunityV2) -> Vec<ParsedEdition> {
5687 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
5688 let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
5689 relay
5690 .fetch(&q, &community.relays)
5691 .await
5692 .unwrap_or_default()
5693 .iter()
5694 .filter_map(|w| control::open_control_edition(w, &group).ok().map(|(ed, _)| ed))
5695 .collect()
5696 }
5697
5698 #[tokio::test]
5699 async fn follow_control_is_a_noop_on_a_freshly_created_community() {
5700 let (_tmp, _guard, _owner) = init_test_db();
5701 let relay = MemoryRelay::new();
5702 let community = create_community(&relay, "Fresh", vec!["wss://r".into()], None).await.unwrap();
5703 let session = SessionGuard::capture();
5704 assert!(follow_control(&relay, &community, &session).await.unwrap().is_none());
5706 }
5707
5708 #[tokio::test]
5709 async fn follow_control_adds_a_new_public_channel_and_re_subscribes_it() {
5710 let (_tmp, _guard, owner) = init_test_db();
5711 let relay = MemoryRelay::new();
5712 let community = create_community(&relay, "Grow", vec!["wss://r".into()], None).await.unwrap();
5713 let new_id = ChannelId([0x5a; 32]);
5714 publish_channel_edition(&relay, &community, &owner, &new_id, "announcements", false, 1, false).await;
5715
5716 let session = SessionGuard::capture();
5717 let updated = follow_control(&relay, &community, &session).await.unwrap().expect("a new channel changed the view");
5718 assert_eq!(updated.channels.len(), 2);
5719 let added = updated.channel(&new_id).expect("the new channel folded in");
5720 assert_eq!(added.name, "announcements");
5721 assert!(!added.private);
5722 assert_eq!(added.key, None, "a public channel derives from the root (no stored key)");
5723
5724 let authors = super::super::realtime::plane_authors(std::slice::from_ref(&updated));
5726 let addr = channel_group_key(&updated.community_root, &new_id, updated.root_epoch).pk();
5727 assert!(authors.contains(&addr), "the added channel joins the live subscription");
5728
5729 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5731 assert!(reloaded.channel(&new_id).is_some());
5732 }
5733
5734 #[tokio::test]
5735 async fn follow_control_renames_the_community_and_an_existing_channel() {
5736 let (_tmp, _guard, owner) = init_test_db();
5737 let relay = MemoryRelay::new();
5738 let community = create_community(&relay, "Old Name", vec!["wss://r".into()], None).await.unwrap();
5739 let general = community.channels[0].id;
5740 publish_community_meta(&relay, &community, &owner, "New Name", 2).await;
5742 publish_channel_edition(&relay, &community, &owner, &general, "lobby", false, 2, false).await;
5743
5744 let session = SessionGuard::capture();
5745 let updated = follow_control(&relay, &community, &session).await.unwrap().unwrap();
5746 assert_eq!(updated.name, "New Name");
5747 assert_eq!(updated.channel(&general).unwrap().name, "lobby");
5748 assert_eq!(updated.channels.len(), 1, "a rename doesn't add a channel");
5749 }
5750
5751 #[tokio::test]
5752 async fn follow_control_deletes_a_channel() {
5753 let (_tmp, _guard, owner) = init_test_db();
5754 let relay = MemoryRelay::new();
5755 let community = create_community(&relay, "Prune", vec!["wss://r".into()], None).await.unwrap();
5756 let extra = ChannelId([0x77; 32]);
5757 let session = SessionGuard::capture();
5758
5759 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
5761 let with_extra = follow_control(&relay, &community, &session).await.unwrap().expect("added");
5762 assert!(with_extra.channel(&extra).is_some());
5763
5764 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
5766 let updated = follow_control(&relay, &with_extra, &session).await.unwrap().expect("removed");
5767 assert!(updated.channel(&extra).is_none(), "a deleted channel folds out");
5768 assert_eq!(updated.channels.len(), 1, "only #general remains");
5769 }
5770
5771 async fn inject_stale_prefix(relay: &MemoryRelay, community: &CommunityV2, max_version: u64, stale_relay: &str) {
5775 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
5776 let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
5777 let wraps = relay.fetch(&query, &community.relays).await.unwrap();
5778 for w in &wraps {
5779 if let Ok((ed, _)) = control::open_control_edition(w, &group) {
5780 if ed.version <= max_version {
5781 relay.inject(w, &[stale_relay.to_string()]);
5782 }
5783 }
5784 }
5785 }
5786
5787 #[tokio::test]
5788 async fn a_withholding_relay_cannot_roll_back_a_rename() {
5789 let (_tmp, _guard, owner) = init_test_db();
5792 let relay = MemoryRelay::new();
5793 let community = create_community(&relay, "Original", vec!["wss://good".into()], None).await.unwrap();
5794 publish_community_meta(&relay, &community, &owner, "Renamed", 2).await;
5795
5796 let session = SessionGuard::capture();
5797 let updated = follow_control(&relay, &community, &session).await.unwrap().expect("rename adopted");
5798 assert_eq!(updated.name, "Renamed");
5799
5800 inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
5802 let mut stale_view = updated.clone();
5803 stale_view.relays = vec!["wss://stale".into()];
5804 assert!(
5805 follow_control(&relay, &stale_view, &session).await.unwrap().is_none(),
5806 "a stale-only relay must not change the held view"
5807 );
5808 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5809 assert_eq!(held.name, "Renamed", "the persisted floor refuses the rollback");
5810 }
5811
5812 #[tokio::test]
5813 async fn a_withholding_relay_cannot_resurrect_a_deleted_channel() {
5814 let (_tmp, _guard, owner) = init_test_db();
5815 let relay = MemoryRelay::new();
5816 let community = create_community(&relay, "Prune2", vec!["wss://good".into()], None).await.unwrap();
5817 let extra = ChannelId([0x44; 32]);
5818 let session = SessionGuard::capture();
5819
5820 publish_community_meta(&relay, &community, &owner, "Prune2", 2).await;
5823 assert!(follow_control(&relay, &community, &session).await.unwrap().is_none());
5824
5825 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
5826 let with_extra = follow_control(&relay, &community, &session).await.unwrap().expect("added");
5827 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
5828 let pruned = follow_control(&relay, &with_extra, &session).await.unwrap().expect("removed");
5829 assert!(pruned.channel(&extra).is_none());
5830
5831 inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
5833 let mut stale_view = pruned.clone();
5834 stale_view.relays = vec!["wss://stale".into()];
5835 assert!(
5836 follow_control(&relay, &stale_view, &session).await.unwrap().is_none(),
5837 "the withheld delete must not resurrect the channel"
5838 );
5839 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5840 assert!(held.channel(&extra).is_none(), "the deleted channel stays deleted");
5841 }
5842
5843 #[tokio::test]
5844 async fn a_new_epoch_bootstraps_past_an_old_epoch_floor() {
5845 let (_tmp, _guard, owner) = init_test_db();
5849 let relay = MemoryRelay::new();
5850 let community = create_community(&relay, "Before", vec!["wss://good".into()], None).await.unwrap();
5851 let session = SessionGuard::capture();
5852 publish_community_meta(&relay, &community, &owner, "Edited", 2).await;
5853 let updated = follow_control(&relay, &community, &session).await.unwrap().expect("edit adopted");
5854 assert_eq!(updated.name, "Edited");
5855
5856 let mut refounded = updated.clone();
5859 refounded.root_epoch = crate::community::Epoch(1);
5860 crate::db::community::save_community_v2(&refounded).unwrap();
5861 publish_community_meta(&relay, &refounded, &owner, "Compacted", 5).await;
5862
5863 let adopted = follow_control(&relay, &refounded, &session).await.unwrap().expect("compacted head adopted");
5864 assert_eq!(adopted.name, "Compacted", "a fresh epoch bootstraps despite the dangling prev");
5865 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5867 let heads = crate::db::community::get_all_edition_heads_epoched(&cid_hex).unwrap();
5868 assert!(
5869 heads.get(&cid_hex).is_some_and(|(e, v, _)| *e == 1 && *v == 5),
5870 "the adopted head carries the fold's epoch + version"
5871 );
5872 }
5873
5874 #[tokio::test]
5875 async fn a_same_version_owner_fork_at_the_floor_converges_to_the_deterministic_winner() {
5876 let (_tmp, _guard, owner) = init_test_db();
5881 let relay = MemoryRelay::new();
5882 let community = create_community(&relay, "Fork", vec!["wss://r".into()], None).await.unwrap();
5883 let session = SessionGuard::capture();
5884 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
5885 let genesis_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
5886
5887 publish_community_meta(&relay, &community, &owner, "Ours", 2).await;
5888 let ours = follow_control(&relay, &community, &session).await.unwrap().expect("ours adopted");
5889 assert_eq!(ours.name, "Ours");
5890
5891 let our_inner = {
5893 let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
5894 let wraps = relay.fetch(&q, &community.relays).await.unwrap();
5895 wraps
5896 .iter()
5897 .find_map(|w| {
5898 control::open_control_edition(w, &group)
5899 .ok()
5900 .filter(|(ed, _)| ed.version == 2 && ed.vsk == vsk::COMMUNITY_METADATA)
5901 .map(|(ed, _)| ed.inner_id)
5902 })
5903 .unwrap()
5904 };
5905
5906 let meta = control::CommunityMetadata { name: "Theirs".into(), ..Default::default() };
5909 let content = serde_json::to_string(&meta).unwrap();
5910 let mut ts = 2_000u64;
5911 let fork_wrap = loop {
5912 let rumor = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &content, ts, None);
5913 let inner = rumor.id.unwrap().to_bytes();
5914 if inner < our_inner {
5915 break control::seal_control_edition(&rumor, &group, &owner, Timestamp::from_secs(ts)).unwrap().0;
5916 }
5917 ts += 1;
5918 };
5919 relay.publish(&fork_wrap, &community.relays).await.unwrap();
5920
5921 let converged = follow_control(&relay, &ours, &session).await.unwrap().expect("fork winner adopted");
5922 assert_eq!(converged.name, "Theirs", "the floor converges to the lower-inner-id winner");
5923 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5924 let held = crate::db::community::get_edition_head_inner_id(&cid_hex, &cid_hex).unwrap();
5925 assert!(held.is_some_and(|h| h < our_inner), "the persisted floor's tiebreak key moved to the winner");
5926 }
5927
5928 #[tokio::test]
5929 async fn an_anchored_prefix_applies_while_a_gap_above_awaits_the_missing_link() {
5930 let (_tmp, _guard, owner) = init_test_db();
5934 let relay = MemoryRelay::new();
5935 let community = create_community(&relay, "Prefix", vec!["wss://r".into()], None).await.unwrap();
5936 let session = SessionGuard::capture();
5937 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
5938
5939 publish_community_meta(&relay, &community, &owner, "Two", 2).await;
5940 let v2_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
5941
5942 let c3 = serde_json::to_string(&control::CommunityMetadata { name: "Three".into(), ..Default::default() }).unwrap();
5944 let r3 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 3, Some(&v2_hash), &c3, 3_000, None);
5945 let (w3, _) = control::seal_control_edition(&r3, &group, &owner, Timestamp::from_secs(3_000)).unwrap();
5946 let (ed3, _) = control::open_control_edition(&w3, &group).unwrap();
5947 let c4 = serde_json::to_string(&control::CommunityMetadata { name: "Four".into(), ..Default::default() }).unwrap();
5948 let r4 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 4, Some(&ed3.self_hash), &c4, 4_000, None);
5949 let (w4, _) = control::seal_control_edition(&r4, &group, &owner, Timestamp::from_secs(4_000)).unwrap();
5950 relay.publish(&w4, &community.relays).await.unwrap();
5951
5952 let updated = follow_control(&relay, &community, &session).await.unwrap().expect("the verified prefix applies");
5953 assert_eq!(updated.name, "Two", "the anchored prefix lands; the detached v4 does not");
5954
5955 relay.publish(&w3, &community.relays).await.unwrap();
5956 let healed = follow_control(&relay, &updated, &session).await.unwrap().expect("the chain heals");
5957 assert_eq!(healed.name, "Four", "once the link arrives, the head advances past the prefix");
5958 }
5959
5960 #[tokio::test]
5961 async fn paging_rescues_a_floor_link_evicted_from_the_newest_window() {
5962 let (_tmp, _guard, owner) = init_test_db();
5966 let relay = MemoryRelay::new();
5967 let community = create_community(&relay, "Paged", vec!["wss://r".into()], None).await.unwrap();
5968 let session = SessionGuard::capture();
5969 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
5970
5971 publish_community_meta(&relay, &community, &owner, "Two", 2).await;
5972 let base = follow_control(&relay, &community, &session).await.unwrap().expect("floor at v2");
5973 publish_community_meta(&relay, &base, &owner, "Three", 3).await; let v3_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
5975
5976 let rogue = Keys::generate();
5979 for i in 0..(FOLLOW_PAGE as u64 - 1) {
5980 let rumor = control::build_edition_rumor(rogue.public_key(), vsk::CHANNEL_METADATA, &[0xCC; 32], 1, None, "{\"name\":\"junk\",\"private\":false}", 4_000 + i, None);
5981 let (w, _) = control::seal_control_edition(&rumor, &group, &rogue, Timestamp::from_secs(4_000 + i)).unwrap();
5982 relay.publish(&w, &community.relays).await.unwrap();
5983 }
5984 let c4 = serde_json::to_string(&control::CommunityMetadata { name: "Four".into(), ..Default::default() }).unwrap();
5987 let r4 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 4, Some(&v3_hash), &c4, 10_000, None);
5988 let (w4, _) = control::seal_control_edition(&r4, &group, &owner, Timestamp::from_secs(10_000)).unwrap();
5989 relay.publish(&w4, &community.relays).await.unwrap();
5990
5991 let healed = follow_control(&relay, &base, &session).await.unwrap().expect("paging recovered the chain");
5992 assert_eq!(healed.name, "Four", "the gap paged past the flood to the floor link");
5993 }
5994
5995 #[tokio::test]
5996 async fn a_follow_after_delete_does_not_resurrect_the_community() {
5997 let (_tmp, _guard, owner) = init_test_db();
6000 let relay = MemoryRelay::new();
6001 let community = create_community(&relay, "Gone", vec!["wss://r".into()], None).await.unwrap();
6002 publish_community_meta(&relay, &community, &owner, "Edited", 2).await;
6003 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6004 crate::db::community::delete_community(&cid_hex).unwrap();
6005
6006 let session = SessionGuard::capture();
6007 assert!(
6008 follow_control(&relay, &community, &session).await.unwrap().is_none(),
6009 "a follow racing a delete is a no-op"
6010 );
6011 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none(), "the community stays deleted");
6012 assert!(crate::db::community::edition_head_entity_ids(&cid_hex).unwrap().is_empty(), "no orphan floor rows");
6013 }
6014
6015 #[tokio::test]
6016 async fn a_rekey_follow_after_delete_does_not_resurrect_the_community() {
6017 let (_tmp, _guard, owner) = init_test_db();
6020 let relay = MemoryRelay::new();
6021 let community = create_community(&relay, "GoneKeys", vec!["wss://r".into()], None).await.unwrap();
6022 let new_root = [0xB2; 32];
6023 publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
6024 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6025 crate::db::community::delete_community(&cid_hex).unwrap();
6026
6027 let session = SessionGuard::capture();
6028 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
6029 assert!(follow.updated.is_none() && !follow.self_removed, "a rekey follow racing a delete adopts nothing");
6030 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none(), "the community stays deleted");
6031 }
6032
6033 #[tokio::test]
6034 async fn a_joiner_bootstraps_the_highest_head_across_a_lost_middle_edition() {
6035 let (bed, owner, member) = TestBed::new();
6040 bed.swap_to(&owner);
6041 let community = create_community(&bed.relay, "Skip", bed.relays.clone(), None).await.unwrap();
6042 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
6043 let genesis_hash = head_hash_on_relay(&bed.relay, &community, &community.id().0).await.unwrap();
6044
6045 let c2 = serde_json::to_string(&control::CommunityMetadata { name: "Two".into(), ..Default::default() }).unwrap();
6047 let r2 = control::build_edition_rumor(owner.keys.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &c2, 2_000, None);
6048 let (w2, _) = control::seal_control_edition(&r2, &group, &owner.keys, Timestamp::from_secs(2_000)).unwrap();
6049 let (ed2, _) = control::open_control_edition(&w2, &group).unwrap();
6050 let c3 = serde_json::to_string(&control::CommunityMetadata { name: "Three".into(), ..Default::default() }).unwrap();
6051 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);
6052 let (w3, _) = control::seal_control_edition(&r3, &group, &owner.keys, Timestamp::from_secs(3_000)).unwrap();
6053 bed.relay.publish(&w3, &community.relays).await.unwrap();
6054
6055 let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
6056 let bundle_json = serde_json::to_string(&bundle).unwrap();
6057 bed.swap_to(&member);
6058 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
6059 assert_eq!(joined.name, "Three", "the joiner bootstraps the highest signed head, not the anchored stale prefix");
6060 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
6061 let head = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap();
6062 assert!(head.is_some_and(|(v, _)| v == 3), "the seeded floor is the bootstrap head");
6063 }
6064
6065 #[tokio::test]
6066 async fn a_losing_same_version_fork_cannot_replace_the_held_floor() {
6067 let (_tmp, _guard, owner) = init_test_db();
6071 let relay = MemoryRelay::new();
6072 let community = create_community(&relay, "Fork2", vec!["wss://good".into()], None).await.unwrap();
6073 let session = SessionGuard::capture();
6074 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
6075 let genesis_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
6076
6077 publish_community_meta(&relay, &community, &owner, "Ours", 2).await;
6078 let ours = follow_control(&relay, &community, &session).await.unwrap().expect("ours adopted");
6079 assert_eq!(ours.name, "Ours");
6080 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6081 let held_before = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().unwrap();
6082 let our_inner = crate::db::community::get_edition_head_inner_id(&cid_hex, &cid_hex).unwrap().unwrap();
6083
6084 let meta = control::CommunityMetadata { name: "Theirs".into(), ..Default::default() };
6087 let content = serde_json::to_string(&meta).unwrap();
6088 let mut ts = 5_000u64;
6089 let fork_wrap = loop {
6090 let rumor = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &content, ts, None);
6091 if rumor.id.unwrap().to_bytes() > our_inner {
6092 break control::seal_control_edition(&rumor, &group, &owner, Timestamp::from_secs(ts)).unwrap().0;
6093 }
6094 ts += 1;
6095 };
6096 inject_stale_prefix(&relay, &community, 1, "wss://stale").await; relay.inject(&fork_wrap, &["wss://stale".to_string()]);
6098 let mut stale_view = ours.clone();
6099 stale_view.relays = vec!["wss://stale".into()];
6100
6101 assert!(
6102 follow_control(&relay, &stale_view, &session).await.unwrap().is_none(),
6103 "a losing fork served without our floor edition changes nothing"
6104 );
6105 let held_after = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().unwrap();
6106 assert_eq!(held_after, held_before, "the floor row is untouched");
6107 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
6108 assert_eq!(held.name, "Ours", "the held state is untouched");
6109 }
6110
6111 #[tokio::test]
6112 async fn follow_control_ignores_a_non_owner_edition() {
6113 let (_tmp, _guard, _owner) = init_test_db();
6117 let relay = MemoryRelay::new();
6118 let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
6119 let rogue = Keys::generate();
6120 let rogue_id = ChannelId([0x99; 32]);
6121 publish_channel_edition(&relay, &community, &rogue, &rogue_id, "backdoor", false, 1, false).await;
6122
6123 let session = SessionGuard::capture();
6124 assert!(
6125 follow_control(&relay, &community, &session).await.unwrap().is_none(),
6126 "a non-owner control edition is not folded"
6127 );
6128 }
6129
6130 #[tokio::test]
6131 async fn follow_control_records_a_new_private_channel_keyless_and_unreadable() {
6132 let (_tmp, _guard, owner) = init_test_db();
6136 let relay = MemoryRelay::new();
6137 let community = create_community(&relay, "Priv", vec!["wss://r".into()], None).await.unwrap();
6138 let priv_id = ChannelId([0x33; 32]);
6139 publish_channel_edition(&relay, &community, &owner, &priv_id, "mods", true, 1, false).await;
6140
6141 let session = SessionGuard::capture();
6142 let updated = follow_control(&relay, &community, &session)
6143 .await
6144 .unwrap()
6145 .expect("the keyless record is a change");
6146 let ch = updated.channel(&priv_id).expect("the private channel is recorded");
6147 assert!(ch.private && ch.key.is_none(), "recorded keyless");
6148 assert_eq!(ch.epoch, Epoch(0), "epoch 0 = the root generation (scan cursor)");
6149 assert!(updated.channel_read_coords(ch).is_empty(), "unreadable until keyed");
6150 assert!(
6151 fetch_channel(&relay, &updated, &priv_id, 50).await.unwrap().is_empty(),
6152 "a keyless fetch returns empty (and never queries the root plane)"
6153 );
6154 assert!(
6155 send_message(&relay, &updated, &priv_id, "nope").await.is_err(),
6156 "a keyless send refuses"
6157 );
6158 let reloaded = crate::db::community::load_community_v2(updated.id()).unwrap().unwrap();
6161 let rch = reloaded.channel(&priv_id).unwrap();
6162 assert!(rch.private && rch.key.is_none() && rch.epoch == Epoch(0), "keyless survives reload");
6163 let bundle = bundle_of(&reloaded, None, None, None);
6165 assert!(
6166 !bundle.channels.iter().any(|c| c.id == crate::simd::hex::bytes_to_hex_32(&priv_id.0)),
6167 "an ungrantable keyless channel stays out of invite bundles"
6168 );
6169 }
6170
6171 async fn publish_base_rotation(
6178 relay: &MemoryRelay,
6179 community: &CommunityV2,
6180 rotator: &Keys,
6181 recipients: &[PublicKey],
6182 new_root: &[u8; 32],
6183 prev_key: &[u8; 32],
6184 ) {
6185 let new_epoch = Epoch(community.root_epoch.0 + 1);
6186 let prev_epoch = community.root_epoch;
6187 let prev_commit = super::super::derive::epoch_key_commitment(prev_epoch, prev_key);
6188 let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
6189 let blobs: Vec<_> = recipients
6190 .iter()
6191 .map(|r| rekey::build_blob_local(rotator.secret_key(), &rotator.public_key().to_bytes(), r, RekeyScope::Root, new_epoch, new_root).unwrap())
6192 .collect();
6193 let events = rekey::build_rekey_chunks_local(rotator, &group, RekeyScope::Root, new_epoch, prev_epoch, &prev_commit, &blobs, 2_000).unwrap();
6194 for e in &events {
6195 relay.publish(e, &community.relays).await.unwrap();
6196 }
6197 }
6198
6199 fn add_private_channel(community: &mut CommunityV2, id: ChannelId, key: [u8; 32], epoch: Epoch) {
6201 community.channels.push(ChannelV2 { id, name: "mods".into(), private: true, key: Some(key), epoch, voice: None, meta_custom: None, meta_extra: Default::default() });
6202 crate::db::community::save_community_v2(community).unwrap();
6203 }
6204
6205 #[tokio::test]
6206 async fn follow_rekeys_is_a_noop_without_rotations() {
6207 let (_tmp, _guard, _owner) = init_test_db();
6208 let relay = MemoryRelay::new();
6209 let community = create_community(&relay, "Still", vec!["wss://r".into()], None).await.unwrap();
6210 let session = SessionGuard::capture();
6211 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
6212 assert!(follow.updated.is_none() && !follow.self_removed, "no rotation → nothing to adopt");
6213 }
6214
6215 #[tokio::test]
6216 async fn follow_rekeys_adopts_an_owner_base_rotation() {
6217 let (_tmp, _guard, owner) = init_test_db();
6218 let relay = MemoryRelay::new();
6219 let community = create_community(&relay, "Refound", vec!["wss://r".into()], None).await.unwrap();
6220 let new_root = [0xB1; 32];
6221 publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
6223
6224 let session = SessionGuard::capture();
6225 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopted");
6226 assert_eq!(updated.root_epoch, Epoch(1), "advanced one epoch");
6227 assert_eq!(updated.community_root, new_root, "adopted the fresh root");
6228 let addr = super::super::realtime::plane_authors(std::slice::from_ref(&updated));
6230 let general = updated.channels[0].id;
6231 let new_chat = channel_group_key(&new_root, &general, Epoch(1)).pk();
6232 assert!(addr.contains(&new_chat), "the public channel re-addresses under the new root");
6233 }
6234
6235 #[tokio::test]
6236 async fn follow_rekeys_adopts_an_owner_private_channel_rotation() {
6237 let (_tmp, _guard, owner) = init_test_db();
6238 let relay = MemoryRelay::new();
6239 let mut community = create_community(&relay, "PrivRot", vec!["wss://r".into()], None).await.unwrap();
6240 let priv_id = ChannelId([0x33; 32]);
6241 add_private_channel(&mut community, priv_id, [0x44; 32], Epoch(0));
6242
6243 let new_key = [0x55; 32];
6245 let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &[0x44; 32]);
6246 let group = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(1));
6247 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();
6248 let events = rekey::build_rekey_chunks_local(&owner, &group, RekeyScope::Channel(priv_id), Epoch(1), Epoch(0), &prev_commit, &[blob], 2_000).unwrap();
6249 for e in &events {
6250 relay.publish(e, &community.relays).await.unwrap();
6251 }
6252
6253 let session = SessionGuard::capture();
6254 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopted");
6255 let ch = updated.channel(&priv_id).unwrap();
6256 assert_eq!(ch.epoch, Epoch(1), "the private channel advanced an epoch");
6257 assert_eq!(ch.key, Some(new_key), "adopted the fresh channel key");
6258 assert_eq!(updated.root_epoch, Epoch(0), "the base is untouched by a channel rotation");
6259 }
6260
6261 #[tokio::test]
6262 async fn follow_rekeys_ignores_a_non_owner_rotation() {
6263 let (_tmp, _guard, _owner) = init_test_db();
6266 let relay = MemoryRelay::new();
6267 let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
6268 let rogue = Keys::generate();
6269 publish_base_rotation(&relay, &community, &rogue, &[rogue.public_key()], &[0xEE; 32], &community.community_root).await;
6270
6271 let session = SessionGuard::capture();
6272 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
6273 assert!(follow.updated.is_none() && !follow.self_removed, "a non-owner rotation is not adopted");
6274 }
6275
6276 #[tokio::test]
6277 async fn follow_rekeys_ignores_a_rotation_off_the_wrong_prev() {
6278 let (_tmp, _guard, owner) = init_test_db();
6281 let relay = MemoryRelay::new();
6282 let community = create_community(&relay, "Forked", vec!["wss://r".into()], None).await.unwrap();
6283 publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &[0xB2; 32], &[0x00; 32]).await;
6285
6286 let session = SessionGuard::capture();
6287 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
6288 assert!(follow.updated.is_none(), "a fork off the wrong prev is not adopted");
6289 }
6290
6291 #[tokio::test]
6292 async fn follow_rekeys_holds_on_an_incomplete_rotation() {
6293 let (_tmp, _guard, owner) = init_test_db();
6296 let relay = MemoryRelay::new();
6297 let community = create_community(&relay, "Partial", vec!["wss://r".into()], None).await.unwrap();
6298 let new_epoch = Epoch(1);
6299 let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
6300 let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
6301 let other = Keys::generate();
6303 let blob = rekey::build_blob_local(owner.secret_key(), &owner.public_key().to_bytes(), &other.public_key(), RekeyScope::Root, new_epoch, &[0xB3; 32]).unwrap();
6304 let rumor = rekey::build_rekey_rumor(owner.public_key(), RekeyScope::Root, new_epoch, Epoch(0), &prev_commit, &[blob], 1, 2, 2_000).unwrap();
6305 let (wrap, _) = rekey::seal_rekey_chunk(&rumor, &group, &owner, Timestamp::from_secs(2_000)).unwrap();
6306 relay.publish(&wrap, &community.relays).await.unwrap();
6307
6308 let session = SessionGuard::capture();
6309 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
6310 assert!(follow.updated.is_none() && !follow.self_removed, "an incomplete rotation neither adopts nor removes");
6311 }
6312
6313 #[tokio::test]
6314 async fn follow_rekeys_removes_a_member_dropped_by_a_base_rotation() {
6315 let (bed, owner, member) = TestBed::new();
6319 bed.swap_to(&owner);
6320 let community = create_community(&bed.relay, "Evict", bed.relays.clone(), None).await.unwrap();
6321 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
6322
6323 bed.swap_to(&member);
6324 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
6325 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
6326
6327 bed.swap_to(&owner);
6329 let stranger = Keys::generate();
6330 publish_base_rotation(&bed.relay, &community, &owner.keys, &[stranger.public_key()], &[0xC4; 32], &community.community_root).await;
6331
6332 bed.swap_to(&member);
6334 let session = SessionGuard::capture();
6335 let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
6336 assert!(follow.self_removed, "a complete base rotation dropping the member removes them");
6337 assert!(follow.updated.is_none(), "a removed member adopts nothing");
6338 }
6339
6340 #[tokio::test]
6341 async fn follow_rekeys_finds_a_channel_rekey_under_an_archived_prior_root() {
6342 let (_tmp, _guard, owner) = init_test_db();
6348 let relay = MemoryRelay::new();
6349 let mut community = create_community(&relay, "Strand", vec!["wss://r".into()], None).await.unwrap();
6350 let root0 = community.community_root;
6351 let priv_id = ChannelId([0x33; 32]);
6352 let key1 = [0x44; 32];
6353 add_private_channel(&mut community, priv_id, key1, Epoch(1));
6354
6355 let key2 = [0x55; 32];
6358 let prev_commit = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
6359 let group = channel_rekey_group_key(&root0, &priv_id, Epoch(2));
6360 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();
6361 for e in rekey::build_rekey_chunks_local(&owner, &group, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &prev_commit, &[blob], 2_000).unwrap() {
6362 relay.publish(&e, &community.relays).await.unwrap();
6363 }
6364
6365 community.community_root = [0xB7; 32];
6369 community.root_epoch = Epoch(1);
6370 crate::db::community::save_community_v2(&community).unwrap();
6371
6372 let session = SessionGuard::capture();
6373 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the prior-root crate is found");
6374 let ch = updated.channel(&priv_id).unwrap();
6375 assert_eq!(ch.epoch, Epoch(2), "the channel advanced despite the moved base");
6376 assert_eq!(ch.key, Some(key2), "adopted the key delivered under the prior root");
6377 }
6378
6379 #[tokio::test]
6380 async fn follow_rekeys_keyless_cursor_walks_past_an_excluding_rotation_then_adopts() {
6381 let (_tmp, _guard, owner) = init_test_db();
6386 let relay = MemoryRelay::new();
6387 let mut community = create_community(&relay, "Cursor", vec!["wss://r".into()], None).await.unwrap();
6388 let priv_id = ChannelId([0x66; 32]);
6389 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() });
6390 crate::db::community::save_community_v2(&community).unwrap();
6391 let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
6392 assert!(community.channel(&priv_id).unwrap().key.is_none(), "keyless survives the round-trip");
6393
6394 let stranger = Keys::generate();
6396 let key1 = [0x71; 32];
6397 let pc1 = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
6398 let g1 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(1));
6399 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();
6400 for e in rekey::build_rekey_chunks_local(&owner, &g1, RekeyScope::Channel(priv_id), Epoch(1), Epoch(0), &pc1, &[b1], 2_000).unwrap() {
6401 relay.publish(&e, &community.relays).await.unwrap();
6402 }
6403 let key2 = [0x72; 32];
6406 let pc2 = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
6407 let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
6408 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();
6409 for e in rekey::build_rekey_chunks_local(&owner, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc2, &[b2], 2_100).unwrap() {
6410 relay.publish(&e, &community.relays).await.unwrap();
6411 }
6412
6413 let session = SessionGuard::capture();
6416 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the walk lands on the included epoch");
6417 let ch = updated.channel(&priv_id).unwrap();
6418 assert_eq!(ch.epoch, Epoch(2), "cursor walked through the excluding epoch to the included one");
6419 assert_eq!(ch.key, Some(key2), "adopted the delivery that includes us");
6420 }
6421
6422 #[tokio::test]
6423 async fn follow_rekeys_honors_an_admin_channel_rotation_but_never_a_strangers() {
6424 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
6428 let (_tmp, _guard, _owner) = init_test_db();
6429 let relay = MemoryRelay::new();
6430 let mut community = create_community(&relay, "AdminRot", vec!["wss://r".into()], None).await.unwrap();
6431 let priv_id = ChannelId([0x88; 32]);
6432 let key1 = [0x91; 32];
6433 add_private_channel(&mut community, priv_id, key1, Epoch(1));
6434
6435 let admin = Keys::generate();
6437 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6438 let role = Role::admin("aa".repeat(32));
6439 let roster = CommunityRoles {
6440 roles: vec![role.clone()],
6441 grants: vec![MemberGrant { member: admin.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }],
6442 };
6443 crate::db::community::set_community_roles(&cid_hex, &roster, 1_000).unwrap();
6444
6445 let key2 = [0x92; 32];
6447 let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
6448 let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
6449 let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
6450 let blob = rekey::build_blob_local(admin.secret_key(), &admin.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
6451 for e in rekey::build_rekey_chunks_local(&admin, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[blob], 2_000).unwrap() {
6452 relay.publish(&e, &community.relays).await.unwrap();
6453 }
6454 let session = SessionGuard::capture();
6455 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("an admin rotation is honored");
6456 assert_eq!(updated.channel(&priv_id).unwrap().key, Some(key2), "adopted the admin's key");
6457
6458 let rogue = Keys::generate();
6460 let key3 = [0x93; 32];
6461 let pc3 = super::super::derive::epoch_key_commitment(Epoch(2), &key2);
6462 let g3 = channel_rekey_group_key(&updated.community_root, &priv_id, Epoch(3));
6463 let rb = rekey::build_blob_local(rogue.secret_key(), &rogue.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(3), &key3).unwrap();
6464 for e in rekey::build_rekey_chunks_local(&rogue, &g3, RekeyScope::Channel(priv_id), Epoch(3), Epoch(2), &pc3, &[rb], 2_100).unwrap() {
6465 relay.publish(&e, &updated.relays).await.unwrap();
6466 }
6467 let follow = follow_rekeys(&relay, &updated, &session).await.unwrap();
6468 assert!(follow.updated.is_none(), "a stranger's channel rotation is never adopted");
6469 }
6470
6471 #[tokio::test]
6472 async fn a_non_outranking_admins_rotation_never_concludes_my_removal() {
6473 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
6478 let (bed, owner, member) = TestBed::new();
6479 bed.swap_to(&owner);
6480 let community = create_community(&bed.relay, "Outrank", bed.relays.clone(), None).await.unwrap();
6481
6482 bed.swap_to(&member);
6485 let mut held = community.clone();
6486 let priv_id = ChannelId([0xAB; 32]);
6487 let key1 = [0xA1; 32];
6488 add_private_channel(&mut held, priv_id, key1, Epoch(1));
6489 let peer = Keys::generate();
6490 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6491 let role = Role::admin("bb".repeat(32));
6492 let roster = CommunityRoles {
6493 roles: vec![role.clone()],
6494 grants: vec![
6495 MemberGrant { member: peer.public_key().to_hex(), role_ids: vec![role.role_id.clone()] },
6496 MemberGrant { member: member.keys.public_key().to_hex(), role_ids: vec![role.role_id.clone()] },
6497 ],
6498 };
6499 crate::db::community::set_community_roles(&cid_hex, &roster, 1_000).unwrap();
6500
6501 let key2 = [0xA2; 32];
6503 let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
6504 let g2 = channel_rekey_group_key(&held.community_root, &priv_id, Epoch(2));
6505 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();
6506 for e in rekey::build_rekey_chunks_local(&peer, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[pb], 2_000).unwrap() {
6507 bed.relay.publish(&e, &held.relays).await.unwrap();
6508 }
6509 let session = SessionGuard::capture();
6510 let follow = follow_rekeys(&bed.relay, &held, &session).await.unwrap();
6511 assert!(follow.updated.is_none(), "an equal-rank rotation excluding me is Stay, never my removal");
6512 let reloaded = crate::db::community::load_community_v2(held.id()).unwrap().unwrap();
6513 assert!(reloaded.channel(&priv_id).is_some(), "my channel record survives the peer's rotation");
6514
6515 let key3 = [0xA3; 32];
6517 let stranger = Keys::generate();
6518 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();
6519 for e in rekey::build_rekey_chunks_local(&owner.keys, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[ob], 2_100).unwrap() {
6520 bed.relay.publish(&e, &held.relays).await.unwrap();
6521 }
6522 let follow = follow_rekeys(&bed.relay, &held, &session).await.unwrap();
6523 let updated = follow.updated.expect("the owner's removal folds");
6524 assert!(updated.channel(&priv_id).is_none(), "the owner's exclusion cuts my channel record");
6525 }
6526
6527 #[tokio::test]
6528 async fn converting_a_public_channel_to_private_is_refused() {
6529 let (_tmp, _guard, _owner) = init_test_db();
6532 let relay = MemoryRelay::new();
6533 let community = create_community(&relay, "NoConvert", vec!["wss://r".into()], None).await.unwrap();
6534 let general = community.channels[0].id;
6535 let meta = control::ChannelMetadata { name: "general".into(), private: true, voice: None, deleted: None, custom: None, extra: Default::default() };
6536 let err = edit_channel_metadata(&relay, &community, &general, &meta).await.unwrap_err();
6537 assert!(err.contains("not supported"), "conversion is refused at the producer: {err}");
6538 let meta = control::ChannelMetadata { name: "lobby".into(), private: false, voice: None, deleted: None, custom: None, extra: Default::default() };
6540 edit_channel_metadata(&relay, &community, &general, &meta).await.unwrap();
6541 }
6542
6543 async fn publish_remote_tombstone(relay: &MemoryRelay, me: &Keys, relays: &[String], cid_hex: &str, removed_at: u64) {
6546 let doc = super::super::list::CommunityList {
6547 entries: vec![],
6548 tombstones: vec![super::super::list::Tombstone { community_id: cid_hex.to_string(), removed_at, extra: Default::default() }],
6549 extra: Default::default(),
6550 };
6551 let event = super::super::list::build_list_event(me, &doc).unwrap();
6552 relay.publish(&event, relays).await.unwrap();
6553 }
6554
6555 #[tokio::test]
6556 async fn joining_one_community_does_not_resurrect_a_sibling_left_community() {
6557 let (_tmp, _guard, me) = init_test_db();
6561 let relay = MemoryRelay::new();
6562 let x = create_community(&relay, "X", vec!["wss://r".into()], None).await.unwrap();
6563 let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
6564
6565 publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, now_ms() + 10_000).await;
6567
6568 let y = create_community(&relay, "Y", vec!["wss://r".into()], None).await.unwrap();
6570 republish_community_list(&relay, Some(y.id())).await.unwrap();
6571
6572 let list = fetch_community_list(&relay, &x.relays).await.unwrap().unwrap();
6574 assert!(!list.is_live(&x_hex), "joining Y did not resurrect the sibling-left X");
6575 assert!(list.is_live(&crate::simd::hex::bytes_to_hex_32(&y.id().0)), "Y is live");
6576 }
6577
6578 #[tokio::test]
6579 async fn sync_tears_down_a_community_a_sibling_left() {
6580 let (_tmp, _guard, me) = init_test_db();
6583 let relay = MemoryRelay::new();
6584 let x = create_community(&relay, "Leaveme", vec!["wss://r".into()], None).await.unwrap();
6585 let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
6586 assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_some(), "held before sync");
6587
6588 publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, now_ms() + 10_000).await;
6589 sync_community_list(&relay, &x.relays).await.unwrap();
6590 assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_none(), "the sibling's leave tore X down locally");
6591 }
6592
6593 #[tokio::test]
6594 async fn a_rejoined_community_survives_a_stale_tombstone_on_sync() {
6595 let (_tmp, _guard, me) = init_test_db();
6598 let relay = MemoryRelay::new();
6599 let x = create_community(&relay, "Rejoin", vec!["wss://r".into()], None).await.unwrap();
6600 let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
6601 publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, 1).await;
6603 republish_community_list(&relay, Some(x.id())).await.unwrap();
6605 sync_community_list(&relay, &x.relays).await.unwrap();
6606 assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_some(), "a re-joined community is not torn down by a stale tombstone");
6607 }
6608
6609 #[tokio::test]
6610 async fn a_failed_remote_fetch_never_clobbers_the_published_list() {
6611 let (_tmp, _guard, _me) = init_test_db();
6614 let good = MemoryRelay::new();
6615 let community = create_community(&good, "Seeded", vec!["wss://r".into()], None).await.unwrap();
6616 assert!(fetch_community_list(&good, &community.relays).await.unwrap().is_some());
6617
6618 struct FetchErrors;
6620 #[async_trait::async_trait]
6621 impl Transport for FetchErrors {
6622 async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
6623 panic!("republish must NOT publish when the remote fetch failed");
6624 }
6625 async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
6626 Ok(())
6627 }
6628 async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
6629 Err("relay unreachable".to_string())
6630 }
6631 }
6632 republish_community_list(&FetchErrors, Some(community.id())).await.unwrap();
6634 }
6635
6636 #[tokio::test]
6637 async fn a_granted_member_survives_a_refounding_even_with_no_guestbook_join() {
6638 let (_tmp, _guard, owner) = init_test_db();
6644 let relay = MemoryRelay::new();
6645 let community = create_community(&relay, "Backstop", vec!["wss://r".into()], None).await.unwrap();
6646
6647 let lurker = Keys::generate();
6649 let rid = "b1".repeat(32);
6650 publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
6651 publish_grant(&relay, &community, &owner, &lurker.public_key(), vec![rid.clone()], 1).await;
6652
6653 let members = memberlist(&relay, &community).await.unwrap();
6655 assert!(members.contains(&lurker.public_key()), "a granted member with no Join is still a member");
6656
6657 let banned_grantee = Keys::generate();
6659 publish_grant(&relay, &community, &owner, &banned_grantee.public_key(), vec![rid], 1).await;
6660 set_banlist(&relay, &community, &[banned_grantee.public_key().to_hex()]).await.unwrap();
6661 let members = memberlist(&relay, &community).await.unwrap();
6662 assert!(members.contains(&lurker.public_key()), "the honest grantee still counts");
6663 assert!(!members.contains(&banned_grantee.public_key()), "a banned grantee is not re-admitted by the union");
6664
6665 let refounded = refound_community(&relay, &community, &[]).await.unwrap();
6667 assert_eq!(refounded.root_epoch, Epoch(1));
6668 let base_group = base_rekey_group_key(&community.community_root, community.id(), Epoch(1));
6669 let chunks = fetch_rekey_chunks(&relay, &community.relays, &base_group).await.unwrap();
6670 let rotations = rekey::collect_rotations(&chunks);
6671 let lurker_x = lurker.public_key().to_bytes();
6672 let delivered = rotations.iter().any(|r| {
6673 rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &lurker_x, r.scope, r.new_epoch).is_some()
6674 });
6675 assert!(delivered, "the Refounding delivered the new root to the granted lurker");
6676 }
6677
6678 #[tokio::test]
6679 async fn the_memberlist_pages_past_a_guestbook_flood() {
6680 let (_tmp, _guard, owner) = init_test_db();
6684 let relay = MemoryRelay::new();
6685 let community = create_community(&relay, "GBFlood", vec!["wss://r".into()], None).await.unwrap();
6686 let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
6687
6688 let honest = Keys::generate();
6690 let join = guestbook::build_join_rumor(honest.public_key(), None, 1_000);
6691 let (w, _) = guestbook::seal_guestbook_rumor(&join, &gb, &honest, Timestamp::from_secs(1)).unwrap();
6692 relay.publish(&w, &community.relays).await.unwrap();
6693 for i in 0..600u64 {
6694 let throwaway = Keys::generate();
6695 let j = guestbook::build_join_rumor(throwaway.public_key(), None, 2_000 + i);
6696 let (w, _) = guestbook::seal_guestbook_rumor(&j, &gb, &throwaway, Timestamp::from_secs(2 + i)).unwrap();
6697 relay.publish(&w, &community.relays).await.unwrap();
6698 }
6699
6700 let members = memberlist(&relay, &community).await.unwrap();
6701 assert!(members.contains(&honest.public_key()), "the honest member's aged-out Join is still counted past the flood");
6702 }
6703
6704 #[tokio::test]
6705 async fn a_rekey_plane_flood_cannot_bury_a_genuine_rotation() {
6706 let (_tmp, _guard, owner) = init_test_db();
6711 let relay = MemoryRelay::new();
6712 let community = create_community(&relay, "Flooded", vec!["wss://r".into()], None).await.unwrap();
6713 let new_root = [0xD9; 32];
6714 let new_epoch = Epoch(1);
6715 let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
6716
6717 publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
6719
6720 let rogue = Keys::generate();
6723 let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
6724 for i in 0..260u64 {
6725 let blob = rekey::build_blob_local(rogue.secret_key(), &rogue.public_key().to_bytes(), &rogue.public_key(), RekeyScope::Root, new_epoch, &[0xEE; 32]).unwrap();
6726 let rumor = rekey::build_rekey_rumor(rogue.public_key(), RekeyScope::Root, new_epoch, Epoch(0), &prev_commit, &[blob], 1, 1, 3_000 + i).unwrap();
6727 let (wrap, _) = rekey::seal_rekey_chunk(&rumor, &group, &rogue, Timestamp::from_secs(3_000 + i)).unwrap();
6728 relay.publish(&wrap, &community.relays).await.unwrap();
6729 }
6730
6731 let session = SessionGuard::capture();
6732 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the genuine rotation is recovered past the flood");
6733 assert_eq!(updated.root_epoch, Epoch(1));
6734 assert_eq!(updated.community_root, new_root, "adopted the owner's root, not a junk one");
6735 }
6736
6737 #[tokio::test]
6738 async fn a_swap_during_create_private_channel_aborts_without_a_write() {
6739 let (bed, owner, _member) = TestBed::new();
6744 bed.swap_to(&owner);
6745 let community = create_community(&bed.relay, "SwapCreate", bed.relays.clone(), None).await.unwrap();
6746 let before = crate::db::community::load_community_v2(community.id()).unwrap().unwrap().channels.len();
6747
6748 let swap_relay = SwapMidFetch { inner: MemoryRelay::new() };
6750 let err = create_private_channel(&swap_relay, &community, "ghost").await.unwrap_err();
6751 assert!(err.contains("account changed"), "a swap mid-create aborts: {err}");
6752 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
6753 assert_eq!(after.channels.len(), before, "no channel row was written");
6754 assert!(!after.channels.iter().any(|c| c.name == "ghost"), "the ghost channel never persisted");
6755 }
6756
6757 #[tokio::test]
6758 async fn two_admins_racing_a_channel_rotation_converge_on_one_key() {
6759 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
6767 let (_tmp, _guard, owner) = init_test_db();
6768 let relay = MemoryRelay::new();
6769 let mut community = create_community(&relay, "Race", vec!["wss://r".into()], None).await.unwrap();
6770 let priv_id = ChannelId([0xC0; 32]);
6771 let key1 = [0xC1; 32];
6772 add_private_channel(&mut community, priv_id, key1, Epoch(1));
6773
6774 let (a, b) = (Keys::generate(), Keys::generate());
6776 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6777 let role = Role::admin("ce".repeat(32));
6778 let roster = CommunityRoles {
6779 roles: vec![role.clone()],
6780 grants: [&a, &b].iter().map(|k| MemberGrant { member: k.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }).collect(),
6781 };
6782 crate::db::community::set_community_roles(&cid_hex, &roster, 1_000).unwrap();
6783
6784 let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
6787 let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
6788 let group = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
6789 let key_a = [0x0A; 32];
6790 let key_b = [0xFB; 32]; for (signer, k) in [(&a, &key_a), (&b, &key_b)] {
6792 let blob = rekey::build_blob_local(signer.secret_key(), &signer.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), k).unwrap();
6793 for e in rekey::build_rekey_chunks_local(signer, &group, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[blob], 2_000).unwrap() {
6794 relay.publish(&e, &community.relays).await.unwrap();
6795 }
6796 }
6797
6798 let session = SessionGuard::capture();
6799 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopts a winner");
6800 let adopted = updated.channel(&priv_id).unwrap().key.unwrap();
6801 assert_eq!(adopted, key_a, "converges on the lexicographically lowest key (deterministic across clients)");
6802
6803 let mut peer = community.clone();
6805 if let Some(c) = peer.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
6806 c.key = Some(key1);
6807 c.epoch = Epoch(1);
6808 }
6809 let updated2 = follow_rekeys(&relay, &peer, &session).await.unwrap().updated.expect("peer adopts");
6811 assert_eq!(updated2.channel(&priv_id).unwrap().key.unwrap(), key_a, "every follower lands on the identical key");
6812 }
6813
6814 #[tokio::test]
6815 async fn create_private_channel_refuses_a_member_without_manage_channels() {
6816 let (bed, owner, member) = TestBed::new();
6819 bed.swap_to(&owner);
6820 let community = create_community(&bed.relay, "Gate", bed.relays.clone(), None).await.unwrap();
6821 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
6822
6823 bed.swap_to(&member);
6824 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
6825 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
6826 let err = create_private_channel(&bed.relay, &joined, "sneaky").await.unwrap_err();
6827 assert!(err.contains("MANAGE_CHANNELS"), "refused with the permission it lacks: {err}");
6828 let err = create_public_channel(&bed.relay, &joined, "sneaky-too").await.unwrap_err();
6829 assert!(err.contains("MANAGE_CHANNELS"), "public creation gates identically: {err}");
6830 }
6831
6832 #[tokio::test]
6835 async fn accept_rejects_a_bundle_with_a_forged_community_root() {
6836 let (bed, owner, member) = TestBed::new();
6840 bed.swap_to(&owner);
6841 let community = create_community(&bed.relay, "Real", bed.relays.clone(), None).await.unwrap();
6842
6843 let fake = crate::simd::hex::bytes_to_hex_32(&[0xEE; 32]);
6844 let mut forged = bundle_of(&community, None, None, None);
6845 forged.community_root = fake.clone();
6846 for ch in &mut forged.channels {
6847 ch.key = fake.clone();
6848 }
6849 let attacker = Keys::generate();
6850 let wrap = invite::build_direct_invite(&attacker, &member.keys.public_key(), &forged).unwrap();
6851 bed.relay.publish(&wrap, &bed.relays).await.unwrap();
6852
6853 bed.swap_to(&member);
6854 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
6855 let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
6856 assert!(err.contains("could not verify"), "a forged root fails the owner-genesis check: {err}");
6857 assert!(
6858 crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
6859 "a rejected join persists nothing"
6860 );
6861 }
6862
6863 #[tokio::test]
6864 async fn follow_control_heals_a_bundle_misclassified_public_channel() {
6865 let (_tmp, _guard, _owner) = init_test_db();
6869 let relay = MemoryRelay::new();
6870 let community = create_community(&relay, "Heal", vec!["wss://r".into()], None).await.unwrap();
6871 let general = community.channels[0].id;
6872 let mut poisoned = community.clone();
6873 poisoned.channels[0].private = true;
6874 poisoned.channels[0].key = Some([0x66; 32]);
6875 crate::db::community::save_community_v2(&poisoned).unwrap();
6876
6877 let session = SessionGuard::capture();
6878 let healed = follow_control(&relay, &poisoned, &session).await.unwrap().expect("healed");
6879 let ch = healed.channel(&general).unwrap();
6880 assert!(!ch.private, "the owner's public declaration overrides the bundle");
6881 assert_eq!(ch.key, None, "a healed public channel derives from the root");
6882 }
6883
6884 #[tokio::test]
6885 async fn a_deleted_channel_does_not_resurrect_on_reload() {
6886 let (_tmp, _guard, owner) = init_test_db();
6889 let relay = MemoryRelay::new();
6890 let community = create_community(&relay, "Prune", vec!["wss://r".into()], None).await.unwrap();
6891 let extra = ChannelId([0x77; 32]);
6892 let session = SessionGuard::capture();
6893 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
6894 let with_extra = follow_control(&relay, &community, &session).await.unwrap().unwrap();
6895 assert!(with_extra.channel(&extra).is_some());
6896 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
6897 let after = follow_control(&relay, &with_extra, &session).await.unwrap().unwrap();
6898 assert!(after.channel(&extra).is_none());
6899
6900 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
6901 assert!(reloaded.channel(&extra).is_none(), "a deleted channel must not resurrect on reload");
6902 assert_eq!(reloaded.channels.len(), 1);
6903 }
6904
6905 #[tokio::test]
6906 async fn a_channel_owned_by_another_community_is_skipped_not_clobbered() {
6907 let (_tmp, _guard, _owner) = init_test_db();
6911 let relay = MemoryRelay::new();
6912 let a = create_community(&relay, "A", vec!["wss://r".into()], None).await.unwrap();
6913 let a_channel = a.channels[0].id;
6914 let mut b = create_community(&relay, "B", vec!["wss://r".into()], None).await.unwrap();
6915 let b_channel = b.channels[0].id;
6916 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() });
6918
6919 crate::db::community::save_community_v2(&b).expect("save succeeds, the phantom is skipped");
6920 let a_reloaded = crate::db::community::load_community_v2(a.id()).unwrap().unwrap();
6922 assert!(!a_reloaded.channels.iter().any(|c| c.private), "A's channel is untouched");
6923 assert_eq!(a_reloaded.channels[0].id.0, a_channel.0);
6924 let b_reloaded = crate::db::community::load_community_v2(b.id()).unwrap().unwrap();
6926 assert!(b_reloaded.channel(&b_channel).is_some(), "B's own channel persists");
6927 assert!(b_reloaded.channel(&a_channel).is_none(), "the foreign-owned channel is skipped, not stolen");
6928 }
6929
6930 struct CappedRelay {
6936 events: Vec<Event>,
6937 cap: usize,
6938 }
6939 #[async_trait::async_trait]
6940 impl Transport for CappedRelay {
6941 async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
6942 Ok(())
6943 }
6944 async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
6945 Ok(())
6946 }
6947 async fn fetch(&self, q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
6948 let mut m: Vec<Event> = self
6949 .events
6950 .iter()
6951 .filter(|e| q.authors.is_empty() || q.authors.contains(&e.pubkey.to_hex()))
6952 .filter(|e| q.until.is_none_or(|u| e.created_at.as_secs() <= u))
6953 .cloned()
6954 .collect();
6955 m.sort_by(|a, b| b.created_at.cmp(&a.created_at)); m.truncate(self.cap.min(q.limit.unwrap_or(usize::MAX)));
6957 Ok(m)
6958 }
6959 }
6960
6961 #[tokio::test]
6962 async fn verify_pages_a_capped_relay_past_a_flood_to_the_genesis() {
6963 let (_tmp, _guard, owner) = init_test_db();
6968 let meta = control::CommunityMetadata { name: "Capped".into(), relays: vec!["wss://r".into()], ..Default::default() };
6969 let g = control::genesis(&owner, meta, 1_000).unwrap();
6970 let community = CommunityV2::from_genesis(&g, "Capped", None, vec!["wss://r".into()], 1_000);
6971
6972 let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
6973 let rogue = Keys::generate();
6974 let mut events: Vec<Event> = g.wraps.to_vec();
6975 for i in 0..250u64 {
6976 let rumor = control::build_edition_rumor(rogue.public_key(), vsk::CHANNEL_METADATA, &[0xAB; 32], 1, None, "{\"name\":\"junk\",\"private\":false}", 1_001 + i, None);
6977 let (wrap, _) = control::seal_control_edition(&rumor, &control, &rogue, Timestamp::from_secs(1_001 + i)).unwrap();
6978 events.push(wrap);
6979 }
6980 let relay = CappedRelay { events, cap: 100 };
6982 let verified = verify_owner_root_and_reconcile(&relay, community.clone()).await;
6983 assert!(verified.is_ok(), "the until-walk pages a capped relay past the flood to the genesis: {:?}", verified.err());
6984 }
6985
6986 #[tokio::test]
6987 async fn accept_parked_invite_joins_from_the_stored_bundle() {
6988 let (bed, owner, member) = TestBed::new();
6991 bed.swap_to(&owner);
6992 let community = create_community(&bed.relay, "Parked", bed.relays.clone(), None).await.unwrap();
6993 let general = community.channels[0].id;
6994 send_message(&bed.relay, &community, &general, "owner: hi").await.unwrap();
6995 let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
6996 let bundle_json = serde_json::to_string(&bundle).unwrap();
6997 let inviter_hex = owner.keys.public_key().to_hex();
6998
6999 bed.swap_to(&member);
7000 let joined = accept_parked_invite(&bed.relay, &bundle_json, Some(&inviter_hex)).await.unwrap();
7001 assert_eq!(joined.id().0, community.id().0, "joined the community from the parked bundle");
7002 assert!(joined.identity.verify());
7003 assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: hi"]);
7004 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
7007 assert!(
7008 crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().is_some(),
7009 "the joiner's control floor is seeded from the join-time fold"
7010 );
7011
7012 bed.swap_to(&owner);
7014 let members = memberlist(&bed.relay, &community).await.unwrap();
7015 assert!(members.contains(&member.keys.public_key()), "the parked-invite joiner is a member");
7016 }
7017
7018 #[tokio::test]
7019 async fn accept_parked_invite_rejects_a_forged_root() {
7020 let (_tmp, _guard, _owner) = init_test_db();
7024 let relay = MemoryRelay::new();
7025 let community = create_community(&relay, "Real", vec!["wss://r".into()], None).await.unwrap();
7026 let mut forged = bundle_of(&community, None, None, None);
7027 let fake = crate::simd::hex::bytes_to_hex_32(&[0xEE; 32]);
7028 forged.community_root = fake.clone();
7029 for ch in &mut forged.channels {
7030 ch.key = fake.clone();
7031 }
7032 let bundle_json = serde_json::to_string(&forged).unwrap();
7033
7034 let err = accept_parked_invite(&relay, &bundle_json, None).await.unwrap_err();
7035 assert!(err.contains("could not verify"), "a forged-root parked bundle fails definitively: {err}");
7036 }
7037
7038 #[test]
7039 fn v2_and_v1_bundles_are_distinguishable_by_parse() {
7040 let owner = Keys::generate();
7044 let identity = super::super::control::CommunityIdentity::mint(&owner.public_key());
7045 let hex = crate::simd::hex::bytes_to_hex_32;
7046 let v2 = invite::CommunityInvite {
7047 community_id: hex(&identity.community_id.0),
7048 owner: hex(&identity.owner_xonly),
7049 owner_salt: hex(&identity.owner_salt),
7050 community_root: hex(&[0x11; 32]),
7051 root_epoch: 0,
7052 channels: vec![],
7053 relays: vec!["wss://r".into()],
7054 name: "V2".into(),
7055 icon: None,
7056 expires_at: None,
7057 creator_npub: None,
7058 label: None,
7059 extra: Default::default(),
7060 };
7061 let v2_json = serde_json::to_string(&v2).unwrap();
7062 assert!(invite::CommunityInvite::from_bundle_json(&v2_json).is_ok(), "a real v2 bundle parses");
7063 let v1_like = r#"{"community_id":"aa","name":"X","relays":[]}"#;
7064 assert!(invite::CommunityInvite::from_bundle_json(v1_like).is_err(), "a v1 bundle is not a v2 bundle");
7065 }
7066
7067 #[tokio::test]
7068 async fn verify_rejects_a_cross_community_owner_edition_replay() {
7069 let (_tmp, _guard, owner) = init_test_db();
7075
7076 let gx = control::genesis(&owner, control::CommunityMetadata { name: "X".into(), ..Default::default() }, 1_000).unwrap();
7078 let x_control = control_group_key(&gx.community_root, &gx.identity.community_id, Epoch(0));
7079 let (_ed, opened) = control::open_control_edition(&gx.wraps[0], &x_control).unwrap();
7080
7081 let t_identity = control::CommunityIdentity::mint(&owner.public_key());
7083 let fake_root = [0xEE; 32];
7084 let t = CommunityV2 {
7085 identity: t_identity,
7086 community_root: fake_root,
7087 root_epoch: Epoch(0),
7088 name: "T".into(),
7089 description: None,
7090 icon: None,
7091 banner: None,
7092 meta_custom: None,
7093 meta_extra: Default::default(),
7094 relays: vec!["wss://r".into()],
7095 channels: vec![],
7096 dissolved: false,
7097 created_at_ms: 0,
7098 };
7099 let t_control = control_group_key(&fake_root, t.id(), t.root_epoch);
7102 let (replayed, _) = stream::rewrap_seal(&opened.seal, &t_control, Timestamp::from_secs(1_000)).unwrap();
7103 let relay = MemoryRelay::new();
7104 relay.publish(&replayed, &t.relays).await.unwrap();
7105
7106 let verified = verify_owner_root_and_reconcile(&relay, t.clone()).await;
7107 assert!(verified.is_err(), "a cross-community owner-edition replay must not authenticate a forged root");
7108 }
7109
7110 #[tokio::test]
7118 #[ignore = "hits a real relay over the network"]
7119 async fn live_smoke_create_send_fetch_on_a_real_relay() {
7120 use crate::community::transport::LiveTransport;
7121 use nostr_sdk::prelude::ToBech32;
7122
7123 let relay = std::env::var("VECTOR_SMOKE_RELAY").unwrap_or_else(|_| "wss://jskitty.com/nostr".to_string());
7124 let relays = vec![relay.clone()];
7125
7126 let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
7128 crate::db::close_database();
7129 crate::db::clear_id_caches();
7130 let tmp = tempfile::tempdir().unwrap();
7131 let keys = match std::env::var("VECTOR_SMOKE_NSEC") {
7134 Ok(n) => Keys::parse(&n).expect("VECTOR_SMOKE_NSEC is not a valid nsec"),
7135 Err(_) => Keys::generate(),
7136 };
7137 let npub = keys.public_key().to_bech32().unwrap();
7138 if std::env::var("VECTOR_SMOKE_PRINT_NSEC").is_ok() {
7141 println!("[smoke] OWNER nsec (throwaway — do NOT reuse): {}", keys.secret_key().to_bech32().unwrap());
7142 }
7143 std::fs::create_dir_all(tmp.path().join(&npub)).unwrap();
7144 crate::db::set_app_data_dir(tmp.path().to_path_buf());
7145 crate::db::set_current_account(npub.clone()).unwrap();
7146 crate::db::init_database(&npub).unwrap();
7147 crate::state::MY_SECRET_KEY.store_from_keys(&keys, &[]);
7148 crate::state::set_my_public_key(keys.public_key());
7149 println!("[smoke] throwaway identity {npub}");
7150
7151 let client = nostr_sdk::ClientBuilder::new().signer(keys.clone()).build();
7153 client.pool().add_relay(relay.as_str(), nostr_sdk::RelayOptions::default()).await.ok();
7154 client.connect().await;
7155 crate::state::set_nostr_client(client);
7156 let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(15));
7157
7158 let community = create_community(&transport, "V2 Live Smoke", relays.clone(), None).await.expect("create");
7160 let general = community.channels[0].id;
7161 println!("[smoke] created community {} on {relay}", crate::simd::hex::bytes_to_hex_32(&community.id().0));
7162
7163 let text = "hello from a Vector Concord v2 live smoke test";
7164 let sent_id = send_message(&transport, &community, &general, text).await.expect("send");
7165 println!("[smoke] sent message {sent_id}");
7166
7167 tokio::time::sleep(std::time::Duration::from_secs(3)).await;
7169
7170 let page = fetch_channel(&transport, &community, &general, 50).await.expect("fetch");
7171 let texts: Vec<String> = page
7172 .iter()
7173 .filter_map(|f| match &f.event {
7174 ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
7175 _ => None,
7176 })
7177 .collect();
7178 println!("[smoke] fetched {} message(s) back: {texts:?}", texts.len());
7179 assert!(texts.contains(&text.to_string()), "the message did not round-trip through the real relay");
7180
7181 let link = mint_public_link(&transport, &community, "https://vectorapp.io", None, None).await.expect("mint link");
7183 println!("[smoke] invite link: {}", link.url);
7184 println!("[smoke] PASS — v2 create+send+fetch+invite round-tripped on {relay}");
7185 }
7186
7187 #[tokio::test]
7188 async fn chat_ops_react_edit_delete_round_trip() {
7189 let (bed, owner, _member) = TestBed::new();
7190 bed.swap_to(&owner);
7191 let community = create_community(&bed.relay, "Ops", bed.relays.clone(), None).await.unwrap();
7192 let general = community.channels[0].id;
7193 let me_hex = owner.keys.public_key().to_hex();
7194
7195 let msg_id = send_message(&bed.relay, &community, &general, "original").await.unwrap();
7196 send_reaction(&bed.relay, &community, &general, &msg_id, &me_hex, super::super::kind::MESSAGE, ":fire:", Some(("fire", "https://e/f.png")))
7197 .await
7198 .unwrap();
7199 send_edit(&bed.relay, &community, &general, &msg_id, "edited").await.unwrap();
7200 send_delete(&bed.relay, &community, &general, &msg_id, super::super::kind::MESSAGE).await.unwrap();
7201
7202 let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
7203 let target = crate::simd::hex::hex_to_bytes_32(&msg_id);
7204 let mut saw = (false, false, false);
7205 for f in &page {
7206 match &f.event {
7207 ChatEvent::Reaction { target: t, emoji, emoji_url, .. } if *t == target => {
7208 assert_eq!(emoji, ":fire:");
7209 assert_eq!(emoji_url.as_deref(), Some("https://e/f.png"));
7210 saw.0 = true;
7211 }
7212 ChatEvent::Edit { target: t, new_content, .. } if *t == target => {
7213 assert_eq!(new_content, "edited");
7214 saw.1 = true;
7215 }
7216 ChatEvent::Delete { target: t, .. } if *t == target => saw.2 = true,
7217 _ => {}
7218 }
7219 }
7220 assert!(saw.0 && saw.1 && saw.2, "reaction/edit/delete all round-trip: {saw:?}");
7221 }
7222
7223 #[tokio::test]
7224 async fn a_typing_signal_rides_the_ephemeral_wrap_and_is_never_stored() {
7225 let (bed, owner, _member) = TestBed::new();
7226 bed.swap_to(&owner);
7227 let community = create_community(&bed.relay, "Typ", bed.relays.clone(), None).await.unwrap();
7228 let general = community.channels[0].id;
7229 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
7230
7231 let mut sub = bed.relay.subscribe(Query {
7233 kinds: vec![stream::KIND_WRAP_EPHEMERAL],
7234 authors: vec![group.pk_hex()],
7235 ..Default::default()
7236 });
7237 send_typing(&bed.relay, &community, &general).await.unwrap();
7238 let wrap = sub.try_recv().expect("the typing wrap streams to a live subscriber");
7239 let opened = match chat::open_chat_event(&wrap, &group, &general, community.root_epoch) {
7240 Ok(ChatEvent::Typing { opened }) => opened,
7241 other => panic!("the ephemeral wrap must open as a Typing event, got {other:?}"),
7242 };
7243
7244 let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
7247 assert!(page.iter().all(|f| !matches!(f.event, ChatEvent::Typing { .. })));
7248
7249 assert!(
7251 crate::db::community::get_message_key(&opened.rumor_id.to_hex()).unwrap().is_none(),
7252 "ephemeral sends must not retain scrub keys"
7253 );
7254 }
7255
7256 #[tokio::test]
7257 async fn a_durable_send_retains_the_wrap_scrub_key_and_full_delete_nukes_the_relay_copy() {
7258 let (bed, owner, _member) = TestBed::new();
7259 bed.swap_to(&owner);
7260 let community = create_community(&bed.relay, "Nuke", bed.relays.clone(), None).await.unwrap();
7261 let general = community.channels[0].id;
7262 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
7263
7264 let id = send_message(&bed.relay, &community, &general, "scrub me").await.unwrap();
7265
7266 let (keys, outer_hex, relays) =
7269 crate::db::community::get_message_key(&id).unwrap().expect("a durable send retains its scrub key");
7270 assert_eq!(relays, community.relays);
7271 let wrap_query = Query {
7272 kinds: vec![stream::KIND_WRAP],
7273 authors: vec![group.pk_hex()],
7274 ..Default::default()
7275 };
7276 let wraps = bed.relay.fetch(&wrap_query, &community.relays).await.unwrap();
7277 let wrap = wraps.iter().find(|w| w.id.to_hex() == outer_hex).expect("retained outer id is the published wrap");
7278 assert_eq!(keys.public_key(), wrap.pubkey, "retained key is the wrap's author");
7279
7280 let me_hex = owner.keys.public_key().to_hex();
7282 let rid = send_reaction(&bed.relay, &community, &general, &id, &me_hex, super::super::kind::MESSAGE, "🔥", None)
7283 .await
7284 .unwrap();
7285 assert!(crate::db::community::get_message_key(&rid).unwrap().is_some(), "reaction sends retain too");
7286
7287 crate::community::service::delete_message(&bed.relay, &id).await.unwrap();
7290 assert!(crate::db::community::get_message_key(&id).unwrap().is_none(), "key consumed after the scrub");
7291 let after = bed.relay.fetch(&wrap_query, &community.relays).await.unwrap();
7292 assert!(!after.iter().any(|w| w.id.to_hex() == outer_hex), "wrap scrubbed from the relay");
7293 }
7294
7295 #[tokio::test]
7296 async fn backfill_heals_scrub_keys_for_own_pre_retention_messages_only() {
7297 let (bed, owner, _member) = TestBed::new();
7298 bed.swap_to(&owner);
7299 let community = create_community(&bed.relay, "Heal", bed.relays.clone(), None).await.unwrap();
7300 let general = community.channels[0].id;
7301 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
7302
7303 let id = send_message(&bed.relay, &community, &general, "old send").await.unwrap();
7306 crate::db::community::delete_message_key(&id).unwrap();
7307 assert!(crate::db::community::get_message_key(&id).unwrap().is_none());
7308
7309 let mkeys = Keys::generate();
7311 let rumor = chat::build_message_rumor(mkeys.public_key(), &general, community.root_epoch, "foreign", None, &[], vec![], 6_000);
7312 let foreign_id = rumor.id.unwrap().to_hex();
7313 let (fw, _) = chat::seal_chat_rumor(&rumor, &group, &mkeys, Timestamp::from_secs(6), false).unwrap();
7314 bed.relay.publish(&fw, &community.relays).await.unwrap();
7315
7316 fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
7318 let (keys, _outer, relays) =
7319 crate::db::community::get_message_key(&id).unwrap().expect("backfill heals own unretained rows");
7320 assert_eq!(keys.public_key(), group.pk(), "healed key is the wrap's signing key");
7321 assert_eq!(relays, community.relays);
7322
7323 assert!(crate::db::community::get_message_key(&foreign_id).unwrap().is_none());
7325
7326 crate::community::service::delete_message(&bed.relay, &id).await.unwrap();
7328 let left = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
7329 assert!(
7330 !left.iter().any(|f| f.event.opened().rumor_id.to_hex() == id),
7331 "healed message scrubbed from the relay"
7332 );
7333 }
7334
7335 #[tokio::test]
7336 async fn send_chat_message_threads_the_reply_and_extra_tags() {
7337 let (bed, owner, _member) = TestBed::new();
7338 bed.swap_to(&owner);
7339 let community = create_community(&bed.relay, "Re", bed.relays.clone(), None).await.unwrap();
7340 let general = community.channels[0].id;
7341 let me_hex = owner.keys.public_key().to_hex();
7342
7343 let parent_id = send_message(&bed.relay, &community, &general, "parent").await.unwrap();
7344 let imeta = nostr_sdk::prelude::Tag::custom(
7345 nostr_sdk::prelude::TagKind::custom("imeta"),
7346 ["url https://e/blob".to_string(), "m image/png".to_string()],
7347 );
7348 let child_id = send_chat_message(
7349 &bed.relay, &community, &general, "child",
7350 Some((parent_id.as_str(), me_hex.as_str())), &[], vec![imeta],
7351 )
7352 .await
7353 .unwrap();
7354
7355 let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
7356 let child = page
7357 .iter()
7358 .find_map(|f| match &f.event {
7359 ChatEvent::Message { opened, reply_to, .. } if opened.rumor_id.to_hex() == child_id => Some((opened, reply_to)),
7360 _ => None,
7361 })
7362 .expect("the reply message round-trips");
7363 let reply = child.1.as_ref().expect("the reply reference is carried");
7364 assert_eq!(crate::simd::hex::bytes_to_hex_32(&reply.id), parent_id);
7365 assert_eq!(reply.author, Some(owner.keys.public_key()));
7366 assert!(
7367 child.0.rumor.tags.iter().any(|t| t.kind() == nostr_sdk::prelude::TagKind::custom("imeta")),
7368 "the imeta attachment tag rides the rumor verbatim"
7369 );
7370 }
7371
7372 #[tokio::test]
7373 async fn a_kick_needs_kick_authority_and_removes_the_target() {
7374 let (bed, owner, member) = TestBed::new();
7375 bed.swap_to(&owner);
7376 let community = create_community(&bed.relay, "Kick", bed.relays.clone(), None).await.unwrap();
7377
7378 let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
7380 let join = guestbook::build_join_rumor(member.keys.public_key(), None, 2_000);
7381 let (wrap, _) = guestbook::seal_guestbook_rumor(&join, &gb, &member.keys, Timestamp::from_secs(2)).unwrap();
7382 bed.relay.publish(&wrap, &bed.relays).await.unwrap();
7383 let before = memberlist(&bed.relay, &community).await.unwrap();
7384 assert!(before.contains(&member.keys.public_key()), "the join lands first");
7385
7386 bed.swap_to(&member);
7388 let err = kick_member(&bed.relay, &community, &owner.keys.public_key()).await.unwrap_err();
7389 assert!(err.contains("not authorized"), "unprivileged kick refused: {err}");
7390
7391 bed.swap_to(&owner);
7393 kick_member(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
7394 let after = memberlist(&bed.relay, &community).await.unwrap();
7395 assert!(!after.contains(&member.keys.public_key()), "the kicked member leaves the fold");
7396 assert!(after.contains(&owner.keys.public_key()), "the owner remains");
7397 }
7398
7399 #[tokio::test]
7400 async fn grant_admin_mints_one_deterministic_role_and_revoke_strips_it() {
7401 let (bed, owner, member) = TestBed::new();
7402 bed.swap_to(&owner);
7403 let community = create_community(&bed.relay, "Adm", bed.relays.clone(), None).await.unwrap();
7404 let member_pk = member.keys.public_key();
7405 let member_hex = member_pk.to_hex();
7406 let owner_hex = owner.keys.public_key().to_hex();
7407
7408 grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
7409 let view = fetch_authority(&bed.relay, &community).await;
7410 assert!(view.roles.is_admin(&member_hex), "the grant folds as admin");
7411 assert!(view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::MANAGE_ROLES));
7412
7413 let second = Keys::generate().public_key();
7416 grant_admin(&bed.relay, &community, &second).await.unwrap();
7417 grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
7418 let view = fetch_authority(&bed.relay, &community).await;
7419 assert_eq!(view.roles.roles.len(), 1, "one Admin role, never a fork");
7420 assert!(view.roles.is_admin(&member_hex) && view.roles.is_admin(&second.to_hex()));
7421 let grant = view.roles.grants.iter().find(|g| g.member == member_hex).unwrap();
7422 assert_eq!(grant.role_ids.len(), 1, "no duplicate role id in the grant");
7423
7424 revoke_admin(&bed.relay, &community, &member_pk).await.unwrap();
7426 let view = fetch_authority(&bed.relay, &community).await;
7427 assert!(!view.roles.is_admin(&member_hex), "revoked");
7428 assert!(view.roles.is_admin(&second.to_hex()), "the other admin is untouched");
7429 assert!(!view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::KICK));
7430 }
7431
7432 #[tokio::test]
7433 async fn follow_control_persists_the_roster_for_sync_local_reads() {
7434 let (bed, owner, member) = TestBed::new();
7435 bed.swap_to(&owner);
7436 let community = create_community(&bed.relay, "Persist", bed.relays.clone(), None).await.unwrap();
7437 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
7438 let member_hex = member.keys.public_key().to_hex();
7439 grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
7440
7441 let session = crate::state::SessionGuard::capture();
7443 follow_control(&bed.relay, &community, &session).await.unwrap();
7444 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
7445 assert!(roster.is_admin(&member_hex), "the persisted roster reads back without a fetch");
7446
7447 let withholding = MemoryRelay::new();
7450 let _ = follow_control(&withholding, &community, &session).await;
7451 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
7452 assert!(roster.is_admin(&member_hex), "withholding never shrinks standing");
7453
7454 revoke_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
7456 follow_control(&bed.relay, &community, &session).await.unwrap();
7457 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
7458 assert!(!roster.is_admin(&member_hex), "the revoke folds + persists");
7459 }
7460
7461 #[tokio::test]
7462 async fn grant_admin_is_refused_for_a_non_owner_and_publishes_nothing() {
7463 let (bed, owner, member) = TestBed::new();
7464 bed.swap_to(&owner);
7465 let community = create_community(&bed.relay, "NoSquat", bed.relays.clone(), None).await.unwrap();
7466
7467 bed.swap_to(&member);
7468 let err = grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap_err();
7469 assert!(err.contains("owner"), "refused before any publish: {err}");
7470
7471 bed.swap_to(&owner);
7474 let view = fetch_authority(&bed.relay, &community).await;
7475 assert!(view.roles.roles.is_empty(), "no role edition landed");
7476 grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
7477 let view = fetch_authority(&bed.relay, &community).await;
7478 assert!(view.roles.is_admin(&member.keys.public_key().to_hex()));
7479 }
7480
7481 #[tokio::test]
7482 async fn grant_admin_merges_other_roles_and_refuses_a_withheld_grant() {
7483 let (bed, owner, member) = TestBed::new();
7484 bed.swap_to(&owner);
7485 let community = create_community(&bed.relay, "Merge", bed.relays.clone(), None).await.unwrap();
7486 let member_pk = member.keys.public_key();
7487
7488 let mod_rid = crate::simd::hex::bytes_to_hex_32(&[0x66; 32]);
7491 set_role(&bed.relay, &community, &admin_role(&mod_rid, Permissions::BAN)).await.unwrap();
7492 grant_roles(&bed.relay, &community, &member_pk, vec![mod_rid.clone()]).await.unwrap();
7493
7494 let withholding = MemoryRelay::new();
7497 let err = grant_admin(&withholding, &community, &member_pk).await.unwrap_err();
7498 assert!(err.contains("could not be fetched"), "withheld grant refused: {err}");
7499
7500 grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
7502 let view = fetch_authority(&bed.relay, &community).await;
7503 let grant = view.roles.grants.iter().find(|g| g.member == member_pk.to_hex()).unwrap();
7504 assert_eq!(grant.role_ids.len(), 2, "admin ADDED to the existing grant, not replacing it");
7505 assert!(grant.role_ids.contains(&mod_rid));
7506 }
7507
7508 #[tokio::test]
7509 async fn fetch_authority_reflects_a_granted_admin() {
7510 let (bed, owner, member) = TestBed::new();
7511 bed.swap_to(&owner);
7512 let community = create_community(&bed.relay, "Auth", bed.relays.clone(), None).await.unwrap();
7513 let rid = crate::simd::hex::bytes_to_hex_32(&[0x5a; 32]);
7514 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
7515 publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
7516
7517 let view = fetch_authority(&bed.relay, &community).await;
7518 let member_hex = member.keys.public_key().to_hex();
7519 assert!(view.roles.is_admin(&member_hex), "the granted member folds as admin");
7520 assert!(
7521 view.roles.is_authorized(&member_hex, Some(&owner.keys.public_key().to_hex()), Permissions::KICK),
7522 "an ADMIN_ALL grant carries KICK"
7523 );
7524 assert!(view.banned.is_empty());
7525 }
7526}