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 {
92 transport.publish(wrap, &community.relays).await?;
93 }
94
95 let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
97 let join_rumor = guestbook::build_join_rumor(owner.public_key(), None, at_ms);
98 if let Ok((join_wrap, _)) = guestbook::seal_guestbook_rumor(&join_rumor, &gb_group, &owner, Timestamp::from_secs(at_ms / 1000)) {
99 let _ = transport.publish(&join_wrap, &community.relays).await;
100 }
101
102 let _ = republish_community_list(transport, Some(community.id())).await;
104 Ok(community)
105}
106
107pub async fn send_message<T: Transport + ?Sized>(
111 transport: &T,
112 community: &CommunityV2,
113 channel_id: &ChannelId,
114 content: &str,
115) -> Result<String, String> {
116 send_chat_message(transport, community, channel_id, content, None, &[], vec![]).await
117}
118
119pub async fn send_chat_message<T: Transport + ?Sized>(
123 transport: &T,
124 community: &CommunityV2,
125 channel_id: &ChannelId,
126 content: &str,
127 reply_to: Option<(&str, &str)>,
128 emoji: &[(&str, &str)],
129 extra_tags: Vec<nostr_sdk::prelude::Tag>,
130) -> Result<String, String> {
131 let (author, group, epoch, session) = chat_send_context(community, channel_id)?;
132 let at_ms = now_ms();
133 let rumor = chat::build_message_rumor(author.public_key(), channel_id, epoch, content, reply_to, emoji, extra_tags, at_ms);
134 publish_chat(transport, community, &session, &group, &author, channel_id, epoch, rumor, at_ms, false).await
135}
136
137#[allow(clippy::too_many_arguments)]
142pub async fn send_reaction<T: Transport + ?Sized>(
143 transport: &T,
144 community: &CommunityV2,
145 channel_id: &ChannelId,
146 target_id_hex: &str,
147 target_author_hex: &str,
148 target_kind: u16,
149 emoji_content: &str,
150 emoji: Option<(&str, &str)>,
151) -> Result<String, String> {
152 let (author, group, epoch, session) = chat_send_context(community, channel_id)?;
153 let at_ms = now_ms();
154 let rumor =
155 chat::build_reaction_rumor(author.public_key(), channel_id, epoch, target_id_hex, target_author_hex, target_kind, emoji_content, emoji, at_ms);
156 publish_chat(transport, community, &session, &group, &author, channel_id, epoch, rumor, at_ms, false).await
157}
158
159pub async fn send_edit<T: Transport + ?Sized>(
163 transport: &T,
164 community: &CommunityV2,
165 channel_id: &ChannelId,
166 target_id_hex: &str,
167 new_content: &str,
168) -> Result<String, String> {
169 let (author, group, epoch, session) = chat_send_context(community, channel_id)?;
170 let at_ms = now_ms();
171 let rumor = chat::build_edit_rumor(author.public_key(), channel_id, epoch, target_id_hex, new_content, at_ms);
172 publish_chat(transport, community, &session, &group, &author, channel_id, epoch, rumor, at_ms, false).await
173}
174
175pub async fn send_delete<T: Transport + ?Sized>(
179 transport: &T,
180 community: &CommunityV2,
181 channel_id: &ChannelId,
182 target_id_hex: &str,
183 target_kind: u16,
184) -> Result<String, String> {
185 let (author, group, epoch, session) = chat_send_context(community, channel_id)?;
186 let at_ms = now_ms();
187 let rumor = chat::build_delete_rumor(author.public_key(), channel_id, epoch, target_id_hex, target_kind, at_ms);
188 publish_chat(transport, community, &session, &group, &author, channel_id, epoch, rumor, at_ms, false).await
189}
190
191pub async fn send_typing<T: Transport + ?Sized>(
193 transport: &T,
194 community: &CommunityV2,
195 channel_id: &ChannelId,
196) -> Result<(), String> {
197 let (author, group, epoch, session) = chat_send_context(community, channel_id)?;
198 let at_ms = now_ms();
199 let rumor = chat::build_typing_rumor(author.public_key(), channel_id, epoch, at_ms);
200 publish_chat(transport, community, &session, &group, &author, channel_id, epoch, rumor, at_ms, true).await.map(|_| ())
201}
202
203fn chat_send_context(community: &CommunityV2, channel_id: &ChannelId) -> Result<(Keys, GroupKey, Epoch, SessionGuard), String> {
209 let session = SessionGuard::capture();
210 let author = local_keys()?;
211 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
212 if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
213 return Err("this community has been dissolved".to_string());
214 }
215 if crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default().contains(&author.public_key().to_hex()) {
219 return Err("you are banned from this community".to_string());
220 }
221 let ch = community.channel(channel_id).ok_or("no such channel in this community")?;
222 if ch.private && ch.key.is_none() {
223 return Err("this private channel has no key yet (awaiting rekey delivery)".to_string());
224 }
225 let (secret, epoch) = community.channel_secret(ch);
226 Ok((author, channel_group_key(&secret, channel_id, epoch), epoch, session))
227}
228
229#[allow(clippy::too_many_arguments)]
232async fn publish_chat<T: Transport + ?Sized>(
233 transport: &T,
234 community: &CommunityV2,
235 session: &SessionGuard,
236 group: &GroupKey,
237 author: &Keys,
238 channel_id: &ChannelId,
239 epoch: Epoch,
240 rumor: nostr_sdk::prelude::UnsignedEvent,
241 at_ms: u64,
242 ephemeral: bool,
243) -> Result<String, String> {
244 let rumor_id = rumor.id.ok_or("rumor has no id")?.to_hex();
245 let (wrap, _ephemeral_keys) = chat::seal_chat_rumor(&rumor, group, author, Timestamp::from_secs(at_ms / 1000), ephemeral)
246 .map_err(|e| e.to_string())?;
247 if !session.is_valid() {
248 return Err("account changed before send".to_string());
249 }
250 transport.publish(&wrap, &community.relays).await?;
251 if !ephemeral {
256 if let Ok(event) = chat::open_chat_event(&wrap, group, channel_id, epoch) {
257 let channel_hex = crate::simd::hex::bytes_to_hex_32(&channel_id.0);
258 let outcome = {
259 let mut st = crate::state::STATE.lock().await;
260 if !session.is_valid() {
261 return Ok(rumor_id); }
263 super::inbound::apply_chat_to_state(&mut st, &event, &channel_hex, &author.public_key())
264 };
265 if let Some(outcome) = outcome {
266 if !session.is_valid() {
267 return Ok(rumor_id);
268 }
269 super::inbound::persist_chat(&channel_hex, &outcome).await;
270 }
271 }
272 }
273 Ok(rumor_id)
274}
275
276pub struct FetchedEvent {
279 pub event: ChatEvent,
280 pub epoch: Epoch,
281}
282
283pub async fn fetch_channel<T: Transport + ?Sized>(
287 transport: &T,
288 community: &CommunityV2,
289 channel_id: &ChannelId,
290 limit: usize,
291) -> Result<Vec<FetchedEvent>, String> {
292 fetch_channel_history(transport, community, channel_id, limit, 1, |_| true).await
293}
294
295pub async fn fetch_channel_history<T: Transport + ?Sized>(
309 transport: &T,
310 community: &CommunityV2,
311 channel_id: &ChannelId,
312 page: usize,
313 max_pages: usize,
314 mut keep_paging: impl FnMut(&[FetchedEvent]) -> bool,
315) -> Result<Vec<FetchedEvent>, String> {
316 let ch = community.channel(channel_id).ok_or("no such channel in this community")?;
317 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
322 let coords: Vec<([u8; 32], Epoch)> = if ch.private {
323 let Some(current) = ch.key else {
324 return Ok(Vec::new());
325 };
326 let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
327 let mut held = crate::db::community::held_epoch_keys(&cid_hex, &ch_hex).unwrap_or_default();
328 if !held.iter().any(|(ep, _)| *ep == ch.epoch) {
329 held.push((ch.epoch, current));
330 }
331 held.into_iter().filter(|(_, k)| *k != community.community_root).map(|(ep, k)| (k, ep)).collect()
334 } else {
335 let mut roots = crate::db::community::held_epoch_keys(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap_or_default();
336 if !roots.iter().any(|(ep, _)| *ep == community.root_epoch) {
337 roots.push((community.root_epoch, community.community_root));
338 }
339 roots.into_iter().map(|(ep, root)| (root, ep)).collect()
340 };
341 if coords.is_empty() {
342 return Ok(Vec::new());
343 }
344
345 let authors: Vec<String> = coords
347 .iter()
348 .map(|(secret, epoch)| channel_group_key(secret, channel_id, *epoch).pk_hex())
349 .collect();
350
351 let mut seen_wraps: std::collections::HashSet<nostr_sdk::EventId> = std::collections::HashSet::new();
352 let mut seen_rumors = std::collections::HashSet::new();
353 let mut out: Vec<(u64, FetchedEvent)> = Vec::new();
354 let mut until: Option<u64> = None;
355 let mut oldest: Option<u64> = None;
356 for _ in 0..max_pages {
357 let query = Query {
358 kinds: vec![stream::KIND_WRAP],
359 authors: authors.clone(),
360 until,
361 limit: Some(page),
362 ..Default::default()
363 };
364 let wraps = transport.fetch(&query, &community.relays).await?;
365 if wraps.is_empty() {
366 break;
367 }
368 let mut fresh = 0usize;
369 let mut page_events: Vec<FetchedEvent> = Vec::new();
370 for wrap in &wraps {
371 if !seen_wraps.insert(wrap.id) {
372 continue;
373 }
374 fresh += 1;
375 let at = wrap.created_at.as_secs();
376 if oldest.is_none_or(|o| at < o) {
377 oldest = Some(at);
378 }
379 for (secret, epoch) in &coords {
381 let group = channel_group_key(secret, channel_id, *epoch);
382 if wrap.pubkey != group.pk() {
383 continue;
384 }
385 if let Ok(event) = chat::open_chat_event(wrap, &group, channel_id, *epoch) {
386 let id = event.opened().rumor_id;
387 if seen_rumors.insert(id) {
388 page_events.push(FetchedEvent { event, epoch: *epoch });
389 }
390 }
391 break;
392 }
393 }
394 if fresh == 0 {
395 if wraps.len() < page {
396 break; }
398 let Some(o) = oldest else { break };
402 if o == 0 {
403 break;
404 }
405 crate::log_warn!("v2: same-second history wall at {o} — stepping past it (messages beyond the relay page cap in that second are unreachable)");
406 until = Some(o - 1);
407 continue;
408 }
409 let stop = !page_events.is_empty() && !keep_paging(&page_events);
410 out.extend(page_events.into_iter().map(|e| (e.event.opened().at_ms, e)));
411 if stop {
412 break; }
414 until = oldest; }
416 out.sort_by_key(|(ms, _)| *ms);
417 Ok(out.into_iter().map(|(_, e)| e).collect())
418}
419
420pub fn bundle_of(
427 community: &CommunityV2,
428 creator: Option<PublicKey>,
429 expires_at_ms: Option<u64>,
430 label: Option<String>,
431) -> CommunityInvite {
432 let hex = crate::simd::hex::bytes_to_hex_32;
433 let channels = community
434 .channels
435 .iter()
436 .filter(|c| !(c.private && c.key.is_none()))
441 .map(|c| invite::ChannelGrant {
442 id: hex(&c.id.0),
443 key: hex(&c.key.unwrap_or(community.community_root)),
444 epoch: c.epoch.0,
445 name: c.name.clone(),
446 })
447 .collect();
448 CommunityInvite {
449 community_id: hex(&community.identity.community_id.0),
450 owner: hex(&community.identity.owner_xonly),
451 owner_salt: hex(&community.identity.owner_salt),
452 community_root: hex(&community.community_root),
453 root_epoch: community.root_epoch.0,
454 channels,
455 relays: community.relays.clone(),
456 name: community.name.clone(),
457 icon: None,
458 expires_at: expires_at_ms,
459 creator_npub: creator.map(|p| p.to_hex()),
460 label,
461 extra: Default::default(),
462 }
463}
464
465pub async fn send_direct_invite<T: Transport + ?Sized>(
471 transport: &T,
472 community: &CommunityV2,
473 recipient: &PublicKey,
474 expires_at_ms: Option<u64>,
475 label: Option<String>,
476) -> Result<Event, String> {
477 let session = SessionGuard::capture();
478 let inviter = local_keys()?;
479 let bundle = bundle_of(community, Some(inviter.public_key()), expires_at_ms, label);
480 let wrap = invite::build_direct_invite(&inviter, recipient, &bundle).map_err(|e| e.to_string())?;
481 if !session.is_valid() {
482 return Err("account changed before sending invite".to_string());
483 }
484 transport.publish(&wrap, &community.relays).await?;
485 Ok(wrap)
486}
487
488pub struct MintedLink {
492 pub url: String,
493 pub bundle_event: Event,
494 pub link_signer: Keys,
495 pub token: [u8; super::derive::TOKEN_LEN],
496}
497
498pub async fn mint_public_link<T: Transport + ?Sized>(
504 transport: &T,
505 community: &CommunityV2,
506 base: &str,
507 expires_at_ms: Option<u64>,
508 label: Option<String>,
509) -> Result<MintedLink, String> {
510 let session = SessionGuard::capture();
511 let mut token = [0u8; super::derive::TOKEN_LEN];
512 token.copy_from_slice(&super::super::random_32()[..super::derive::TOKEN_LEN]);
513 let link_signer = Keys::generate();
514 let bundle = bundle_of(community, Some(local_keys()?.public_key()), expires_at_ms, label.clone());
515 let bundle_key = super::derive::invite_bundle_key(&token);
516 let bundle_event = invite::build_bundle_event(&link_signer, &bundle, &bundle_key).map_err(|e| e.to_string())?;
517 let url = invite::build_invite_url(base, &link_signer.public_key(), &token, &community.relays).map_err(|e| e.to_string())?;
518
519 if !session.is_valid() {
520 return Err("account changed before minting link".to_string());
521 }
522 transport.publish_durable(&bundle_event, &community.relays).await?;
523 let minted = MintedLink { url, bundle_event, link_signer, token };
524 let _ = record_minted_link(transport, community, &minted).await;
528 if session.is_valid() {
533 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
534 let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
535 let _ = crate::db::community::save_public_invite(&token_hex, &cid_hex, &minted.url, expires_at_ms.map(|e| e as i64), label.as_deref());
536 }
537 Ok(minted)
538}
539
540async fn fetch_invite_list<T: Transport + ?Sized>(transport: &T, relays: &[String]) -> Option<invite::InviteList> {
545 let me = local_keys().ok()?;
546 let query = Query {
547 kinds: vec![super::kind::INVITE_LIST],
548 authors: vec![me.public_key().to_hex()],
549 limit: Some(4),
550 ..Default::default()
551 };
552 let events = transport.fetch(&query, relays).await.ok()?;
553 events
554 .into_iter()
555 .filter_map(|e| invite::parse_invite_list_event(&e, &me).ok().map(|l| (e.created_at.as_secs(), l)))
556 .max_by_key(|(at, _)| *at)
557 .map(|(_, l)| l)
558}
559
560fn live_signers_for(list: &invite::InviteList, community_id_hex: &str) -> Vec<PublicKey> {
563 let dead: std::collections::HashSet<&str> = list.tombstones.iter().map(|t| t.token.as_str()).collect();
564 list.entries
565 .iter()
566 .filter(|e| e.community_id == community_id_hex && !dead.contains(e.token.as_str()))
567 .filter_map(|e| Keys::parse(&e.signer_sk).ok().map(|k| k.public_key()))
568 .collect()
569}
570
571async fn publish_invite_registry<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, session: &SessionGuard, live_signers: &[PublicKey]) -> Result<(), String> {
575 let me = local_keys()?;
576 let eid = super::derive::invite_links_locator(community.id(), &me.public_key().to_bytes());
577 let content = invite::build_registry_content(live_signers);
578 publish_control_edition(transport, community, session, vsk::INVITE_LINKS, &eid, &content, None).await
579}
580
581async fn record_minted_link<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, minted: &MintedLink) -> Result<(), String> {
584 let session = SessionGuard::capture();
585 let me = local_keys()?;
586 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
587 let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
588 let mut list = fetch_invite_list(transport, &community.relays).await.unwrap_or_default();
589 if !list.entries.iter().any(|e| e.token == token_hex) {
590 list.entries.push(invite::InviteEntry {
591 token: token_hex,
592 signer_sk: minted.link_signer.secret_key().to_secret_hex(),
593 community_id: cid_hex.clone(),
594 url: minted.url.clone(),
595 label: None,
596 created_at: now_ms() / 1000,
597 expires_at: None,
598 extra: Default::default(),
599 });
600 }
601 if !session.is_valid() {
602 return Err("account changed during link record".to_string());
603 }
604 let event = invite::build_invite_list_event(&me, &list).map_err(|e| e.to_string())?;
605 transport.publish(&event, &community.relays).await?;
606 let signers = live_signers_for(&list, &cid_hex);
607 publish_invite_registry(transport, community, &session, &signers).await
608}
609
610pub async fn revoke_public_link<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, token_hex: &str) -> Result<(), String> {
616 let session = SessionGuard::capture();
617 let me = local_keys()?;
618 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
619 let mut list = fetch_invite_list(transport, &community.relays).await.ok_or("no invite list found to revoke from")?;
620 let entry = list
621 .entries
622 .iter()
623 .find(|e| e.token == token_hex && e.community_id == cid_hex)
624 .cloned()
625 .ok_or("no such link in the invite list")?;
626 let link_signer = Keys::parse(&entry.signer_sk).map_err(|_| "malformed link signer")?;
628 let revocation = invite::build_revocation(&link_signer).map_err(|e| e.to_string())?;
629 if !session.is_valid() {
630 return Err("account changed during revoke".to_string());
631 }
632 transport.publish_durable(&revocation, &community.relays).await?;
633 list.tombstones.push(invite::InviteTombstone { token: token_hex.to_string(), community_id: cid_hex.clone(), extra: Default::default() });
635 list.entries.retain(|e| e.token != token_hex);
636 let event = invite::build_invite_list_event(&me, &list).map_err(|e| e.to_string())?;
637 transport.publish(&event, &community.relays).await?;
638 let signers = live_signers_for(&list, &cid_hex);
639 publish_invite_registry(transport, community, &session, &signers).await?;
640 if session.is_valid() {
642 let _ = crate::db::community::delete_public_invite(token_hex);
643 }
644 Ok(())
645}
646
647pub async fn refresh_public_links<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
652 let session = SessionGuard::capture();
653 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
654 let me = local_keys()?;
659 let query = Query {
660 kinds: vec![super::kind::INVITE_LIST],
661 authors: vec![me.public_key().to_hex()],
662 limit: Some(4),
663 ..Default::default()
664 };
665 let events = transport.fetch(&query, &community.relays).await?;
666 let list = events
667 .into_iter()
668 .filter_map(|e| invite::parse_invite_list_event(&e, &me).ok().map(|l| (e.created_at.as_secs(), l)))
669 .max_by_key(|(at, _)| *at)
670 .map(|(_, l)| l);
671 let Some(list) = list else {
672 return Ok(());
673 };
674 let creator = local_keys()?.public_key();
675 let dead: std::collections::HashSet<&str> = list.tombstones.iter().map(|t| t.token.as_str()).collect();
676 for entry in &list.entries {
677 if entry.community_id != cid_hex || dead.contains(entry.token.as_str()) || entry.token.len() != 2 * super::derive::TOKEN_LEN {
678 continue;
679 }
680 let Ok(link_signer) = Keys::parse(&entry.signer_sk) else { continue };
681 let token = crate::simd::hex::hex_to_bytes_16(&entry.token);
682 let bundle = bundle_of(community, Some(creator), entry.expires_at, entry.label.clone());
683 let bundle_key = super::derive::invite_bundle_key(&token);
684 if let Ok(event) = invite::build_bundle_event(&link_signer, &bundle, &bundle_key) {
685 if !session.is_valid() {
686 return Err("account changed during link refresh".to_string());
687 }
688 let _ = transport.publish_durable(&event, &community.relays).await;
689 }
690 }
691 Ok(())
692}
693
694pub async fn community_is_public<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> bool {
699 use crate::community::roles::Permissions;
700 use std::collections::BTreeMap;
701 let Ok(owner) = community.owner() else { return false };
702 let owner_hex = owner.to_hex();
703 let cid = community.id();
704 let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
705 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
706 .unwrap_or_default()
707 .into_iter()
708 .filter(|(_, f)| f.0 == community.root_epoch.0)
709 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
710 .collect();
711 let control = control_group_key(&community.community_root, cid, community.root_epoch);
712 let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![control.pk_hex()], limit: Some(FOLLOW_PAGE), ..Default::default() };
713 let editions: Vec<ParsedEdition> = transport
714 .fetch(&query, &community.relays)
715 .await
716 .unwrap_or_default()
717 .iter()
718 .filter_map(|w| control::open_control_edition(w, &control).ok().map(|(ed, _)| ed))
719 .collect();
720 let authority = fold_authority(community, &editions, &floors);
721
722 let mut by_eid: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
723 for e in &editions {
724 if e.vsk == vsk::INVITE_LINKS {
725 by_eid.entry(e.entity_id).or_default().push(e);
726 }
727 }
728 for (eid, group) in &by_eid {
729 let fold_eds: Vec<version::Edition> = group.iter().map(|p| p.to_fold_edition()).collect();
730 let (Some(hi), _) = fold_head(&fold_eds, floors.get(&crate::simd::hex::bytes_to_hex_32(eid))) else { continue };
731 let head = group[hi];
732 if !authority.roles.is_authorized(&head.author.to_hex(), Some(&owner_hex), Permissions::CREATE_INVITE) {
734 continue;
735 }
736 if super::derive::invite_links_locator(cid, &head.author.to_bytes()) != *eid {
737 continue;
738 }
739 if invite::parse_registry_content(&head.content).map(|s| !s.is_empty()).unwrap_or(false) {
740 return true;
741 }
742 }
743 false
744}
745
746async fn accept_bundle<T: Transport + ?Sized>(
752 transport: &T,
753 session: &SessionGuard,
754 bundle: &CommunityInvite,
755 invited_by: Option<PublicKey>,
756) -> Result<CommunityV2, String> {
757 let me = local_keys()?;
758 let at_ms = now_ms();
759 if bundle.expired(at_ms) {
761 return Err("this invite has expired".to_string());
762 }
763 let community = CommunityV2::from_bundle(bundle, at_ms)?;
765
766 let (community, join_heads) = verify_owner_root_and_reconcile(transport, community).await?;
773
774 if is_dissolved(transport, &community).await {
776 return Err("this community has been dissolved".to_string());
777 }
778
779 if !session.is_valid() {
783 return Err("account changed during join".to_string());
784 }
785 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
790 for h in &join_heads {
791 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)?;
792 }
793 crate::db::community::save_community_v2(&community)?;
794 let _ = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, community.root_epoch.0, &community.community_root);
797 for ch in &community.channels {
800 if let (true, Some(key)) = (ch.private, ch.key) {
801 let _ = crate::db::community::store_epoch_key(&cid_hex, &crate::simd::hex::bytes_to_hex_32(&ch.id.0), ch.epoch.0, &key);
802 }
803 }
804
805 let attribution = invited_by
807 .map(|p| p.to_hex())
808 .or_else(|| bundle.creator_npub.clone())
809 .zip(Some(bundle.label.clone().unwrap_or_default()));
810 let attr_ref = attribution.as_ref().map(|(c, l)| (c.as_str(), l.as_str()));
811 let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
812 let join_rumor = guestbook::build_join_rumor(me.public_key(), attr_ref, at_ms);
813 if let Ok((join_wrap, _)) = guestbook::seal_guestbook_rumor(&join_rumor, &gb_group, &me, Timestamp::from_secs(at_ms / 1000)) {
814 let _ = transport.publish(&join_wrap, &community.relays).await;
815 }
816
817 let _ = republish_community_list(transport, Some(community.id())).await;
819 Ok(community)
820}
821
822async fn verify_owner_root_and_reconcile<T: Transport + ?Sized>(
835 transport: &T,
836 community: CommunityV2,
837) -> Result<(CommunityV2, Vec<FoldedHead>), String> {
838 let owner = community.owner()?;
839 let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
840 let control_pk = control.pk_hex();
841
842 super::streamauth::prime(&community);
849
850 const PAGE: usize = 500;
870 const MAX_PAGES: usize = 4;
871 const FAR_FUTURE_SECS: u64 = 4_102_444_800; let mut editions: Vec<ParsedEdition> = Vec::new();
873 let mut found_genesis = false;
874 let mut until: Option<u64> = Some(FAR_FUTURE_SECS);
875 let mut seen_wraps: std::collections::HashSet<nostr_sdk::EventId> = std::collections::HashSet::new();
876 for _ in 0..MAX_PAGES {
877 let query = Query {
878 kinds: vec![stream::KIND_WRAP],
879 authors: vec![control_pk.clone()],
880 until,
881 limit: Some(PAGE),
882 ..Default::default()
883 };
884 let wraps = transport.fetch(&query, &community.relays).await?;
885 let mut oldest = u64::MAX;
889 let mut fresh = 0usize;
890 for w in &wraps {
891 if !seen_wraps.insert(w.id) {
892 continue;
893 }
894 fresh += 1;
895 oldest = oldest.min(w.created_at.as_secs());
896 if let Ok((ed, _)) = control::open_control_edition(w, &control) {
897 if ed.author == owner {
898 if ed.vsk == vsk::COMMUNITY_METADATA && ed.entity_id == community.id().0 {
899 found_genesis = true;
900 }
901 editions.push(ed);
902 }
903 }
904 }
905 if found_genesis || fresh == 0 {
906 break; }
908 until = Some(oldest);
909 }
910 if !found_genesis {
911 return Err(
912 "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"
913 .to_string(),
914 );
915 }
916 let empty_floors = Floors::new();
925 let authority = AuthoritySet::owner_only();
926 let fold = apply_control_fold(&community, &editions, &empty_floors, &authority);
927 Ok((fold.updated.unwrap_or(community), fold.heads))
928}
929
930pub async fn accept_direct_invite<T: Transport + ?Sized>(transport: &T, wrap: &Event) -> Result<CommunityV2, String> {
934 let session = SessionGuard::capture();
935 let me = local_keys()?;
936 let (inviter, bundle) = invite::unwrap_direct_invite(wrap, &me).map_err(|e| e.to_string())?;
937 accept_bundle(transport, &session, &bundle, Some(inviter)).await
938}
939
940pub async fn accept_parked_invite<T: Transport + ?Sized>(
946 transport: &T,
947 bundle_json: &str,
948 inviter_hex: Option<&str>,
949) -> Result<CommunityV2, String> {
950 let session = SessionGuard::capture();
951 let bundle = CommunityInvite::from_bundle_json(bundle_json).map_err(|e| e.to_string())?;
952 let invited_by = inviter_hex.and_then(|h| PublicKey::parse(h).ok());
953 accept_bundle(transport, &session, &bundle, invited_by).await
954}
955
956pub async fn accept_public_link<T: Transport + ?Sized>(transport: &T, url: &str) -> Result<CommunityV2, String> {
963 let session = SessionGuard::capture();
965 let parsed = invite::parse_invite_link(url).map_err(|e| e.to_string())?;
966 let query = Query {
967 kinds: vec![super::kind::INVITE_BUNDLE],
968 authors: vec![parsed.link_signer.to_hex()],
969 d_tags: vec![String::new()],
970 ..Default::default()
971 };
972 let relays = if parsed.bootstrap_relays.is_empty() {
973 invite::stock_relays()
974 } else {
975 parsed.bootstrap_relays.clone()
976 };
977 let events = transport.fetch(&query, &relays).await?;
978 if !session.is_valid() {
979 return Err("account changed during join".to_string());
980 }
981 let bundle_key = super::derive::invite_bundle_key(&parsed.token);
982
983 let mut newest_live: Option<(u64, CommunityInvite)> = None;
985 for event in &events {
986 match invite::parse_bundle_event(event, &parsed.link_signer, &bundle_key) {
987 Ok(invite::BundleState::Revoked) => return Err("this invite link has been revoked".to_string()),
988 Ok(invite::BundleState::Live(bundle)) => {
989 let at = event.created_at.as_secs();
990 if newest_live.as_ref().is_none_or(|(t, _)| at > *t) {
991 newest_live = Some((at, *bundle));
992 }
993 }
994 Err(_) => {} }
996 }
997 match newest_live {
998 Some((_, bundle)) => accept_bundle(transport, &session, &bundle, None).await,
999 None => Err("invite bundle not found on relays".to_string()),
1000 }
1001}
1002
1003pub async fn leave_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
1005 let session = SessionGuard::capture();
1006 let me = local_keys()?;
1007 let at_ms = now_ms();
1008 let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1009 let leave_rumor = guestbook::build_leave_rumor(me.public_key(), at_ms);
1010 if let Ok((wrap, _)) = guestbook::seal_guestbook_rumor(&leave_rumor, &gb_group, &me, Timestamp::from_secs(at_ms / 1000)) {
1011 let _ = transport.publish(&wrap, &community.relays).await;
1012 }
1013 if !session.is_valid() {
1014 return Err("account changed during leave".to_string());
1015 }
1016 let _ = tombstone_community_list(transport, community.id(), &community.relays).await;
1020 if !session.is_valid() {
1022 return Err("account changed during leave".to_string());
1023 }
1024 crate::db::community::delete_community(&crate::simd::hex::bytes_to_hex_32(&community.id().0))?;
1025 Ok(())
1026}
1027
1028pub async fn kick_member<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, target: &PublicKey) -> Result<(), String> {
1033 let session = SessionGuard::capture();
1034 let me = local_keys()?;
1035 let authority = fetch_authority(transport, community).await;
1038 let owner_hex = community.owner()?.to_hex();
1039 if !authority.roles.can_act_on_member(
1040 &me.public_key().to_hex(),
1041 Some(&owner_hex),
1042 &target.to_hex(),
1043 crate::community::roles::Permissions::KICK,
1044 ) {
1045 return Err("not authorized to kick this member".to_string());
1046 }
1047 let at_ms = now_ms();
1048 let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1049 let rumor = guestbook::build_kick_rumor(me.public_key(), *target, None, at_ms);
1050 let (wrap, _) = guestbook::seal_guestbook_rumor(&rumor, &gb_group, &me, Timestamp::from_secs(at_ms / 1000))
1051 .map_err(|e| e.to_string())?;
1052 if !session.is_valid() {
1053 return Err("account changed before send".to_string());
1054 }
1055 transport.publish(&wrap, &community.relays).await?;
1056 Ok(())
1057}
1058
1059pub struct AuthorityView {
1065 pub roles: crate::community::roles::CommunityRoles,
1066 pub banned: std::collections::BTreeSet<String>,
1067 pub gapped: bool,
1069 pub floored: std::collections::BTreeSet<String>,
1071 pub head_entities: std::collections::BTreeSet<String>,
1073}
1074
1075pub async fn fetch_authority<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> AuthorityView {
1081 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1082 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)
1083 .unwrap_or_default()
1084 .into_iter()
1085 .filter(|(_, f)| f.0 == community.root_epoch.0)
1086 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
1087 .collect();
1088 let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
1089
1090 let mut editions: Vec<ParsedEdition> = Vec::new();
1091 let mut seen: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
1092 let mut seen_wraps: std::collections::HashSet<nostr_sdk::EventId> = std::collections::HashSet::new();
1093 let mut oldest: Option<u64> = None;
1094 let mut until: Option<u64> = None;
1095 let mut a = fold_authority(community, &[], &floors);
1100 for _ in 0..FOLLOW_MAX_PAGES {
1101 let query = Query {
1102 kinds: vec![stream::KIND_WRAP],
1103 authors: vec![control.pk_hex()],
1104 until,
1105 limit: Some(FOLLOW_PAGE),
1106 ..Default::default()
1107 };
1108 let Ok(wraps) = transport.fetch(&query, &community.relays).await else { break };
1109 let mut fresh = 0usize;
1110 for w in &wraps {
1111 if !seen_wraps.insert(w.id) {
1112 continue;
1113 }
1114 fresh += 1;
1115 let at = w.created_at.as_secs();
1116 if oldest.is_none_or(|o| at < o) {
1117 oldest = Some(at);
1118 }
1119 if let Ok((ed, _)) = control::open_control_edition(w, &control) {
1120 if seen.insert(ed.inner_id) {
1121 editions.push(ed);
1122 }
1123 }
1124 }
1125 a = fold_authority(community, &editions, &floors);
1126 if !a.gapped || fresh == 0 {
1127 break;
1128 }
1129 until = oldest;
1130 }
1131 AuthorityView {
1132 roles: a.roles,
1133 banned: a.banned,
1134 gapped: a.gapped,
1135 floored: floors.keys().cloned().collect(),
1136 head_entities: a.heads.iter().map(|h| h.entity_hex.clone()).collect(),
1137 }
1138}
1139
1140pub async fn memberlist<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<Vec<PublicKey>, String> {
1146 let gb_group = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1147 const GB_PAGE: usize = 500;
1154 const GB_MAX_PAGES: usize = 12;
1155 let owner = community.owner()?;
1156 let mut events = Vec::new();
1157 let mut seen: std::collections::HashSet<nostr_sdk::EventId> = std::collections::HashSet::new();
1158 let mut until: Option<u64> = None;
1159 let mut oldest: Option<u64> = None;
1160 for _ in 0..GB_MAX_PAGES {
1161 let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![gb_group.pk_hex()], until, limit: Some(GB_PAGE), ..Default::default() };
1162 let wraps = transport.fetch(&query, &community.relays).await?;
1163 let mut fresh = 0usize;
1164 for wrap in &wraps {
1165 if !seen.insert(wrap.id) {
1166 continue;
1167 }
1168 fresh += 1;
1169 let at = wrap.created_at.as_secs();
1170 if oldest.is_none_or(|o| at < o) {
1171 oldest = Some(at);
1172 }
1173 if let Ok(opened) = stream::open_wrap(wrap, &gb_group) {
1174 if let Ok(ev) = guestbook::parse_guestbook_event(&opened) {
1175 events.push(ev);
1176 }
1177 }
1178 }
1179 if fresh == 0 || wraps.len() < GB_PAGE {
1180 break;
1181 }
1182 match oldest {
1183 Some(o) if o > 0 => until = Some(o),
1184 _ => break,
1185 }
1186 }
1187 let mut observed: std::collections::BTreeMap<PublicKey, u64> = std::collections::BTreeMap::new();
1190 for ch in &community.channels {
1191 if let Ok(page) = fetch_channel(transport, community, &ch.id, 200).await {
1192 for f in &page {
1193 let e = observed.entry(f.event.opened().author).or_insert(0);
1194 *e = (*e).max(f.event.opened().at_ms);
1195 }
1196 }
1197 }
1198
1199 let authority = fetch_authority(transport, community).await;
1204 let owner_hex = owner.to_hex();
1205
1206 for g in &authority.roles.grants {
1213 if let Some(pk) = PublicKey::from_hex(&g.member).ok().filter(|_| !g.role_ids.is_empty()) {
1214 observed.entry(pk).or_insert(0);
1215 }
1216 }
1217
1218 let snapshot_authority = (community.root_epoch.0 > 0).then_some(&owner);
1224 let can_kick = |actor: &PublicKey, target: &PublicKey| {
1227 authority
1228 .roles
1229 .can_act_on_member(&actor.to_hex(), Some(&owner_hex), &target.to_hex(), crate::community::roles::Permissions::KICK)
1230 };
1231 let coalesced = guestbook::coalesce(&events, now_ms(), snapshot_authority, &can_kick);
1232 let banlist: std::collections::BTreeSet<PublicKey> =
1234 authority.banned.iter().filter_map(|h| PublicKey::from_hex(h).ok()).collect();
1235 let mut members = guestbook::complete_memberlist(&coalesced, &observed, &banlist);
1236 if !banlist.contains(&owner) {
1238 members.insert(owner);
1239 }
1240 Ok(members.into_iter().collect())
1241}
1242
1243pub async fn dissolve_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> Result<(), String> {
1251 let session = SessionGuard::capture();
1252 let me = local_keys()?;
1253 if community.owner()? != me.public_key() {
1254 return Err("only the owner can dissolve a community".to_string());
1255 }
1256 let at = now_ms() / 1000;
1257 let rumor = super::dissolution::dissolved_tombstone_rumor(me.public_key(), community.id(), at);
1258 let wrap = super::dissolution::seal_dissolved(&rumor, community.id(), &me, Timestamp::from_secs(at)).map_err(|e| e.to_string())?;
1259 if !session.is_valid() {
1260 return Err("account changed during dissolve".to_string());
1261 }
1262 transport.publish_durable(&wrap, &community.relays).await?;
1264 crate::db::community::set_community_dissolved(&crate::simd::hex::bytes_to_hex_32(&community.id().0))?;
1265 Ok(())
1266}
1267
1268pub async fn is_dissolved<T: Transport + ?Sized>(transport: &T, community: &CommunityV2) -> bool {
1273 let group = super::derive::dissolved_group_key(community.id());
1274 let query = Query {
1275 kinds: vec![stream::KIND_WRAP],
1276 authors: vec![group.pk_hex()],
1277 limit: Some(20),
1278 ..Default::default()
1279 };
1280 let Ok(wraps) = transport.fetch(&query, &community.relays).await else {
1281 return false;
1282 };
1283 wraps.iter().any(|w| super::dissolution::verify_dissolved(w, &community.identity))
1284}
1285
1286pub async fn refound_community<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, removed: &[PublicKey]) -> Result<CommunityV2, String> {
1299 let session = SessionGuard::capture();
1300 let cid = community.id();
1301 let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
1302 if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
1304 return Err("this community has been dissolved; it cannot be re-founded".to_string());
1305 }
1306 let me = local_keys()?;
1307 let lock = super::realtime::follow_lock(cid);
1311 let _guard = lock.lock().await;
1312 let fresh = crate::db::community::load_community_v2(cid)?.ok_or("community gone before re-founding")?;
1316 let community = &fresh;
1317 let owner = community.owner()?;
1318
1319 if me.public_key() != owner {
1326 return Err("only the owner can re-found (the cryptographic read-cut)".to_string());
1327 }
1328
1329 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
1333 .into_iter()
1334 .filter(|(_, f)| f.0 == community.root_epoch.0)
1335 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
1336 .collect();
1337 let current_control = control_group_key(&community.community_root, cid, community.root_epoch);
1338 let mut opened: Vec<(ParsedEdition, super::stream::OpenedStream)> = Vec::new();
1345 let mut seen_wraps: std::collections::HashSet<nostr_sdk::EventId> = std::collections::HashSet::new();
1346 let mut oldest: Option<u64> = None;
1347 let mut until: Option<u64> = None;
1348 for _ in 0..FOLLOW_MAX_PAGES {
1349 let query = Query {
1350 kinds: vec![stream::KIND_WRAP],
1351 authors: vec![current_control.pk_hex()],
1352 until,
1353 limit: Some(FOLLOW_PAGE),
1354 ..Default::default()
1355 };
1356 let wraps = transport.fetch(&query, &community.relays).await?;
1357 let mut fresh = 0usize;
1358 for w in &wraps {
1359 if !seen_wraps.insert(w.id) {
1360 continue;
1361 }
1362 fresh += 1;
1363 let at = w.created_at.as_secs();
1364 if oldest.is_none_or(|o| at < o) {
1365 oldest = Some(at);
1366 }
1367 if let Ok(parsed) = control::open_control_edition(w, ¤t_control) {
1368 opened.push(parsed);
1369 }
1370 }
1371 let present: std::collections::HashSet<String> =
1374 opened.iter().map(|(e, _)| crate::simd::hex::bytes_to_hex_32(&e.entity_id)).collect();
1375 if floors.keys().all(|k| present.contains(k)) || fresh == 0 {
1376 break;
1377 }
1378 until = oldest;
1379 }
1380
1381 let prev_epoch = community.root_epoch;
1382 let new_epoch = Epoch(prev_epoch.0.checked_add(1).ok_or("root epoch overflow")?);
1383 let prev_commit = super::derive::epoch_key_commitment(prev_epoch, &community.community_root);
1384 let new_root = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch.0)?;
1389 let new_control = control_group_key(&new_root, cid, new_epoch);
1390 let at = now_ms();
1391 let at_secs = at / 1000;
1392
1393 use std::collections::BTreeMap;
1399 let mut by_eid: BTreeMap<String, Vec<usize>> = BTreeMap::new();
1400 for (i, (e, _)) in opened.iter().enumerate() {
1401 by_eid.entry(crate::simd::hex::bytes_to_hex_32(&e.entity_id)).or_default().push(i);
1402 }
1403 let mut carried: Vec<(FoldedHead, Event)> = Vec::new();
1404 for (floor_key, floor) in &floors {
1405 let head_idx = by_eid
1413 .get(floor_key)
1414 .and_then(|v| v.iter().copied().find(|&i| opened[i].0.self_hash == floor.1));
1415 let Some(head_idx) = head_idx else {
1416 return Err(format!("re-founding aborted: the committed head of control entity {floor_key} (v{}) was not served; no state published", floor.0));
1417 };
1418 let (head_ed, head_os) = &opened[head_idx];
1419 let h = FoldedHead { entity_hex: floor_key.clone(), version: head_ed.version, self_hash: head_ed.self_hash, inner_id: head_ed.inner_id };
1420 let (rewrapped, _) = super::stream::rewrap_seal(&head_os.seal, &new_control, Timestamp::from_secs(at_secs)).map_err(|e| e.to_string())?;
1421 carried.push((h, rewrapped));
1422 }
1423 if !session.is_valid() {
1424 return Err("account changed during re-founding acquire".to_string());
1425 }
1426
1427 let members = memberlist(transport, community).await?;
1429 let removed_set: std::collections::HashSet<[u8; 32]> = removed.iter().map(|p| p.to_bytes()).collect();
1430 let mut recipients: Vec<PublicKey> = members.into_iter().filter(|m| !removed_set.contains(&m.to_bytes())).collect();
1431 if !recipients.iter().any(|p| *p == me.public_key()) {
1432 recipients.push(me.public_key());
1433 }
1434
1435 let mut base_blobs = Vec::new();
1437 for r in &recipients {
1438 base_blobs.push(
1439 super::rekey::build_blob_local(me.secret_key(), &me.public_key().to_bytes(), r, super::rekey::RekeyScope::Root, new_epoch, &new_root)
1440 .map_err(|e| e.to_string())?,
1441 );
1442 }
1443 let base_group = super::derive::base_rekey_group_key(&community.community_root, cid, new_epoch);
1444 let base_chunks =
1445 super::rekey::build_rekey_chunks_local(&me, &base_group, super::rekey::RekeyScope::Root, new_epoch, prev_epoch, &prev_commit, &base_blobs, at_secs)
1446 .map_err(|e| e.to_string())?;
1447
1448 let mut channel_updates: Vec<(ChannelId, [u8; 32], Epoch)> = Vec::new();
1451 let mut channel_chunk_sets: Vec<Vec<Event>> = Vec::new();
1452 for ch in &community.channels {
1453 let (Some(old_key), true) = (ch.key, ch.private) else { continue };
1454 let ch_new_epoch = Epoch(ch.epoch.0.checked_add(1).ok_or("channel epoch overflow")?);
1455 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)?;
1458 let ch_prev_commit = super::derive::epoch_key_commitment(ch.epoch, &old_key);
1459 let mut ch_blobs = Vec::new();
1460 for r in &recipients {
1461 ch_blobs.push(
1462 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)
1463 .map_err(|e| e.to_string())?,
1464 );
1465 }
1466 let ch_group = super::derive::channel_rekey_group_key(&community.community_root, &ch.id, ch_new_epoch);
1467 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)
1468 .map_err(|e| e.to_string())?;
1469 channel_updates.push((ch.id, ch_new_key, ch_new_epoch));
1470 channel_chunk_sets.push(ch_chunks);
1471 }
1472 if !session.is_valid() {
1473 return Err("account changed during re-founding prepare".to_string());
1474 }
1475
1476 for c in &base_chunks {
1479 transport.publish_durable(c, &community.relays).await?;
1480 }
1481 for set in &channel_chunk_sets {
1482 for c in set {
1483 transport.publish_durable(c, &community.relays).await?;
1484 }
1485 }
1486 for (_, wrap) in &carried {
1487 transport.publish_durable(wrap, &community.relays).await?;
1488 }
1489 let gb_group = super::derive::guestbook_group_key(&new_root, cid, new_epoch);
1492 let snap_id = crate::community::random_32();
1493 for rumor in guestbook::build_snapshot_rumors(me.public_key(), &recipients, snap_id, at) {
1494 if let Ok((wrap, _)) = guestbook::seal_guestbook_rumor(&rumor, &gb_group, &me, Timestamp::from_secs(at_secs)) {
1495 let _ = transport.publish(&wrap, &community.relays).await;
1496 }
1497 }
1498
1499 if !session.is_valid() {
1501 return Err("account changed during re-founding commit".to_string());
1502 }
1503 if crate::db::community::community_protocol(cid)?.is_none() {
1504 return Ok(community.clone()); }
1506 let mut updated = community.clone();
1510 updated.community_root = new_root;
1511 updated.root_epoch = new_epoch;
1512 for (id, key, ep) in &channel_updates {
1513 if let Some(c) = updated.channels.iter_mut().find(|c| c.id.0 == id.0) {
1514 c.key = Some(*key);
1515 c.epoch = *ep;
1516 }
1517 }
1518 crate::db::community::save_community_v2(&updated)?;
1519 crate::db::community::advance_server_root_epoch(&cid_hex, new_epoch.0, &new_root)?;
1524 for (h, _) in &carried {
1525 crate::db::community::set_edition_head_at_epoch(&cid_hex, &h.entity_hex, h.version, &h.self_hash, &h.inner_id, new_epoch.0)?;
1526 }
1527 for attempt in 0..3u8 {
1533 match refresh_public_links(transport, &updated).await {
1534 Ok(()) => break,
1535 Err(_) if !session.is_valid() => break, Err(e) if attempt == 2 => {
1537 crate::log_warn!("v2: post-refounding public-link refresh failed after retries ({e}); live links may serve the prior root until the next refresh");
1538 }
1539 Err(_) => continue,
1540 }
1541 }
1542 Ok(updated)
1543}
1544
1545fn mint_or_reuse_rotation_key(community_id_hex: &str, scope_hex: &str, new_epoch: u64) -> Result<[u8; 32], String> {
1552 if let Some(existing) = crate::db::community::held_epoch_key(community_id_hex, scope_hex, new_epoch)? {
1553 return Ok(existing);
1554 }
1555 let fresh = crate::community::random_32();
1556 crate::db::community::store_epoch_key(community_id_hex, scope_hex, new_epoch, &fresh)?;
1557 Ok(fresh)
1558}
1559
1560fn join_material(community: &CommunityV2) -> super::list::JoinMaterial {
1566 let hex = crate::simd::hex::bytes_to_hex_32;
1567 let channels = community
1568 .channels
1569 .iter()
1570 .filter(|c| c.private)
1571 .filter_map(|c| {
1572 c.key.map(|k| super::list::ChannelKeyRef { id: hex(&c.id.0), key: hex(&k), epoch: c.epoch.0, name: c.name.clone() })
1573 })
1574 .collect();
1575 super::list::JoinMaterial {
1576 community_id: hex(&community.identity.community_id.0),
1577 owner: hex(&community.identity.owner_xonly),
1578 owner_salt: hex(&community.identity.owner_salt),
1579 community_root: hex(&community.community_root),
1580 root_epoch: community.root_epoch.0,
1581 channels,
1582 relays: community.relays.clone(),
1583 name: community.name.clone(),
1584 extra: Default::default(),
1585 }
1586}
1587
1588fn material_to_invite(jm: &super::list::JoinMaterial) -> CommunityInvite {
1592 let channels = jm
1593 .channels
1594 .iter()
1595 .map(|c| invite::ChannelGrant { id: c.id.clone(), key: c.key.clone(), epoch: c.epoch, name: c.name.clone() })
1596 .collect();
1597 CommunityInvite {
1598 community_id: jm.community_id.clone(),
1599 owner: jm.owner.clone(),
1600 owner_salt: jm.owner_salt.clone(),
1601 community_root: jm.community_root.clone(),
1602 root_epoch: jm.root_epoch,
1603 channels,
1604 relays: jm.relays.clone(),
1605 name: jm.name.clone(),
1606 icon: None,
1607 expires_at: None,
1608 creator_npub: None,
1609 label: None,
1610 extra: Default::default(),
1611 }
1612}
1613
1614fn held_v2_relays() -> Vec<String> {
1617 let mut set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
1618 if let Ok(ids) = crate::db::community::list_community_ids() {
1619 for id in ids {
1620 if matches!(crate::db::community::community_protocol(&id), Ok(Some(crate::community::ConcordProtocol::V2))) {
1621 if let Ok(Some(c)) = crate::db::community::load_community_v2(&id) {
1622 set.extend(c.relays);
1623 }
1624 }
1625 }
1626 }
1627 set.into_iter().collect()
1628}
1629
1630async fn fetch_community_list<T: Transport + ?Sized>(transport: &T, relays: &[String]) -> Result<Option<super::list::CommunityList>, String> {
1636 let me = local_keys()?;
1637 let query = Query {
1638 kinds: vec![super::kind::COMMUNITY_LIST],
1639 authors: vec![me.public_key().to_hex()],
1640 limit: Some(4),
1641 ..Default::default()
1642 };
1643 let events = transport.fetch(&query, relays).await?;
1644 Ok(events
1645 .into_iter()
1646 .filter_map(|e| super::list::parse_list_event(&e, &me).ok().map(|l| (e.created_at.as_secs(), l)))
1647 .max_by_key(|(at, _)| *at)
1648 .map(|(_, l)| l))
1649}
1650
1651pub async fn republish_community_list<T: Transport + ?Sized>(transport: &T, just_joined: Option<&crate::community::CommunityId>) -> Result<(), String> {
1660 let session = SessionGuard::capture();
1661 let me = local_keys()?;
1662 let relays = held_v2_relays();
1663 if relays.is_empty() {
1664 return Ok(()); }
1666 let remote = match fetch_community_list(transport, &relays).await {
1670 Ok(r) => r.unwrap_or_default(),
1671 Err(_) => return Ok(()),
1672 };
1673 let just_joined_hex = just_joined.map(|c| crate::simd::hex::bytes_to_hex_32(&c.0));
1674 let now = now_ms();
1675 let mut local = super::list::CommunityList::default();
1676 for id in crate::db::community::list_community_ids()? {
1677 if !matches!(crate::db::community::community_protocol(&id), Ok(Some(crate::community::ConcordProtocol::V2))) {
1678 continue;
1679 }
1680 let Some(c) = crate::db::community::load_community_v2(&id)? else { continue };
1681 let cid_hex = crate::simd::hex::bytes_to_hex_32(&c.id().0);
1682 let is_join = just_joined_hex.as_deref() == Some(cid_hex.as_str());
1683 if !is_join && !remote.is_live(&cid_hex) && remote.tombstones.iter().any(|t| t.community_id == cid_hex) {
1687 continue;
1688 }
1689 let added_at = if remote.is_live(&cid_hex) && !is_join {
1692 remote.entries.iter().find(|e| e.community_id == cid_hex).map(|e| e.added_at).unwrap_or(now)
1693 } else {
1694 now
1695 };
1696 let jm = join_material(&c);
1697 local.entries.push(super::list::CommunityListEntry { community_id: cid_hex, seed: jm.clone(), current: jm, added_at, extra: Default::default() });
1698 }
1699 let merged = remote.merge(&local);
1700 merged.assert_fits().map_err(|e| e.to_string())?;
1701 let event = super::list::build_list_event(&me, &merged).map_err(|e| e.to_string())?;
1702 if !session.is_valid() {
1703 return Err("account changed during community-list publish".to_string());
1704 }
1705 transport.publish(&event, &relays).await
1706}
1707
1708async fn tombstone_community_list<T: Transport + ?Sized>(transport: &T, community_id: &crate::community::CommunityId, relays: &[String]) -> Result<(), String> {
1711 let me = local_keys()?;
1712 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community_id.0);
1713 let mut doc = match fetch_community_list(transport, relays).await {
1717 Ok(d) => d.unwrap_or_default(),
1718 Err(e) => return Err(e),
1719 };
1720 let now = now_ms();
1721 doc.tombstones.retain(|t| t.community_id != cid_hex);
1722 doc.tombstones.push(super::list::Tombstone { community_id: cid_hex, removed_at: now, extra: Default::default() });
1723 doc.assert_fits().map_err(|e| e.to_string())?;
1724 let event = super::list::build_list_event(&me, &doc).map_err(|e| e.to_string())?;
1725 transport.publish(&event, relays).await
1726}
1727
1728pub async fn sync_community_list<T: Transport + ?Sized>(transport: &T, bootstrap_relays: &[String]) -> Result<Vec<CommunityV2>, String> {
1734 let session = SessionGuard::capture();
1735 let mut relays = held_v2_relays();
1736 relays.extend(bootstrap_relays.iter().cloned());
1737 relays.sort();
1738 relays.dedup();
1739 if relays.is_empty() {
1740 return Ok(vec![]);
1741 }
1742 let list = match fetch_community_list(transport, &relays).await {
1743 Ok(Some(l)) => l,
1744 Ok(None) | Err(_) => return Ok(vec![]),
1745 };
1746 for t in &list.tombstones {
1752 if list.is_live(&t.community_id) {
1753 continue;
1754 }
1755 let Some(cid) = crate::simd::hex::hex_to_bytes_32_checked(&t.community_id) else { continue };
1756 let id = crate::community::CommunityId(cid);
1757 if crate::db::community::load_community_v2(&id).ok().flatten().is_none() {
1758 continue; }
1760 if !session.is_valid() {
1761 return Err("account changed during community-list sync".to_string());
1762 }
1763 let _ = crate::db::community::delete_community(&t.community_id);
1764 }
1765 let mut joined = Vec::new();
1766 for entry in list.live_entries() {
1767 let Some(cid) = crate::simd::hex::hex_to_bytes_32_checked(&entry.community_id) else { continue };
1768 if crate::db::community::load_community_v2(&crate::community::CommunityId(cid)).ok().flatten().is_some() {
1769 continue; }
1771 if !session.is_valid() {
1772 return Err("account changed during community-list sync".to_string());
1773 }
1774 let bundle = material_to_invite(&entry.current);
1777 if let Ok(community) = accept_bundle(transport, &session, &bundle, None).await {
1778 joined.push(community);
1779 }
1780 }
1781 Ok(joined)
1782}
1783
1784async fn publish_control_edition<T: Transport + ?Sized>(
1793 transport: &T,
1794 community: &CommunityV2,
1795 session: &SessionGuard,
1796 vsk: &str,
1797 entity_id: &[u8; 32],
1798 content: &str,
1799 citation: Option<&crate::community::edition::AuthorityCitation>,
1800) -> Result<(), String> {
1801 let me = local_keys()?;
1802 let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
1803 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1804 let entity_hex = crate::simd::hex::bytes_to_hex_32(entity_id);
1805 let (version, prev) = match crate::db::community::get_edition_head(&cid_hex, &entity_hex)? {
1806 Some((v, h)) => (v + 1, Some(h)),
1807 None => (1, None),
1808 };
1809 let at = now_ms() / 1000;
1810 let rumor = control::build_edition_rumor(me.public_key(), vsk, entity_id, version, prev.as_ref(), content, at, citation);
1811 let (wrap, _) = control::seal_control_edition(&rumor, &control, &me, Timestamp::from_secs(at)).map_err(|e| e.to_string())?;
1812 if !session.is_valid() {
1813 return Err("account changed before control publish".to_string());
1814 }
1815 transport.publish(&wrap, &community.relays).await?;
1816 if !session.is_valid() {
1822 return Ok(());
1823 }
1824 if let Ok((ed, _)) = control::open_control_edition(&wrap, &control) {
1825 crate::db::community::set_edition_head_at_epoch(&cid_hex, &entity_hex, ed.version, &ed.self_hash, &ed.inner_id, community.root_epoch.0)?;
1826 }
1827 Ok(())
1828}
1829
1830pub async fn set_role<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, role: &crate::community::roles::Role) -> Result<(), String> {
1834 let session = SessionGuard::capture();
1835 super::roles::validate_role(role)?;
1836 let content = super::roles::role_content_json(role)?;
1837 let role_id = crate::simd::hex::hex_to_bytes_32_checked(&role.role_id).ok_or("role_id must be 32-byte hex")?;
1838 publish_control_edition(transport, community, &session, vsk::ROLE, &role_id, &content, None).await
1839}
1840
1841pub async fn grant_roles<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey, role_ids: Vec<String>) -> Result<(), String> {
1845 let session = SessionGuard::capture();
1846 let grant = crate::community::roles::MemberGrant { member: member.to_hex(), role_ids };
1847 let content = super::roles::grant_content_json(&grant)?;
1848 let eid = super::derive::grant_locator(community.id(), &member.to_bytes());
1849 publish_control_edition(transport, community, &session, vsk::GRANT, &eid, &content, None).await
1850}
1851
1852pub async fn ensure_admin_role<T: Transport + ?Sized>(
1857 transport: &T,
1858 community: &CommunityV2,
1859 view: &AuthorityView,
1860 create_if_missing: bool,
1861) -> Result<Option<String>, String> {
1862 use crate::community::roles::{Permissions, Role, RoleScope};
1863 if let Some(r) = view
1864 .roles
1865 .roles
1866 .iter()
1867 .find(|r| matches!(r.scope, RoleScope::Server) && r.permissions.contains(Permissions::ADMIN_ALL))
1868 {
1869 return Ok(Some(r.role_id.clone()));
1870 }
1871 if !create_if_missing {
1872 return Ok(None);
1873 }
1874 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1875 let role_id = crate::crypto::sha256_hex(format!("vector/v2/role/admin/{cid_hex}").as_bytes());
1876 set_role(transport, community, &Role::admin(role_id.clone())).await?;
1877 Ok(Some(role_id))
1878}
1879
1880pub async fn grant_admin<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey) -> Result<(), String> {
1887 let session = SessionGuard::capture();
1890 let me = local_keys()?;
1891 if me.public_key() != community.owner()? {
1892 return Err("only the community owner can grant @admin".to_string());
1893 }
1894 let view = fetch_authority(transport, community).await;
1895 if !session.is_valid() {
1896 return Err("account changed during grant".to_string());
1897 }
1898 let member_hex = member.to_hex();
1899 require_grant_head(community, &view, &member_hex)?;
1900 let role_id = ensure_admin_role(transport, community, &view, true)
1901 .await?
1902 .expect("create_if_missing yields an id");
1903 let mut role_ids = view
1904 .roles
1905 .grants
1906 .iter()
1907 .find(|g| g.member == member_hex)
1908 .map(|g| g.role_ids.clone())
1909 .unwrap_or_default();
1910 if role_ids.contains(&role_id) {
1911 return Ok(()); }
1913 role_ids.push(role_id);
1914 grant_roles(transport, community, member, role_ids).await
1915}
1916
1917pub async fn revoke_admin<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, member: &PublicKey) -> Result<(), String> {
1920 let session = SessionGuard::capture();
1921 let me = local_keys()?;
1922 if me.public_key() != community.owner()? {
1923 return Err("only the community owner can revoke @admin".to_string());
1924 }
1925 let view = fetch_authority(transport, community).await;
1926 if !session.is_valid() {
1927 return Err("account changed during revoke".to_string());
1928 }
1929 let member_hex = member.to_hex();
1930 require_grant_head(community, &view, &member_hex)?;
1931 let Some(role_id) = ensure_admin_role(transport, community, &view, false).await? else {
1932 return Ok(()); };
1934 let mut role_ids = view
1935 .roles
1936 .grants
1937 .iter()
1938 .find(|g| g.member == member_hex)
1939 .map(|g| g.role_ids.clone())
1940 .unwrap_or_default();
1941 let before = role_ids.len();
1942 role_ids.retain(|r| r != &role_id);
1943 if role_ids.len() == before {
1944 return Ok(());
1945 }
1946 grant_roles(transport, community, member, role_ids).await
1947}
1948
1949fn require_grant_head(community: &CommunityV2, view: &AuthorityView, member_hex: &str) -> Result<(), String> {
1953 let Some(member) = crate::simd::hex::hex_to_bytes_32_checked(member_hex) else {
1954 return Err("malformed member key".to_string());
1955 };
1956 let eid_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &member));
1957 if view.floored.contains(&eid_hex) && !view.head_entities.contains(&eid_hex) {
1958 return Err("this member's current grant could not be fetched; try again once relays serve the control plane".to_string());
1959 }
1960 Ok(())
1961}
1962
1963pub async fn set_banlist<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, banned: &[String]) -> Result<(), String> {
1966 let session = SessionGuard::capture();
1967 super::roles::validate_banlist(banned)?;
1968 let content = super::roles::banlist_content_json(banned)?;
1969 let eid = super::derive::banlist_locator(community.id());
1970 publish_control_edition(transport, community, &session, vsk::BANLIST, &eid, &content, None).await
1971}
1972
1973pub async fn edit_community_metadata<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, meta: &control::CommunityMetadata) -> Result<(), String> {
1976 let session = SessionGuard::capture();
1977 control::validate_community_metadata(meta).map_err(|e| e.to_string())?;
1978 let content = serde_json::to_string(meta).map_err(|e| e.to_string())?;
1979 publish_control_edition(transport, community, &session, vsk::COMMUNITY_METADATA, &community.id().0, &content, None).await
1980}
1981
1982pub async fn edit_channel_metadata<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, channel_id: &ChannelId, meta: &control::ChannelMetadata) -> Result<(), String> {
1985 let session = SessionGuard::capture();
1986 let me = local_keys()?;
1987 ensure_channel_manager(community, &me.public_key())?;
1988 if meta.private {
1993 if let Some(held) = community.channel(channel_id) {
1994 if !held.private {
1995 return Err("converting a public channel to private is not supported yet".to_string());
1996 }
1997 }
1998 }
1999 control::validate_channel_metadata(meta).map_err(|e| e.to_string())?;
2000 let content = serde_json::to_string(meta).map_err(|e| e.to_string())?;
2001 publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content, None).await
2002}
2003
2004fn ensure_channel_manager(community: &CommunityV2, me: &PublicKey) -> Result<(), String> {
2010 let owner = community.owner()?;
2011 if *me == owner {
2012 return Ok(());
2013 }
2014 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2015 let me_hex = me.to_hex();
2016 if crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default().contains(&me_hex) {
2017 return Err("you are banned from this community".to_string());
2018 }
2019 let roster = crate::db::community::get_community_roles(&cid_hex)?;
2020 if roster.is_authorized(&me_hex, Some(&owner.to_hex()), crate::community::roles::Permissions::MANAGE_CHANNELS) {
2021 Ok(())
2022 } else {
2023 Err("managing channels here needs the MANAGE_CHANNELS permission".to_string())
2024 }
2025}
2026
2027pub async fn create_public_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str) -> Result<ChannelId, String> {
2033 let session = SessionGuard::capture();
2034 let lock = super::realtime::follow_lock(community.id());
2038 let _guard = lock.lock().await;
2039 let me = local_keys()?;
2040 ensure_channel_manager(community, &me.public_key())?;
2041 let channel_id = ChannelId(super::super::random_32());
2042 let meta = control::ChannelMetadata { name: name.to_string(), private: false, voice: None, deleted: None, custom: None, extra: Default::default() };
2043 control::validate_channel_metadata(&meta).map_err(|e| e.to_string())?;
2044 let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
2045 publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content, None).await?;
2046 if !session.is_valid() {
2047 return Err("account changed during channel create".to_string());
2048 }
2049 let mut updated = community.clone();
2051 updated.channels.push(ChannelV2 { id: channel_id, name: name.to_string(), private: false, key: None, epoch: updated.root_epoch });
2052 crate::db::community::save_community_v2(&updated)?;
2053 Ok(channel_id)
2054}
2055
2056pub async fn create_private_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, name: &str) -> Result<ChannelId, String> {
2067 let session = SessionGuard::capture();
2068 let lock = super::realtime::follow_lock(community.id());
2072 let _guard = lock.lock().await;
2073 let me = local_keys()?;
2074 ensure_channel_manager(community, &me.public_key())?;
2075 let meta = control::ChannelMetadata { name: name.to_string(), private: true, voice: None, deleted: None, custom: None, extra: Default::default() };
2076 control::validate_channel_metadata(&meta).map_err(|e| e.to_string())?;
2077 let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
2078
2079 let channel_id = ChannelId(super::super::random_32());
2080 let channel_key = super::super::random_32();
2081 let epoch = Epoch(1);
2082
2083 let mut recipients = memberlist(transport, community).await?;
2085 if !recipients.iter().any(|p| *p == me.public_key()) {
2086 recipients.push(me.public_key());
2087 }
2088 let prev_commit = super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
2089 let mut blobs = Vec::with_capacity(recipients.len());
2090 for r in &recipients {
2091 blobs.push(
2092 rekey::build_blob_local(me.secret_key(), &me.public_key().to_bytes(), r, RekeyScope::Channel(channel_id), epoch, &channel_key)
2093 .map_err(|e| e.to_string())?,
2094 );
2095 }
2096 let group = channel_rekey_group_key(&community.community_root, &channel_id, epoch);
2097 let at_secs = now_ms() / 1000;
2098 let chunks = rekey::build_rekey_chunks_local(&me, &group, RekeyScope::Channel(channel_id), epoch, Epoch(0), &prev_commit, &blobs, at_secs)
2099 .map_err(|e| e.to_string())?;
2100 if !session.is_valid() {
2101 return Err("account changed during channel create".to_string());
2102 }
2103 for c in &chunks {
2104 transport.publish_durable(c, &community.relays).await?;
2105 }
2106 publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content, None).await?;
2107 if !session.is_valid() {
2108 return Err("account changed during channel create".to_string());
2109 }
2110 if crate::db::community::community_protocol(community.id())?.is_none() {
2112 return Err("community removed during channel create".to_string());
2113 }
2114 let mut updated = community.clone();
2115 updated.channels.push(ChannelV2 { id: channel_id, name: name.to_string(), private: true, key: Some(channel_key), epoch });
2116 crate::db::community::save_community_v2(&updated)?;
2117 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2120 crate::db::community::store_epoch_key(&cid_hex, &crate::simd::hex::bytes_to_hex_32(&channel_id.0), epoch.0, &channel_key)?;
2121 let _ = refresh_public_links(transport, &updated).await;
2123 Ok(channel_id)
2124}
2125
2126pub async fn delete_channel<T: Transport + ?Sized>(transport: &T, community: &CommunityV2, channel_id: &ChannelId, name: &str) -> Result<(), String> {
2129 let session = SessionGuard::capture();
2130 let lock = super::realtime::follow_lock(community.id());
2132 let _guard = lock.lock().await;
2133 let me = local_keys()?;
2134 ensure_channel_manager(community, &me.public_key())?;
2135 let meta = control::ChannelMetadata { name: name.to_string(), private: false, voice: None, deleted: Some(true), custom: None, extra: Default::default() };
2136 let content = serde_json::to_string(&meta).map_err(|e| e.to_string())?;
2137 publish_control_edition(transport, community, &session, vsk::CHANNEL_METADATA, &channel_id.0, &content, None).await?;
2138 if !session.is_valid() {
2139 return Err("account changed during channel delete".to_string());
2140 }
2141 let mut updated = community.clone();
2142 updated.channels.retain(|c| c.id.0 != channel_id.0);
2143 crate::db::community::save_community_v2(&updated)?;
2144 Ok(())
2145}
2146
2147pub async fn follow_control<T: Transport + ?Sized>(
2171 transport: &T,
2172 community: &CommunityV2,
2173 session: &SessionGuard,
2174) -> Result<Option<CommunityV2>, String> {
2175 community.owner()?; let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
2177 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2178
2179 let floors: Floors = crate::db::community::get_all_edition_heads_full(&cid_hex)?
2185 .into_iter()
2186 .filter(|(_, f)| f.0 == community.root_epoch.0)
2187 .map(|(entity, f)| (entity, (f.1, f.2, f.3)))
2188 .collect();
2189
2190 let mut editions: Vec<ParsedEdition> = Vec::new();
2194 let mut seen: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
2195 let mut seen_wraps: std::collections::HashSet<nostr_sdk::EventId> = std::collections::HashSet::new();
2196 let mut oldest: Option<u64> = None;
2197 let mut until: Option<u64> = None;
2198 let mut fold = ControlFold { updated: None, heads: Vec::new(), gapped: false };
2199 let mut authority = AuthoritySet::owner_only();
2200 for _ in 0..FOLLOW_MAX_PAGES {
2201 let query = Query {
2202 kinds: vec![stream::KIND_WRAP],
2203 authors: vec![control.pk_hex()],
2204 until,
2205 limit: Some(FOLLOW_PAGE),
2206 ..Default::default()
2207 };
2208 let wraps = transport.fetch(&query, &community.relays).await?;
2209 let mut fresh = 0usize;
2213 for w in &wraps {
2214 if !seen_wraps.insert(w.id) {
2215 continue;
2216 }
2217 fresh += 1;
2218 let at = w.created_at.as_secs();
2219 if oldest.is_none_or(|o| at < o) {
2220 oldest = Some(at);
2221 }
2222 if let Ok((ed, _)) = control::open_control_edition(w, &control) {
2225 if seen.insert(ed.inner_id) {
2226 editions.push(ed);
2227 }
2228 }
2229 }
2230 authority = fold_authority(community, &editions, &floors);
2233 fold = apply_control_fold(community, &editions, &floors, &authority);
2234 if !(fold.gapped || authority.gapped) || fresh == 0 {
2235 break;
2236 }
2237 until = oldest;
2238 }
2239
2240 if !session.is_valid() {
2243 return Err("account changed during control follow".to_string());
2244 }
2245 if crate::db::community::community_protocol(community.id())?.is_none() {
2248 return Ok(None);
2249 }
2250 for h in fold.heads.iter().chain(authority.heads.iter()) {
2256 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)?;
2257 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)?;
2258 }
2259 if let Some((banned, version)) = &authority.banlist_persist {
2262 crate::db::community::set_community_banlist(&cid_hex, banned, *version as i64)?;
2263 }
2264 let newest_roster_at: i64 = editions
2271 .iter()
2272 .filter(|e| e.vsk == vsk::ROLE || e.vsk == vsk::GRANT || e.vsk == vsk::BANLIST)
2273 .map(|e| e.created_at as i64)
2274 .max()
2275 .unwrap_or(0);
2276 let stored = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
2282 let head_ents: std::collections::HashSet<&str> = authority.heads.iter().map(|h| h.entity_hex.as_str()).collect();
2283 let stored_complete = stored.roles.iter().all(|r| !floors.contains_key(&r.role_id) || head_ents.contains(r.role_id.as_str()))
2284 && stored.grants.iter().all(|g| {
2285 crate::simd::hex::hex_to_bytes_32_checked(&g.member).is_none_or(|m| {
2286 let eid = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(community.id(), &m));
2287 !floors.contains_key(&eid) || head_ents.contains(eid.as_str())
2288 })
2289 });
2290 if !authority.gapped && stored_complete && newest_roster_at >= crate::db::community::get_community_roles_at(&cid_hex)? {
2291 crate::db::community::set_community_roles(&cid_hex, &authority.roles, newest_roster_at)?;
2292 }
2293 match fold.updated {
2294 Some(u) => {
2295 crate::db::community::save_community_v2(&u)?;
2296 Ok(Some(u))
2297 }
2298 None => Ok(None),
2299 }
2300}
2301
2302const FOLLOW_MAX_PAGES: usize = 4;
2305const FOLLOW_PAGE: usize = 500;
2306
2307#[derive(Clone)]
2309struct FoldedHead {
2310 entity_hex: String,
2311 version: u64,
2312 self_hash: [u8; 32],
2313 inner_id: [u8; 32],
2314}
2315
2316struct ControlFold {
2322 updated: Option<CommunityV2>,
2323 heads: Vec<FoldedHead>,
2324 gapped: bool,
2325}
2326
2327type Floors = std::collections::HashMap<String, (u64, [u8; 32], Option<[u8; 32]>)>;
2329
2330fn apply_control_fold(community: &CommunityV2, editions: &[ParsedEdition], floors: &Floors, authority: &AuthoritySet) -> ControlFold {
2351 use crate::community::roles::Permissions;
2352 use std::collections::BTreeMap;
2353
2354 let owner_hex = community.owner().ok().map(|o| o.to_hex());
2355
2356 let mut groups: BTreeMap<(String, [u8; 32]), Vec<&ParsedEdition>> = BTreeMap::new();
2357 for e in editions {
2358 groups.entry((e.vsk.clone(), e.entity_id)).or_default().push(e);
2359 }
2360
2361 let mut out = community.clone();
2362 let mut changed = false;
2363 let mut heads = Vec::new();
2364 let mut gapped = false;
2365 for ((vsk_code, eid), group) in &groups {
2366 let is_meta = vsk_code == vsk::COMMUNITY_METADATA && *eid == community.id().0;
2371 let is_channel = vsk_code == vsk::CHANNEL_METADATA && *eid != community.id().0;
2372 if !is_meta && !is_channel {
2373 continue;
2374 }
2375 let required = if is_meta { Permissions::MANAGE_METADATA } else { Permissions::MANAGE_CHANNELS };
2380 let authed: Vec<&ParsedEdition> = group
2381 .iter()
2382 .copied()
2383 .filter(|e| {
2384 let author = e.author.to_hex();
2385 !authority.banned.contains(&author) && authority.roles.is_authorized(&author, owner_hex.as_deref(), required)
2388 })
2389 .collect();
2390 if authed.is_empty() {
2391 continue;
2392 }
2393 let entity_hex = crate::simd::hex::bytes_to_hex_32(eid);
2394 let fold_eds: Vec<version::Edition> = authed.iter().map(|p| p.to_fold_edition()).collect();
2395 let (hi, entity_gapped) = fold_head(&fold_eds, floors.get(&entity_hex));
2396 gapped |= entity_gapped;
2397 let Some(hi) = hi else { continue };
2398
2399 let head = authed[hi];
2400 heads.push(FoldedHead { entity_hex, version: head.version, self_hash: head.self_hash, inner_id: head.inner_id });
2401 if is_meta {
2402 if let Ok(meta) = serde_json::from_str::<control::CommunityMetadata>(&head.content) {
2403 changed |= apply_community_metadata(&mut out, meta);
2404 }
2405 } else if let Ok(meta) = serde_json::from_str::<control::ChannelMetadata>(&head.content) {
2406 changed |= apply_channel_metadata(&mut out, ChannelId(*eid), meta);
2410 }
2411 }
2412 ControlFold { updated: changed.then_some(out), heads, gapped }
2413}
2414
2415fn fold_head(fold_eds: &[version::Edition], floor: Option<&(u64, [u8; 32], Option<[u8; 32]>)>) -> (Option<usize>, bool) {
2422 let floor_v = floor.map(|f| f.0).unwrap_or(0);
2423 if floor_v == 0 {
2424 return (version::bootstrap_head(fold_eds, 0), false);
2425 }
2426 let floor_hash = floor.map(|f| &f.1);
2427 let held_inner = floor.and_then(|f| f.2);
2428 let result = version::fold(fold_eds, floor_v, floor_hash);
2429 if result.anchored {
2430 return (result.head, result.gap); }
2432 if result.head.is_none() && !result.gap {
2433 return (None, false); }
2435 let fork = fold_eds.iter().enumerate().filter(|(_, e)| e.version == floor_v).min_by_key(|(_, e)| e.tiebreak_id);
2439 let win_hash = match fork {
2440 Some((_, w)) if floor_hash != Some(&w.self_hash) && held_inner.is_none_or(|h| w.tiebreak_id < h) => w.self_hash,
2441 _ => return (None, true), };
2443 let re = version::fold(fold_eds, floor_v, Some(&win_hash));
2444 if !re.anchored {
2445 return (None, true);
2446 }
2447 (re.head, re.gap)
2448}
2449
2450struct AuthoritySet {
2455 roles: crate::community::roles::CommunityRoles,
2456 banned: std::collections::BTreeSet<String>,
2457 heads: Vec<FoldedHead>,
2458 gapped: bool,
2459 banlist_persist: Option<(Vec<String>, u64)>,
2463}
2464
2465impl AuthoritySet {
2466 fn owner_only() -> Self {
2469 AuthoritySet { roles: Default::default(), banned: Default::default(), heads: vec![], gapped: false, banlist_persist: None }
2470 }
2471}
2472
2473fn fold_authority(community: &CommunityV2, editions: &[ParsedEdition], floors: &Floors) -> AuthoritySet {
2480 use crate::community::roles::Permissions;
2481 use std::collections::BTreeMap;
2482
2483 let cid = community.id();
2484 let cid_hex = crate::simd::hex::bytes_to_hex_32(&cid.0);
2485 let owner = community.owner().ok();
2486 let owner_hex = owner.map(|o| o.to_hex());
2487 let banlist_eid = super::derive::banlist_locator(cid);
2488 let banlist_hex = crate::simd::hex::bytes_to_hex_32(&banlist_eid);
2489
2490 let mut groups: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
2491 for e in editions {
2492 if e.vsk == vsk::ROLE || e.vsk == vsk::GRANT || e.vsk == vsk::BANLIST {
2493 groups.entry(e.entity_id).or_default().push(e);
2494 }
2495 }
2496
2497 let mut role_cands: BTreeMap<String, Vec<AuthorityCand>> = BTreeMap::new();
2505 let mut grant_cands: BTreeMap<String, Vec<AuthorityCand>> = BTreeMap::new();
2506 let mut gapped = false;
2507
2508 for (eid, group) in &groups {
2509 if *eid == banlist_eid {
2511 continue;
2512 }
2513 let entity_hex = crate::simd::hex::bytes_to_hex_32(eid);
2514 let fold_eds: Vec<version::Edition> = group.iter().map(|p| p.to_fold_edition()).collect();
2515 let (_hi, entity_gapped) = fold_head(&fold_eds, floors.get(&entity_hex));
2516 gapped |= entity_gapped;
2517 let floor_v = floors.get(&entity_hex).map(|f| f.0).unwrap_or(0);
2518
2519 for p in group {
2520 if p.version < floor_v {
2522 continue;
2523 }
2524 let head = FoldedHead { entity_hex: entity_hex.clone(), version: p.version, self_hash: p.self_hash, inner_id: p.inner_id };
2525 match p.vsk.as_str() {
2526 vsk::ROLE => {
2527 if let Some(role) = super::roles::parse_role_content(&p.content) {
2529 if role.role_id == entity_hex && role.position != 0 {
2530 role_cands.entry(entity_hex.clone()).or_default().push(AuthorityCand { role: Some(role), grant: None, author: p.author, head });
2531 }
2532 }
2533 }
2534 vsk::GRANT => {
2535 if let Some(mut grant) = super::roles::parse_grant_content(&p.content) {
2536 if let Some(member) = crate::simd::hex::hex_to_bytes_32_checked(&grant.member) {
2537 if super::derive::grant_locator(cid, &member) == *eid {
2538 grant.role_ids.truncate(super::roles::MAX_ROLES_PER_MEMBER);
2539 grant_cands.entry(entity_hex.clone()).or_default().push(AuthorityCand { role: None, grant: Some(grant), author: p.author, head });
2540 }
2541 }
2542 }
2543 }
2544 _ => {}
2545 }
2546 }
2547 }
2548 for cands in role_cands.values_mut().chain(grant_cands.values_mut()) {
2549 cands.sort_by(|a, b| b.head.version.cmp(&a.head.version).then(a.head.inner_id.cmp(&b.head.inner_id)));
2550 }
2551
2552 let empty: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
2553 let (prelim, _) = select_authorized(&role_cands, &grant_cands, owner_hex.as_deref(), &empty);
2556
2557 let persisted_banned: Vec<String> = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
2568 let banned_authors: std::collections::HashSet<&str> = persisted_banned.iter().map(String::as_str).collect();
2573 let banlist_authored: Vec<&ParsedEdition> = groups
2574 .get(&banlist_eid)
2575 .map(|g| {
2576 g.iter()
2577 .copied()
2578 .filter(|e| {
2579 let ah = e.author.to_hex();
2580 !banned_authors.contains(ah.as_str()) && prelim.is_authorized(&ah, owner_hex.as_deref(), Permissions::BAN)
2581 })
2582 .collect()
2583 })
2584 .unwrap_or_default();
2585 let mut banlist_persist: Option<(Vec<String>, u64)> = None;
2586 let mut banlist_head: Option<FoldedHead> = None;
2587 let banned: std::collections::BTreeSet<String> = if banlist_authored.is_empty() {
2588 persisted_banned.into_iter().collect()
2589 } else {
2590 let fold_eds: Vec<version::Edition> = banlist_authored.iter().map(|p| p.to_fold_edition()).collect();
2591 let (hi, g) = fold_head(&fold_eds, floors.get(&banlist_hex));
2592 gapped |= g;
2593 match hi {
2594 Some(hi) => {
2595 let head = banlist_authored[hi];
2596 let ah = head.author.to_hex();
2597 let list: Vec<String> = super::roles::parse_banlist_content(&head.content)
2598 .unwrap_or_default()
2599 .into_iter()
2600 .filter(|t| prelim.can_act_on_member(&ah, owner_hex.as_deref(), t, Permissions::BAN))
2601 .take(super::roles::MAX_BANLIST)
2602 .collect();
2603 banlist_head = Some(FoldedHead { entity_hex: banlist_hex.clone(), version: head.version, self_hash: head.self_hash, inner_id: head.inner_id });
2604 banlist_persist = Some((list.clone(), head.version));
2605 list.into_iter().collect()
2606 }
2607 None => persisted_banned.into_iter().collect(),
2608 }
2609 };
2610
2611 let (mut authorized, mut heads) = select_authorized(&role_cands, &grant_cands, owner_hex.as_deref(), &banned);
2615 if let Some(bh) = banlist_head {
2616 heads.push(bh);
2617 }
2618
2619 if authorized.roles.len() > super::roles::MAX_ROLES_PER_COMMUNITY {
2624 authorized.roles.sort_by(|a, b| a.role_id.cmp(&b.role_id));
2625 authorized.roles.truncate(super::roles::MAX_ROLES_PER_COMMUNITY);
2626 let kept: std::collections::HashSet<&str> = authorized.roles.iter().map(|r| r.role_id.as_str()).collect();
2627 authorized.grants.iter_mut().for_each(|g| g.role_ids.retain(|rid| kept.contains(rid.as_str())));
2628 authorized.grants.retain(|g| !g.role_ids.is_empty());
2629 }
2630
2631 AuthoritySet { roles: authorized, banned, heads, gapped, banlist_persist }
2632}
2633
2634struct AuthorityCand {
2637 role: Option<crate::community::roles::Role>,
2638 grant: Option<crate::community::roles::MemberGrant>,
2639 author: PublicKey,
2640 head: FoldedHead,
2641}
2642
2643fn select_authorized(
2652 role_cands: &std::collections::BTreeMap<String, Vec<AuthorityCand>>,
2653 grant_cands: &std::collections::BTreeMap<String, Vec<AuthorityCand>>,
2654 owner_hex: Option<&str>,
2655 excluded: &std::collections::BTreeSet<String>,
2656) -> (crate::community::roles::CommunityRoles, Vec<FoldedHead>) {
2657 use crate::community::roles::{CommunityRoles, Permissions};
2658 let mut accepted = CommunityRoles::default();
2659 let mut heads: Vec<FoldedHead> = Vec::new();
2660 let bound = 2 * (role_cands.len() + grant_cands.len()) + 8;
2665 for _ in 0..bound {
2666 let mut next = CommunityRoles::default();
2667 let mut next_heads: Vec<FoldedHead> = Vec::new();
2668
2669 for cands in role_cands.values() {
2670 for c in cands {
2671 let Some(role) = &c.role else { continue };
2672 let ah = c.author.to_hex();
2673 if excluded.contains(&ah) {
2674 continue;
2675 }
2676 if role.position != 0 && accepted.can_act_on_position(&ah, owner_hex, role.position, Permissions::MANAGE_ROLES) {
2677 next.roles.push(role.clone());
2678 next_heads.push(c.head.clone());
2679 break; }
2681 }
2682 }
2683 for cands in grant_cands.values() {
2684 for c in cands {
2685 let Some(grant) = &c.grant else { continue };
2686 let ah = c.author.to_hex();
2687 if excluded.contains(&ah) || excluded.contains(&grant.member) {
2688 continue;
2689 }
2690 let positions: Option<Vec<u32>> = grant.role_ids.iter().map(|rid| accepted.role(rid).map(|r| r.position)).collect();
2693 let Some(positions) = positions else { continue };
2694 if positions.iter().all(|p| accepted.can_act_on_position(&ah, owner_hex, *p, Permissions::MANAGE_ROLES))
2695 && accepted.can_act_on_member(&ah, owner_hex, &grant.member, Permissions::MANAGE_ROLES)
2696 {
2697 next_heads.push(c.head.clone());
2701 if !grant.role_ids.is_empty() {
2702 next.grants.push(grant.clone());
2703 }
2704 break;
2705 }
2706 }
2707 }
2708
2709 let converged = next.roles == accepted.roles && next.grants == accepted.grants;
2710 accepted = next;
2711 heads = next_heads;
2712 if converged {
2713 break;
2714 }
2715 }
2716 (accepted, heads)
2717}
2718
2719fn apply_community_metadata(out: &mut CommunityV2, meta: control::CommunityMetadata) -> bool {
2723 let mut changed = false;
2724 if out.name != meta.name {
2725 out.name = meta.name;
2726 changed = true;
2727 }
2728 if out.description != meta.description {
2729 out.description = meta.description;
2730 changed = true;
2731 }
2732 if !meta.relays.is_empty() && out.relays != meta.relays {
2733 out.relays = meta.relays;
2734 changed = true;
2735 }
2736 changed
2737}
2738
2739fn apply_channel_metadata(out: &mut CommunityV2, id: ChannelId, meta: control::ChannelMetadata) -> bool {
2744 let deleted = meta.deleted.unwrap_or(false);
2745 if deleted {
2746 let before = out.channels.len();
2747 out.channels.retain(|c| c.id.0 != id.0);
2748 return out.channels.len() != before;
2749 }
2750 match out.channels.iter_mut().find(|c| c.id.0 == id.0) {
2751 Some(existing) => {
2752 let mut changed = false;
2753 if existing.name != meta.name {
2754 existing.name = meta.name;
2755 changed = true;
2756 }
2757 if !meta.private && (existing.private || existing.key.is_some()) {
2766 existing.private = false;
2767 existing.key = None;
2768 changed = true;
2769 }
2770 changed
2771 }
2772 None if !meta.private => {
2773 out.channels.push(ChannelV2 {
2776 id,
2777 name: meta.name,
2778 private: false,
2779 key: None,
2780 epoch: out.root_epoch,
2781 });
2782 true
2783 }
2784 None => {
2785 out.channels.push(ChannelV2 { id, name: meta.name, private: true, key: None, epoch: Epoch(0) });
2791 true
2792 }
2793 }
2794}
2795
2796pub struct RekeyFollow {
2800 pub updated: Option<CommunityV2>,
2803 pub self_removed: bool,
2806 pub dissolved: bool,
2809}
2810
2811const MAX_ADDRESSING_ROOTS: usize = 8;
2818
2819pub async fn follow_rekeys<T: Transport + ?Sized>(
2849 transport: &T,
2850 community: &CommunityV2,
2851 session: &SessionGuard,
2852) -> Result<RekeyFollow, String> {
2853 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2856 if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
2857 return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: true });
2858 }
2859 if is_dissolved(transport, community).await {
2865 if session.is_valid() {
2866 let _ = crate::db::community::set_community_dissolved(&cid_hex);
2867 }
2868 return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: true });
2869 }
2870 let me = local_keys()?;
2871 let my_xonly = me.public_key().to_bytes();
2872 let owner = community.owner()?;
2873 let owner_hex = owner.to_hex();
2874 let mut cur = community.clone();
2875 let mut changed = false;
2876
2877 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
2881 let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
2882 let me_hex = me.public_key().to_hex();
2883 let channel_rotator_ok = |rotator: &PublicKey| -> bool {
2884 if *rotator == owner {
2885 return true;
2886 }
2887 let rh = rotator.to_hex();
2888 !banned.contains(&rh) && roster.is_authorized(&rh, Some(&owner_hex), crate::community::roles::Permissions::MANAGE_CHANNELS)
2889 };
2890 let channel_rotator_outranks_me = |rotator: &PublicKey| -> bool {
2895 if *rotator == owner {
2896 return true;
2897 }
2898 let rh = rotator.to_hex();
2899 !banned.contains(&rh) && roster.can_act_on_member(&rh, Some(&owner_hex), &me_hex, crate::community::roles::Permissions::MANAGE_CHANNELS)
2900 };
2901 let base_rotator_ok = |rotator: &PublicKey| -> bool { *rotator == owner };
2902
2903 const MAX_STEPS: usize = 128;
2907 for _ in 0..MAX_STEPS {
2908 let mut advanced = false;
2909
2910 let mut addressing_roots: Vec<[u8; 32]> = vec![cur.community_root];
2914 let mut archived = crate::db::community::held_epoch_keys(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap_or_default();
2915 archived.sort_by(|a, b| b.0 .0.cmp(&a.0 .0));
2916 for (_, r) in archived {
2917 if !addressing_roots.contains(&r) {
2918 addressing_roots.push(r);
2919 }
2920 }
2921 addressing_roots.truncate(MAX_ADDRESSING_ROOTS);
2922
2923 let channel_ids: Vec<ChannelId> = cur.channels.iter().filter(|c| c.private).map(|c| c.id).collect();
2926 for cid in channel_ids {
2927 let (held_key, held_epoch) = match cur.channel(&cid) {
2928 Some(ch) => (ch.key, ch.epoch),
2929 None => continue,
2930 };
2931 let next = Epoch(held_epoch.0.saturating_add(1));
2932 let mut batches: Vec<(Vec<rekey::RekeyChunk>, Option<(Epoch, [u8; 32])>)> = Vec::new();
2933 for root in &addressing_roots {
2934 let group = channel_rekey_group_key(root, &cid, next);
2935 let chunks = fetch_rekey_chunks(transport, &cur.relays, &group).await?;
2936 if chunks.is_empty() {
2937 continue;
2938 }
2939 batches.push((chunks, held_key.map(|k| (held_epoch, k))));
2940 }
2941 match advance_scope(&batches, RekeyScope::Channel(cid), &channel_rotator_ok, &channel_rotator_outranks_me, me.secret_key(), &my_xonly, next) {
2948 Advance::Adopt { new_key } => {
2949 if let Some(ch) = cur.channels.iter_mut().find(|c| c.id.0 == cid.0) {
2950 ch.key = Some(new_key);
2951 ch.epoch = next;
2952 }
2953 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) {
2958 crate::log_warn!("v2: channel epoch-key archive failed (history across this rotation may not read back): {e}");
2959 }
2960 advanced = true;
2961 changed = true;
2962 }
2963 Advance::Removed => {
2964 match held_key {
2965 Some(_) => {
2967 cur.channels.retain(|c| c.id.0 != cid.0);
2968 }
2969 None => {
2974 if let Some(ch) = cur.channels.iter_mut().find(|c| c.id.0 == cid.0) {
2975 ch.epoch = next;
2976 }
2977 }
2978 }
2979 advanced = true;
2980 changed = true;
2981 }
2982 Advance::Stay => {}
2983 }
2984 }
2985
2986 {
2990 let held_epoch = cur.root_epoch;
2991 let held_key = cur.community_root;
2992 let next = Epoch(held_epoch.0.saturating_add(1));
2993 let group = base_rekey_group_key(&cur.community_root, cur.id(), next);
2994 let chunks = fetch_rekey_chunks(transport, &cur.relays, &group).await?;
2995 let batches = vec![(chunks, Some((held_epoch, held_key)))];
2996 match advance_scope(&batches, RekeyScope::Root, &base_rotator_ok, &base_rotator_ok, me.secret_key(), &my_xonly, next) {
2997 Advance::Adopt { new_key } => {
2998 cur.community_root = new_key;
2999 cur.root_epoch = next;
3000 if let Err(e) = crate::db::community::store_epoch_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, next.0, &new_key) {
3004 crate::log_warn!("v2: base epoch-key archive failed (this epoch's history may not read back after the next rotation): {e}");
3005 }
3006 advanced = true;
3007 changed = true;
3008 }
3009 Advance::Removed => {
3010 if !session.is_valid() {
3011 return Err("account changed during rekey follow".to_string());
3012 }
3013 return Ok(RekeyFollow { updated: None, self_removed: true, dissolved: false });
3014 }
3015 Advance::Stay => {}
3016 }
3017 }
3018
3019 if !advanced {
3020 break;
3021 }
3022 }
3023
3024 if !changed {
3025 return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: false });
3026 }
3027 if !session.is_valid() {
3028 return Err("account changed during rekey follow".to_string());
3029 }
3030 if crate::db::community::community_protocol(community.id())?.is_none() {
3033 return Ok(RekeyFollow { updated: None, self_removed: false, dissolved: false });
3034 }
3035 crate::db::community::save_community_v2(&cur)?;
3036 Ok(RekeyFollow { updated: Some(cur), self_removed: false, dissolved: false })
3037}
3038
3039enum Advance {
3042 Adopt { new_key: [u8; 32] },
3044 Removed,
3046 Stay,
3048}
3049
3050async fn fetch_rekey_chunks<T: Transport + ?Sized>(
3052 transport: &T,
3053 relays: &[String],
3054 group: &GroupKey,
3055) -> Result<Vec<rekey::RekeyChunk>, String> {
3056 const REKEY_PAGE: usize = 200;
3064 const REKEY_MAX_PAGES: usize = 6;
3065 let mut out = Vec::new();
3066 let mut seen: std::collections::HashSet<nostr_sdk::EventId> = std::collections::HashSet::new();
3067 let mut until: Option<u64> = None;
3068 let mut oldest: Option<u64> = None;
3069 for _ in 0..REKEY_MAX_PAGES {
3070 let query = Query {
3071 kinds: vec![stream::KIND_WRAP],
3072 authors: vec![group.pk_hex()],
3073 until,
3074 limit: Some(REKEY_PAGE),
3075 ..Default::default()
3076 };
3077 let wraps = transport.fetch(&query, relays).await?;
3078 let mut fresh = 0usize;
3079 for w in &wraps {
3080 if !seen.insert(w.id) {
3081 continue;
3082 }
3083 fresh += 1;
3084 let at = w.created_at.as_secs();
3085 if oldest.is_none_or(|o| at < o) {
3086 oldest = Some(at);
3087 }
3088 if let Ok(opened) = stream::open_wrap(w, group) {
3089 if let Ok(chunk) = rekey::parse_rekey_chunk(&opened) {
3090 out.push(chunk);
3091 }
3092 }
3093 }
3094 if fresh == 0 || wraps.len() < REKEY_PAGE {
3097 break;
3098 }
3099 match oldest {
3100 Some(o) if o > 0 => until = Some(o),
3101 _ => break,
3102 }
3103 }
3104 Ok(out)
3105}
3106
3107fn advance_scope(
3121 batches: &[(Vec<rekey::RekeyChunk>, Option<(Epoch, [u8; 32])>)],
3122 scope: RekeyScope,
3123 rotator_ok: &dyn Fn(&PublicKey) -> bool,
3124 rotator_may_remove_me: &dyn Fn(&PublicKey) -> bool,
3125 my_sk: &SecretKey,
3126 my_xonly: &[u8; 32],
3127 next_epoch: Epoch,
3128) -> Advance {
3129 let mut winners: Vec<[u8; 32]> = Vec::new();
3130 let mut saw_complete_candidate = false;
3131 let mut saw_outranking_candidate = false;
3132 let keyed = batches.iter().any(|(_, held)| held.is_some());
3133 for (chunks, held) in batches {
3134 let rotations = rekey::collect_rotations(chunks);
3135 for r in &rotations {
3136 if !rotator_ok(&r.rotator) || r.scope.id32() != scope.id32() || r.new_epoch.0 != next_epoch.0 || !r.is_complete() {
3137 continue;
3138 }
3139 if let Some((held_epoch, held_key)) = held {
3140 if r.continuity(*held_epoch, held_key) != Continuity::Extends {
3141 continue;
3142 }
3143 }
3144 saw_complete_candidate = true;
3145 saw_outranking_candidate |= rotator_may_remove_me(&r.rotator);
3146 if let Some(blob) = rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), my_xonly, r.scope, r.new_epoch) {
3147 if let Ok(k) = rekey::open_blob_local(my_sk, &r.rotator, r.scope, r.new_epoch, blob) {
3148 winners.push(k);
3149 }
3150 }
3151 }
3152 }
3153 if !winners.is_empty() {
3154 let idx = rekey::lowest_key_winner(&winners).expect("winners is non-empty");
3162 return Advance::Adopt { new_key: winners[idx] };
3163 }
3164 if saw_complete_candidate && (!keyed || saw_outranking_candidate) {
3165 Advance::Removed
3166 } else {
3167 Advance::Stay
3168 }
3169}
3170
3171#[cfg(test)]
3172mod tests {
3173 use super::super::super::transport::memory::MemoryRelay;
3174 use super::*;
3175 use crate::community::roles::{MemberGrant, Permissions, Role, RoleScope};
3176
3177 fn account_name(n: u32) -> String {
3179 const B: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
3180 let mut acct = String::from("npub1");
3181 let mut v = n as usize;
3182 for _ in 0..58 {
3183 acct.push(B[v % 32] as char);
3184 v = v / 32 + 7;
3185 }
3186 acct
3187 }
3188
3189 struct Actor {
3191 keys: Keys,
3192 account: String,
3193 }
3194
3195 struct TestBed {
3201 _tmp: tempfile::TempDir,
3202 _guard: std::sync::MutexGuard<'static, ()>,
3203 relay: MemoryRelay,
3204 relays: Vec<String>,
3205 }
3206
3207 impl TestBed {
3208 fn new() -> (TestBed, Actor, Actor) {
3209 static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(70_000);
3210 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
3211 crate::db::close_database();
3212 crate::db::clear_id_caches();
3213 let tmp = tempfile::tempdir().unwrap();
3214 crate::db::set_app_data_dir(tmp.path().to_path_buf());
3215
3216 let mut mk = || {
3217 let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3218 let account = account_name(n);
3219 std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
3220 crate::db::set_current_account(account.clone()).unwrap();
3221 crate::db::init_database(&account).unwrap();
3222 Actor { keys: Keys::generate(), account }
3223 };
3224 let owner = mk();
3225 let member = mk();
3226 let _ = crate::state::take_nostr_client();
3227 let bed = TestBed {
3228 _tmp: tmp,
3229 _guard: guard,
3230 relay: MemoryRelay::new(),
3231 relays: vec!["wss://r".to_string()],
3232 };
3233 (bed, owner, member)
3234 }
3235
3236 fn swap_to(&self, actor: &Actor) {
3238 crate::db::set_current_account(actor.account.clone()).unwrap();
3239 crate::db::init_database(&actor.account).unwrap();
3240 crate::db::clear_id_caches();
3241 crate::state::MY_SECRET_KEY.store_from_keys(&actor.keys, &[]);
3242 crate::state::set_my_public_key(actor.keys.public_key());
3243 }
3244 }
3245
3246 fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>, Keys) {
3248 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
3249 crate::db::close_database();
3250 crate::db::clear_id_caches();
3251 let tmp = tempfile::tempdir().unwrap();
3252 static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(50_000);
3253 let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3254 let acct = account_name(n);
3255 std::fs::create_dir_all(tmp.path().join(&acct)).unwrap();
3256 crate::db::set_app_data_dir(tmp.path().to_path_buf());
3257 crate::db::set_current_account(acct.clone()).unwrap();
3258 crate::db::init_database(&acct).unwrap();
3259 let _ = crate::state::take_nostr_client();
3260 let owner = Keys::generate();
3261 crate::state::MY_SECRET_KEY.store_from_keys(&owner, &[]);
3262 crate::state::set_my_public_key(owner.public_key());
3263 (tmp, guard, owner)
3264 }
3265
3266 struct SwapMidFetch {
3269 inner: MemoryRelay,
3270 }
3271 #[async_trait::async_trait]
3272 impl Transport for SwapMidFetch {
3273 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
3274 self.inner.publish(e, r).await
3275 }
3276 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
3277 self.inner.publish_durable(e, r).await
3278 }
3279 async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> {
3280 let out = self.inner.fetch(q, r).await;
3281 crate::state::bump_session_generation();
3282 out
3283 }
3284 }
3285
3286 struct FixedFetch {
3291 events: Vec<Event>,
3292 }
3293 #[async_trait::async_trait]
3294 impl Transport for FixedFetch {
3295 async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
3296 Ok(())
3297 }
3298 async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
3299 Ok(())
3300 }
3301 async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
3302 Ok(self.events.clone())
3303 }
3304 }
3305
3306 async fn fetch_direct_invite(relay: &MemoryRelay, relays: &[String], me: &PublicKey) -> Event {
3309 let q = Query {
3310 kinds: vec![stream::KIND_WRAP],
3311 p_tags: vec![me.to_hex()],
3312 k_tags: vec!["3313".to_string()],
3313 ..Default::default()
3314 };
3315 relay.fetch(&q, relays).await.unwrap().into_iter().next().expect("a direct invite is waiting")
3316 }
3317
3318 #[tokio::test]
3319 async fn create_persists_and_reloads_a_v2_community() {
3320 let (_tmp, _guard, owner) = init_test_db();
3321 let relay = MemoryRelay::new();
3322 let relays = vec!["wss://r".to_string()];
3323
3324 let created = create_community(&relay, "Vectorville", relays.clone(), Some("hi".into())).await.unwrap();
3325 assert!(created.identity.verify());
3326 assert_eq!(created.owner().unwrap(), owner.public_key());
3327 assert_eq!(created.channels.len(), 1);
3328
3329 assert_eq!(
3331 crate::db::community::community_protocol(created.id()).unwrap(),
3332 Some(crate::community::ConcordProtocol::V2)
3333 );
3334 let loaded = crate::db::community::load_community_v2(created.id()).unwrap().expect("reloads");
3335 assert_eq!(loaded.name, "Vectorville");
3336 assert_eq!(loaded.community_root, created.community_root);
3337 assert_eq!(loaded.identity, created.identity);
3338 assert_eq!(loaded.channels[0].id.0, created.channels[0].id.0);
3339 assert!(!loaded.channels[0].private);
3340
3341 assert!(relay.count_on("wss://r") >= 3, "2 genesis editions + 1 guestbook join");
3343 }
3344
3345 #[tokio::test]
3346 async fn owner_sends_and_reads_back_a_message() {
3347 let (_tmp, _guard, _owner) = init_test_db();
3348 let relay = MemoryRelay::new();
3349 let community = create_community(&relay, "Chat", vec!["wss://r".into()], None).await.unwrap();
3350 let general = community.channels[0].id;
3351
3352 let id1 = send_message(&relay, &community, &general, "hello world").await.unwrap();
3353 let id2 = send_message(&relay, &community, &general, "second message").await.unwrap();
3354 assert_ne!(id1, id2);
3355
3356 let page = fetch_channel(&relay, &community, &general, 100).await.unwrap();
3357 let texts: Vec<String> = page
3358 .iter()
3359 .filter_map(|f| match &f.event {
3360 ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
3361 _ => None,
3362 })
3363 .collect();
3364 assert_eq!(texts, vec!["hello world", "second message"], "messages round-trip in ms order");
3365 }
3366
3367 #[tokio::test]
3368 async fn a_second_member_reads_the_public_channel_from_the_root() {
3369 let (_tmp, _guard, _owner) = init_test_db();
3373 let relay = MemoryRelay::new();
3374 let community = create_community(&relay, "Public", vec!["wss://r".into()], None).await.unwrap();
3375 let general = community.channels[0].id;
3376 send_message(&relay, &community, &general, "everyone can read this").await.unwrap();
3377
3378 let member_view = community.clone();
3380 let page = fetch_channel(&relay, &member_view, &general, 100).await.unwrap();
3381 assert_eq!(page.len(), 1);
3382 assert!(matches!(&page[0].event, ChatEvent::Message { .. }));
3383 assert_eq!(page[0].event.opened().rumor.content, "everyone can read this");
3384 }
3385
3386 async fn texts_in<T: crate::community::transport::Transport + ?Sized>(relay: &T, community: &CommunityV2, channel: &ChannelId) -> Vec<String> {
3389 fetch_channel(relay, community, channel, 100)
3390 .await
3391 .unwrap()
3392 .iter()
3393 .filter_map(|f| match &f.event {
3394 ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
3395 _ => None,
3396 })
3397 .collect()
3398 }
3399
3400 #[tokio::test]
3401 async fn direct_invite_full_loop_owner_and_member_converse() {
3402 let (bed, owner, member) = TestBed::new();
3403
3404 bed.swap_to(&owner);
3406 let community = create_community(&bed.relay, "Guild", bed.relays.clone(), None).await.unwrap();
3407 let general = community.channels[0].id;
3408 send_message(&bed.relay, &community, &general, "owner: welcome!").await.unwrap();
3409 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
3410
3411 bed.swap_to(&member);
3413 assert!(
3414 crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
3415 "the member does not hold the community before joining"
3416 );
3417 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
3418 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
3419 assert_eq!(joined.id().0, community.id().0, "joined the same community");
3420 assert!(joined.identity.verify(), "the joiner independently verifies the owner commitment");
3421 assert_eq!(joined.owner().unwrap(), owner.keys.public_key());
3422
3423 assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: welcome!"]);
3425 send_message(&bed.relay, &joined, &general, "member: thanks for the invite").await.unwrap();
3426
3427 bed.swap_to(&owner);
3429 assert_eq!(
3430 texts_in(&bed.relay, &community, &general).await,
3431 vec!["owner: welcome!", "member: thanks for the invite"],
3432 "both actors' messages interleave in ms order on the shared channel"
3433 );
3434
3435 let members = memberlist(&bed.relay, &community).await.unwrap();
3437 assert!(members.contains(&owner.keys.public_key()), "owner is a member (genesis Join)");
3438 assert!(members.contains(&member.keys.public_key()), "member is a member (invite Join)");
3439 assert_eq!(members.len(), 2);
3440 }
3441
3442 #[tokio::test]
3443 async fn public_link_full_loop() {
3444 let (bed, owner, member) = TestBed::new();
3445
3446 bed.swap_to(&owner);
3447 let community = create_community(&bed.relay, "Public Guild", bed.relays.clone(), None).await.unwrap();
3448 let general = community.channels[0].id;
3449 send_message(&bed.relay, &community, &general, "come on in").await.unwrap();
3450 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
3452 assert!(link.url.starts_with("https://vectorapp.io/invite/"));
3453 assert!(link.url.contains('#'), "the fragment carries the token");
3454
3455 bed.swap_to(&member);
3457 let joined = accept_public_link(&bed.relay, &link.url).await.unwrap();
3458 assert_eq!(joined.id().0, community.id().0);
3459 assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["come on in"]);
3460 }
3461
3462 #[tokio::test]
3463 async fn a_revoked_link_refuses_to_join() {
3464 let (bed, owner, member) = TestBed::new();
3465 bed.swap_to(&owner);
3466 let community = create_community(&bed.relay, "Revoked", bed.relays.clone(), None).await.unwrap();
3467 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
3468 let tombstone = invite::build_revocation(&link.link_signer).unwrap();
3470 bed.relay.publish_durable(&tombstone, &bed.relays).await.unwrap();
3471
3472 bed.swap_to(&member);
3473 let err = accept_public_link(&bed.relay, &link.url).await.unwrap_err();
3474 assert!(err.contains("revoked"), "a retired link finds the grave, not keys: {err}");
3475 }
3476
3477 #[tokio::test]
3478 async fn an_expired_direct_invite_refuses_to_join() {
3479 let (bed, owner, member) = TestBed::new();
3480 bed.swap_to(&owner);
3481 let community = create_community(&bed.relay, "Expired", bed.relays.clone(), None).await.unwrap();
3482 let inviter = owner.keys.clone();
3484 let mut bundle = bundle_of(&community, Some(inviter.public_key()), Some(1_000), None);
3485 bundle.expires_at = Some(1_000); let wrap = invite::build_direct_invite(&inviter, &member.keys.public_key(), &bundle).unwrap();
3487 bed.relay.publish(&wrap, &bed.relays).await.unwrap();
3488
3489 bed.swap_to(&member);
3490 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
3491 let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
3492 assert!(err.contains("expired"), "a past-expiry invite refuses to join: {err}");
3493 }
3494
3495 #[tokio::test]
3496 async fn a_tombstone_beats_a_live_bundle_regardless_of_fetch_order() {
3497 let (bed, owner, member) = TestBed::new();
3501 bed.swap_to(&owner);
3502 let community = create_community(&bed.relay, "Rev", bed.relays.clone(), None).await.unwrap();
3503 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
3504 let tombstone = invite::build_revocation(&link.link_signer).unwrap();
3505
3506 let union = FixedFetch { events: vec![link.bundle_event.clone(), tombstone] };
3509
3510 bed.swap_to(&member);
3511 let err = accept_public_link(&union, &link.url).await.unwrap_err();
3512 assert!(err.contains("revoked"), "a tombstone must beat a Live returned first: {err}");
3513 }
3514
3515 #[test]
3516 fn from_bundle_refuses_an_over_cap_bundle_before_allocating() {
3517 let owner = Keys::generate();
3523 let identity = super::super::control::CommunityIdentity::mint(&owner.public_key());
3524 let hex = crate::simd::hex::bytes_to_hex_32;
3525 let root = [0x11u8; 32];
3526 let mut bundle = CommunityInvite {
3527 community_id: hex(&identity.community_id.0),
3528 owner: hex(&identity.owner_xonly),
3529 owner_salt: hex(&identity.owner_salt),
3530 community_root: hex(&root),
3531 root_epoch: 0,
3532 channels: vec![],
3533 relays: vec!["wss://r".into()],
3534 name: "X".into(),
3535 icon: None,
3536 expires_at: None,
3537 creator_npub: None,
3538 label: None,
3539 extra: Default::default(),
3540 };
3541 bundle.channels = (0..=invite::MAX_BUNDLE_CHANNELS)
3542 .map(|i| {
3543 let mut id = [0u8; 32];
3544 id[..8].copy_from_slice(&(i as u64).to_be_bytes());
3545 invite::ChannelGrant { id: hex(&id), key: hex(&root), epoch: 0, name: "x".into() }
3546 })
3547 .collect();
3548 assert!(CommunityV2::from_bundle(&bundle, 0).is_err(), "an over-cap bundle is refused before allocating");
3549 }
3550
3551 #[tokio::test]
3552 async fn a_join_swap_between_fetch_and_save_aborts_and_leaves_the_other_account_clean() {
3553 let (bed, owner, member) = TestBed::new();
3558 bed.swap_to(&owner);
3559 let community = create_community(&bed.relay, "Straddle", bed.relays.clone(), None).await.unwrap();
3560 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
3561 let swap_relay = SwapMidFetch { inner: MemoryRelay::new() };
3563 swap_relay.inner.publish_durable(&link.bundle_event, &bed.relays).await.unwrap();
3564
3565 bed.swap_to(&member);
3566 let err = accept_public_link(&swap_relay, &link.url).await.unwrap_err();
3567 assert!(err.contains("account changed"), "a swap mid-join must abort: {err}");
3568 assert!(
3569 crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
3570 "the aborted join wrote nothing to the (member) account DB"
3571 );
3572 }
3573
3574 #[tokio::test]
3575 async fn the_owner_is_a_member_even_without_a_fetched_genesis_join() {
3576 let (_tmp, _guard, owner) = init_test_db();
3579 let relay = MemoryRelay::new();
3580 let community = create_community(&relay, "Owned", vec!["wss://r".into()], None).await.unwrap();
3581 let empty = MemoryRelay::new();
3584 let members = memberlist(&empty, &community).await.unwrap();
3585 assert_eq!(members, vec![owner.public_key()], "owner present with no fetched Join");
3586 }
3587
3588 #[tokio::test]
3589 async fn an_expiring_minted_invite_refuses_after_the_deadline() {
3590 let (bed, owner, member) = TestBed::new();
3593 bed.swap_to(&owner);
3594 let community = create_community(&bed.relay, "Timed", bed.relays.clone(), None).await.unwrap();
3595 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), Some(1_000), Some("beta".into()))
3596 .await
3597 .unwrap();
3598
3599 bed.swap_to(&member);
3600 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
3601 assert!(
3602 accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err().contains("expired"),
3603 "a minted expiring invite refuses past its deadline"
3604 );
3605 }
3606
3607 #[tokio::test]
3608 async fn a_member_who_leaves_drops_from_the_memberlist() {
3609 let (bed, owner, member) = TestBed::new();
3610 bed.swap_to(&owner);
3611 let community = create_community(&bed.relay, "Leaving", bed.relays.clone(), None).await.unwrap();
3612 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
3613
3614 bed.swap_to(&member);
3615 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
3616 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
3617 tokio::time::sleep(std::time::Duration::from_millis(2)).await;
3619 leave_community(&bed.relay, &joined).await.unwrap();
3620
3621 bed.swap_to(&owner);
3622 let members = memberlist(&bed.relay, &community).await.unwrap();
3623 assert!(members.contains(&owner.keys.public_key()));
3624 assert!(!members.contains(&member.keys.public_key()), "a member who left drops from the list");
3625 }
3626
3627 #[tokio::test]
3628 async fn a_swapped_member_cannot_see_the_owners_community_until_joining() {
3629 let (bed, owner, member) = TestBed::new();
3632 bed.swap_to(&owner);
3633 let community = create_community(&bed.relay, "Private-so-far", bed.relays.clone(), None).await.unwrap();
3634 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_some());
3635
3636 bed.swap_to(&member);
3637 assert!(
3638 crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
3639 "the owner's community must be invisible in the member's account DB"
3640 );
3641 assert_eq!(crate::db::community::list_community_ids().unwrap().len(), 0);
3642 }
3643
3644 async fn head_hash_on_relay(relay: &MemoryRelay, community: &CommunityV2, entity_id: &[u8; 32]) -> Option<[u8; 32]> {
3652 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
3653 let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
3654 let wraps = relay.fetch(&query, &community.relays).await.ok()?;
3655 let mut head: Option<(u64, [u8; 32])> = None;
3656 for w in &wraps {
3657 if let Ok((ed, _)) = control::open_control_edition(w, &group) {
3658 if ed.entity_id == *entity_id && head.is_none_or(|(v, _)| ed.version > v) {
3659 head = Some((ed.version, ed.self_hash));
3660 }
3661 }
3662 }
3663 head.map(|(_, h)| h)
3664 }
3665
3666 async fn publish_channel_edition(
3667 relay: &MemoryRelay,
3668 community: &CommunityV2,
3669 signer: &Keys,
3670 channel_id: &ChannelId,
3671 name: &str,
3672 private: bool,
3673 version: u64,
3674 deleted: bool,
3675 ) {
3676 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
3677 let prev = head_hash_on_relay(relay, community, &channel_id.0).await;
3678 let meta = control::ChannelMetadata { name: name.into(), private, deleted: deleted.then_some(true), ..Default::default() };
3679 let content = serde_json::to_string(&meta).unwrap();
3680 let rumor = control::build_edition_rumor(signer.public_key(), vsk::CHANNEL_METADATA, &channel_id.0, version, prev.as_ref(), &content, 1_000, None);
3681 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
3682 relay.publish(&wrap, &community.relays).await.unwrap();
3683 }
3684
3685 async fn publish_community_meta(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, name: &str, version: u64) {
3688 publish_community_meta_at(relay, community, signer, name, version, 1_000).await;
3689 }
3690
3691 async fn publish_community_meta_at(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, name: &str, version: u64, at_secs: u64) {
3694 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
3695 let prev = head_hash_on_relay(relay, community, &community.id().0).await;
3696 let meta = control::CommunityMetadata { name: name.into(), ..Default::default() };
3697 let content = serde_json::to_string(&meta).unwrap();
3698 let rumor = control::build_edition_rumor(signer.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, version, prev.as_ref(), &content, at_secs, None);
3699 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(at_secs)).unwrap();
3700 relay.publish(&wrap, &community.relays).await.unwrap();
3701 }
3702
3703 async fn publish_role(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, role: &Role, version: u64) {
3705 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
3706 let role_id = crate::simd::hex::hex_to_bytes_32_checked(&role.role_id).unwrap();
3707 let prev = head_hash_on_relay(relay, community, &role_id).await;
3708 let content = crate::community::v2::roles::role_content_json(role).unwrap();
3709 let rumor = control::build_edition_rumor(signer.public_key(), vsk::ROLE, &role_id, version, prev.as_ref(), &content, 1_000, None);
3710 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
3711 relay.publish(&wrap, &community.relays).await.unwrap();
3712 }
3713
3714 async fn publish_grant(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, member: &PublicKey, role_ids: Vec<String>, version: u64) {
3716 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
3717 let eid = crate::community::v2::derive::grant_locator(community.id(), &member.to_bytes());
3718 let prev = head_hash_on_relay(relay, community, &eid).await;
3719 let grant = MemberGrant { member: member.to_hex(), role_ids };
3720 let content = crate::community::v2::roles::grant_content_json(&grant).unwrap();
3721 let rumor = control::build_edition_rumor(signer.public_key(), vsk::GRANT, &eid, version, prev.as_ref(), &content, 1_000, None);
3722 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
3723 relay.publish(&wrap, &community.relays).await.unwrap();
3724 }
3725
3726 async fn publish_banlist(relay: &MemoryRelay, community: &CommunityV2, signer: &Keys, banned: &[String], version: u64) {
3728 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
3729 let eid = crate::community::v2::derive::banlist_locator(community.id());
3730 let prev = head_hash_on_relay(relay, community, &eid).await;
3731 let content = crate::community::v2::roles::banlist_content_json(banned).unwrap();
3732 let rumor = control::build_edition_rumor(signer.public_key(), vsk::BANLIST, &eid, version, prev.as_ref(), &content, 1_000, None);
3733 let (wrap, _) = control::seal_control_edition(&rumor, &group, signer, Timestamp::from_secs(1_000)).unwrap();
3734 relay.publish(&wrap, &community.relays).await.unwrap();
3735 }
3736
3737 fn admin_role(role_id: &str, perms: u64) -> Role {
3738 Role { role_id: role_id.into(), name: "Admin".into(), position: 1, permissions: Permissions(perms), scope: RoleScope::Server, color: 0 }
3739 }
3740
3741 #[tokio::test]
3747 async fn a_non_owner_cannot_suppress_the_admin_role_by_forging_a_higher_version() {
3748 let (bed, owner, attacker) = TestBed::new();
3749 bed.swap_to(&owner);
3750 let community = create_community(&bed.relay, "AttackA", bed.relays.clone(), None).await.unwrap();
3751 let victim = Keys::generate().public_key();
3752 grant_admin(&bed.relay, &community, &victim).await.unwrap();
3753
3754 let admin_rid = fetch_authority(&bed.relay, &community)
3756 .await
3757 .roles
3758 .roles
3759 .iter()
3760 .find(|r| r.permissions.contains(Permissions::ADMIN_ALL))
3761 .unwrap()
3762 .role_id
3763 .clone();
3764 publish_role(
3766 &bed.relay,
3767 &community,
3768 &attacker.keys,
3769 &Role { role_id: admin_rid.clone(), name: "pwned".into(), position: 1, permissions: Permissions(0), scope: RoleScope::Server, color: 0 },
3770 2,
3771 )
3772 .await;
3773
3774 let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
3775 assert!(authority.roles.is_admin(&victim.to_hex()), "the forged strip is DROPPED; the owner's admin role survives beneath it");
3776 assert!(
3777 authority.heads.iter().any(|h| h.entity_hex == admin_rid && h.version == 1),
3778 "the floor advances only to the AUTHORIZED head (owner v1)"
3779 );
3780 assert!(!authority.heads.iter().any(|h| h.version == 2), "the forged v2 never poisons the floor");
3781 }
3782
3783 #[tokio::test]
3784 async fn a_non_owner_cannot_strip_a_members_grant_by_forging_a_higher_version() {
3785 let (bed, owner, attacker) = TestBed::new();
3786 bed.swap_to(&owner);
3787 let community = create_community(&bed.relay, "AttackC", bed.relays.clone(), None).await.unwrap();
3788 let victim = Keys::generate();
3789 grant_admin(&bed.relay, &community, &victim.public_key()).await.unwrap();
3790
3791 publish_grant(&bed.relay, &community, &attacker.keys, &victim.public_key(), vec![], 9).await;
3793
3794 let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
3795 assert!(
3796 authority.roles.is_admin(&victim.public_key().to_hex()),
3797 "the forged strip is dropped; the owner's grant survives and the victim keeps admin"
3798 );
3799 }
3800
3801 #[tokio::test]
3802 async fn forged_low_id_roles_by_a_non_owner_never_enter_the_authorized_roster() {
3803 let (bed, owner, attacker) = TestBed::new();
3804 bed.swap_to(&owner);
3805 let community = create_community(&bed.relay, "AttackB", bed.relays.clone(), None).await.unwrap();
3806 let victim = Keys::generate().public_key();
3807 grant_admin(&bed.relay, &community, &victim).await.unwrap();
3808
3809 for i in 0u8..6 {
3812 let rid = crate::simd::hex::bytes_to_hex_32(&[i; 32]);
3813 publish_role(&bed.relay, &community, &attacker.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
3814 }
3815
3816 let authority = fold_authority(&community, &fetch_control(&bed.relay, &community).await, &load_floors(&community));
3817 assert!(authority.roles.is_admin(&victim.to_hex()), "the legit admin survives the forged flood");
3818 assert_eq!(authority.roles.roles.len(), 1, "only the owner's admin role is authorized; every forgery is dropped");
3819 }
3820
3821 fn authority_fingerprint(a: &AuthoritySet) -> String {
3824 let mut roles = a.roles.roles.clone();
3825 roles.sort_by(|x, y| x.role_id.cmp(&y.role_id));
3826 let mut grants = a.roles.grants.clone();
3827 for g in &mut grants {
3828 g.role_ids.sort();
3829 }
3830 grants.sort_by(|x, y| x.member.cmp(&y.member));
3831 let banned: Vec<&String> = a.banned.iter().collect();
3832 serde_json::json!({ "roles": roles, "grants": grants, "banned": banned }).to_string()
3833 }
3834
3835 #[tokio::test]
3836 async fn the_v2_authority_fold_is_order_independent() {
3837 let (bed, owner, _a) = TestBed::new();
3843 bed.swap_to(&owner);
3844 let community = create_community(&bed.relay, "Determinism", bed.relays.clone(), None).await.unwrap();
3845
3846 let admin1 = Keys::generate().public_key();
3849 let admin2 = Keys::generate().public_key();
3850 grant_admin(&bed.relay, &community, &admin1).await.unwrap();
3851 grant_admin(&bed.relay, &community, &admin2).await.unwrap();
3852 let mod_rid = "5c".repeat(32);
3853 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&mod_rid, Permissions::KICK | Permissions::MANAGE_MESSAGES), 1).await;
3854 let member = Keys::generate().public_key();
3855 publish_grant(&bed.relay, &community, &owner.keys, &member, vec![mod_rid.clone()], 1).await;
3856 let banned_member = Keys::generate().public_key();
3857 publish_grant(&bed.relay, &community, &owner.keys, &banned_member, vec![mod_rid], 1).await;
3858 set_banlist(&bed.relay, &community, &[banned_member.to_hex()]).await.unwrap();
3859 let meta = control::CommunityMetadata { name: "Renamed".into(), relays: community.relays.clone(), ..Default::default() };
3860 edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
3861 create_public_channel(&bed.relay, &community, "extra").await.unwrap();
3862
3863 let editions = fetch_control(&bed.relay, &community).await;
3864 let floors = load_floors(&community);
3865 assert!(editions.len() >= 6, "a rich plane was built ({} editions)", editions.len());
3866
3867 let baseline = authority_fingerprint(&fold_authority(&community, &editions, &floors));
3868
3869 let mut orders: Vec<Vec<ParsedEdition>> = Vec::new();
3872 let mut rev = editions.clone();
3873 rev.reverse();
3874 orders.push(rev);
3875 for shift in [1usize, 3, 5, 7] {
3876 let n = editions.len();
3877 orders.push((0..n).map(|i| editions[(i + shift) % n].clone()).collect());
3878 }
3879 let mut zip = Vec::with_capacity(editions.len());
3881 let (mut lo, mut hi) = (0isize, editions.len() as isize - 1);
3882 while lo <= hi {
3883 zip.push(editions[lo as usize].clone());
3884 if lo != hi {
3885 zip.push(editions[hi as usize].clone());
3886 }
3887 lo += 1;
3888 hi -= 1;
3889 }
3890 orders.push(zip);
3891
3892 for (i, order) in orders.iter().enumerate() {
3893 let got = authority_fingerprint(&fold_authority(&community, order, &floors));
3894 assert_eq!(got, baseline, "arrival order #{i} must resolve the identical authority (consensus)");
3895 }
3896 assert!(baseline.contains(&admin1.to_hex()) || baseline.contains(&member.to_hex()), "grants are present in the fingerprint");
3899 assert!(baseline.contains(&banned_member.to_hex()), "the banlist entry is in the fingerprint");
3900 }
3901
3902 struct FetchErrors(MemoryRelay);
3904 #[async_trait::async_trait]
3905 impl crate::community::transport::Transport for FetchErrors {
3906 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> {
3907 self.0.publish(e, r).await
3908 }
3909 async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
3910 Err("relay down".to_string())
3911 }
3912 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
3913 self.0.publish_durable(e, r).await
3914 }
3915 }
3916
3917 #[tokio::test]
3918 async fn fetch_authority_retains_the_persisted_banlist_on_a_transport_error() {
3919 let (bed, owner, victim) = TestBed::new();
3920 bed.swap_to(&owner);
3921 let community = create_community(&bed.relay, "BanRetain", bed.relays.clone(), None).await.unwrap();
3922 let victim_hex = victim.keys.public_key().to_hex();
3923 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3925 crate::db::community::set_community_banlist(&cid_hex, &[victim_hex.clone()], 1).unwrap();
3926
3927 let down = FetchErrors(MemoryRelay::new());
3930 let view = fetch_authority(&down, &community).await;
3931 assert!(view.banned.contains(&victim_hex), "a transport error retains the persisted banlist");
3932 }
3933
3934 #[tokio::test]
3935 async fn follow_control_retains_the_roster_when_a_floored_role_ages_out() {
3936 let (bed, owner, _m) = TestBed::new();
3937 bed.swap_to(&owner);
3938 let community = create_community(&bed.relay, "Complete", bed.relays.clone(), None).await.unwrap();
3939 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
3940 let (a, b) = (Keys::generate().public_key(), Keys::generate().public_key());
3941 let rid = crate::simd::hex::bytes_to_hex_32(&[0x7c; 32]);
3942
3943 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
3945 publish_grant(&bed.relay, &community, &owner.keys, &a, vec![rid.clone()], 1).await;
3946 publish_grant(&bed.relay, &community, &owner.keys, &b, vec![rid.clone()], 1).await;
3947 let session = crate::state::SessionGuard::capture();
3948 follow_control(&bed.relay, &community, &session).await.unwrap();
3949 assert!(crate::db::community::get_community_roles(&cid_hex).unwrap().is_admin(&a.to_hex()), "seeded");
3950
3951 let relay2 = MemoryRelay::new();
3955 publish_grant(&relay2, &community, &owner.keys, &a, vec![rid.clone()], 1).await;
3956 follow_control(&relay2, &community, &session).await.unwrap();
3957 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
3958 assert!(roster.is_admin(&a.to_hex()) && roster.is_admin(&b.to_hex()), "a floored-but-unfetched role retains the stored roster");
3959 }
3960
3961 #[tokio::test]
3962 async fn an_authorized_admin_edits_metadata_but_a_demoted_one_cannot() {
3963 let (_tmp, _guard, owner) = init_test_db();
3967 let relay = MemoryRelay::new();
3968 let community = create_community(&relay, "Base", vec!["wss://r".into()], None).await.unwrap();
3969 let admin = Keys::generate();
3970 let rid = "a1".repeat(32);
3971 publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
3972 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
3973 publish_community_meta(&relay, &community, &admin, "Admin Rename", 2).await;
3974
3975 let session = SessionGuard::capture();
3976 let updated = follow_control(&relay, &community, &session).await.unwrap().expect("admin edit authorized");
3977 assert_eq!(updated.name, "Admin Rename", "an admin with MANAGE_METADATA renames");
3978
3979 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![], 2).await; publish_community_meta(&relay, &community, &admin, "Demoted Rename", 3).await;
3981 let _ = follow_control(&relay, &community, &session).await.unwrap();
3982 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
3983 assert_eq!(held.name, "Admin Rename", "a demoted admin's edit is dropped; the name holds");
3984 }
3985
3986 #[tokio::test]
3987 async fn a_roleless_member_cannot_edit_metadata() {
3988 let (_tmp, _guard, _owner) = init_test_db();
3989 let relay = MemoryRelay::new();
3990 let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
3991 let stranger = Keys::generate();
3992 publish_community_meta(&relay, &community, &stranger, "Hijacked", 2).await;
3993 let session = SessionGuard::capture();
3994 assert!(
3995 follow_control(&relay, &community, &session).await.unwrap().is_none(),
3996 "a roleless member's metadata edit never folds"
3997 );
3998 }
3999
4000 #[tokio::test]
4001 async fn a_self_signed_grant_is_not_authority() {
4002 let (_tmp, _guard, _owner) = init_test_db();
4006 let relay = MemoryRelay::new();
4007 let community = create_community(&relay, "NoSelfPromo", vec!["wss://r".into()], None).await.unwrap();
4008 let rogue = Keys::generate();
4009 let rid = "b2".repeat(32);
4010 publish_role(&relay, &community, &rogue, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
4011 publish_grant(&relay, &community, &rogue, &rogue.public_key(), vec![rid.clone()], 1).await;
4012 publish_community_meta(&relay, &community, &rogue, "Seized", 2).await;
4013 let session = SessionGuard::capture();
4014 assert!(
4015 follow_control(&relay, &community, &session).await.unwrap().is_none(),
4016 "a self-signed grant confers no authority"
4017 );
4018 }
4019
4020 #[tokio::test]
4021 async fn the_banlist_is_enforced_only_from_a_ban_holder() {
4022 let (_tmp, _guard, owner) = init_test_db();
4023 let relay = MemoryRelay::new();
4024 let community = create_community(&relay, "Bans", vec!["wss://r".into()], None).await.unwrap();
4025 let target = "cc".repeat(32);
4026
4027 let rogue = Keys::generate();
4029 publish_banlist(&relay, &community, &rogue, &[target.clone()], 1).await;
4030 let floors = load_floors(&community);
4031 let editions = fetch_control(&relay, &community).await;
4032 let authority = fold_authority(&community, &editions, &floors);
4033 assert!(authority.banned.is_empty(), "a non-owner (no BAN) banlist is not enforced");
4034
4035 publish_banlist(&relay, &community, &owner, &[target.clone()], 2).await;
4037 let editions = fetch_control(&relay, &community).await;
4038 let authority = fold_authority(&community, &editions, &floors);
4039 assert!(authority.banned.contains(&target), "the owner's banlist is enforced");
4040 }
4041
4042 #[tokio::test]
4043 async fn a_banned_admin_loses_all_authority() {
4044 let (_tmp, _guard, owner) = init_test_db();
4047 let relay = MemoryRelay::new();
4048 let community = create_community(&relay, "BanAuth", vec!["wss://r".into()], None).await.unwrap();
4049 let admin = Keys::generate();
4050 let rid = "e5".repeat(32);
4051 publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
4052 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
4053 publish_banlist(&relay, &community, &owner, &[admin.public_key().to_hex()], 1).await; publish_community_meta(&relay, &community, &admin, "Banned Rename", 2).await;
4055
4056 let session = SessionGuard::capture();
4057 assert!(
4058 follow_control(&relay, &community, &session).await.unwrap().is_none(),
4059 "a banned admin's edit is dropped even with an unstripped grant"
4060 );
4061 let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
4062 assert!(authority.banned.contains(&admin.public_key().to_hex()));
4063 assert!(
4064 !authority.roles.is_authorized(&admin.public_key().to_hex(), Some(&owner.public_key().to_hex()), Permissions::MANAGE_METADATA),
4065 "a banned admin holds no bit"
4066 );
4067 }
4068
4069 #[tokio::test]
4070 async fn a_ban_holder_cannot_ban_a_superior_or_the_owner() {
4071 let (_tmp, _guard, owner) = init_test_db();
4075 let relay = MemoryRelay::new();
4076 let community = create_community(&relay, "Ranks", vec!["wss://r".into()], None).await.unwrap();
4077 let admin = Keys::generate();
4078 let moder = Keys::generate();
4079 let stranger = Keys::generate();
4080 let (admin_rid, mod_rid) = ("a1".repeat(32), "b2".repeat(32));
4081 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;
4082 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;
4083 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![admin_rid], 1).await;
4084 publish_grant(&relay, &community, &owner, &moder.public_key(), vec![mod_rid], 1).await;
4085 publish_banlist(&relay, &community, &moder, &[admin.public_key().to_hex(), owner.public_key().to_hex(), stranger.public_key().to_hex()], 1).await;
4086
4087 let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
4088 assert!(!authority.banned.contains(&admin.public_key().to_hex()), "a mod cannot ban a superior admin");
4089 assert!(!authority.banned.contains(&owner.public_key().to_hex()), "nobody can ban the owner");
4090 assert!(authority.banned.contains(&stranger.public_key().to_hex()), "the mod CAN ban a lower-ranked member");
4091 }
4092
4093 #[tokio::test]
4094 async fn an_unauthorized_higher_banlist_cannot_unban() {
4095 let (_tmp, _guard, owner) = init_test_db();
4099 let relay = MemoryRelay::new();
4100 let community = create_community(&relay, "NoUnban", vec!["wss://r".into()], None).await.unwrap();
4101 let target = "cc".repeat(32);
4102 publish_banlist(&relay, &community, &owner, &[target.clone()], 1).await;
4103 let session = SessionGuard::capture();
4104 follow_control(&relay, &community, &session).await.unwrap(); let rogue = Keys::generate();
4107 publish_banlist(&relay, &community, &rogue, &[], 2).await; let authority = fold_authority(&community, &fetch_control(&relay, &community).await, &load_floors(&community));
4109 assert!(authority.banned.contains(&target), "an unauthorized higher banlist cannot un-ban");
4110 }
4111
4112 #[tokio::test]
4113 async fn the_community_list_syncs_a_membership_to_a_fresh_device() {
4114 let (_tmp, _guard, _owner) = init_test_db();
4117 let relay = MemoryRelay::new();
4118 let relays = vec!["wss://r".to_string()];
4119 let community = create_community(&relay, "Synced", relays.clone(), None).await.unwrap();
4120 crate::db::community::delete_community(&crate::simd::hex::bytes_to_hex_32(&community.id().0)).unwrap();
4121 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none());
4122
4123 let rehydrated = sync_community_list(&relay, &relays).await.unwrap();
4124 assert_eq!(rehydrated.len(), 1, "the left-behind membership rehydrates");
4125 assert_eq!(rehydrated[0].id().0, community.id().0);
4126 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_some(), "and is now held locally");
4127 }
4128
4129 #[tokio::test]
4130 async fn a_leave_tombstones_the_membership_so_sync_does_not_rejoin() {
4131 let (_tmp, _guard, _owner) = init_test_db();
4132 let relay = MemoryRelay::new();
4133 let relays = vec!["wss://r".to_string()];
4134 let community = create_community(&relay, "Left", relays.clone(), None).await.unwrap();
4135 leave_community(&relay, &community).await.unwrap(); let rehydrated = sync_community_list(&relay, &relays).await.unwrap();
4138 assert!(rehydrated.is_empty(), "a tombstoned membership is not rejoined on sync");
4139 }
4140
4141 #[tokio::test]
4142 async fn accepting_the_same_bundle_twice_is_idempotent() {
4143 let (bed, owner, member) = TestBed::new();
4147 bed.swap_to(&owner);
4148 let community = create_community(&bed.relay, "Idem", bed.relays.clone(), None).await.unwrap();
4149 create_public_channel(&bed.relay, &community, "extra").await.unwrap();
4150 let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4151 let bundle = serde_json::to_string(&bundle_of(&community, Some(owner.keys.public_key()), None, None)).unwrap();
4152
4153 bed.swap_to(&member);
4154 let first = accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
4155 let channels_after_first = first.channels.len();
4156 let root_after_first = first.community_root;
4157
4158 let second = accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
4160 assert_eq!(second.id().0, first.id().0, "same community_id");
4161 assert_eq!(second.channels.len(), channels_after_first, "no duplicate channels on re-accept");
4162 assert_eq!(second.community_root, root_after_first, "root unchanged");
4163
4164 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4166 assert_eq!(reloaded.channels.len(), channels_after_first, "the DB holds one clean channel set");
4167 assert_eq!(crate::db::community::list_community_ids().unwrap().iter().filter(|id| id.0 == community.id().0).count(), 1, "exactly one community row");
4168 }
4169
4170 #[tokio::test]
4171 async fn a_severed_member_can_be_unbanned_and_re_admitted() {
4172 let (bed, owner, member) = TestBed::new();
4177 bed.swap_to(&owner);
4178 let mut community = create_community(&bed.relay, "Redeemable", bed.relays.clone(), None).await.unwrap();
4179 let general = community.channels[0].id;
4180 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
4181 send_message(&bed.relay, &community, &general, "owner: welcome").await.unwrap();
4182
4183 bed.swap_to(&member);
4184 let invite = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
4185 let joined = accept_direct_invite(&bed.relay, &invite).await.unwrap();
4186 assert!(texts_in(&bed.relay, &joined, &general).await.contains(&"owner: welcome".to_string()));
4187
4188 bed.swap_to(&owner);
4190 set_banlist(&bed.relay, &community, &[member.keys.public_key().to_hex()]).await.unwrap();
4191 grant_roles(&bed.relay, &community, &member.keys.public_key(), vec![]).await.unwrap();
4192 community = refound_community(&bed.relay, &community, &[member.keys.public_key()]).await.unwrap();
4193 assert_eq!(community.root_epoch, Epoch(1));
4194 send_message(&bed.relay, &community, &general, "owner: after the ban").await.unwrap();
4195
4196 bed.swap_to(&member);
4198 let session = SessionGuard::capture();
4199 assert!(follow_rekeys(&bed.relay, &joined, &session).await.unwrap().self_removed, "the member is cryptographically severed");
4200
4201 bed.swap_to(&owner);
4205 set_banlist(&bed.relay, &community, &[]).await.unwrap();
4206 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4207 assert_eq!(community.root_epoch, Epoch(1), "the owner's bundle carries epoch 1");
4208 let fresh_bundle = serde_json::to_string(&bundle_of(&community, Some(owner.keys.public_key()), None, None)).unwrap();
4209
4210 bed.swap_to(&member);
4212 let rejoined = accept_parked_invite(&bed.relay, &fresh_bundle, None).await.unwrap();
4213 assert_eq!(rejoined.root_epoch, Epoch(1), "rejoined at the current epoch");
4214 assert_eq!(rejoined.community_root, community.community_root, "holds the NEW root");
4215 let seen = texts_in(&bed.relay, &rejoined, &general).await;
4216 assert!(seen.contains(&"owner: after the ban".to_string()), "reads post-ban history with the new root");
4217 send_message(&bed.relay, &rejoined, &general, "member: i am back").await.unwrap();
4218
4219 bed.swap_to(&owner);
4220 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4221 assert!(
4222 texts_in(&bed.relay, &community, &general).await.contains(&"member: i am back".to_string()),
4223 "the re-admitted member converses again at the new epoch"
4224 );
4225 let members = memberlist(&bed.relay, &community).await.unwrap();
4227 assert!(members.contains(&member.keys.public_key()), "the re-admitted member is in the list");
4228 }
4229
4230 #[tokio::test]
4231 async fn dissolution_blocks_a_join() {
4232 let (bed, owner, member) = TestBed::new();
4235 bed.swap_to(&owner);
4236 let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
4237 let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
4238 let bundle_json = serde_json::to_string(&bundle).unwrap();
4239 dissolve_community(&bed.relay, &community).await.unwrap();
4240 assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the owner's local hold is sealed");
4241
4242 bed.swap_to(&member);
4243 let err = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap_err();
4244 assert!(err.contains("dissolved"), "a join refuses a dissolved community: {err}");
4245 }
4246
4247 #[tokio::test]
4248 async fn only_the_owner_can_dissolve() {
4249 let (bed, owner, member) = TestBed::new();
4250 bed.swap_to(&owner);
4251 let community = create_community(&bed.relay, "Mine", bed.relays.clone(), None).await.unwrap();
4252 bed.swap_to(&member);
4253 assert!(dissolve_community(&bed.relay, &community).await.is_err(), "only the owner can dissolve");
4254 assert!(!is_dissolved(&bed.relay, &community).await, "and no tombstone was published");
4255 }
4256
4257 #[tokio::test]
4258 async fn a_foreign_tombstone_is_not_death() {
4259 let (_tmp, _guard, _owner) = init_test_db();
4262 let relay = MemoryRelay::new();
4263 let community = create_community(&relay, "Safe", vec!["wss://r".into()], None).await.unwrap();
4264 let rogue = Keys::generate();
4265 let rumor = crate::community::v2::dissolution::dissolved_tombstone_rumor(rogue.public_key(), community.id(), 1_000);
4266 let wrap = crate::community::v2::dissolution::seal_dissolved(&rumor, community.id(), &rogue, Timestamp::from_secs(1_000)).unwrap();
4267 relay.publish(&wrap, &community.relays).await.unwrap();
4268 assert!(!is_dissolved(&relay, &community).await, "a foreign-signed tombstone is not death");
4269 }
4270
4271 #[tokio::test]
4272 async fn a_public_channel_reads_history_across_a_refounding() {
4273 let (_tmp, _guard, _owner) = init_test_db();
4277 let relay = MemoryRelay::new();
4278 let community = create_community(&relay, "History", vec!["wss://r".into()], None).await.unwrap();
4279 let general = community.channels[0].id;
4280 send_message(&relay, &community, &general, "before the refounding").await.unwrap();
4281
4282 let refounded = refound_community(&relay, &community, &[]).await.unwrap();
4283 assert_eq!(refounded.root_epoch, Epoch(1), "the epoch advanced");
4284 send_message(&relay, &refounded, &general, "after the refounding").await.unwrap();
4285
4286 let texts = texts_in(&relay, &refounded, &general).await;
4287 assert!(texts.contains(&"before the refounding".to_string()), "the epoch-0 message is still readable");
4288 assert!(texts.contains(&"after the refounding".to_string()), "the epoch-1 message reads too");
4289 }
4290
4291 #[tokio::test]
4292 async fn refounding_aborts_when_control_state_is_withheld() {
4293 let (_tmp, _guard, owner) = init_test_db();
4297 let relay = MemoryRelay::new();
4298 let community = create_community(&relay, "Withheld", vec!["wss://good".into()], None).await.unwrap();
4299 publish_banlist(&relay, &community, &owner, &["cc".repeat(32)], 1).await;
4300 let session = SessionGuard::capture();
4301 follow_control(&relay, &community, &session).await.unwrap(); let mut moved = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4306 moved.relays = vec!["wss://empty".into()];
4307 crate::db::community::save_community_v2(&moved).unwrap();
4308
4309 let err = refound_community(&relay, &moved, &[]).await.unwrap_err();
4310 assert!(err.contains("was not served"), "a withheld control head aborts the refounding: {err}");
4311 assert_eq!(
4312 crate::db::community::load_community_v2(community.id()).unwrap().unwrap().root_epoch,
4313 Epoch(0),
4314 "the epoch did NOT advance (zero published state)"
4315 );
4316 }
4317
4318 #[tokio::test]
4319 async fn refounding_rolls_the_root_and_severs_a_removed_member() {
4320 let (bed, owner, member) = TestBed::new();
4323 bed.swap_to(&owner);
4324 let community = create_community(&bed.relay, "Refound", bed.relays.clone(), None).await.unwrap();
4325 let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
4326 let bundle_json = serde_json::to_string(&bundle).unwrap();
4327 bed.swap_to(&member);
4328 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
4329
4330 bed.swap_to(&owner);
4331 let refounded = refound_community(&bed.relay, &community, &[member.keys.public_key()]).await.unwrap();
4332 assert_eq!(refounded.root_epoch, Epoch(1), "the epoch advanced");
4333 assert_ne!(refounded.community_root, community.community_root, "the base root rolled");
4334 let session = SessionGuard::capture();
4336 assert_eq!(
4337 crate::db::community::load_community_v2(community.id()).unwrap().unwrap().root_epoch,
4338 Epoch(1),
4339 "the owner committed the new epoch"
4340 );
4341
4342 bed.swap_to(&member);
4344 let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
4345 assert!(follow.self_removed, "the removed member is cut by the re-founding");
4346 }
4347
4348 #[tokio::test]
4349 async fn only_the_owner_re_founds_even_a_ban_holding_admin_cannot() {
4350 let (bed, owner, member) = TestBed::new();
4354 bed.swap_to(&owner);
4355 let community = create_community(&bed.relay, "Guarded", bed.relays.clone(), None).await.unwrap();
4356 let rid = "b0".repeat(32);
4357 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::BAN), 1).await;
4358 publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
4359 let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
4360 let bundle_json = serde_json::to_string(&bundle).unwrap();
4361 bed.swap_to(&member);
4362 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
4363 assert!(refound_community(&bed.relay, &joined, &[owner.keys.public_key()]).await.is_err(), "a non-owner BAN-holder can't re-found");
4364 }
4365
4366 #[tokio::test]
4367 async fn a_retried_refounding_reuses_the_same_root() {
4368 let (_tmp, _guard, _owner) = init_test_db();
4371 let relay = MemoryRelay::new();
4372 let community = create_community(&relay, "Retry", vec!["wss://r".into()], None).await.unwrap();
4373 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
4374 let first = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap();
4375 let second = mint_or_reuse_rotation_key(&cid_hex, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap();
4376 assert_eq!(first, second, "a retry reuses the archived root, never double-mints");
4377 }
4378
4379 #[tokio::test]
4380 async fn minting_a_link_makes_the_community_public_and_revoke_makes_it_private() {
4381 let (_tmp, _guard, _owner) = init_test_db();
4384 let relay = MemoryRelay::new();
4385 let community = create_community(&relay, "Invitable", vec!["wss://r".into()], None).await.unwrap();
4386 assert!(!community_is_public(&relay, &community).await, "a fresh community is Private");
4387
4388 let minted = mint_public_link(&relay, &community, "https://x", None, None).await.unwrap();
4389 assert!(community_is_public(&relay, &community).await, "a live link makes it Public");
4390 let list = fetch_invite_list(&relay, &community.relays).await.expect("the 13303 list was published");
4391 assert_eq!(list.entries.len(), 1, "the minted link is recorded across devices");
4392
4393 let token_hex = crate::simd::hex::bytes_to_hex_16(&minted.token);
4394 revoke_public_link(&relay, &community, &token_hex).await.unwrap();
4395 assert!(!community_is_public(&relay, &community).await, "retiring the last link makes it Private again");
4396 let after = fetch_invite_list(&relay, &community.relays).await.unwrap();
4397 assert!(after.entries.is_empty() && after.tombstones.len() == 1, "the link is tombstoned in the invite list");
4398 }
4399
4400 #[tokio::test]
4401 async fn a_registry_from_a_non_create_invite_holder_does_not_make_it_public() {
4402 let (_tmp, _guard, owner) = init_test_db();
4404 let relay = MemoryRelay::new();
4405 let community = create_community(&relay, "Gated", vec!["wss://r".into()], None).await.unwrap();
4406 let rogue = Keys::generate();
4407 let eid = crate::community::v2::derive::invite_links_locator(community.id(), &rogue.public_key().to_bytes());
4409 let content = crate::community::v2::invite::build_registry_content(&[Keys::generate().public_key()]);
4410 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
4411 let rumor = control::build_edition_rumor(rogue.public_key(), vsk::INVITE_LINKS, &eid, 1, None, &content, 1_000, None);
4412 let (wrap, _) = control::seal_control_edition(&rumor, &group, &rogue, Timestamp::from_secs(1_000)).unwrap();
4413 relay.publish(&wrap, &community.relays).await.unwrap();
4414 let _ = owner;
4415 assert!(!community_is_public(&relay, &community).await, "a non-CREATE_INVITE registry is ignored");
4416 }
4417
4418 #[tokio::test]
4419 async fn full_lifecycle_e2e() {
4420 let (bed, owner, member) = TestBed::new();
4426
4427 bed.swap_to(&owner);
4428 let community = create_community(&bed.relay, "Lifecycle", bed.relays.clone(), None).await.unwrap();
4429 let general = community.channels[0].id;
4430 send_message(&bed.relay, &community, &general, "owner: welcome").await.unwrap();
4431
4432 let _minted = mint_public_link(&bed.relay, &community, "https://x", None, None).await.unwrap();
4434 assert!(community_is_public(&bed.relay, &community).await, "a live link makes it Public");
4435
4436 let rid = "aa".repeat(32);
4438 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
4439 publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
4440
4441 let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
4443 let bundle_json = serde_json::to_string(&bundle).unwrap();
4444 bed.swap_to(&member);
4445 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
4446 assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: welcome"]);
4447 publish_community_meta(&bed.relay, &joined, &member.keys, "Lifecycle Renamed", 2).await;
4449
4450 bed.swap_to(&owner);
4452 let session = SessionGuard::capture();
4453 let updated = follow_control(&bed.relay, &community, &session).await.unwrap().expect("the admin edit folds");
4454 assert_eq!(updated.name, "Lifecycle Renamed", "an authorized admin's metadata edit is honored");
4455
4456 set_banlist(&bed.relay, &updated, &[member.keys.public_key().to_hex()]).await.unwrap();
4458 grant_roles(&bed.relay, &updated, &member.keys.public_key(), vec![]).await.unwrap();
4459 let refounded = refound_community(&bed.relay, &updated, &[member.keys.public_key()]).await.unwrap();
4460 assert_eq!(refounded.root_epoch, Epoch(1), "the ban rolled the root");
4461 let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
4463 assert!(post.banned.contains(&member.keys.public_key().to_hex()), "the ban survives the re-founding");
4464 assert!(
4466 texts_in(&bed.relay, &refounded, &general).await.contains(&"owner: welcome".to_string()),
4467 "pre-refounding history stays readable"
4468 );
4469
4470 bed.swap_to(&member);
4472 let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
4473 assert!(follow.self_removed, "the banned member is cryptographically cut");
4474
4475 bed.swap_to(&owner);
4477 dissolve_community(&bed.relay, &refounded).await.unwrap();
4478 assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the community is sealed");
4479 }
4480
4481 #[tokio::test]
4487 async fn a_forged_edition_cannot_suppress_a_role_across_a_refounding() {
4488 let (bed, owner, member) = TestBed::new();
4493 let attacker = Keys::generate();
4494 bed.swap_to(&owner);
4495 let community = create_community(&bed.relay, "NoSuppress", bed.relays.clone(), None).await.unwrap();
4496 let rid = crate::simd::hex::bytes_to_hex_32(&[0xa1; 32]);
4497 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
4498 publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid.clone()], 1).await;
4499 let session = SessionGuard::capture();
4501 follow_control(&bed.relay, &community, &session).await.unwrap();
4502 assert!(fetch_authority(&bed.relay, &community).await.roles.is_admin(&member.keys.public_key().to_hex()), "member is admin pre-attack");
4503
4504 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;
4506
4507 let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
4509 assert_eq!(refounded.root_epoch, Epoch(1), "root rolled");
4510
4511 let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
4513 assert!(post.roles.is_admin(&member.keys.public_key().to_hex()), "the admin role survives the refounding despite the forgery");
4514 }
4515
4516 #[tokio::test]
4517 async fn memberlist_survives_a_refounding_via_the_snapshot() {
4518 let (bed, owner, member) = TestBed::new();
4521 bed.swap_to(&owner);
4522 let community = create_community(&bed.relay, "Snapshot", bed.relays.clone(), None).await.unwrap();
4523
4524 let bundle = serde_json::to_string(&bundle_of(&community, Some(owner.keys.public_key()), None, None)).unwrap();
4526 bed.swap_to(&member);
4527 accept_parked_invite(&bed.relay, &bundle, None).await.unwrap();
4528 bed.swap_to(&owner);
4529 assert!(memberlist(&bed.relay, &community).await.unwrap().contains(&member.keys.public_key()), "member present pre-refound");
4530
4531 let refounded = refound_community(&bed.relay, &community, &[]).await.unwrap();
4533 assert_eq!(refounded.root_epoch, Epoch(1), "the root rolled");
4534
4535 let members = memberlist(&bed.relay, &refounded).await.unwrap();
4537 assert!(members.contains(&member.keys.public_key()), "a silent survivor stays a member after the refounding");
4538 assert!(members.contains(&owner.keys.public_key()), "owner is always a member");
4539 }
4540
4541 #[tokio::test]
4542 async fn e2e_two_accounts_channels_converse_moderate() {
4543 use crate::community::v2::inbound::{apply_chat_to_state, persist_chat};
4544 use nostr_sdk::prelude::ToBech32;
4545 let (bed, a, b) = TestBed::new();
4546 let (a_npub, b_npub) = (a.keys.public_key().to_bech32().unwrap(), b.keys.public_key().to_bech32().unwrap());
4547 let (a_hex, b_hex) = (a.keys.public_key().to_hex(), b.keys.public_key().to_hex());
4548 println!("\n===== Concord v2 deep e2e =====");
4549 println!("[acct] A (owner) = {a_npub}");
4550 println!("[acct] B (member) = {b_npub}");
4551
4552 bed.swap_to(&a);
4554 let mut community = create_community(&bed.relay, "Deep E2E", bed.relays.clone(), None).await.unwrap();
4555 let general = community.channels[0].id;
4556 println!("[create] community {} · #general {}", crate::simd::hex::bytes_to_hex_32(&community.id().0), crate::simd::hex::bytes_to_hex_32(&general.0));
4557
4558 let priv_id = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
4562 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4563 let priv_ch = community.channel(&priv_id).unwrap();
4564 assert!(priv_ch.private && priv_ch.key.is_some() && priv_ch.epoch == Epoch(1), "born-private: keyed at epoch 1");
4565 println!("[channel] +private #mods {} (native create: key over the rekey plane)", crate::simd::hex::bytes_to_hex_32(&priv_id.0));
4566
4567 let announcements = create_public_channel(&bed.relay, &community, "announcements").await.unwrap();
4569 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4570 let random = create_public_channel(&bed.relay, &community, "random").await.unwrap();
4571 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4572 println!("[channel] +public #announcements {} · #random {}", crate::simd::hex::bytes_to_hex_32(&announcements.0), crate::simd::hex::bytes_to_hex_32(&random.0));
4573 assert_eq!(community.channels.len(), 4, "general + mods + announcements + random");
4574
4575 let m1 = send_message(&bed.relay, &community, &general, "A: welcome to the deep e2e").await.unwrap();
4577 send_message(&bed.relay, &community, &announcements, "A: read the rules").await.unwrap();
4578 send_message(&bed.relay, &community, &priv_id, "A: mods-only channel").await.unwrap();
4579 println!("[msg] A posted in #general / #announcements / #mods");
4580
4581 let admin_rid = crate::simd::hex::bytes_to_hex_32(&[0xa1; 32]);
4583 publish_role(&bed.relay, &community, &a.keys, &admin_role(&admin_rid, Permissions::ADMIN_ALL), 1).await;
4584 publish_grant(&bed.relay, &community, &a.keys, &b.keys.public_key(), vec![admin_rid], 1).await;
4585 let link = mint_public_link(&bed.relay, &community, "https://vectorapp.io", None, None).await.unwrap();
4586 assert!(community_is_public(&bed.relay, &community).await, "a live link makes it Public");
4587 println!("[invite] granted B @admin · minted link {}", link.url);
4588
4589 let bundle_json = serde_json::to_string(&bundle_of(&community, Some(a.keys.public_key()), None, None)).unwrap();
4590 bed.swap_to(&b);
4591 let mut b_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
4592 println!("[join] B joined; sees {} channels", b_view.channels.len());
4593 assert_eq!(b_view.channels.len(), 4, "B receives all four channels (incl. the private one's key) in the bundle");
4594 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");
4595 assert!(texts_in(&bed.relay, &b_view, &general).await.contains(&"A: welcome to the deep e2e".to_string()), "B reads A's #general history");
4596 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");
4597 let session_b = SessionGuard::capture();
4600 if let Some(fresh) = follow_control(&bed.relay, &b_view, &session_b).await.unwrap() {
4601 b_view = fresh;
4602 }
4603 println!("[follow] B folded control (roster persisted: B is @admin)");
4604
4605 send_message(&bed.relay, &b_view, &general, "B: thanks, glad to be here").await.unwrap();
4607 send_message(&bed.relay, &b_view, &priv_id, "B: mods checking in").await.unwrap();
4608 println!("[msg] B replied in #general + #mods");
4609 let my_pk = b.keys.public_key();
4612 let gh = crate::simd::hex::bytes_to_hex_32(&general.0);
4613 for f in fetch_channel(&bed.relay, &b_view, &general, 100).await.unwrap() {
4614 let outcome = { let mut st = crate::state::STATE.lock().await; apply_chat_to_state(&mut st, &f.event, &gh, &my_pk) };
4615 if let Some(o) = outcome { persist_chat(&gh, &o).await; }
4616 }
4617 assert!(crate::db::events::event_exists(&m1).unwrap(), "A's message persisted into B's shared store (get_messages backfill)");
4618 println!("[persist] #general history persisted into the shared events store");
4619
4620 send_reaction(&bed.relay, &b_view, &general, &m1, &a_hex, super::super::kind::MESSAGE, "🔥", None).await.unwrap();
4622 bed.swap_to(&a);
4623 let m_edit = send_message(&bed.relay, &community, &general, "A: this will be edited").await.unwrap();
4624 send_edit(&bed.relay, &community, &general, &m_edit, "A: edited!").await.unwrap();
4625 let m_del = send_message(&bed.relay, &community, &general, "A: this will be deleted").await.unwrap();
4626 send_delete(&bed.relay, &community, &general, &m_del, super::super::kind::MESSAGE).await.unwrap();
4627 println!("[ops] reaction + edit + delete round-tripped");
4628
4629 bed.swap_to(&b);
4631 let bugs = create_public_channel(&bed.relay, &b_view, "bug-reports").await.unwrap();
4632 println!("[channel] B(admin) +public #bug-reports {}", crate::simd::hex::bytes_to_hex_32(&bugs.0));
4633 bed.swap_to(&a);
4634 let session = SessionGuard::capture();
4635 if let Some(updated) = follow_control(&bed.relay, &community, &session).await.unwrap() {
4636 community = updated;
4637 }
4638 assert!(community.channels.iter().any(|c| c.id.0 == bugs.0), "A folds in B's authorized new channel");
4639 println!("[follow] A folded in B's #bug-reports (now {} channels)", community.channels.len());
4640
4641 let vault = create_private_channel(&bed.relay, &community, "vault").await.unwrap();
4645 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4646 send_message(&bed.relay, &community, &vault, "A: vault is open").await.unwrap();
4647 println!("[channel] +private #vault {} (B is a live member — delivery via rekey plane)", crate::simd::hex::bytes_to_hex_32(&vault.0));
4648 bed.swap_to(&b);
4649 let session_b2 = SessionGuard::capture();
4650 if let Some(fresh) = follow_control(&bed.relay, &b_view, &session_b2).await.unwrap() {
4651 b_view = fresh;
4652 }
4653 let ch = b_view.channel(&vault).expect("B recorded the announced private channel");
4654 assert!(ch.private && ch.key.is_none() && ch.epoch == Epoch(0), "B's record is keyless at cursor 0");
4655 let rf = follow_rekeys(&bed.relay, &b_view, &session_b2).await.unwrap();
4656 b_view = rf.updated.expect("the rekey walk adopts the creation delivery");
4657 let ch = b_view.channel(&vault).expect("still recorded");
4658 assert!(ch.key.is_some() && ch.epoch == Epoch(1), "B adopted the epoch-1 key from the creation crate");
4659 assert!(
4660 texts_in(&bed.relay, &b_view, &vault).await.contains(&"A: vault is open".to_string()),
4661 "B reads the private history with the ADOPTED key"
4662 );
4663 send_message(&bed.relay, &b_view, &vault, "B: in the vault").await.unwrap();
4664 bed.swap_to(&a);
4665 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4666 assert!(
4667 texts_in(&bed.relay, &community, &vault).await.contains(&"B: in the vault".to_string()),
4668 "A reads B's reply on the natively-created private channel"
4669 );
4670 println!("[private] B adopted #vault via rekey plane; two-way private conversation verified");
4671
4672 let members = memberlist(&bed.relay, &community).await.unwrap();
4674 let member_hexes: std::collections::BTreeSet<String> = members.iter().map(|m| m.to_hex()).collect();
4675 assert!(member_hexes.contains(&a_hex) && member_hexes.contains(&b_hex), "A + B both in the memberlist");
4676 println!("[members] {} members: A + B present", members.len());
4677
4678 set_banlist(&bed.relay, &community, &[b_hex.clone()]).await.unwrap();
4680 grant_roles(&bed.relay, &community, &b.keys.public_key(), vec![]).await.unwrap();
4681 let refounded = refound_community(&bed.relay, &community, &[b.keys.public_key()]).await.unwrap();
4682 assert_eq!(refounded.root_epoch, Epoch(1), "the ban rolled the root");
4683 let post = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
4684 assert!(post.banned.contains(&b_hex), "the ban survives the refounding");
4685 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");
4686 assert!(
4687 texts_in(&bed.relay, &refounded, &priv_id).await.iter().any(|t| t == "A: mods-only channel"),
4688 "PRIVATE history reads across the channel's own rotation (per-channel multi-epoch archive)"
4689 );
4690 println!("[ban] B banned; root rolled to epoch 1; ban survives; pre-ban history intact (public + private)");
4691 bed.swap_to(&b);
4693 let session_b3 = SessionGuard::capture();
4694 assert!(follow_rekeys(&bed.relay, &b_view, &session_b3).await.unwrap().self_removed, "B is cryptographically cut by the ban-refound");
4695 println!("[ban] B's rekey-follow: self_removed = true (severed)");
4696
4697 bed.swap_to(&a);
4699 set_banlist(&bed.relay, &refounded, &[]).await.unwrap();
4700 let after_unban = fold_authority(&refounded, &fetch_control(&bed.relay, &refounded).await, &load_floors(&refounded));
4701 assert!(!after_unban.banned.contains(&b_hex), "the unban clears B from the banlist");
4702 println!("[unban] B removed from the banlist (re-invitable)");
4703
4704 dissolve_community(&bed.relay, &refounded).await.unwrap();
4706 assert!(crate::db::community::load_community_v2(community.id()).unwrap().unwrap().dissolved, "the community is sealed");
4707 println!("[dissolve] community sealed (read-only)\n===== e2e PASS =====\n");
4708 }
4709
4710 #[tokio::test]
4716 #[ignore]
4717 async fn live_e2e_two_accounts() {
4718 use crate::community::transport::LiveTransport;
4719 use nostr_sdk::prelude::{ClientBuilder, RelayOptions, ToBech32};
4720
4721 let relay = std::env::var("VECTOR_E2E_RELAY").unwrap_or_else(|_| "wss://jskitty.com/nostr".to_string());
4722 let relays = vec![relay.clone()];
4723 let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
4724 crate::db::close_database();
4725 crate::db::clear_id_caches();
4726 let tmp = tempfile::tempdir().unwrap();
4727 crate::db::set_app_data_dir(tmp.path().to_path_buf());
4728
4729 let a = std::env::var("VECTOR_E2E_NSEC_A").ok().and_then(|n| Keys::parse(&n).ok()).unwrap_or_else(Keys::generate);
4731 let b = std::env::var("VECTOR_E2E_NSEC_B").ok().and_then(|n| Keys::parse(&n).ok()).unwrap_or_else(Keys::generate);
4732
4733 let log = |line: String| {
4734 println!("{line}");
4735 if let Ok(p) = std::env::var("VECTOR_E2E_LOG") {
4736 use std::io::Write;
4737 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&p) {
4738 let _ = writeln!(f, "{line}");
4739 }
4740 }
4741 };
4742 log(format!("===== LIVE Concord v2 e2e on {relay} ====="));
4743 log(format!("VECTOR_E2E_NSEC_A={} ({})", a.secret_key().to_bech32().unwrap(), a.public_key().to_bech32().unwrap()));
4744 log(format!("VECTOR_E2E_NSEC_B={} ({})", b.secret_key().to_bech32().unwrap(), b.public_key().to_bech32().unwrap()));
4745
4746 for k in [&a, &b] {
4747 let npub = k.public_key().to_bech32().unwrap();
4748 std::fs::create_dir_all(tmp.path().join(&npub)).unwrap();
4749 crate::db::set_current_account(npub.clone()).unwrap();
4750 crate::db::init_database(&npub).unwrap();
4751 }
4752 let client = ClientBuilder::new().signer(a.clone()).build();
4755 client.pool().add_relay(relay.as_str(), RelayOptions::default()).await.ok();
4756 client.connect().await;
4757 crate::state::set_nostr_client(client);
4758 let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(15));
4759 let become_acct = |k: &Keys| {
4760 let npub = k.public_key().to_bech32().unwrap();
4761 crate::db::set_current_account(npub.clone()).unwrap();
4762 crate::db::init_database(&npub).unwrap();
4763 crate::db::clear_id_caches();
4764 crate::state::MY_SECRET_KEY.store_from_keys(k, &[]);
4765 crate::state::set_my_public_key(k.public_key());
4766 };
4767 let settle = || tokio::time::sleep(std::time::Duration::from_secs(2));
4768
4769 become_acct(&a);
4771 let mut community = create_community(&transport, "Live E2E", relays.clone(), None).await.expect("create");
4772 let general = community.channels[0].id;
4773 log(format!("[create] community {} · #general {}", crate::simd::hex::bytes_to_hex_32(&community.id().0), crate::simd::hex::bytes_to_hex_32(&general.0)));
4774 send_message(&transport, &community, &general, "A: live hello").await.expect("send");
4775 let ann = create_public_channel(&transport, &community, "announcements").await.expect("channel");
4776 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4777 log(format!("[channel] +public #announcements {}", crate::simd::hex::bytes_to_hex_32(&ann.0)));
4778 grant_admin(&transport, &community, &b.public_key()).await.expect("grant admin");
4779 let link = mint_public_link(&transport, &community, "https://vectorapp.io", None, None).await.expect("mint");
4780 log(format!("[invite] B granted @admin · link {}", link.url));
4781 let bundle_json = serde_json::to_string(&bundle_of(&community, Some(a.public_key()), None, None)).unwrap();
4782 settle().await;
4783
4784 become_acct(&b);
4786 let b_view = accept_parked_invite(&transport, &bundle_json, None).await.expect("join");
4787 log(format!("[join] B joined; {} channels", b_view.channels.len()));
4788 settle().await;
4789 let page = fetch_channel(&transport, &b_view, &general, 50).await.expect("fetch");
4790 let seen: Vec<String> = page.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
4791 log(format!("[read] B sees #general: {seen:?}"));
4792 assert!(seen.iter().any(|t| t == "A: live hello"), "B reads A's message over the real relay");
4793 send_message(&transport, &b_view, &general, "B: live reply").await.expect("reply");
4794
4795 let hello = page.iter().find(|f| f.event.opened().rumor.content == "A: live hello").expect("A's message");
4799 let hello_id = hello.event.opened().rumor_id.to_hex();
4800 let bkeys = crate::state::MY_SECRET_KEY.to_keys().unwrap();
4801 let cgroup = channel_group_key(&b_view.community_root, &general, b_view.root_epoch);
4802 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());
4803 let (reply_wrap, _) = chat::seal_chat_rumor(&reply_rumor, &cgroup, &bkeys, Timestamp::from_secs(now_ms() / 1000), false).expect("seal 1111");
4804 transport.publish(&reply_wrap, &b_view.relays).await.expect("publish 1111");
4805 log("[thread] B published a kind-1111 threaded reply to A's message".to_string());
4806 settle().await;
4807
4808 become_acct(&a);
4810 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4811 let a_page = fetch_channel(&transport, &community, &general, 50).await.expect("A fetch");
4812 let thread = a_page.iter().find(|f| f.event.opened().rumor.content == "B: threaded reply to hello").expect("A sees the 1111");
4813 if let chat::ChatEvent::Message { reply_to, opened, .. } = &thread.event {
4814 assert_eq!(opened.rumor.kind.as_u16(), super::super::kind::COMMENT, "wire kind preserved as 1111");
4815 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");
4816 } else {
4817 panic!("the 1111 parsed as a Message");
4818 }
4819 log("[thread] A read B's threaded reply, parent resolved — cross-client 1111 interop OK".to_string());
4820 become_acct(&b);
4821 settle().await;
4822
4823 become_acct(&a);
4827 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4828 let vault = create_private_channel(&transport, &community, "vault").await.expect("private channel");
4829 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4830 send_message(&transport, &community, &vault, "A: vault live").await.expect("vault send");
4831 log(format!("[channel] +private #vault {} (key delivered over the rekey plane)", crate::simd::hex::bytes_to_hex_32(&vault.0)));
4832 settle().await;
4833
4834 become_acct(&b);
4835 let session_b = SessionGuard::capture();
4836 let mut b_view = crate::db::community::load_community_v2(b_view.id()).unwrap().unwrap();
4837 if let Some(fresh) = follow_control(&transport, &b_view, &session_b).await.expect("B control follow") {
4838 b_view = fresh;
4839 }
4840 if let Some(fresh) = follow_rekeys(&transport, &b_view, &session_b).await.expect("B rekey follow").updated {
4841 b_view = fresh;
4842 }
4843 let vch = b_view.channel(&vault).expect("B folded the vault");
4844 assert!(vch.key.is_some() && vch.epoch == Epoch(1), "B adopted the vault key from the live rekey plane");
4845 let vseen = texts_in(&transport, &b_view, &vault).await;
4846 log(format!("[read] B sees #vault: {vseen:?}"));
4847 assert!(vseen.iter().any(|t| t == "A: vault live"), "B reads the private channel with the ADOPTED key");
4848 send_message(&transport, &b_view, &vault, "B: in the live vault").await.expect("vault reply");
4849 settle().await;
4850
4851 become_acct(&a);
4852 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4853 assert!(
4854 texts_in(&transport, &community, &vault).await.iter().any(|t| t == "B: in the live vault"),
4855 "A reads B's private reply"
4856 );
4857 log("[private] two-way #vault conversation over the live relay".to_string());
4858
4859 set_banlist(&transport, &community, &[b.public_key().to_hex()]).await.expect("banlist");
4861 grant_roles(&transport, &community, &b.public_key(), vec![]).await.expect("strip");
4862 let refounded = refound_community(&transport, &community, &[b.public_key()]).await.expect("refound");
4863 log(format!("[ban] B banned; root → epoch {}", refounded.root_epoch.0));
4864 settle().await;
4865 dissolve_community(&transport, &refounded).await.expect("dissolve");
4866 log("[dissolve] community sealed".to_string());
4867 log("===== LIVE e2e PASS =====".to_string());
4868 }
4869
4870 #[tokio::test]
4871 async fn an_offline_member_learns_of_a_dissolution_on_catch_up() {
4872 let (bed, owner, member) = TestBed::new();
4876 bed.swap_to(&owner);
4877 let community = create_community(&bed.relay, "Doomed", bed.relays.clone(), None).await.unwrap();
4878 let general = community.channels[0].id;
4879 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
4880
4881 bed.swap_to(&member);
4882 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
4883 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
4884
4885 bed.swap_to(&owner);
4887 dissolve_community(&bed.relay, &community).await.unwrap();
4888
4889 bed.swap_to(&member);
4891 let session = SessionGuard::capture();
4892 let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
4893 assert!(follow.dissolved, "the catch-up surfaces the tombstone");
4894 assert!(!follow.self_removed && follow.updated.is_none());
4895 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
4896 assert!(crate::db::community::get_community_dissolved(&cid_hex).unwrap(), "sealed read-only locally");
4897 let err = send_message(&bed.relay, &joined, &general, "into the void").await.unwrap_err();
4898 assert!(err.contains("dissolved"), "sends refuse a grave: {err}");
4899 let again = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
4901 assert!(again.dissolved && again.updated.is_none());
4902 }
4903
4904 #[tokio::test]
4905 async fn a_wide_community_survives_refoundings_and_an_offline_member_converges() {
4906 const PRIV_CHANNELS: usize = 6;
4911 let (bed, owner, member) = TestBed::new();
4912 bed.swap_to(&owner);
4913 let mut community = create_community(&bed.relay, "Wide", bed.relays.clone(), None).await.unwrap();
4914 let mut priv_ids = Vec::new();
4915 for i in 0..PRIV_CHANNELS {
4916 let id = create_private_channel(&bed.relay, &community, &format!("priv{i}")).await.unwrap();
4917 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4918 send_message(&bed.relay, &community, &id, &format!("priv{i} epoch0")).await.unwrap();
4919 priv_ids.push(id);
4920 }
4921 let bundle_json = serde_json::to_string(&bundle_of(&community, Some(owner.keys.public_key()), None, None)).unwrap();
4922
4923 bed.swap_to(&member);
4925 let member_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
4926 assert_eq!(member_view.channels.iter().filter(|c| c.private && c.key.is_some()).count(), PRIV_CHANNELS, "joined with all private keys");
4927
4928 bed.swap_to(&owner);
4930 for epoch in 1..=2u64 {
4931 community = refound_community(&bed.relay, &community, &[]).await.unwrap();
4932 assert_eq!(community.root_epoch, Epoch(epoch));
4933 for id in &priv_ids {
4934 send_message(&bed.relay, &community, id, &format!("{} epoch{epoch}", crate::simd::hex::bytes_to_hex_32(&id.0))).await.unwrap();
4935 }
4936 }
4937
4938 bed.swap_to(&member);
4940 let session = SessionGuard::capture();
4941 let mut passes = 0;
4942 loop {
4943 passes += 1;
4944 assert!(passes <= 8, "a wide catch-up must converge, not churn (pass {passes})");
4945 let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
4946 let rk = follow_rekeys(&bed.relay, &cur, &session).await.unwrap();
4947 assert!(!rk.self_removed);
4948 let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
4949 let ctl = follow_control(&bed.relay, &cur, &session).await.unwrap();
4950 if rk.updated.is_none() && ctl.is_none() {
4951 break;
4952 }
4953 }
4954 let caught_up = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
4955 assert_eq!(caught_up.root_epoch, Epoch(2), "walked both refoundings");
4956 for id in &priv_ids {
4958 let mine = caught_up.channel(id).expect("channel survived");
4959 let theirs = community.channel(id).unwrap();
4960 assert_eq!(mine.key, theirs.key, "channel {} converged on the owner key", crate::simd::hex::bytes_to_hex_32(&id.0));
4961 assert_eq!(mine.epoch, theirs.epoch, "…at the same epoch");
4962 let texts = texts_in(&bed.relay, &caught_up, id).await;
4963 let id_hex = crate::simd::hex::bytes_to_hex_32(&id.0);
4964 assert!(texts.iter().any(|t| t.contains("epoch0")), "channel {id_hex} reads epoch-0 history");
4965 for epoch in 1..=2u64 {
4966 assert!(texts.iter().any(|t| t.contains(&format!("epoch{epoch}"))), "channel {id_hex} reads epoch-{epoch} history");
4967 }
4968 }
4969 }
4970
4971 #[tokio::test]
4972 async fn an_offline_member_catches_up_across_three_refoundings() {
4973 use nostr_sdk::prelude::ToBech32;
4978 let (bed, owner, member) = TestBed::new();
4979 bed.swap_to(&owner);
4980 let mut community = create_community(&bed.relay, "Sleeper", bed.relays.clone(), None).await.unwrap();
4981 let general = community.channels[0].id;
4982 let mods = create_private_channel(&bed.relay, &community, "mods").await.unwrap();
4983 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
4984 send_message(&bed.relay, &community, &general, "epoch0: hello").await.unwrap();
4985 send_message(&bed.relay, &community, &mods, "epoch0: mods secret").await.unwrap();
4986 let bundle_json = serde_json::to_string(&bundle_of(&community, Some(owner.keys.public_key()), None, None)).unwrap();
4987
4988 bed.swap_to(&member);
4990 let member_view = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
4991 assert_eq!(member_view.root_epoch, Epoch(0));
4992
4993 bed.swap_to(&owner);
4995 let stranger = Keys::generate();
4996 for epoch in 1..=3u64 {
4997 community = refound_community(&bed.relay, &community, &[]).await.unwrap();
4998 assert_eq!(community.root_epoch, Epoch(epoch));
4999 send_message(&bed.relay, &community, &general, &format!("epoch{epoch}: general news")).await.unwrap();
5000 send_message(&bed.relay, &community, &mods, &format!("epoch{epoch}: mods word")).await.unwrap();
5001 }
5002 let news = create_public_channel(&bed.relay, &community, "news").await.unwrap();
5003 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5004 let vault = create_private_channel(&bed.relay, &community, "vault").await.unwrap();
5005 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5006 send_message(&bed.relay, &community, &vault, "epoch3: vault opened").await.unwrap();
5007 set_banlist(&bed.relay, &community, &[stranger.public_key().to_hex()]).await.unwrap();
5008 let meta = control::CommunityMetadata { name: "Sleeper Reborn".into(), relays: community.relays.clone(), ..Default::default() };
5009 edit_community_metadata(&bed.relay, &community, &meta).await.unwrap();
5010
5011 bed.swap_to(&member);
5014 let session = SessionGuard::capture();
5015 let mut passes = 0;
5016 loop {
5017 passes += 1;
5018 assert!(passes <= 6, "catch-up must converge, not churn");
5019 let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
5020 let rekeyed = follow_rekeys(&bed.relay, &cur, &session).await.unwrap();
5021 assert!(!rekeyed.self_removed, "the member was never removed");
5022 let cur = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
5023 let controlled = follow_control(&bed.relay, &cur, &session).await.unwrap();
5024 if rekeyed.updated.is_none() && controlled.is_none() {
5025 break;
5026 }
5027 }
5028 let caught_up = crate::db::community::load_community_v2(member_view.id()).unwrap().unwrap();
5029
5030 assert_eq!(caught_up.root_epoch, Epoch(3), "walked all three refoundings");
5032 assert_eq!(caught_up.community_root, community.community_root, "landed on the owner's root");
5033 assert_eq!(caught_up.name, "Sleeper Reborn");
5034 assert!(caught_up.channels.iter().any(|c| c.id.0 == news.0), "folded the new public channel");
5036 let m = caught_up.channel(&mods).expect("mods survived");
5037 let owner_mods = community.channel(&mods).unwrap();
5038 assert_eq!(m.epoch, owner_mods.epoch, "mods walked every per-refound rotation");
5039 assert_eq!(m.key, owner_mods.key, "…to the owner's exact key");
5040 let v = caught_up.channel(&vault).expect("vault folded in");
5041 assert_eq!(v.key, community.channel(&vault).unwrap().key, "adopted the mid-sleep private channel's key");
5042 let cid_hex = crate::simd::hex::bytes_to_hex_32(&caught_up.id().0);
5044 let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap();
5045 assert!(banned.contains(&stranger.public_key().to_hex()), "the ban folded through");
5046 let gen_texts = texts_in(&bed.relay, &caught_up, &general).await;
5049 for epoch in 0..=3u64 {
5050 let needle = if epoch == 0 { "epoch0: hello".to_string() } else { format!("epoch{epoch}: general news") };
5051 assert!(gen_texts.contains(&needle), "general history spans epoch {epoch}: {gen_texts:?}");
5052 }
5053 let mods_texts = texts_in(&bed.relay, &caught_up, &mods).await;
5054 for epoch in 0..=3u64 {
5055 let needle = if epoch == 0 { "epoch0: mods secret".to_string() } else { format!("epoch{epoch}: mods word") };
5056 assert!(mods_texts.contains(&needle), "private history spans epoch {epoch}: {mods_texts:?}");
5057 }
5058 assert!(texts_in(&bed.relay, &caught_up, &vault).await.contains(&"epoch3: vault opened".to_string()));
5059 send_message(&bed.relay, &caught_up, &general, "member: good morning").await.unwrap();
5061 bed.swap_to(&owner);
5062 assert!(
5063 texts_in(&bed.relay, &community, &general).await.contains(&"member: good morning".to_string()),
5064 "the caught-up member converses at the new epoch ({})",
5065 member.keys.public_key().to_bech32().unwrap()
5066 );
5067 }
5068
5069 async fn flood_general(relay: &MemoryRelay, community: &CommunityV2, author: &Keys, n: usize, base_secs: u64) {
5072 let general = community.channels[0].id;
5073 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
5074 for i in 0..n {
5075 let at = base_secs + i as u64;
5076 let rumor = chat::build_message_rumor(author.public_key(), &general, community.root_epoch, &format!("msg {i}"), None, &[], vec![], at * 1000);
5077 let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, author, Timestamp::from_secs(at), false).unwrap();
5078 relay.publish(&wrap, &community.relays).await.unwrap();
5079 }
5080 }
5081
5082 #[tokio::test]
5083 async fn the_history_walk_pages_past_a_multi_page_burst() {
5084 let (_tmp, _guard, owner) = init_test_db();
5087 let relay = MemoryRelay::new();
5088 let community = create_community(&relay, "Burst", vec!["wss://r".into()], None).await.unwrap();
5089 let general = community.channels[0].id;
5090 flood_general(&relay, &community, &owner, 120, 10_000).await;
5091
5092 let all = fetch_channel_history(&relay, &community, &general, 50, 8, |_| true).await.unwrap();
5093 assert_eq!(all.len(), 120, "the walk pages the whole burst");
5094 let contents: Vec<String> = all.iter().map(|f| f.event.opened().rumor.content.clone()).collect();
5096 assert_eq!(contents.first().map(String::as_str), Some("msg 0"));
5097 assert_eq!(contents.last().map(String::as_str), Some("msg 119"));
5098 let unique: std::collections::HashSet<&String> = contents.iter().collect();
5099 assert_eq!(unique.len(), 120, "wrap-id + rumor-id dedup holds across page boundaries");
5100
5101 let one = fetch_channel(&relay, &community, &general, 50).await.unwrap();
5103 assert_eq!(one.len(), 50, "fetch_channel is one newest page");
5104 assert_eq!(one.last().map(|f| f.event.opened().rumor.content.clone()).as_deref(), Some("msg 119"));
5105 }
5106
5107 #[tokio::test]
5108 async fn the_history_walk_stops_when_the_caller_is_caught_up() {
5109 let (_tmp, _guard, owner) = init_test_db();
5110 let relay = MemoryRelay::new();
5111 let community = create_community(&relay, "Caught", vec!["wss://r".into()], None).await.unwrap();
5112 let general = community.channels[0].id;
5113 flood_general(&relay, &community, &owner, 120, 10_000).await;
5114
5115 let mut pages = 0usize;
5117 let got = fetch_channel_history(&relay, &community, &general, 50, 8, |_| {
5118 pages += 1;
5119 false
5120 })
5121 .await
5122 .unwrap();
5123 assert_eq!(pages, 1, "the early stop is consulted once");
5124 assert_eq!(got.len(), 50, "only the newest page is fetched");
5125 assert_eq!(got.last().map(|f| f.event.opened().rumor.content.clone()).as_deref(), Some("msg 119"));
5126 }
5127
5128 #[tokio::test]
5129 async fn a_same_second_history_wall_terminates_instead_of_looping() {
5130 let (_tmp, _guard, owner) = init_test_db();
5134 let relay = MemoryRelay::new();
5135 let community = create_community(&relay, "Wall", vec!["wss://r".into()], None).await.unwrap();
5136 let general = community.channels[0].id;
5137 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
5138 for i in 0..60usize {
5139 let rumor = chat::build_message_rumor(owner.public_key(), &general, community.root_epoch, &format!("burst {i}"), None, &[], vec![], 5_000_000 + i as u64);
5140 let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, &owner, Timestamp::from_secs(5_000), false).unwrap();
5141 relay.publish(&wrap, &community.relays).await.unwrap();
5142 }
5143 let got = fetch_channel_history(&relay, &community, &general, 25, 8, |_| true).await.unwrap();
5144 assert!(got.len() >= 25, "at least the relay page is read");
5145 assert!(got.len() <= 60, "sane bound");
5146 }
5148
5149 #[tokio::test]
5150 async fn a_grant_revoke_survives_a_withholding_relay() {
5151 let (_tmp, _guard, owner) = init_test_db();
5154 let relay = MemoryRelay::new();
5155 let community = create_community(&relay, "Revoke", vec!["wss://good".into()], None).await.unwrap();
5156 let admin = Keys::generate();
5157 let rid = "d4".repeat(32);
5158 publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::MANAGE_METADATA), 1).await;
5159 publish_grant(&relay, &community, &owner, &admin.public_key(), vec![rid.clone()], 1).await;
5160 let session = SessionGuard::capture();
5161 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();
5164
5165 inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
5167 let mut stale = community.clone();
5168 stale.relays = vec!["wss://stale".into()];
5169 let floors = load_floors(&community);
5170 let editions = fetch_control(&relay, &stale).await;
5171 let authority = fold_authority(&stale, &editions, &floors);
5172 assert!(
5173 !authority.roles.is_authorized(&admin.public_key().to_hex(), Some(&owner.public_key().to_hex()), Permissions::MANAGE_METADATA),
5174 "the persisted grant floor refuses the rolled-back (re-granted) view"
5175 );
5176 }
5177
5178 fn load_floors(community: &CommunityV2) -> Floors {
5180 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5181 crate::db::community::get_all_edition_heads_full(&cid_hex)
5182 .unwrap_or_default()
5183 .into_iter()
5184 .filter(|(_, f)| f.0 == community.root_epoch.0)
5185 .map(|(e, f)| (e, (f.1, f.2, f.3)))
5186 .collect()
5187 }
5188
5189 async fn fetch_control(relay: &MemoryRelay, community: &CommunityV2) -> Vec<ParsedEdition> {
5191 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
5192 let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
5193 relay
5194 .fetch(&q, &community.relays)
5195 .await
5196 .unwrap_or_default()
5197 .iter()
5198 .filter_map(|w| control::open_control_edition(w, &group).ok().map(|(ed, _)| ed))
5199 .collect()
5200 }
5201
5202 #[tokio::test]
5203 async fn follow_control_is_a_noop_on_a_freshly_created_community() {
5204 let (_tmp, _guard, _owner) = init_test_db();
5205 let relay = MemoryRelay::new();
5206 let community = create_community(&relay, "Fresh", vec!["wss://r".into()], None).await.unwrap();
5207 let session = SessionGuard::capture();
5208 assert!(follow_control(&relay, &community, &session).await.unwrap().is_none());
5210 }
5211
5212 #[tokio::test]
5213 async fn follow_control_adds_a_new_public_channel_and_re_subscribes_it() {
5214 let (_tmp, _guard, owner) = init_test_db();
5215 let relay = MemoryRelay::new();
5216 let community = create_community(&relay, "Grow", vec!["wss://r".into()], None).await.unwrap();
5217 let new_id = ChannelId([0x5a; 32]);
5218 publish_channel_edition(&relay, &community, &owner, &new_id, "announcements", false, 1, false).await;
5219
5220 let session = SessionGuard::capture();
5221 let updated = follow_control(&relay, &community, &session).await.unwrap().expect("a new channel changed the view");
5222 assert_eq!(updated.channels.len(), 2);
5223 let added = updated.channel(&new_id).expect("the new channel folded in");
5224 assert_eq!(added.name, "announcements");
5225 assert!(!added.private);
5226 assert_eq!(added.key, None, "a public channel derives from the root (no stored key)");
5227
5228 let authors = super::super::realtime::plane_authors(std::slice::from_ref(&updated));
5230 let addr = channel_group_key(&updated.community_root, &new_id, updated.root_epoch).pk();
5231 assert!(authors.contains(&addr), "the added channel joins the live subscription");
5232
5233 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5235 assert!(reloaded.channel(&new_id).is_some());
5236 }
5237
5238 #[tokio::test]
5239 async fn follow_control_renames_the_community_and_an_existing_channel() {
5240 let (_tmp, _guard, owner) = init_test_db();
5241 let relay = MemoryRelay::new();
5242 let community = create_community(&relay, "Old Name", vec!["wss://r".into()], None).await.unwrap();
5243 let general = community.channels[0].id;
5244 publish_community_meta(&relay, &community, &owner, "New Name", 2).await;
5246 publish_channel_edition(&relay, &community, &owner, &general, "lobby", false, 2, false).await;
5247
5248 let session = SessionGuard::capture();
5249 let updated = follow_control(&relay, &community, &session).await.unwrap().unwrap();
5250 assert_eq!(updated.name, "New Name");
5251 assert_eq!(updated.channel(&general).unwrap().name, "lobby");
5252 assert_eq!(updated.channels.len(), 1, "a rename doesn't add a channel");
5253 }
5254
5255 #[tokio::test]
5256 async fn follow_control_deletes_a_channel() {
5257 let (_tmp, _guard, owner) = init_test_db();
5258 let relay = MemoryRelay::new();
5259 let community = create_community(&relay, "Prune", vec!["wss://r".into()], None).await.unwrap();
5260 let extra = ChannelId([0x77; 32]);
5261 let session = SessionGuard::capture();
5262
5263 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
5265 let with_extra = follow_control(&relay, &community, &session).await.unwrap().expect("added");
5266 assert!(with_extra.channel(&extra).is_some());
5267
5268 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
5270 let updated = follow_control(&relay, &with_extra, &session).await.unwrap().expect("removed");
5271 assert!(updated.channel(&extra).is_none(), "a deleted channel folds out");
5272 assert_eq!(updated.channels.len(), 1, "only #general remains");
5273 }
5274
5275 async fn inject_stale_prefix(relay: &MemoryRelay, community: &CommunityV2, max_version: u64, stale_relay: &str) {
5279 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
5280 let query = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
5281 let wraps = relay.fetch(&query, &community.relays).await.unwrap();
5282 for w in &wraps {
5283 if let Ok((ed, _)) = control::open_control_edition(w, &group) {
5284 if ed.version <= max_version {
5285 relay.inject(w, &[stale_relay.to_string()]);
5286 }
5287 }
5288 }
5289 }
5290
5291 #[tokio::test]
5292 async fn a_withholding_relay_cannot_roll_back_a_rename() {
5293 let (_tmp, _guard, owner) = init_test_db();
5296 let relay = MemoryRelay::new();
5297 let community = create_community(&relay, "Original", vec!["wss://good".into()], None).await.unwrap();
5298 publish_community_meta(&relay, &community, &owner, "Renamed", 2).await;
5299
5300 let session = SessionGuard::capture();
5301 let updated = follow_control(&relay, &community, &session).await.unwrap().expect("rename adopted");
5302 assert_eq!(updated.name, "Renamed");
5303
5304 inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
5306 let mut stale_view = updated.clone();
5307 stale_view.relays = vec!["wss://stale".into()];
5308 assert!(
5309 follow_control(&relay, &stale_view, &session).await.unwrap().is_none(),
5310 "a stale-only relay must not change the held view"
5311 );
5312 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5313 assert_eq!(held.name, "Renamed", "the persisted floor refuses the rollback");
5314 }
5315
5316 #[tokio::test]
5317 async fn a_withholding_relay_cannot_resurrect_a_deleted_channel() {
5318 let (_tmp, _guard, owner) = init_test_db();
5319 let relay = MemoryRelay::new();
5320 let community = create_community(&relay, "Prune2", vec!["wss://good".into()], None).await.unwrap();
5321 let extra = ChannelId([0x44; 32]);
5322 let session = SessionGuard::capture();
5323
5324 publish_community_meta(&relay, &community, &owner, "Prune2", 2).await;
5327 assert!(follow_control(&relay, &community, &session).await.unwrap().is_none());
5328
5329 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
5330 let with_extra = follow_control(&relay, &community, &session).await.unwrap().expect("added");
5331 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
5332 let pruned = follow_control(&relay, &with_extra, &session).await.unwrap().expect("removed");
5333 assert!(pruned.channel(&extra).is_none());
5334
5335 inject_stale_prefix(&relay, &community, 1, "wss://stale").await;
5337 let mut stale_view = pruned.clone();
5338 stale_view.relays = vec!["wss://stale".into()];
5339 assert!(
5340 follow_control(&relay, &stale_view, &session).await.unwrap().is_none(),
5341 "the withheld delete must not resurrect the channel"
5342 );
5343 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5344 assert!(held.channel(&extra).is_none(), "the deleted channel stays deleted");
5345 }
5346
5347 #[tokio::test]
5348 async fn a_new_epoch_bootstraps_past_an_old_epoch_floor() {
5349 let (_tmp, _guard, owner) = init_test_db();
5353 let relay = MemoryRelay::new();
5354 let community = create_community(&relay, "Before", vec!["wss://good".into()], None).await.unwrap();
5355 let session = SessionGuard::capture();
5356 publish_community_meta(&relay, &community, &owner, "Edited", 2).await;
5357 let updated = follow_control(&relay, &community, &session).await.unwrap().expect("edit adopted");
5358 assert_eq!(updated.name, "Edited");
5359
5360 let mut refounded = updated.clone();
5363 refounded.root_epoch = crate::community::Epoch(1);
5364 crate::db::community::save_community_v2(&refounded).unwrap();
5365 publish_community_meta(&relay, &refounded, &owner, "Compacted", 5).await;
5366
5367 let adopted = follow_control(&relay, &refounded, &session).await.unwrap().expect("compacted head adopted");
5368 assert_eq!(adopted.name, "Compacted", "a fresh epoch bootstraps despite the dangling prev");
5369 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5371 let heads = crate::db::community::get_all_edition_heads_epoched(&cid_hex).unwrap();
5372 assert!(
5373 heads.get(&cid_hex).is_some_and(|(e, v, _)| *e == 1 && *v == 5),
5374 "the adopted head carries the fold's epoch + version"
5375 );
5376 }
5377
5378 #[tokio::test]
5379 async fn a_same_version_owner_fork_at_the_floor_converges_to_the_deterministic_winner() {
5380 let (_tmp, _guard, owner) = init_test_db();
5385 let relay = MemoryRelay::new();
5386 let community = create_community(&relay, "Fork", vec!["wss://r".into()], None).await.unwrap();
5387 let session = SessionGuard::capture();
5388 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
5389 let genesis_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
5390
5391 publish_community_meta(&relay, &community, &owner, "Ours", 2).await;
5392 let ours = follow_control(&relay, &community, &session).await.unwrap().expect("ours adopted");
5393 assert_eq!(ours.name, "Ours");
5394
5395 let our_inner = {
5397 let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], limit: Some(500), ..Default::default() };
5398 let wraps = relay.fetch(&q, &community.relays).await.unwrap();
5399 wraps
5400 .iter()
5401 .find_map(|w| {
5402 control::open_control_edition(w, &group)
5403 .ok()
5404 .filter(|(ed, _)| ed.version == 2 && ed.vsk == vsk::COMMUNITY_METADATA)
5405 .map(|(ed, _)| ed.inner_id)
5406 })
5407 .unwrap()
5408 };
5409
5410 let meta = control::CommunityMetadata { name: "Theirs".into(), ..Default::default() };
5413 let content = serde_json::to_string(&meta).unwrap();
5414 let mut ts = 2_000u64;
5415 let fork_wrap = loop {
5416 let rumor = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &content, ts, None);
5417 let inner = rumor.id.unwrap().to_bytes();
5418 if inner < our_inner {
5419 break control::seal_control_edition(&rumor, &group, &owner, Timestamp::from_secs(ts)).unwrap().0;
5420 }
5421 ts += 1;
5422 };
5423 relay.publish(&fork_wrap, &community.relays).await.unwrap();
5424
5425 let converged = follow_control(&relay, &ours, &session).await.unwrap().expect("fork winner adopted");
5426 assert_eq!(converged.name, "Theirs", "the floor converges to the lower-inner-id winner");
5427 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5428 let held = crate::db::community::get_edition_head_inner_id(&cid_hex, &cid_hex).unwrap();
5429 assert!(held.is_some_and(|h| h < our_inner), "the persisted floor's tiebreak key moved to the winner");
5430 }
5431
5432 #[tokio::test]
5433 async fn an_anchored_prefix_applies_while_a_gap_above_awaits_the_missing_link() {
5434 let (_tmp, _guard, owner) = init_test_db();
5438 let relay = MemoryRelay::new();
5439 let community = create_community(&relay, "Prefix", vec!["wss://r".into()], None).await.unwrap();
5440 let session = SessionGuard::capture();
5441 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
5442
5443 publish_community_meta(&relay, &community, &owner, "Two", 2).await;
5444 let v2_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
5445
5446 let c3 = serde_json::to_string(&control::CommunityMetadata { name: "Three".into(), ..Default::default() }).unwrap();
5448 let r3 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 3, Some(&v2_hash), &c3, 3_000, None);
5449 let (w3, _) = control::seal_control_edition(&r3, &group, &owner, Timestamp::from_secs(3_000)).unwrap();
5450 let (ed3, _) = control::open_control_edition(&w3, &group).unwrap();
5451 let c4 = serde_json::to_string(&control::CommunityMetadata { name: "Four".into(), ..Default::default() }).unwrap();
5452 let r4 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 4, Some(&ed3.self_hash), &c4, 4_000, None);
5453 let (w4, _) = control::seal_control_edition(&r4, &group, &owner, Timestamp::from_secs(4_000)).unwrap();
5454 relay.publish(&w4, &community.relays).await.unwrap();
5455
5456 let updated = follow_control(&relay, &community, &session).await.unwrap().expect("the verified prefix applies");
5457 assert_eq!(updated.name, "Two", "the anchored prefix lands; the detached v4 does not");
5458
5459 relay.publish(&w3, &community.relays).await.unwrap();
5460 let healed = follow_control(&relay, &updated, &session).await.unwrap().expect("the chain heals");
5461 assert_eq!(healed.name, "Four", "once the link arrives, the head advances past the prefix");
5462 }
5463
5464 #[tokio::test]
5465 async fn paging_rescues_a_floor_link_evicted_from_the_newest_window() {
5466 let (_tmp, _guard, owner) = init_test_db();
5470 let relay = MemoryRelay::new();
5471 let community = create_community(&relay, "Paged", vec!["wss://r".into()], None).await.unwrap();
5472 let session = SessionGuard::capture();
5473 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
5474
5475 publish_community_meta(&relay, &community, &owner, "Two", 2).await;
5476 let base = follow_control(&relay, &community, &session).await.unwrap().expect("floor at v2");
5477 publish_community_meta(&relay, &base, &owner, "Three", 3).await; let v3_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
5479
5480 let rogue = Keys::generate();
5483 for i in 0..(FOLLOW_PAGE as u64 - 1) {
5484 let rumor = control::build_edition_rumor(rogue.public_key(), vsk::CHANNEL_METADATA, &[0xCC; 32], 1, None, "{\"name\":\"junk\",\"private\":false}", 4_000 + i, None);
5485 let (w, _) = control::seal_control_edition(&rumor, &group, &rogue, Timestamp::from_secs(4_000 + i)).unwrap();
5486 relay.publish(&w, &community.relays).await.unwrap();
5487 }
5488 let c4 = serde_json::to_string(&control::CommunityMetadata { name: "Four".into(), ..Default::default() }).unwrap();
5491 let r4 = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 4, Some(&v3_hash), &c4, 10_000, None);
5492 let (w4, _) = control::seal_control_edition(&r4, &group, &owner, Timestamp::from_secs(10_000)).unwrap();
5493 relay.publish(&w4, &community.relays).await.unwrap();
5494
5495 let healed = follow_control(&relay, &base, &session).await.unwrap().expect("paging recovered the chain");
5496 assert_eq!(healed.name, "Four", "the gap paged past the flood to the floor link");
5497 }
5498
5499 #[tokio::test]
5500 async fn a_follow_after_delete_does_not_resurrect_the_community() {
5501 let (_tmp, _guard, owner) = init_test_db();
5504 let relay = MemoryRelay::new();
5505 let community = create_community(&relay, "Gone", vec!["wss://r".into()], None).await.unwrap();
5506 publish_community_meta(&relay, &community, &owner, "Edited", 2).await;
5507 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5508 crate::db::community::delete_community(&cid_hex).unwrap();
5509
5510 let session = SessionGuard::capture();
5511 assert!(
5512 follow_control(&relay, &community, &session).await.unwrap().is_none(),
5513 "a follow racing a delete is a no-op"
5514 );
5515 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none(), "the community stays deleted");
5516 assert!(crate::db::community::edition_head_entity_ids(&cid_hex).unwrap().is_empty(), "no orphan floor rows");
5517 }
5518
5519 #[tokio::test]
5520 async fn a_rekey_follow_after_delete_does_not_resurrect_the_community() {
5521 let (_tmp, _guard, owner) = init_test_db();
5524 let relay = MemoryRelay::new();
5525 let community = create_community(&relay, "GoneKeys", vec!["wss://r".into()], None).await.unwrap();
5526 let new_root = [0xB2; 32];
5527 publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
5528 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5529 crate::db::community::delete_community(&cid_hex).unwrap();
5530
5531 let session = SessionGuard::capture();
5532 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
5533 assert!(follow.updated.is_none() && !follow.self_removed, "a rekey follow racing a delete adopts nothing");
5534 assert!(crate::db::community::load_community_v2(community.id()).unwrap().is_none(), "the community stays deleted");
5535 }
5536
5537 #[tokio::test]
5538 async fn a_joiner_bootstraps_the_highest_head_across_a_lost_middle_edition() {
5539 let (bed, owner, member) = TestBed::new();
5544 bed.swap_to(&owner);
5545 let community = create_community(&bed.relay, "Skip", bed.relays.clone(), None).await.unwrap();
5546 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
5547 let genesis_hash = head_hash_on_relay(&bed.relay, &community, &community.id().0).await.unwrap();
5548
5549 let c2 = serde_json::to_string(&control::CommunityMetadata { name: "Two".into(), ..Default::default() }).unwrap();
5551 let r2 = control::build_edition_rumor(owner.keys.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &c2, 2_000, None);
5552 let (w2, _) = control::seal_control_edition(&r2, &group, &owner.keys, Timestamp::from_secs(2_000)).unwrap();
5553 let (ed2, _) = control::open_control_edition(&w2, &group).unwrap();
5554 let c3 = serde_json::to_string(&control::CommunityMetadata { name: "Three".into(), ..Default::default() }).unwrap();
5555 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);
5556 let (w3, _) = control::seal_control_edition(&r3, &group, &owner.keys, Timestamp::from_secs(3_000)).unwrap();
5557 bed.relay.publish(&w3, &community.relays).await.unwrap();
5558
5559 let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
5560 let bundle_json = serde_json::to_string(&bundle).unwrap();
5561 bed.swap_to(&member);
5562 let joined = accept_parked_invite(&bed.relay, &bundle_json, None).await.unwrap();
5563 assert_eq!(joined.name, "Three", "the joiner bootstraps the highest signed head, not the anchored stale prefix");
5564 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
5565 let head = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap();
5566 assert!(head.is_some_and(|(v, _)| v == 3), "the seeded floor is the bootstrap head");
5567 }
5568
5569 #[tokio::test]
5570 async fn a_losing_same_version_fork_cannot_replace_the_held_floor() {
5571 let (_tmp, _guard, owner) = init_test_db();
5575 let relay = MemoryRelay::new();
5576 let community = create_community(&relay, "Fork2", vec!["wss://good".into()], None).await.unwrap();
5577 let session = SessionGuard::capture();
5578 let group = control_group_key(&community.community_root, community.id(), community.root_epoch);
5579 let genesis_hash = head_hash_on_relay(&relay, &community, &community.id().0).await.unwrap();
5580
5581 publish_community_meta(&relay, &community, &owner, "Ours", 2).await;
5582 let ours = follow_control(&relay, &community, &session).await.unwrap().expect("ours adopted");
5583 assert_eq!(ours.name, "Ours");
5584 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5585 let held_before = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().unwrap();
5586 let our_inner = crate::db::community::get_edition_head_inner_id(&cid_hex, &cid_hex).unwrap().unwrap();
5587
5588 let meta = control::CommunityMetadata { name: "Theirs".into(), ..Default::default() };
5591 let content = serde_json::to_string(&meta).unwrap();
5592 let mut ts = 5_000u64;
5593 let fork_wrap = loop {
5594 let rumor = control::build_edition_rumor(owner.public_key(), vsk::COMMUNITY_METADATA, &community.id().0, 2, Some(&genesis_hash), &content, ts, None);
5595 if rumor.id.unwrap().to_bytes() > our_inner {
5596 break control::seal_control_edition(&rumor, &group, &owner, Timestamp::from_secs(ts)).unwrap().0;
5597 }
5598 ts += 1;
5599 };
5600 inject_stale_prefix(&relay, &community, 1, "wss://stale").await; relay.inject(&fork_wrap, &["wss://stale".to_string()]);
5602 let mut stale_view = ours.clone();
5603 stale_view.relays = vec!["wss://stale".into()];
5604
5605 assert!(
5606 follow_control(&relay, &stale_view, &session).await.unwrap().is_none(),
5607 "a losing fork served without our floor edition changes nothing"
5608 );
5609 let held_after = crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().unwrap();
5610 assert_eq!(held_after, held_before, "the floor row is untouched");
5611 let held = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5612 assert_eq!(held.name, "Ours", "the held state is untouched");
5613 }
5614
5615 #[tokio::test]
5616 async fn follow_control_ignores_a_non_owner_edition() {
5617 let (_tmp, _guard, _owner) = init_test_db();
5621 let relay = MemoryRelay::new();
5622 let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
5623 let rogue = Keys::generate();
5624 let rogue_id = ChannelId([0x99; 32]);
5625 publish_channel_edition(&relay, &community, &rogue, &rogue_id, "backdoor", false, 1, false).await;
5626
5627 let session = SessionGuard::capture();
5628 assert!(
5629 follow_control(&relay, &community, &session).await.unwrap().is_none(),
5630 "a non-owner control edition is not folded"
5631 );
5632 }
5633
5634 #[tokio::test]
5635 async fn follow_control_records_a_new_private_channel_keyless_and_unreadable() {
5636 let (_tmp, _guard, owner) = init_test_db();
5640 let relay = MemoryRelay::new();
5641 let community = create_community(&relay, "Priv", vec!["wss://r".into()], None).await.unwrap();
5642 let priv_id = ChannelId([0x33; 32]);
5643 publish_channel_edition(&relay, &community, &owner, &priv_id, "mods", true, 1, false).await;
5644
5645 let session = SessionGuard::capture();
5646 let updated = follow_control(&relay, &community, &session)
5647 .await
5648 .unwrap()
5649 .expect("the keyless record is a change");
5650 let ch = updated.channel(&priv_id).expect("the private channel is recorded");
5651 assert!(ch.private && ch.key.is_none(), "recorded keyless");
5652 assert_eq!(ch.epoch, Epoch(0), "epoch 0 = the root generation (scan cursor)");
5653 assert!(updated.channel_read_coords(ch).is_empty(), "unreadable until keyed");
5654 assert!(
5655 fetch_channel(&relay, &updated, &priv_id, 50).await.unwrap().is_empty(),
5656 "a keyless fetch returns empty (and never queries the root plane)"
5657 );
5658 assert!(
5659 send_message(&relay, &updated, &priv_id, "nope").await.is_err(),
5660 "a keyless send refuses"
5661 );
5662 let reloaded = crate::db::community::load_community_v2(updated.id()).unwrap().unwrap();
5665 let rch = reloaded.channel(&priv_id).unwrap();
5666 assert!(rch.private && rch.key.is_none() && rch.epoch == Epoch(0), "keyless survives reload");
5667 let bundle = bundle_of(&reloaded, None, None, None);
5669 assert!(
5670 !bundle.channels.iter().any(|c| c.id == crate::simd::hex::bytes_to_hex_32(&priv_id.0)),
5671 "an ungrantable keyless channel stays out of invite bundles"
5672 );
5673 }
5674
5675 async fn publish_base_rotation(
5682 relay: &MemoryRelay,
5683 community: &CommunityV2,
5684 rotator: &Keys,
5685 recipients: &[PublicKey],
5686 new_root: &[u8; 32],
5687 prev_key: &[u8; 32],
5688 ) {
5689 let new_epoch = Epoch(community.root_epoch.0 + 1);
5690 let prev_epoch = community.root_epoch;
5691 let prev_commit = super::super::derive::epoch_key_commitment(prev_epoch, prev_key);
5692 let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
5693 let blobs: Vec<_> = recipients
5694 .iter()
5695 .map(|r| rekey::build_blob_local(rotator.secret_key(), &rotator.public_key().to_bytes(), r, RekeyScope::Root, new_epoch, new_root).unwrap())
5696 .collect();
5697 let events = rekey::build_rekey_chunks_local(rotator, &group, RekeyScope::Root, new_epoch, prev_epoch, &prev_commit, &blobs, 2_000).unwrap();
5698 for e in &events {
5699 relay.publish(e, &community.relays).await.unwrap();
5700 }
5701 }
5702
5703 fn add_private_channel(community: &mut CommunityV2, id: ChannelId, key: [u8; 32], epoch: Epoch) {
5705 community.channels.push(ChannelV2 { id, name: "mods".into(), private: true, key: Some(key), epoch });
5706 crate::db::community::save_community_v2(community).unwrap();
5707 }
5708
5709 #[tokio::test]
5710 async fn follow_rekeys_is_a_noop_without_rotations() {
5711 let (_tmp, _guard, _owner) = init_test_db();
5712 let relay = MemoryRelay::new();
5713 let community = create_community(&relay, "Still", vec!["wss://r".into()], None).await.unwrap();
5714 let session = SessionGuard::capture();
5715 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
5716 assert!(follow.updated.is_none() && !follow.self_removed, "no rotation → nothing to adopt");
5717 }
5718
5719 #[tokio::test]
5720 async fn follow_rekeys_adopts_an_owner_base_rotation() {
5721 let (_tmp, _guard, owner) = init_test_db();
5722 let relay = MemoryRelay::new();
5723 let community = create_community(&relay, "Refound", vec!["wss://r".into()], None).await.unwrap();
5724 let new_root = [0xB1; 32];
5725 publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
5727
5728 let session = SessionGuard::capture();
5729 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopted");
5730 assert_eq!(updated.root_epoch, Epoch(1), "advanced one epoch");
5731 assert_eq!(updated.community_root, new_root, "adopted the fresh root");
5732 let addr = super::super::realtime::plane_authors(std::slice::from_ref(&updated));
5734 let general = updated.channels[0].id;
5735 let new_chat = channel_group_key(&new_root, &general, Epoch(1)).pk();
5736 assert!(addr.contains(&new_chat), "the public channel re-addresses under the new root");
5737 }
5738
5739 #[tokio::test]
5740 async fn follow_rekeys_adopts_an_owner_private_channel_rotation() {
5741 let (_tmp, _guard, owner) = init_test_db();
5742 let relay = MemoryRelay::new();
5743 let mut community = create_community(&relay, "PrivRot", vec!["wss://r".into()], None).await.unwrap();
5744 let priv_id = ChannelId([0x33; 32]);
5745 add_private_channel(&mut community, priv_id, [0x44; 32], Epoch(0));
5746
5747 let new_key = [0x55; 32];
5749 let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &[0x44; 32]);
5750 let group = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(1));
5751 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();
5752 let events = rekey::build_rekey_chunks_local(&owner, &group, RekeyScope::Channel(priv_id), Epoch(1), Epoch(0), &prev_commit, &[blob], 2_000).unwrap();
5753 for e in &events {
5754 relay.publish(e, &community.relays).await.unwrap();
5755 }
5756
5757 let session = SessionGuard::capture();
5758 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopted");
5759 let ch = updated.channel(&priv_id).unwrap();
5760 assert_eq!(ch.epoch, Epoch(1), "the private channel advanced an epoch");
5761 assert_eq!(ch.key, Some(new_key), "adopted the fresh channel key");
5762 assert_eq!(updated.root_epoch, Epoch(0), "the base is untouched by a channel rotation");
5763 }
5764
5765 #[tokio::test]
5766 async fn follow_rekeys_ignores_a_non_owner_rotation() {
5767 let (_tmp, _guard, _owner) = init_test_db();
5770 let relay = MemoryRelay::new();
5771 let community = create_community(&relay, "Guarded", vec!["wss://r".into()], None).await.unwrap();
5772 let rogue = Keys::generate();
5773 publish_base_rotation(&relay, &community, &rogue, &[rogue.public_key()], &[0xEE; 32], &community.community_root).await;
5774
5775 let session = SessionGuard::capture();
5776 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
5777 assert!(follow.updated.is_none() && !follow.self_removed, "a non-owner rotation is not adopted");
5778 }
5779
5780 #[tokio::test]
5781 async fn follow_rekeys_ignores_a_rotation_off_the_wrong_prev() {
5782 let (_tmp, _guard, owner) = init_test_db();
5785 let relay = MemoryRelay::new();
5786 let community = create_community(&relay, "Forked", vec!["wss://r".into()], None).await.unwrap();
5787 publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &[0xB2; 32], &[0x00; 32]).await;
5789
5790 let session = SessionGuard::capture();
5791 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
5792 assert!(follow.updated.is_none(), "a fork off the wrong prev is not adopted");
5793 }
5794
5795 #[tokio::test]
5796 async fn follow_rekeys_holds_on_an_incomplete_rotation() {
5797 let (_tmp, _guard, owner) = init_test_db();
5800 let relay = MemoryRelay::new();
5801 let community = create_community(&relay, "Partial", vec!["wss://r".into()], None).await.unwrap();
5802 let new_epoch = Epoch(1);
5803 let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
5804 let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
5805 let other = Keys::generate();
5807 let blob = rekey::build_blob_local(owner.secret_key(), &owner.public_key().to_bytes(), &other.public_key(), RekeyScope::Root, new_epoch, &[0xB3; 32]).unwrap();
5808 let rumor = rekey::build_rekey_rumor(owner.public_key(), RekeyScope::Root, new_epoch, Epoch(0), &prev_commit, &[blob], 1, 2, 2_000).unwrap();
5809 let (wrap, _) = rekey::seal_rekey_chunk(&rumor, &group, &owner, Timestamp::from_secs(2_000)).unwrap();
5810 relay.publish(&wrap, &community.relays).await.unwrap();
5811
5812 let session = SessionGuard::capture();
5813 let follow = follow_rekeys(&relay, &community, &session).await.unwrap();
5814 assert!(follow.updated.is_none() && !follow.self_removed, "an incomplete rotation neither adopts nor removes");
5815 }
5816
5817 #[tokio::test]
5818 async fn follow_rekeys_removes_a_member_dropped_by_a_base_rotation() {
5819 let (bed, owner, member) = TestBed::new();
5823 bed.swap_to(&owner);
5824 let community = create_community(&bed.relay, "Evict", bed.relays.clone(), None).await.unwrap();
5825 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
5826
5827 bed.swap_to(&member);
5828 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
5829 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
5830
5831 bed.swap_to(&owner);
5833 let stranger = Keys::generate();
5834 publish_base_rotation(&bed.relay, &community, &owner.keys, &[stranger.public_key()], &[0xC4; 32], &community.community_root).await;
5835
5836 bed.swap_to(&member);
5838 let session = SessionGuard::capture();
5839 let follow = follow_rekeys(&bed.relay, &joined, &session).await.unwrap();
5840 assert!(follow.self_removed, "a complete base rotation dropping the member removes them");
5841 assert!(follow.updated.is_none(), "a removed member adopts nothing");
5842 }
5843
5844 #[tokio::test]
5845 async fn follow_rekeys_finds_a_channel_rekey_under_an_archived_prior_root() {
5846 let (_tmp, _guard, owner) = init_test_db();
5852 let relay = MemoryRelay::new();
5853 let mut community = create_community(&relay, "Strand", vec!["wss://r".into()], None).await.unwrap();
5854 let root0 = community.community_root;
5855 let priv_id = ChannelId([0x33; 32]);
5856 let key1 = [0x44; 32];
5857 add_private_channel(&mut community, priv_id, key1, Epoch(1));
5858
5859 let key2 = [0x55; 32];
5862 let prev_commit = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
5863 let group = channel_rekey_group_key(&root0, &priv_id, Epoch(2));
5864 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();
5865 for e in rekey::build_rekey_chunks_local(&owner, &group, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &prev_commit, &[blob], 2_000).unwrap() {
5866 relay.publish(&e, &community.relays).await.unwrap();
5867 }
5868
5869 community.community_root = [0xB7; 32];
5873 community.root_epoch = Epoch(1);
5874 crate::db::community::save_community_v2(&community).unwrap();
5875
5876 let session = SessionGuard::capture();
5877 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the prior-root crate is found");
5878 let ch = updated.channel(&priv_id).unwrap();
5879 assert_eq!(ch.epoch, Epoch(2), "the channel advanced despite the moved base");
5880 assert_eq!(ch.key, Some(key2), "adopted the key delivered under the prior root");
5881 }
5882
5883 #[tokio::test]
5884 async fn follow_rekeys_keyless_cursor_walks_past_an_excluding_rotation_then_adopts() {
5885 let (_tmp, _guard, owner) = init_test_db();
5890 let relay = MemoryRelay::new();
5891 let mut community = create_community(&relay, "Cursor", vec!["wss://r".into()], None).await.unwrap();
5892 let priv_id = ChannelId([0x66; 32]);
5893 community.channels.push(ChannelV2 { id: priv_id, name: "vault".into(), private: true, key: None, epoch: Epoch(0) });
5894 crate::db::community::save_community_v2(&community).unwrap();
5895 let community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
5896 assert!(community.channel(&priv_id).unwrap().key.is_none(), "keyless survives the round-trip");
5897
5898 let stranger = Keys::generate();
5900 let key1 = [0x71; 32];
5901 let pc1 = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
5902 let g1 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(1));
5903 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();
5904 for e in rekey::build_rekey_chunks_local(&owner, &g1, RekeyScope::Channel(priv_id), Epoch(1), Epoch(0), &pc1, &[b1], 2_000).unwrap() {
5905 relay.publish(&e, &community.relays).await.unwrap();
5906 }
5907 let key2 = [0x72; 32];
5910 let pc2 = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
5911 let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
5912 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();
5913 for e in rekey::build_rekey_chunks_local(&owner, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc2, &[b2], 2_100).unwrap() {
5914 relay.publish(&e, &community.relays).await.unwrap();
5915 }
5916
5917 let session = SessionGuard::capture();
5920 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the walk lands on the included epoch");
5921 let ch = updated.channel(&priv_id).unwrap();
5922 assert_eq!(ch.epoch, Epoch(2), "cursor walked through the excluding epoch to the included one");
5923 assert_eq!(ch.key, Some(key2), "adopted the delivery that includes us");
5924 }
5925
5926 #[tokio::test]
5927 async fn follow_rekeys_honors_an_admin_channel_rotation_but_never_a_strangers() {
5928 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
5932 let (_tmp, _guard, _owner) = init_test_db();
5933 let relay = MemoryRelay::new();
5934 let mut community = create_community(&relay, "AdminRot", vec!["wss://r".into()], None).await.unwrap();
5935 let priv_id = ChannelId([0x88; 32]);
5936 let key1 = [0x91; 32];
5937 add_private_channel(&mut community, priv_id, key1, Epoch(1));
5938
5939 let admin = Keys::generate();
5941 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5942 let role = Role::admin("aa".repeat(32));
5943 let roster = CommunityRoles {
5944 roles: vec![role.clone()],
5945 grants: vec![MemberGrant { member: admin.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }],
5946 };
5947 crate::db::community::set_community_roles(&cid_hex, &roster, 1_000).unwrap();
5948
5949 let key2 = [0x92; 32];
5951 let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
5952 let g2 = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
5953 let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
5954 let blob = rekey::build_blob_local(admin.secret_key(), &admin.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), &key2).unwrap();
5955 for e in rekey::build_rekey_chunks_local(&admin, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[blob], 2_000).unwrap() {
5956 relay.publish(&e, &community.relays).await.unwrap();
5957 }
5958 let session = SessionGuard::capture();
5959 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("an admin rotation is honored");
5960 assert_eq!(updated.channel(&priv_id).unwrap().key, Some(key2), "adopted the admin's key");
5961
5962 let rogue = Keys::generate();
5964 let key3 = [0x93; 32];
5965 let pc3 = super::super::derive::epoch_key_commitment(Epoch(2), &key2);
5966 let g3 = channel_rekey_group_key(&updated.community_root, &priv_id, Epoch(3));
5967 let rb = rekey::build_blob_local(rogue.secret_key(), &rogue.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(3), &key3).unwrap();
5968 for e in rekey::build_rekey_chunks_local(&rogue, &g3, RekeyScope::Channel(priv_id), Epoch(3), Epoch(2), &pc3, &[rb], 2_100).unwrap() {
5969 relay.publish(&e, &updated.relays).await.unwrap();
5970 }
5971 let follow = follow_rekeys(&relay, &updated, &session).await.unwrap();
5972 assert!(follow.updated.is_none(), "a stranger's channel rotation is never adopted");
5973 }
5974
5975 #[tokio::test]
5976 async fn a_non_outranking_admins_rotation_never_concludes_my_removal() {
5977 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
5982 let (bed, owner, member) = TestBed::new();
5983 bed.swap_to(&owner);
5984 let community = create_community(&bed.relay, "Outrank", bed.relays.clone(), None).await.unwrap();
5985
5986 bed.swap_to(&member);
5989 let mut held = community.clone();
5990 let priv_id = ChannelId([0xAB; 32]);
5991 let key1 = [0xA1; 32];
5992 add_private_channel(&mut held, priv_id, key1, Epoch(1));
5993 let peer = Keys::generate();
5994 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
5995 let role = Role::admin("bb".repeat(32));
5996 let roster = CommunityRoles {
5997 roles: vec![role.clone()],
5998 grants: vec![
5999 MemberGrant { member: peer.public_key().to_hex(), role_ids: vec![role.role_id.clone()] },
6000 MemberGrant { member: member.keys.public_key().to_hex(), role_ids: vec![role.role_id.clone()] },
6001 ],
6002 };
6003 crate::db::community::set_community_roles(&cid_hex, &roster, 1_000).unwrap();
6004
6005 let key2 = [0xA2; 32];
6007 let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
6008 let g2 = channel_rekey_group_key(&held.community_root, &priv_id, Epoch(2));
6009 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();
6010 for e in rekey::build_rekey_chunks_local(&peer, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[pb], 2_000).unwrap() {
6011 bed.relay.publish(&e, &held.relays).await.unwrap();
6012 }
6013 let session = SessionGuard::capture();
6014 let follow = follow_rekeys(&bed.relay, &held, &session).await.unwrap();
6015 assert!(follow.updated.is_none(), "an equal-rank rotation excluding me is Stay, never my removal");
6016 let reloaded = crate::db::community::load_community_v2(held.id()).unwrap().unwrap();
6017 assert!(reloaded.channel(&priv_id).is_some(), "my channel record survives the peer's rotation");
6018
6019 let key3 = [0xA3; 32];
6021 let stranger = Keys::generate();
6022 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();
6023 for e in rekey::build_rekey_chunks_local(&owner.keys, &g2, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[ob], 2_100).unwrap() {
6024 bed.relay.publish(&e, &held.relays).await.unwrap();
6025 }
6026 let follow = follow_rekeys(&bed.relay, &held, &session).await.unwrap();
6027 let updated = follow.updated.expect("the owner's removal folds");
6028 assert!(updated.channel(&priv_id).is_none(), "the owner's exclusion cuts my channel record");
6029 }
6030
6031 #[tokio::test]
6032 async fn converting_a_public_channel_to_private_is_refused() {
6033 let (_tmp, _guard, _owner) = init_test_db();
6036 let relay = MemoryRelay::new();
6037 let community = create_community(&relay, "NoConvert", vec!["wss://r".into()], None).await.unwrap();
6038 let general = community.channels[0].id;
6039 let meta = control::ChannelMetadata { name: "general".into(), private: true, voice: None, deleted: None, custom: None, extra: Default::default() };
6040 let err = edit_channel_metadata(&relay, &community, &general, &meta).await.unwrap_err();
6041 assert!(err.contains("not supported"), "conversion is refused at the producer: {err}");
6042 let meta = control::ChannelMetadata { name: "lobby".into(), private: false, voice: None, deleted: None, custom: None, extra: Default::default() };
6044 edit_channel_metadata(&relay, &community, &general, &meta).await.unwrap();
6045 }
6046
6047 async fn publish_remote_tombstone(relay: &MemoryRelay, me: &Keys, relays: &[String], cid_hex: &str, removed_at: u64) {
6050 let doc = super::super::list::CommunityList {
6051 entries: vec![],
6052 tombstones: vec![super::super::list::Tombstone { community_id: cid_hex.to_string(), removed_at, extra: Default::default() }],
6053 extra: Default::default(),
6054 };
6055 let event = super::super::list::build_list_event(me, &doc).unwrap();
6056 relay.publish(&event, relays).await.unwrap();
6057 }
6058
6059 #[tokio::test]
6060 async fn joining_one_community_does_not_resurrect_a_sibling_left_community() {
6061 let (_tmp, _guard, me) = init_test_db();
6065 let relay = MemoryRelay::new();
6066 let x = create_community(&relay, "X", vec!["wss://r".into()], None).await.unwrap();
6067 let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
6068
6069 publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, now_ms() + 10_000).await;
6071
6072 let y = create_community(&relay, "Y", vec!["wss://r".into()], None).await.unwrap();
6074 republish_community_list(&relay, Some(y.id())).await.unwrap();
6075
6076 let list = fetch_community_list(&relay, &x.relays).await.unwrap().unwrap();
6078 assert!(!list.is_live(&x_hex), "joining Y did not resurrect the sibling-left X");
6079 assert!(list.is_live(&crate::simd::hex::bytes_to_hex_32(&y.id().0)), "Y is live");
6080 }
6081
6082 #[tokio::test]
6083 async fn sync_tears_down_a_community_a_sibling_left() {
6084 let (_tmp, _guard, me) = init_test_db();
6087 let relay = MemoryRelay::new();
6088 let x = create_community(&relay, "Leaveme", vec!["wss://r".into()], None).await.unwrap();
6089 let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
6090 assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_some(), "held before sync");
6091
6092 publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, now_ms() + 10_000).await;
6093 sync_community_list(&relay, &x.relays).await.unwrap();
6094 assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_none(), "the sibling's leave tore X down locally");
6095 }
6096
6097 #[tokio::test]
6098 async fn a_rejoined_community_survives_a_stale_tombstone_on_sync() {
6099 let (_tmp, _guard, me) = init_test_db();
6102 let relay = MemoryRelay::new();
6103 let x = create_community(&relay, "Rejoin", vec!["wss://r".into()], None).await.unwrap();
6104 let x_hex = crate::simd::hex::bytes_to_hex_32(&x.id().0);
6105 publish_remote_tombstone(&relay, &me, &x.relays, &x_hex, 1).await;
6107 republish_community_list(&relay, Some(x.id())).await.unwrap();
6109 sync_community_list(&relay, &x.relays).await.unwrap();
6110 assert!(crate::db::community::load_community_v2(x.id()).unwrap().is_some(), "a re-joined community is not torn down by a stale tombstone");
6111 }
6112
6113 #[tokio::test]
6114 async fn a_failed_remote_fetch_never_clobbers_the_published_list() {
6115 let (_tmp, _guard, _me) = init_test_db();
6118 let good = MemoryRelay::new();
6119 let community = create_community(&good, "Seeded", vec!["wss://r".into()], None).await.unwrap();
6120 assert!(fetch_community_list(&good, &community.relays).await.unwrap().is_some());
6121
6122 struct FetchErrors;
6124 #[async_trait::async_trait]
6125 impl Transport for FetchErrors {
6126 async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
6127 panic!("republish must NOT publish when the remote fetch failed");
6128 }
6129 async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
6130 Ok(())
6131 }
6132 async fn fetch(&self, _q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
6133 Err("relay unreachable".to_string())
6134 }
6135 }
6136 republish_community_list(&FetchErrors, Some(community.id())).await.unwrap();
6138 }
6139
6140 #[tokio::test]
6141 async fn a_granted_member_survives_a_refounding_even_with_no_guestbook_join() {
6142 let (_tmp, _guard, owner) = init_test_db();
6148 let relay = MemoryRelay::new();
6149 let community = create_community(&relay, "Backstop", vec!["wss://r".into()], None).await.unwrap();
6150
6151 let lurker = Keys::generate();
6153 let rid = "b1".repeat(32);
6154 publish_role(&relay, &community, &owner, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
6155 publish_grant(&relay, &community, &owner, &lurker.public_key(), vec![rid.clone()], 1).await;
6156
6157 let members = memberlist(&relay, &community).await.unwrap();
6159 assert!(members.contains(&lurker.public_key()), "a granted member with no Join is still a member");
6160
6161 let banned_grantee = Keys::generate();
6163 publish_grant(&relay, &community, &owner, &banned_grantee.public_key(), vec![rid], 1).await;
6164 set_banlist(&relay, &community, &[banned_grantee.public_key().to_hex()]).await.unwrap();
6165 let members = memberlist(&relay, &community).await.unwrap();
6166 assert!(members.contains(&lurker.public_key()), "the honest grantee still counts");
6167 assert!(!members.contains(&banned_grantee.public_key()), "a banned grantee is not re-admitted by the union");
6168
6169 let refounded = refound_community(&relay, &community, &[]).await.unwrap();
6171 assert_eq!(refounded.root_epoch, Epoch(1));
6172 let base_group = base_rekey_group_key(&community.community_root, community.id(), Epoch(1));
6173 let chunks = fetch_rekey_chunks(&relay, &community.relays, &base_group).await.unwrap();
6174 let rotations = rekey::collect_rotations(&chunks);
6175 let lurker_x = lurker.public_key().to_bytes();
6176 let delivered = rotations.iter().any(|r| {
6177 rekey::find_my_blob(&r.blobs, &r.rotator.to_bytes(), &lurker_x, r.scope, r.new_epoch).is_some()
6178 });
6179 assert!(delivered, "the Refounding delivered the new root to the granted lurker");
6180 }
6181
6182 #[tokio::test]
6183 async fn the_memberlist_pages_past_a_guestbook_flood() {
6184 let (_tmp, _guard, owner) = init_test_db();
6188 let relay = MemoryRelay::new();
6189 let community = create_community(&relay, "GBFlood", vec!["wss://r".into()], None).await.unwrap();
6190 let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
6191
6192 let honest = Keys::generate();
6194 let join = guestbook::build_join_rumor(honest.public_key(), None, 1_000);
6195 let (w, _) = guestbook::seal_guestbook_rumor(&join, &gb, &honest, Timestamp::from_secs(1)).unwrap();
6196 relay.publish(&w, &community.relays).await.unwrap();
6197 for i in 0..600u64 {
6198 let throwaway = Keys::generate();
6199 let j = guestbook::build_join_rumor(throwaway.public_key(), None, 2_000 + i);
6200 let (w, _) = guestbook::seal_guestbook_rumor(&j, &gb, &throwaway, Timestamp::from_secs(2 + i)).unwrap();
6201 relay.publish(&w, &community.relays).await.unwrap();
6202 }
6203
6204 let members = memberlist(&relay, &community).await.unwrap();
6205 assert!(members.contains(&honest.public_key()), "the honest member's aged-out Join is still counted past the flood");
6206 }
6207
6208 #[tokio::test]
6209 async fn a_rekey_plane_flood_cannot_bury_a_genuine_rotation() {
6210 let (_tmp, _guard, owner) = init_test_db();
6215 let relay = MemoryRelay::new();
6216 let community = create_community(&relay, "Flooded", vec!["wss://r".into()], None).await.unwrap();
6217 let new_root = [0xD9; 32];
6218 let new_epoch = Epoch(1);
6219 let group = base_rekey_group_key(&community.community_root, community.id(), new_epoch);
6220
6221 publish_base_rotation(&relay, &community, &owner, &[owner.public_key()], &new_root, &community.community_root).await;
6223
6224 let rogue = Keys::generate();
6227 let prev_commit = super::super::derive::epoch_key_commitment(Epoch(0), &community.community_root);
6228 for i in 0..260u64 {
6229 let blob = rekey::build_blob_local(rogue.secret_key(), &rogue.public_key().to_bytes(), &rogue.public_key(), RekeyScope::Root, new_epoch, &[0xEE; 32]).unwrap();
6230 let rumor = rekey::build_rekey_rumor(rogue.public_key(), RekeyScope::Root, new_epoch, Epoch(0), &prev_commit, &[blob], 1, 1, 3_000 + i).unwrap();
6231 let (wrap, _) = rekey::seal_rekey_chunk(&rumor, &group, &rogue, Timestamp::from_secs(3_000 + i)).unwrap();
6232 relay.publish(&wrap, &community.relays).await.unwrap();
6233 }
6234
6235 let session = SessionGuard::capture();
6236 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("the genuine rotation is recovered past the flood");
6237 assert_eq!(updated.root_epoch, Epoch(1));
6238 assert_eq!(updated.community_root, new_root, "adopted the owner's root, not a junk one");
6239 }
6240
6241 #[tokio::test]
6242 async fn a_swap_during_create_private_channel_aborts_without_a_write() {
6243 let (bed, owner, _member) = TestBed::new();
6248 bed.swap_to(&owner);
6249 let community = create_community(&bed.relay, "SwapCreate", bed.relays.clone(), None).await.unwrap();
6250 let before = crate::db::community::load_community_v2(community.id()).unwrap().unwrap().channels.len();
6251
6252 let swap_relay = SwapMidFetch { inner: MemoryRelay::new() };
6254 let err = create_private_channel(&swap_relay, &community, "ghost").await.unwrap_err();
6255 assert!(err.contains("account changed"), "a swap mid-create aborts: {err}");
6256 let after = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
6257 assert_eq!(after.channels.len(), before, "no channel row was written");
6258 assert!(!after.channels.iter().any(|c| c.name == "ghost"), "the ghost channel never persisted");
6259 }
6260
6261 #[tokio::test]
6262 async fn two_admins_racing_a_channel_rotation_converge_on_one_key() {
6263 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
6271 let (_tmp, _guard, owner) = init_test_db();
6272 let relay = MemoryRelay::new();
6273 let mut community = create_community(&relay, "Race", vec!["wss://r".into()], None).await.unwrap();
6274 let priv_id = ChannelId([0xC0; 32]);
6275 let key1 = [0xC1; 32];
6276 add_private_channel(&mut community, priv_id, key1, Epoch(1));
6277
6278 let (a, b) = (Keys::generate(), Keys::generate());
6280 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6281 let role = Role::admin("ce".repeat(32));
6282 let roster = CommunityRoles {
6283 roles: vec![role.clone()],
6284 grants: [&a, &b].iter().map(|k| MemberGrant { member: k.public_key().to_hex(), role_ids: vec![role.role_id.clone()] }).collect(),
6285 };
6286 crate::db::community::set_community_roles(&cid_hex, &roster, 1_000).unwrap();
6287
6288 let me_pk = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key();
6291 let pc = super::super::derive::epoch_key_commitment(Epoch(1), &key1);
6292 let group = channel_rekey_group_key(&community.community_root, &priv_id, Epoch(2));
6293 let key_a = [0x0A; 32];
6294 let key_b = [0xFB; 32]; for (signer, k) in [(&a, &key_a), (&b, &key_b)] {
6296 let blob = rekey::build_blob_local(signer.secret_key(), &signer.public_key().to_bytes(), &me_pk, RekeyScope::Channel(priv_id), Epoch(2), k).unwrap();
6297 for e in rekey::build_rekey_chunks_local(signer, &group, RekeyScope::Channel(priv_id), Epoch(2), Epoch(1), &pc, &[blob], 2_000).unwrap() {
6298 relay.publish(&e, &community.relays).await.unwrap();
6299 }
6300 }
6301
6302 let session = SessionGuard::capture();
6303 let updated = follow_rekeys(&relay, &community, &session).await.unwrap().updated.expect("adopts a winner");
6304 let adopted = updated.channel(&priv_id).unwrap().key.unwrap();
6305 assert_eq!(adopted, key_a, "converges on the lexicographically lowest key (deterministic across clients)");
6306
6307 let mut peer = community.clone();
6309 if let Some(c) = peer.channels.iter_mut().find(|c| c.id.0 == priv_id.0) {
6310 c.key = Some(key1);
6311 c.epoch = Epoch(1);
6312 }
6313 let updated2 = follow_rekeys(&relay, &peer, &session).await.unwrap().updated.expect("peer adopts");
6315 assert_eq!(updated2.channel(&priv_id).unwrap().key.unwrap(), key_a, "every follower lands on the identical key");
6316 }
6317
6318 #[tokio::test]
6319 async fn create_private_channel_refuses_a_member_without_manage_channels() {
6320 let (bed, owner, member) = TestBed::new();
6323 bed.swap_to(&owner);
6324 let community = create_community(&bed.relay, "Gate", bed.relays.clone(), None).await.unwrap();
6325 send_direct_invite(&bed.relay, &community, &member.keys.public_key(), None, None).await.unwrap();
6326
6327 bed.swap_to(&member);
6328 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
6329 let joined = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap();
6330 let err = create_private_channel(&bed.relay, &joined, "sneaky").await.unwrap_err();
6331 assert!(err.contains("MANAGE_CHANNELS"), "refused with the permission it lacks: {err}");
6332 let err = create_public_channel(&bed.relay, &joined, "sneaky-too").await.unwrap_err();
6333 assert!(err.contains("MANAGE_CHANNELS"), "public creation gates identically: {err}");
6334 }
6335
6336 #[tokio::test]
6339 async fn accept_rejects_a_bundle_with_a_forged_community_root() {
6340 let (bed, owner, member) = TestBed::new();
6344 bed.swap_to(&owner);
6345 let community = create_community(&bed.relay, "Real", bed.relays.clone(), None).await.unwrap();
6346
6347 let fake = crate::simd::hex::bytes_to_hex_32(&[0xEE; 32]);
6348 let mut forged = bundle_of(&community, None, None, None);
6349 forged.community_root = fake.clone();
6350 for ch in &mut forged.channels {
6351 ch.key = fake.clone();
6352 }
6353 let attacker = Keys::generate();
6354 let wrap = invite::build_direct_invite(&attacker, &member.keys.public_key(), &forged).unwrap();
6355 bed.relay.publish(&wrap, &bed.relays).await.unwrap();
6356
6357 bed.swap_to(&member);
6358 let invite_wrap = fetch_direct_invite(&bed.relay, &bed.relays, &member.keys.public_key()).await;
6359 let err = accept_direct_invite(&bed.relay, &invite_wrap).await.unwrap_err();
6360 assert!(err.contains("could not verify"), "a forged root fails the owner-genesis check: {err}");
6361 assert!(
6362 crate::db::community::load_community_v2(community.id()).unwrap().is_none(),
6363 "a rejected join persists nothing"
6364 );
6365 }
6366
6367 #[tokio::test]
6368 async fn follow_control_heals_a_bundle_misclassified_public_channel() {
6369 let (_tmp, _guard, _owner) = init_test_db();
6373 let relay = MemoryRelay::new();
6374 let community = create_community(&relay, "Heal", vec!["wss://r".into()], None).await.unwrap();
6375 let general = community.channels[0].id;
6376 let mut poisoned = community.clone();
6377 poisoned.channels[0].private = true;
6378 poisoned.channels[0].key = Some([0x66; 32]);
6379 crate::db::community::save_community_v2(&poisoned).unwrap();
6380
6381 let session = SessionGuard::capture();
6382 let healed = follow_control(&relay, &poisoned, &session).await.unwrap().expect("healed");
6383 let ch = healed.channel(&general).unwrap();
6384 assert!(!ch.private, "the owner's public declaration overrides the bundle");
6385 assert_eq!(ch.key, None, "a healed public channel derives from the root");
6386 }
6387
6388 #[tokio::test]
6389 async fn a_deleted_channel_does_not_resurrect_on_reload() {
6390 let (_tmp, _guard, owner) = init_test_db();
6393 let relay = MemoryRelay::new();
6394 let community = create_community(&relay, "Prune", vec!["wss://r".into()], None).await.unwrap();
6395 let extra = ChannelId([0x77; 32]);
6396 let session = SessionGuard::capture();
6397 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 1, false).await;
6398 let with_extra = follow_control(&relay, &community, &session).await.unwrap().unwrap();
6399 assert!(with_extra.channel(&extra).is_some());
6400 publish_channel_edition(&relay, &community, &owner, &extra, "temp", false, 2, true).await;
6401 let after = follow_control(&relay, &with_extra, &session).await.unwrap().unwrap();
6402 assert!(after.channel(&extra).is_none());
6403
6404 let reloaded = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
6405 assert!(reloaded.channel(&extra).is_none(), "a deleted channel must not resurrect on reload");
6406 assert_eq!(reloaded.channels.len(), 1);
6407 }
6408
6409 #[tokio::test]
6410 async fn a_channel_owned_by_another_community_is_skipped_not_clobbered() {
6411 let (_tmp, _guard, _owner) = init_test_db();
6415 let relay = MemoryRelay::new();
6416 let a = create_community(&relay, "A", vec!["wss://r".into()], None).await.unwrap();
6417 let a_channel = a.channels[0].id;
6418 let mut b = create_community(&relay, "B", vec!["wss://r".into()], None).await.unwrap();
6419 let b_channel = b.channels[0].id;
6420 b.channels.push(ChannelV2 { id: a_channel, name: "phantom".into(), private: false, key: None, epoch: b.root_epoch });
6422
6423 crate::db::community::save_community_v2(&b).expect("save succeeds, the phantom is skipped");
6424 let a_reloaded = crate::db::community::load_community_v2(a.id()).unwrap().unwrap();
6426 assert!(!a_reloaded.channels.iter().any(|c| c.private), "A's channel is untouched");
6427 assert_eq!(a_reloaded.channels[0].id.0, a_channel.0);
6428 let b_reloaded = crate::db::community::load_community_v2(b.id()).unwrap().unwrap();
6430 assert!(b_reloaded.channel(&b_channel).is_some(), "B's own channel persists");
6431 assert!(b_reloaded.channel(&a_channel).is_none(), "the foreign-owned channel is skipped, not stolen");
6432 }
6433
6434 struct CappedRelay {
6440 events: Vec<Event>,
6441 cap: usize,
6442 }
6443 #[async_trait::async_trait]
6444 impl Transport for CappedRelay {
6445 async fn publish(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
6446 Ok(())
6447 }
6448 async fn publish_durable(&self, _e: &Event, _r: &[String]) -> Result<(), String> {
6449 Ok(())
6450 }
6451 async fn fetch(&self, q: &Query, _r: &[String]) -> Result<Vec<Event>, String> {
6452 let mut m: Vec<Event> = self
6453 .events
6454 .iter()
6455 .filter(|e| q.authors.is_empty() || q.authors.contains(&e.pubkey.to_hex()))
6456 .filter(|e| q.until.is_none_or(|u| e.created_at.as_secs() <= u))
6457 .cloned()
6458 .collect();
6459 m.sort_by(|a, b| b.created_at.cmp(&a.created_at)); m.truncate(self.cap.min(q.limit.unwrap_or(usize::MAX)));
6461 Ok(m)
6462 }
6463 }
6464
6465 #[tokio::test]
6466 async fn verify_pages_a_capped_relay_past_a_flood_to_the_genesis() {
6467 let (_tmp, _guard, owner) = init_test_db();
6472 let meta = control::CommunityMetadata { name: "Capped".into(), relays: vec!["wss://r".into()], ..Default::default() };
6473 let g = control::genesis(&owner, meta, 1_000).unwrap();
6474 let community = CommunityV2::from_genesis(&g, "Capped", None, vec!["wss://r".into()], 1_000);
6475
6476 let control = control_group_key(&community.community_root, community.id(), community.root_epoch);
6477 let rogue = Keys::generate();
6478 let mut events: Vec<Event> = g.wraps.to_vec();
6479 for i in 0..250u64 {
6480 let rumor = control::build_edition_rumor(rogue.public_key(), vsk::CHANNEL_METADATA, &[0xAB; 32], 1, None, "{\"name\":\"junk\",\"private\":false}", 1_001 + i, None);
6481 let (wrap, _) = control::seal_control_edition(&rumor, &control, &rogue, Timestamp::from_secs(1_001 + i)).unwrap();
6482 events.push(wrap);
6483 }
6484 let relay = CappedRelay { events, cap: 100 };
6486 let verified = verify_owner_root_and_reconcile(&relay, community.clone()).await;
6487 assert!(verified.is_ok(), "the until-walk pages a capped relay past the flood to the genesis: {:?}", verified.err());
6488 }
6489
6490 #[tokio::test]
6491 async fn accept_parked_invite_joins_from_the_stored_bundle() {
6492 let (bed, owner, member) = TestBed::new();
6495 bed.swap_to(&owner);
6496 let community = create_community(&bed.relay, "Parked", bed.relays.clone(), None).await.unwrap();
6497 let general = community.channels[0].id;
6498 send_message(&bed.relay, &community, &general, "owner: hi").await.unwrap();
6499 let bundle = bundle_of(&community, Some(owner.keys.public_key()), None, None);
6500 let bundle_json = serde_json::to_string(&bundle).unwrap();
6501 let inviter_hex = owner.keys.public_key().to_hex();
6502
6503 bed.swap_to(&member);
6504 let joined = accept_parked_invite(&bed.relay, &bundle_json, Some(&inviter_hex)).await.unwrap();
6505 assert_eq!(joined.id().0, community.id().0, "joined the community from the parked bundle");
6506 assert!(joined.identity.verify());
6507 assert_eq!(texts_in(&bed.relay, &joined, &general).await, vec!["owner: hi"]);
6508 let cid_hex = crate::simd::hex::bytes_to_hex_32(&joined.id().0);
6511 assert!(
6512 crate::db::community::get_edition_head(&cid_hex, &cid_hex).unwrap().is_some(),
6513 "the joiner's control floor is seeded from the join-time fold"
6514 );
6515
6516 bed.swap_to(&owner);
6518 let members = memberlist(&bed.relay, &community).await.unwrap();
6519 assert!(members.contains(&member.keys.public_key()), "the parked-invite joiner is a member");
6520 }
6521
6522 #[tokio::test]
6523 async fn accept_parked_invite_rejects_a_forged_root() {
6524 let (_tmp, _guard, _owner) = init_test_db();
6528 let relay = MemoryRelay::new();
6529 let community = create_community(&relay, "Real", vec!["wss://r".into()], None).await.unwrap();
6530 let mut forged = bundle_of(&community, None, None, None);
6531 let fake = crate::simd::hex::bytes_to_hex_32(&[0xEE; 32]);
6532 forged.community_root = fake.clone();
6533 for ch in &mut forged.channels {
6534 ch.key = fake.clone();
6535 }
6536 let bundle_json = serde_json::to_string(&forged).unwrap();
6537
6538 let err = accept_parked_invite(&relay, &bundle_json, None).await.unwrap_err();
6539 assert!(err.contains("could not verify"), "a forged-root parked bundle fails definitively: {err}");
6540 }
6541
6542 #[test]
6543 fn v2_and_v1_bundles_are_distinguishable_by_parse() {
6544 let owner = Keys::generate();
6548 let identity = super::super::control::CommunityIdentity::mint(&owner.public_key());
6549 let hex = crate::simd::hex::bytes_to_hex_32;
6550 let v2 = invite::CommunityInvite {
6551 community_id: hex(&identity.community_id.0),
6552 owner: hex(&identity.owner_xonly),
6553 owner_salt: hex(&identity.owner_salt),
6554 community_root: hex(&[0x11; 32]),
6555 root_epoch: 0,
6556 channels: vec![],
6557 relays: vec!["wss://r".into()],
6558 name: "V2".into(),
6559 icon: None,
6560 expires_at: None,
6561 creator_npub: None,
6562 label: None,
6563 extra: Default::default(),
6564 };
6565 let v2_json = serde_json::to_string(&v2).unwrap();
6566 assert!(invite::CommunityInvite::from_bundle_json(&v2_json).is_ok(), "a real v2 bundle parses");
6567 let v1_like = r#"{"community_id":"aa","name":"X","relays":[]}"#;
6568 assert!(invite::CommunityInvite::from_bundle_json(v1_like).is_err(), "a v1 bundle is not a v2 bundle");
6569 }
6570
6571 #[tokio::test]
6572 async fn verify_rejects_a_cross_community_owner_edition_replay() {
6573 let (_tmp, _guard, owner) = init_test_db();
6579
6580 let gx = control::genesis(&owner, control::CommunityMetadata { name: "X".into(), ..Default::default() }, 1_000).unwrap();
6582 let x_control = control_group_key(&gx.community_root, &gx.identity.community_id, Epoch(0));
6583 let (_ed, opened) = control::open_control_edition(&gx.wraps[0], &x_control).unwrap();
6584
6585 let t_identity = control::CommunityIdentity::mint(&owner.public_key());
6587 let fake_root = [0xEE; 32];
6588 let t = CommunityV2 {
6589 identity: t_identity,
6590 community_root: fake_root,
6591 root_epoch: Epoch(0),
6592 name: "T".into(),
6593 description: None,
6594 relays: vec!["wss://r".into()],
6595 channels: vec![],
6596 dissolved: false,
6597 created_at_ms: 0,
6598 };
6599 let t_control = control_group_key(&fake_root, t.id(), t.root_epoch);
6602 let (replayed, _) = stream::rewrap_seal(&opened.seal, &t_control, Timestamp::from_secs(1_000)).unwrap();
6603 let relay = MemoryRelay::new();
6604 relay.publish(&replayed, &t.relays).await.unwrap();
6605
6606 let verified = verify_owner_root_and_reconcile(&relay, t.clone()).await;
6607 assert!(verified.is_err(), "a cross-community owner-edition replay must not authenticate a forged root");
6608 }
6609
6610 #[tokio::test]
6618 #[ignore = "hits a real relay over the network"]
6619 async fn live_smoke_create_send_fetch_on_a_real_relay() {
6620 use crate::community::transport::LiveTransport;
6621 use nostr_sdk::prelude::ToBech32;
6622
6623 let relay = std::env::var("VECTOR_SMOKE_RELAY").unwrap_or_else(|_| "wss://jskitty.com/nostr".to_string());
6624 let relays = vec![relay.clone()];
6625
6626 let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
6628 crate::db::close_database();
6629 crate::db::clear_id_caches();
6630 let tmp = tempfile::tempdir().unwrap();
6631 let keys = match std::env::var("VECTOR_SMOKE_NSEC") {
6634 Ok(n) => Keys::parse(&n).expect("VECTOR_SMOKE_NSEC is not a valid nsec"),
6635 Err(_) => Keys::generate(),
6636 };
6637 let npub = keys.public_key().to_bech32().unwrap();
6638 if std::env::var("VECTOR_SMOKE_PRINT_NSEC").is_ok() {
6641 println!("[smoke] OWNER nsec (throwaway — do NOT reuse): {}", keys.secret_key().to_bech32().unwrap());
6642 }
6643 std::fs::create_dir_all(tmp.path().join(&npub)).unwrap();
6644 crate::db::set_app_data_dir(tmp.path().to_path_buf());
6645 crate::db::set_current_account(npub.clone()).unwrap();
6646 crate::db::init_database(&npub).unwrap();
6647 crate::state::MY_SECRET_KEY.store_from_keys(&keys, &[]);
6648 crate::state::set_my_public_key(keys.public_key());
6649 println!("[smoke] throwaway identity {npub}");
6650
6651 let client = nostr_sdk::ClientBuilder::new().signer(keys.clone()).build();
6653 client.pool().add_relay(relay.as_str(), nostr_sdk::RelayOptions::default()).await.ok();
6654 client.connect().await;
6655 crate::state::set_nostr_client(client);
6656 let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(15));
6657
6658 let community = create_community(&transport, "V2 Live Smoke", relays.clone(), None).await.expect("create");
6660 let general = community.channels[0].id;
6661 println!("[smoke] created community {} on {relay}", crate::simd::hex::bytes_to_hex_32(&community.id().0));
6662
6663 let text = "hello from a Vector Concord v2 live smoke test";
6664 let sent_id = send_message(&transport, &community, &general, text).await.expect("send");
6665 println!("[smoke] sent message {sent_id}");
6666
6667 tokio::time::sleep(std::time::Duration::from_secs(3)).await;
6669
6670 let page = fetch_channel(&transport, &community, &general, 50).await.expect("fetch");
6671 let texts: Vec<String> = page
6672 .iter()
6673 .filter_map(|f| match &f.event {
6674 ChatEvent::Message { .. } => Some(f.event.opened().rumor.content.clone()),
6675 _ => None,
6676 })
6677 .collect();
6678 println!("[smoke] fetched {} message(s) back: {texts:?}", texts.len());
6679 assert!(texts.contains(&text.to_string()), "the message did not round-trip through the real relay");
6680
6681 let link = mint_public_link(&transport, &community, "https://vectorapp.io", None, None).await.expect("mint link");
6683 println!("[smoke] invite link: {}", link.url);
6684 println!("[smoke] PASS — v2 create+send+fetch+invite round-tripped on {relay}");
6685 }
6686
6687 #[tokio::test]
6688 async fn chat_ops_react_edit_delete_round_trip() {
6689 let (bed, owner, _member) = TestBed::new();
6690 bed.swap_to(&owner);
6691 let community = create_community(&bed.relay, "Ops", bed.relays.clone(), None).await.unwrap();
6692 let general = community.channels[0].id;
6693 let me_hex = owner.keys.public_key().to_hex();
6694
6695 let msg_id = send_message(&bed.relay, &community, &general, "original").await.unwrap();
6696 send_reaction(&bed.relay, &community, &general, &msg_id, &me_hex, super::super::kind::MESSAGE, ":fire:", Some(("fire", "https://e/f.png")))
6697 .await
6698 .unwrap();
6699 send_edit(&bed.relay, &community, &general, &msg_id, "edited").await.unwrap();
6700 send_delete(&bed.relay, &community, &general, &msg_id, super::super::kind::MESSAGE).await.unwrap();
6701
6702 let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
6703 let target = crate::simd::hex::hex_to_bytes_32(&msg_id);
6704 let mut saw = (false, false, false);
6705 for f in &page {
6706 match &f.event {
6707 ChatEvent::Reaction { target: t, emoji, emoji_url, .. } if *t == target => {
6708 assert_eq!(emoji, ":fire:");
6709 assert_eq!(emoji_url.as_deref(), Some("https://e/f.png"));
6710 saw.0 = true;
6711 }
6712 ChatEvent::Edit { target: t, new_content, .. } if *t == target => {
6713 assert_eq!(new_content, "edited");
6714 saw.1 = true;
6715 }
6716 ChatEvent::Delete { target: t, .. } if *t == target => saw.2 = true,
6717 _ => {}
6718 }
6719 }
6720 assert!(saw.0 && saw.1 && saw.2, "reaction/edit/delete all round-trip: {saw:?}");
6721 }
6722
6723 #[tokio::test]
6724 async fn a_typing_signal_rides_the_ephemeral_wrap_and_is_never_stored() {
6725 let (bed, owner, _member) = TestBed::new();
6726 bed.swap_to(&owner);
6727 let community = create_community(&bed.relay, "Typ", bed.relays.clone(), None).await.unwrap();
6728 let general = community.channels[0].id;
6729 let group = channel_group_key(&community.community_root, &general, community.root_epoch);
6730
6731 let mut sub = bed.relay.subscribe(Query {
6733 kinds: vec![stream::KIND_WRAP_EPHEMERAL],
6734 authors: vec![group.pk_hex()],
6735 ..Default::default()
6736 });
6737 send_typing(&bed.relay, &community, &general).await.unwrap();
6738 let wrap = sub.try_recv().expect("the typing wrap streams to a live subscriber");
6739 assert!(
6740 matches!(chat::open_chat_event(&wrap, &group, &general, community.root_epoch), Ok(ChatEvent::Typing { .. })),
6741 "the ephemeral wrap opens as a Typing event"
6742 );
6743
6744 let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
6747 assert!(page.iter().all(|f| !matches!(f.event, ChatEvent::Typing { .. })));
6748 }
6749
6750 #[tokio::test]
6751 async fn send_chat_message_threads_the_reply_and_extra_tags() {
6752 let (bed, owner, _member) = TestBed::new();
6753 bed.swap_to(&owner);
6754 let community = create_community(&bed.relay, "Re", bed.relays.clone(), None).await.unwrap();
6755 let general = community.channels[0].id;
6756 let me_hex = owner.keys.public_key().to_hex();
6757
6758 let parent_id = send_message(&bed.relay, &community, &general, "parent").await.unwrap();
6759 let imeta = nostr_sdk::prelude::Tag::custom(
6760 nostr_sdk::prelude::TagKind::custom("imeta"),
6761 ["url https://e/blob".to_string(), "m image/png".to_string()],
6762 );
6763 let child_id = send_chat_message(
6764 &bed.relay, &community, &general, "child",
6765 Some((parent_id.as_str(), me_hex.as_str())), &[], vec![imeta],
6766 )
6767 .await
6768 .unwrap();
6769
6770 let page = fetch_channel(&bed.relay, &community, &general, 50).await.unwrap();
6771 let child = page
6772 .iter()
6773 .find_map(|f| match &f.event {
6774 ChatEvent::Message { opened, reply_to, .. } if opened.rumor_id.to_hex() == child_id => Some((opened, reply_to)),
6775 _ => None,
6776 })
6777 .expect("the reply message round-trips");
6778 let reply = child.1.as_ref().expect("the reply reference is carried");
6779 assert_eq!(crate::simd::hex::bytes_to_hex_32(&reply.id), parent_id);
6780 assert_eq!(reply.author, Some(owner.keys.public_key()));
6781 assert!(
6782 child.0.rumor.tags.iter().any(|t| t.kind() == nostr_sdk::prelude::TagKind::custom("imeta")),
6783 "the imeta attachment tag rides the rumor verbatim"
6784 );
6785 }
6786
6787 #[tokio::test]
6788 async fn a_kick_needs_kick_authority_and_removes_the_target() {
6789 let (bed, owner, member) = TestBed::new();
6790 bed.swap_to(&owner);
6791 let community = create_community(&bed.relay, "Kick", bed.relays.clone(), None).await.unwrap();
6792
6793 let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
6795 let join = guestbook::build_join_rumor(member.keys.public_key(), None, 2_000);
6796 let (wrap, _) = guestbook::seal_guestbook_rumor(&join, &gb, &member.keys, Timestamp::from_secs(2)).unwrap();
6797 bed.relay.publish(&wrap, &bed.relays).await.unwrap();
6798 let before = memberlist(&bed.relay, &community).await.unwrap();
6799 assert!(before.contains(&member.keys.public_key()), "the join lands first");
6800
6801 bed.swap_to(&member);
6803 let err = kick_member(&bed.relay, &community, &owner.keys.public_key()).await.unwrap_err();
6804 assert!(err.contains("not authorized"), "unprivileged kick refused: {err}");
6805
6806 bed.swap_to(&owner);
6808 kick_member(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
6809 let after = memberlist(&bed.relay, &community).await.unwrap();
6810 assert!(!after.contains(&member.keys.public_key()), "the kicked member leaves the fold");
6811 assert!(after.contains(&owner.keys.public_key()), "the owner remains");
6812 }
6813
6814 #[tokio::test]
6815 async fn grant_admin_mints_one_deterministic_role_and_revoke_strips_it() {
6816 let (bed, owner, member) = TestBed::new();
6817 bed.swap_to(&owner);
6818 let community = create_community(&bed.relay, "Adm", bed.relays.clone(), None).await.unwrap();
6819 let member_pk = member.keys.public_key();
6820 let member_hex = member_pk.to_hex();
6821 let owner_hex = owner.keys.public_key().to_hex();
6822
6823 grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
6824 let view = fetch_authority(&bed.relay, &community).await;
6825 assert!(view.roles.is_admin(&member_hex), "the grant folds as admin");
6826 assert!(view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::MANAGE_ROLES));
6827
6828 let second = Keys::generate().public_key();
6831 grant_admin(&bed.relay, &community, &second).await.unwrap();
6832 grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
6833 let view = fetch_authority(&bed.relay, &community).await;
6834 assert_eq!(view.roles.roles.len(), 1, "one Admin role, never a fork");
6835 assert!(view.roles.is_admin(&member_hex) && view.roles.is_admin(&second.to_hex()));
6836 let grant = view.roles.grants.iter().find(|g| g.member == member_hex).unwrap();
6837 assert_eq!(grant.role_ids.len(), 1, "no duplicate role id in the grant");
6838
6839 revoke_admin(&bed.relay, &community, &member_pk).await.unwrap();
6841 let view = fetch_authority(&bed.relay, &community).await;
6842 assert!(!view.roles.is_admin(&member_hex), "revoked");
6843 assert!(view.roles.is_admin(&second.to_hex()), "the other admin is untouched");
6844 assert!(!view.roles.is_authorized(&member_hex, Some(&owner_hex), Permissions::KICK));
6845 }
6846
6847 #[tokio::test]
6848 async fn follow_control_persists_the_roster_for_sync_local_reads() {
6849 let (bed, owner, member) = TestBed::new();
6850 bed.swap_to(&owner);
6851 let community = create_community(&bed.relay, "Persist", bed.relays.clone(), None).await.unwrap();
6852 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
6853 let member_hex = member.keys.public_key().to_hex();
6854 grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
6855
6856 let session = crate::state::SessionGuard::capture();
6858 follow_control(&bed.relay, &community, &session).await.unwrap();
6859 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
6860 assert!(roster.is_admin(&member_hex), "the persisted roster reads back without a fetch");
6861
6862 let withholding = MemoryRelay::new();
6865 let _ = follow_control(&withholding, &community, &session).await;
6866 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
6867 assert!(roster.is_admin(&member_hex), "withholding never shrinks standing");
6868
6869 revoke_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
6871 follow_control(&bed.relay, &community, &session).await.unwrap();
6872 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap();
6873 assert!(!roster.is_admin(&member_hex), "the revoke folds + persists");
6874 }
6875
6876 #[tokio::test]
6877 async fn grant_admin_is_refused_for_a_non_owner_and_publishes_nothing() {
6878 let (bed, owner, member) = TestBed::new();
6879 bed.swap_to(&owner);
6880 let community = create_community(&bed.relay, "NoSquat", bed.relays.clone(), None).await.unwrap();
6881
6882 bed.swap_to(&member);
6883 let err = grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap_err();
6884 assert!(err.contains("owner"), "refused before any publish: {err}");
6885
6886 bed.swap_to(&owner);
6889 let view = fetch_authority(&bed.relay, &community).await;
6890 assert!(view.roles.roles.is_empty(), "no role edition landed");
6891 grant_admin(&bed.relay, &community, &member.keys.public_key()).await.unwrap();
6892 let view = fetch_authority(&bed.relay, &community).await;
6893 assert!(view.roles.is_admin(&member.keys.public_key().to_hex()));
6894 }
6895
6896 #[tokio::test]
6897 async fn grant_admin_merges_other_roles_and_refuses_a_withheld_grant() {
6898 let (bed, owner, member) = TestBed::new();
6899 bed.swap_to(&owner);
6900 let community = create_community(&bed.relay, "Merge", bed.relays.clone(), None).await.unwrap();
6901 let member_pk = member.keys.public_key();
6902
6903 let mod_rid = crate::simd::hex::bytes_to_hex_32(&[0x66; 32]);
6906 set_role(&bed.relay, &community, &admin_role(&mod_rid, Permissions::BAN)).await.unwrap();
6907 grant_roles(&bed.relay, &community, &member_pk, vec![mod_rid.clone()]).await.unwrap();
6908
6909 let withholding = MemoryRelay::new();
6912 let err = grant_admin(&withholding, &community, &member_pk).await.unwrap_err();
6913 assert!(err.contains("could not be fetched"), "withheld grant refused: {err}");
6914
6915 grant_admin(&bed.relay, &community, &member_pk).await.unwrap();
6917 let view = fetch_authority(&bed.relay, &community).await;
6918 let grant = view.roles.grants.iter().find(|g| g.member == member_pk.to_hex()).unwrap();
6919 assert_eq!(grant.role_ids.len(), 2, "admin ADDED to the existing grant, not replacing it");
6920 assert!(grant.role_ids.contains(&mod_rid));
6921 }
6922
6923 #[tokio::test]
6924 async fn fetch_authority_reflects_a_granted_admin() {
6925 let (bed, owner, member) = TestBed::new();
6926 bed.swap_to(&owner);
6927 let community = create_community(&bed.relay, "Auth", bed.relays.clone(), None).await.unwrap();
6928 let rid = crate::simd::hex::bytes_to_hex_32(&[0x5a; 32]);
6929 publish_role(&bed.relay, &community, &owner.keys, &admin_role(&rid, Permissions::ADMIN_ALL), 1).await;
6930 publish_grant(&bed.relay, &community, &owner.keys, &member.keys.public_key(), vec![rid], 1).await;
6931
6932 let view = fetch_authority(&bed.relay, &community).await;
6933 let member_hex = member.keys.public_key().to_hex();
6934 assert!(view.roles.is_admin(&member_hex), "the granted member folds as admin");
6935 assert!(
6936 view.roles.is_authorized(&member_hex, Some(&owner.keys.public_key().to_hex()), Permissions::KICK),
6937 "an ADMIN_ALL grant carries KICK"
6938 );
6939 assert!(view.banned.is_empty());
6940 }
6941}