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 mut seen_wraps: std::collections::HashSet<nostr_sdk::EventId> = std::collections::HashSet::new();
429 let mut seen_rumors = std::collections::HashSet::new();
430 let mut out: Vec<(u64, FetchedEvent)> = Vec::new();
431 let mut until: Option<u64> = None;
432 let mut oldest: Option<u64> = None;
433 for _ in 0..max_pages {
434 let mut wraps: Vec<Event> = Vec::new();
440 let mut wrap_ids: std::collections::HashSet<nostr_sdk::EventId> = std::collections::HashSet::new();
441 for (secret, epoch) in &coords {
442 let plane = channel_group_key(secret, channel_id, *epoch);
443 let q = Query {
444 kinds: vec![stream::KIND_WRAP],
445 authors: vec![plane.pk_hex()],
446 until,
447 limit: Some(page),
448 ..Default::default()
449 };
450 if let Ok(evs) = transport.fetch_plane(plane.keys(), &q, &community.relays).await {
451 for e in evs {
452 if wrap_ids.insert(e.id) {
453 wraps.push(e);
454 }
455 }
456 }
457 }
458 if wraps.is_empty() {
459 break;
460 }
461 let mut fresh = 0usize;
462 let mut page_events: Vec<FetchedEvent> = Vec::new();
463 for wrap in &wraps {
464 if !seen_wraps.insert(wrap.id) {
465 continue;
466 }
467 fresh += 1;
468 let at = wrap.created_at.as_secs();
469 if oldest.is_none_or(|o| at < o) {
470 oldest = Some(at);
471 }
472 for (secret, epoch) in &coords {
474 let group = channel_group_key(secret, channel_id, *epoch);
475 if wrap.pubkey != group.pk() {
476 continue;
477 }
478 if let Ok(event) = chat::open_chat_event(wrap, &group, channel_id, *epoch) {
479 let id = event.opened().rumor_id;
480 if seen_rumors.insert(id) {
481 if session.is_valid() {
482 heal_own_wrap_key(&event, &group, &community.relays);
483 }
484 page_events.push(FetchedEvent { event, epoch: *epoch });
485 }
486 }
487 break;
488 }
489 }
490 if fresh == 0 {
491 if wraps.len() < page {
492 break; }
494 let Some(o) = oldest else { break };
498 if o == 0 {
499 break;
500 }
501 crate::log_warn!("v2: same-second history wall at {o} — stepping past it (messages beyond the relay page cap in that second are unreachable)");
502 until = Some(o - 1);
503 continue;
504 }
505 let stop = !page_events.is_empty() && !keep_paging(&page_events);
506 out.extend(page_events.into_iter().map(|e| (e.event.opened().at_ms, e)));
507 if stop {
508 break; }
510 until = oldest; }
512 out.sort_by_key(|(ms, _)| *ms);
513 Ok(out.into_iter().map(|(_, e)| e).collect())
514}
515
516pub fn bundle_of(
523 community: &CommunityV2,
524 creator: Option<PublicKey>,
525 expires_at_ms: Option<u64>,
526 label: Option<String>,
527) -> CommunityInvite {
528 let hex = crate::simd::hex::bytes_to_hex_32;
529 let channels = community
530 .channels
531 .iter()
532 .filter(|c| !(c.private && c.key.is_none()))
537 .map(|c| invite::ChannelGrant {
538 id: hex(&c.id.0),
539 key: hex(&c.key.unwrap_or(community.community_root)),
540 epoch: c.epoch.0,
541 name: c.name.clone(),
542 })
543 .collect();
544 CommunityInvite {
545 community_id: hex(&community.identity.community_id.0),
546 owner: hex(&community.identity.owner_xonly),
547 owner_salt: hex(&community.identity.owner_salt),
548 community_root: hex(&community.community_root),
549 root_epoch: community.root_epoch.0,
550 channels,
551 relays: community.relays.clone(),
552 name: community.name.clone(),
553 icon: community.icon.clone(),
556 expires_at: expires_at_ms,
557 creator_npub: creator.map(|p| p.to_hex()),
558 label,
559 extra: Default::default(),
560 }
561}
562
563pub async fn send_direct_invite<T: Transport + ?Sized>(
569 transport: &T,
570 community: &CommunityV2,
571 recipient: &PublicKey,
572 expires_at_ms: Option<u64>,
573 label: Option<String>,
574) -> Result<Event, String> {
575 let session = SessionGuard::capture();
576 let inviter = local_keys()?;
577 let bundle = bundle_of(community, Some(inviter.public_key()), expires_at_ms, label);
578 let wrap = invite::build_direct_invite(&inviter, recipient, &bundle).map_err(|e| e.to_string())?;
579 if !session.is_valid() {
580 return Err("account changed before sending invite".to_string());
581 }
582 transport.publish(&wrap, &community.relays).await?;
583 Ok(wrap)
584}
585
586pub struct MintedLink {
590 pub url: String,
591 pub bundle_event: Event,
592 pub link_signer: Keys,
593 pub token: [u8; super::derive::TOKEN_LEN],
594}
595
596pub async fn mint_public_link<T: Transport + ?Sized>(
602 transport: &T,
603 community: &CommunityV2,
604 base: &str,
605 expires_at_ms: Option<u64>,
606 label: Option<String>,
607) -> Result<MintedLink, String> {
608 let session = SessionGuard::capture();
609 let mut token = [0u8; super::derive::TOKEN_LEN];
610 token.copy_from_slice(&super::super::random_32()[..super::derive::TOKEN_LEN]);
611 let link_signer = Keys::generate();
612 let bundle = bundle_of(community, Some(local_keys()?.public_key()), expires_at_ms, label.clone());
613 let bundle_key = super::derive::invite_bundle_key(&token);
614 let bundle_event = invite::build_bundle_event(&link_signer, &bundle, &bundle_key).map_err(|e| e.to_string())?;
615 let url = invite::build_invite_url(base, &link_signer.public_key(), &token, &community.relays).map_err(|e| e.to_string())?;
616
617 if !session.is_valid() {
618 return Err("account changed before minting link".to_string());
619 }
620 transport.publish_durable(&bundle_event, &community.relays).await?;
621 let minted = MintedLink { url, bundle_event, link_signer, token };
622 let _ = record_minted_link(transport, community, &minted).await;
626 if session.is_valid() {
631 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
632 let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
633 let _ = crate::db::community::save_public_invite(&token_hex, &cid_hex, &minted.url, expires_at_ms.map(|e| e as i64), label.as_deref());
634 }
635 Ok(minted)
636}
637
638async fn fetch_invite_list<T: Transport + ?Sized>(
647 transport: &T,
648 relays: &[String],
649) -> Result<Option<invite::InviteList>, String> {
650 let me = local_keys()?;
651 let query = Query {
652 kinds: vec![super::kind::INVITE_LIST],
653 authors: vec![me.public_key().to_hex()],
654 limit: Some(4),
655 evidence: crate::community::transport::Evidence::Full,
656 ..Default::default()
657 };
658 let events = transport.fetch(&query, relays).await?;
659 Ok(events
660 .into_iter()
661 .filter_map(|e| invite::parse_invite_list_event(&e, &me).ok().map(|l| (e.created_at.as_secs(), l)))
662 .max_by_key(|(at, _)| *at)
663 .map(|(_, l)| l))
664}
665
666fn live_signers_for(list: &invite::InviteList, community_id_hex: &str) -> Vec<PublicKey> {
669 let dead: std::collections::HashSet<&str> = list.tombstones.iter().map(|t| t.token.as_str()).collect();
670 list.entries
671 .iter()
672 .filter(|e| e.community_id == community_id_hex && !dead.contains(e.token.as_str()))
673 .filter_map(|e| Keys::parse(&e.signer_sk).ok().map(|k| k.public_key()))
674 .collect()
675}
676
677async fn publish_invite_registry<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, session: &SessionGuard, live_signers: &[PublicKey]) -> Result<(), String> {
681 let me = local_keys()?;
682 let eid = super::derive::invite_links_locator(community.id(), &me.public_key().to_bytes());
683 let content = invite::build_registry_content(live_signers);
684 publish_control_edition(transport, community, session, vsk::INVITE_LINKS, &eid, &content, None).await
685}
686
687async fn record_minted_link<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, minted: &MintedLink) -> Result<(), String> {
690 let session = SessionGuard::capture();
691 let me = local_keys()?;
692 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
693 let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
694 let mut list = fetch_invite_list(transport, &community.relays).await?.unwrap_or_default();
699 if !list.entries.iter().any(|e| e.token == token_hex) {
700 list.entries.push(invite::InviteEntry {
701 token: token_hex,
702 signer_sk: minted.link_signer.secret_key().to_secret_hex(),
703 community_id: cid_hex.clone(),
704 url: minted.url.clone(),
705 label: None,
706 created_at: now_ms() / 1000,
707 expires_at: None,
708 extra: Default::default(),
709 });
710 }
711 if !session.is_valid() {
712 return Err("account changed during link record".to_string());
713 }
714 let event = invite::build_invite_list_event(&me, &list).map_err(|e| e.to_string())?;
715 transport.publish(&event, &community.relays).await?;
716 let signers = live_signers_for(&list, &cid_hex);
717 publish_invite_registry(transport, community, &session, &signers).await
718}
719
720pub async fn revoke_public_link<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, token_hex: &str) -> Result<(), String> {
726 let session = SessionGuard::capture();
727 let me = local_keys()?;
728 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
729 let mut list = fetch_invite_list(transport, &community.relays).await?.ok_or("no invite list found to revoke from")?;
730 let entry = list
731 .entries
732 .iter()
733 .find(|e| e.token == token_hex && e.community_id == cid_hex)
734 .cloned()
735 .ok_or("no such link in the invite list")?;
736 let link_signer = Keys::parse(&entry.signer_sk).map_err(|_| "malformed link signer")?;
738 let revocation = invite::build_revocation(&link_signer).map_err(|e| e.to_string())?;
739 if !session.is_valid() {
740 return Err("account changed during revoke".to_string());
741 }
742 transport.publish_durable(&revocation, &community.relays).await?;
743 list.tombstones.push(invite::InviteTombstone { token: token_hex.to_string(), community_id: cid_hex.clone(), extra: Default::default() });
745 list.entries.retain(|e| e.token != token_hex);
746 let event = invite::build_invite_list_event(&me, &list).map_err(|e| e.to_string())?;
747 transport.publish(&event, &community.relays).await?;
748 let signers = live_signers_for(&list, &cid_hex);
749 publish_invite_registry(transport, community, &session, &signers).await?;
750 if session.is_valid() {
752 let _ = crate::db::community::delete_public_invite(token_hex);
753 }
754 Ok(())
755}
756
757pub async fn refresh_public_links<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
762 let session = SessionGuard::capture();
763 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
764 let me = local_keys()?;
769 let query = Query {
770 kinds: vec![super::kind::INVITE_LIST],
771 authors: vec![me.public_key().to_hex()],
772 limit: Some(4),
773 ..Default::default()
774 };
775 let events = transport.fetch(&query, &community.relays).await?;
776 let list = events
777 .into_iter()
778 .filter_map(|e| invite::parse_invite_list_event(&e, &me).ok().map(|l| (e.created_at.as_secs(), l)))
779 .max_by_key(|(at, _)| *at)
780 .map(|(_, l)| l);
781 let Some(list) = list else {
782 return Ok(());
783 };
784 let creator = local_keys()?.public_key();
785 let dead: std::collections::HashSet<&str> = list.tombstones.iter().map(|t| t.token.as_str()).collect();
786 for entry in &list.entries {
787 if entry.community_id != cid_hex || dead.contains(entry.token.as_str()) || entry.token.len() != 2 * super::derive::TOKEN_LEN {
788 continue;
789 }
790 let Ok(link_signer) = Keys::parse(&entry.signer_sk) else { continue };
791 let token = crate::simd::hex::hex_to_bytes_16(&entry.token);
792 let bundle = bundle_of(community, Some(creator), entry.expires_at, entry.label.clone());
793 let bundle_key = super::derive::invite_bundle_key(&token);
794 if let Ok(event) = invite::build_bundle_event(&link_signer, &bundle, &bundle_key) {
795 if !session.is_valid() {
796 return Err("account changed during link refresh".to_string());
797 }
798 let _ = transport.publish_durable(&event, &community.relays).await;
799 }
800 }
801 Ok(())
802}
803
804pub async fn community_is_public<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> bool {
809 use crate::community::roles::Permissions;
810 use std::collections::BTreeMap;
811 let Ok(owner) = community.owner() else { return false };
812 let owner_hex = owner.to_hex();
813 let cid = community.id();
814 let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
815 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
816 .unwrap_or_default()
817 .into_iter()
818 .filter(|(_, f)| f.0 == community.root_epoch.0)
819 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
820 .collect();
821 let control = control_group_key(&community.community_root, cid, community.root_epoch);
822 let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![control.pk_hex()], limit: Some(FOLLOW_PAGE), ..Default::default() };
823 let editions: Vec<ParsedEdition> = transport
824 .fetch(&query, &community.relays)
825 .await
826 .unwrap_or_default()
827 .iter()
828 .filter_map(|w| control::open_control_edition(w, &control).ok().map(|(ed, _)| ed))
829 .collect();
830 let authority = fold_authority(community, &editions, &floors);
831
832 let mut by_eid: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
833 for e in &editions {
834 if e.vsk == vsk::INVITE_LINKS {
835 by_eid.entry(e.entity_id).or_default().push(e);
836 }
837 }
838 for (eid, group) in &by_eid {
839 let fold_eds: Vec<version::Edition> = group.iter().map(|p| p.to_fold_edition()).collect();
840 let (Some(hi), _) = fold_head(&fold_eds, floors.get(&crate::simd::hex::bytes_to_hex_32(eid))) else { continue };
841 let head = group[hi];
842 if !authority.roles.is_authorized(&head.author.to_hex(), Some(&owner_hex), Permissions::CREATE_INVITE) {
844 continue;
845 }
846 if super::derive::invite_links_locator(cid, &head.author.to_bytes()) != *eid {
847 continue;
848 }
849 if invite::parse_registry_content(&head.content).map(|s| !s.is_empty()).unwrap_or(false) {
850 return true;
851 }
852 }
853 false
854}
855
856async fn accept_bundle<T: Transport + ?Sized>(
862 transport: &T,
863 session: &SessionGuard,
864 bundle: &CommunityInvite,
865 invited_by: Option<PublicKey>,
866) -> Result<CommunityV2, String> {
867 let me = local_keys()?;
868 let at_ms = now_ms();
869 if bundle.expired(at_ms) {
871 return Err("this invite has expired".to_string());
872 }
873 let community = CommunityV2::from_bundle(bundle, at_ms)?;
875
876 let handoff = VERIFIED_PREVIEW.lock().unwrap().take().filter(|v| {
885 v.session.is_valid()
886 && v.at.elapsed() < VERIFIED_PREVIEW_TTL
887 && v.community_id == community.id().0
888 && v.community_root == community.community_root
889 });
890 let (community, join_heads) = match handoff {
891 Some(v) => {
892 let mut c = v.folded;
893 c.created_at_ms = at_ms;
895 (c, v.heads)
896 }
897 None => verify_owner_root_and_reconcile(transport, community).await?,
898 };
899
900 if is_dissolved(transport, &community).await {
902 return Err("this community has been dissolved".to_string());
903 }
904
905 if !session.is_valid() {
909 return Err("account changed during join".to_string());
910 }
911 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
916 for h in &join_heads {
917 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)?;
918 }
919 crate::db::community::save_community_v2(&community)?;
920 let _ = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, community.root_epoch.0, &community.community_root);
923 for ch in &community.channels {
926 if let (true, Some(key)) = (ch.private, ch.key) {
927 let _ = crate::db::community::store_epoch_key(&cid_hex, &crate::simd::hex::bytes_to_hex_32(&ch.id.0), ch.epoch.0, &key);
928 }
929 }
930
931 let attribution = invited_by
933 .map(|p| p.to_hex())
934 .or_else(|| bundle.creator_npub.clone())
935 .zip(Some(bundle.label.clone().unwrap_or_default()));
936 let attr_ref = attribution.as_ref().map(|(c, l)| (c.as_str(), l.as_str()));
937 let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
938 let join_rumor = guestbook::build_join_rumor(me.public_key(), attr_ref, at_ms);
939 if let Ok((join_wrap, _)) = guestbook::seal_guestbook_rumor(&join_rumor, &gb_group, &me, Timestamp::from_secs(at_ms / 1000)) {
940 let _ = transport.publish(&join_wrap, &community.relays).await;
941 }
942
943 let _ = republish_community_list(transport, Some(community.id())).await;
945 Ok(community)
946}
947
948async fn verify_owner_root_and_reconcile<T: Transport + ?Sized>(
961 transport: &T,
962 community: CommunityV2,
963) -> Result<(CommunityV2, Vec<FoldedHead>), String> {
964 let owner = community.owner()?;
965 let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
966 let control_pk = control.pk_hex();
967
968 super::streamauth::prime(&community);
975
976 const PAGE: usize = 500;
996 const MAX_PAGES: usize = 4;
997 const FAR_FUTURE_SECS: u64 = 4_102_444_800; let mut editions: Vec<ParsedEdition> = Vec::new();
999 let mut found_genesis = false;
1000 let mut until: Option<u64> = Some(FAR_FUTURE_SECS);
1001 let mut seen_wraps: std::collections::HashSet<nostr_sdk::EventId> = std::collections::HashSet::new();
1002 for _ in 0..MAX_PAGES {
1003 let query = Query {
1004 kinds: vec![stream::KIND_WRAP],
1005 authors: vec![control_pk.clone()],
1006 until,
1007 limit: Some(PAGE),
1008 ..Default::default()
1009 };
1010 let wraps = transport.fetch(&query, &community.relays).await?;
1011 let mut oldest = u64::MAX;
1015 let mut fresh = 0usize;
1016 for w in &wraps {
1017 if !seen_wraps.insert(w.id) {
1018 continue;
1019 }
1020 fresh += 1;
1021 oldest = oldest.min(w.created_at.as_secs());
1022 if let Ok((ed, _)) = control::open_control_edition(w, &control) {
1023 if ed.author == owner {
1024 if ed.vsk == vsk::COMMUNITY_METADATA && ed.entity_id == community.id().0 {
1025 found_genesis = true;
1026 }
1027 editions.push(ed);
1028 }
1029 }
1030 }
1031 if found_genesis || fresh == 0 {
1032 break; }
1034 until = Some(oldest);
1035 }
1036 if !found_genesis {
1037 return Err(
1038 "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"
1039 .to_string(),
1040 );
1041 }
1042 let empty_floors = Floors::new();
1051 let authority = AuthoritySet::owner_only();
1052 let fold = apply_control_fold(&community, &editions, &empty_floors, &authority);
1053 Ok((fold.updated.unwrap_or(community), fold.heads))
1054}
1055
1056pub async fn accept_direct_invite<T: Transport + ?Sized>(transport: &T, wrap: &Event) -> Result<CommunityV2, String> {
1060 let session = SessionGuard::capture();
1061 let me = local_keys()?;
1062 let (inviter, bundle) = invite::unwrap_direct_invite(wrap, &me).map_err(|e| e.to_string())?;
1063 accept_bundle(transport, &session, &bundle, Some(inviter)).await
1064}
1065
1066pub async fn accept_parked_invite<T: Transport + ?Sized>(
1072 transport: &T,
1073 bundle_json: &str,
1074 inviter_hex: Option<&str>,
1075) -> Result<CommunityV2, String> {
1076 let session = SessionGuard::capture();
1077 let bundle = CommunityInvite::from_bundle_json(bundle_json).map_err(|e| e.to_string())?;
1078 let invited_by = inviter_hex.and_then(|h| PublicKey::parse(h).ok());
1079 accept_bundle(transport, &session, &bundle, invited_by).await
1080}
1081
1082pub async fn fetch_public_bundle<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityInvite, String> {
1089 let parsed = invite::parse_invite_link(url).map_err(|e| e.to_string())?;
1090 let query = Query {
1095 kinds: vec![super::kind::INVITE_BUNDLE],
1096 authors: vec![parsed.link_signer.to_hex()],
1097 ..Default::default()
1098 };
1099 let relays = if parsed.bootstrap_relays.is_empty() {
1100 invite::stock_relays()
1101 } else {
1102 parsed.bootstrap_relays.clone()
1103 };
1104 let events = transport.fetch(&query, &relays).await?;
1105 let bundle_key = super::derive::invite_bundle_key(&parsed.token);
1106
1107 let mut newest_live: Option<(u64, CommunityInvite)> = None;
1109 for event in &events {
1110 match invite::parse_bundle_event(event, &parsed.link_signer, &bundle_key) {
1111 Ok(invite::BundleState::Revoked) => return Err("this invite link has been revoked".to_string()),
1112 Ok(invite::BundleState::Live(bundle)) => {
1113 let at = event.created_at.as_secs();
1114 if newest_live.as_ref().is_none_or(|(t, _)| at > *t) {
1115 newest_live = Some((at, *bundle));
1116 }
1117 }
1118 Err(_) => {} }
1120 }
1121 newest_live.map(|(_, b)| b).ok_or_else(|| "invite bundle not found on relays".to_string())
1122}
1123
1124struct VerifiedPreview {
1130 session: SessionGuard,
1131 at: std::time::Instant,
1132 community_id: [u8; 32],
1133 community_root: [u8; 32],
1134 folded: CommunityV2,
1135 heads: Vec<FoldedHead>,
1136}
1137static VERIFIED_PREVIEW: std::sync::Mutex<Option<VerifiedPreview>> = std::sync::Mutex::new(None);
1138const VERIFIED_PREVIEW_TTL: std::time::Duration = std::time::Duration::from_secs(120);
1139
1140pub async fn preview_public_link<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityV2, String> {
1147 let bundle = fetch_public_bundle(transport, url).await?;
1148 preview_bundle(transport, &bundle).await
1149}
1150
1151pub async fn preview_bundle<T: Transport + ?Sized>(transport: &T, bundle: &CommunityInvite) -> Result<CommunityV2, String> {
1156 let community = CommunityV2::from_bundle(bundle, 0)?;
1157 match verify_owner_root_and_reconcile(transport, community.clone()).await {
1158 Ok((folded, heads)) => {
1159 *VERIFIED_PREVIEW.lock().unwrap() = Some(VerifiedPreview {
1160 session: SessionGuard::capture(),
1161 at: std::time::Instant::now(),
1162 community_id: folded.id().0,
1163 community_root: folded.community_root,
1164 folded: folded.clone(),
1165 heads,
1166 });
1167 Ok(folded)
1168 }
1169 Err(_) => Ok(community),
1170 }
1171}
1172
1173pub async fn accept_public_link<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityV2, String> {
1175 let session = SessionGuard::capture();
1177 let bundle = fetch_public_bundle(transport, url).await?;
1178 if !session.is_valid() {
1179 return Err("account changed during join".to_string());
1180 }
1181 accept_bundle(transport, &session, &bundle, None).await
1182}
1183
1184pub async fn leave_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
1186 let session = SessionGuard::capture();
1187 let me = local_keys()?;
1188 let at_ms = now_ms();
1189 let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1190 let leave_rumor = guestbook::build_leave_rumor(me.public_key(), at_ms);
1191 if let Ok((wrap, _)) = guestbook::seal_guestbook_rumor(&leave_rumor, &gb_group, &me, Timestamp::from_secs(at_ms / 1000)) {
1192 let _ = transport.publish(&wrap, &community.relays).await;
1193 }
1194 if !session.is_valid() {
1195 return Err("account changed during leave".to_string());
1196 }
1197 let _ = tombstone_community_list(transport, community.id(), &community.relays).await;
1201 if !session.is_valid() {
1203 return Err("account changed during leave".to_string());
1204 }
1205 crate::db::community::delete_community(&crate::simd::hex::bytes_to_hex_32(&community.id().0))?;
1206 Ok(())
1207}
1208
1209pub async fn kick_member<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, target: &PublicKey) -> Result<(), String> {
1214 let session = SessionGuard::capture();
1215 let me = local_keys()?;
1216 let authority = fetch_authority(transport, community).await;
1219 let owner_hex = community.owner()?.to_hex();
1220 if !authority.roles.can_act_on_member(
1221 &me.public_key().to_hex(),
1222 Some(&owner_hex),
1223 &target.to_hex(),
1224 crate::community::roles::Permissions::KICK,
1225 ) {
1226 return Err("not authorized to kick this member".to_string());
1227 }
1228 let at_ms = now_ms();
1229 let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1230 let rumor = guestbook::build_kick_rumor(me.public_key(), *target, None, at_ms);
1231 let (wrap, _) = guestbook::seal_guestbook_rumor(&rumor, &gb_group, &me, Timestamp::from_secs(at_ms / 1000))
1232 .map_err(|e| e.to_string())?;
1233 if !session.is_valid() {
1234 return Err("account changed before send".to_string());
1235 }
1236 transport.publish(&wrap, &community.relays).await?;
1237 Ok(())
1238}
1239
1240pub struct AuthorityView {
1246 pub roles: crate::community::roles::CommunityRoles,
1247 pub banned: std::collections::BTreeSet<String>,
1248 pub gapped: bool,
1250 pub floored: std::collections::BTreeSet<String>,
1252 pub head_entities: std::collections::BTreeSet<String>,
1254}
1255
1256pub async fn fetch_authority<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> AuthorityView {
1262 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1263 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
1264 .unwrap_or_default()
1265 .into_iter()
1266 .filter(|(_, f)| f.0 == community.root_epoch.0)
1267 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
1268 .collect();
1269 let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
1270
1271 let mut editions: Vec<ParsedEdition> = Vec::new();
1272 let mut seen: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
1273 let mut seen_wraps: std::collections::HashSet<nostr_sdk::EventId> = std::collections::HashSet::new();
1274 let mut oldest: Option<u64> = None;
1275 let mut until: Option<u64> = None;
1276 let mut a = fold_authority(community, &[], &floors);
1281 for _ in 0..FOLLOW_MAX_PAGES {
1282 let query = Query {
1283 kinds: vec![stream::KIND_WRAP],
1284 authors: vec![control.pk_hex()],
1285 until,
1286 limit: Some(FOLLOW_PAGE),
1287 ..Default::default()
1288 };
1289 let Ok(wraps) = transport.fetch(&query, &community.relays).await else { break };
1290 let mut fresh = 0usize;
1291 for w in &wraps {
1292 if !seen_wraps.insert(w.id) {
1293 continue;
1294 }
1295 fresh += 1;
1296 let at = w.created_at.as_secs();
1297 if oldest.is_none_or(|o| at < o) {
1298 oldest = Some(at);
1299 }
1300 if let Ok((ed, _)) = control::open_control_edition(w, &control) {
1301 if seen.insert(ed.inner_id) {
1302 editions.push(ed);
1303 }
1304 }
1305 }
1306 a = fold_authority(community, &editions, &floors);
1307 if !a.gapped || fresh == 0 {
1308 break;
1309 }
1310 until = oldest;
1311 }
1312 AuthorityView {
1313 roles: a.roles,
1314 banned: a.banned,
1315 gapped: a.gapped,
1316 floored: floors.keys().cloned().collect(),
1317 head_entities: a.heads.iter().map(|h| h.entity_hex.clone()).collect(),
1318 }
1319}
1320
1321async fn fetch_guestbook_events<T: Transport + ?Sized>(
1333 transport: &T,
1334 community: &CommunityV2,
1335 since_secs: u64,
1336) -> Result<(Vec<guestbook::GuestbookEvent>, u64), String> {
1337 let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1338 const GB_PAGE: usize = 500;
1339 const GB_MAX_PAGES: usize = 12;
1340 let mut events = Vec::new();
1341 let mut newest: u64 = since_secs;
1342 let mut seen: std::collections::HashSet<nostr_sdk::EventId> = std::collections::HashSet::new();
1343 let mut until: Option<u64> = None;
1344 let mut oldest: Option<u64> = None;
1345 for _ in 0..GB_MAX_PAGES {
1346 let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![gb_group.pk_hex()], until, limit: Some(GB_PAGE), ..Default::default() };
1347 let wraps = transport.fetch(&query, &community.relays).await?;
1348 let mut fresh = 0usize;
1349 for wrap in &wraps {
1350 if !seen.insert(wrap.id) {
1351 continue;
1352 }
1353 fresh += 1;
1354 let at = wrap.created_at.as_secs();
1355 if oldest.is_none_or(|o| at < o) {
1356 oldest = Some(at);
1357 }
1358 if at > newest {
1359 newest = at;
1360 }
1361 if at < since_secs {
1363 continue;
1364 }
1365 if let Ok(opened) = stream::open_wrap(wrap, &gb_group) {
1366 if let Ok(ev) = guestbook::parse_guestbook_event(&opened) {
1367 events.push(ev);
1368 }
1369 }
1370 }
1371 if fresh == 0 || wraps.len() < GB_PAGE || oldest.is_some_and(|o| o < since_secs) {
1372 break;
1373 }
1374 match oldest {
1375 Some(o) if o > 0 => until = Some(o),
1376 _ => break,
1377 }
1378 }
1379 Ok((events, newest))
1380}
1381
1382fn fold_members(
1387 community: &CommunityV2,
1388 events: &[guestbook::GuestbookEvent],
1389 mut observed: std::collections::BTreeMap<PublicKey, u64>,
1390 roles: &crate::community::roles::CommunityRoles,
1391 banlist: &std::collections::BTreeSet<PublicKey>,
1392) -> Result<Vec<PublicKey>, String> {
1393 let owner = community.owner()?;
1394 let owner_hex = owner.to_hex();
1395
1396 for g in &roles.grants {
1403 if let Some(pk) = PublicKey::from_hex(&g.member).ok().filter(|_| !g.role_ids.is_empty()) {
1404 observed.entry(pk).or_insert(0);
1405 }
1406 }
1407
1408 let snapshot_authority = (community.root_epoch.0 > 0).then_some(&owner);
1414 let can_kick = |actor: &PublicKey, target: &PublicKey| {
1417 roles.can_act_on_member(&actor.to_hex(), Some(&owner_hex), &target.to_hex(), crate::community::roles::Permissions::KICK)
1418 };
1419 let coalesced = guestbook::coalesce(events, now_ms(), snapshot_authority, &can_kick);
1420 let mut members = guestbook::complete_memberlist(&coalesced, &observed, banlist);
1421 if !banlist.contains(&owner) {
1423 members.insert(owner);
1424 }
1425 Ok(members.into_iter().collect())
1426}
1427
1428pub async fn sync_guestbook<T: Transport + ?Sized>(
1433 transport: &T,
1434 community: &CommunityV2,
1435 session: &SessionGuard,
1436) -> Result<Vec<guestbook::GuestbookEvent>, String> {
1437 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1438 let (mut events, cursor) = crate::db::community::get_guestbook(&cid_hex)?;
1439 let since = cursor.saturating_sub(1);
1442 let (fresh, newest) = fetch_guestbook_events(transport, community, since).await?;
1443 if !session.is_valid() {
1444 return Err("account changed during guestbook sync".to_string());
1445 }
1446 let known: std::collections::HashSet<[u8; 32]> = events.iter().map(|e| e.rumor_id).collect();
1447 let mut added = Vec::new();
1448 for ev in fresh {
1449 if !known.contains(&ev.rumor_id) {
1450 events.push(ev.clone());
1451 added.push(ev);
1452 }
1453 }
1454 if !added.is_empty() || newest > cursor {
1455 crate::db::community::set_guestbook(&cid_hex, &events, newest.max(cursor))?;
1456 }
1457 Ok(added)
1458}
1459
1460pub fn ingest_guestbook_event(community: &CommunityV2, ev: guestbook::GuestbookEvent, wrap_secs: u64) -> Result<bool, String> {
1463 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1464 let (mut events, cursor) = crate::db::community::get_guestbook(&cid_hex)?;
1465 if events.iter().any(|e| e.rumor_id == ev.rumor_id) {
1466 return Ok(false);
1467 }
1468 events.push(ev);
1469 crate::db::community::set_guestbook(&cid_hex, &events, cursor.max(wrap_secs))?;
1470 Ok(true)
1471}
1472
1473pub fn stored_memberlist(community: &CommunityV2) -> Result<Vec<PublicKey>, String> {
1480 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1481 let (events, _cursor) = crate::db::community::get_guestbook(&cid_hex)?;
1482 let mut observed: std::collections::BTreeMap<PublicKey, u64> = std::collections::BTreeMap::new();
1483 for (npub, last_active_secs) in crate::db::community::community_member_activity(&cid_hex).unwrap_or_default() {
1484 if let Ok(pk) = PublicKey::parse(&npub) {
1485 observed.insert(pk, last_active_secs.saturating_mul(1000));
1486 }
1487 }
1488 let roles = crate::db::community::get_community_roles(&cid_hex)?;
1489 let banlist: std::collections::BTreeSet<PublicKey> = crate::db::community::get_community_banlist(&cid_hex)
1490 .unwrap_or_default()
1491 .iter()
1492 .filter_map(|h| PublicKey::from_hex(h).ok())
1493 .collect();
1494 fold_members(community, &events, observed, &roles, &banlist)
1495}
1496
1497pub async fn memberlist<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<Vec<PublicKey>, String> {
1503 let (events, _newest) = fetch_guestbook_events(transport, community, 0).await?;
1504 let mut observed: std::collections::BTreeMap<PublicKey, u64> = std::collections::BTreeMap::new();
1507 for ch in &community.channels {
1508 if let Ok(page) = fetch_channel(transport, community, &ch.id, 200).await {
1509 for f in &page {
1510 let e = observed.entry(f.event.opened().author).or_insert(0);
1511 *e = (*e).max(f.event.opened().at_ms);
1512 }
1513 }
1514 }
1515
1516 let authority = fetch_authority(transport, community).await;
1521 let banlist: std::collections::BTreeSet<PublicKey> =
1523 authority.banned.iter().filter_map(|h| PublicKey::from_hex(h).ok()).collect();
1524 fold_members(community, &events, observed, &authority.roles, &banlist)
1525}
1526
1527pub async fn dissolve_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
1535 let session = SessionGuard::capture();
1536 let me = local_keys()?;
1537 if community.owner()? != me.public_key() {
1538 return Err("only the owner can dissolve a community".to_string());
1539 }
1540 let at = now_ms() / 1000;
1541 let rumor = super::dissolution::dissolved_tombstone_rumor(me.public_key(), community.id(), at);
1542 let wrap = super::dissolution::seal_dissolved(&rumor, community.id(), &me, Timestamp::from_secs(at)).map_err(|e| e.to_string())?;
1543 if !session.is_valid() {
1544 return Err("account changed during dissolve".to_string());
1545 }
1546 transport.publish_durable(&wrap, &community.relays).await?;
1548 crate::db::community::set_community_dissolved(&crate::simd::hex::bytes_to_hex_32(&community.id().0))?;
1549 Ok(())
1550}
1551
1552pub async fn is_dissolved<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> bool {
1557 let group = super::derive::dissolved_group_key(community.id());
1558 let query = Query {
1559 kinds: vec![stream::KIND_WRAP],
1560 authors: vec![group.pk_hex()],
1561 limit: Some(20),
1562 ..Default::default()
1563 };
1564 let Ok(wraps) = transport.fetch(&query, &community.relays).await else {
1565 return false;
1566 };
1567 wraps.iter().any(|w| super::dissolution::verify_dissolved(w, &community.identity))
1568}
1569
1570pub async fn refound_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, removed: &[PublicKey]) -> Result<CommunityV2, String> {
1583 let session = SessionGuard::capture();
1584 let cid = community.id();
1585 let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
1586 if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
1588 return Err("this community has been dissolved; it cannot be re-founded".to_string());
1589 }
1590 let me = local_keys()?;
1591 let lock = super::realtime::follow_lock(cid);
1595 let _guard = lock.lock().await;
1596 let fresh = crate::db::community::load_community_v2(cid)?.ok_or("community gone before re-founding")?;
1600 let community = &fresh;
1601 let owner = community.owner()?;
1602
1603 {
1610 let owner_hex = owner.to_hex();
1611 let me_hex = me.public_key().to_hex();
1612 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
1618 let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
1619 let authorized = me.public_key() == owner
1620 || (!banned.contains(&me_hex)
1621 && roster.is_authorized(&me_hex, Some(&owner_hex), crate::community::roles::Permissions::BAN)
1622 && removed.iter().all(|t| {
1623 roster.can_act_on_member(&me_hex, Some(&owner_hex), &t.to_hex(), crate::community::roles::Permissions::BAN)
1624 }));
1625 if !authorized {
1626 return Err("re-founding requires the BAN permission and outranking every removed member".to_string());
1627 }
1628 }
1629
1630 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
1634 .into_iter()
1635 .filter(|(_, f)| f.0 == community.root_epoch.0)
1636 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
1637 .collect();
1638 let current_control = control_group_key(&community.community_root, cid, community.root_epoch);
1639 let mut opened: Vec<(ParsedEdition, super::stream::OpenedStream)> = Vec::new();
1646 let mut seen_wraps: std::collections::HashSet<nostr_sdk::EventId> = std::collections::HashSet::new();
1647 let mut oldest: Option<u64> = None;
1648 let mut until: Option<u64> = None;
1649 for _ in 0..FOLLOW_MAX_PAGES {
1650 let query = Query {
1651 kinds: vec![stream::KIND_WRAP],
1652 authors: vec![current_control.pk_hex()],
1653 until,
1654 limit: Some(FOLLOW_PAGE),
1655 ..Default::default()
1656 };
1657 let wraps = transport.fetch(&query, &community.relays).await?;
1658 let mut fresh = 0usize;
1659 for w in &wraps {
1660 if !seen_wraps.insert(w.id) {
1661 continue;
1662 }
1663 fresh += 1;
1664 let at = w.created_at.as_secs();
1665 if oldest.is_none_or(|o| at < o) {
1666 oldest = Some(at);
1667 }
1668 if let Ok(parsed) = control::open_control_edition(w, ¤t_control) {
1669 opened.push(parsed);
1670 }
1671 }
1672 let present: std::collections::HashSet<String> =
1675 opened.iter().map(|(e, _)| crate::simd::hex::bytes_to_hex_32(&e.entity_id)).collect();
1676 if floors.keys().all(|k| present.contains(k)) || fresh == 0 {
1677 break;
1678 }
1679 until = oldest;
1680 }
1681
1682 let prev_epoch = community.root_epoch;
1683 let new_epoch = Epoch(prev_epoch.0.checked_add(1).ok_or("root epoch overflow")?);
1684 let prev_commit = super::derive::epoch_key_commitment(prev_epoch, &community.community_root);
1685 let new_root = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch.0)?;
1690 let new_control = control_group_key(&new_root, cid, new_epoch);
1691 let at = now_ms();
1692 let at_secs = at / 1000;
1693
1694 use std::collections::BTreeMap;
1700 let mut by_eid: BTreeMap<String, Vec<usize>> = BTreeMap::new();
1701 for (i, (e, _)) in opened.iter().enumerate() {
1702 by_eid.entry(crate::simd::hex::bytes_to_hex_32(&e.entity_id)).or_default().push(i);
1703 }
1704 let mut carried: Vec<(FoldedHead, Event)> = Vec::new();
1705 for (floor_key, floor) in &floors {
1706 let head_idx = by_eid
1714 .get(floor_key)
1715 .and_then(|v| v.iter().copied().find(|&i| opened[i].0.self_hash == floor.1));
1716 let Some(head_idx) = head_idx else {
1717 return Err(format!("re-founding aborted: the committed head of control entity {floor_key} (v{}) was not served; no state published", floor.0));
1718 };
1719 let (head_ed, head_os) = &opened[head_idx];
1720 let h = FoldedHead { entity_hex: floor_key.clone(), version: head_ed.version, self_hash: head_ed.self_hash, inner_id: head_ed.inner_id };
1721 let (rewrapped, _) = super::stream::rewrap_seal(&head_os.seal, &new_control, Timestamp::from_secs(at_secs)).map_err(|e| e.to_string())?;
1722 carried.push((h, rewrapped));
1723 }
1724 if !session.is_valid() {
1725 return Err("account changed during re-founding acquire".to_string());
1726 }
1727
1728 let members = memberlist(transport, community).await?;
1730 let removed_set: std::collections::HashSet<[u8; 32]> = removed.iter().map(|p| p.to_bytes()).collect();
1731 let mut recipients: Vec<PublicKey> = members.into_iter().filter(|m| !removed_set.contains(&m.to_bytes())).collect();
1732 if !recipients.iter().any(|p| *p == me.public_key()) {
1733 recipients.push(me.public_key());
1734 }
1735
1736 let mut base_blobs = Vec::new();
1738 for r in &recipients {
1739 base_blobs.push(
1740 super::rekey::build_blob_local(me.secret_key(), &me.public_key().to_bytes(), r, super::rekey::RekeyScope::Root, new_epoch, &new_root)
1741 .map_err(|e| e.to_string())?,
1742 );
1743 }
1744 let base_group = super::derive::base_rekey_group_key(&community.community_root, cid, new_epoch);
1745 let base_chunks =
1746 super::rekey::build_rekey_chunks_local(&me, &base_group, super::rekey::RekeyScope::Root, new_epoch, prev_epoch, &prev_commit, &base_blobs, at_secs)
1747 .map_err(|e| e.to_string())?;
1748
1749 let mut channel_updates: Vec<(ChannelId, [u8; 32], Epoch)> = Vec::new();
1752 let mut channel_chunk_sets: Vec<Vec<Event>> = Vec::new();
1753 for ch in &community.channels {
1754 let (Some(old_key), true) = (ch.key, ch.private) else { continue };
1755 let ch_new_epoch = Epoch(ch.epoch.0.checked_add(1).ok_or("channel epoch overflow")?);
1756 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)?;
1759 let ch_prev_commit = super::derive::epoch_key_commitment(ch.epoch, &old_key);
1760 let mut ch_blobs = Vec::new();
1761 for r in &recipients {
1762 ch_blobs.push(
1763 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)
1764 .map_err(|e| e.to_string())?,
1765 );
1766 }
1767 let ch_group = super::derive::channel_rekey_group_key(&community.community_root, &ch.id, ch_new_epoch);
1768 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)
1769 .map_err(|e| e.to_string())?;
1770 channel_updates.push((ch.id, ch_new_key, ch_new_epoch));
1771 channel_chunk_sets.push(ch_chunks);
1772 }
1773 if !session.is_valid() {
1774 return Err("account changed during re-founding prepare".to_string());
1775 }
1776
1777 for c in &base_chunks {
1780 transport.publish_durable(c, &community.relays).await?;
1781 }
1782 for set in &channel_chunk_sets {
1783 for c in set {
1784 transport.publish_durable(c, &community.relays).await?;
1785 }
1786 }
1787 for (_, wrap) in &carried {
1788 transport.publish_durable(wrap, &community.relays).await?;
1789 }
1790 let gb_group = super::derive::guestbook_group_key(&new_root, cid, new_epoch);
1793 let snap_id = crate::community::random_32();
1794 for rumor in guestbook::build_snapshot_rumors(me.public_key(), &recipients, snap_id, at) {
1795 if let Ok((wrap, _)) = guestbook::seal_guestbook_rumor(&rumor, &gb_group, &me, Timestamp::from_secs(at_secs)) {
1796 let _ = transport.publish(&wrap, &community.relays).await;
1797 }
1798 }
1799
1800 if !session.is_valid() {
1802 return Err("account changed during re-founding commit".to_string());
1803 }
1804 if crate::db::community::community_protocol(cid)?.is_none() {
1805 return Ok(community.clone()); }
1807 let mut updated = community.clone();
1811 updated.community_root = new_root;
1812 updated.root_epoch = new_epoch;
1813 for (id, key, ep) in &channel_updates {
1814 if let Some(c) = updated.channels.iter_mut().find(|c| c.id.0 == id.0) {
1815 c.key = Some(*key);
1816 c.epoch = *ep;
1817 }
1818 }
1819 crate::db::community::save_community_v2(&updated)?;
1820 crate::db::community::advance_server_root_epoch(&cid_hex, new_epoch.0, &new_root)?;
1825 for (h, _) in &carried {
1826 crate::db::community::set_edition_head_at_epoch(&cid_hex, &h.entity_hex, h.version, &h.self_hash, &h.inner_id, new_epoch.0)?;
1827 }
1828 for attempt in 0..3u8 {
1834 match refresh_public_links(transport, &updated).await {
1835 Ok(()) => break,
1836 Err(_) if !session.is_valid() => break, Err(e) if attempt == 2 => {
1838 crate::log_warn!("v2: post-refounding public-link refresh failed after retries ({e}); live links may serve the prior root until the next refresh");
1839 }
1840 Err(_) => continue,
1841 }
1842 }
1843 Ok(updated)
1844}
1845
1846fn mint_or_reuse_rotation_key(community_id_hex: &str, scope_hex: &str, new_epoch: u64) -> Result<[u8; 32], String> {
1853 if let Some(existing) = crate::db::community::held_epoch_key(community_id_hex, scope_hex, new_epoch)? {
1854 return Ok(existing);
1855 }
1856 let fresh = crate::community::random_32();
1857 crate::db::community::store_epoch_key(community_id_hex, scope_hex, new_epoch, &fresh)?;
1858 Ok(fresh)
1859}
1860
1861fn join_material(community: &CommunityV2) -> super::list::JoinMaterial {
1867 let hex = crate::simd::hex::bytes_to_hex_32;
1868 let channels = community
1869 .channels
1870 .iter()
1871 .filter(|c| c.private)
1872 .filter_map(|c| {
1873 c.key.map(|k| super::list::ChannelKeyRef { id: hex(&c.id.0), key: hex(&k), epoch: c.epoch.0, name: c.name.clone() })
1874 })
1875 .collect();
1876 super::list::JoinMaterial {
1877 community_id: hex(&community.identity.community_id.0),
1878 owner: hex(&community.identity.owner_xonly),
1879 owner_salt: hex(&community.identity.owner_salt),
1880 community_root: hex(&community.community_root),
1881 root_epoch: community.root_epoch.0,
1882 channels,
1883 relays: community.relays.clone(),
1884 name: community.name.clone(),
1885 extra: Default::default(),
1886 }
1887}
1888
1889fn material_to_invite(jm: &super::list::JoinMaterial) -> CommunityInvite {
1893 let channels = jm
1894 .channels
1895 .iter()
1896 .map(|c| invite::ChannelGrant { id: c.id.clone(), key: c.key.clone(), epoch: c.epoch, name: c.name.clone() })
1897 .collect();
1898 CommunityInvite {
1899 community_id: jm.community_id.clone(),
1900 owner: jm.owner.clone(),
1901 owner_salt: jm.owner_salt.clone(),
1902 community_root: jm.community_root.clone(),
1903 root_epoch: jm.root_epoch,
1904 channels,
1905 relays: jm.relays.clone(),
1906 name: jm.name.clone(),
1907 icon: None,
1908 expires_at: None,
1909 creator_npub: None,
1910 label: None,
1911 extra: Default::default(),
1912 }
1913}
1914
1915fn held_v2_relays() -> Vec<String> {
1918 let mut set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
1919 if let Ok(ids) = crate::db::community::list_community_ids() {
1920 for id in ids {
1921 if matches!(crate::db::community::community_protocol(&id), Ok(Some(crate::community::ConcordProtocol::V2))) {
1922 if let Ok(Some(c)) = crate::db::community::load_community_v2(&id) {
1923 set.extend(c.relays);
1924 }
1925 }
1926 }
1927 }
1928 set.into_iter().collect()
1929}
1930
1931async fn fetch_community_list<T: Transport + ?Sized>(transport: &T, relays: &[String]) -> Result<Option<super::list::CommunityList>, String> {
1937 let me = local_keys()?;
1938 let query = Query {
1939 kinds: vec![super::kind::COMMUNITY_LIST],
1940 authors: vec![me.public_key().to_hex()],
1941 limit: Some(4),
1942 ..Default::default()
1943 };
1944 let events = transport.fetch(&query, relays).await?;
1945 Ok(events
1946 .into_iter()
1947 .filter_map(|e| super::list::parse_list_event(&e, &me).ok().map(|l| (e.created_at.as_secs(), l)))
1948 .max_by_key(|(at, _)| *at)
1949 .map(|(_, l)| l))
1950}
1951
1952pub async fn republish_community_list<T: Transport + ?Sized>(transport: &T, just_joined: Option<&crate::community::CommunityId>) -> Result<(), String> {
1961 let session = SessionGuard::capture();
1962 let me = local_keys()?;
1963 let relays = held_v2_relays();
1964 if relays.is_empty() {
1965 return Ok(()); }
1967 let remote = match fetch_community_list(transport, &relays).await {
1971 Ok(r) => r.unwrap_or_default(),
1972 Err(_) => return Ok(()),
1973 };
1974 let just_joined_hex = just_joined.map(|c| crate::simd::hex::bytes_to_hex_32(&c.0));
1975 let now = now_ms();
1976 let mut local = super::list::CommunityList::default();
1977 for id in crate::db::community::list_community_ids()? {
1978 if !matches!(crate::db::community::community_protocol(&id), Ok(Some(crate::community::ConcordProtocol::V2))) {
1979 continue;
1980 }
1981 let Some(c) = crate::db::community::load_community_v2(&id)? else { continue };
1982 let cid_hex = crate::simd::hex::bytes_to_hex_32(&c.id().0);
1983 let is_join = just_joined_hex.as_deref() == Some(cid_hex.as_str());
1984 if !is_join && !remote.is_live(&cid_hex) && remote.tombstones.iter().any(|t| t.community_id == cid_hex) {
1988 continue;
1989 }
1990 let added_at = if remote.is_live(&cid_hex) && !is_join {
1993 remote.entries.iter().find(|e| e.community_id == cid_hex).map(|e| e.added_at).unwrap_or(now)
1994 } else {
1995 now
1996 };
1997 let jm = join_material(&c);
1998 local.entries.push(super::list::CommunityListEntry { community_id: cid_hex, seed: jm.clone(), current: jm, added_at, extra: Default::default() });
1999 }
2000 let merged = remote.merge(&local);
2001 merged.assert_fits().map_err(|e| e.to_string())?;
2002 let event = super::list::build_list_event(&me, &merged).map_err(|e| e.to_string())?;
2003 if !session.is_valid() {
2004 return Err("account changed during community-list publish".to_string());
2005 }
2006 transport.publish(&event, &relays).await
2007}
2008
2009async fn tombstone_community_list<T: Transport + ?Sized>(transport: &T, community_id: &crate::community::CommunityId, relays: &[String]) -> Result<(), String> {
2012 let me = local_keys()?;
2013 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community_id.0);
2014 let mut doc = match fetch_community_list(transport, relays).await {
2018 Ok(d) => d.unwrap_or_default(),
2019 Err(e) => return Err(e),
2020 };
2021 let now = now_ms();
2022 doc.tombstones.retain(|t| t.community_id != cid_hex);
2023 doc.tombstones.push(super::list::Tombstone { community_id: cid_hex, removed_at: now, extra: Default::default() });
2024 doc.assert_fits().map_err(|e| e.to_string())?;
2025 let event = super::list::build_list_event(&me, &doc).map_err(|e| e.to_string())?;
2026 transport.publish(&event, relays).await
2027}
2028
2029pub async fn sync_community_list<T: Transport + ?Sized>(transport: &T, bootstrap_relays: &[String]) -> Result<Vec<CommunityV2>, String> {
2035 let session = SessionGuard::capture();
2036 let mut relays = held_v2_relays();
2037 relays.extend(bootstrap_relays.iter().cloned());
2038 relays.sort();
2039 relays.dedup();
2040 if relays.is_empty() {
2041 return Ok(vec![]);
2042 }
2043 let list = match fetch_community_list(transport, &relays).await {
2044 Ok(Some(l)) => l,
2045 Ok(None) | Err(_) => return Ok(vec![]),
2046 };
2047 for t in &list.tombstones {
2053 if list.is_live(&t.community_id) {
2054 continue;
2055 }
2056 let Some(cid) = crate::simd::hex::hex_to_bytes_32_checked(&t.community_id) else { continue };
2057 let id = crate::community::CommunityId(cid);
2058 if crate::db::community::load_community_v2(&id).ok().flatten().is_none() {
2059 continue; }
2061 if !session.is_valid() {
2062 return Err("account changed during community-list sync".to_string());
2063 }
2064 let _ = crate::db::community::delete_community(&t.community_id);
2065 }
2066 let mut joined = Vec::new();
2067 for entry in list.live_entries() {
2068 let Some(cid) = crate::simd::hex::hex_to_bytes_32_checked(&entry.community_id) else { continue };
2069 if crate::db::community::load_community_v2(&crate::community::CommunityId(cid)).ok().flatten().is_some() {
2070 continue; }
2072 if !session.is_valid() {
2073 return Err("account changed during community-list sync".to_string());
2074 }
2075 let bundle = material_to_invite(&entry.current);
2078 if let Ok(community) = accept_bundle(transport, &session, &bundle, None).await {
2079 joined.push(community);
2080 }
2081 }
2082 Ok(joined)
2083}
2084
2085async fn publish_control_edition<T: Transport + ?Sized>(
2094 transport: &T,
2095 community: &CommunityV2,
2096 session: &SessionGuard,
2097 vsk: &str,
2098 entity_id: &[u8; 32],
2099 content: &str,
2100 citation: Option<&crate::community::edition::AuthorityCitation>,
2101) -> Result<(), String> {
2102 let me = local_keys()?;
2103 let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
2104 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2105 let entity_hex = crate::simd::hex::bytes_to_hex_32(entity_id);
2106 let (version, prev) = match crate::db::community::get_edition_head(&cid_hex, &entity_hex)? {
2107 Some((v, h)) => (v + 1, Some(h)),
2108 None => (1, None),
2109 };
2110 let at = now_ms() / 1000;
2111 let rumor = control::build_edition_rumor(me.public_key(), vsk, entity_id, version, prev.as_ref(), content, at, citation);
2112 let (wrap, _) = control::seal_control_edition(&rumor, &control, &me, Timestamp::from_secs(at)).map_err(|e| e.to_string())?;
2113 if !session.is_valid() {
2114 return Err("account changed before control publish".to_string());
2115 }
2116 transport.publish(&wrap, &community.relays).await?;
2117 if !session.is_valid() {
2123 return Ok(());
2124 }
2125 if let Ok((ed, _)) = control::open_control_edition(&wrap, &control) {
2126 crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, ed.version, &ed.self_hash, &ed.inner_id, community.root_epoch.0)?;
2127 }
2128 Ok(())
2129}
2130
2131pub async fn set_role<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, role: &crate::community::roles::Role) -> Result<(), String> {
2135 let session = SessionGuard::capture();
2136 super::roles::validate_role(role)?;
2137 let content = super::roles::role_content_json(role)?;
2138 let role_id = crate::simd::hex::hex_to_bytes_32_checked(&role.role_id).ok_or("role_id must be 32-byte hex")?;
2139 publish_control_edition(transport, community, &session, vsk::ROLE, &role_id, &content, None).await
2140}
2141
2142pub async fn grant_roles<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey, role_ids: Vec<String>) -> Result<(), String> {
2146 let session = SessionGuard::capture();
2147 let grant = crate::community::roles::MemberGrant { member: member.to_hex(), role_ids };
2148 let content = super::roles::grant_content_json(&grant)?;
2149 let eid = super::derive::grant_locator(community.id(), &member.to_bytes());
2150 publish_control_edition(transport, community, &session, vsk::GRANT, &eid, &content, None).await
2151}
2152
2153pub async fn ensure_admin_role<T: Transport + ?Sized>(
2158 transport: &T,
2159 community: &CommunityV2,
2160 view: &AuthorityView,
2161 create_if_missing: bool,
2162) -> Result<Option<String>, String> {
2163 use crate::community::roles::{Permissions, Role, RoleScope};
2164 if let Some(r) = view
2165 .roles
2166 .roles
2167 .iter()
2168 .find(|r| matches!(r.scope, RoleScope::Server) && r.permissions.contains(Permissions::ADMIN_ALL))
2169 {
2170 return Ok(Some(r.role_id.clone()));
2171 }
2172 if !create_if_missing {
2173 return Ok(None);
2174 }
2175 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2176 let role_id = crate::crypto::sha256_hex(format!("vector/v2/role/admin/{cid_hex}").as_bytes());
2177 set_role(transport, community, &Role::admin(role_id.clone())).await?;
2178 Ok(Some(role_id))
2179}
2180
2181pub async fn grant_admin<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey) -> Result<(), String> {
2188 let session = SessionGuard::capture();
2191 let me = local_keys()?;
2192 if me.public_key() != community.owner()? {
2193 return Err("only the community owner can grant @admin".to_string());
2194 }
2195 let view = fetch_authority(transport, community).await;
2196 if !session.is_valid() {
2197 return Err("account changed during grant".to_string());
2198 }
2199 let member_hex = member.to_hex();
2200 require_grant_head(community, &view, &member_hex)?;
2201 let role_id = ensure_admin_role(transport, community, &view, true)
2202 .await?
2203 .expect("create_if_missing yields an id");
2204 let mut role_ids = view
2205 .roles
2206 .grants
2207 .iter()
2208 .find(|g| g.member == member_hex)
2209 .map(|g| g.role_ids.clone())
2210 .unwrap_or_default();
2211 if role_ids.contains(&role_id) {
2212 return Ok(()); }
2214 role_ids.push(role_id);
2215 grant_roles(transport, community, member, role_ids).await
2216}
2217
2218pub async fn revoke_admin<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey) -> Result<(), String> {
2221 let session = SessionGuard::capture();
2222 let me = local_keys()?;
2223 if me.public_key() != community.owner()? {
2224 return Err("only the community owner can revoke @admin".to_string());
2225 }
2226 let view = fetch_authority(transport, community).await;
2227 if !session.is_valid() {
2228 return Err("account changed during revoke".to_string());
2229 }
2230 let member_hex = member.to_hex();
2231 require_grant_head(community, &view, &member_hex)?;
2232 let Some(role_id) = ensure_admin_role(transport, community, &view, false).await? else {
2233 return Ok(()); };
2235 let mut role_ids = view
2236 .roles
2237 .grants
2238 .iter()
2239 .find(|g| g.member == member_hex)
2240 .map(|g| g.role_ids.clone())
2241 .unwrap_or_default();
2242 let before = role_ids.len();
2243 role_ids.retain(|r| r != &role_id);
2244 if role_ids.len() == before {
2245 return Ok(());
2246 }
2247 grant_roles(transport, community, member, role_ids).await
2248}
2249
2250fn require_grant_head(community: &CommunityV2, view: &AuthorityView, member_hex: &str) -> Result<(), String> {
2254 let Some(member) = crate::simd::hex::hex_to_bytes_32_checked(member_hex) else {
2255 return Err("malformed member key".to_string());
2256 };
2257 let eid_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &member));
2258 if view.floored.contains(&eid_hex) && !view.head_entities.contains(&eid_hex) {
2259 return Err("this member's current grant could not be fetched; try again once relays serve the control plane".to_string());
2260 }
2261 Ok(())
2262}
2263
2264pub async fn set_banlist<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, banned: &[String]) -> Result<(), String> {
2267 let session = SessionGuard::capture();
2268 super::roles::validate_banlist(banned)?;
2269 let content = super::roles::banlist_content_json(banned)?;
2270 let eid = super::derive::banlist_locator(community.id());
2271 publish_control_edition(transport, community, &session, vsk::BANLIST, &eid, &content, None).await
2272}
2273
2274pub async fn edit_community_metadata<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, meta: &control::CommunityMetadata) -> Result<(), String> {
2277 let session = SessionGuard::capture();
2278 control::validate_community_metadata(meta).map_err(|e| e.to_string())?;
2279 let content = serde_json::to_string(meta).map_err(|e| e.to_string())?;
2280 publish_control_edition(transport, community, &session, vsk::COMMUNITY_METADATA, &community.id().0, &content, None).await
2281}
2282
2283pub async fn edit_channel_metadata<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, channel_id: &ChannelId, meta: &control::ChannelMetadata) -> Result<(), String> {
2286 let session = SessionGuard::capture();
2287 let me = local_keys()?;
2288 ensure_channel_manager(community, &me.public_key())?;
2289 if meta.private {
2294 if let Some(held) = community.channel(channel_id) {
2295 if !held.private {
2296 return Err("converting a public channel to private is not supported yet".to_string());
2297 }
2298 }
2299 }
2300 control::validate_channel_metadata(meta).map_err(|e| e.to_string())?;
2301 let content = serde_json::to_string(meta).map_err(|e| e.to_string())?;
2302 publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content, None).await
2303}
2304
2305fn ensure_channel_manager(community: &CommunityV2, me: &PublicKey) -> Result<(), String> {
2311 let owner = community.owner()?;
2312 if *me == owner {
2313 return Ok(());
2314 }
2315 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2316 let me_hex = me.to_hex();
2317 if crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default().contains(&me_hex) {
2318 return Err("you are banned from this community".to_string());
2319 }
2320 let roster = crate::db::community::get_community_roles(&cid_hex)?;
2321 if roster.is_authorized(&me_hex, Some(&owner.to_hex()), crate::community::roles::Permissions::MANAGE_CHANNELS) {
2322 Ok(())
2323 } else {
2324 Err("managing channels here needs the MANAGE_CHANNELS permission".to_string())
2325 }
2326}
2327
2328pub async fn create_public_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str) -> Result<ChannelId, String> {
2334 let session = SessionGuard::capture();
2335 let lock = super::realtime::follow_lock(community.id());
2339 let _guard = lock.lock().await;
2340 let me = local_keys()?;
2341 ensure_channel_manager(community, &me.public_key())?;
2342 let channel_id = ChannelId(super::super::random_32());
2343 let meta = control::ChannelMetadata { name: name.to_string(), private: false, voice: None, deleted: None, custom: None, extra: Default::default() };
2344 control::validate_channel_metadata(&meta).map_err(|e| e.to_string())?;
2345 let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
2346 publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content, None).await?;
2347 if !session.is_valid() {
2348 return Err("account changed during channel create".to_string());
2349 }
2350 let mut updated = community.clone();
2352 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() });
2353 crate::db::community::save_community_v2(&updated)?;
2354 Ok(channel_id)
2355}
2356
2357pub async fn create_private_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str) -> Result<ChannelId, String> {
2368 let session = SessionGuard::capture();
2369 let lock = super::realtime::follow_lock(community.id());
2373 let _guard = lock.lock().await;
2374 let me = local_keys()?;
2375 ensure_channel_manager(community, &me.public_key())?;
2376 let meta = control::ChannelMetadata { name: name.to_string(), private: true, voice: None, deleted: None, custom: None, extra: Default::default() };
2377 control::validate_channel_metadata(&meta).map_err(|e| e.to_string())?;
2378 let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
2379
2380 let channel_id = ChannelId(super::super::random_32());
2381 let channel_key = super::super::random_32();
2382 let epoch = Epoch(1);
2383
2384 let mut recipients = memberlist(transport, community).await?;
2386 if !recipients.iter().any(|p| *p == me.public_key()) {
2387 recipients.push(me.public_key());
2388 }
2389 let prev_commit = super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
2390 let mut blobs = Vec::with_capacity(recipients.len());
2391 for r in &recipients {
2392 blobs.push(
2393 rekey::build_blob_local(me.secret_key(), &me.public_key().to_bytes(), r, RekeyScope::Channel(channel_id), epoch, &channel_key)
2394 .map_err(|e| e.to_string())?,
2395 );
2396 }
2397 let group = channel_rekey_group_key(&community.community_root, &channel_id, epoch);
2398 let at_secs = now_ms() / 1000;
2399 let chunks = rekey::build_rekey_chunks_local(&me, &group, RekeyScope::Channel(channel_id), epoch, Epoch(0), &prev_commit, &blobs, at_secs)
2400 .map_err(|e| e.to_string())?;
2401 if !session.is_valid() {
2402 return Err("account changed during channel create".to_string());
2403 }
2404 for c in &chunks {
2405 transport.publish_durable(c, &community.relays).await?;
2406 }
2407 publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content, None).await?;
2408 if !session.is_valid() {
2409 return Err("account changed during channel create".to_string());
2410 }
2411 if crate::db::community::community_protocol(community.id())?.is_none() {
2413 return Err("community removed during channel create".to_string());
2414 }
2415 let mut updated = community.clone();
2416 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() });
2417 crate::db::community::save_community_v2(&updated)?;
2418 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2421 crate::db::community::store_epoch_key(&cid_hex, &crate::simd::hex::bytes_to_hex_32(&channel_id.0), epoch.0, &channel_key)?;
2422 let _ = refresh_public_links(transport, &updated).await;
2424 Ok(channel_id)
2425}
2426
2427pub async fn delete_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, channel_id: &ChannelId, name: &str) -> Result<(), String> {
2430 let session = SessionGuard::capture();
2431 let lock = super::realtime::follow_lock(community.id());
2433 let _guard = lock.lock().await;
2434 let me = local_keys()?;
2435 ensure_channel_manager(community, &me.public_key())?;
2436 let mut meta = community.channel(channel_id).map(|c| c.metadata()).unwrap_or_else(|| control::ChannelMetadata {
2440 name: name.to_string(), private: false, voice: None, deleted: None, custom: None, extra: Default::default(),
2441 });
2442 meta.deleted = Some(true);
2443 let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
2444 publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content, None).await?;
2445 if !session.is_valid() {
2446 return Err("account changed during channel delete".to_string());
2447 }
2448 let mut updated = community.clone();
2449 updated.channels.retain(|c| c.id.0 != channel_id.0);
2450 crate::db::community::save_community_v2(&updated)?;
2451 Ok(())
2452}
2453
2454pub async fn follow_control<T: Transport + ?Sized>(
2478 transport: &T,
2479 community: &CommunityV2,
2480 session: &SessionGuard,
2481) -> Result<Option<CommunityV2>, String> {
2482 community.owner()?; let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
2484 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2485
2486 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
2492 .into_iter()
2493 .filter(|(_, f)| f.0 == community.root_epoch.0)
2494 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
2495 .collect();
2496
2497 let mut editions: Vec<ParsedEdition> = Vec::new();
2501 let mut seen: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
2502 let mut seen_wraps: std::collections::HashSet<nostr_sdk::EventId> = std::collections::HashSet::new();
2503 let mut oldest: Option<u64> = None;
2504 let mut until: Option<u64> = None;
2505 let mut fold = ControlFold { updated: None, heads: Vec::new(), gapped: false };
2506 let mut authority = AuthoritySet::owner_only();
2507 for _ in 0..FOLLOW_MAX_PAGES {
2508 let query = Query {
2509 kinds: vec![stream::KIND_WRAP],
2510 authors: vec![control.pk_hex()],
2511 until,
2512 limit: Some(FOLLOW_PAGE),
2513 ..Default::default()
2514 };
2515 let wraps = transport.fetch(&query, &community.relays).await?;
2516 let mut fresh = 0usize;
2520 for w in &wraps {
2521 if !seen_wraps.insert(w.id) {
2522 continue;
2523 }
2524 fresh += 1;
2525 let at = w.created_at.as_secs();
2526 if oldest.is_none_or(|o| at < o) {
2527 oldest = Some(at);
2528 }
2529 if let Ok((ed, _)) = control::open_control_edition(w, &control) {
2532 if seen.insert(ed.inner_id) {
2533 editions.push(ed);
2534 }
2535 }
2536 }
2537 authority = fold_authority(community, &editions, &floors);
2540 fold = apply_control_fold(community, &editions, &floors, &authority);
2541 if !(fold.gapped || authority.gapped) || fresh == 0 {
2542 break;
2543 }
2544 until = oldest;
2545 }
2546
2547 if !session.is_valid() {
2550 return Err("account changed during control follow".to_string());
2551 }
2552 if crate::db::community::community_protocol(community.id())?.is_none() {
2555 return Ok(None);
2556 }
2557 for h in fold.heads.iter().chain(authority.heads.iter()) {
2563 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)?;
2564 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)?;
2565 }
2566 if let Some((banned, version)) = &authority.banlist_persist {
2569 crate::db::community::set_community_banlist(&cid_hex, banned, *version as i64)?;
2570 }
2571 let newest_roster_at: i64 = editions
2578 .iter()
2579 .filter(|e| e.vsk == vsk::ROLE || e.vsk == vsk::GRANT || e.vsk == vsk::BANLIST)
2580 .map(|e| e.created_at as i64)
2581 .max()
2582 .unwrap_or(0);
2583 let stored = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
2589 let head_ents: std::collections::HashSet<&str> = authority.heads.iter().map(|h| h.entity_hex.as_str()).collect();
2590 let stored_complete = stored.roles.iter().all(|r| !floors.contains_key(&r.role_id) || head_ents.contains(r.role_id.as_str()))
2591 && stored.grants.iter().all(|g| {
2592 crate::simd::hex::hex_to_bytes_32_checked(&g.member).is_none_or(|m| {
2593 let eid = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &m));
2594 !floors.contains_key(&eid) || head_ents.contains(eid.as_str())
2595 })
2596 });
2597 if !authority.gapped && stored_complete && newest_roster_at >= crate::db::community::get_community_roles_at(&cid_hex)? {
2598 crate::db::community::set_community_roles(&cid_hex, &authority.roles, newest_roster_at)?;
2599 }
2600 match fold.updated {
2601 Some(u) => {
2602 crate::db::community::save_community_v2(&u)?;
2603 Ok(Some(u))
2604 }
2605 None => Ok(None),
2606 }
2607}
2608
2609const FOLLOW_MAX_PAGES: usize = 4;
2612const FOLLOW_PAGE: usize = 500;
2613
2614#[derive(Clone)]
2616struct FoldedHead {
2617 entity_hex: String,
2618 version: u64,
2619 self_hash: [u8; 32],
2620 inner_id: [u8; 32],
2621}
2622
2623struct ControlFold {
2629 updated: Option<CommunityV2>,
2630 heads: Vec<FoldedHead>,
2631 gapped: bool,
2632}
2633
2634type Floors = std::collections::HashMap<String, (u64, [u8; 32], Option<[u8; 32]>)>;
2636
2637fn apply_control_fold(community: &CommunityV2, editions: &[ParsedEdition], floors: &Floors, authority: &AuthoritySet) -> ControlFold {
2658 use crate::community::roles::Permissions;
2659 use std::collections::BTreeMap;
2660
2661 let owner_hex = community.owner().ok().map(|o| o.to_hex());
2662
2663 let mut groups: BTreeMap<(String, [u8; 32]), Vec<&ParsedEdition>> = BTreeMap::new();
2664 for e in editions {
2665 groups.entry((e.vsk.clone(), e.entity_id)).or_default().push(e);
2666 }
2667
2668 let mut out = community.clone();
2669 let mut changed = false;
2670 let mut heads = Vec::new();
2671 let mut gapped = false;
2672 for ((vsk_code, eid), group) in &groups {
2673 let is_meta = vsk_code == vsk::COMMUNITY_METADATA && *eid == community.id().0;
2678 let is_channel = vsk_code == vsk::CHANNEL_METADATA && *eid != community.id().0;
2679 if !is_meta && !is_channel {
2680 continue;
2681 }
2682 let required = if is_meta { Permissions::MANAGE_METADATA } else { Permissions::MANAGE_CHANNELS };
2687 let authed: Vec<&ParsedEdition> = group
2688 .iter()
2689 .copied()
2690 .filter(|e| {
2691 let author = e.author.to_hex();
2692 !authority.banned.contains(&author) && authority.roles.is_authorized(&author, owner_hex.as_deref(), required)
2695 })
2696 .collect();
2697 if authed.is_empty() {
2698 continue;
2699 }
2700 let entity_hex = crate::simd::hex::bytes_to_hex_32(eid);
2701 let fold_eds: Vec<version::Edition> = authed.iter().map(|p| p.to_fold_edition()).collect();
2702 let (hi, entity_gapped) = fold_head(&fold_eds, floors.get(&entity_hex));
2703 gapped |= entity_gapped;
2704 let Some(hi) = hi else { continue };
2705
2706 let head = authed[hi];
2707 heads.push(FoldedHead { entity_hex, version: head.version, self_hash: head.self_hash, inner_id: head.inner_id });
2708 if is_meta {
2709 if let Ok(meta) = serde_json::from_str::<control::CommunityMetadata>(&head.content) {
2710 changed |= apply_community_metadata(&mut out, meta);
2711 }
2712 } else if let Ok(meta) = serde_json::from_str::<control::ChannelMetadata>(&head.content) {
2713 changed |= apply_channel_metadata(&mut out, ChannelId(*eid), meta);
2717 }
2718 }
2719 ControlFold { updated: changed.then_some(out), heads, gapped }
2720}
2721
2722fn fold_head(fold_eds: &[version::Edition], floor: Option<&(u64, [u8; 32], Option<[u8; 32]>)>) -> (Option<usize>, bool) {
2729 let floor_v = floor.map(|f| f.0).unwrap_or(0);
2730 if floor_v == 0 {
2731 return (version::bootstrap_head(fold_eds, 0), false);
2732 }
2733 let floor_hash = floor.map(|f| &f.1);
2734 let held_inner = floor.and_then(|f| f.2);
2735 let result = version::fold(fold_eds, floor_v, floor_hash);
2736 if result.anchored {
2737 return (result.head, result.gap); }
2739 if result.head.is_none() && !result.gap {
2740 return (None, false); }
2742 let fork = fold_eds.iter().enumerate().filter(|(_, e)| e.version == floor_v).min_by_key(|(_, e)| e.tiebreak_id);
2746 let win_hash = match fork {
2747 Some((_, w)) if floor_hash != Some(&w.self_hash) && held_inner.is_none_or(|h| w.tiebreak_id < h) => w.self_hash,
2748 _ => return (None, true), };
2750 let re = version::fold(fold_eds, floor_v, Some(&win_hash));
2751 if !re.anchored {
2752 return (None, true);
2753 }
2754 (re.head, re.gap)
2755}
2756
2757struct AuthoritySet {
2762 roles: crate::community::roles::CommunityRoles,
2763 banned: std::collections::BTreeSet<String>,
2764 heads: Vec<FoldedHead>,
2765 gapped: bool,
2766 banlist_persist: Option<(Vec<String>, u64)>,
2770}
2771
2772impl AuthoritySet {
2773 fn owner_only() -> Self {
2776 AuthoritySet { roles: Default::default(), banned: Default::default(), heads: vec![], gapped: false, banlist_persist: None }
2777 }
2778}
2779
2780fn fold_authority(community: &CommunityV2, editions: &[ParsedEdition], floors: &Floors) -> AuthoritySet {
2787 use crate::community::roles::Permissions;
2788 use std::collections::BTreeMap;
2789
2790 let cid = community.id();
2791 let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
2792 let owner = community.owner().ok();
2793 let owner_hex = owner.map(|o| o.to_hex());
2794 let banlist_eid = super::derive::banlist_locator(cid);
2795 let banlist_hex = crate::simd::hex::bytes_to_hex_32(&banlist_eid);
2796
2797 let mut groups: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
2798 for e in editions {
2799 if e.vsk == vsk::ROLE || e.vsk == vsk::GRANT || e.vsk == vsk::BANLIST {
2800 groups.entry(e.entity_id).or_default().push(e);
2801 }
2802 }
2803
2804 let mut role_cands: BTreeMap<String, Vec<AuthorityCand>> = BTreeMap::new();
2812 let mut grant_cands: BTreeMap<String, Vec<AuthorityCand>> = BTreeMap::new();
2813 let mut gapped = false;
2814
2815 for (eid, group) in &groups {
2816 if *eid == banlist_eid {
2818 continue;
2819 }
2820 let entity_hex = crate::simd::hex::bytes_to_hex_32(eid);
2821 let fold_eds: Vec<version::Edition> = group.iter().map(|p| p.to_fold_edition()).collect();
2822 let (_hi, entity_gapped) = fold_head(&fold_eds, floors.get(&entity_hex));
2823 gapped |= entity_gapped;
2824 let floor_v = floors.get(&entity_hex).map(|f| f.0).unwrap_or(0);
2825
2826 for p in group {
2827 if p.version < floor_v {
2829 continue;
2830 }
2831 let head = FoldedHead { entity_hex: entity_hex.clone(), version: p.version, self_hash: p.self_hash, inner_id: p.inner_id };
2832 match p.vsk.as_str() {
2833 vsk::ROLE => {
2834 if let Some(role) = super::roles::parse_role_content(&p.content) {
2836 if role.role_id == entity_hex && role.position != 0 {
2837 role_cands.entry(entity_hex.clone()).or_default().push(AuthorityCand { role: Some(role), grant: None, author: p.author, head });
2838 }
2839 }
2840 }
2841 vsk::GRANT => {
2842 if let Some(mut grant) = super::roles::parse_grant_content(&p.content) {
2843 if let Some(member) = crate::simd::hex::hex_to_bytes_32_checked(&grant.member) {
2844 if super::derive::grant_locator(cid, &member) == *eid {
2845 grant.role_ids.truncate(super::roles::MAX_ROLES_PER_MEMBER);
2846 grant_cands.entry(entity_hex.clone()).or_default().push(AuthorityCand { role: None, grant: Some(grant), author: p.author, head });
2847 }
2848 }
2849 }
2850 }
2851 _ => {}
2852 }
2853 }
2854 }
2855 for cands in role_cands.values_mut().chain(grant_cands.values_mut()) {
2856 cands.sort_by(|a, b| b.head.version.cmp(&a.head.version).then(a.head.inner_id.cmp(&b.head.inner_id)));
2857 }
2858
2859 let empty: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
2860 let (prelim, _) = select_authorized(&role_cands, &grant_cands, owner_hex.as_deref(), &empty);
2863
2864 let persisted_banned: Vec<String> = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
2875 let banned_authors: std::collections::HashSet<&str> = persisted_banned.iter().map(String::as_str).collect();
2880 let banlist_authored: Vec<&ParsedEdition> = groups
2881 .get(&banlist_eid)
2882 .map(|g| {
2883 g.iter()
2884 .copied()
2885 .filter(|e| {
2886 let ah = e.author.to_hex();
2887 !banned_authors.contains(ah.as_str()) && prelim.is_authorized(&ah, owner_hex.as_deref(), Permissions::BAN)
2888 })
2889 .collect()
2890 })
2891 .unwrap_or_default();
2892 let mut banlist_persist: Option<(Vec<String>, u64)> = None;
2893 let mut banlist_head: Option<FoldedHead> = None;
2894 let banned: std::collections::BTreeSet<String> = if banlist_authored.is_empty() {
2895 persisted_banned.into_iter().collect()
2896 } else {
2897 let fold_eds: Vec<version::Edition> = banlist_authored.iter().map(|p| p.to_fold_edition()).collect();
2898 let (hi, g) = fold_head(&fold_eds, floors.get(&banlist_hex));
2899 gapped |= g;
2900 match hi {
2901 Some(hi) => {
2902 let head = banlist_authored[hi];
2903 let ah = head.author.to_hex();
2904 let list: Vec<String> = super::roles::parse_banlist_content(&head.content)
2905 .unwrap_or_default()
2906 .into_iter()
2907 .filter(|t| prelim.can_act_on_member(&ah, owner_hex.as_deref(), t, Permissions::BAN))
2908 .take(super::roles::MAX_BANLIST)
2909 .collect();
2910 banlist_head = Some(FoldedHead { entity_hex: banlist_hex.clone(), version: head.version, self_hash: head.self_hash, inner_id: head.inner_id });
2911 banlist_persist = Some((list.clone(), head.version));
2912 list.into_iter().collect()
2913 }
2914 None => persisted_banned.into_iter().collect(),
2915 }
2916 };
2917
2918 let (mut authorized, mut heads) = select_authorized(&role_cands, &grant_cands, owner_hex.as_deref(), &banned);
2922 if let Some(bh) = banlist_head {
2923 heads.push(bh);
2924 }
2925
2926 if authorized.roles.len() > super::roles::MAX_ROLES_PER_COMMUNITY {
2931 authorized.roles.sort_by(|a, b| a.role_id.cmp(&b.role_id));
2932 authorized.roles.truncate(super::roles::MAX_ROLES_PER_COMMUNITY);
2933 let kept: std::collections::HashSet<&str> = authorized.roles.iter().map(|r| r.role_id.as_str()).collect();
2934 authorized.grants.iter_mut().for_each(|g| g.role_ids.retain(|rid| kept.contains(rid.as_str())));
2935 authorized.grants.retain(|g| !g.role_ids.is_empty());
2936 }
2937
2938 AuthoritySet { roles: authorized, banned, heads, gapped, banlist_persist }
2939}
2940
2941struct AuthorityCand {
2944 role: Option<crate::community::roles::Role>,
2945 grant: Option<crate::community::roles::MemberGrant>,
2946 author: PublicKey,
2947 head: FoldedHead,
2948}
2949
2950fn select_authorized(
2959 role_cands: &std::collections::BTreeMap<String, Vec<AuthorityCand>>,
2960 grant_cands: &std::collections::BTreeMap<String, Vec<AuthorityCand>>,
2961 owner_hex: Option<&str>,
2962 excluded: &std::collections::BTreeSet<String>,
2963) -> (crate::community::roles::CommunityRoles, Vec<FoldedHead>) {
2964 use crate::community::roles::{CommunityRoles, Permissions};
2965 let mut accepted = CommunityRoles::default();
2966 let mut heads: Vec<FoldedHead> = Vec::new();
2967 let bound = 2 * (role_cands.len() + grant_cands.len()) + 8;
2972 for _ in 0..bound {
2973 let mut next = CommunityRoles::default();
2974 let mut next_heads: Vec<FoldedHead> = Vec::new();
2975
2976 for cands in role_cands.values() {
2977 for c in cands {
2978 let Some(role) = &c.role else { continue };
2979 let ah = c.author.to_hex();
2980 if excluded.contains(&ah) {
2981 continue;
2982 }
2983 if role.position != 0 && accepted.can_act_on_position(&ah, owner_hex, role.position, Permissions::MANAGE_ROLES) {
2984 next.roles.push(role.clone());
2985 next_heads.push(c.head.clone());
2986 break; }
2988 }
2989 }
2990 for cands in grant_cands.values() {
2991 for c in cands {
2992 let Some(grant) = &c.grant else { continue };
2993 let ah = c.author.to_hex();
2994 if excluded.contains(&ah) || excluded.contains(&grant.member) {
2995 continue;
2996 }
2997 let positions: Option<Vec<u32>> = grant.role_ids.iter().map(|rid| accepted.role(rid).map(|r| r.position)).collect();
3000 let Some(positions) = positions else { continue };
3001 if positions.iter().all(|p| accepted.can_act_on_position(&ah, owner_hex, *p, Permissions::MANAGE_ROLES))
3002 && accepted.can_act_on_member(&ah, owner_hex, &grant.member, Permissions::MANAGE_ROLES)
3003 {
3004 next_heads.push(c.head.clone());
3008 if !grant.role_ids.is_empty() {
3009 next.grants.push(grant.clone());
3010 }
3011 break;
3012 }
3013 }
3014 }
3015
3016 let converged = next.roles == accepted.roles && next.grants == accepted.grants;
3017 accepted = next;
3018 heads = next_heads;
3019 if converged {
3020 break;
3021 }
3022 }
3023 (accepted, heads)
3024}
3025
3026fn apply_community_metadata(out: &mut CommunityV2, meta: control::CommunityMetadata) -> bool {
3030 let mut changed = false;
3031 if out.name != meta.name {
3032 out.name = meta.name;
3033 changed = true;
3034 }
3035 if out.description != meta.description {
3036 out.description = meta.description;
3037 changed = true;
3038 }
3039 if out.icon != meta.icon {
3043 out.icon = meta.icon;
3044 changed = true;
3045 }
3046 if out.banner != meta.banner {
3047 out.banner = meta.banner;
3048 changed = true;
3049 }
3050 if out.meta_custom != meta.custom {
3053 out.meta_custom = meta.custom;
3054 changed = true;
3055 }
3056 if out.meta_extra != meta.extra {
3057 out.meta_extra = meta.extra;
3058 changed = true;
3059 }
3060 if !meta.relays.is_empty() && out.relays != meta.relays {
3061 out.relays = meta.relays;
3062 changed = true;
3063 }
3064 changed
3065}
3066
3067fn apply_channel_metadata(out: &mut CommunityV2, id: ChannelId, meta: control::ChannelMetadata) -> bool {
3072 let deleted = meta.deleted.unwrap_or(false);
3073 if deleted {
3074 let before = out.channels.len();
3075 out.channels.retain(|c| c.id.0 != id.0);
3076 return out.channels.len() != before;
3077 }
3078 match out.channels.iter_mut().find(|c| c.id.0 == id.0) {
3079 Some(existing) => {
3080 let mut changed = false;
3081 if existing.name != meta.name {
3082 existing.name = meta.name;
3083 changed = true;
3084 }
3085 if existing.voice != meta.voice {
3088 existing.voice = meta.voice;
3089 changed = true;
3090 }
3091 if existing.meta_custom != meta.custom {
3092 existing.meta_custom = meta.custom;
3093 changed = true;
3094 }
3095 if existing.meta_extra != meta.extra {
3096 existing.meta_extra = meta.extra;
3097 changed = true;
3098 }
3099 if !meta.private && (existing.private || existing.key.is_some()) {
3108 existing.private = false;
3109 existing.key = None;
3110 changed = true;
3111 }
3112 changed
3113 }
3114 None if !meta.private => {
3115 out.channels.push(ChannelV2 {
3118 id,
3119 name: meta.name,
3120 private: false,
3121 key: None,
3122 epoch: out.root_epoch,
3123 voice: meta.voice,
3124 meta_custom: meta.custom,
3125 meta_extra: meta.extra,
3126 });
3127 true
3128 }
3129 None => {
3130 out.channels.push(ChannelV2 {
3136 id,
3137 name: meta.name,
3138 private: true,
3139 key: None,
3140 epoch: Epoch(0),
3141 voice: meta.voice,
3142 meta_custom: meta.custom,
3143 meta_extra: meta.extra,
3144 });
3145 true
3146 }
3147 }
3148}
3149
3150pub struct RekeyFollow {
3154 pub updated: Option<CommunityV2>,
3157 pub self_removed: bool,
3160 pub dissolved: bool,
3163}
3164
3165const MAX_ADDRESSING_ROOTS: usize = 8;
3172
3173pub(crate) fn channel_rekey_addressing_roots(cur_root: [u8; 32], cid_hex: &str) -> Vec<[u8; 32]> {
3182 let mut roots: Vec<[u8; 32]> = vec![cur_root];
3183 let mut archived = crate::db::community::held_epoch_keys(cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX)
3184 .unwrap_or_default();
3185 archived.sort_by(|a, b| b.0 .0.cmp(&a.0 .0));
3186 for (_, r) in archived {
3187 if !roots.contains(&r) {
3188 roots.push(r);
3189 }
3190 }
3191 roots.truncate(MAX_ADDRESSING_ROOTS);
3192 roots
3193}
3194
3195#[cfg(debug_assertions)]
3230pub async fn debug_explain_base_rekey<T: Transport + ?Sized>(
3231 transport: &T,
3232 community: &CommunityV2,
3233) -> Result<serde_json::Value, String> {
3234 let me = local_keys()?;
3235 let my_xonly = me.public_key().to_bytes();
3236 let owner = community.owner()?;
3237 let owner_hex = owner.to_hex();
3238 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3239 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
3240 let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
3241 let held_epoch = community.root_epoch;
3242 let held_key = community.community_root;
3243 let next = Epoch(held_epoch.0.saturating_add(1));
3244 let group = base_rekey_group_key(&held_key, community.id(), next);
3245 let chunks = fetch_rekey_chunks(transport, &community.relays, &group).await?;
3246 let rotations = rekey::collect_rotations(&chunks);
3247
3248 let reports: Vec<serde_json::Value> = rotations
3249 .iter()
3250 .map(|r| {
3251 let rotator_is_owner = r.rotator == owner;
3252 let rotator_authorized = rotator_is_owner
3255 || (!banned.contains(&r.rotator.to_hex())
3256 && roster.is_authorized(&r.rotator.to_hex(), Some(&owner_hex), crate::community::roles::Permissions::BAN));
3257 let scope_ok = r.scope.id32() == rekey::RekeyScope::Root.id32();
3258 let epoch_ok = r.new_epoch.0 == next.0;
3259 let complete = r.is_complete();
3260 let continuity = format!("{:?}", r.continuity(held_epoch, &held_key));
3261 let has_my_blob = rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &my_xonly, r.scope, r.new_epoch).is_some();
3262 let owner_kept = r.rotator == owner
3267 || rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &owner.to_bytes(), r.scope, r.new_epoch).is_some();
3268 let verdict = if !rotator_authorized {
3270 "REJECTED: rotator holds no BAN authority in the folded roster"
3271 } else if !scope_ok {
3272 "REJECTED: scope is not Root"
3273 } else if !epoch_ok {
3274 "REJECTED: new_epoch != held+1"
3275 } else if !complete {
3276 "WAIT: rotation incomplete (missing chunk) — never concludes removal"
3277 } else if continuity != "Extends" {
3278 "REJECTED: continuity does not extend my held root (FORK/GAP)"
3279 } else if has_my_blob {
3280 "ADOPT: authorized + complete + continuous + my blob present"
3281 } else {
3282 "REMOVED: complete authorized rotation with no blob for me"
3283 };
3284 serde_json::json!({
3285 "rotator": r.rotator.to_hex(),
3286 "rotator_is_recorded_owner": rotator_is_owner,
3287 "rotator_authorized_ban": rotator_authorized,
3288 "scope_is_root": scope_ok,
3289 "new_epoch": r.new_epoch.0,
3290 "prev_epoch": r.prev_epoch.0,
3291 "declared_chunks": r.declared_chunks,
3292 "held_chunks": r.held_chunks.iter().copied().collect::<Vec<_>>(),
3293 "is_complete": complete,
3294 "continuity_vs_held_root": continuity,
3295 "my_blob_present": has_my_blob,
3296 "owner_kept": owner_kept,
3297 "blob_count": r.blobs.len(),
3298 "verdict": verdict,
3299 })
3300 })
3301 .collect();
3302
3303 Ok(serde_json::json!({
3304 "recorded_owner": owner.to_hex(),
3305 "held_root_epoch": held_epoch.0,
3306 "probing_next_epoch": next.0,
3307 "base_plane_pk": group.pk_hex(),
3308 "raw_chunks_parsed": chunks.len(),
3309 "rotations_found": rotations.len(),
3310 "rotations": reports,
3311 }))
3312}
3313
3314pub async fn follow_rekeys<T: Transport + ?Sized>(
3315 transport: &T,
3316 community: &CommunityV2,
3317 session: &SessionGuard,
3318) -> Result<RekeyFollow, String> {
3319 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3322 if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
3323 return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: true });
3324 }
3325 if is_dissolved(transport, community).await {
3331 if session.is_valid() {
3332 let _ = crate::db::community::set_community_dissolved(&cid_hex);
3333 }
3334 return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: true });
3335 }
3336 let me = local_keys()?;
3337 let my_xonly = me.public_key().to_bytes();
3338 let owner = community.owner()?;
3339 let owner_hex = owner.to_hex();
3340 let mut cur = community.clone();
3341 let mut changed = false;
3342
3343 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
3355 let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
3356 let me_hex = me.public_key().to_hex();
3357 let channel_rotator_ok = |rotator: &PublicKey| -> bool {
3358 if *rotator == owner {
3359 return true;
3360 }
3361 let rh = rotator.to_hex();
3362 !banned.contains(&rh) && roster.is_authorized(&rh, Some(&owner_hex), crate::community::roles::Permissions::MANAGE_CHANNELS)
3363 };
3364 let channel_rotator_outranks_me = |rotator: &PublicKey| -> bool {
3369 if *rotator == owner {
3370 return true;
3371 }
3372 let rh = rotator.to_hex();
3373 !banned.contains(&rh) && roster.can_act_on_member(&rh, Some(&owner_hex), &me_hex, crate::community::roles::Permissions::MANAGE_CHANNELS)
3374 };
3375 let base_rotator_ok = |rotator: &PublicKey| -> bool {
3381 if *rotator == owner {
3382 return true;
3383 }
3384 let rh = rotator.to_hex();
3385 !banned.contains(&rh) && roster.is_authorized(&rh, Some(&owner_hex), crate::community::roles::Permissions::BAN)
3386 };
3387 let base_rotator_outranks_me = |rotator: &PublicKey| -> bool {
3393 if *rotator == owner {
3394 return true;
3395 }
3396 let rh = rotator.to_hex();
3397 !banned.contains(&rh) && roster.can_act_on_member(&rh, Some(&owner_hex), &me_hex, crate::community::roles::Permissions::BAN)
3398 };
3399
3400 const MAX_STEPS: usize = 128;
3404 for _ in 0..MAX_STEPS {
3405 let mut advanced = false;
3406
3407 let addressing_roots = channel_rekey_addressing_roots(cur.community_root, &cid_hex);
3412
3413 let channel_ids: Vec<ChannelId> = cur.channels.iter().filter(|c| c.private).map(|c| c.id).collect();
3416 for cid in channel_ids {
3417 let (held_key, held_epoch) = match cur.channel(&cid) {
3418 Some(ch) => (ch.key, ch.epoch),
3419 None => continue,
3420 };
3421 let next = Epoch(held_epoch.0.saturating_add(1));
3422 let ch_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
3423 let mut batches: Vec<(Vec<rekey::RekeyChunk>, Option<(Epoch, [u8; 32])>)> = Vec::new();
3424 for (ri, root) in addressing_roots.iter().enumerate() {
3427 let group = channel_rekey_group_key(root, &cid, next);
3428 let chunks = match fetch_rekey_chunks(transport, &cur.relays, &group).await {
3429 Ok(c) => c,
3430 Err(e) => {
3431 crate::log_warn!(
3432 "[v2:follow {}] ch {} next e{} root#{}/{}: rekey plane fetch failed: {}",
3433 &cid_hex[..8], &ch_hex[..8], next.0, ri, addressing_roots.len(), e
3434 );
3435 return Err(e);
3436 }
3437 };
3438 if chunks.is_empty() {
3439 continue;
3440 }
3441 crate::log_debug!(
3442 "[v2:follow {}] ch {} next e{} root#{}/{}: {} rekey chunk(s)",
3443 &cid_hex[..8], &ch_hex[..8], next.0, ri, addressing_roots.len(), chunks.len()
3444 );
3445 batches.push((chunks, held_key.map(|k| (held_epoch, k))));
3446 }
3447 match advance_scope(&batches, RekeyScope::Channel(cid), &channel_rotator_ok, &channel_rotator_outranks_me, &|_r: &rekey::Rotation| true, me.secret_key(), &my_xonly, next) {
3454 Advance::Adopt { new_key } => {
3455 if let Some(ch) = cur.channels.iter_mut().find(|c| c.id.0 == cid.0) {
3456 ch.key = Some(new_key);
3457 ch.epoch = next;
3458 }
3459 crate::log_debug!("[v2:follow {}] ch {} ADOPTED e{}", &cid_hex[..8], &ch_hex[..8], next.0);
3460 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) {
3465 crate::log_warn!("v2: channel epoch-key archive failed (history across this rotation may not read back): {e}");
3466 }
3467 advanced = true;
3468 changed = true;
3469 }
3470 Advance::Removed => {
3471 match held_key {
3472 Some(_) => {
3474 cur.channels.retain(|c| c.id.0 != cid.0);
3475 }
3476 None => {
3481 if let Some(ch) = cur.channels.iter_mut().find(|c| c.id.0 == cid.0) {
3482 ch.epoch = next;
3483 }
3484 }
3485 }
3486 advanced = true;
3487 changed = true;
3488 }
3489 Advance::Stay => {}
3490 }
3491 }
3492
3493 {
3497 let held_epoch = cur.root_epoch;
3498 let held_key = cur.community_root;
3499 let next = Epoch(held_epoch.0.saturating_add(1));
3500 let group = base_rekey_group_key(&cur.community_root, cur.id(), next);
3501 let chunks = fetch_rekey_chunks(transport, &cur.relays, &group).await?;
3502 let batches = vec![(chunks, Some((held_epoch, held_key)))];
3503 let base_admissible = |r: &rekey::Rotation| -> bool {
3510 if r.rotator == owner {
3511 return true; }
3513 let rotator_hex = r.rotator.to_hex();
3514 let has_blob = |xonly: &[u8; 32]| {
3515 rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), xonly, r.scope, r.new_epoch).is_some()
3516 };
3517 if !has_blob(&owner.to_bytes()) {
3519 return false;
3520 }
3521 for g in &roster.grants {
3522 if g.member == rotator_hex || g.member == owner_hex || banned.contains(&g.member) {
3523 continue; }
3525 if !roster.can_act_on_member(&rotator_hex, Some(&owner_hex), &g.member, crate::community::roles::Permissions::BAN) {
3527 if let Ok(pk) = PublicKey::from_hex(&g.member) {
3528 if !has_blob(&pk.to_bytes()) {
3529 return false; }
3531 }
3532 }
3533 }
3534 true
3535 };
3536 match advance_scope(&batches, RekeyScope::Root, &base_rotator_ok, &base_rotator_outranks_me, &base_admissible, me.secret_key(), &my_xonly, next) {
3537 Advance::Adopt { new_key } => {
3538 cur.community_root = new_key;
3539 cur.root_epoch = next;
3540 if let Err(e) = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, next.0, &new_key) {
3544 crate::log_warn!("v2: base epoch-key archive failed (this epoch's history may not read back after the next rotation): {e}");
3545 }
3546 advanced = true;
3547 changed = true;
3548 }
3549 Advance::Removed => {
3550 if !session.is_valid() {
3551 return Err("account changed during rekey follow".to_string());
3552 }
3553 return Ok(RekeyFollow { updated: None, self_removed: true, dissolved: false });
3554 }
3555 Advance::Stay => {}
3556 }
3557 }
3558
3559 if !advanced {
3560 break;
3561 }
3562 }
3563
3564 if !changed {
3565 return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: false });
3566 }
3567 if !session.is_valid() {
3568 return Err("account changed during rekey follow".to_string());
3569 }
3570 if crate::db::community::community_protocol(community.id())?.is_none() {
3573 return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: false });
3574 }
3575 crate::db::community::save_community_v2(&cur)?;
3576 Ok(RekeyFollow { updated: Some(cur), self_removed: false, dissolved: false })
3577}
3578
3579enum Advance {
3582 Adopt { new_key: [u8; 32] },
3584 Removed,
3586 Stay,
3588}
3589
3590async fn fetch_rekey_chunks<T: Transport + ?Sized>(
3592 transport: &T,
3593 relays: &[String],
3594 group: &GroupKey,
3595) -> Result<Vec<rekey::RekeyChunk>, String> {
3596 const REKEY_PAGE: usize = 200;
3604 const REKEY_MAX_PAGES: usize = 6;
3605 let mut out = Vec::new();
3606 let mut seen: std::collections::HashSet<nostr_sdk::EventId> = std::collections::HashSet::new();
3607 let mut until: Option<u64> = None;
3608 let mut oldest: Option<u64> = None;
3609 for _ in 0..REKEY_MAX_PAGES {
3610 let query = Query {
3611 kinds: vec![stream::KIND_WRAP],
3612 authors: vec![group.pk_hex()],
3613 until,
3614 limit: Some(REKEY_PAGE),
3615 ..Default::default()
3616 };
3617 let wraps = transport.fetch_plane(group.keys(), &query, relays).await?;
3622 let mut fresh = 0usize;
3623 for w in &wraps {
3624 if !seen.insert(w.id) {
3625 continue;
3626 }
3627 fresh += 1;
3628 let at = w.created_at.as_secs();
3629 if oldest.is_none_or(|o| at < o) {
3630 oldest = Some(at);
3631 }
3632 if let Ok(opened) = stream::open_wrap(w, group) {
3633 if let Ok(chunk) = rekey::parse_rekey_chunk(&opened) {
3634 out.push(chunk);
3635 }
3636 }
3637 }
3638 if fresh == 0 || wraps.len() < REKEY_PAGE {
3641 break;
3642 }
3643 match oldest {
3644 Some(o) if o > 0 => until = Some(o),
3645 _ => break,
3646 }
3647 }
3648 Ok(out)
3649}
3650
3651fn advance_scope(
3665 batches: &[(Vec<rekey::RekeyChunk>, Option<(Epoch, [u8; 32])>)],
3666 scope: RekeyScope,
3667 rotator_ok: &dyn Fn(&PublicKey) -> bool,
3668 rotator_may_remove_me: &dyn Fn(&PublicKey) -> bool,
3669 admissible: &dyn Fn(&rekey::Rotation) -> bool,
3670 my_sk: &SecretKey,
3671 my_xonly: &[u8; 32],
3672 next_epoch: Epoch,
3673) -> Advance {
3674 let mut winners: Vec<[u8; 32]> = Vec::new();
3675 let mut saw_complete_candidate = false;
3676 let mut saw_outranking_candidate = false;
3677 let keyed = batches.iter().any(|(_, held)| held.is_some());
3678 for (chunks, held) in batches {
3679 let rotations = rekey::collect_rotations(chunks);
3680 for r in &rotations {
3681 if !rotator_ok(&r.rotator) || r.scope.id32() != scope.id32() || r.new_epoch.0 != next_epoch.0 || !r.is_complete() {
3682 continue;
3683 }
3684 if let Some((held_epoch, held_key)) = held {
3685 if r.continuity(*held_epoch, held_key) != Continuity::Extends {
3686 continue;
3687 }
3688 }
3689 if !admissible(r) {
3695 continue;
3696 }
3697 saw_complete_candidate = true;
3698 saw_outranking_candidate |= rotator_may_remove_me(&r.rotator);
3699 if let Some(blob) = rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), my_xonly, r.scope, r.new_epoch) {
3700 if let Ok(k) = rekey::open_blob_local(my_sk, &r.rotator, r.scope, r.new_epoch, blob) {
3701 winners.push(k);
3702 }
3703 }
3704 }
3705 }
3706 if !winners.is_empty() {
3707 let idx = rekey::lowest_key_winner(&winners).expect("winners is non-empty");
3715 return Advance::Adopt { new_key: winners[idx] };
3716 }
3717 if saw_complete_candidate && (!keyed || saw_outranking_candidate) {
3718 Advance::Removed
3719 } else {
3720 Advance::Stay
3721 }
3722}
3723
3724#[cfg(test)]
3725mod tests {
3726 use super::super::super::transport::memory::MemoryRelay;
3727 use super::*;
3728 use crate::community::roles::{MemberGrant, Permissions, Role, RoleScope};
3729
3730 fn account_name(n: u32) -> String {
3732 const B: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
3733 let mut acct = String::from("npub1");
3734 let mut v = n as usize;
3735 for _ in 0..58 {
3736 acct.push(B[v % 32] as char);
3737 v = v / 32 + 7;
3738 }
3739 acct
3740 }
3741
3742 struct Actor {
3744 keys: Keys,
3745 account: String,
3746 }
3747
3748 struct TestBed {
3754 _tmp: tempfile::TempDir,
3755 _guard: std::sync::MutexGuard<'static, ()>,
3756 relay: MemoryRelay,
3757 relays: Vec<String>,
3758 }
3759
3760 impl TestBed {
3761 fn new() -> (TestBed, Actor, Actor) {
3762 static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(70_000);
3763 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
3764 crate::db::close_database();
3765 crate::db::clear_id_caches();
3766 let tmp = tempfile::tempdir().unwrap();
3767 crate::db::set_app_data_dir(tmp.path().to_path_buf());
3768
3769 let mut mk = || {
3770 let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3771 let account = account_name(n);
3772 std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
3773 crate::db::set_current_account(account.clone()).unwrap();
3774 crate::db::init_database(&account).unwrap();
3775 Actor { keys: Keys::generate(), account }
3776 };
3777 let owner = mk();
3778 let member = mk();
3779 let _ = crate::state::take_nostr_client();
3780 let bed = TestBed {
3781 _tmp: tmp,
3782 _guard: guard,
3783 relay: MemoryRelay::new(),
3784 relays: vec!["wss://r".to_string()],
3785 };
3786 (bed, owner, member)
3787 }
3788
3789 fn swap_to(&self, actor: &Actor) {
3791 crate::db::set_current_account(actor.account.clone()).unwrap();
3792 crate::db::init_database(&actor.account).unwrap();
3793 crate::db::clear_id_caches();
3794 crate::state::MY_SECRET_KEY.store_from_keys(&actor.keys, &[]);
3795 crate::state::set_my_public_key(actor.keys.public_key());
3796 }
3797 }
3798
3799 fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>, Keys) {
3801 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
3802 crate::db::close_database();
3803 crate::db::clear_id_caches();
3804 let tmp = tempfile::tempdir().unwrap();
3805 static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(50_000);
3806 let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3807 let acct = account_name(n);
3808 std::fs::create_dir_all(tmp.path().join(&acct)).unwrap();
3809 crate::db::set_app_data_dir(tmp.path().to_path_buf());
3810 crate::db::set_current_account(acct.clone()).unwrap();
3811 crate::db::init_database(&acct).unwrap();
3812 let _ = crate::state::take_nostr_client();
3813 let owner = Keys::generate();
3814 crate::state::MY_SECRET_KEY.store_from_keys(&owner, &[]);
3815 crate::state::set_my_public_key(owner.public_key());
3816 (tmp, guard, owner)
3817 }
3818
3819 struct SwapMidFetch {
3822 inner: MemoryRelay,
3823 }
3824 #[async_trait::async_trait]
3825 impl Transport for SwapMidFetch {
3826 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
3827 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
3828 self.inner.publish(e, r).await
3829 }
3830 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
3831 self.inner.publish_durable(e, r).await
3832 }
3833 async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> {
3834 let out = self.inner.fetch(q, r).await;
3835 crate::state::bump_session_generation();
3836 out
3837 }
3838 }
3839
3840 struct FixedFetch {
3845 events: Vec<Event>,
3846 }
3847 #[async_trait::async_trait]
3848 impl Transport for FixedFetch {
3849 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
3850 async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
3851 Ok(())
3852 }
3853 async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
3854 Ok(())
3855 }
3856 async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
3857 Ok(self.events.clone())
3858 }
3859 }
3860
3861 async fn fetch_direct_invite(relay: &MemoryRelay, relays: &[String], me: &PublicKey) -> Event {
3864 let q = Query {
3865 kinds: vec![stream::KIND_WRAP],
3866 p_tags: vec![me.to_hex()],
3867 k_tags: vec!["3313".to_string()],
3868 ..Default::default()
3869 };
3870 relay.fetch(&q, relays).await.unwrap().into_iter().next().expect("a direct invite is waiting")
3871 }
3872
3873 #[tokio::test]
3874 async fn create_persists_and_reloads_a_v2_community() {
3875 let (_tmp, _guard, owner) = init_test_db();
3876 let relay = MemoryRelay::new();
3877 let relays = vec!["wss://r".to_string()];
3878
3879 let created = create_community(&relay, "Vectorville", relays.clone(), Some("hi".into())).await.unwrap();
3880 assert!(created.identity.verify());
3881 assert_eq!(created.owner().unwrap(), owner.public_key());
3882 assert_eq!(created.channels.len(), 1);
3883
3884 assert_eq!(
3886 crate::db::community::community_protocol(created.id()).unwrap(),
3887 Some(crate::community::ConcordProtocol::V2)
3888 );
3889 let loaded = crate::db::community::load_community_v2(created.id()).unwrap().expect("reloads");
3890 assert_eq!(loaded.name, "Vectorville");
3891 assert_eq!(loaded.community_root, created.community_root);
3892 assert_eq!(loaded.identity, created.identity);
3893 assert_eq!(loaded.channels[0].id.0, created.channels[0].id.0);
3894 assert!(!loaded.channels[0].private);
3895
3896 assert!(relay.count_on("wss://r") >= 3, "2 genesis editions + 1 guestbook join");
3898 }
3899
3900 #[tokio::test]
3901 async fn owner_sends_and_reads_back_a_message() {
3902 let (_tmp, _guard, _owner) = init_test_db();
3903 let relay = MemoryRelay::new();
3904 let community = create_community(&relay, "Chat", vec!["wss://r".into()], None).await.unwrap();
3905 let general = community.channels[0].id;
3906
3907 let id1 = send_message(&relay, &community, &general, "hello world").await.unwrap();
3908 let id2 = send_message(&relay, &community, &general, "second message").await.unwrap();
3909 assert_ne!(id1, id2);
3910
3911 let page = fetch_channel(&relay, &community, &general, 100).await.unwrap();
3912 let texts: Vec<String> = page
3913 .iter()
3914 .filter_map(|f| match &f.event {
3915 ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
3916 _ => None,
3917 })
3918 .collect();
3919 assert_eq!(texts, vec!["hello world", "second message"], "messages round-trip in ms order");
3920 }
3921
3922 #[tokio::test]
3923 async fn a_second_member_reads_the_public_channel_from_the_root() {
3924 let (_tmp, _guard, _owner) = init_test_db();
3928 let relay = MemoryRelay::new();
3929 let community = create_community(&relay, "Public", vec!["wss://r".into()], None).await.unwrap();
3930 let general = community.channels[0].id;
3931 send_message(&relay, &community, &general, "everyone can read this").await.unwrap();
3932
3933 let member_view = community.clone();
3935 let page = fetch_channel(&relay, &member_view, &general, 100).await.unwrap();
3936 assert_eq!(page.len(), 1);
3937 assert!(matches!(&page[0].event, ChatEvent::Message { .. }));
3938 assert_eq!(page[0].event.opened().rumor.content, "everyone can read this");
3939 }
3940
3941 async fn texts_in<T: crate::community::transport::Transport + ?Sized>(relay: &T, community: &CommunityV2, channel: &ChannelId) -> Vec<String> {
3944 fetch_channel(relay, community, channel, 100)
3945 .await
3946 .unwrap()
3947 .iter()
3948 .filter_map(|f| match &f.event {
3949 ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
3950 _ => None,
3951 })
3952 .collect()
3953 }
3954
3955 #[tokio::test]
3956 async fn direct_invite_full_loop_owner_and_member_converse() {
3957 let (bed, owner, member) = TestBed::new();
3958
3959 bed.swap_to(&owner);
3961 let community = create_community(&bed.relay, "Guild", bed.relays.clone(), None).await.unwrap();
3962 let general = community.channels[0].id;
3963 send_message(&bed.relay, &community, &general, "owner: welcome!").await.unwrap();
3964 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
3965
3966 bed.swap_to(&member);
3968 assert!(
3969 crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
3970 "the member does not hold the community before joining"
3971 );
3972 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
3973 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
3974 assert_eq!(joined.id().0, community.id().0, "joined the same community");
3975 assert!(joined.identity.verify(), "the joiner independently verifies the owner commitment");
3976 assert_eq!(joined.owner().unwrap(), owner.keys.public_key());
3977
3978 assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: welcome!"]);
3980 send_message(&bed.relay, &joined, &general, "member: thanks for the invite").await.unwrap();
3981
3982 bed.swap_to(&owner);
3984 assert_eq!(
3985 texts_in(&bed.relay, &community, &general).await,
3986 vec!["owner: welcome!", "member: thanks for the invite"],
3987 "both actors' messages interleave in ms order on the shared channel"
3988 );
3989
3990 let members = memberlist(&bed.relay, &community).await.unwrap();
3992 assert!(members.contains(&owner.keys.public_key()), "owner is a member (genesis Join)");
3993 assert!(members.contains(&member.keys.public_key()), "member is a member (invite Join)");
3994 assert_eq!(members.len(), 2);
3995 }
3996
3997 #[tokio::test]
3998 async fn public_link_full_loop() {
3999 let (bed, owner, member) = TestBed::new();
4000
4001 bed.swap_to(&owner);
4002 let community = create_community(&bed.relay, "Public Guild", bed.relays.clone(), None).await.unwrap();
4003 let general = community.channels[0].id;
4004 send_message(&bed.relay, &community, &general, "come on in").await.unwrap();
4005 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
4007 assert!(link.url.starts_with("https://vectorapp.io/invite/"));
4008 assert!(link.url.contains('#'), "the fragment carries the token");
4009
4010 bed.swap_to(&member);
4012 let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
4013 assert_eq!(joined.id().0, community.id().0);
4014 assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["come on in"]);
4015 }
4016
4017 #[test]
4018 fn bundle_of_snapshots_the_held_icon() {
4019 let owner = Keys::generate();
4020 let g = control::genesis(&owner, control::CommunityMetadata { name: "Logo".into(), ..Default::default() }, 1_000).unwrap();
4021 let mut c = CommunityV2::from_genesis(&g, "Logo", None, vec!["wss://r".into()], 0);
4022 let icon = control::ImageRef { url: "https://blossom.example/i".into(), key: "k".into(), nonce: "n".into(), hash: "h".into(), extra: Default::default() };
4023 c.icon = Some(icon.clone());
4024 let bundle = bundle_of(&c, None, None, None);
4025 assert_eq!(bundle.icon, Some(icon), "a parked invite renders the real logo from the mint-time snapshot");
4026 }
4027
4028 #[test]
4029 fn addressing_roots_fan_current_plus_archived_bounded_and_deduped() {
4030 let (_tmp, _guard, _owner) = init_test_db();
4035 let cur_root = [9u8; 32];
4036 let cid = crate::community::CommunityId([1u8; 32]);
4037 let cid_hex = cid.to_hex();
4038
4039 let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
4041 assert_eq!(roots, vec![cur_root], "with no archived roots the fan is the current root alone");
4042
4043 crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 0, &[1u8; 32]).unwrap();
4045 crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1, &[2u8; 32]).unwrap();
4046 let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
4047 assert_eq!(roots[0], cur_root, "current root leads");
4048 assert!(roots.contains(&[1u8; 32]) && roots.contains(&[2u8; 32]), "both archived roots are in the fan");
4049 assert_eq!(roots.len(), 3, "current + 2 archived, no dupes");
4050 assert_eq!(roots[1], [2u8; 32], "higher archived epoch is addressed before the lower");
4052
4053 crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 2, &cur_root).unwrap();
4055 let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
4056 assert_eq!(roots.iter().filter(|r| **r == cur_root).count(), 1, "the current root is never duplicated");
4057
4058 for e in 3..20u64 {
4060 crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, e, &[e as u8; 32]).unwrap();
4061 }
4062 let roots = channel_rekey_addressing_roots(cur_root, &cid_hex);
4063 assert_eq!(roots.len(), MAX_ADDRESSING_ROOTS, "the fan is bounded so a relay can't feed an unbounded walk");
4064 }
4065
4066 #[tokio::test]
4067 async fn public_link_preview_shows_live_name_and_icon_without_joining() {
4068 let (bed, owner, member) = TestBed::new();
4069
4070 bed.swap_to(&owner);
4071 let community = create_community(&bed.relay, "Soapbox", bed.relays.clone(), None).await.unwrap();
4072 let icon = control::ImageRef {
4075 url: "https://blossom.example/soap".into(),
4076 key: "k".into(),
4077 nonce: "n".into(),
4078 hash: "h".into(),
4079 extra: Default::default(),
4080 };
4081 let mut meta = community.metadata();
4082 meta.icon = Some(icon.clone());
4083 edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
4084 let link = mint_public_link(&bed.relay, &community, "https://armada.buzz", None, None).await.unwrap();
4086
4087 bed.swap_to(&member);
4089 let preview = preview_public_link(&bed.relay, &link.url).await.unwrap();
4090 assert_eq!(preview.name, "Soapbox");
4091 assert_eq!(preview.icon, Some(icon), "the icon folds from the live Control Plane, not the bundle");
4092 assert!(
4093 crate::db::community::load_community_v2(preview.id()).unwrap().is_none(),
4094 "previewing must not persist a membership"
4095 );
4096 }
4097
4098 #[tokio::test]
4099 async fn a_previewed_join_reuses_the_verified_fold() {
4100 let (bed, owner, member) = TestBed::new();
4101 bed.swap_to(&owner);
4102 let community = create_community(&bed.relay, "FastJoin", bed.relays.clone(), None).await.unwrap();
4103 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
4104
4105 bed.swap_to(&member);
4106 let _ = preview_public_link(&bed.relay, &link.url).await.unwrap();
4107 let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
4108 assert_eq!(joined.id().0, community.id().0);
4109 assert!(joined.created_at_ms > 0, "the handoff stamps the JOIN's acquisition time, not the preview's");
4110 assert!(VERIFIED_PREVIEW.lock().unwrap().is_none(), "the handoff slot must be consumed by the join");
4113 }
4114
4115 #[tokio::test]
4116 async fn guestbook_store_seeds_syncs_incrementally_and_matches_the_live_fold() {
4117 let (bed, owner, member) = TestBed::new();
4118 bed.swap_to(&owner);
4119 let community = create_community(&bed.relay, "GB", bed.relays.clone(), None).await.unwrap();
4120 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
4121
4122 bed.swap_to(&member);
4123 let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
4124
4125 let session = SessionGuard::capture();
4127 assert!(!sync_guestbook(&bed.relay, &joined, &session).await.unwrap().is_empty(), "the seed folds fresh events");
4128 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
4129 let (_, cursor) = crate::db::community::get_guestbook(&cid_hex).unwrap();
4130 assert!(cursor > 0, "the cursor advanced past zero");
4131 let stored: std::collections::BTreeSet<_> = stored_memberlist(&joined).unwrap().into_iter().collect();
4132 let live: std::collections::BTreeSet<_> = memberlist(&bed.relay, &joined).await.unwrap().into_iter().collect();
4133 assert_eq!(stored, live, "stored fold == live fold after the seed");
4134 assert!(stored.contains(&member.keys.public_key()));
4135
4136 assert!(sync_guestbook(&bed.relay, &joined, &session).await.unwrap().is_empty());
4138
4139 bed.swap_to(&owner);
4141 kick_member(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
4142 bed.swap_to(&member);
4143 let session = SessionGuard::capture();
4144 assert!(!sync_guestbook(&bed.relay, &joined, &session).await.unwrap().is_empty(), "the kick lands incrementally");
4145 assert!(
4146 !stored_memberlist(&joined).unwrap().contains(&member.keys.public_key()),
4147 "an owner kick removes the member from the stored fold"
4148 );
4149 }
4150
4151 #[tokio::test]
4152 async fn a_preview_then_revoke_still_refuses_the_join() {
4153 let (bed, owner, member) = TestBed::new();
4154 bed.swap_to(&owner);
4155 let community = create_community(&bed.relay, "RevokeRace", bed.relays.clone(), None).await.unwrap();
4156 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
4157
4158 bed.swap_to(&member);
4160 let p = preview_public_link(&bed.relay, &link.url).await.unwrap();
4161 assert_eq!(p.name, "RevokeRace");
4162 bed.swap_to(&owner);
4163 let tombstone = invite::build_revocation(&link.link_signer).unwrap();
4164 bed.relay.publish_durable(&tombstone, &bed.relays).await.unwrap();
4165
4166 bed.swap_to(&member);
4169 let err = accept_public_link(&bed.relay, &link.url).await.unwrap_err();
4170 assert!(err.contains("revoked"), "got: {err}");
4171 }
4172
4173 #[tokio::test]
4174 async fn a_revoked_link_refuses_to_join() {
4175 let (bed, owner, member) = TestBed::new();
4176 bed.swap_to(&owner);
4177 let community = create_community(&bed.relay, "Revoked", bed.relays.clone(), None).await.unwrap();
4178 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
4179 let tombstone = invite::build_revocation(&link.link_signer).unwrap();
4181 bed.relay.publish_durable(&tombstone, &bed.relays).await.unwrap();
4182
4183 bed.swap_to(&member);
4184 let err = accept_public_link(&bed.relay, &link.url).await.unwrap_err();
4185 assert!(err.contains("revoked"), "a retired link finds the grave, not keys: {err}");
4186 }
4187
4188 #[tokio::test]
4189 async fn an_expired_direct_invite_refuses_to_join() {
4190 let (bed, owner, member) = TestBed::new();
4191 bed.swap_to(&owner);
4192 let community = create_community(&bed.relay, "Expired", bed.relays.clone(), None).await.unwrap();
4193 let inviter = owner.keys.clone();
4195 let mut bundle = bundle_of(&community, Some(inviter.public_key()), Some(1_000), None);
4196 bundle.expires_at = Some(1_000); let wrap = invite::build_direct_invite(&inviter, &member.keys.public_key(), &bundle).unwrap();
4198 bed.relay.publish(&wrap, &bed.relays).await.unwrap();
4199
4200 bed.swap_to(&member);
4201 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
4202 let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
4203 assert!(err.contains("expired"), "a past-expiry invite refuses to join: {err}");
4204 }
4205
4206 #[tokio::test]
4207 async fn a_tombstone_beats_a_live_bundle_regardless_of_fetch_order() {
4208 let (bed, owner, member) = TestBed::new();
4212 bed.swap_to(&owner);
4213 let community = create_community(&bed.relay, "Rev", bed.relays.clone(), None).await.unwrap();
4214 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
4215 let tombstone = invite::build_revocation(&link.link_signer).unwrap();
4216
4217 let union = FixedFetch { events: vec![link.bundle_event.clone(), tombstone] };
4220
4221 bed.swap_to(&member);
4222 let err = accept_public_link(&union, &link.url).await.unwrap_err();
4223 assert!(err.contains("revoked"), "a tombstone must beat a Live returned first: {err}");
4224 }
4225
4226 #[test]
4227 fn from_bundle_refuses_an_over_cap_bundle_before_allocating() {
4228 let owner = Keys::generate();
4234 let identity = super::super::control::CommunityIdentity::mint(&owner.public_key());
4235 let hex = crate::simd::hex::bytes_to_hex_32;
4236 let root = [0x11u8; 32];
4237 let mut bundle = CommunityInvite {
4238 community_id: hex(&identity.community_id.0),
4239 owner: hex(&identity.owner_xonly),
4240 owner_salt: hex(&identity.owner_salt),
4241 community_root: hex(&root),
4242 root_epoch: 0,
4243 channels: vec![],
4244 relays: vec!["wss://r".into()],
4245 name: "X".into(),
4246 icon: None,
4247 expires_at: None,
4248 creator_npub: None,
4249 label: None,
4250 extra: Default::default(),
4251 };
4252 bundle.channels = (0..=invite::MAX_BUNDLE_CHANNELS)
4253 .map(|i| {
4254 let mut id = [0u8; 32];
4255 id[..8].copy_from_slice(&(i as u64).to_be_bytes());
4256 invite::ChannelGrant { id: hex(&id), key: hex(&root), epoch: 0, name: "x".into() }
4257 })
4258 .collect();
4259 assert!(CommunityV2::from_bundle(&bundle, 0).is_err(), "an over-cap bundle is refused before allocating");
4260 }
4261
4262 #[tokio::test]
4263 async fn a_join_swap_between_fetch_and_save_aborts_and_leaves_the_other_account_clean() {
4264 let (bed, owner, member) = TestBed::new();
4269 bed.swap_to(&owner);
4270 let community = create_community(&bed.relay, "Straddle", bed.relays.clone(), None).await.unwrap();
4271 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
4272 let swap_relay = SwapMidFetch { inner: MemoryRelay::new() };
4274 swap_relay.inner.publish_durable(&link.bundle_event, &bed.relays).await.unwrap();
4275
4276 bed.swap_to(&member);
4277 let err = accept_public_link(&swap_relay, &link.url).await.unwrap_err();
4278 assert!(err.contains("account changed"), "a swap mid-join must abort: {err}");
4279 assert!(
4280 crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
4281 "the aborted join wrote nothing to the (member) account DB"
4282 );
4283 }
4284
4285 #[tokio::test]
4286 async fn the_owner_is_a_member_even_without_a_fetched_genesis_join() {
4287 let (_tmp, _guard, owner) = init_test_db();
4290 let relay = MemoryRelay::new();
4291 let community = create_community(&relay, "Owned", vec!["wss://r".into()], None).await.unwrap();
4292 let empty = MemoryRelay::new();
4295 let members = memberlist(&empty, &community).await.unwrap();
4296 assert_eq!(members, vec![owner.public_key()], "owner present with no fetched Join");
4297 }
4298
4299 #[tokio::test]
4300 async fn an_expiring_minted_invite_refuses_after_the_deadline() {
4301 let (bed, owner, member) = TestBed::new();
4304 bed.swap_to(&owner);
4305 let community = create_community(&bed.relay, "Timed", bed.relays.clone(), None).await.unwrap();
4306 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), Some(1_000), Some("beta".into()))
4307 .await
4308 .unwrap();
4309
4310 bed.swap_to(&member);
4311 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
4312 assert!(
4313 accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err().contains("expired"),
4314 "a minted expiring invite refuses past its deadline"
4315 );
4316 }
4317
4318 #[tokio::test]
4319 async fn a_member_who_leaves_drops_from_the_memberlist() {
4320 let (bed, owner, member) = TestBed::new();
4321 bed.swap_to(&owner);
4322 let community = create_community(&bed.relay, "Leaving", bed.relays.clone(), None).await.unwrap();
4323 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
4324
4325 bed.swap_to(&member);
4326 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
4327 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
4328 tokio::time::sleep(std::time::Duration::from_millis(2)).await;
4330 leave_community(&bed.relay, &joined).await.unwrap();
4331
4332 bed.swap_to(&owner);
4333 let members = memberlist(&bed.relay, &community).await.unwrap();
4334 assert!(members.contains(&owner.keys.public_key()));
4335 assert!(!members.contains(&member.keys.public_key()), "a member who left drops from the list");
4336 }
4337
4338 #[tokio::test]
4339 async fn a_swapped_member_cannot_see_the_owners_community_until_joining() {
4340 let (bed, owner, member) = TestBed::new();
4343 bed.swap_to(&owner);
4344 let community = create_community(&bed.relay, "Private-so-far", bed.relays.clone(), None).await.unwrap();
4345 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_some());
4346
4347 bed.swap_to(&member);
4348 assert!(
4349 crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
4350 "the owner's community must be invisible in the member's account DB"
4351 );
4352 assert_eq!(crate::db::community::list_community_ids().unwrap().len(), 0);
4353 }
4354
4355 async fn head_hash_on_relay(relay: &MemoryRelay, community: &CommunityV2, entity_id: &[u8; 32]) -> Option<[u8; 32]> {
4363 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
4364 let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
4365 let wraps = relay.fetch(&query, &community.relays).await.ok()?;
4366 let mut head: Option<(u64, [u8; 32])> = None;
4367 for w in &wraps {
4368 if let Ok((ed, _)) = control::open_control_edition(w, &group) {
4369 if ed.entity_id == *entity_id && head.is_none_or(|(v, _)| ed.version > v) {
4370 head = Some((ed.version, ed.self_hash));
4371 }
4372 }
4373 }
4374 head.map(|(_, h)| h)
4375 }
4376
4377 async fn publish_channel_edition(
4378 relay: &MemoryRelay,
4379 community: &CommunityV2,
4380 signer: &Keys,
4381 channel_id: &ChannelId,
4382 name: &str,
4383 private: bool,
4384 version: u64,
4385 deleted: bool,
4386 ) {
4387 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
4388 let prev = head_hash_on_relay(relay, community, &channel_id.0).await;
4389 let meta = control::ChannelMetadata { name: name.into(), private, deleted: deleted.then_some(true), ..Default::default() };
4390 let content = serde_json::to_string(&meta).unwrap();
4391 let rumor = control::build_edition_rumor(signer.public_key(), vsk::CHANNEL_METADATA, &channel_id.0, version, prev.as_ref(), &content, 1_000, None);
4392 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
4393 relay.publish(&wrap, &community.relays).await.unwrap();
4394 }
4395
4396 async fn publish_community_meta(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, name: &str, version: u64) {
4399 publish_community_meta_at(relay, community, signer, name, version, 1_000).await;
4400 }
4401
4402 async fn publish_community_meta_at(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, name: &str, version: u64, at_secs: u64) {
4405 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
4406 let prev = head_hash_on_relay(relay, community, &community.id().0).await;
4407 let meta = control::CommunityMetadata { name: name.into(), ..Default::default() };
4408 let content = serde_json::to_string(&meta).unwrap();
4409 let rumor = control::build_edition_rumor(signer.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, version, prev.as_ref(), &content, at_secs, None);
4410 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(at_secs)).unwrap();
4411 relay.publish(&wrap, &community.relays).await.unwrap();
4412 }
4413
4414 #[test]
4415 fn metadata_apply_captures_undriven_fields_for_republish() {
4416 let owner = Keys::generate();
4417 let g = control::genesis(&owner, control::CommunityMetadata { name: "A".into(), ..Default::default() }, 1_000).unwrap();
4418 let mut held = CommunityV2::from_genesis(&g, "A", None, vec!["wss://r".into()], 0);
4419 let general = held.channels[0].id;
4420
4421 let mut custom = serde_json::Map::new();
4423 custom.insert("accent".into(), serde_json::Value::from("#89f0b6"));
4424 let mut extra = serde_json::Map::new();
4425 extra.insert("vnd_flag".into(), serde_json::Value::Bool(true));
4426 let meta = control::CommunityMetadata { name: "A".into(), custom: Some(custom.clone()), extra: extra.clone(), ..Default::default() };
4427 assert!(apply_community_metadata(&mut held, meta), "gaining custom/extra is a change");
4428 assert_eq!(held.meta_custom, Some(custom.clone()));
4429 assert_eq!(held.meta_extra, extra);
4430 assert_eq!(held.metadata().custom, Some(custom));
4432 assert_eq!(held.metadata().extra, held.meta_extra);
4433
4434 let mut ch_custom = serde_json::Map::new();
4436 ch_custom.insert("slowmode".into(), serde_json::Value::from(30));
4437 let ch_meta = control::ChannelMetadata {
4438 name: "general".into(),
4439 private: false,
4440 voice: Some(true),
4441 deleted: None,
4442 custom: Some(ch_custom.clone()),
4443 extra: Default::default(),
4444 };
4445 assert!(apply_channel_metadata(&mut held, general, ch_meta), "gaining voice/custom is a change");
4446 let ch = held.channel(&general).unwrap();
4447 assert_eq!(ch.voice, Some(true));
4448 assert_eq!(ch.meta_custom, Some(ch_custom.clone()));
4449 let rename = { let mut d = ch.metadata(); d.name = "lounge".into(); d };
4450 assert_eq!(rename.voice, Some(true), "our rename edition carries the foreign voice flag");
4451 assert_eq!(rename.custom, Some(ch_custom));
4452 }
4453
4454 #[test]
4455 fn community_metadata_apply_sets_and_clears_images() {
4456 let owner = Keys::generate();
4457 let g = control::genesis(&owner, control::CommunityMetadata { name: "A".into(), ..Default::default() }, 1_000).unwrap();
4458 let mut held = CommunityV2::from_genesis(&g, "A", None, vec!["wss://r".into()], 0);
4459
4460 let icon = control::ImageRef {
4461 url: "https://blossom.example/i".into(),
4462 key: "k".into(),
4463 nonce: "n".into(),
4464 hash: "h".into(),
4465 extra: Default::default(),
4466 };
4467 let with_icon = control::CommunityMetadata { name: "A".into(), icon: Some(icon.clone()), ..Default::default() };
4468 assert!(apply_community_metadata(&mut held, with_icon), "gaining an icon is a change");
4469 assert_eq!(held.icon.as_ref(), Some(&icon));
4470
4471 let without = control::CommunityMetadata { name: "A".into(), ..Default::default() };
4473 assert!(apply_community_metadata(&mut held, without), "losing the icon is a change");
4474 assert_eq!(held.icon, None);
4475 }
4476
4477 async fn publish_role(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, role: &Role, version: u64) {
4479 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
4480 let role_id = crate::simd::hex::hex_to_bytes_32_checked(&role.role_id).unwrap();
4481 let prev = head_hash_on_relay(relay, community, &role_id).await;
4482 let content = crate::community::v2::roles::role_content_json(role).unwrap();
4483 let rumor = control::build_edition_rumor(signer.public_key(), vsk::ROLE, &role_id, version, prev.as_ref(), &content, 1_000, None);
4484 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
4485 relay.publish(&wrap, &community.relays).await.unwrap();
4486 }
4487
4488 async fn publish_grant(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, member: &PublicKey, role_ids: Vec<String>, version: u64) {
4490 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
4491 let eid = crate::community::v2::derive::grant_locator(community.id(), &member.to_bytes());
4492 let prev = head_hash_on_relay(relay, community, &eid).await;
4493 let grant = MemberGrant { member: member.to_hex(), role_ids };
4494 let content = crate::community::v2::roles::grant_content_json(&grant).unwrap();
4495 let rumor = control::build_edition_rumor(signer.public_key(), vsk::GRANT, &eid, version, prev.as_ref(), &content, 1_000, None);
4496 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
4497 relay.publish(&wrap, &community.relays).await.unwrap();
4498 }
4499
4500 async fn publish_banlist(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, banned: &[String], version: u64) {
4502 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
4503 let eid = crate::community::v2::derive::banlist_locator(community.id());
4504 let prev = head_hash_on_relay(relay, community, &eid).await;
4505 let content = crate::community::v2::roles::banlist_content_json(banned).unwrap();
4506 let rumor = control::build_edition_rumor(signer.public_key(), vsk::BANLIST, &eid, version, prev.as_ref(), &content, 1_000, None);
4507 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
4508 relay.publish(&wrap, &community.relays).await.unwrap();
4509 }
4510
4511 fn admin_role(role_id: &str, perms: u64) -> Role {
4512 Role { role_id: role_id.into(), name: "Admin".into(), position: 1, permissions: Permissions(perms), scope: RoleScope::Server, color: 0 }
4513 }
4514
4515 #[tokio::test]
4521 async fn a_non_owner_cannot_suppress_the_admin_role_by_forging_a_higher_version() {
4522 let (bed, owner, attacker) = TestBed::new();
4523 bed.swap_to(&owner);
4524 let community = create_community(&bed.relay, "AttackA", bed.relays.clone(), None).await.unwrap();
4525 let victim = Keys::generate().public_key();
4526 grant_admin(&bed.relay, &community, &victim).await.unwrap();
4527
4528 let admin_rid = fetch_authority(&bed.relay, &community)
4530 .await
4531 .roles
4532 .roles
4533 .iter()
4534 .find(|r| r.permissions.contains(Permissions::ADMIN_ALL))
4535 .unwrap()
4536 .role_id
4537 .clone();
4538 publish_role(
4540 &bed.relay,
4541 &community,
4542 &attacker.keys,
4543 &Role { role_id: admin_rid.clone(), name: "pwned".into(), position: 1, permissions: Permissions(0), scope: RoleScope::Server, color: 0 },
4544 2,
4545 )
4546 .await;
4547
4548 let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
4549 assert!(authority.roles.is_admin(&victim.to_hex()), "the forged strip is DROPPED; the owner's admin role survives beneath it");
4550 assert!(
4551 authority.heads.iter().any(|h| h.entity_hex == admin_rid && h.version == 1),
4552 "the floor advances only to the AUTHORIZED head (owner v1)"
4553 );
4554 assert!(!authority.heads.iter().any(|h| h.version == 2), "the forged v2 never poisons the floor");
4555 }
4556
4557 #[tokio::test]
4558 async fn a_non_owner_cannot_strip_a_members_grant_by_forging_a_higher_version() {
4559 let (bed, owner, attacker) = TestBed::new();
4560 bed.swap_to(&owner);
4561 let community = create_community(&bed.relay, "AttackC", bed.relays.clone(), None).await.unwrap();
4562 let victim = Keys::generate();
4563 grant_admin(&bed.relay, &community, &victim.public_key()).await.unwrap();
4564
4565 publish_grant(&bed.relay, &community, &attacker.keys, &victim.public_key(), vec![], 9).await;
4567
4568 let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
4569 assert!(
4570 authority.roles.is_admin(&victim.public_key().to_hex()),
4571 "the forged strip is dropped; the owner's grant survives and the victim keeps admin"
4572 );
4573 }
4574
4575 #[tokio::test]
4576 async fn forged_low_id_roles_by_a_non_owner_never_enter_the_authorized_roster() {
4577 let (bed, owner, attacker) = TestBed::new();
4578 bed.swap_to(&owner);
4579 let community = create_community(&bed.relay, "AttackB", bed.relays.clone(), None).await.unwrap();
4580 let victim = Keys::generate().public_key();
4581 grant_admin(&bed.relay, &community, &victim).await.unwrap();
4582
4583 for i in 0u8..6 {
4586 let rid = crate::simd::hex::bytes_to_hex_32(&[i; 32]);
4587 publish_role(&bed.relay, &community, &attacker.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
4588 }
4589
4590 let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
4591 assert!(authority.roles.is_admin(&victim.to_hex()), "the legit admin survives the forged flood");
4592 assert_eq!(authority.roles.roles.len(), 1, "only the owner's admin role is authorized; every forgery is dropped");
4593 }
4594
4595 fn authority_fingerprint(a: &AuthoritySet) -> String {
4598 let mut roles = a.roles.roles.clone();
4599 roles.sort_by(|x, y| x.role_id.cmp(&y.role_id));
4600 let mut grants = a.roles.grants.clone();
4601 for g in &mut grants {
4602 g.role_ids.sort();
4603 }
4604 grants.sort_by(|x, y| x.member.cmp(&y.member));
4605 let banned: Vec<&String> = a.banned.iter().collect();
4606 serde_json::json!({ "roles": roles, "grants": grants, "banned": banned }).to_string()
4607 }
4608
4609 #[tokio::test]
4610 async fn the_v2_authority_fold_is_order_independent() {
4611 let (bed, owner, _a) = TestBed::new();
4617 bed.swap_to(&owner);
4618 let community = create_community(&bed.relay, "Determinism", bed.relays.clone(), None).await.unwrap();
4619
4620 let admin1 = Keys::generate().public_key();
4623 let admin2 = Keys::generate().public_key();
4624 grant_admin(&bed.relay, &community, &admin1).await.unwrap();
4625 grant_admin(&bed.relay, &community, &admin2).await.unwrap();
4626 let mod_rid = "5c".repeat(32);
4627 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&mod_rid, Permissions::KICK | Permissions::MANAGE_MESSAGES), 1).await;
4628 let member = Keys::generate().public_key();
4629 publish_grant(&bed.relay, &community, &owner.keys, &member, vec![mod_rid.clone()], 1).await;
4630 let banned_member = Keys::generate().public_key();
4631 publish_grant(&bed.relay, &community, &owner.keys, &banned_member, vec![mod_rid], 1).await;
4632 set_banlist(&bed.relay, &community, &[banned_member.to_hex()]).await.unwrap();
4633 let meta = control::CommunityMetadata { name: "Renamed".into(), relays: community.relays.clone(), ..Default::default() };
4634 edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
4635 create_public_channel(&bed.relay, &community, "extra").await.unwrap();
4636
4637 let editions = fetch_control(&bed.relay, &community).await;
4638 let floors = load_floors(&community);
4639 assert!(editions.len() >= 6, "a rich plane was built ({} editions)", editions.len());
4640
4641 let baseline = authority_fingerprint(&fold_authority(&community, &editions, &floors));
4642
4643 let mut orders: Vec<Vec<ParsedEdition>> = Vec::new();
4646 let mut rev = editions.clone();
4647 rev.reverse();
4648 orders.push(rev);
4649 for shift in [1usize, 3, 5, 7] {
4650 let n = editions.len();
4651 orders.push((0..n).map(|i| editions[(i + shift) % n].clone()).collect());
4652 }
4653 let mut zip = Vec::with_capacity(editions.len());
4655 let (mut lo, mut hi) = (0isize, editions.len() as isize - 1);
4656 while lo <= hi {
4657 zip.push(editions[lo as usize].clone());
4658 if lo != hi {
4659 zip.push(editions[hi as usize].clone());
4660 }
4661 lo += 1;
4662 hi -= 1;
4663 }
4664 orders.push(zip);
4665
4666 for (i, order) in orders.iter().enumerate() {
4667 let got = authority_fingerprint(&fold_authority(&community, order, &floors));
4668 assert_eq!(got, baseline, "arrival order #{i} must resolve the identical authority (consensus)");
4669 }
4670 assert!(baseline.contains(&admin1.to_hex()) || baseline.contains(&member.to_hex()), "grants are present in the fingerprint");
4673 assert!(baseline.contains(&banned_member.to_hex()), "the banlist entry is in the fingerprint");
4674 }
4675
4676 struct FetchErrors(MemoryRelay);
4678 #[async_trait::async_trait]
4679 impl crate::community::transport::Transport for FetchErrors {
4680 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
4681 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
4682 self.0.publish(e, r).await
4683 }
4684 async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
4685 Err("relay down".to_string())
4686 }
4687 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
4688 self.0.publish_durable(e, r).await
4689 }
4690 }
4691
4692 #[tokio::test]
4693 async fn fetch_authority_retains_the_persisted_banlist_on_a_transport_error() {
4694 let (bed, owner, victim) = TestBed::new();
4695 bed.swap_to(&owner);
4696 let community = create_community(&bed.relay, "BanRetain", bed.relays.clone(), None).await.unwrap();
4697 let victim_hex = victim.keys.public_key().to_hex();
4698 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4700 crate::db::community::set_community_banlist(&cid_hex, &[victim_hex.clone()], 1).unwrap();
4701
4702 let down = FetchErrors(MemoryRelay::new());
4705 let view = fetch_authority(&down, &community).await;
4706 assert!(view.banned.contains(&victim_hex), "a transport error retains the persisted banlist");
4707 }
4708
4709 #[tokio::test]
4710 async fn follow_control_retains_the_roster_when_a_floored_role_ages_out() {
4711 let (bed, owner, _m) = TestBed::new();
4712 bed.swap_to(&owner);
4713 let community = create_community(&bed.relay, "Complete", bed.relays.clone(), None).await.unwrap();
4714 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4715 let (a, b) = (Keys::generate().public_key(), Keys::generate().public_key());
4716 let rid = crate::simd::hex::bytes_to_hex_32(&[0x7c; 32]);
4717
4718 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
4720 publish_grant(&bed.relay, &community, &owner.keys, &a, vec![rid.clone()], 1).await;
4721 publish_grant(&bed.relay, &community, &owner.keys, &b, vec![rid.clone()], 1).await;
4722 let session = crate::state::SessionGuard::capture();
4723 follow_control(&bed.relay, &community, &session).await.unwrap();
4724 assert!(crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&a.to_hex()), "seeded");
4725
4726 let relay2 = MemoryRelay::new();
4730 publish_grant(&relay2, &community, &owner.keys, &a, vec![rid.clone()], 1).await;
4731 follow_control(&relay2, &community, &session).await.unwrap();
4732 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
4733 assert!(roster.is_admin(&a.to_hex()) && roster.is_admin(&b.to_hex()), "a floored-but-unfetched role retains the stored roster");
4734 }
4735
4736 #[tokio::test]
4737 async fn an_authorized_admin_edits_metadata_but_a_demoted_one_cannot() {
4738 let (_tmp, _guard, owner) = init_test_db();
4742 let relay = MemoryRelay::new();
4743 let community = create_community(&relay, "Base", vec!["wss://r".into()], None).await.unwrap();
4744 let admin = Keys::generate();
4745 let rid = "a1".repeat(32);
4746 publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
4747 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
4748 publish_community_meta(&relay, &community, &admin, "Admin Rename", 2).await;
4749
4750 let session = SessionGuard::capture();
4751 let updated = follow_control(&relay, &community, &session).await.unwrap().expect("admin edit authorized");
4752 assert_eq!(updated.name, "Admin Rename", "an admin with MANAGE_METADATA renames");
4753
4754 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![], 2).await; publish_community_meta(&relay, &community, &admin, "Demoted Rename", 3).await;
4756 let _ = follow_control(&relay, &community, &session).await.unwrap();
4757 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4758 assert_eq!(held.name, "Admin Rename", "a demoted admin's edit is dropped; the name holds");
4759 }
4760
4761 #[tokio::test]
4762 async fn a_roleless_member_cannot_edit_metadata() {
4763 let (_tmp, _guard, _owner) = init_test_db();
4764 let relay = MemoryRelay::new();
4765 let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
4766 let stranger = Keys::generate();
4767 publish_community_meta(&relay, &community, &stranger, "Hijacked", 2).await;
4768 let session = SessionGuard::capture();
4769 assert!(
4770 follow_control(&relay, &community, &session).await.unwrap().is_none(),
4771 "a roleless member's metadata edit never folds"
4772 );
4773 }
4774
4775 #[tokio::test]
4776 async fn a_self_signed_grant_is_not_authority() {
4777 let (_tmp, _guard, _owner) = init_test_db();
4781 let relay = MemoryRelay::new();
4782 let community = create_community(&relay, "NoSelfPromo", vec!["wss://r".into()], None).await.unwrap();
4783 let rogue = Keys::generate();
4784 let rid = "b2".repeat(32);
4785 publish_role(&relay, &community, &rogue, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
4786 publish_grant(&relay, &community, &rogue, &rogue.public_key(), vec![rid.clone()], 1).await;
4787 publish_community_meta(&relay, &community, &rogue, "Seized", 2).await;
4788 let session = SessionGuard::capture();
4789 assert!(
4790 follow_control(&relay, &community, &session).await.unwrap().is_none(),
4791 "a self-signed grant confers no authority"
4792 );
4793 }
4794
4795 #[tokio::test]
4796 async fn the_banlist_is_enforced_only_from_a_ban_holder() {
4797 let (_tmp, _guard, owner) = init_test_db();
4798 let relay = MemoryRelay::new();
4799 let community = create_community(&relay, "Bans", vec!["wss://r".into()], None).await.unwrap();
4800 let target = "cc".repeat(32);
4801
4802 let rogue = Keys::generate();
4804 publish_banlist(&relay, &community, &rogue, &[target.clone()], 1).await;
4805 let floors = load_floors(&community);
4806 let editions = fetch_control(&relay, &community).await;
4807 let authority = fold_authority(&community, &editions, &floors);
4808 assert!(authority.banned.is_empty(), "a non-owner (no BAN) banlist is not enforced");
4809
4810 publish_banlist(&relay, &community, &owner, &[target.clone()], 2).await;
4812 let editions = fetch_control(&relay, &community).await;
4813 let authority = fold_authority(&community, &editions, &floors);
4814 assert!(authority.banned.contains(&target), "the owner's banlist is enforced");
4815 }
4816
4817 #[tokio::test]
4818 async fn a_banned_admin_loses_all_authority() {
4819 let (_tmp, _guard, owner) = init_test_db();
4822 let relay = MemoryRelay::new();
4823 let community = create_community(&relay, "BanAuth", vec!["wss://r".into()], None).await.unwrap();
4824 let admin = Keys::generate();
4825 let rid = "e5".repeat(32);
4826 publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
4827 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
4828 publish_banlist(&relay, &community, &owner, &[admin.public_key().to_hex()], 1).await; publish_community_meta(&relay, &community, &admin, "Banned Rename", 2).await;
4830
4831 let session = SessionGuard::capture();
4832 assert!(
4833 follow_control(&relay, &community, &session).await.unwrap().is_none(),
4834 "a banned admin's edit is dropped even with an unstripped grant"
4835 );
4836 let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
4837 assert!(authority.banned.contains(&admin.public_key().to_hex()));
4838 assert!(
4839 !authority.roles.is_authorized(&admin.public_key().to_hex(), Some(&owner.public_key().to_hex()), Permissions::MANAGE_METADATA),
4840 "a banned admin holds no bit"
4841 );
4842 }
4843
4844 #[tokio::test]
4845 async fn a_ban_holder_cannot_ban_a_superior_or_the_owner() {
4846 let (_tmp, _guard, owner) = init_test_db();
4850 let relay = MemoryRelay::new();
4851 let community = create_community(&relay, "Ranks", vec!["wss://r".into()], None).await.unwrap();
4852 let admin = Keys::generate();
4853 let moder = Keys::generate();
4854 let stranger = Keys::generate();
4855 let (admin_rid, mod_rid) = ("a1".repeat(32), "b2".repeat(32));
4856 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;
4857 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;
4858 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![admin_rid], 1).await;
4859 publish_grant(&relay, &community, &owner, &moder.public_key(), vec![mod_rid], 1).await;
4860 publish_banlist(&relay, &community, &moder, &[admin.public_key().to_hex(), owner.public_key().to_hex(), stranger.public_key().to_hex()], 1).await;
4861
4862 let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
4863 assert!(!authority.banned.contains(&admin.public_key().to_hex()), "a mod cannot ban a superior admin");
4864 assert!(!authority.banned.contains(&owner.public_key().to_hex()), "nobody can ban the owner");
4865 assert!(authority.banned.contains(&stranger.public_key().to_hex()), "the mod CAN ban a lower-ranked member");
4866 }
4867
4868 #[tokio::test]
4869 async fn an_unauthorized_higher_banlist_cannot_unban() {
4870 let (_tmp, _guard, owner) = init_test_db();
4874 let relay = MemoryRelay::new();
4875 let community = create_community(&relay, "NoUnban", vec!["wss://r".into()], None).await.unwrap();
4876 let target = "cc".repeat(32);
4877 publish_banlist(&relay, &community, &owner, &[target.clone()], 1).await;
4878 let session = SessionGuard::capture();
4879 follow_control(&relay, &community, &session).await.unwrap(); let rogue = Keys::generate();
4882 publish_banlist(&relay, &community, &rogue, &[], 2).await; let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
4884 assert!(authority.banned.contains(&target), "an unauthorized higher banlist cannot un-ban");
4885 }
4886
4887 #[tokio::test]
4888 async fn the_community_list_syncs_a_membership_to_a_fresh_device() {
4889 let (_tmp, _guard, _owner) = init_test_db();
4892 let relay = MemoryRelay::new();
4893 let relays = vec!["wss://r".to_string()];
4894 let community = create_community(&relay, "Synced", relays.clone(), None).await.unwrap();
4895 crate::db::community::delete_community(&crate::simd::hex::bytes_to_hex_32(&community.id().0)).unwrap();
4896 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none());
4897
4898 let rehydrated = sync_community_list(&relay, &relays).await.unwrap();
4899 assert_eq!(rehydrated.len(), 1, "the left-behind membership rehydrates");
4900 assert_eq!(rehydrated[0].id().0, community.id().0);
4901 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_some(), "and is now held locally");
4902 }
4903
4904 #[tokio::test]
4905 async fn a_leave_tombstones_the_membership_so_sync_does_not_rejoin() {
4906 let (_tmp, _guard, _owner) = init_test_db();
4907 let relay = MemoryRelay::new();
4908 let relays = vec!["wss://r".to_string()];
4909 let community = create_community(&relay, "Left", relays.clone(), None).await.unwrap();
4910 leave_community(&relay, &community).await.unwrap(); let rehydrated = sync_community_list(&relay, &relays).await.unwrap();
4913 assert!(rehydrated.is_empty(), "a tombstoned membership is not rejoined on sync");
4914 }
4915
4916 #[tokio::test]
4917 async fn accepting_the_same_bundle_twice_is_idempotent() {
4918 let (bed, owner, member) = TestBed::new();
4922 bed.swap_to(&owner);
4923 let community = create_community(&bed.relay, "Idem", bed.relays.clone(), None).await.unwrap();
4924 create_public_channel(&bed.relay, &community, "extra").await.unwrap();
4925 let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4926 let bundle = serde_json::to_string(&bundle_of(&community, Some(owner.keys.public_key()), None, None)).unwrap();
4927
4928 bed.swap_to(&member);
4929 let first = accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
4930 let channels_after_first = first.channels.len();
4931 let root_after_first = first.community_root;
4932
4933 let second = accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
4935 assert_eq!(second.id().0, first.id().0, "same community_id");
4936 assert_eq!(second.channels.len(), channels_after_first, "no duplicate channels on re-accept");
4937 assert_eq!(second.community_root, root_after_first, "root unchanged");
4938
4939 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4941 assert_eq!(reloaded.channels.len(), channels_after_first, "the DB holds one clean channel set");
4942 assert_eq!(crate::db::community::list_community_ids().unwrap().iter().filter(|id| id.0 == community.id().0).count(), 1, "exactly one community row");
4943 }
4944
4945 #[tokio::test]
4946 async fn a_severed_member_can_be_unbanned_and_re_admitted() {
4947 let (bed, owner, member) = TestBed::new();
4952 bed.swap_to(&owner);
4953 let mut community = create_community(&bed.relay, "Redeemable", bed.relays.clone(), None).await.unwrap();
4954 let general = community.channels[0].id;
4955 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
4956 send_message(&bed.relay, &community, &general, "owner: welcome").await.unwrap();
4957
4958 bed.swap_to(&member);
4959 let invite = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
4960 let joined = accept_direct_invite(&bed.relay, &invite).await.unwrap();
4961 assert!(texts_in(&bed.relay, &joined, &general).await.contains(&"owner: welcome".to_string()));
4962
4963 bed.swap_to(&owner);
4965 set_banlist(&bed.relay, &community, &[member.keys.public_key().to_hex()]).await.unwrap();
4966 grant_roles(&bed.relay, &community, &member.keys.public_key(), vec![]).await.unwrap();
4967 community = refound_community(&bed.relay, &community, &[member.keys.public_key()]).await.unwrap();
4968 assert_eq!(community.root_epoch, Epoch(1));
4969 send_message(&bed.relay, &community, &general, "owner: after the ban").await.unwrap();
4970
4971 bed.swap_to(&member);
4973 let session = SessionGuard::capture();
4974 assert!(follow_rekeys(&bed.relay, &joined, &session).await.unwrap().self_removed, "the member is cryptographically severed");
4975
4976 bed.swap_to(&owner);
4980 set_banlist(&bed.relay, &community, &[]).await.unwrap();
4981 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4982 assert_eq!(community.root_epoch, Epoch(1), "the owner's bundle carries epoch 1");
4983 let fresh_bundle = serde_json::to_string(&bundle_of(&community, Some(owner.keys.public_key()), None, None)).unwrap();
4984
4985 bed.swap_to(&member);
4987 let rejoined = accept_parked_invite(&bed.relay, &fresh_bundle, None).await.unwrap();
4988 assert_eq!(rejoined.root_epoch, Epoch(1), "rejoined at the current epoch");
4989 assert_eq!(rejoined.community_root, community.community_root, "holds the NEW root");
4990 let seen = texts_in(&bed.relay, &rejoined, &general).await;
4991 assert!(seen.contains(&"owner: after the ban".to_string()), "reads post-ban history with the new root");
4992 send_message(&bed.relay, &rejoined, &general, "member: i am back").await.unwrap();
4993
4994 bed.swap_to(&owner);
4995 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4996 assert!(
4997 texts_in(&bed.relay, &community, &general).await.contains(&"member: i am back".to_string()),
4998 "the re-admitted member converses again at the new epoch"
4999 );
5000 let members = memberlist(&bed.relay, &community).await.unwrap();
5002 assert!(members.contains(&member.keys.public_key()), "the re-admitted member is in the list");
5003 }
5004
5005 #[tokio::test]
5006 async fn dissolution_blocks_a_join() {
5007 let (bed, owner, member) = TestBed::new();
5010 bed.swap_to(&owner);
5011 let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
5012 let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
5013 let bundle_json = serde_json::to_string(&bundle).unwrap();
5014 dissolve_community(&bed.relay, &community).await.unwrap();
5015 assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the owner's local hold is sealed");
5016
5017 bed.swap_to(&member);
5018 let err = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap_err();
5019 assert!(err.contains("dissolved"), "a join refuses a dissolved community: {err}");
5020 }
5021
5022 #[tokio::test]
5023 async fn only_the_owner_can_dissolve() {
5024 let (bed, owner, member) = TestBed::new();
5025 bed.swap_to(&owner);
5026 let community = create_community(&bed.relay, "Mine", bed.relays.clone(), None).await.unwrap();
5027 bed.swap_to(&member);
5028 assert!(dissolve_community(&bed.relay, &community).await.is_err(), "only the owner can dissolve");
5029 assert!(!is_dissolved(&bed.relay, &community).await, "and no tombstone was published");
5030 }
5031
5032 #[tokio::test]
5033 async fn a_foreign_tombstone_is_not_death() {
5034 let (_tmp, _guard, _owner) = init_test_db();
5037 let relay = MemoryRelay::new();
5038 let community = create_community(&relay, "Safe", vec!["wss://r".into()], None).await.unwrap();
5039 let rogue = Keys::generate();
5040 let rumor = crate::community::v2::dissolution::dissolved_tombstone_rumor(rogue.public_key(), community.id(), 1_000);
5041 let wrap = crate::community::v2::dissolution::seal_dissolved(&rumor, community.id(), &rogue, Timestamp::from_secs(1_000)).unwrap();
5042 relay.publish(&wrap, &community.relays).await.unwrap();
5043 assert!(!is_dissolved(&relay, &community).await, "a foreign-signed tombstone is not death");
5044 }
5045
5046 #[tokio::test]
5047 async fn a_public_channel_reads_history_across_a_refounding() {
5048 let (_tmp, _guard, _owner) = init_test_db();
5052 let relay = MemoryRelay::new();
5053 let community = create_community(&relay, "History", vec!["wss://r".into()], None).await.unwrap();
5054 let general = community.channels[0].id;
5055 send_message(&relay, &community, &general, "before the refounding").await.unwrap();
5056
5057 let refounded = refound_community(&relay, &community, &[]).await.unwrap();
5058 assert_eq!(refounded.root_epoch, Epoch(1), "the epoch advanced");
5059 send_message(&relay, &refounded, &general, "after the refounding").await.unwrap();
5060
5061 let texts = texts_in(&relay, &refounded, &general).await;
5062 assert!(texts.contains(&"before the refounding".to_string()), "the epoch-0 message is still readable");
5063 assert!(texts.contains(&"after the refounding".to_string()), "the epoch-1 message reads too");
5064 }
5065
5066 #[tokio::test]
5067 async fn refounding_aborts_when_control_state_is_withheld() {
5068 let (_tmp, _guard, owner) = init_test_db();
5072 let relay = MemoryRelay::new();
5073 let community = create_community(&relay, "Withheld", vec!["wss://good".into()], None).await.unwrap();
5074 publish_banlist(&relay, &community, &owner, &["cc".repeat(32)], 1).await;
5075 let session = SessionGuard::capture();
5076 follow_control(&relay, &community, &session).await.unwrap(); let mut moved = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5081 moved.relays = vec!["wss://empty".into()];
5082 crate::db::community::save_community_v2(&moved).unwrap();
5083
5084 let err = refound_community(&relay, &moved, &[]).await.unwrap_err();
5085 assert!(err.contains("was not served"), "a withheld control head aborts the refounding: {err}");
5086 assert_eq!(
5087 crate::db::community::load_community_v2(community.id()).unwrap().unwrap().root_epoch,
5088 Epoch(0),
5089 "the epoch did NOT advance (zero published state)"
5090 );
5091 }
5092
5093 #[tokio::test]
5094 async fn refounding_rolls_the_root_and_severs_a_removed_member() {
5095 let (bed, owner, member) = TestBed::new();
5098 bed.swap_to(&owner);
5099 let community = create_community(&bed.relay, "Refound", bed.relays.clone(), None).await.unwrap();
5100 let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
5101 let bundle_json = serde_json::to_string(&bundle).unwrap();
5102 bed.swap_to(&member);
5103 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
5104
5105 bed.swap_to(&owner);
5106 let refounded = refound_community(&bed.relay, &community, &[member.keys.public_key()]).await.unwrap();
5107 assert_eq!(refounded.root_epoch, Epoch(1), "the epoch advanced");
5108 assert_ne!(refounded.community_root, community.community_root, "the base root rolled");
5109 let session = SessionGuard::capture();
5111 assert_eq!(
5112 crate::db::community::load_community_v2(community.id()).unwrap().unwrap().root_epoch,
5113 Epoch(1),
5114 "the owner committed the new epoch"
5115 );
5116
5117 bed.swap_to(&member);
5119 let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
5120 assert!(follow.self_removed, "the removed member is cut by the re-founding");
5121 }
5122
5123 #[tokio::test]
5124 async fn a_ban_holding_admin_can_re_found_but_not_evict_a_superior() {
5125 let (bed, owner, member) = TestBed::new();
5130 bed.swap_to(&owner);
5131 let community = create_community(&bed.relay, "Guarded", bed.relays.clone(), None).await.unwrap();
5132 let rid = "b0".repeat(32);
5133 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
5134 publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
5135 let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
5136 let bundle_json = serde_json::to_string(&bundle).unwrap();
5137 bed.swap_to(&member);
5138 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
5139 let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
5142 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
5143 assert!(refound_community(&bed.relay, &joined, &[owner.keys.public_key()]).await.is_err(), "a BAN-holder can't re-found to evict the owner");
5145 assert!(refound_community(&bed.relay, &joined, &[]).await.is_ok(), "a BAN-holding admin can re-found");
5147 }
5148
5149 #[tokio::test]
5150 async fn follow_rekeys_adopts_an_authorized_non_owner_base_rotation() {
5151 let (bed, owner, me) = TestBed::new();
5156 let admin = Keys::generate();
5157 bed.swap_to(&owner);
5158 let community = create_community(&bed.relay, "AdminRefound", bed.relays.clone(), None).await.unwrap();
5159 let rid = "b0".repeat(32);
5160 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
5161 publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid], 1).await;
5162
5163 let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
5165 let bundle_json = serde_json::to_string(&bundle).unwrap();
5166 bed.swap_to(&me);
5167 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
5168 let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
5169 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
5170
5171 let new_root = [0xC7; 32];
5174 publish_base_rotation(&bed.relay, &joined, &admin, &[owner.keys.public_key(), me.keys.public_key()], &new_root, &joined.community_root).await;
5175
5176 let updated = follow_rekeys(&bed.relay, &joined, &SessionGuard::capture()).await.unwrap().updated
5177 .expect("an authorized admin's Refounding is adopted");
5178 assert_eq!(updated.root_epoch, Epoch(1), "advanced past the admin's rotation");
5179 assert_eq!(updated.community_root, new_root, "adopted the admin's fresh root");
5180 }
5181
5182 #[tokio::test]
5183 async fn follow_rekeys_refuses_a_refounding_that_excludes_the_owner() {
5184 let (bed, owner, me) = TestBed::new();
5188 let admin = Keys::generate();
5189 bed.swap_to(&owner);
5190 let community = create_community(&bed.relay, "NoCoup", bed.relays.clone(), None).await.unwrap();
5191 let rid = "b0".repeat(32);
5192 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
5193 publish_grant(&bed.relay, &community, &owner.keys, &admin.public_key(), vec![rid], 1).await;
5194
5195 let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
5196 let bundle_json = serde_json::to_string(&bundle).unwrap();
5197 bed.swap_to(&me);
5198 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
5199 let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
5200 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
5201
5202 publish_base_rotation(&bed.relay, &joined, &admin, &[me.keys.public_key()], &[0xEE; 32], &joined.community_root).await;
5204 let follow = follow_rekeys(&bed.relay, &joined, &SessionGuard::capture()).await.unwrap();
5205 assert!(follow.updated.is_none() && !follow.self_removed, "an owner-excluding Refounding is not adopted");
5206 }
5207
5208 #[tokio::test]
5209 async fn follow_rekeys_refuses_a_refounding_that_excludes_a_peer_admin() {
5210 let (bed, owner, me) = TestBed::new();
5214 let admin_a = Keys::generate();
5215 let admin_b = Keys::generate(); bed.swap_to(&owner);
5217 let community = create_community(&bed.relay, "Peers", bed.relays.clone(), None).await.unwrap();
5218 let rid = "b0".repeat(32);
5219 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
5220 publish_grant(&bed.relay, &community, &owner.keys, &admin_a.public_key(), vec![rid.clone()], 1).await;
5222 publish_grant(&bed.relay, &community, &owner.keys, &admin_b.public_key(), vec![rid], 1).await;
5223
5224 let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
5225 let bundle_json = serde_json::to_string(&bundle).unwrap();
5226 bed.swap_to(&me);
5227 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
5228 let _ = follow_control(&bed.relay, &joined, &SessionGuard::capture()).await;
5229 let joined = crate::db::community::load_community_v2(joined.id()).unwrap().unwrap();
5230
5231 publish_base_rotation(&bed.relay, &joined, &admin_a, &[owner.keys.public_key(), me.keys.public_key()], &[0xDD; 32], &joined.community_root).await;
5233
5234 let follow = follow_rekeys(&bed.relay, &joined, &SessionGuard::capture()).await.unwrap();
5235 assert!(follow.updated.is_none() && !follow.self_removed, "excluding an equal-rank peer admin is inadmissible");
5236 }
5237
5238 #[tokio::test]
5239 async fn a_retried_refounding_reuses_the_same_root() {
5240 let (_tmp, _guard, _owner) = init_test_db();
5243 let relay = MemoryRelay::new();
5244 let community = create_community(&relay, "Retry", vec!["wss://r".into()], None).await.unwrap();
5245 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5246 let first = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap();
5247 let second = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap();
5248 assert_eq!(first, second, "a retry reuses the archived root, never double-mints");
5249 }
5250
5251 #[tokio::test]
5252 async fn minting_a_link_makes_the_community_public_and_revoke_makes_it_private() {
5253 let (_tmp, _guard, _owner) = init_test_db();
5256 let relay = MemoryRelay::new();
5257 let community = create_community(&relay, "Invitable", vec!["wss://r".into()], None).await.unwrap();
5258 assert!(!community_is_public(&relay, &community).await, "a fresh community is Private");
5259
5260 let minted = mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
5261 assert!(community_is_public(&relay, &community).await, "a live link makes it Public");
5262 let list = fetch_invite_list(&relay, &community.relays).await.unwrap().expect("the 13303 list was published");
5263 assert_eq!(list.entries.len(), 1, "the minted link is recorded across devices");
5264
5265 let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
5266 revoke_public_link(&relay, &community, &token_hex).await.unwrap();
5267 assert!(!community_is_public(&relay, &community).await, "retiring the last link makes it Private again");
5268 let after = fetch_invite_list(&relay, &community.relays).await.unwrap().unwrap();
5269 assert!(after.entries.is_empty() && after.tombstones.len() == 1, "the link is tombstoned in the invite list");
5270 }
5271
5272 #[tokio::test]
5273 async fn a_registry_from_a_non_create_invite_holder_does_not_make_it_public() {
5274 let (_tmp, _guard, owner) = init_test_db();
5276 let relay = MemoryRelay::new();
5277 let community = create_community(&relay, "Gated", vec!["wss://r".into()], None).await.unwrap();
5278 let rogue = Keys::generate();
5279 let eid = crate::community::v2::derive::invite_links_locator(community.id(), &rogue.public_key().to_bytes());
5281 let content = crate::community::v2::invite::build_registry_content(&[Keys::generate().public_key()]);
5282 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
5283 let rumor = control::build_edition_rumor(rogue.public_key(), vsk::INVITE_LINKS, &eid, 1, None, &content, 1_000, None);
5284 let (wrap, _) = control::seal_control_edition(&rumor, &group, &rogue, Timestamp::from_secs(1_000)).unwrap();
5285 relay.publish(&wrap, &community.relays).await.unwrap();
5286 let _ = owner;
5287 assert!(!community_is_public(&relay, &community).await, "a non-CREATE_INVITE registry is ignored");
5288 }
5289
5290 #[tokio::test]
5291 async fn full_lifecycle_e2e() {
5292 let (bed, owner, member) = TestBed::new();
5298
5299 bed.swap_to(&owner);
5300 let community = create_community(&bed.relay, "Lifecycle", bed.relays.clone(), None).await.unwrap();
5301 let general = community.channels[0].id;
5302 send_message(&bed.relay, &community, &general, "owner: welcome").await.unwrap();
5303
5304 let _minted = mint_public_link(&bed.relay, &community, "https://x", None, None).await.unwrap();
5306 assert!(community_is_public(&bed.relay, &community).await, "a live link makes it Public");
5307
5308 let rid = "aa".repeat(32);
5310 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
5311 publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
5312
5313 let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
5315 let bundle_json = serde_json::to_string(&bundle).unwrap();
5316 bed.swap_to(&member);
5317 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
5318 assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: welcome"]);
5319 publish_community_meta(&bed.relay, &joined, &member.keys, "Lifecycle Renamed", 2).await;
5321
5322 bed.swap_to(&owner);
5324 let session = SessionGuard::capture();
5325 let updated = follow_control(&bed.relay, &community, &session).await.unwrap().expect("the admin edit folds");
5326 assert_eq!(updated.name, "Lifecycle Renamed", "an authorized admin's metadata edit is honored");
5327
5328 set_banlist(&bed.relay, &updated, &[member.keys.public_key().to_hex()]).await.unwrap();
5330 grant_roles(&bed.relay, &updated, &member.keys.public_key(), vec![]).await.unwrap();
5331 let refounded = refound_community(&bed.relay, &updated, &[member.keys.public_key()]).await.unwrap();
5332 assert_eq!(refounded.root_epoch, Epoch(1), "the ban rolled the root");
5333 let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
5335 assert!(post.banned.contains(&member.keys.public_key().to_hex()), "the ban survives the re-founding");
5336 assert!(
5338 texts_in(&bed.relay, &refounded, &general).await.contains(&"owner: welcome".to_string()),
5339 "pre-refounding history stays readable"
5340 );
5341
5342 bed.swap_to(&member);
5344 let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
5345 assert!(follow.self_removed, "the banned member is cryptographically cut");
5346
5347 bed.swap_to(&owner);
5349 dissolve_community(&bed.relay, &refounded).await.unwrap();
5350 assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the community is sealed");
5351 }
5352
5353 #[tokio::test]
5359 async fn a_forged_edition_cannot_suppress_a_role_across_a_refounding() {
5360 let (bed, owner, member) = TestBed::new();
5365 let attacker = Keys::generate();
5366 bed.swap_to(&owner);
5367 let community = create_community(&bed.relay, "NoSuppress", bed.relays.clone(), None).await.unwrap();
5368 let rid = crate::simd::hex::bytes_to_hex_32(&[0xa1; 32]);
5369 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
5370 publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid.clone()], 1).await;
5371 let session = SessionGuard::capture();
5373 follow_control(&bed.relay, &community, &session).await.unwrap();
5374 assert!(fetch_authority(&bed.relay, &community).await.roles.is_admin(&member.keys.public_key().to_hex()), "member is admin pre-attack");
5375
5376 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;
5378
5379 let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
5381 assert_eq!(refounded.root_epoch, Epoch(1), "root rolled");
5382
5383 let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
5385 assert!(post.roles.is_admin(&member.keys.public_key().to_hex()), "the admin role survives the refounding despite the forgery");
5386 }
5387
5388 #[tokio::test]
5389 async fn memberlist_survives_a_refounding_via_the_snapshot() {
5390 let (bed, owner, member) = TestBed::new();
5393 bed.swap_to(&owner);
5394 let community = create_community(&bed.relay, "Snapshot", bed.relays.clone(), None).await.unwrap();
5395
5396 let bundle = serde_json::to_string(&bundle_of(&community, Some(owner.keys.public_key()), None, None)).unwrap();
5398 bed.swap_to(&member);
5399 accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
5400 bed.swap_to(&owner);
5401 assert!(memberlist(&bed.relay, &community).await.unwrap().contains(&member.keys.public_key()), "member present pre-refound");
5402
5403 let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
5405 assert_eq!(refounded.root_epoch, Epoch(1), "the root rolled");
5406
5407 let members = memberlist(&bed.relay, &refounded).await.unwrap();
5409 assert!(members.contains(&member.keys.public_key()), "a silent survivor stays a member after the refounding");
5410 assert!(members.contains(&owner.keys.public_key()), "owner is always a member");
5411 }
5412
5413 #[tokio::test]
5414 async fn e2e_two_accounts_channels_converse_moderate() {
5415 use crate::community::v2::inbound::{apply_chat_to_state, persist_chat};
5416 use nostr_sdk::prelude::ToBech32;
5417 let (bed, a, b) = TestBed::new();
5418 let (a_npub, b_npub) = (a.keys.public_key().to_bech32().unwrap(), b.keys.public_key().to_bech32().unwrap());
5419 let (a_hex, b_hex) = (a.keys.public_key().to_hex(), b.keys.public_key().to_hex());
5420 println!("\n===== Concord v2 deep e2e =====");
5421 println!("[acct] A (owner) = {a_npub}");
5422 println!("[acct] B (member) = {b_npub}");
5423
5424 bed.swap_to(&a);
5426 let mut community = create_community(&bed.relay, "Deep E2E", bed.relays.clone(), None).await.unwrap();
5427 let general = community.channels[0].id;
5428 println!("[create] community {} · #general {}", crate::simd::hex::bytes_to_hex_32(&community.id().0), crate::simd::hex::bytes_to_hex_32(&general.0));
5429
5430 let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
5434 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5435 let priv_ch = community.channel(&priv_id).unwrap();
5436 assert!(priv_ch.private && priv_ch.key.is_some() && priv_ch.epoch == Epoch(1), "born-private: keyed at epoch 1");
5437 println!("[channel] +private #mods {} (native create: key over the rekey plane)", crate::simd::hex::bytes_to_hex_32(&priv_id.0));
5438
5439 let announcements = create_public_channel(&bed.relay, &community, "announcements").await.unwrap();
5441 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5442 let random = create_public_channel(&bed.relay, &community, "random").await.unwrap();
5443 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5444 println!("[channel] +public #announcements {} · #random {}", crate::simd::hex::bytes_to_hex_32(&announcements.0), crate::simd::hex::bytes_to_hex_32(&random.0));
5445 assert_eq!(community.channels.len(), 4, "general + mods + announcements + random");
5446
5447 let m1 = send_message(&bed.relay, &community, &general, "A: welcome to the deep e2e").await.unwrap();
5449 send_message(&bed.relay, &community, &announcements, "A: read the rules").await.unwrap();
5450 send_message(&bed.relay, &community, &priv_id, "A: mods-only channel").await.unwrap();
5451 println!("[msg] A posted in #general / #announcements / #mods");
5452
5453 let admin_rid = crate::simd::hex::bytes_to_hex_32(&[0xa1; 32]);
5455 publish_role(&bed.relay, &community, &a.keys, &admin_role(&admin_rid, Permissions::ADMIN_ALL), 1).await;
5456 publish_grant(&bed.relay, &community, &a.keys, &b.keys.public_key(), vec![admin_rid], 1).await;
5457 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
5458 assert!(community_is_public(&bed.relay, &community).await, "a live link makes it Public");
5459 println!("[invite] granted B @admin · minted link {}", link.url);
5460
5461 let bundle_json = serde_json::to_string(&bundle_of(&community, Some(a.keys.public_key()), None, None)).unwrap();
5462 bed.swap_to(&b);
5463 let mut b_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
5464 println!("[join] B joined; sees {} channels", b_view.channels.len());
5465 assert_eq!(b_view.channels.len(), 4, "B receives all four channels (incl. the private one's key) in the bundle");
5466 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");
5467 assert!(texts_in(&bed.relay, &b_view, &general).await.contains(&"A: welcome to the deep e2e".to_string()), "B reads A's #general history");
5468 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");
5469 let session_b = SessionGuard::capture();
5472 if let Some(fresh) = follow_control(&bed.relay, &b_view, &session_b).await.unwrap() {
5473 b_view = fresh;
5474 }
5475 println!("[follow] B folded control (roster persisted: B is @admin)");
5476
5477 send_message(&bed.relay, &b_view, &general, "B: thanks, glad to be here").await.unwrap();
5479 send_message(&bed.relay, &b_view, &priv_id, "B: mods checking in").await.unwrap();
5480 println!("[msg] B replied in #general + #mods");
5481 let my_pk = b.keys.public_key();
5484 let gh = crate::simd::hex::bytes_to_hex_32(&general.0);
5485 for f in fetch_channel(&bed.relay, &b_view, &general, 100).await.unwrap() {
5486 let outcome = { let mut st = crate::state::STATE.lock().await; apply_chat_to_state(&mut st, &f.event, &gh, &my_pk) };
5487 if let Some(o) = outcome { persist_chat(&gh, &o).await; }
5488 }
5489 assert!(crate::db::events::event_exists(&m1).unwrap(), "A's message persisted into B's shared store (get_messages backfill)");
5490 println!("[persist] #general history persisted into the shared events store");
5491
5492 send_reaction(&bed.relay, &b_view, &general, &m1, &a_hex, super::super::kind::MESSAGE, "🔥", None).await.unwrap();
5494 bed.swap_to(&a);
5495 let m_edit = send_message(&bed.relay, &community, &general, "A: this will be edited").await.unwrap();
5496 send_edit(&bed.relay, &community, &general, &m_edit, "A: edited!").await.unwrap();
5497 let m_del = send_message(&bed.relay, &community, &general, "A: this will be deleted").await.unwrap();
5498 send_delete(&bed.relay, &community, &general, &m_del, super::super::kind::MESSAGE).await.unwrap();
5499 println!("[ops] reaction + edit + delete round-tripped");
5500
5501 bed.swap_to(&b);
5503 let bugs = create_public_channel(&bed.relay, &b_view, "bug-reports").await.unwrap();
5504 println!("[channel] B(admin) +public #bug-reports {}", crate::simd::hex::bytes_to_hex_32(&bugs.0));
5505 bed.swap_to(&a);
5506 let session = SessionGuard::capture();
5507 if let Some(updated) = follow_control(&bed.relay, &community, &session).await.unwrap() {
5508 community = updated;
5509 }
5510 assert!(community.channels.iter().any(|c| c.id.0 == bugs.0), "A folds in B's authorized new channel");
5511 println!("[follow] A folded in B's #bug-reports (now {} channels)", community.channels.len());
5512
5513 let vault = create_private_channel(&bed.relay, &community, "vault").await.unwrap();
5517 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5518 send_message(&bed.relay, &community, &vault, "A: vault is open").await.unwrap();
5519 println!("[channel] +private #vault {} (B is a live member — delivery via rekey plane)", crate::simd::hex::bytes_to_hex_32(&vault.0));
5520 bed.swap_to(&b);
5521 let session_b2 = SessionGuard::capture();
5522 if let Some(fresh) = follow_control(&bed.relay, &b_view, &session_b2).await.unwrap() {
5523 b_view = fresh;
5524 }
5525 let ch = b_view.channel(&vault).expect("B recorded the announced private channel");
5526 assert!(ch.private && ch.key.is_none() && ch.epoch == Epoch(0), "B's record is keyless at cursor 0");
5527 let rf = follow_rekeys(&bed.relay, &b_view, &session_b2).await.unwrap();
5528 b_view = rf.updated.expect("the rekey walk adopts the creation delivery");
5529 let ch = b_view.channel(&vault).expect("still recorded");
5530 assert!(ch.key.is_some() && ch.epoch == Epoch(1), "B adopted the epoch-1 key from the creation crate");
5531 assert!(
5532 texts_in(&bed.relay, &b_view, &vault).await.contains(&"A: vault is open".to_string()),
5533 "B reads the private history with the ADOPTED key"
5534 );
5535 send_message(&bed.relay, &b_view, &vault, "B: in the vault").await.unwrap();
5536 bed.swap_to(&a);
5537 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5538 assert!(
5539 texts_in(&bed.relay, &community, &vault).await.contains(&"B: in the vault".to_string()),
5540 "A reads B's reply on the natively-created private channel"
5541 );
5542 println!("[private] B adopted #vault via rekey plane; two-way private conversation verified");
5543
5544 let members = memberlist(&bed.relay, &community).await.unwrap();
5546 let member_hexes: std::collections::BTreeSet<String> = members.iter().map(|m| m.to_hex()).collect();
5547 assert!(member_hexes.contains(&a_hex) && member_hexes.contains(&b_hex), "A + B both in the memberlist");
5548 println!("[members] {} members: A + B present", members.len());
5549
5550 set_banlist(&bed.relay, &community, &[b_hex.clone()]).await.unwrap();
5552 grant_roles(&bed.relay, &community, &b.keys.public_key(), vec![]).await.unwrap();
5553 let refounded = refound_community(&bed.relay, &community, &[b.keys.public_key()]).await.unwrap();
5554 assert_eq!(refounded.root_epoch, Epoch(1), "the ban rolled the root");
5555 let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
5556 assert!(post.banned.contains(&b_hex), "the ban survives the refounding");
5557 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");
5558 assert!(
5559 texts_in(&bed.relay, &refounded, &priv_id).await.iter().any(|t| t == "A: mods-only channel"),
5560 "PRIVATE history reads across the channel's own rotation (per-channel multi-epoch archive)"
5561 );
5562 println!("[ban] B banned; root rolled to epoch 1; ban survives; pre-ban history intact (public + private)");
5563 bed.swap_to(&b);
5565 let session_b3 = SessionGuard::capture();
5566 assert!(follow_rekeys(&bed.relay, &b_view, &session_b3).await.unwrap().self_removed, "B is cryptographically cut by the ban-refound");
5567 println!("[ban] B's rekey-follow: self_removed = true (severed)");
5568
5569 bed.swap_to(&a);
5571 set_banlist(&bed.relay, &refounded, &[]).await.unwrap();
5572 let after_unban = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
5573 assert!(!after_unban.banned.contains(&b_hex), "the unban clears B from the banlist");
5574 println!("[unban] B removed from the banlist (re-invitable)");
5575
5576 dissolve_community(&bed.relay, &refounded).await.unwrap();
5578 assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the community is sealed");
5579 println!("[dissolve] community sealed (read-only)\n===== e2e PASS =====\n");
5580 }
5581
5582 #[tokio::test]
5588 #[ignore]
5589 async fn live_e2e_two_accounts() {
5590 use crate::community::transport::LiveTransport;
5591 use nostr_sdk::prelude::{ClientBuilder, RelayOptions, ToBech32};
5592
5593 let relay = std::env::var("VECTOR_E2E_RELAY").unwrap_or_else(|_| "wss://jskitty.com/nostr".to_string());
5594 let relays = vec![relay.clone()];
5595 let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
5596 crate::db::close_database();
5597 crate::db::clear_id_caches();
5598 let tmp = tempfile::tempdir().unwrap();
5599 crate::db::set_app_data_dir(tmp.path().to_path_buf());
5600
5601 let a = std::env::var("VECTOR_E2E_NSEC_A").ok().and_then(|n| Keys::parse(&n).ok()).unwrap_or_else(Keys::generate);
5603 let b = std::env::var("VECTOR_E2E_NSEC_B").ok().and_then(|n| Keys::parse(&n).ok()).unwrap_or_else(Keys::generate);
5604
5605 let log = |line: String| {
5606 println!("{line}");
5607 if let Ok(p) = std::env::var("VECTOR_E2E_LOG") {
5608 use std::io::Write;
5609 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&p) {
5610 let _ = writeln!(f, "{line}");
5611 }
5612 }
5613 };
5614 log(format!("===== LIVE Concord v2 e2e on {relay} ====="));
5615 log(format!("VECTOR_E2E_NSEC_A={} ({})", a.secret_key().to_bech32().unwrap(), a.public_key().to_bech32().unwrap()));
5616 log(format!("VECTOR_E2E_NSEC_B={} ({})", b.secret_key().to_bech32().unwrap(), b.public_key().to_bech32().unwrap()));
5617
5618 for k in [&a, &b] {
5619 let npub = k.public_key().to_bech32().unwrap();
5620 std::fs::create_dir_all(tmp.path().join(&npub)).unwrap();
5621 crate::db::set_current_account(npub.clone()).unwrap();
5622 crate::db::init_database(&npub).unwrap();
5623 }
5624 let client = ClientBuilder::new().signer(a.clone()).build();
5627 client.pool().add_relay(relay.as_str(), RelayOptions::default()).await.ok();
5628 client.connect().await;
5629 crate::state::set_nostr_client(client);
5630 let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(15));
5631 let become_acct = |k: &Keys| {
5632 let npub = k.public_key().to_bech32().unwrap();
5633 crate::db::set_current_account(npub.clone()).unwrap();
5634 crate::db::init_database(&npub).unwrap();
5635 crate::db::clear_id_caches();
5636 crate::state::MY_SECRET_KEY.store_from_keys(k, &[]);
5637 crate::state::set_my_public_key(k.public_key());
5638 };
5639 let settle = || tokio::time::sleep(std::time::Duration::from_secs(2));
5640
5641 become_acct(&a);
5643 let mut community = create_community(&transport, "Live E2E", relays.clone(), None).await.expect("create");
5644 let general = community.channels[0].id;
5645 log(format!("[create] community {} · #general {}", crate::simd::hex::bytes_to_hex_32(&community.id().0), crate::simd::hex::bytes_to_hex_32(&general.0)));
5646 send_message(&transport, &community, &general, "A: live hello").await.expect("send");
5647 let ann = create_public_channel(&transport, &community, "announcements").await.expect("channel");
5648 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5649 log(format!("[channel] +public #announcements {}", crate::simd::hex::bytes_to_hex_32(&ann.0)));
5650 grant_admin(&transport, &community, &b.public_key()).await.expect("grant admin");
5651 let link = mint_public_link(&transport, &community, "https://vectorapp.io", None, None).await.expect("mint");
5652 log(format!("[invite] B granted @admin · link {}", link.url));
5653 let bundle_json = serde_json::to_string(&bundle_of(&community, Some(a.public_key()), None, None)).unwrap();
5654 settle().await;
5655
5656 become_acct(&b);
5658 let b_view = accept_parked_invite(&transport, &bundle_json, None).await.expect("join");
5659 log(format!("[join] B joined; {} channels", b_view.channels.len()));
5660 settle().await;
5661 let page = fetch_channel(&transport, &b_view, &general, 50).await.expect("fetch");
5662 let seen: Vec<String> = page.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
5663 log(format!("[read] B sees #general: {seen:?}"));
5664 assert!(seen.iter().any(|t| t == "A: live hello"), "B reads A's message over the real relay");
5665 send_message(&transport, &b_view, &general, "B: live reply").await.expect("reply");
5666
5667 let hello = page.iter().find(|f| f.event.opened().rumor.content == "A: live hello").expect("A's message");
5671 let hello_id = hello.event.opened().rumor_id.to_hex();
5672 let bkeys = crate::state::MY_SECRET_KEY.to_keys().unwrap();
5673 let cgroup = channel_group_key(&b_view.community_root, &general, b_view.root_epoch);
5674 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());
5675 let (reply_wrap, _) = chat::seal_chat_rumor(&reply_rumor, &cgroup, &bkeys, Timestamp::from_secs(now_ms() / 1000), false).expect("seal 1111");
5676 transport.publish(&reply_wrap, &b_view.relays).await.expect("publish 1111");
5677 log("[thread] B published a kind-1111 threaded reply to A's message".to_string());
5678 settle().await;
5679
5680 become_acct(&a);
5682 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5683 let a_page = fetch_channel(&transport, &community, &general, 50).await.expect("A fetch");
5684 let thread = a_page.iter().find(|f| f.event.opened().rumor.content == "B: threaded reply to hello").expect("A sees the 1111");
5685 if let chat::ChatEvent::Message { reply_to, opened, .. } = &thread.event {
5686 assert_eq!(opened.rumor.kind.as_u16(), super::super::kind::COMMENT, "wire kind preserved as 1111");
5687 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");
5688 } else {
5689 panic!("the 1111 parsed as a Message");
5690 }
5691 log("[thread] A read B's threaded reply, parent resolved — cross-client 1111 interop OK".to_string());
5692 become_acct(&b);
5693 settle().await;
5694
5695 become_acct(&a);
5699 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5700 let vault = create_private_channel(&transport, &community, "vault").await.expect("private channel");
5701 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5702 send_message(&transport, &community, &vault, "A: vault live").await.expect("vault send");
5703 log(format!("[channel] +private #vault {} (key delivered over the rekey plane)", crate::simd::hex::bytes_to_hex_32(&vault.0)));
5704 settle().await;
5705
5706 become_acct(&b);
5707 let session_b = SessionGuard::capture();
5708 let mut b_view = crate::db::community::load_community_v2(b_view.id()).unwrap().unwrap();
5709 if let Some(fresh) = follow_control(&transport, &b_view, &session_b).await.expect("B control follow") {
5710 b_view = fresh;
5711 }
5712 if let Some(fresh) = follow_rekeys(&transport, &b_view, &session_b).await.expect("B rekey follow").updated {
5713 b_view = fresh;
5714 }
5715 let vch = b_view.channel(&vault).expect("B folded the vault");
5716 assert!(vch.key.is_some() && vch.epoch == Epoch(1), "B adopted the vault key from the live rekey plane");
5717 let vseen = texts_in(&transport, &b_view, &vault).await;
5718 log(format!("[read] B sees #vault: {vseen:?}"));
5719 assert!(vseen.iter().any(|t| t == "A: vault live"), "B reads the private channel with the ADOPTED key");
5720 send_message(&transport, &b_view, &vault, "B: in the live vault").await.expect("vault reply");
5721 settle().await;
5722
5723 become_acct(&a);
5724 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5725 assert!(
5726 texts_in(&transport, &community, &vault).await.iter().any(|t| t == "B: in the live vault"),
5727 "A reads B's private reply"
5728 );
5729 log("[private] two-way #vault conversation over the live relay".to_string());
5730
5731 set_banlist(&transport, &community, &[b.public_key().to_hex()]).await.expect("banlist");
5733 grant_roles(&transport, &community, &b.public_key(), vec![]).await.expect("strip");
5734 let refounded = refound_community(&transport, &community, &[b.public_key()]).await.expect("refound");
5735 log(format!("[ban] B banned; root → epoch {}", refounded.root_epoch.0));
5736 settle().await;
5737 dissolve_community(&transport, &refounded).await.expect("dissolve");
5738 log("[dissolve] community sealed".to_string());
5739 log("===== LIVE e2e PASS =====".to_string());
5740 }
5741
5742 #[tokio::test]
5743 async fn an_offline_member_learns_of_a_dissolution_on_catch_up() {
5744 let (bed, owner, member) = TestBed::new();
5748 bed.swap_to(&owner);
5749 let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
5750 let general = community.channels[0].id;
5751 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
5752
5753 bed.swap_to(&member);
5754 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
5755 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
5756
5757 bed.swap_to(&owner);
5759 dissolve_community(&bed.relay, &community).await.unwrap();
5760
5761 bed.swap_to(&member);
5763 let session = SessionGuard::capture();
5764 let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
5765 assert!(follow.dissolved, "the catch-up surfaces the tombstone");
5766 assert!(!follow.self_removed && follow.updated.is_none());
5767 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
5768 assert!(crate::db::community::get_community_dissolved(&cid_hex).unwrap(), "sealed read-only locally");
5769 let err = send_message(&bed.relay, &joined, &general, "into the void").await.unwrap_err();
5770 assert!(err.contains("dissolved"), "sends refuse a grave: {err}");
5771 let again = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
5773 assert!(again.dissolved && again.updated.is_none());
5774 }
5775
5776 #[tokio::test]
5777 async fn a_wide_community_survives_refoundings_and_an_offline_member_converges() {
5778 const PRIV_CHANNELS: usize = 6;
5783 let (bed, owner, member) = TestBed::new();
5784 bed.swap_to(&owner);
5785 let mut community = create_community(&bed.relay, "Wide", bed.relays.clone(), None).await.unwrap();
5786 let mut priv_ids = Vec::new();
5787 for i in 0..PRIV_CHANNELS {
5788 let id = create_private_channel(&bed.relay, &community, &format!("priv{i}")).await.unwrap();
5789 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5790 send_message(&bed.relay, &community, &id, &format!("priv{i} epoch0")).await.unwrap();
5791 priv_ids.push(id);
5792 }
5793 let bundle_json = serde_json::to_string(&bundle_of(&community, Some(owner.keys.public_key()), None, None)).unwrap();
5794
5795 bed.swap_to(&member);
5797 let member_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
5798 assert_eq!(member_view.channels.iter().filter(|c| c.private && c.key.is_some()).count(), PRIV_CHANNELS, "joined with all private keys");
5799
5800 bed.swap_to(&owner);
5802 for epoch in 1..=2u64 {
5803 community = refound_community(&bed.relay, &community, &[]).await.unwrap();
5804 assert_eq!(community.root_epoch, Epoch(epoch));
5805 for id in &priv_ids {
5806 send_message(&bed.relay, &community, id, &format!("{} epoch{epoch}", crate::simd::hex::bytes_to_hex_32(&id.0))).await.unwrap();
5807 }
5808 }
5809
5810 bed.swap_to(&member);
5812 let session = SessionGuard::capture();
5813 let mut passes = 0;
5814 loop {
5815 passes += 1;
5816 assert!(passes <= 8, "a wide catch-up must converge, not churn (pass {passes})");
5817 let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
5818 let rk = follow_rekeys(&bed.relay, &cur, &session).await.unwrap();
5819 assert!(!rk.self_removed);
5820 let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
5821 let ctl = follow_control(&bed.relay, &cur, &session).await.unwrap();
5822 if rk.updated.is_none() && ctl.is_none() {
5823 break;
5824 }
5825 }
5826 let caught_up = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
5827 assert_eq!(caught_up.root_epoch, Epoch(2), "walked both refoundings");
5828 for id in &priv_ids {
5830 let mine = caught_up.channel(id).expect("channel survived");
5831 let theirs = community.channel(id).unwrap();
5832 assert_eq!(mine.key, theirs.key, "channel {} converged on the owner key", crate::simd::hex::bytes_to_hex_32(&id.0));
5833 assert_eq!(mine.epoch, theirs.epoch, "…at the same epoch");
5834 let texts = texts_in(&bed.relay, &caught_up, id).await;
5835 let id_hex = crate::simd::hex::bytes_to_hex_32(&id.0);
5836 assert!(texts.iter().any(|t| t.contains("epoch0")), "channel {id_hex} reads epoch-0 history");
5837 for epoch in 1..=2u64 {
5838 assert!(texts.iter().any(|t| t.contains(&format!("epoch{epoch}"))), "channel {id_hex} reads epoch-{epoch} history");
5839 }
5840 }
5841 }
5842
5843 #[tokio::test]
5844 async fn an_offline_member_catches_up_across_three_refoundings() {
5845 use nostr_sdk::prelude::ToBech32;
5850 let (bed, owner, member) = TestBed::new();
5851 bed.swap_to(&owner);
5852 let mut community = create_community(&bed.relay, "Sleeper", bed.relays.clone(), None).await.unwrap();
5853 let general = community.channels[0].id;
5854 let mods = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
5855 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5856 send_message(&bed.relay, &community, &general, "epoch0: hello").await.unwrap();
5857 send_message(&bed.relay, &community, &mods, "epoch0: mods secret").await.unwrap();
5858 let bundle_json = serde_json::to_string(&bundle_of(&community, Some(owner.keys.public_key()), None, None)).unwrap();
5859
5860 bed.swap_to(&member);
5862 let member_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
5863 assert_eq!(member_view.root_epoch, Epoch(0));
5864
5865 bed.swap_to(&owner);
5867 let stranger = Keys::generate();
5868 for epoch in 1..=3u64 {
5869 community = refound_community(&bed.relay, &community, &[]).await.unwrap();
5870 assert_eq!(community.root_epoch, Epoch(epoch));
5871 send_message(&bed.relay, &community, &general, &format!("epoch{epoch}: general news")).await.unwrap();
5872 send_message(&bed.relay, &community, &mods, &format!("epoch{epoch}: mods word")).await.unwrap();
5873 }
5874 let news = create_public_channel(&bed.relay, &community, "news").await.unwrap();
5875 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5876 let vault = create_private_channel(&bed.relay, &community, "vault").await.unwrap();
5877 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5878 send_message(&bed.relay, &community, &vault, "epoch3: vault opened").await.unwrap();
5879 set_banlist(&bed.relay, &community, &[stranger.public_key().to_hex()]).await.unwrap();
5880 let meta = control::CommunityMetadata { name: "Sleeper Reborn".into(), relays: community.relays.clone(), ..Default::default() };
5881 edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
5882
5883 bed.swap_to(&member);
5886 let session = SessionGuard::capture();
5887 let mut passes = 0;
5888 loop {
5889 passes += 1;
5890 assert!(passes <= 6, "catch-up must converge, not churn");
5891 let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
5892 let rekeyed = follow_rekeys(&bed.relay, &cur, &session).await.unwrap();
5893 assert!(!rekeyed.self_removed, "the member was never removed");
5894 let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
5895 let controlled = follow_control(&bed.relay, &cur, &session).await.unwrap();
5896 if rekeyed.updated.is_none() && controlled.is_none() {
5897 break;
5898 }
5899 }
5900 let caught_up = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
5901
5902 assert_eq!(caught_up.root_epoch, Epoch(3), "walked all three refoundings");
5904 assert_eq!(caught_up.community_root, community.community_root, "landed on the owner's root");
5905 assert_eq!(caught_up.name, "Sleeper Reborn");
5906 assert!(caught_up.channels.iter().any(|c| c.id.0 == news.0), "folded the new public channel");
5908 let m = caught_up.channel(&mods).expect("mods survived");
5909 let owner_mods = community.channel(&mods).unwrap();
5910 assert_eq!(m.epoch, owner_mods.epoch, "mods walked every per-refound rotation");
5911 assert_eq!(m.key, owner_mods.key, "…to the owner's exact key");
5912 let v = caught_up.channel(&vault).expect("vault folded in");
5913 assert_eq!(v.key, community.channel(&vault).unwrap().key, "adopted the mid-sleep private channel's key");
5914 let cid_hex = crate::simd::hex::bytes_to_hex_32(&caught_up.id().0);
5916 let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap();
5917 assert!(banned.contains(&stranger.public_key().to_hex()), "the ban folded through");
5918 let gen_texts = texts_in(&bed.relay, &caught_up, &general).await;
5921 for epoch in 0..=3u64 {
5922 let needle = if epoch == 0 { "epoch0: hello".to_string() } else { format!("epoch{epoch}: general news") };
5923 assert!(gen_texts.contains(&needle), "general history spans epoch {epoch}: {gen_texts:?}");
5924 }
5925 let mods_texts = texts_in(&bed.relay, &caught_up, &mods).await;
5926 for epoch in 0..=3u64 {
5927 let needle = if epoch == 0 { "epoch0: mods secret".to_string() } else { format!("epoch{epoch}: mods word") };
5928 assert!(mods_texts.contains(&needle), "private history spans epoch {epoch}: {mods_texts:?}");
5929 }
5930 assert!(texts_in(&bed.relay, &caught_up, &vault).await.contains(&"epoch3: vault opened".to_string()));
5931 send_message(&bed.relay, &caught_up, &general, "member: good morning").await.unwrap();
5933 bed.swap_to(&owner);
5934 assert!(
5935 texts_in(&bed.relay, &community, &general).await.contains(&"member: good morning".to_string()),
5936 "the caught-up member converses at the new epoch ({})",
5937 member.keys.public_key().to_bech32().unwrap()
5938 );
5939 }
5940
5941 async fn flood_general(relay: &MemoryRelay, community: &CommunityV2, author: &Keys, n: usize, base_secs: u64) {
5944 let general = community.channels[0].id;
5945 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
5946 for i in 0..n {
5947 let at = base_secs + i as u64;
5948 let rumor = chat::build_message_rumor(author.public_key(), &general, community.root_epoch, &format!("msg {i}"), None, &[], vec![], at * 1000);
5949 let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, author, Timestamp::from_secs(at), false).unwrap();
5950 relay.publish(&wrap, &community.relays).await.unwrap();
5951 }
5952 }
5953
5954 #[tokio::test]
5955 async fn the_history_walk_pages_past_a_multi_page_burst() {
5956 let (_tmp, _guard, owner) = init_test_db();
5959 let relay = MemoryRelay::new();
5960 let community = create_community(&relay, "Burst", vec!["wss://r".into()], None).await.unwrap();
5961 let general = community.channels[0].id;
5962 flood_general(&relay, &community, &owner, 120, 10_000).await;
5963
5964 let all = fetch_channel_history(&relay, &community, &general, 50, 8, |_| true).await.unwrap();
5965 assert_eq!(all.len(), 120, "the walk pages the whole burst");
5966 let contents: Vec<String> = all.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
5968 assert_eq!(contents.first().map(String::as_str), Some("msg 0"));
5969 assert_eq!(contents.last().map(String::as_str), Some("msg 119"));
5970 let unique: std::collections::HashSet<&String> = contents.iter().collect();
5971 assert_eq!(unique.len(), 120, "wrap-id + rumor-id dedup holds across page boundaries");
5972
5973 let one = fetch_channel(&relay, &community, &general, 50).await.unwrap();
5975 assert_eq!(one.len(), 50, "fetch_channel is one newest page");
5976 assert_eq!(one.last().map(|f| f.event.opened().rumor.content.clone()).as_deref(), Some("msg 119"));
5977 }
5978
5979 #[tokio::test]
5980 async fn the_history_walk_stops_when_the_caller_is_caught_up() {
5981 let (_tmp, _guard, owner) = init_test_db();
5982 let relay = MemoryRelay::new();
5983 let community = create_community(&relay, "Caught", vec!["wss://r".into()], None).await.unwrap();
5984 let general = community.channels[0].id;
5985 flood_general(&relay, &community, &owner, 120, 10_000).await;
5986
5987 let mut pages = 0usize;
5989 let got = fetch_channel_history(&relay, &community, &general, 50, 8, |_| {
5990 pages += 1;
5991 false
5992 })
5993 .await
5994 .unwrap();
5995 assert_eq!(pages, 1, "the early stop is consulted once");
5996 assert_eq!(got.len(), 50, "only the newest page is fetched");
5997 assert_eq!(got.last().map(|f| f.event.opened().rumor.content.clone()).as_deref(), Some("msg 119"));
5998 }
5999
6000 #[tokio::test]
6001 async fn a_same_second_history_wall_terminates_instead_of_looping() {
6002 let (_tmp, _guard, owner) = init_test_db();
6006 let relay = MemoryRelay::new();
6007 let community = create_community(&relay, "Wall", vec!["wss://r".into()], None).await.unwrap();
6008 let general = community.channels[0].id;
6009 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
6010 for i in 0..60usize {
6011 let rumor = chat::build_message_rumor(owner.public_key(), &general, community.root_epoch, &format!("burst {i}"), None, &[], vec![], 5_000_000 + i as u64);
6012 let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, &owner, Timestamp::from_secs(5_000), false).unwrap();
6013 relay.publish(&wrap, &community.relays).await.unwrap();
6014 }
6015 let got = fetch_channel_history(&relay, &community, &general, 25, 8, |_| true).await.unwrap();
6016 assert!(got.len() >= 25, "at least the relay page is read");
6017 assert!(got.len() <= 60, "sane bound");
6018 }
6020
6021 #[tokio::test]
6022 async fn a_grant_revoke_survives_a_withholding_relay() {
6023 let (_tmp, _guard, owner) = init_test_db();
6026 let relay = MemoryRelay::new();
6027 let community = create_community(&relay, "Revoke", vec!["wss://good".into()], None).await.unwrap();
6028 let admin = Keys::generate();
6029 let rid = "d4".repeat(32);
6030 publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
6031 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
6032 let session = SessionGuard::capture();
6033 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();
6036
6037 inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
6039 let mut stale = community.clone();
6040 stale.relays = vec!["wss://stale".into()];
6041 let floors = load_floors(&community);
6042 let editions = fetch_control(&relay, &stale).await;
6043 let authority = fold_authority(&stale, &editions, &floors);
6044 assert!(
6045 !authority.roles.is_authorized(&admin.public_key().to_hex(), Some(&owner.public_key().to_hex()), Permissions::MANAGE_METADATA),
6046 "the persisted grant floor refuses the rolled-back (re-granted) view"
6047 );
6048 }
6049
6050 fn load_floors(community: &CommunityV2) -> Floors {
6052 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6053 crate::db::community::get_all_edition_heads_full(&cid_hex)
6054 .unwrap_or_default()
6055 .into_iter()
6056 .filter(|(_, f)| f.0 == community.root_epoch.0)
6057 .map(|(e, f)| (e, (f.1, f.2, f.3)))
6058 .collect()
6059 }
6060
6061 async fn fetch_control(relay: &MemoryRelay, community: &CommunityV2) -> Vec<ParsedEdition> {
6063 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
6064 let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
6065 relay
6066 .fetch(&q, &community.relays)
6067 .await
6068 .unwrap_or_default()
6069 .iter()
6070 .filter_map(|w| control::open_control_edition(w, &group).ok().map(|(ed, _)| ed))
6071 .collect()
6072 }
6073
6074 #[tokio::test]
6075 async fn follow_control_is_a_noop_on_a_freshly_created_community() {
6076 let (_tmp, _guard, _owner) = init_test_db();
6077 let relay = MemoryRelay::new();
6078 let community = create_community(&relay, "Fresh", vec!["wss://r".into()], None).await.unwrap();
6079 let session = SessionGuard::capture();
6080 assert!(follow_control(&relay, &community, &session).await.unwrap().is_none());
6082 }
6083
6084 #[tokio::test]
6085 async fn follow_control_adds_a_new_public_channel_and_re_subscribes_it() {
6086 let (_tmp, _guard, owner) = init_test_db();
6087 let relay = MemoryRelay::new();
6088 let community = create_community(&relay, "Grow", vec!["wss://r".into()], None).await.unwrap();
6089 let new_id = ChannelId([0x5a; 32]);
6090 publish_channel_edition(&relay, &community, &owner, &new_id, "announcements", false, 1, false).await;
6091
6092 let session = SessionGuard::capture();
6093 let updated = follow_control(&relay, &community, &session).await.unwrap().expect("a new channel changed the view");
6094 assert_eq!(updated.channels.len(), 2);
6095 let added = updated.channel(&new_id).expect("the new channel folded in");
6096 assert_eq!(added.name, "announcements");
6097 assert!(!added.private);
6098 assert_eq!(added.key, None, "a public channel derives from the root (no stored key)");
6099
6100 let authors = super::super::realtime::plane_authors(std::slice::from_ref(&updated));
6102 let addr = channel_group_key(&updated.community_root, &new_id, updated.root_epoch).pk();
6103 assert!(authors.contains(&addr), "the added channel joins the live subscription");
6104
6105 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
6107 assert!(reloaded.channel(&new_id).is_some());
6108 }
6109
6110 #[tokio::test]
6111 async fn follow_control_renames_the_community_and_an_existing_channel() {
6112 let (_tmp, _guard, owner) = init_test_db();
6113 let relay = MemoryRelay::new();
6114 let community = create_community(&relay, "Old Name", vec!["wss://r".into()], None).await.unwrap();
6115 let general = community.channels[0].id;
6116 publish_community_meta(&relay, &community, &owner, "New Name", 2).await;
6118 publish_channel_edition(&relay, &community, &owner, &general, "lobby", false, 2, false).await;
6119
6120 let session = SessionGuard::capture();
6121 let updated = follow_control(&relay, &community, &session).await.unwrap().unwrap();
6122 assert_eq!(updated.name, "New Name");
6123 assert_eq!(updated.channel(&general).unwrap().name, "lobby");
6124 assert_eq!(updated.channels.len(), 1, "a rename doesn't add a channel");
6125 }
6126
6127 #[tokio::test]
6128 async fn follow_control_deletes_a_channel() {
6129 let (_tmp, _guard, owner) = init_test_db();
6130 let relay = MemoryRelay::new();
6131 let community = create_community(&relay, "Prune", vec!["wss://r".into()], None).await.unwrap();
6132 let extra = ChannelId([0x77; 32]);
6133 let session = SessionGuard::capture();
6134
6135 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
6137 let with_extra = follow_control(&relay, &community, &session).await.unwrap().expect("added");
6138 assert!(with_extra.channel(&extra).is_some());
6139
6140 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
6142 let updated = follow_control(&relay, &with_extra, &session).await.unwrap().expect("removed");
6143 assert!(updated.channel(&extra).is_none(), "a deleted channel folds out");
6144 assert_eq!(updated.channels.len(), 1, "only #general remains");
6145 }
6146
6147 async fn inject_stale_prefix(relay: &MemoryRelay, community: &CommunityV2, max_version: u64, stale_relay: &str) {
6151 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
6152 let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
6153 let wraps = relay.fetch(&query, &community.relays).await.unwrap();
6154 for w in &wraps {
6155 if let Ok((ed, _)) = control::open_control_edition(w, &group) {
6156 if ed.version <= max_version {
6157 relay.inject(w, &[stale_relay.to_string()]);
6158 }
6159 }
6160 }
6161 }
6162
6163 #[tokio::test]
6164 async fn a_withholding_relay_cannot_roll_back_a_rename() {
6165 let (_tmp, _guard, owner) = init_test_db();
6168 let relay = MemoryRelay::new();
6169 let community = create_community(&relay, "Original", vec!["wss://good".into()], None).await.unwrap();
6170 publish_community_meta(&relay, &community, &owner, "Renamed", 2).await;
6171
6172 let session = SessionGuard::capture();
6173 let updated = follow_control(&relay, &community, &session).await.unwrap().expect("rename adopted");
6174 assert_eq!(updated.name, "Renamed");
6175
6176 inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
6178 let mut stale_view = updated.clone();
6179 stale_view.relays = vec!["wss://stale".into()];
6180 assert!(
6181 follow_control(&relay, &stale_view, &session).await.unwrap().is_none(),
6182 "a stale-only relay must not change the held view"
6183 );
6184 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
6185 assert_eq!(held.name, "Renamed", "the persisted floor refuses the rollback");
6186 }
6187
6188 #[tokio::test]
6189 async fn a_withholding_relay_cannot_resurrect_a_deleted_channel() {
6190 let (_tmp, _guard, owner) = init_test_db();
6191 let relay = MemoryRelay::new();
6192 let community = create_community(&relay, "Prune2", vec!["wss://good".into()], None).await.unwrap();
6193 let extra = ChannelId([0x44; 32]);
6194 let session = SessionGuard::capture();
6195
6196 publish_community_meta(&relay, &community, &owner, "Prune2", 2).await;
6199 assert!(follow_control(&relay, &community, &session).await.unwrap().is_none());
6200
6201 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
6202 let with_extra = follow_control(&relay, &community, &session).await.unwrap().expect("added");
6203 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
6204 let pruned = follow_control(&relay, &with_extra, &session).await.unwrap().expect("removed");
6205 assert!(pruned.channel(&extra).is_none());
6206
6207 inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
6209 let mut stale_view = pruned.clone();
6210 stale_view.relays = vec!["wss://stale".into()];
6211 assert!(
6212 follow_control(&relay, &stale_view, &session).await.unwrap().is_none(),
6213 "the withheld delete must not resurrect the channel"
6214 );
6215 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
6216 assert!(held.channel(&extra).is_none(), "the deleted channel stays deleted");
6217 }
6218
6219 #[tokio::test]
6220 async fn a_new_epoch_bootstraps_past_an_old_epoch_floor() {
6221 let (_tmp, _guard, owner) = init_test_db();
6225 let relay = MemoryRelay::new();
6226 let community = create_community(&relay, "Before", vec!["wss://good".into()], None).await.unwrap();
6227 let session = SessionGuard::capture();
6228 publish_community_meta(&relay, &community, &owner, "Edited", 2).await;
6229 let updated = follow_control(&relay, &community, &session).await.unwrap().expect("edit adopted");
6230 assert_eq!(updated.name, "Edited");
6231
6232 let mut refounded = updated.clone();
6235 refounded.root_epoch = crate::community::Epoch(1);
6236 crate::db::community::save_community_v2(&refounded).unwrap();
6237 publish_community_meta(&relay, &refounded, &owner, "Compacted", 5).await;
6238
6239 let adopted = follow_control(&relay, &refounded, &session).await.unwrap().expect("compacted head adopted");
6240 assert_eq!(adopted.name, "Compacted", "a fresh epoch bootstraps despite the dangling prev");
6241 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6243 let heads = crate::db::community::get_all_edition_heads_epoched(&cid_hex).unwrap();
6244 assert!(
6245 heads.get(&cid_hex).is_some_and(|(e, v, _)| *e == 1 && *v == 5),
6246 "the adopted head carries the fold's epoch + version"
6247 );
6248 }
6249
6250 #[tokio::test]
6251 async fn a_same_version_owner_fork_at_the_floor_converges_to_the_deterministic_winner() {
6252 let (_tmp, _guard, owner) = init_test_db();
6257 let relay = MemoryRelay::new();
6258 let community = create_community(&relay, "Fork", vec!["wss://r".into()], None).await.unwrap();
6259 let session = SessionGuard::capture();
6260 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
6261 let genesis_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
6262
6263 publish_community_meta(&relay, &community, &owner, "Ours", 2).await;
6264 let ours = follow_control(&relay, &community, &session).await.unwrap().expect("ours adopted");
6265 assert_eq!(ours.name, "Ours");
6266
6267 let our_inner = {
6269 let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
6270 let wraps = relay.fetch(&q, &community.relays).await.unwrap();
6271 wraps
6272 .iter()
6273 .find_map(|w| {
6274 control::open_control_edition(w, &group)
6275 .ok()
6276 .filter(|(ed, _)| ed.version == 2 && ed.vsk == vsk::COMMUNITY_METADATA)
6277 .map(|(ed, _)| ed.inner_id)
6278 })
6279 .unwrap()
6280 };
6281
6282 let meta = control::CommunityMetadata { name: "Theirs".into(), ..Default::default() };
6285 let content = serde_json::to_string(&meta).unwrap();
6286 let mut ts = 2_000u64;
6287 let fork_wrap = loop {
6288 let rumor = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &content, ts, None);
6289 let inner = rumor.id.unwrap().to_bytes();
6290 if inner < our_inner {
6291 break control::seal_control_edition(&rumor, &group, &owner, Timestamp::from_secs(ts)).unwrap().0;
6292 }
6293 ts += 1;
6294 };
6295 relay.publish(&fork_wrap, &community.relays).await.unwrap();
6296
6297 let converged = follow_control(&relay, &ours, &session).await.unwrap().expect("fork winner adopted");
6298 assert_eq!(converged.name, "Theirs", "the floor converges to the lower-inner-id winner");
6299 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6300 let held = crate::db::community::get_edition_head_inner_id(&cid_hex, &cid_hex).unwrap();
6301 assert!(held.is_some_and(|h| h < our_inner), "the persisted floor's tiebreak key moved to the winner");
6302 }
6303
6304 #[tokio::test]
6305 async fn an_anchored_prefix_applies_while_a_gap_above_awaits_the_missing_link() {
6306 let (_tmp, _guard, owner) = init_test_db();
6310 let relay = MemoryRelay::new();
6311 let community = create_community(&relay, "Prefix", vec!["wss://r".into()], None).await.unwrap();
6312 let session = SessionGuard::capture();
6313 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
6314
6315 publish_community_meta(&relay, &community, &owner, "Two", 2).await;
6316 let v2_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
6317
6318 let c3 = serde_json::to_string(&control::CommunityMetadata { name: "Three".into(), ..Default::default() }).unwrap();
6320 let r3 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 3, Some(&v2_hash), &c3, 3_000, None);
6321 let (w3, _) = control::seal_control_edition(&r3, &group, &owner, Timestamp::from_secs(3_000)).unwrap();
6322 let (ed3, _) = control::open_control_edition(&w3, &group).unwrap();
6323 let c4 = serde_json::to_string(&control::CommunityMetadata { name: "Four".into(), ..Default::default() }).unwrap();
6324 let r4 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 4, Some(&ed3.self_hash), &c4, 4_000, None);
6325 let (w4, _) = control::seal_control_edition(&r4, &group, &owner, Timestamp::from_secs(4_000)).unwrap();
6326 relay.publish(&w4, &community.relays).await.unwrap();
6327
6328 let updated = follow_control(&relay, &community, &session).await.unwrap().expect("the verified prefix applies");
6329 assert_eq!(updated.name, "Two", "the anchored prefix lands; the detached v4 does not");
6330
6331 relay.publish(&w3, &community.relays).await.unwrap();
6332 let healed = follow_control(&relay, &updated, &session).await.unwrap().expect("the chain heals");
6333 assert_eq!(healed.name, "Four", "once the link arrives, the head advances past the prefix");
6334 }
6335
6336 #[tokio::test]
6337 async fn paging_rescues_a_floor_link_evicted_from_the_newest_window() {
6338 let (_tmp, _guard, owner) = init_test_db();
6342 let relay = MemoryRelay::new();
6343 let community = create_community(&relay, "Paged", vec!["wss://r".into()], None).await.unwrap();
6344 let session = SessionGuard::capture();
6345 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
6346
6347 publish_community_meta(&relay, &community, &owner, "Two", 2).await;
6348 let base = follow_control(&relay, &community, &session).await.unwrap().expect("floor at v2");
6349 publish_community_meta(&relay, &base, &owner, "Three", 3).await; let v3_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
6351
6352 let rogue = Keys::generate();
6355 for i in 0..(FOLLOW_PAGE as u64 - 1) {
6356 let rumor = control::build_edition_rumor(rogue.public_key(), vsk::CHANNEL_METADATA, &[0xCC; 32], 1, None, "{\"name\":\"junk\",\"private\":false}", 4_000 + i, None);
6357 let (w, _) = control::seal_control_edition(&rumor, &group, &rogue, Timestamp::from_secs(4_000 + i)).unwrap();
6358 relay.publish(&w, &community.relays).await.unwrap();
6359 }
6360 let c4 = serde_json::to_string(&control::CommunityMetadata { name: "Four".into(), ..Default::default() }).unwrap();
6363 let r4 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 4, Some(&v3_hash), &c4, 10_000, None);
6364 let (w4, _) = control::seal_control_edition(&r4, &group, &owner, Timestamp::from_secs(10_000)).unwrap();
6365 relay.publish(&w4, &community.relays).await.unwrap();
6366
6367 let healed = follow_control(&relay, &base, &session).await.unwrap().expect("paging recovered the chain");
6368 assert_eq!(healed.name, "Four", "the gap paged past the flood to the floor link");
6369 }
6370
6371 #[tokio::test]
6372 async fn a_follow_after_delete_does_not_resurrect_the_community() {
6373 let (_tmp, _guard, owner) = init_test_db();
6376 let relay = MemoryRelay::new();
6377 let community = create_community(&relay, "Gone", vec!["wss://r".into()], None).await.unwrap();
6378 publish_community_meta(&relay, &community, &owner, "Edited", 2).await;
6379 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6380 crate::db::community::delete_community(&cid_hex).unwrap();
6381
6382 let session = SessionGuard::capture();
6383 assert!(
6384 follow_control(&relay, &community, &session).await.unwrap().is_none(),
6385 "a follow racing a delete is a no-op"
6386 );
6387 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none(), "the community stays deleted");
6388 assert!(crate::db::community::edition_head_entity_ids(&cid_hex).unwrap().is_empty(), "no orphan floor rows");
6389 }
6390
6391 #[tokio::test]
6392 async fn a_rekey_follow_after_delete_does_not_resurrect_the_community() {
6393 let (_tmp, _guard, owner) = init_test_db();
6396 let relay = MemoryRelay::new();
6397 let community = create_community(&relay, "GoneKeys", vec!["wss://r".into()], None).await.unwrap();
6398 let new_root = [0xB2; 32];
6399 publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
6400 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6401 crate::db::community::delete_community(&cid_hex).unwrap();
6402
6403 let session = SessionGuard::capture();
6404 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
6405 assert!(follow.updated.is_none() && !follow.self_removed, "a rekey follow racing a delete adopts nothing");
6406 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none(), "the community stays deleted");
6407 }
6408
6409 #[tokio::test]
6410 async fn a_joiner_bootstraps_the_highest_head_across_a_lost_middle_edition() {
6411 let (bed, owner, member) = TestBed::new();
6416 bed.swap_to(&owner);
6417 let community = create_community(&bed.relay, "Skip", bed.relays.clone(), None).await.unwrap();
6418 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
6419 let genesis_hash = head_hash_on_relay(&bed.relay, &community, &community.id().0).await.unwrap();
6420
6421 let c2 = serde_json::to_string(&control::CommunityMetadata { name: "Two".into(), ..Default::default() }).unwrap();
6423 let r2 = control::build_edition_rumor(owner.keys.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &c2, 2_000, None);
6424 let (w2, _) = control::seal_control_edition(&r2, &group, &owner.keys, Timestamp::from_secs(2_000)).unwrap();
6425 let (ed2, _) = control::open_control_edition(&w2, &group).unwrap();
6426 let c3 = serde_json::to_string(&control::CommunityMetadata { name: "Three".into(), ..Default::default() }).unwrap();
6427 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);
6428 let (w3, _) = control::seal_control_edition(&r3, &group, &owner.keys, Timestamp::from_secs(3_000)).unwrap();
6429 bed.relay.publish(&w3, &community.relays).await.unwrap();
6430
6431 let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
6432 let bundle_json = serde_json::to_string(&bundle).unwrap();
6433 bed.swap_to(&member);
6434 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
6435 assert_eq!(joined.name, "Three", "the joiner bootstraps the highest signed head, not the anchored stale prefix");
6436 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
6437 let head = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap();
6438 assert!(head.is_some_and(|(v, _)| v == 3), "the seeded floor is the bootstrap head");
6439 }
6440
6441 #[tokio::test]
6442 async fn a_losing_same_version_fork_cannot_replace_the_held_floor() {
6443 let (_tmp, _guard, owner) = init_test_db();
6447 let relay = MemoryRelay::new();
6448 let community = create_community(&relay, "Fork2", vec!["wss://good".into()], None).await.unwrap();
6449 let session = SessionGuard::capture();
6450 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
6451 let genesis_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
6452
6453 publish_community_meta(&relay, &community, &owner, "Ours", 2).await;
6454 let ours = follow_control(&relay, &community, &session).await.unwrap().expect("ours adopted");
6455 assert_eq!(ours.name, "Ours");
6456 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6457 let held_before = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().unwrap();
6458 let our_inner = crate::db::community::get_edition_head_inner_id(&cid_hex, &cid_hex).unwrap().unwrap();
6459
6460 let meta = control::CommunityMetadata { name: "Theirs".into(), ..Default::default() };
6463 let content = serde_json::to_string(&meta).unwrap();
6464 let mut ts = 5_000u64;
6465 let fork_wrap = loop {
6466 let rumor = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &content, ts, None);
6467 if rumor.id.unwrap().to_bytes() > our_inner {
6468 break control::seal_control_edition(&rumor, &group, &owner, Timestamp::from_secs(ts)).unwrap().0;
6469 }
6470 ts += 1;
6471 };
6472 inject_stale_prefix(&relay, &community, 1, "wss://stale").await; relay.inject(&fork_wrap, &["wss://stale".to_string()]);
6474 let mut stale_view = ours.clone();
6475 stale_view.relays = vec!["wss://stale".into()];
6476
6477 assert!(
6478 follow_control(&relay, &stale_view, &session).await.unwrap().is_none(),
6479 "a losing fork served without our floor edition changes nothing"
6480 );
6481 let held_after = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().unwrap();
6482 assert_eq!(held_after, held_before, "the floor row is untouched");
6483 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
6484 assert_eq!(held.name, "Ours", "the held state is untouched");
6485 }
6486
6487 #[tokio::test]
6488 async fn follow_control_ignores_a_non_owner_edition() {
6489 let (_tmp, _guard, _owner) = init_test_db();
6493 let relay = MemoryRelay::new();
6494 let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
6495 let rogue = Keys::generate();
6496 let rogue_id = ChannelId([0x99; 32]);
6497 publish_channel_edition(&relay, &community, &rogue, &rogue_id, "backdoor", false, 1, false).await;
6498
6499 let session = SessionGuard::capture();
6500 assert!(
6501 follow_control(&relay, &community, &session).await.unwrap().is_none(),
6502 "a non-owner control edition is not folded"
6503 );
6504 }
6505
6506 #[tokio::test]
6507 async fn follow_control_records_a_new_private_channel_keyless_and_unreadable() {
6508 let (_tmp, _guard, owner) = init_test_db();
6512 let relay = MemoryRelay::new();
6513 let community = create_community(&relay, "Priv", vec!["wss://r".into()], None).await.unwrap();
6514 let priv_id = ChannelId([0x33; 32]);
6515 publish_channel_edition(&relay, &community, &owner, &priv_id, "mods", true, 1, false).await;
6516
6517 let session = SessionGuard::capture();
6518 let updated = follow_control(&relay, &community, &session)
6519 .await
6520 .unwrap()
6521 .expect("the keyless record is a change");
6522 let ch = updated.channel(&priv_id).expect("the private channel is recorded");
6523 assert!(ch.private && ch.key.is_none(), "recorded keyless");
6524 assert_eq!(ch.epoch, Epoch(0), "epoch 0 = the root generation (scan cursor)");
6525 assert!(updated.channel_read_coords(ch).is_empty(), "unreadable until keyed");
6526 assert!(
6527 fetch_channel(&relay, &updated, &priv_id, 50).await.unwrap().is_empty(),
6528 "a keyless fetch returns empty (and never queries the root plane)"
6529 );
6530 assert!(
6531 send_message(&relay, &updated, &priv_id, "nope").await.is_err(),
6532 "a keyless send refuses"
6533 );
6534 let reloaded = crate::db::community::load_community_v2(updated.id()).unwrap().unwrap();
6537 let rch = reloaded.channel(&priv_id).unwrap();
6538 assert!(rch.private && rch.key.is_none() && rch.epoch == Epoch(0), "keyless survives reload");
6539 let bundle = bundle_of(&reloaded, None, None, None);
6541 assert!(
6542 !bundle.channels.iter().any(|c| c.id == crate::simd::hex::bytes_to_hex_32(&priv_id.0)),
6543 "an ungrantable keyless channel stays out of invite bundles"
6544 );
6545 }
6546
6547 async fn publish_base_rotation(
6554 relay: &MemoryRelay,
6555 community: &CommunityV2,
6556 rotator: &Keys,
6557 recipients: &[PublicKey],
6558 new_root: &[u8; 32],
6559 prev_key: &[u8; 32],
6560 ) {
6561 let new_epoch = Epoch(community.root_epoch.0 + 1);
6562 let prev_epoch = community.root_epoch;
6563 let prev_commit = super::super::derive::epoch_key_commitment(prev_epoch, prev_key);
6564 let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
6565 let blobs: Vec<_> = recipients
6566 .iter()
6567 .map(|r| rekey::build_blob_local(rotator.secret_key(), &rotator.public_key().to_bytes(), r, RekeyScope::Root, new_epoch, new_root).unwrap())
6568 .collect();
6569 let events = rekey::build_rekey_chunks_local(rotator, &group, RekeyScope::Root, new_epoch, prev_epoch, &prev_commit, &blobs, 2_000).unwrap();
6570 for e in &events {
6571 relay.publish(e, &community.relays).await.unwrap();
6572 }
6573 }
6574
6575 fn add_private_channel(community: &mut CommunityV2, id: ChannelId, key: [u8; 32], epoch: Epoch) {
6577 community.channels.push(ChannelV2 { id, name: "mods".into(), private: true, key: Some(key), epoch, voice: None, meta_custom: None, meta_extra: Default::default() });
6578 crate::db::community::save_community_v2(community).unwrap();
6579 }
6580
6581 #[tokio::test]
6582 async fn follow_rekeys_is_a_noop_without_rotations() {
6583 let (_tmp, _guard, _owner) = init_test_db();
6584 let relay = MemoryRelay::new();
6585 let community = create_community(&relay, "Still", vec!["wss://r".into()], None).await.unwrap();
6586 let session = SessionGuard::capture();
6587 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
6588 assert!(follow.updated.is_none() && !follow.self_removed, "no rotation → nothing to adopt");
6589 }
6590
6591 #[tokio::test]
6592 async fn follow_rekeys_adopts_an_owner_base_rotation() {
6593 let (_tmp, _guard, owner) = init_test_db();
6594 let relay = MemoryRelay::new();
6595 let community = create_community(&relay, "Refound", vec!["wss://r".into()], None).await.unwrap();
6596 let new_root = [0xB1; 32];
6597 publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
6599
6600 let session = SessionGuard::capture();
6601 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopted");
6602 assert_eq!(updated.root_epoch, Epoch(1), "advanced one epoch");
6603 assert_eq!(updated.community_root, new_root, "adopted the fresh root");
6604 let addr = super::super::realtime::plane_authors(std::slice::from_ref(&updated));
6606 let general = updated.channels[0].id;
6607 let new_chat = channel_group_key(&new_root, &general, Epoch(1)).pk();
6608 assert!(addr.contains(&new_chat), "the public channel re-addresses under the new root");
6609 }
6610
6611 #[tokio::test]
6612 async fn follow_rekeys_adopts_an_owner_private_channel_rotation() {
6613 let (_tmp, _guard, owner) = init_test_db();
6614 let relay = MemoryRelay::new();
6615 let mut community = create_community(&relay, "PrivRot", vec!["wss://r".into()], None).await.unwrap();
6616 let priv_id = ChannelId([0x33; 32]);
6617 add_private_channel(&mut community, priv_id, [0x44; 32], Epoch(0));
6618
6619 let new_key = [0x55; 32];
6621 let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &[0x44; 32]);
6622 let group = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(1));
6623 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();
6624 let events = rekey::build_rekey_chunks_local(&owner, &group, RekeyScope::Channel(priv_id), Epoch(1), Epoch(0), &prev_commit, &[blob], 2_000).unwrap();
6625 for e in &events {
6626 relay.publish(e, &community.relays).await.unwrap();
6627 }
6628
6629 let session = SessionGuard::capture();
6630 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopted");
6631 let ch = updated.channel(&priv_id).unwrap();
6632 assert_eq!(ch.epoch, Epoch(1), "the private channel advanced an epoch");
6633 assert_eq!(ch.key, Some(new_key), "adopted the fresh channel key");
6634 assert_eq!(updated.root_epoch, Epoch(0), "the base is untouched by a channel rotation");
6635 }
6636
6637 #[tokio::test]
6638 async fn follow_rekeys_ignores_a_non_owner_rotation() {
6639 let (_tmp, _guard, _owner) = init_test_db();
6642 let relay = MemoryRelay::new();
6643 let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
6644 let rogue = Keys::generate();
6645 publish_base_rotation(&relay, &community, &rogue, &[rogue.public_key()], &[0xEE; 32], &community.community_root).await;
6646
6647 let session = SessionGuard::capture();
6648 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
6649 assert!(follow.updated.is_none() && !follow.self_removed, "a non-owner rotation is not adopted");
6650 }
6651
6652 #[tokio::test]
6653 async fn follow_rekeys_ignores_a_rotation_off_the_wrong_prev() {
6654 let (_tmp, _guard, owner) = init_test_db();
6657 let relay = MemoryRelay::new();
6658 let community = create_community(&relay, "Forked", vec!["wss://r".into()], None).await.unwrap();
6659 publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &[0xB2; 32], &[0x00; 32]).await;
6661
6662 let session = SessionGuard::capture();
6663 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
6664 assert!(follow.updated.is_none(), "a fork off the wrong prev is not adopted");
6665 }
6666
6667 #[tokio::test]
6668 async fn follow_rekeys_holds_on_an_incomplete_rotation() {
6669 let (_tmp, _guard, owner) = init_test_db();
6672 let relay = MemoryRelay::new();
6673 let community = create_community(&relay, "Partial", vec!["wss://r".into()], None).await.unwrap();
6674 let new_epoch = Epoch(1);
6675 let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
6676 let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
6677 let other = Keys::generate();
6679 let blob = rekey::build_blob_local(owner.secret_key(), &owner.public_key().to_bytes(), &other.public_key(), RekeyScope::Root, new_epoch, &[0xB3; 32]).unwrap();
6680 let rumor = rekey::build_rekey_rumor(owner.public_key(), RekeyScope::Root, new_epoch, Epoch(0), &prev_commit, &[blob], 1, 2, 2_000).unwrap();
6681 let (wrap, _) = rekey::seal_rekey_chunk(&rumor, &group, &owner, Timestamp::from_secs(2_000)).unwrap();
6682 relay.publish(&wrap, &community.relays).await.unwrap();
6683
6684 let session = SessionGuard::capture();
6685 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
6686 assert!(follow.updated.is_none() && !follow.self_removed, "an incomplete rotation neither adopts nor removes");
6687 }
6688
6689 #[tokio::test]
6690 async fn follow_rekeys_removes_a_member_dropped_by_a_base_rotation() {
6691 let (bed, owner, member) = TestBed::new();
6695 bed.swap_to(&owner);
6696 let community = create_community(&bed.relay, "Evict", bed.relays.clone(), None).await.unwrap();
6697 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
6698
6699 bed.swap_to(&member);
6700 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
6701 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
6702
6703 bed.swap_to(&owner);
6705 let stranger = Keys::generate();
6706 publish_base_rotation(&bed.relay, &community, &owner.keys, &[stranger.public_key()], &[0xC4; 32], &community.community_root).await;
6707
6708 bed.swap_to(&member);
6710 let session = SessionGuard::capture();
6711 let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
6712 assert!(follow.self_removed, "a complete base rotation dropping the member removes them");
6713 assert!(follow.updated.is_none(), "a removed member adopts nothing");
6714 }
6715
6716 #[tokio::test]
6717 async fn follow_rekeys_finds_a_channel_rekey_under_an_archived_prior_root() {
6718 let (_tmp, _guard, owner) = init_test_db();
6724 let relay = MemoryRelay::new();
6725 let mut community = create_community(&relay, "Strand", vec!["wss://r".into()], None).await.unwrap();
6726 let root0 = community.community_root;
6727 let priv_id = ChannelId([0x33; 32]);
6728 let key1 = [0x44; 32];
6729 add_private_channel(&mut community, priv_id, key1, Epoch(1));
6730
6731 let key2 = [0x55; 32];
6734 let prev_commit = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
6735 let group = channel_rekey_group_key(&root0, &priv_id, Epoch(2));
6736 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();
6737 for e in rekey::build_rekey_chunks_local(&owner, &group, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &prev_commit, &[blob], 2_000).unwrap() {
6738 relay.publish(&e, &community.relays).await.unwrap();
6739 }
6740
6741 community.community_root = [0xB7; 32];
6745 community.root_epoch = Epoch(1);
6746 crate::db::community::save_community_v2(&community).unwrap();
6747
6748 let session = SessionGuard::capture();
6749 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the prior-root crate is found");
6750 let ch = updated.channel(&priv_id).unwrap();
6751 assert_eq!(ch.epoch, Epoch(2), "the channel advanced despite the moved base");
6752 assert_eq!(ch.key, Some(key2), "adopted the key delivered under the prior root");
6753 }
6754
6755 #[tokio::test]
6756 async fn follow_rekeys_keyless_cursor_walks_past_an_excluding_rotation_then_adopts() {
6757 let (_tmp, _guard, owner) = init_test_db();
6762 let relay = MemoryRelay::new();
6763 let mut community = create_community(&relay, "Cursor", vec!["wss://r".into()], None).await.unwrap();
6764 let priv_id = ChannelId([0x66; 32]);
6765 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() });
6766 crate::db::community::save_community_v2(&community).unwrap();
6767 let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
6768 assert!(community.channel(&priv_id).unwrap().key.is_none(), "keyless survives the round-trip");
6769
6770 let stranger = Keys::generate();
6772 let key1 = [0x71; 32];
6773 let pc1 = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
6774 let g1 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(1));
6775 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();
6776 for e in rekey::build_rekey_chunks_local(&owner, &g1, RekeyScope::Channel(priv_id), Epoch(1), Epoch(0), &pc1, &[b1], 2_000).unwrap() {
6777 relay.publish(&e, &community.relays).await.unwrap();
6778 }
6779 let key2 = [0x72; 32];
6782 let pc2 = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
6783 let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
6784 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();
6785 for e in rekey::build_rekey_chunks_local(&owner, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc2, &[b2], 2_100).unwrap() {
6786 relay.publish(&e, &community.relays).await.unwrap();
6787 }
6788
6789 let session = SessionGuard::capture();
6792 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the walk lands on the included epoch");
6793 let ch = updated.channel(&priv_id).unwrap();
6794 assert_eq!(ch.epoch, Epoch(2), "cursor walked through the excluding epoch to the included one");
6795 assert_eq!(ch.key, Some(key2), "adopted the delivery that includes us");
6796 }
6797
6798 #[tokio::test]
6799 async fn follow_rekeys_honors_an_admin_channel_rotation_but_never_a_strangers() {
6800 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
6804 let (_tmp, _guard, _owner) = init_test_db();
6805 let relay = MemoryRelay::new();
6806 let mut community = create_community(&relay, "AdminRot", vec!["wss://r".into()], None).await.unwrap();
6807 let priv_id = ChannelId([0x88; 32]);
6808 let key1 = [0x91; 32];
6809 add_private_channel(&mut community, priv_id, key1, Epoch(1));
6810
6811 let admin = Keys::generate();
6813 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6814 let role = Role::admin("aa".repeat(32));
6815 let roster = CommunityRoles {
6816 roles: vec![role.clone()],
6817 grants: vec![MemberGrant { member: admin.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }],
6818 };
6819 crate::db::community::set_community_roles(&cid_hex, &roster, 1_000).unwrap();
6820
6821 let key2 = [0x92; 32];
6823 let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
6824 let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
6825 let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
6826 let blob = rekey::build_blob_local(admin.secret_key(), &admin.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
6827 for e in rekey::build_rekey_chunks_local(&admin, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[blob], 2_000).unwrap() {
6828 relay.publish(&e, &community.relays).await.unwrap();
6829 }
6830 let session = SessionGuard::capture();
6831 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("an admin rotation is honored");
6832 assert_eq!(updated.channel(&priv_id).unwrap().key, Some(key2), "adopted the admin's key");
6833
6834 let rogue = Keys::generate();
6836 let key3 = [0x93; 32];
6837 let pc3 = super::super::derive::epoch_key_commitment(Epoch(2), &key2);
6838 let g3 = channel_rekey_group_key(&updated.community_root, &priv_id, Epoch(3));
6839 let rb = rekey::build_blob_local(rogue.secret_key(), &rogue.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(3), &key3).unwrap();
6840 for e in rekey::build_rekey_chunks_local(&rogue, &g3, RekeyScope::Channel(priv_id), Epoch(3), Epoch(2), &pc3, &[rb], 2_100).unwrap() {
6841 relay.publish(&e, &updated.relays).await.unwrap();
6842 }
6843 let follow = follow_rekeys(&relay, &updated, &session).await.unwrap();
6844 assert!(follow.updated.is_none(), "a stranger's channel rotation is never adopted");
6845 }
6846
6847 #[tokio::test]
6848 async fn a_non_outranking_admins_rotation_never_concludes_my_removal() {
6849 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
6854 let (bed, owner, member) = TestBed::new();
6855 bed.swap_to(&owner);
6856 let community = create_community(&bed.relay, "Outrank", bed.relays.clone(), None).await.unwrap();
6857
6858 bed.swap_to(&member);
6861 let mut held = community.clone();
6862 let priv_id = ChannelId([0xAB; 32]);
6863 let key1 = [0xA1; 32];
6864 add_private_channel(&mut held, priv_id, key1, Epoch(1));
6865 let peer = Keys::generate();
6866 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6867 let role = Role::admin("bb".repeat(32));
6868 let roster = CommunityRoles {
6869 roles: vec![role.clone()],
6870 grants: vec![
6871 MemberGrant { member: peer.public_key().to_hex(), role_ids: vec![role.role_id.clone()] },
6872 MemberGrant { member: member.keys.public_key().to_hex(), role_ids: vec![role.role_id.clone()] },
6873 ],
6874 };
6875 crate::db::community::set_community_roles(&cid_hex, &roster, 1_000).unwrap();
6876
6877 let key2 = [0xA2; 32];
6879 let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
6880 let g2 = channel_rekey_group_key(&held.community_root, &priv_id, Epoch(2));
6881 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();
6882 for e in rekey::build_rekey_chunks_local(&peer, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[pb], 2_000).unwrap() {
6883 bed.relay.publish(&e, &held.relays).await.unwrap();
6884 }
6885 let session = SessionGuard::capture();
6886 let follow = follow_rekeys(&bed.relay, &held, &session).await.unwrap();
6887 assert!(follow.updated.is_none(), "an equal-rank rotation excluding me is Stay, never my removal");
6888 let reloaded = crate::db::community::load_community_v2(held.id()).unwrap().unwrap();
6889 assert!(reloaded.channel(&priv_id).is_some(), "my channel record survives the peer's rotation");
6890
6891 let key3 = [0xA3; 32];
6893 let stranger = Keys::generate();
6894 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();
6895 for e in rekey::build_rekey_chunks_local(&owner.keys, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[ob], 2_100).unwrap() {
6896 bed.relay.publish(&e, &held.relays).await.unwrap();
6897 }
6898 let follow = follow_rekeys(&bed.relay, &held, &session).await.unwrap();
6899 let updated = follow.updated.expect("the owner's removal folds");
6900 assert!(updated.channel(&priv_id).is_none(), "the owner's exclusion cuts my channel record");
6901 }
6902
6903 #[tokio::test]
6904 async fn converting_a_public_channel_to_private_is_refused() {
6905 let (_tmp, _guard, _owner) = init_test_db();
6908 let relay = MemoryRelay::new();
6909 let community = create_community(&relay, "NoConvert", vec!["wss://r".into()], None).await.unwrap();
6910 let general = community.channels[0].id;
6911 let meta = control::ChannelMetadata { name: "general".into(), private: true, voice: None, deleted: None, custom: None, extra: Default::default() };
6912 let err = edit_channel_metadata(&relay, &community, &general, &meta).await.unwrap_err();
6913 assert!(err.contains("not supported"), "conversion is refused at the producer: {err}");
6914 let meta = control::ChannelMetadata { name: "lobby".into(), private: false, voice: None, deleted: None, custom: None, extra: Default::default() };
6916 edit_channel_metadata(&relay, &community, &general, &meta).await.unwrap();
6917 }
6918
6919 async fn publish_remote_tombstone(relay: &MemoryRelay, me: &Keys, relays: &[String], cid_hex: &str, removed_at: u64) {
6922 let doc = super::super::list::CommunityList {
6923 entries: vec![],
6924 tombstones: vec![super::super::list::Tombstone { community_id: cid_hex.to_string(), removed_at, extra: Default::default() }],
6925 extra: Default::default(),
6926 };
6927 let event = super::super::list::build_list_event(me, &doc).unwrap();
6928 relay.publish(&event, relays).await.unwrap();
6929 }
6930
6931 #[tokio::test]
6932 async fn joining_one_community_does_not_resurrect_a_sibling_left_community() {
6933 let (_tmp, _guard, me) = init_test_db();
6937 let relay = MemoryRelay::new();
6938 let x = create_community(&relay, "X", vec!["wss://r".into()], None).await.unwrap();
6939 let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
6940
6941 publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, now_ms() + 10_000).await;
6943
6944 let y = create_community(&relay, "Y", vec!["wss://r".into()], None).await.unwrap();
6946 republish_community_list(&relay, Some(y.id())).await.unwrap();
6947
6948 let list = fetch_community_list(&relay, &x.relays).await.unwrap().unwrap();
6950 assert!(!list.is_live(&x_hex), "joining Y did not resurrect the sibling-left X");
6951 assert!(list.is_live(&crate::simd::hex::bytes_to_hex_32(&y.id().0)), "Y is live");
6952 }
6953
6954 #[tokio::test]
6955 async fn sync_tears_down_a_community_a_sibling_left() {
6956 let (_tmp, _guard, me) = init_test_db();
6959 let relay = MemoryRelay::new();
6960 let x = create_community(&relay, "Leaveme", vec!["wss://r".into()], None).await.unwrap();
6961 let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
6962 assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_some(), "held before sync");
6963
6964 publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, now_ms() + 10_000).await;
6965 sync_community_list(&relay, &x.relays).await.unwrap();
6966 assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_none(), "the sibling's leave tore X down locally");
6967 }
6968
6969 #[tokio::test]
6970 async fn a_rejoined_community_survives_a_stale_tombstone_on_sync() {
6971 let (_tmp, _guard, me) = init_test_db();
6974 let relay = MemoryRelay::new();
6975 let x = create_community(&relay, "Rejoin", vec!["wss://r".into()], None).await.unwrap();
6976 let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
6977 publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, 1).await;
6979 republish_community_list(&relay, Some(x.id())).await.unwrap();
6981 sync_community_list(&relay, &x.relays).await.unwrap();
6982 assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_some(), "a re-joined community is not torn down by a stale tombstone");
6983 }
6984
6985 #[tokio::test]
6986 async fn a_failed_remote_fetch_never_clobbers_the_published_list() {
6987 let (_tmp, _guard, _me) = init_test_db();
6990 let good = MemoryRelay::new();
6991 let community = create_community(&good, "Seeded", vec!["wss://r".into()], None).await.unwrap();
6992 assert!(fetch_community_list(&good, &community.relays).await.unwrap().is_some());
6993
6994 struct FetchErrors;
6996 #[async_trait::async_trait]
6997 impl Transport for FetchErrors {
6998 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
6999 async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
7000 panic!("republish must NOT publish when the remote fetch failed");
7001 }
7002 async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
7003 Ok(())
7004 }
7005 async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
7006 Err("relay unreachable".to_string())
7007 }
7008 }
7009 republish_community_list(&FetchErrors, Some(community.id())).await.unwrap();
7011 }
7012
7013 #[tokio::test]
7014 async fn a_granted_member_survives_a_refounding_even_with_no_guestbook_join() {
7015 let (_tmp, _guard, owner) = init_test_db();
7021 let relay = MemoryRelay::new();
7022 let community = create_community(&relay, "Backstop", vec!["wss://r".into()], None).await.unwrap();
7023
7024 let lurker = Keys::generate();
7026 let rid = "b1".repeat(32);
7027 publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
7028 publish_grant(&relay, &community, &owner, &lurker.public_key(), vec![rid.clone()], 1).await;
7029
7030 let members = memberlist(&relay, &community).await.unwrap();
7032 assert!(members.contains(&lurker.public_key()), "a granted member with no Join is still a member");
7033
7034 let banned_grantee = Keys::generate();
7036 publish_grant(&relay, &community, &owner, &banned_grantee.public_key(), vec![rid], 1).await;
7037 set_banlist(&relay, &community, &[banned_grantee.public_key().to_hex()]).await.unwrap();
7038 let members = memberlist(&relay, &community).await.unwrap();
7039 assert!(members.contains(&lurker.public_key()), "the honest grantee still counts");
7040 assert!(!members.contains(&banned_grantee.public_key()), "a banned grantee is not re-admitted by the union");
7041
7042 let refounded = refound_community(&relay, &community, &[]).await.unwrap();
7044 assert_eq!(refounded.root_epoch, Epoch(1));
7045 let base_group = base_rekey_group_key(&community.community_root, community.id(), Epoch(1));
7046 let chunks = fetch_rekey_chunks(&relay, &community.relays, &base_group).await.unwrap();
7047 let rotations = rekey::collect_rotations(&chunks);
7048 let lurker_x = lurker.public_key().to_bytes();
7049 let delivered = rotations.iter().any(|r| {
7050 rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &lurker_x, r.scope, r.new_epoch).is_some()
7051 });
7052 assert!(delivered, "the Refounding delivered the new root to the granted lurker");
7053 }
7054
7055 #[tokio::test]
7056 async fn the_memberlist_pages_past_a_guestbook_flood() {
7057 let (_tmp, _guard, owner) = init_test_db();
7061 let relay = MemoryRelay::new();
7062 let community = create_community(&relay, "GBFlood", vec!["wss://r".into()], None).await.unwrap();
7063 let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
7064
7065 let honest = Keys::generate();
7067 let join = guestbook::build_join_rumor(honest.public_key(), None, 1_000);
7068 let (w, _) = guestbook::seal_guestbook_rumor(&join, &gb, &honest, Timestamp::from_secs(1)).unwrap();
7069 relay.publish(&w, &community.relays).await.unwrap();
7070 for i in 0..600u64 {
7071 let throwaway = Keys::generate();
7072 let j = guestbook::build_join_rumor(throwaway.public_key(), None, 2_000 + i);
7073 let (w, _) = guestbook::seal_guestbook_rumor(&j, &gb, &throwaway, Timestamp::from_secs(2 + i)).unwrap();
7074 relay.publish(&w, &community.relays).await.unwrap();
7075 }
7076
7077 let members = memberlist(&relay, &community).await.unwrap();
7078 assert!(members.contains(&honest.public_key()), "the honest member's aged-out Join is still counted past the flood");
7079 }
7080
7081 #[tokio::test]
7082 async fn a_rekey_plane_flood_cannot_bury_a_genuine_rotation() {
7083 let (_tmp, _guard, owner) = init_test_db();
7088 let relay = MemoryRelay::new();
7089 let community = create_community(&relay, "Flooded", vec!["wss://r".into()], None).await.unwrap();
7090 let new_root = [0xD9; 32];
7091 let new_epoch = Epoch(1);
7092 let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
7093
7094 publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
7096
7097 let rogue = Keys::generate();
7100 let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
7101 for i in 0..260u64 {
7102 let blob = rekey::build_blob_local(rogue.secret_key(), &rogue.public_key().to_bytes(), &rogue.public_key(), RekeyScope::Root, new_epoch, &[0xEE; 32]).unwrap();
7103 let rumor = rekey::build_rekey_rumor(rogue.public_key(), RekeyScope::Root, new_epoch, Epoch(0), &prev_commit, &[blob], 1, 1, 3_000 + i).unwrap();
7104 let (wrap, _) = rekey::seal_rekey_chunk(&rumor, &group, &rogue, Timestamp::from_secs(3_000 + i)).unwrap();
7105 relay.publish(&wrap, &community.relays).await.unwrap();
7106 }
7107
7108 let session = SessionGuard::capture();
7109 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the genuine rotation is recovered past the flood");
7110 assert_eq!(updated.root_epoch, Epoch(1));
7111 assert_eq!(updated.community_root, new_root, "adopted the owner's root, not a junk one");
7112 }
7113
7114 #[tokio::test]
7115 async fn a_swap_during_create_private_channel_aborts_without_a_write() {
7116 let (bed, owner, _member) = TestBed::new();
7121 bed.swap_to(&owner);
7122 let community = create_community(&bed.relay, "SwapCreate", bed.relays.clone(), None).await.unwrap();
7123 let before = crate::db::community::load_community_v2(community.id()).unwrap().unwrap().channels.len();
7124
7125 let swap_relay = SwapMidFetch { inner: MemoryRelay::new() };
7127 let err = create_private_channel(&swap_relay, &community, "ghost").await.unwrap_err();
7128 assert!(err.contains("account changed"), "a swap mid-create aborts: {err}");
7129 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
7130 assert_eq!(after.channels.len(), before, "no channel row was written");
7131 assert!(!after.channels.iter().any(|c| c.name == "ghost"), "the ghost channel never persisted");
7132 }
7133
7134 #[tokio::test]
7135 async fn two_admins_racing_a_channel_rotation_converge_on_one_key() {
7136 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
7144 let (_tmp, _guard, owner) = init_test_db();
7145 let relay = MemoryRelay::new();
7146 let mut community = create_community(&relay, "Race", vec!["wss://r".into()], None).await.unwrap();
7147 let priv_id = ChannelId([0xC0; 32]);
7148 let key1 = [0xC1; 32];
7149 add_private_channel(&mut community, priv_id, key1, Epoch(1));
7150
7151 let (a, b) = (Keys::generate(), Keys::generate());
7153 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
7154 let role = Role::admin("ce".repeat(32));
7155 let roster = CommunityRoles {
7156 roles: vec![role.clone()],
7157 grants: [&a, &b].iter().map(|k| MemberGrant { member: k.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }).collect(),
7158 };
7159 crate::db::community::set_community_roles(&cid_hex, &roster, 1_000).unwrap();
7160
7161 let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
7164 let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
7165 let group = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
7166 let key_a = [0x0A; 32];
7167 let key_b = [0xFB; 32]; for (signer, k) in [(&a, &key_a), (&b, &key_b)] {
7169 let blob = rekey::build_blob_local(signer.secret_key(), &signer.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), k).unwrap();
7170 for e in rekey::build_rekey_chunks_local(signer, &group, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[blob], 2_000).unwrap() {
7171 relay.publish(&e, &community.relays).await.unwrap();
7172 }
7173 }
7174
7175 let session = SessionGuard::capture();
7176 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopts a winner");
7177 let adopted = updated.channel(&priv_id).unwrap().key.unwrap();
7178 assert_eq!(adopted, key_a, "converges on the lexicographically lowest key (deterministic across clients)");
7179
7180 let mut peer = community.clone();
7182 if let Some(c) = peer.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
7183 c.key = Some(key1);
7184 c.epoch = Epoch(1);
7185 }
7186 let updated2 = follow_rekeys(&relay, &peer, &session).await.unwrap().updated.expect("peer adopts");
7188 assert_eq!(updated2.channel(&priv_id).unwrap().key.unwrap(), key_a, "every follower lands on the identical key");
7189 }
7190
7191 #[tokio::test]
7192 async fn create_private_channel_refuses_a_member_without_manage_channels() {
7193 let (bed, owner, member) = TestBed::new();
7196 bed.swap_to(&owner);
7197 let community = create_community(&bed.relay, "Gate", bed.relays.clone(), None).await.unwrap();
7198 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
7199
7200 bed.swap_to(&member);
7201 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
7202 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
7203 let err = create_private_channel(&bed.relay, &joined, "sneaky").await.unwrap_err();
7204 assert!(err.contains("MANAGE_CHANNELS"), "refused with the permission it lacks: {err}");
7205 let err = create_public_channel(&bed.relay, &joined, "sneaky-too").await.unwrap_err();
7206 assert!(err.contains("MANAGE_CHANNELS"), "public creation gates identically: {err}");
7207 }
7208
7209 #[tokio::test]
7212 async fn accept_rejects_a_bundle_with_a_forged_community_root() {
7213 let (bed, owner, member) = TestBed::new();
7217 bed.swap_to(&owner);
7218 let community = create_community(&bed.relay, "Real", bed.relays.clone(), None).await.unwrap();
7219
7220 let fake = crate::simd::hex::bytes_to_hex_32(&[0xEE; 32]);
7221 let mut forged = bundle_of(&community, None, None, None);
7222 forged.community_root = fake.clone();
7223 for ch in &mut forged.channels {
7224 ch.key = fake.clone();
7225 }
7226 let attacker = Keys::generate();
7227 let wrap = invite::build_direct_invite(&attacker, &member.keys.public_key(), &forged).unwrap();
7228 bed.relay.publish(&wrap, &bed.relays).await.unwrap();
7229
7230 bed.swap_to(&member);
7231 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
7232 let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
7233 assert!(err.contains("could not verify"), "a forged root fails the owner-genesis check: {err}");
7234 assert!(
7235 crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
7236 "a rejected join persists nothing"
7237 );
7238 }
7239
7240 #[tokio::test]
7241 async fn follow_control_heals_a_bundle_misclassified_public_channel() {
7242 let (_tmp, _guard, _owner) = init_test_db();
7246 let relay = MemoryRelay::new();
7247 let community = create_community(&relay, "Heal", vec!["wss://r".into()], None).await.unwrap();
7248 let general = community.channels[0].id;
7249 let mut poisoned = community.clone();
7250 poisoned.channels[0].private = true;
7251 poisoned.channels[0].key = Some([0x66; 32]);
7252 crate::db::community::save_community_v2(&poisoned).unwrap();
7253
7254 let session = SessionGuard::capture();
7255 let healed = follow_control(&relay, &poisoned, &session).await.unwrap().expect("healed");
7256 let ch = healed.channel(&general).unwrap();
7257 assert!(!ch.private, "the owner's public declaration overrides the bundle");
7258 assert_eq!(ch.key, None, "a healed public channel derives from the root");
7259 }
7260
7261 #[tokio::test]
7262 async fn a_deleted_channel_does_not_resurrect_on_reload() {
7263 let (_tmp, _guard, owner) = init_test_db();
7266 let relay = MemoryRelay::new();
7267 let community = create_community(&relay, "Prune", vec!["wss://r".into()], None).await.unwrap();
7268 let extra = ChannelId([0x77; 32]);
7269 let session = SessionGuard::capture();
7270 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
7271 let with_extra = follow_control(&relay, &community, &session).await.unwrap().unwrap();
7272 assert!(with_extra.channel(&extra).is_some());
7273 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
7274 let after = follow_control(&relay, &with_extra, &session).await.unwrap().unwrap();
7275 assert!(after.channel(&extra).is_none());
7276
7277 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
7278 assert!(reloaded.channel(&extra).is_none(), "a deleted channel must not resurrect on reload");
7279 assert_eq!(reloaded.channels.len(), 1);
7280 }
7281
7282 #[tokio::test]
7283 async fn a_channel_owned_by_another_community_is_skipped_not_clobbered() {
7284 let (_tmp, _guard, _owner) = init_test_db();
7288 let relay = MemoryRelay::new();
7289 let a = create_community(&relay, "A", vec!["wss://r".into()], None).await.unwrap();
7290 let a_channel = a.channels[0].id;
7291 let mut b = create_community(&relay, "B", vec!["wss://r".into()], None).await.unwrap();
7292 let b_channel = b.channels[0].id;
7293 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() });
7295
7296 crate::db::community::save_community_v2(&b).expect("save succeeds, the phantom is skipped");
7297 let a_reloaded = crate::db::community::load_community_v2(a.id()).unwrap().unwrap();
7299 assert!(!a_reloaded.channels.iter().any(|c| c.private), "A's channel is untouched");
7300 assert_eq!(a_reloaded.channels[0].id.0, a_channel.0);
7301 let b_reloaded = crate::db::community::load_community_v2(b.id()).unwrap().unwrap();
7303 assert!(b_reloaded.channel(&b_channel).is_some(), "B's own channel persists");
7304 assert!(b_reloaded.channel(&a_channel).is_none(), "the foreign-owned channel is skipped, not stolen");
7305 }
7306
7307 struct CappedRelay {
7313 events: Vec<Event>,
7314 cap: usize,
7315 }
7316 #[async_trait::async_trait]
7317 impl Transport for CappedRelay {
7318 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
7319 async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
7320 Ok(())
7321 }
7322 async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
7323 Ok(())
7324 }
7325 async fn fetch(&self, q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
7326 let mut m: Vec<Event> = self
7327 .events
7328 .iter()
7329 .filter(|e| q.authors.is_empty() || q.authors.contains(&e.pubkey.to_hex()))
7330 .filter(|e| q.until.is_none_or(|u| e.created_at.as_secs() <= u))
7331 .cloned()
7332 .collect();
7333 m.sort_by(|a, b| b.created_at.cmp(&a.created_at)); m.truncate(self.cap.min(q.limit.unwrap_or(usize::MAX)));
7335 Ok(m)
7336 }
7337 }
7338
7339 #[tokio::test]
7340 async fn verify_pages_a_capped_relay_past_a_flood_to_the_genesis() {
7341 let (_tmp, _guard, owner) = init_test_db();
7346 let meta = control::CommunityMetadata { name: "Capped".into(), relays: vec!["wss://r".into()], ..Default::default() };
7347 let g = control::genesis(&owner, meta, 1_000).unwrap();
7348 let community = CommunityV2::from_genesis(&g, "Capped", None, vec!["wss://r".into()], 1_000);
7349
7350 let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
7351 let rogue = Keys::generate();
7352 let mut events: Vec<Event> = g.wraps.to_vec();
7353 for i in 0..250u64 {
7354 let rumor = control::build_edition_rumor(rogue.public_key(), vsk::CHANNEL_METADATA, &[0xAB; 32], 1, None, "{\"name\":\"junk\",\"private\":false}", 1_001 + i, None);
7355 let (wrap, _) = control::seal_control_edition(&rumor, &control, &rogue, Timestamp::from_secs(1_001 + i)).unwrap();
7356 events.push(wrap);
7357 }
7358 let relay = CappedRelay { events, cap: 100 };
7360 let verified = verify_owner_root_and_reconcile(&relay, community.clone()).await;
7361 assert!(verified.is_ok(), "the until-walk pages a capped relay past the flood to the genesis: {:?}", verified.err());
7362 }
7363
7364 #[tokio::test]
7365 async fn accept_parked_invite_joins_from_the_stored_bundle() {
7366 let (bed, owner, member) = TestBed::new();
7369 bed.swap_to(&owner);
7370 let community = create_community(&bed.relay, "Parked", bed.relays.clone(), None).await.unwrap();
7371 let general = community.channels[0].id;
7372 send_message(&bed.relay, &community, &general, "owner: hi").await.unwrap();
7373 let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
7374 let bundle_json = serde_json::to_string(&bundle).unwrap();
7375 let inviter_hex = owner.keys.public_key().to_hex();
7376
7377 bed.swap_to(&member);
7378 let joined = accept_parked_invite(&bed.relay, &bundle_json, Some(&inviter_hex)).await.unwrap();
7379 assert_eq!(joined.id().0, community.id().0, "joined the community from the parked bundle");
7380 assert!(joined.identity.verify());
7381 assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: hi"]);
7382 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
7385 assert!(
7386 crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().is_some(),
7387 "the joiner's control floor is seeded from the join-time fold"
7388 );
7389
7390 bed.swap_to(&owner);
7392 let members = memberlist(&bed.relay, &community).await.unwrap();
7393 assert!(members.contains(&member.keys.public_key()), "the parked-invite joiner is a member");
7394 }
7395
7396 #[tokio::test]
7397 async fn accept_parked_invite_rejects_a_forged_root() {
7398 let (_tmp, _guard, _owner) = init_test_db();
7402 let relay = MemoryRelay::new();
7403 let community = create_community(&relay, "Real", vec!["wss://r".into()], None).await.unwrap();
7404 let mut forged = bundle_of(&community, None, None, None);
7405 let fake = crate::simd::hex::bytes_to_hex_32(&[0xEE; 32]);
7406 forged.community_root = fake.clone();
7407 for ch in &mut forged.channels {
7408 ch.key = fake.clone();
7409 }
7410 let bundle_json = serde_json::to_string(&forged).unwrap();
7411
7412 let err = accept_parked_invite(&relay, &bundle_json, None).await.unwrap_err();
7413 assert!(err.contains("could not verify"), "a forged-root parked bundle fails definitively: {err}");
7414 }
7415
7416 #[test]
7417 fn v2_and_v1_bundles_are_distinguishable_by_parse() {
7418 let owner = Keys::generate();
7422 let identity = super::super::control::CommunityIdentity::mint(&owner.public_key());
7423 let hex = crate::simd::hex::bytes_to_hex_32;
7424 let v2 = invite::CommunityInvite {
7425 community_id: hex(&identity.community_id.0),
7426 owner: hex(&identity.owner_xonly),
7427 owner_salt: hex(&identity.owner_salt),
7428 community_root: hex(&[0x11; 32]),
7429 root_epoch: 0,
7430 channels: vec![],
7431 relays: vec!["wss://r".into()],
7432 name: "V2".into(),
7433 icon: None,
7434 expires_at: None,
7435 creator_npub: None,
7436 label: None,
7437 extra: Default::default(),
7438 };
7439 let v2_json = serde_json::to_string(&v2).unwrap();
7440 assert!(invite::CommunityInvite::from_bundle_json(&v2_json).is_ok(), "a real v2 bundle parses");
7441 let v1_like = r#"{"community_id":"aa","name":"X","relays":[]}"#;
7442 assert!(invite::CommunityInvite::from_bundle_json(v1_like).is_err(), "a v1 bundle is not a v2 bundle");
7443 }
7444
7445 #[tokio::test]
7446 async fn verify_rejects_a_cross_community_owner_edition_replay() {
7447 let (_tmp, _guard, owner) = init_test_db();
7453
7454 let gx = control::genesis(&owner, control::CommunityMetadata { name: "X".into(), ..Default::default() }, 1_000).unwrap();
7456 let x_control = control_group_key(&gx.community_root, &gx.identity.community_id, Epoch(0));
7457 let (_ed, opened) = control::open_control_edition(&gx.wraps[0], &x_control).unwrap();
7458
7459 let t_identity = control::CommunityIdentity::mint(&owner.public_key());
7461 let fake_root = [0xEE; 32];
7462 let t = CommunityV2 {
7463 identity: t_identity,
7464 community_root: fake_root,
7465 root_epoch: Epoch(0),
7466 name: "T".into(),
7467 description: None,
7468 icon: None,
7469 banner: None,
7470 meta_custom: None,
7471 meta_extra: Default::default(),
7472 relays: vec!["wss://r".into()],
7473 channels: vec![],
7474 dissolved: false,
7475 created_at_ms: 0,
7476 };
7477 let t_control = control_group_key(&fake_root, t.id(), t.root_epoch);
7480 let (replayed, _) = stream::rewrap_seal(&opened.seal, &t_control, Timestamp::from_secs(1_000)).unwrap();
7481 let relay = MemoryRelay::new();
7482 relay.publish(&replayed, &t.relays).await.unwrap();
7483
7484 let verified = verify_owner_root_and_reconcile(&relay, t.clone()).await;
7485 assert!(verified.is_err(), "a cross-community owner-edition replay must not authenticate a forged root");
7486 }
7487
7488 #[tokio::test]
7496 #[ignore = "hits a real relay over the network"]
7497 async fn live_smoke_create_send_fetch_on_a_real_relay() {
7498 use crate::community::transport::LiveTransport;
7499 use nostr_sdk::prelude::ToBech32;
7500
7501 let relay = std::env::var("VECTOR_SMOKE_RELAY").unwrap_or_else(|_| "wss://jskitty.com/nostr".to_string());
7502 let relays = vec![relay.clone()];
7503
7504 let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
7506 crate::db::close_database();
7507 crate::db::clear_id_caches();
7508 let tmp = tempfile::tempdir().unwrap();
7509 let keys = match std::env::var("VECTOR_SMOKE_NSEC") {
7512 Ok(n) => Keys::parse(&n).expect("VECTOR_SMOKE_NSEC is not a valid nsec"),
7513 Err(_) => Keys::generate(),
7514 };
7515 let npub = keys.public_key().to_bech32().unwrap();
7516 if std::env::var("VECTOR_SMOKE_PRINT_NSEC").is_ok() {
7519 println!("[smoke] OWNER nsec (throwaway — do NOT reuse): {}", keys.secret_key().to_bech32().unwrap());
7520 }
7521 std::fs::create_dir_all(tmp.path().join(&npub)).unwrap();
7522 crate::db::set_app_data_dir(tmp.path().to_path_buf());
7523 crate::db::set_current_account(npub.clone()).unwrap();
7524 crate::db::init_database(&npub).unwrap();
7525 crate::state::MY_SECRET_KEY.store_from_keys(&keys, &[]);
7526 crate::state::set_my_public_key(keys.public_key());
7527 println!("[smoke] throwaway identity {npub}");
7528
7529 let client = nostr_sdk::ClientBuilder::new().signer(keys.clone()).build();
7531 client.pool().add_relay(relay.as_str(), nostr_sdk::RelayOptions::default()).await.ok();
7532 client.connect().await;
7533 crate::state::set_nostr_client(client);
7534 let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(15));
7535
7536 let community = create_community(&transport, "V2 Live Smoke", relays.clone(), None).await.expect("create");
7538 let general = community.channels[0].id;
7539 println!("[smoke] created community {} on {relay}", crate::simd::hex::bytes_to_hex_32(&community.id().0));
7540
7541 let text = "hello from a Vector Concord v2 live smoke test";
7542 let sent_id = send_message(&transport, &community, &general, text).await.expect("send");
7543 println!("[smoke] sent message {sent_id}");
7544
7545 tokio::time::sleep(std::time::Duration::from_secs(3)).await;
7547
7548 let page = fetch_channel(&transport, &community, &general, 50).await.expect("fetch");
7549 let texts: Vec<String> = page
7550 .iter()
7551 .filter_map(|f| match &f.event {
7552 ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
7553 _ => None,
7554 })
7555 .collect();
7556 println!("[smoke] fetched {} message(s) back: {texts:?}", texts.len());
7557 assert!(texts.contains(&text.to_string()), "the message did not round-trip through the real relay");
7558
7559 let link = mint_public_link(&transport, &community, "https://vectorapp.io", None, None).await.expect("mint link");
7561 println!("[smoke] invite link: {}", link.url);
7562 println!("[smoke] PASS — v2 create+send+fetch+invite round-tripped on {relay}");
7563 }
7564
7565 #[tokio::test]
7566 async fn chat_ops_react_edit_delete_round_trip() {
7567 let (bed, owner, _member) = TestBed::new();
7568 bed.swap_to(&owner);
7569 let community = create_community(&bed.relay, "Ops", bed.relays.clone(), None).await.unwrap();
7570 let general = community.channels[0].id;
7571 let me_hex = owner.keys.public_key().to_hex();
7572
7573 let msg_id = send_message(&bed.relay, &community, &general, "original").await.unwrap();
7574 send_reaction(&bed.relay, &community, &general, &msg_id, &me_hex, super::super::kind::MESSAGE, ":fire:", Some(("fire", "https://e/f.png")))
7575 .await
7576 .unwrap();
7577 send_edit(&bed.relay, &community, &general, &msg_id, "edited").await.unwrap();
7578 send_delete(&bed.relay, &community, &general, &msg_id, super::super::kind::MESSAGE).await.unwrap();
7579
7580 let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
7581 let target = crate::simd::hex::hex_to_bytes_32(&msg_id);
7582 let mut saw = (false, false, false);
7583 for f in &page {
7584 match &f.event {
7585 ChatEvent::Reaction { target: t, emoji, emoji_url, .. } if *t == target => {
7586 assert_eq!(emoji, ":fire:");
7587 assert_eq!(emoji_url.as_deref(), Some("https://e/f.png"));
7588 saw.0 = true;
7589 }
7590 ChatEvent::Edit { target: t, new_content, .. } if *t == target => {
7591 assert_eq!(new_content, "edited");
7592 saw.1 = true;
7593 }
7594 ChatEvent::Delete { target: t, .. } if *t == target => saw.2 = true,
7595 _ => {}
7596 }
7597 }
7598 assert!(saw.0 && saw.1 && saw.2, "reaction/edit/delete all round-trip: {saw:?}");
7599 }
7600
7601 #[tokio::test]
7602 async fn a_typing_signal_rides_the_ephemeral_wrap_and_is_never_stored() {
7603 let (bed, owner, _member) = TestBed::new();
7604 bed.swap_to(&owner);
7605 let community = create_community(&bed.relay, "Typ", bed.relays.clone(), None).await.unwrap();
7606 let general = community.channels[0].id;
7607 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
7608
7609 let mut sub = bed.relay.subscribe(Query {
7611 kinds: vec![stream::KIND_WRAP_EPHEMERAL],
7612 authors: vec![group.pk_hex()],
7613 ..Default::default()
7614 });
7615 send_typing(&bed.relay, &community, &general).await.unwrap();
7616 let wrap = sub.try_recv().expect("the typing wrap streams to a live subscriber");
7617 let opened = match chat::open_chat_event(&wrap, &group, &general, community.root_epoch) {
7618 Ok(ChatEvent::Typing { opened }) => opened,
7619 other => panic!("the ephemeral wrap must open as a Typing event, got {other:?}"),
7620 };
7621
7622 let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
7625 assert!(page.iter().all(|f| !matches!(f.event, ChatEvent::Typing { .. })));
7626
7627 assert!(
7629 crate::db::community::get_message_key(&opened.rumor_id.to_hex()).unwrap().is_none(),
7630 "ephemeral sends must not retain scrub keys"
7631 );
7632 }
7633
7634 #[tokio::test]
7635 async fn a_durable_send_retains_the_wrap_scrub_key_and_full_delete_nukes_the_relay_copy() {
7636 let (bed, owner, _member) = TestBed::new();
7637 bed.swap_to(&owner);
7638 let community = create_community(&bed.relay, "Nuke", bed.relays.clone(), None).await.unwrap();
7639 let general = community.channels[0].id;
7640 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
7641
7642 let id = send_message(&bed.relay, &community, &general, "scrub me").await.unwrap();
7643
7644 let (keys, outer_hex, relays) =
7647 crate::db::community::get_message_key(&id).unwrap().expect("a durable send retains its scrub key");
7648 assert_eq!(relays, community.relays);
7649 let wrap_query = Query {
7650 kinds: vec![stream::KIND_WRAP],
7651 authors: vec![group.pk_hex()],
7652 ..Default::default()
7653 };
7654 let wraps = bed.relay.fetch(&wrap_query, &community.relays).await.unwrap();
7655 let wrap = wraps.iter().find(|w| w.id.to_hex() == outer_hex).expect("retained outer id is the published wrap");
7656 assert_eq!(keys.public_key(), wrap.pubkey, "retained key is the wrap's author");
7657
7658 let me_hex = owner.keys.public_key().to_hex();
7660 let rid = send_reaction(&bed.relay, &community, &general, &id, &me_hex, super::super::kind::MESSAGE, "🔥", None)
7661 .await
7662 .unwrap();
7663 assert!(crate::db::community::get_message_key(&rid).unwrap().is_some(), "reaction sends retain too");
7664
7665 crate::community::service::delete_message(&bed.relay, &id).await.unwrap();
7668 assert!(crate::db::community::get_message_key(&id).unwrap().is_none(), "key consumed after the scrub");
7669 let after = bed.relay.fetch(&wrap_query, &community.relays).await.unwrap();
7670 assert!(!after.iter().any(|w| w.id.to_hex() == outer_hex), "wrap scrubbed from the relay");
7671 }
7672
7673 #[tokio::test]
7674 async fn backfill_heals_scrub_keys_for_own_pre_retention_messages_only() {
7675 let (bed, owner, _member) = TestBed::new();
7676 bed.swap_to(&owner);
7677 let community = create_community(&bed.relay, "Heal", bed.relays.clone(), None).await.unwrap();
7678 let general = community.channels[0].id;
7679 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
7680
7681 let id = send_message(&bed.relay, &community, &general, "old send").await.unwrap();
7684 crate::db::community::delete_message_key(&id).unwrap();
7685 assert!(crate::db::community::get_message_key(&id).unwrap().is_none());
7686
7687 let mkeys = Keys::generate();
7689 let rumor = chat::build_message_rumor(mkeys.public_key(), &general, community.root_epoch, "foreign", None, &[], vec![], 6_000);
7690 let foreign_id = rumor.id.unwrap().to_hex();
7691 let (fw, _) = chat::seal_chat_rumor(&rumor, &group, &mkeys, Timestamp::from_secs(6), false).unwrap();
7692 bed.relay.publish(&fw, &community.relays).await.unwrap();
7693
7694 fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
7696 let (keys, _outer, relays) =
7697 crate::db::community::get_message_key(&id).unwrap().expect("backfill heals own unretained rows");
7698 assert_eq!(keys.public_key(), group.pk(), "healed key is the wrap's signing key");
7699 assert_eq!(relays, community.relays);
7700
7701 assert!(crate::db::community::get_message_key(&foreign_id).unwrap().is_none());
7703
7704 crate::community::service::delete_message(&bed.relay, &id).await.unwrap();
7706 let left = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
7707 assert!(
7708 !left.iter().any(|f| f.event.opened().rumor_id.to_hex() == id),
7709 "healed message scrubbed from the relay"
7710 );
7711 }
7712
7713 #[tokio::test]
7714 async fn send_chat_message_threads_the_reply_and_extra_tags() {
7715 let (bed, owner, _member) = TestBed::new();
7716 bed.swap_to(&owner);
7717 let community = create_community(&bed.relay, "Re", bed.relays.clone(), None).await.unwrap();
7718 let general = community.channels[0].id;
7719 let me_hex = owner.keys.public_key().to_hex();
7720
7721 let parent_id = send_message(&bed.relay, &community, &general, "parent").await.unwrap();
7722 let imeta = nostr_sdk::prelude::Tag::custom(
7723 nostr_sdk::prelude::TagKind::custom("imeta"),
7724 ["url https://e/blob".to_string(), "m image/png".to_string()],
7725 );
7726 let child_id = send_chat_message(
7727 &bed.relay, &community, &general, "child",
7728 Some((parent_id.as_str(), me_hex.as_str())), &[], vec![imeta],
7729 )
7730 .await
7731 .unwrap();
7732
7733 let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
7734 let child = page
7735 .iter()
7736 .find_map(|f| match &f.event {
7737 ChatEvent::Message { opened, reply_to, .. } if opened.rumor_id.to_hex() == child_id => Some((opened, reply_to)),
7738 _ => None,
7739 })
7740 .expect("the reply message round-trips");
7741 let reply = child.1.as_ref().expect("the reply reference is carried");
7742 assert_eq!(crate::simd::hex::bytes_to_hex_32(&reply.id), parent_id);
7743 assert_eq!(reply.author, Some(owner.keys.public_key()));
7744 assert!(
7745 child.0.rumor.tags.iter().any(|t| t.kind() == nostr_sdk::prelude::TagKind::custom("imeta")),
7746 "the imeta attachment tag rides the rumor verbatim"
7747 );
7748 }
7749
7750 #[tokio::test]
7751 async fn a_kick_needs_kick_authority_and_removes_the_target() {
7752 let (bed, owner, member) = TestBed::new();
7753 bed.swap_to(&owner);
7754 let community = create_community(&bed.relay, "Kick", bed.relays.clone(), None).await.unwrap();
7755
7756 let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
7758 let join = guestbook::build_join_rumor(member.keys.public_key(), None, 2_000);
7759 let (wrap, _) = guestbook::seal_guestbook_rumor(&join, &gb, &member.keys, Timestamp::from_secs(2)).unwrap();
7760 bed.relay.publish(&wrap, &bed.relays).await.unwrap();
7761 let before = memberlist(&bed.relay, &community).await.unwrap();
7762 assert!(before.contains(&member.keys.public_key()), "the join lands first");
7763
7764 bed.swap_to(&member);
7766 let err = kick_member(&bed.relay, &community, &owner.keys.public_key()).await.unwrap_err();
7767 assert!(err.contains("not authorized"), "unprivileged kick refused: {err}");
7768
7769 bed.swap_to(&owner);
7771 kick_member(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
7772 let after = memberlist(&bed.relay, &community).await.unwrap();
7773 assert!(!after.contains(&member.keys.public_key()), "the kicked member leaves the fold");
7774 assert!(after.contains(&owner.keys.public_key()), "the owner remains");
7775 }
7776
7777 #[tokio::test]
7778 async fn grant_admin_mints_one_deterministic_role_and_revoke_strips_it() {
7779 let (bed, owner, member) = TestBed::new();
7780 bed.swap_to(&owner);
7781 let community = create_community(&bed.relay, "Adm", bed.relays.clone(), None).await.unwrap();
7782 let member_pk = member.keys.public_key();
7783 let member_hex = member_pk.to_hex();
7784 let owner_hex = owner.keys.public_key().to_hex();
7785
7786 grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
7787 let view = fetch_authority(&bed.relay, &community).await;
7788 assert!(view.roles.is_admin(&member_hex), "the grant folds as admin");
7789 assert!(view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::MANAGE_ROLES));
7790
7791 let second = Keys::generate().public_key();
7794 grant_admin(&bed.relay, &community, &second).await.unwrap();
7795 grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
7796 let view = fetch_authority(&bed.relay, &community).await;
7797 assert_eq!(view.roles.roles.len(), 1, "one Admin role, never a fork");
7798 assert!(view.roles.is_admin(&member_hex) && view.roles.is_admin(&second.to_hex()));
7799 let grant = view.roles.grants.iter().find(|g| g.member == member_hex).unwrap();
7800 assert_eq!(grant.role_ids.len(), 1, "no duplicate role id in the grant");
7801
7802 revoke_admin(&bed.relay, &community, &member_pk).await.unwrap();
7804 let view = fetch_authority(&bed.relay, &community).await;
7805 assert!(!view.roles.is_admin(&member_hex), "revoked");
7806 assert!(view.roles.is_admin(&second.to_hex()), "the other admin is untouched");
7807 assert!(!view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::KICK));
7808 }
7809
7810 #[tokio::test]
7811 async fn follow_control_persists_the_roster_for_sync_local_reads() {
7812 let (bed, owner, member) = TestBed::new();
7813 bed.swap_to(&owner);
7814 let community = create_community(&bed.relay, "Persist", bed.relays.clone(), None).await.unwrap();
7815 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
7816 let member_hex = member.keys.public_key().to_hex();
7817 grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
7818
7819 let session = crate::state::SessionGuard::capture();
7821 follow_control(&bed.relay, &community, &session).await.unwrap();
7822 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
7823 assert!(roster.is_admin(&member_hex), "the persisted roster reads back without a fetch");
7824
7825 let withholding = MemoryRelay::new();
7828 let _ = follow_control(&withholding, &community, &session).await;
7829 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
7830 assert!(roster.is_admin(&member_hex), "withholding never shrinks standing");
7831
7832 revoke_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
7834 follow_control(&bed.relay, &community, &session).await.unwrap();
7835 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
7836 assert!(!roster.is_admin(&member_hex), "the revoke folds + persists");
7837 }
7838
7839 #[tokio::test]
7840 async fn grant_admin_is_refused_for_a_non_owner_and_publishes_nothing() {
7841 let (bed, owner, member) = TestBed::new();
7842 bed.swap_to(&owner);
7843 let community = create_community(&bed.relay, "NoSquat", bed.relays.clone(), None).await.unwrap();
7844
7845 bed.swap_to(&member);
7846 let err = grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap_err();
7847 assert!(err.contains("owner"), "refused before any publish: {err}");
7848
7849 bed.swap_to(&owner);
7852 let view = fetch_authority(&bed.relay, &community).await;
7853 assert!(view.roles.roles.is_empty(), "no role edition landed");
7854 grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
7855 let view = fetch_authority(&bed.relay, &community).await;
7856 assert!(view.roles.is_admin(&member.keys.public_key().to_hex()));
7857 }
7858
7859 #[tokio::test]
7860 async fn grant_admin_merges_other_roles_and_refuses_a_withheld_grant() {
7861 let (bed, owner, member) = TestBed::new();
7862 bed.swap_to(&owner);
7863 let community = create_community(&bed.relay, "Merge", bed.relays.clone(), None).await.unwrap();
7864 let member_pk = member.keys.public_key();
7865
7866 let mod_rid = crate::simd::hex::bytes_to_hex_32(&[0x66; 32]);
7869 set_role(&bed.relay, &community, &admin_role(&mod_rid, Permissions::BAN)).await.unwrap();
7870 grant_roles(&bed.relay, &community, &member_pk, vec![mod_rid.clone()]).await.unwrap();
7871
7872 let withholding = MemoryRelay::new();
7875 let err = grant_admin(&withholding, &community, &member_pk).await.unwrap_err();
7876 assert!(err.contains("could not be fetched"), "withheld grant refused: {err}");
7877
7878 grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
7880 let view = fetch_authority(&bed.relay, &community).await;
7881 let grant = view.roles.grants.iter().find(|g| g.member == member_pk.to_hex()).unwrap();
7882 assert_eq!(grant.role_ids.len(), 2, "admin ADDED to the existing grant, not replacing it");
7883 assert!(grant.role_ids.contains(&mod_rid));
7884 }
7885
7886 #[tokio::test]
7887 async fn fetch_authority_reflects_a_granted_admin() {
7888 let (bed, owner, member) = TestBed::new();
7889 bed.swap_to(&owner);
7890 let community = create_community(&bed.relay, "Auth", bed.relays.clone(), None).await.unwrap();
7891 let rid = crate::simd::hex::bytes_to_hex_32(&[0x5a; 32]);
7892 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
7893 publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
7894
7895 let view = fetch_authority(&bed.relay, &community).await;
7896 let member_hex = member.keys.public_key().to_hex();
7897 assert!(view.roles.is_admin(&member_hex), "the granted member folds as admin");
7898 assert!(
7899 view.roles.is_authorized(&member_hex, Some(&owner.keys.public_key().to_hex()), Permissions::KICK),
7900 "an ADMIN_ALL grant carries KICK"
7901 );
7902 assert!(view.banned.is_empty());
7903 }
7904}