1use nostr_sdk::prelude::{FinalizeEvent, FinalizeEventAsync};
11use nostr_sdk::prelude::{Event, EventId, Keys, Tag, ToBech32};
12
13use super::invite::CommunityInvite;
14use super::public_invite::{
15 self, build_public_invite_event, locator_hex, parse_public_invite_event, PublicInviteBundle,
16};
17use super::send::{delete_own_message, publish_signed_message};
18use super::transport::{Evidence, Query, Transport};
19use super::{Channel, Community};
20use crate::state::SessionGuard;
21use crate::stored_event::event_kind;
22
23pub const MAX_COMMUNITIES: usize = 50;
33
34fn enforce_community_cap() -> Result<(), String> {
38 let held = super::list::load_local_list().entries.len();
39 if held >= MAX_COMMUNITIES {
40 return Err(format!(
41 "You've reached the limit of {} communities. Leave one to join another.",
42 MAX_COMMUNITIES
43 ));
44 }
45 Ok(())
46}
47
48pub async fn create_community<T: Transport + ?Sized>(
53 transport: &T,
54 name: &str,
55 default_channel_name: &str,
56 relays: Vec<String>,
57) -> Result<Community, String> {
58 let session = SessionGuard::capture();
59 enforce_community_cap()?;
60 let mut community = Community::create(name, default_channel_name, relays);
61 let owner_pk = crate::state::my_public_key().ok_or("cannot create a community without an identity")?;
67 let unsigned = super::owner::build_owner_attestation_unsigned(owner_pk, &community.id.to_hex());
68 let attestation = if let Some(keys) = crate::state::MY_SECRET_KEY.to_keys().filter(|k| k.public_key() == owner_pk) {
72 unsigned.finalize(&keys).map_err(|e| format!("sign owner attestation: {e}"))?
73 } else {
74 let signer = crate::signer::active_signer()
78 .map_err(|e| format!("cannot create a community without an identity signer: {e}"))?;
81 unsigned.finalize_async(&signer).await.map_err(|e| format!("sign owner attestation: {e}"))?
82 };
83 community.owner_attestation = Some(attestation.as_json());
84 if !session.is_valid() {
86 return Err("account changed during community creation".to_string());
87 }
88 crate::db::community::save_community(&community)?;
94
95 let signer = crate::signer::active_signer()?;
98 let cid = community.id.to_hex();
99 let created = std::time::SystemTime::now()
100 .duration_since(std::time::UNIX_EPOCH)
101 .map(|d| d.as_secs())
102 .unwrap_or(0);
103
104 let admin = super::roles::Role::admin(crate::simd::hex::bytes_to_hex_32(&super::random_32()));
112 let root_meta = super::metadata::CommunityMetadata::of(&community);
113 let root_inner = super::roster::build_community_root_edition_unsigned(owner_pk, &community.id, &root_meta, 1, None, created, None)?
114 .finalize_async(&signer).await.map_err(|e| format!("sign genesis group-root: {e}"))?;
115 let role_inner = super::roster::build_role_edition_unsigned(owner_pk, &admin, 1, None, created, None)?
116 .finalize_async(&signer).await.map_err(|e| format!("sign genesis admin-role: {e}"))?;
117 let mut heads: Vec<(String, [u8; 32], Option<[u8; 32]>)> = vec![
121 (cid.clone(), super::version::edition_hash(&community.id.0, 1, None, root_inner.content.as_bytes()), Some(root_inner.id.to_bytes())),
122 (admin.role_id.clone(), super::version::edition_hash(&crate::simd::hex::hex_to_bytes_32(&admin.role_id), 1, None, role_inner.content.as_bytes()), None),
123 ];
124 let mut to_publish: Vec<Event> = vec![
125 super::roster::seal_control_edition(&Keys::generate(), &root_inner, &community.server_root_key, &community.id, community.server_root_epoch)?,
126 super::roster::seal_control_edition(&Keys::generate(), &role_inner, &community.server_root_key, &community.id, community.server_root_epoch)?,
127 ];
128 for channel in &community.channels {
129 let meta = super::metadata::ChannelMetadata { name: channel.name.clone() };
130 let inner = super::roster::build_channel_metadata_edition_unsigned(owner_pk, &channel.id, &meta, 1, None, created, None)?
131 .finalize_async(&signer).await.map_err(|e| format!("sign genesis channel-metadata: {e}"))?;
132 heads.push((channel.id.to_hex(), super::version::edition_hash(&channel.id.0, 1, None, inner.content.as_bytes()), Some(inner.id.to_bytes())));
133 to_publish.push(super::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch)?);
134 }
135 for outer in &to_publish {
139 transport.publish_durable(outer, &community.relays).await?;
140 }
141 if session.is_valid() {
144 for (entity_hex, hash, inner_id) in &heads {
145 let _ = match inner_id {
146 Some(id) => crate::db::community::set_edition_head_with_id(&cid, entity_hex, 1, hash, id),
147 None => crate::db::community::set_edition_head(&cid, entity_hex, 1, hash),
148 };
149 }
150 let roster = super::roles::CommunityRoles { roles: vec![admin], grants: Vec::new() };
151 let _ = crate::db::community::set_community_roles(&cid, &roster, created as i64);
152 }
153 Ok(community)
154}
155
156pub async fn send_message<T: Transport + ?Sized>(
159 transport: &T,
160 community: &Community,
161 channel: &Channel,
162 author: &Keys,
163 content: &str,
164 ms: u64,
165) -> Result<Event, String> {
166 let session = SessionGuard::capture();
167 let inner = super::envelope::build_inner_event(author.public_key(), &channel.id, channel.epoch, content, ms, None)
171 .finalize(author)
172 .map_err(|e| e.to_string())?;
173 let (outer, ephemeral) = publish_signed_message(transport, community, channel, &inner, false).await?;
174 if !session.is_valid() {
177 return Err("account changed during send; not persisting message key".to_string());
178 }
179 crate::db::community::store_message_key(&inner.id.to_hex(), &outer.id.to_hex(), &ephemeral, &community.relays)?;
180 Ok(outer)
181}
182
183pub async fn send_signed_message<T: Transport + ?Sized>(
188 transport: &T,
189 community: &Community,
190 channel: &Channel,
191 inner: &Event,
192) -> Result<Event, String> {
193 let session = SessionGuard::capture();
194 let (outer, ephemeral) = publish_signed_message(transport, community, channel, inner, false).await?;
195 if !session.is_valid() {
196 return Err("account changed during send; not persisting message key".to_string());
197 }
198 crate::db::community::store_message_key(&inner.id.to_hex(), &outer.id.to_hex(), &ephemeral, &community.relays)?;
199 Ok(outer)
200}
201
202pub async fn build_presence(
212 channel: &Channel,
213 joined: bool,
214 attribution: Option<(String, Option<String>)>,
215) -> Result<nostr_sdk::prelude::Event, String> {
216 let author_pk = crate::state::my_public_key().ok_or("not logged in")?;
217 let ms = std::time::SystemTime::now()
218 .duration_since(std::time::UNIX_EPOCH)
219 .map(|d| d.as_millis() as u64)
220 .unwrap_or(0);
221 let content = match (joined, attribution) {
222 (false, _) => "leave".to_string(),
223 (true, Some((by, label))) => serde_json::json!({ "by": by, "l": label }).to_string(),
224 (true, None) => "join".to_string(),
225 };
226 let unsigned = super::envelope::build_inner_typed(
227 author_pk, &channel.id, channel.epoch, event_kind::COMMUNITY_PRESENCE, &content, ms, None, &[],
228 );
229 let signer = crate::signer::active_signer()?;
230 unsigned.finalize_async(&signer).await.map_err(|e| format!("Failed to sign presence: {e}"))
231}
232
233pub async fn publish_presence_event<T: Transport + ?Sized>(
235 transport: &T,
236 community: &Community,
237 channel: &Channel,
238 inner: &nostr_sdk::prelude::Event,
239) -> Result<(), String> {
240 let _ = publish_signed_message(transport, community, channel, inner, true).await?;
241 Ok(())
242}
243
244pub async fn publish_presence<T: Transport + ?Sized>(
245 transport: &T,
246 community: &Community,
247 channel: &Channel,
248 joined: bool,
249 attribution: Option<(String, Option<String>)>,
250) -> Result<(), String> {
251 let inner = build_presence(channel, joined, attribution).await?;
252 publish_presence_event(transport, community, channel, &inner).await
253}
254
255pub async fn publish_webxdc_signal<T: Transport + ?Sized>(
262 transport: &T,
263 community: &Community,
264 channel: &Channel,
265 topic_id: &str,
266 node_addr: Option<&str>,
267) -> Result<(), String> {
268 let author_pk = crate::state::my_public_key().ok_or("not logged in")?;
269 let ms = std::time::SystemTime::now()
270 .duration_since(std::time::UNIX_EPOCH)
271 .map(|d| d.as_millis() as u64)
272 .unwrap_or(0);
273 let content = crate::webxdc::peer_signal_content(topic_id, node_addr);
274 let unsigned = super::envelope::build_inner_typed(
275 author_pk, &channel.id, channel.epoch, event_kind::COMMUNITY_WEBXDC, &content, ms, None, &[],
276 );
277 let signer = crate::signer::active_signer()?;
278 let inner = unsigned.finalize_async(&signer).await.map_err(|e| format!("Failed to sign webxdc signal: {e}"))?;
279 let _ = publish_signed_message(transport, community, channel, &inner, true).await?;
280 Ok(())
281}
282
283pub async fn publish_typing_signal<T: Transport + ?Sized>(
289 transport: &T,
290 community: &Community,
291 channel: &Channel,
292) -> Result<(), String> {
293 let author_pk = crate::state::my_public_key().ok_or("not logged in")?;
294 let ms = std::time::SystemTime::now()
295 .duration_since(std::time::UNIX_EPOCH)
296 .map(|d| d.as_millis() as u64)
297 .unwrap_or(0);
298 let unsigned = super::envelope::build_inner_typed(
299 author_pk, &channel.id, channel.epoch, event_kind::COMMUNITY_TYPING, "typing", ms, None, &[],
300 );
301 let signer = crate::signer::active_signer()?;
302 let inner = unsigned.finalize_async(&signer).await.map_err(|e| format!("Failed to sign typing signal: {e}"))?;
303 let _ = publish_signed_message(transport, community, channel, &inner, false).await?;
304 Ok(())
305}
306
307pub async fn persist_webxdc_signal(
314 channel_hex: &str,
315 npub: &str,
316 topic_id: &str,
317 node_addr: Option<&str>,
318 event_id: &str,
319 created_at: u64,
320) {
321 if crate::db::events::event_exists(event_id).unwrap_or(true) {
322 return;
323 }
324 let now_secs = std::time::SystemTime::now()
327 .duration_since(std::time::UNIX_EPOCH)
328 .unwrap_or_default()
329 .as_secs();
330 let created_at = created_at.min(now_secs + 300);
331 let Ok(chat_id) = crate::db::id_cache::get_or_create_chat_id(channel_hex) else { return };
332 let mut tags = vec![
333 vec!["webxdc-topic".to_string(), topic_id.to_string()],
334 vec!["d".to_string(), "vector-webxdc-peer".to_string()],
335 ];
336 if let Some(addr) = node_addr {
337 tags.push(vec!["webxdc-node-addr".to_string(), addr.to_string()]);
338 }
339 let event = crate::stored_event::StoredEvent {
340 id: event_id.to_string(),
341 kind: crate::stored_event::event_kind::APPLICATION_SPECIFIC,
342 chat_id,
343 user_id: None,
344 content: if node_addr.is_some() { "peer-advertisement" } else { "peer-left" }.to_string(),
345 tags,
346 reference_id: Some(topic_id.to_string()),
347 created_at,
348 received_at: std::time::SystemTime::now()
349 .duration_since(std::time::UNIX_EPOCH)
350 .unwrap_or_default()
351 .as_millis() as u64,
352 mine: false,
353 pending: false,
354 failed: false,
355 wrapper_event_id: None,
356 npub: Some(npub.to_string()),
357 preview_metadata: None,
358 };
359 if let Err(e) = crate::db::events::save_event(&event).await {
360 crate::log_warn!("[community] failed to persist webxdc peer signal: {e}");
361 }
362}
363
364async fn strip_member_roles_on_removal<T: Transport + ?Sized>(
378 transport: &T,
379 community: &Community,
380 member_hex: &str,
381) {
382 let cid = community.id.to_hex();
383 let roster = match crate::db::community::get_community_roles(&cid) {
384 Ok(r) => r,
385 Err(_) => return,
386 };
387 let held: Vec<String> = roster
388 .grants
389 .iter()
390 .find(|g| g.member == member_hex)
391 .map(|g| g.role_ids.clone())
392 .unwrap_or_default();
393 if held.is_empty() {
394 return; }
396 for role_id in &held {
397 if caller_can_manage_role(community, &roster, role_id, member_hex).is_err() {
398 crate::log_warn!(
399 "removal: not authorized to revoke role {role_id} of {member_hex}; leaving the grant (kick/ban still neutralizes)"
400 );
401 return;
402 }
403 }
404 if let Err(e) = set_member_grant(transport, community, member_hex, Vec::new()).await {
405 crate::log_warn!("removal: role-strip publish failed for {member_hex}: {e}");
406 }
407}
408
409pub async fn publish_kick<T: Transport + ?Sized>(
410 transport: &T,
411 community: &Community,
412 channel: &Channel,
413 target_hex: &str,
414) -> Result<String, String> {
415 let author_pk = crate::state::my_public_key().ok_or("not logged in")?;
416 let me = author_pk.to_hex();
417 let cid = community.id.to_hex();
418 {
421 let owner = proven_owner_hex(community);
422 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
423 if !roster.can_act_on_member(&me, owner.as_deref(), target_hex, super::roles::Permissions::KICK) {
424 return Err("you can't kick a member who outranks you (or the owner)".to_string());
425 }
426 }
427 let ms = std::time::SystemTime::now()
428 .duration_since(std::time::UNIX_EPOCH)
429 .map(|d| d.as_millis() as u64)
430 .unwrap_or(0);
431 let citation = authority_citation(community, &me);
433 let extra: Vec<nostr_sdk::prelude::Tag> = citation.iter().map(|c| c.to_tag()).collect();
434 let unsigned = super::envelope::build_inner_full(
435 author_pk, &channel.id, channel.epoch, event_kind::COMMUNITY_KICK, target_hex, ms, None, &[], &extra,
436 );
437 let signer = crate::signer::active_signer()?;
438 let inner = unsigned.finalize_async(&signer).await.map_err(|e| format!("Failed to sign kick: {e}"))?;
439 publish_signed_message(transport, community, channel, &inner, true).await?;
440 strip_member_roles_on_removal(transport, community, target_hex).await;
443 Ok(inner.id.to_hex())
445}
446
447
448pub async fn publish_banlist<T: Transport + ?Sized>(
454 transport: &T,
455 community: &Community,
456 banned_hex: &[String],
457) -> Result<(), String> {
458 let session = SessionGuard::capture();
459 let cid = community.id.to_hex();
460 let signer = crate::signer::active_signer()?;
463 let actor_pk = crate::state::my_public_key().ok_or("no local identity to sign the banlist edition")?;
464 {
469 let me = actor_pk.to_hex();
470 let owner = proven_owner_hex(community);
471 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
472 let current: std::collections::HashSet<String> =
473 crate::db::community::get_community_banlist(&cid).unwrap_or_default().into_iter().collect();
474 let next: std::collections::HashSet<&str> = banned_hex.iter().map(|s| s.as_str()).collect();
475 let added = banned_hex.iter().filter(|n| !current.contains(n.as_str()));
476 let removed = current.iter().filter(|n| !next.contains(n.as_str()));
477 for target in added.chain(removed) {
478 if !roster.can_act_on_member(&me, owner.as_deref(), target, super::roles::Permissions::BAN) {
479 return Err("you can't ban or unban a member who outranks you (or the owner)".to_string());
480 }
481 }
482 }
483 {
489 let prev: std::collections::HashSet<String> =
490 crate::db::community::get_community_banlist(&cid).unwrap_or_default().into_iter().collect();
491 let adds = banned_hex.iter().any(|n| !prev.contains(n.as_str()));
492 let cut_needed = (adds || crate::db::community::get_read_cut_pending(&cid)?) && !is_public(community)?;
493 if cut_needed && crate::state::MY_SECRET_KEY.to_keys().is_none() {
494 return Err("Banning someone from a private community cuts their read access, which needs a key rotation your account can't perform: it signs remotely (a NIP-46 bunker), and a rotation requires a local key. Ask a community admin who holds a local key to carry out the ban.".to_string());
495 }
496 }
497 let entity_id = super::derive::banlist_locator(&community.id);
499 let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
500 let (version, prev_hash) = match crate::db::community::get_edition_head(&cid, &entity_hex)? {
501 Some((v, h)) => (v + 1, Some(h)),
502 None => (1, None),
503 };
504 let created_at = std::time::SystemTime::now()
505 .duration_since(std::time::UNIX_EPOCH)
506 .map(|d| d.as_secs())
507 .unwrap_or(0);
508 let citation = authority_citation(community, &actor_pk.to_hex());
511 let unsigned = super::roster::build_banlist_edition_unsigned(actor_pk, &community.id, banned_hex, version, prev_hash.as_ref(), created_at, citation.as_ref())?;
512 let inner = unsigned.finalize_async(&signer).await.map_err(|e| format!("sign banlist edition: {e}"))?;
513 let outer = super::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch)?;
514 let self_hash = super::version::edition_hash(&entity_id, version, prev_hash.as_ref(), inner.content.as_bytes());
515
516 let newly_added: Vec<String> = {
519 let prev: std::collections::HashSet<String> =
520 crate::db::community::get_community_banlist(&cid).unwrap_or_default().into_iter().collect();
521 banned_hex.iter().filter(|n| !prev.contains(n.as_str())).cloned().collect()
522 };
523 let newly_banned = !newly_added.is_empty();
524
525 transport.publish_durable(&outer, &community.relays).await?;
529 if session.is_valid() {
530 crate::db::community::set_community_banlist(&cid, banned_hex, created_at as i64)?;
531 crate::db::community::set_edition_head(&cid, &entity_hex, version, &self_hash)?;
532 }
533
534 if session.is_valid() {
539 for member_hex in &newly_added {
540 strip_member_roles_on_removal(transport, community, member_hex).await;
541 }
542 }
543
544 let need_cut = (newly_banned || crate::db::community::get_read_cut_pending(&cid)?)
559 && session.is_valid()
560 && !is_public(community)?;
561 if need_cut {
562 run_read_cut(transport, community, newly_banned).await?;
565 }
566 Ok(())
567}
568
569pub fn am_i_banned(community: &Community) -> bool {
575 let me = match crate::state::my_public_key() {
576 Some(p) => p.to_hex(),
577 None => return false,
578 };
579 crate::db::community::get_community_banlist(&community.id.to_hex())
580 .unwrap_or_default()
581 .iter()
582 .any(|b| b == &me)
583}
584
585pub async fn retry_pending_read_cut<T: Transport + ?Sized>(
591 transport: &T,
592 community: &Community,
593) -> Result<(), String> {
594 let cid = community.id.to_hex();
595 if !crate::db::community::get_read_cut_pending(&cid)? {
596 return Ok(());
597 }
598 if is_public(community)? {
599 crate::db::community::set_read_cut_pending(&cid, false)?; return Ok(());
601 }
602 let fresh = crate::db::community::load_community(&community.id)?.ok_or("community no longer present")?;
607 run_read_cut(transport, &fresh, false).await
608}
609
610async fn fetch_control_folded<T: Transport + ?Sized>(
620 transport: &T,
621 community: &Community,
622) -> Result<super::roster::FoldedRoster, String> {
623 fetch_control_folded_with(transport, community, Evidence::Quorum).await
624}
625
626async fn fetch_control_folded_with<T: Transport + ?Sized>(
627 transport: &T,
628 community: &Community,
629 evidence: Evidence,
630) -> Result<super::roster::FoldedRoster, String> {
631 let z_tags = vec![super::roster::control_pseudonym(&community.server_root_key, &community.id, community.server_root_epoch)];
636 let query = Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags, evidence, ..Default::default() };
641 let raw = transport.fetch(&query, &community.relays).await?;
642 let inner_editions: Vec<Event> = raw
645 .iter()
646 .take(super::roster::MAX_CONTROL_EDITIONS)
647 .filter_map(|ev| super::roster::open_control_edition(ev, &community.server_root_key).ok())
648 .collect();
649 let fetched = inner_editions.len();
654 let current_epoch = community.server_root_epoch.0;
660 let floors: std::collections::HashMap<String, (u64, [u8; 32])> =
661 crate::db::community::get_all_edition_heads_epoched(&community.id.to_hex())?
662 .into_iter()
663 .filter(|(_, (epoch, _, _))| *epoch == current_epoch)
664 .map(|(entity, (_epoch, version, hash))| (entity, (version, hash)))
665 .collect();
666 let mut folded = super::roster::fold_roster(&inner_editions, &community.id, &floors);
667 folded.fetched = fetched; Ok(folded)
669}
670
671pub async fn fetch_and_apply_control<T: Transport + ?Sized>(
677 transport: &T,
678 community: &Community,
679) -> Result<usize, String> {
680 fetch_and_apply_control_with(transport, community, Evidence::Quorum).await
681}
682
683pub async fn fetch_and_apply_control_full<T: Transport + ?Sized>(
688 transport: &T,
689 community: &Community,
690) -> Result<usize, String> {
691 fetch_and_apply_control_with(transport, community, Evidence::Full).await
692}
693
694async fn fetch_and_apply_control_with<T: Transport + ?Sized>(
695 transport: &T,
696 community: &Community,
697 evidence: Evidence,
698) -> Result<usize, String> {
699 let session = SessionGuard::capture();
700 let cid = community.id.to_hex();
701 if crate::db::community::get_community_dissolved(&cid)? {
704 return Ok(0);
705 }
706 let folded = fetch_control_folded_with(transport, community, evidence).await?;
707 if !session.is_valid() {
708 return Err("account changed during control fetch".to_string());
709 }
710 if let Some(owner) = proven_owner_hex(community) {
720 let by_fold = folded.dissolved_by.iter().any(|s| s.to_hex() == owner);
721 let probe_records = if by_fold {
722 Vec::new()
723 } else {
724 dissolved_tombstone_records(transport, community).await
725 };
726 let by_probe = !by_fold && probe_records.iter().any(|d| d.author.to_hex() == owner);
727 if by_fold || by_probe {
728 let mut tombstones = folded.dissolved_editions.clone();
734 tombstones.extend(probe_records);
735 let mut migration_pointer_found = false;
736 if let Some((_, raw)) = super::migration::select_pointer(&tombstones, &owner) {
737 if session.is_valid() {
738 let _ = crate::db::community::set_migration_pointer(&cid, &raw);
739 migration_pointer_found = true;
740 }
741 }
742 let _ = fetch_and_apply_banlist_inner(transport, community, Some(folded.clone())).await;
745 let _ = fetch_and_apply_roles_inner(transport, community, Some(folded.clone())).await;
746 let _ = fetch_and_apply_invite_links_inner(transport, community, Some(folded.clone())).await;
747 let _ = fetch_and_apply_metadata_inner(transport, community, Some(folded.clone())).await;
748 if session.is_valid() {
749 crate::db::community::set_community_dissolved(&cid)?;
750 crate::emit_event("community_refreshed", &serde_json::json!({ "community_id": cid }));
754 if migration_pointer_found {
759 match Box::pin(super::migration::drive_migration(transport, community)).await {
760 Ok(Some(v2_hex)) => super::migration::spawn_finalize_migration(cid.clone(), v2_hex),
761 Ok(None) => {}
762 Err(e) => crate::log_warn!("migration drive for {cid}: {e}"),
763 }
764 }
765 }
766 return Ok(folded.fetched);
767 }
768 }
769 let fetched = folded.fetched;
772 let _ = fetch_and_apply_banlist_inner(transport, community, Some(folded.clone())).await;
773 let _ = fetch_and_apply_roles_inner(transport, community, Some(folded.clone())).await;
774 let _ = fetch_and_apply_invite_links_inner(transport, community, Some(folded.clone())).await;
775 let _ = fetch_and_apply_metadata_inner(transport, community, Some(folded)).await;
776 Ok(fetched)
777}
778
779pub async fn fetch_and_apply_banlist<T: Transport + ?Sized>(
780 transport: &T,
781 community: &Community,
782) -> Result<Vec<String>, String> {
783 fetch_and_apply_banlist_inner(transport, community, None).await
784}
785
786async fn fetch_and_apply_banlist_inner<T: Transport + ?Sized>(
787 transport: &T,
788 community: &Community,
789 prefolded: Option<super::roster::FoldedRoster>,
790) -> Result<Vec<String>, String> {
791 let session = SessionGuard::capture();
792 let cid = community.id.to_hex();
793 let folded = match prefolded {
794 Some(f) => f,
795 None => fetch_control_folded(transport, community).await?,
796 };
797 let owner = proven_owner_hex(community);
800 let authorized = super::roster::authorize_delegation(&folded, owner.as_deref());
801 if !session.is_valid() {
802 return Err("account changed during banlist fetch".to_string());
803 }
804 if let (Some(author), Some(head)) = (folded.banlist_author, &folded.banlist_head) {
805 let author_hex = author.to_hex();
810 let held: std::collections::HashSet<String> =
811 crate::db::community::get_community_banlist(&cid)?.into_iter().collect();
812 let next: std::collections::HashSet<&str> = folded.banned.iter().map(|s| s.as_str()).collect();
813 let added = folded.banned.iter().filter(|n| !held.contains(n.as_str()));
814 let removed = held.iter().filter(|n| !next.contains(n.as_str()));
815 let citation = folded.banlist_head.as_ref().and_then(|h| h.citation.as_ref());
821 let banner_grant_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(&community.id, &author.to_bytes()));
822 let pinned = super::roster::authority_citation_satisfied(&folded.heads, owner.as_deref(), &author_hex, &banner_grant_hex, citation);
823 let authed = pinned
824 && added.chain(removed).all(|target| {
825 authorized.can_act_on_member(&author_hex, owner.as_deref(), target, super::roles::Permissions::BAN)
826 });
827 let held_version = crate::db::community::get_edition_head(&cid, &head.entity_hex)?.map(|(v, _)| v).unwrap_or(0);
828 if authed && head.version > held_version {
829 crate::db::community::set_community_banlist(&cid, &folded.banned, head.version as i64)?;
830 crate::db::community::set_edition_head(&cid, &head.entity_hex, head.version, &head.self_hash)?;
831 return Ok(folded.banned);
832 }
833 }
834 crate::db::community::get_community_banlist(&cid)
836}
837
838pub async fn set_member_grant<T: Transport + ?Sized>(
843 transport: &T,
844 community: &Community,
845 member_hex: &str,
846 role_ids: Vec<String>,
847) -> Result<(), String> {
848 let session = SessionGuard::capture();
849 let signer = crate::signer::active_signer()?;
852 let actor_pk = crate::state::my_public_key().ok_or("no local identity to sign the grant edition")?;
853 let cid = community.id.to_hex();
854 let grant = super::roles::MemberGrant { member: member_hex.to_string(), role_ids };
855
856 let member_bytes = crate::simd::hex::hex_to_bytes_32(member_hex);
859 let entity_id = super::derive::grant_locator(&community.id, &member_bytes);
860 let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
861 let (version, prev_hash) = match crate::db::community::get_edition_head(&cid, &entity_hex)? {
862 Some((v, h)) => (v + 1, Some(h)),
863 None => (1, None),
864 };
865 let created_at = std::time::SystemTime::now()
866 .duration_since(std::time::UNIX_EPOCH)
867 .map(|d| d.as_secs())
868 .unwrap_or(0);
869
870 let citation = authority_citation(community, &actor_pk.to_hex());
877 let unsigned = super::roster::build_grant_edition_unsigned(actor_pk, &community.id, &grant, version, prev_hash.as_ref(), created_at, citation.as_ref())?;
878 let inner = unsigned.finalize_async(&signer).await.map_err(|e| format!("sign grant edition: {e}"))?;
879 let outer = super::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch)?;
880 let self_hash = super::version::edition_hash(&entity_id, version, prev_hash.as_ref(), inner.content.as_bytes());
884
885 let is_full_revoke = grant.role_ids.is_empty();
886 let mut roster = crate::db::community::get_community_roles(&cid)?;
888 roster.grants.retain(|g| g.member != member_hex);
889 if !grant.role_ids.is_empty() {
890 roster.grants.push(grant);
891 }
892
893 transport.publish_durable(&outer, &community.relays).await?;
899 if session.is_valid() {
900 crate::db::community::set_community_roles(&cid, &roster, created_at as i64)?;
901 crate::db::community::set_edition_head(&cid, &entity_hex, version, &self_hash)?;
902 }
903
904 if is_full_revoke && session.is_valid() {
912 if let Ok(folded) = fetch_control_folded(transport, community).await {
913 if session.is_valid() {
914 let current = crate::db::community::load_community(&community.id)?.unwrap_or_else(|| community.clone());
915 if folded.root_author.map(|a| a.to_hex()).as_deref() == Some(member_hex) {
916 if let Some(meta) = &folded.root_meta {
917 let mut c = current.clone();
918 c.name = meta.name.clone();
919 c.description = meta.description.clone();
920 c.icon = meta.icon.clone();
921 c.banner = meta.banner.clone();
922 let _ = republish_community_metadata(transport, &c).await;
923 }
924 }
925 for cm in &folded.channel_meta {
926 if cm.author.to_hex() == member_hex
927 && current.channels.iter().any(|ch| ch.id.0 == cm.channel_id)
928 {
929 let _ = republish_channel_metadata(
930 transport, ¤t, &crate::community::ChannelId(cm.channel_id), &cm.meta.name,
931 ).await;
932 }
933 }
934 }
935 }
936 }
937 Ok(())
938}
939
940pub fn is_proven_owner(community: &Community) -> bool {
945 match crate::state::my_public_key() {
946 Some(me) => proven_owner_hex(community).as_deref() == Some(me.to_hex().as_str()),
947 None => false,
948 }
949}
950
951pub fn caller_can_manage_roles(community: &Community) -> bool {
955 let me = match crate::state::my_public_key() {
956 Some(p) => p,
957 None => return false,
958 };
959 let cid = community.id.to_hex();
960 let is_owner = community
961 .owner_attestation
962 .as_ref()
963 .and_then(|a| super::owner::verify_owner_attestation(a, &cid))
964 .map(|pk| pk == me)
965 .unwrap_or(false);
966 if is_owner {
967 return true; }
969 crate::db::community::get_community_roles(&cid)
970 .unwrap_or_default()
971 .has_permission(&me.to_hex(), super::roles::Permissions::MANAGE_ROLES)
972}
973
974pub fn caller_has_permission(community: &Community, permission: u64) -> bool {
978 let me = match crate::state::my_public_key() {
979 Some(p) => p,
980 None => return false,
981 };
982 crate::db::community::get_community_roles(&community.id.to_hex())
983 .unwrap_or_default()
984 .is_authorized(&me.to_hex(), proven_owner_hex(community).as_deref(), permission)
985}
986
987pub fn caller_can_manage_role_id(community: &Community, role_id: &str) -> bool {
992 let me = match crate::state::my_public_key() {
993 Some(p) => p.to_hex(),
994 None => return false,
995 };
996 let roster = crate::db::community::get_community_roles(&community.id.to_hex()).unwrap_or_default();
997 let position = match roster.role(role_id) {
998 Some(r) => r.position,
999 None => return false,
1000 };
1001 roster.can_manage_position(&me, proven_owner_hex(community).as_deref(), position)
1002}
1003
1004#[derive(Debug, Clone, Default, serde::Serialize)]
1009pub struct CommunityCapabilities {
1010 pub manage_metadata: bool,
1011 pub manage_channels: bool,
1012 pub create_invite: bool,
1013 pub kick: bool,
1014 pub ban: bool,
1015 pub manage_messages: bool,
1016 pub manage_roles: bool,
1017}
1018
1019pub fn caller_capabilities(community: &Community) -> CommunityCapabilities {
1020 use super::roles::Permissions as P;
1021 let me_hex = match crate::state::my_public_key() {
1022 Some(p) => p.to_hex(),
1023 None => return CommunityCapabilities::default(),
1024 };
1025 let owner = proven_owner_hex(community);
1026 let roster = crate::db::community::get_community_roles(&community.id.to_hex()).unwrap_or_default();
1027 let has = |bit: u64| roster.is_authorized(&me_hex, owner.as_deref(), bit);
1028 CommunityCapabilities {
1029 manage_metadata: has(P::MANAGE_METADATA),
1030 manage_channels: has(P::MANAGE_CHANNELS),
1031 create_invite: has(P::CREATE_INVITE),
1032 kick: has(P::KICK),
1033 ban: has(P::BAN),
1034 manage_messages: has(P::MANAGE_MESSAGES),
1035 manage_roles: has(P::MANAGE_ROLES),
1036 }
1037}
1038
1039fn authority_citation(community: &Community, actor_hex: &str) -> Option<super::edition::AuthorityCitation> {
1047 if proven_owner_hex(community).as_deref() == Some(actor_hex) {
1048 return None;
1049 }
1050 let cid = community.id.to_hex();
1051 let actor_bytes = crate::simd::hex::hex_to_bytes_32(actor_hex);
1052 let entity_id = super::derive::grant_locator(&community.id, &actor_bytes);
1053 let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
1054 crate::db::community::get_edition_head(&cid, &entity_hex)
1055 .ok()
1056 .flatten()
1057 .map(|(version, edition_hash)| super::edition::AuthorityCitation { entity_id, version, edition_hash })
1058}
1059
1060pub(crate) fn proven_owner_hex(community: &Community) -> Option<String> {
1063 let cid = community.id.to_hex();
1064 community
1065 .owner_attestation
1066 .as_ref()
1067 .and_then(|a| super::owner::verify_owner_attestation(a, &cid))
1068 .map(|pk| pk.to_hex())
1069}
1070
1071pub fn can_moderation_hide(community: &Community, actor_hex: &str, author_hex: &str) -> bool {
1077 let owner = proven_owner_hex(community)
1081 .or_else(|| super::moderation::owner_hex(&community.id.to_hex()));
1082 let roster = crate::db::community::get_community_roles(&community.id.to_hex()).unwrap_or_default();
1083 super::moderation::can_hide(owner.as_deref(), &roster, actor_hex, author_hex)
1084}
1085
1086fn rotator_is_authorized(
1094 cid: &str,
1095 roster: &super::roles::CommunityRoles,
1096 owner_hex: Option<&str>,
1097 rotator_hex: &str,
1098 permission: u64,
1099) -> bool {
1100 if owner_hex != Some(rotator_hex)
1101 && crate::db::community::get_community_banlist(cid)
1102 .unwrap_or_default()
1103 .iter()
1104 .any(|b| b == rotator_hex)
1105 {
1106 return false;
1107 }
1108 roster.is_authorized(rotator_hex, owner_hex, permission)
1109}
1110
1111fn caller_can_manage_role(
1117 community: &Community,
1118 roster: &super::roles::CommunityRoles,
1119 role_id: &str,
1120 member_hex: &str,
1121) -> Result<(), String> {
1122 let me = crate::state::my_public_key().ok_or("no active identity")?.to_hex();
1123 let owner = proven_owner_hex(community);
1124 let owner_ref = owner.as_deref();
1125 let role = roster.role(role_id).ok_or("no such role")?;
1126 if !roster.can_manage_position(&me, owner_ref, role.position) {
1127 return Err("you can only manage roles below your own".to_string());
1128 }
1129 if !roster.can_manage_member(&me, owner_ref, member_hex) {
1130 return Err("you can't manage a member who outranks you".to_string());
1131 }
1132 Ok(())
1133}
1134
1135pub async fn grant_role<T: Transport + ?Sized>(
1139 transport: &T,
1140 community: &Community,
1141 member: nostr_sdk::prelude::PublicKey,
1142 role_id: &str,
1143) -> Result<(), String> {
1144 let cid = community.id.to_hex();
1145 let member_hex = member.to_hex();
1146 let roster = crate::db::community::get_community_roles(&cid)?;
1147 caller_can_manage_role(community, &roster, role_id, &member_hex)?;
1148 let mut role_ids: Vec<String> = roster
1150 .grants
1151 .iter()
1152 .find(|g| g.member == member_hex)
1153 .map(|g| g.role_ids.clone())
1154 .unwrap_or_default();
1155 if !role_ids.iter().any(|r| r == role_id) {
1156 role_ids.push(role_id.to_string());
1157 }
1158
1159 set_member_grant(transport, community, &member_hex, role_ids).await
1163}
1164
1165pub async fn revoke_role<T: Transport + ?Sized>(
1172 transport: &T,
1173 community: &Community,
1174 member: nostr_sdk::prelude::PublicKey,
1175 role_id: &str,
1176) -> Result<(), String> {
1177 let cid = community.id.to_hex();
1178 let member_hex = member.to_hex();
1179 let roster = crate::db::community::get_community_roles(&cid)?;
1180 caller_can_manage_role(community, &roster, role_id, &member_hex)?;
1181 let role_ids: Vec<String> = roster
1182 .grants
1183 .iter()
1184 .find(|g| g.member == member_hex)
1185 .map(|g| g.role_ids.iter().filter(|r| r.as_str() != role_id).cloned().collect())
1186 .unwrap_or_default();
1187 set_member_grant(transport, community, &member_hex, role_ids).await
1188}
1189
1190pub async fn fetch_and_apply_roles<T: Transport + ?Sized>(
1196 transport: &T,
1197 community: &Community,
1198) -> Result<super::roles::CommunityRoles, String> {
1199 fetch_and_apply_roles_inner(transport, community, None).await
1200}
1201
1202async fn fetch_and_apply_roles_inner<T: Transport + ?Sized>(
1203 transport: &T,
1204 community: &Community,
1205 prefolded: Option<super::roster::FoldedRoster>,
1206) -> Result<super::roles::CommunityRoles, String> {
1207 let session = SessionGuard::capture();
1208 let cid = community.id.to_hex();
1209 let folded = match prefolded {
1210 Some(f) => f,
1211 None => fetch_control_folded(transport, community).await?,
1212 };
1213
1214 if !session.is_valid() {
1215 return Err("account changed during roles fetch".to_string());
1216 }
1217 for head in &folded.heads {
1226 crate::db::community::set_edition_head(&cid, &head.entity_hex, head.version, &head.self_hash)?;
1227 }
1228 if folded.heads.is_empty() {
1233 return crate::db::community::get_community_roles(&cid);
1234 }
1235 let authorized = super::roster::authorize_delegation(&folded, proven_owner_hex(community).as_deref());
1239 crate::db::community::set_community_roles(&cid, &authorized, 0)?;
1240 Ok(authorized)
1241}
1242
1243pub async fn publish_owner_hide<T: Transport + ?Sized>(
1248 transport: &T,
1249 community: &Community,
1250 channel: &Channel,
1251 target_message_id: &str,
1252) -> Result<(), String> {
1253 let signer = crate::signer::active_signer()?;
1258 let me_pk = crate::state::my_public_key().ok_or("no local identity to sign the hide")?;
1259 let me = me_pk.to_hex();
1260 {
1261 let target_author = {
1262 let st = crate::state::STATE.lock().await;
1263 st.find_message(target_message_id).and_then(|(_, m)| m.npub)
1264 };
1265 let author = target_author
1266 .ok_or("can't resolve the target message's author to authorize the hide")?;
1267 if !can_moderation_hide(community, &me, &author) {
1268 return Err("you can't hide a message from a member who outranks you (or the owner)".to_string());
1269 }
1270 }
1271 let ms = std::time::SystemTime::now()
1272 .duration_since(std::time::UNIX_EPOCH)
1273 .map(|d| d.as_millis() as u64)
1274 .unwrap_or(0);
1275 let citation = authority_citation(community, &me);
1281 let extra: Vec<Tag> = citation.iter().map(|c| c.to_tag()).collect();
1282 let inner = super::envelope::build_inner_full(
1283 me_pk, &channel.id, channel.epoch,
1284 event_kind::COMMUNITY_DELETE, "", ms, Some(target_message_id), &[], &extra,
1285 )
1286 .finalize_async(&signer)
1287 .await
1288 .map_err(|e| format!("sign hide: {e}"))?;
1289 let _ = publish_signed_message(transport, community, channel, &inner, true).await?;
1290 Ok(())
1291}
1292
1293pub async fn delete_message<T: Transport + ?Sized>(
1298 transport: &T,
1299 message_id: &str,
1300) -> Result<(), String> {
1301 let session = SessionGuard::capture();
1302 if !session.is_valid() {
1303 return Err("account changed; aborting delete".to_string());
1304 }
1305 let (ephemeral, outer_event_id_hex, relays) = match crate::db::community::get_message_key(message_id)? {
1309 Some(v) => v,
1310 None => {
1311 return Err("no retained key for this message (not yours, or already deleted)".to_string())
1312 }
1313 };
1314 let id = EventId::from_hex(&outer_event_id_hex).map_err(|e| e.to_string())?;
1315 delete_own_message(transport, &relays, &ephemeral, id).await?;
1316 crate::db::community::delete_message_key(message_id)?;
1318 Ok(())
1319}
1320
1321pub fn accept_invite(invite: &CommunityInvite) -> Result<Community, String> {
1334 let session = SessionGuard::capture();
1335 let community = super::invite::accept_invite(invite)?; match crate::db::community::load_community(&community.id)? {
1338 Some(existing) => {
1340 if crate::db::community::get_migrated_to(&existing.id.to_hex())?.is_some() {
1344 return Err("This community has upgraded to Concord v2. Ask a member for a fresh invite.".to_string());
1345 }
1346 if is_proven_owner(&existing) {
1347 return Err("you already own this Community".to_string());
1348 }
1349 if existing.server_root_key.as_bytes() != community.server_root_key.as_bytes() {
1353 return Err(
1354 "invite reuses a known Community id under a different authority — rejected"
1355 .to_string(),
1356 );
1357 }
1358 }
1359 None => enforce_community_cap()?,
1361 }
1362
1363 if !session.is_valid() {
1364 return Err("account changed during invite accept".to_string());
1365 }
1366 crate::db::community::save_community(&community)?;
1367 Ok(community)
1368}
1369
1370pub async fn preload_community(invite: &super::invite::CommunityInvite) {
1377 let Ok(community) = super::invite::accept_invite(invite) else { return };
1378 let Some(channel) = community.channels.first() else { return };
1379 let cid = community.id.to_hex();
1380 crate::community::cache::begin_preload(&cid);
1382 let transport = super::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1383 match super::send::fetch_channel_page(&transport, &community, channel, None, None, 50).await {
1385 Ok(page) if !page.is_empty() => crate::community::cache::finish_preload(&cid, page),
1386 _ => crate::community::cache::abort_preload(&cid),
1388 }
1389
1390 let prune_relays = community.relays.clone();
1394 let prune_id = community.id;
1395 let guard = crate::state::SessionGuard::capture();
1396 tokio::spawn(async move {
1397 tokio::time::sleep(crate::community::cache::PRELOAD_TTL).await;
1398 if !guard.is_valid() {
1399 return;
1400 }
1401 if matches!(crate::db::community::load_community(&prune_id), Ok(Some(_))) {
1404 return;
1405 }
1406 crate::community::cache::abort_preload(&prune_id.to_hex());
1408 super::transport::prune_unneeded_community_relays(&prune_relays).await;
1409 });
1410}
1411
1412pub async fn republish_community_metadata<T: Transport + ?Sized>(
1417 transport: &T,
1418 community: &Community,
1419) -> Result<(), String> {
1420 let session = SessionGuard::capture();
1421 let cid = community.id.to_hex();
1422 if crate::db::community::get_migrated_to(&cid)?.is_some() {
1425 return Err("this community has upgraded to Concord v2".to_string());
1426 }
1427 let signer = crate::signer::active_signer()?;
1428 let actor_pk = crate::state::my_public_key().ok_or("no local identity to sign the metadata edition")?;
1429 let owner = proven_owner_hex(community);
1430 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
1431 if !roster.is_authorized(&actor_pk.to_hex(), owner.as_deref(), super::roles::Permissions::MANAGE_METADATA) {
1432 return Err("only a member with manage-metadata authority can edit the community".to_string());
1433 }
1434 let (version, prev_hash) = match crate::db::community::get_edition_head(&cid, &cid)? {
1439 Some((v, h)) => (v + 1, Some(h)),
1440 None => (1, None),
1441 };
1442 let created = std::time::SystemTime::now()
1443 .duration_since(std::time::UNIX_EPOCH)
1444 .map(|d| d.as_secs())
1445 .unwrap_or(0);
1446 let meta = super::metadata::CommunityMetadata::of(community);
1447 let citation = authority_citation(community, &actor_pk.to_hex());
1452 let unsigned = super::roster::build_community_root_edition_unsigned(actor_pk, &community.id, &meta, version, prev_hash.as_ref(), created, citation.as_ref())?;
1453 let inner = unsigned.finalize_async(&signer).await.map_err(|e| format!("sign community-root edition: {e}"))?;
1454 let outer = super::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch)?;
1455 transport.publish_durable(&outer, &community.relays).await?;
1456 if session.is_valid() {
1457 crate::db::community::save_community(community)?;
1458 let h = super::version::edition_hash(&community.id.0, version, prev_hash.as_ref(), inner.content.as_bytes());
1459 crate::db::community::set_edition_head_with_id(&cid, &cid, version, &h, &inner.id.to_bytes())?;
1462 }
1463 Ok(())
1464}
1465
1466pub async fn republish_channel_metadata<T: Transport + ?Sized>(
1472 transport: &T,
1473 community: &Community,
1474 channel_id: &crate::community::ChannelId,
1475 new_name: &str,
1476) -> Result<(), String> {
1477 let session = SessionGuard::capture();
1478 let cid = community.id.to_hex();
1479 let ch_hex = channel_id.to_hex();
1480 if crate::db::community::get_migrated_to(&cid)?.is_some() {
1482 return Err("this community has upgraded to Concord v2".to_string());
1483 }
1484 if !community.channels.iter().any(|c| &c.id == channel_id) {
1485 return Err("no such channel in this community".to_string());
1486 }
1487 let signer = crate::signer::active_signer()?;
1488 let actor_pk = crate::state::my_public_key().ok_or("no local identity to sign the channel metadata edition")?;
1489 let owner = proven_owner_hex(community);
1490 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
1491 if !roster.is_authorized(&actor_pk.to_hex(), owner.as_deref(), super::roles::Permissions::MANAGE_CHANNELS) {
1492 return Err("only a member with manage-channels authority can rename a channel".to_string());
1493 }
1494 let (version, prev_hash) = match crate::db::community::get_edition_head(&cid, &ch_hex)? {
1495 Some((v, h)) => (v + 1, Some(h)),
1496 None => (1, None),
1497 };
1498 let created = std::time::SystemTime::now()
1499 .duration_since(std::time::UNIX_EPOCH)
1500 .map(|d| d.as_secs())
1501 .unwrap_or(0);
1502 let meta = super::metadata::ChannelMetadata { name: new_name.to_string() };
1503 let citation = authority_citation(community, &actor_pk.to_hex());
1506 let unsigned = super::roster::build_channel_metadata_edition_unsigned(actor_pk, channel_id, &meta, version, prev_hash.as_ref(), created, citation.as_ref())?;
1507 let inner = unsigned.finalize_async(&signer).await.map_err(|e| format!("sign channel-metadata edition: {e}"))?;
1508 let outer = super::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch)?;
1509 transport.publish_durable(&outer, &community.relays).await?;
1510 if session.is_valid() {
1511 let mut current = crate::db::community::load_community(&community.id)?.ok_or("community no longer present")?;
1512 if let Some(ch) = current.channels.iter_mut().find(|c| &c.id == channel_id) {
1513 ch.name = new_name.to_string();
1514 }
1515 crate::db::community::save_community(¤t)?;
1516 let h = super::version::edition_hash(&channel_id.0, version, prev_hash.as_ref(), inner.content.as_bytes());
1517 crate::db::community::set_edition_head_with_id(&cid, &ch_hex, version, &h, &inner.id.to_bytes())?;
1518 }
1519 Ok(())
1520}
1521
1522fn generate_invite_label() -> String {
1535 use rand::Rng;
1536 const ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
1537 let mut rng = rand::thread_rng();
1538 (0..6).map(|_| ALPHABET[rng.gen_range(0..ALPHABET.len())] as char).collect()
1539}
1540
1541pub async fn create_public_invite<T: Transport + ?Sized>(
1542 transport: &T,
1543 community: &Community,
1544 expires_at: Option<u64>,
1545 label: Option<String>,
1546) -> Result<(String, String), String> {
1547 if !caller_has_permission(community, super::roles::Permissions::CREATE_INVITE) {
1548 return Err("you need the create-invite permission to mint a public invite".to_string());
1549 }
1550 let session = SessionGuard::capture();
1551
1552 let existing = crate::db::community::list_public_invites(&community.id.to_hex()).unwrap_or_default();
1556 let label_taken = |cand: &str| {
1557 existing.iter().any(|r| r.label.as_deref().map(|e| e.eq_ignore_ascii_case(cand)).unwrap_or(false))
1558 };
1559 let label = match label {
1560 Some(l) if !l.trim().is_empty() => {
1561 let l = l.trim().to_string();
1562 if label_taken(&l) {
1563 return Err(format!("You already have an invite link labeled \u{201c}{l}\u{201d}. Pick a different label."));
1564 }
1565 Some(l)
1566 }
1567 _ => {
1569 let mut l = generate_invite_label();
1570 while label_taken(&l) {
1571 l = generate_invite_label();
1572 }
1573 Some(l)
1574 }
1575 };
1576
1577 let creator_npub = crate::state::my_public_key().and_then(|pk| pk.to_bech32().ok());
1580 let token = public_invite::new_token();
1581 let event = build_public_invite_event(community, &token, expires_at, creator_npub, label.clone()).map_err(|e| e.to_string())?;
1582 transport.publish_durable(&event, &community.relays).await?;
1583
1584 if !session.is_valid() {
1587 return Err("account changed during public invite creation".to_string());
1588 }
1589 let token_hex = crate::simd::hex::bytes_to_hex_32(&token);
1590 let url = public_invite::encode_invite_url(&community.relays, &token);
1591 crate::db::community::save_public_invite(
1592 &token_hex,
1593 &community.id.to_hex(),
1594 &url,
1595 expires_at.map(|e| e as i64),
1596 label.as_deref(),
1597 )?;
1598 super::invite_list::add_invite(super::invite_list::InviteEntry {
1601 token: token_hex.clone(),
1602 community_id: community.id.to_hex(),
1603 url: url.clone(),
1604 label: label.clone(),
1605 created_at: std::time::SystemTime::now()
1606 .duration_since(std::time::UNIX_EPOCH)
1607 .map(|d| d.as_secs())
1608 .unwrap_or(0),
1609 expires_at,
1610 });
1611 republish_my_invite_links(transport, community).await?;
1614 Ok((token_hex, url))
1615}
1616
1617pub async fn latest_invite_preview<T: Transport + ?Sized>(
1623 transport: &T,
1624 bundle: &public_invite::PublicInviteBundle,
1625) -> public_invite::PublicInvitePreview {
1626 let snapshot = bundle.preview.clone();
1627 let Ok(community) = super::invite::accept_invite(&bundle.join) else {
1628 return snapshot;
1629 };
1630 let Ok(folded) = fetch_control_folded(transport, &community).await else {
1631 return snapshot;
1632 };
1633 let owner = proven_owner_hex(&community);
1634 let authorized = super::roster::authorize_delegation(&folded, owner.as_deref());
1635 match folded.root_candidates.iter().find(|c| {
1636 authorized.is_authorized(&c.author.to_hex(), owner.as_deref(), super::roles::Permissions::MANAGE_METADATA)
1637 }) {
1638 Some(c) => public_invite::PublicInvitePreview {
1639 name: c.meta.name.clone(),
1640 description: c.meta.description.clone(),
1641 icon: c.meta.icon.clone(),
1642 },
1643 None => snapshot,
1644 }
1645}
1646
1647pub async fn fetch_public_invite<T: Transport + ?Sized>(
1651 transport: &T,
1652 relays: &[String],
1653 token: &[u8; 32],
1654) -> Result<PublicInviteBundle, String> {
1655 let query = Query {
1659 kinds: vec![event_kind::APPLICATION_SPECIFIC],
1660 d_tags: vec![locator_hex(token)],
1661 ..Default::default()
1662 };
1663 let events = transport.fetch(&query, relays).await?;
1664 let (mut bundle_at, mut bundle, mut revoked_at) = (0u64, None, None::<u64>);
1670 for ev in &events {
1671 match parse_public_invite_event(ev, token) {
1672 Ok(b) => if bundle.is_none() || ev.created_at.as_secs() > bundle_at {
1673 bundle_at = ev.created_at.as_secs();
1674 bundle = Some(b);
1675 },
1676 Err(super::public_invite::PublicInviteError::Revoked) => {
1677 let at = ev.created_at.as_secs();
1678 if revoked_at.map_or(true, |r| at > r) { revoked_at = Some(at); }
1679 }
1680 Err(_) => {} }
1682 }
1683 match (bundle, revoked_at) {
1684 (Some(b), Some(r)) if bundle_at > r => Ok(b), (_, Some(_)) => Err("this invite was revoked".to_string()),
1686 (Some(b), None) => Ok(b),
1687 (None, None) => Err("no public invite found at that link (revoked, never posted, or shadowed)".to_string()),
1688 }
1689}
1690
1691pub fn accept_public_invite(bundle: &PublicInviteBundle, now_secs: u64) -> Result<Community, String> {
1695 if bundle.is_expired(now_secs) {
1696 return Err("this invite link has expired".to_string());
1697 }
1698 let mut community = accept_invite(&bundle.join)?;
1699 if bundle.preview.description.is_some() || bundle.preview.icon.is_some() {
1702 community.description = bundle.preview.description.clone();
1703 community.icon = bundle.preview.icon.clone();
1704 crate::db::community::save_community(&community)?;
1705 }
1706 Ok(community)
1707}
1708
1709pub async fn revoke_public_invite<T: Transport + ?Sized>(
1716 transport: &T,
1717 community: &Community,
1718 token: &[u8; 32],
1719) -> Result<(), String> {
1720 let session = SessionGuard::capture();
1721 let cid = community.id.to_hex();
1722 let token_hex = crate::simd::hex::bytes_to_hex_32(token);
1723 let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
1724 if !crate::db::community::list_public_invites(&cid)?.iter().any(|r| r.token == token_hex) {
1727 return Ok(());
1728 }
1729 let my_locators_before: Vec<String> = crate::db::community::list_public_invites(&cid)?
1730 .iter()
1731 .filter(|r| r.expires_at.map_or(true, |e| (e as u64) > now))
1732 .map(|r| public_invite::locator_hex(&crate::simd::hex::hex_to_bytes_32(&r.token)))
1733 .collect();
1734 let _ = fetch_and_apply_invite_links(transport, community).await;
1738 if !session.is_valid() {
1739 return Err("account changed during invite revoke".to_string());
1740 }
1741 let this_locator = public_invite::locator_hex(token);
1747 let cached_aggregate: std::collections::BTreeSet<String> =
1748 crate::db::community::get_community_invite_registry(&cid)?.into_iter().collect();
1749 let my_before: std::collections::BTreeSet<String> = my_locators_before.iter().cloned().collect();
1750 let others: std::collections::BTreeSet<String> = cached_aggregate.difference(&my_before).cloned().collect();
1751 let my_after: std::collections::BTreeSet<String> =
1752 my_before.iter().filter(|l| **l != this_locator).cloned().collect();
1753 let would_empty_aggregate = others.is_empty() && my_after.is_empty();
1754 if would_empty_aggregate && crate::state::MY_SECRET_KEY.to_keys().is_none() {
1755 return Err("Revoking this last invite link makes the community private, which re-keys it so link-joined lurkers lose access. Your account signs remotely (a NIP-46 bunker) and can't perform that rotation. Ask a community admin who holds a local key to privatize the community.".to_string());
1756 }
1757 if let Ok(tombstone) = public_invite::build_public_invite_tombstone(token) {
1767 let _ = transport.publish_durable(&tombstone, &community.relays).await;
1768 }
1769 if !session.is_valid() {
1771 return Err("account changed during invite revoke".to_string());
1772 }
1773 crate::db::community::delete_public_invite(&token_hex)?;
1774 super::invite_list::revoke_invite(&token_hex, &cid);
1777 republish_my_invite_links(transport, community).await?;
1779 if session.is_valid() {
1780 let aggregate_after: Vec<String> = others.union(&my_after).cloned().collect();
1781 crate::db::community::set_community_invite_registry(&cid, &aggregate_after)?;
1782 }
1783 if would_empty_aggregate {
1784 run_read_cut(transport, community, true).await?;
1788 }
1789 Ok(())
1790}
1791
1792pub(crate) async fn dissolved_tombstone_records<T: Transport + ?Sized>(
1808 transport: &T,
1809 community: &Community,
1810) -> Vec<super::roster::DissolvedEdition> {
1811 let z = super::derive::dissolved_pseudonym(&community.id);
1812 let q = Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], ..Default::default() };
1813 transport
1814 .fetch(&q, &community.relays)
1815 .await
1816 .unwrap_or_default()
1817 .iter()
1818 .filter_map(|ev| super::roster::dissolved_tombstone_open(ev, &community.id))
1819 .collect()
1820}
1821
1822pub async fn publish_migration_carrier<T: Transport + ?Sized>(
1830 transport: &T,
1831 community: &Community,
1832 payload_content: &str,
1833) -> Result<(), String> {
1834 let session = SessionGuard::capture();
1835 if !is_proven_owner(community) {
1836 return Err("only the community owner can migrate the community".to_string());
1837 }
1838 let signer = crate::signer::active_signer()?;
1839 let actor_pk = crate::state::my_public_key().ok_or("no local identity to sign the migration")?;
1840 let created_at = std::time::SystemTime::now()
1841 .duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
1842 let unsigned = super::roster::build_group_dissolved_edition_unsigned_with_content(actor_pk, &community.id, created_at, payload_content);
1843 let inner = unsigned.finalize_async(&signer).await.map_err(|e| format!("sign migration carrier: {e}"))?;
1844 let stable = super::roster::seal_dissolved_edition(&Keys::generate(), &inner, &community.id)?;
1847 super::migration::check_outer_size(&stable)?;
1848 transport.publish_durable(&stable, &community.relays).await?;
1849 if let Ok(fast) = super::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch) {
1850 let _ = transport.publish_durable(&fast, &community.relays).await;
1851 }
1852 if !session.is_valid() {
1853 return Err("account changed during migration publish".to_string());
1854 }
1855 Ok(())
1856}
1857
1858pub async fn dissolve_community<T: Transport + ?Sized>(
1859 transport: &T,
1860 community: &Community,
1861) -> Result<(), String> {
1862 let session = SessionGuard::capture();
1863 let cid = community.id.to_hex();
1864
1865 if !is_proven_owner(community) {
1867 return Err("only the community owner can dissolve (delete) the community".to_string());
1868 }
1869 let signer = crate::signer::active_signer()?;
1870 let actor_pk = crate::state::my_public_key().ok_or("no local identity to sign the dissolution")?;
1871
1872 let created_at = std::time::SystemTime::now()
1876 .duration_since(std::time::UNIX_EPOCH)
1877 .map(|d| d.as_secs())
1878 .unwrap_or(0);
1879 let unsigned = super::roster::build_group_dissolved_edition_unsigned(actor_pk, &community.id, created_at);
1880 let inner = unsigned.finalize_async(&signer).await.map_err(|e| format!("sign dissolution tombstone: {e}"))?;
1881 let stable = super::roster::seal_dissolved_edition(&Keys::generate(), &inner, &community.id)?;
1885 transport.publish_durable(&stable, &community.relays).await?;
1886 if let Ok(outer) = super::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch) {
1889 let _ = transport.publish_durable(&outer, &community.relays).await;
1890 }
1891 if !session.is_valid() {
1892 return Err("account changed during dissolution".to_string());
1893 }
1894
1895 if caller_has_permission(community, super::roles::Permissions::CREATE_INVITE) {
1901 let _ = publish_my_invite_links(transport, community, &[]).await;
1902 if let Ok(records) = crate::db::community::list_public_invites(&cid) {
1903 for r in records {
1904 let token = crate::simd::hex::hex_to_bytes_32(&r.token);
1905 if let Ok(tombstone) = public_invite::build_public_invite_tombstone(&token) {
1906 let _ = transport.publish_durable(&tombstone, &community.relays).await;
1907 }
1908 let _ = crate::db::community::delete_public_invite(&r.token);
1909 }
1910 }
1911 }
1912
1913 if !session.is_valid() {
1915 return Err("account changed during dissolution".to_string());
1916 }
1917 crate::db::community::set_community_dissolved(&cid)?;
1918 Ok(())
1919}
1920
1921pub async fn publish_my_invite_links<T: Transport + ?Sized>(
1928 transport: &T,
1929 community: &Community,
1930 my_locators: &[String],
1931) -> Result<(), String> {
1932 let session = SessionGuard::capture();
1933 if !caller_has_permission(community, super::roles::Permissions::CREATE_INVITE) {
1934 return Err("you need the create-invite permission to publish invite links".to_string());
1935 }
1936 let cid = community.id.to_hex();
1937 let signer = crate::signer::active_signer()?;
1938 let actor_pk = crate::state::my_public_key().ok_or("no local identity to sign the invite links")?;
1939 let entity_id = super::derive::invite_links_locator(&community.id, &actor_pk.to_bytes());
1940 let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
1941 let (version, prev_hash) = match crate::db::community::get_edition_head(&cid, &entity_hex)? {
1942 Some((v, h)) => (v + 1, Some(h)),
1943 None => (1, None),
1944 };
1945 let created_at = std::time::SystemTime::now()
1946 .duration_since(std::time::UNIX_EPOCH)
1947 .map(|d| d.as_secs())
1948 .unwrap_or(0);
1949 let citation = authority_citation(community, &actor_pk.to_hex());
1951 let unsigned = super::roster::build_invite_links_edition_unsigned(actor_pk, &community.id, my_locators, version, prev_hash.as_ref(), created_at, citation.as_ref())?;
1952 let inner = unsigned.finalize_async(&signer).await.map_err(|e| format!("sign invite-links edition: {e}"))?;
1953 let outer = super::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch)?;
1954 let self_hash = super::version::edition_hash(&entity_id, version, prev_hash.as_ref(), inner.content.as_bytes());
1955 transport.publish_durable(&outer, &community.relays).await?;
1956 if session.is_valid() {
1957 crate::db::community::set_edition_head(&cid, &entity_hex, version, &self_hash)?;
1958 let mut agg: std::collections::BTreeSet<String> =
1961 crate::db::community::get_community_invite_registry(&cid)?.into_iter().collect();
1962 agg.extend(my_locators.iter().cloned());
1963 crate::db::community::set_community_invite_registry(&cid, &agg.into_iter().collect::<Vec<_>>())?;
1964 crate::db::community::upsert_invite_link_set(&cid, &actor_pk.to_hex(), my_locators)?;
1965 }
1966 Ok(())
1967}
1968
1969pub async fn fetch_and_apply_invite_links<T: Transport + ?Sized>(
1975 transport: &T,
1976 community: &Community,
1977) -> Result<Vec<String>, String> {
1978 fetch_and_apply_invite_links_inner(transport, community, None).await
1979}
1980
1981async fn fetch_and_apply_invite_links_inner<T: Transport + ?Sized>(
1982 transport: &T,
1983 community: &Community,
1984 prefolded: Option<super::roster::FoldedRoster>,
1985) -> Result<Vec<String>, String> {
1986 let session = SessionGuard::capture();
1987 let cid = community.id.to_hex();
1988 let folded = match prefolded {
1989 Some(f) => f,
1990 None => fetch_control_folded(transport, community).await?,
1991 };
1992 if !session.is_valid() {
1993 return Err("account changed during invite-links fetch".to_string());
1994 }
1995 let owner = proven_owner_hex(community);
1996 let authorized = super::roster::authorize_delegation(&folded, owner.as_deref());
1997 let mut aggregate: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
1998 let mut per_creator: Vec<crate::db::community::InviteLinkSetRow> = Vec::new();
2000 for set in &folded.invite_link_sets {
2001 if !authorized.is_authorized(&set.creator.to_hex(), owner.as_deref(), super::roles::Permissions::CREATE_INVITE) {
2004 continue;
2005 }
2006 let held = crate::db::community::get_edition_head(&cid, &set.head.entity_hex)?.map(|(v, _)| v).unwrap_or(0);
2007 if set.head.version > held {
2008 crate::db::community::set_edition_head(&cid, &set.head.entity_hex, set.head.version, &set.head.self_hash)?;
2009 }
2010 aggregate.extend(set.locators.iter().cloned());
2011 per_creator.push(crate::db::community::InviteLinkSetRow {
2012 creator_hex: set.creator.to_hex(),
2013 locators: set.locators.clone(),
2014 });
2015 }
2016 {
2032 let present_creators: std::collections::HashSet<String> =
2033 folded.invite_link_sets.iter().map(|s| s.creator.to_hex()).collect();
2034 for row in crate::db::community::get_invite_link_sets(&cid)? {
2035 if present_creators.contains(&row.creator_hex) {
2036 continue;
2037 }
2038 aggregate.extend(row.locators.iter().cloned());
2039 per_creator.push(row);
2040 }
2041 }
2042 let aggregate: Vec<String> = aggregate.into_iter().collect();
2043 if !session.is_valid() {
2044 return Err("account changed during invite-links fold".to_string());
2045 }
2046 crate::db::community::set_community_invite_registry(&cid, &aggregate)?;
2047 crate::db::community::replace_invite_link_sets(&cid, &per_creator)?;
2048 Ok(aggregate)
2049}
2050
2051pub async fn fetch_and_apply_metadata<T: Transport + ?Sized>(
2059 transport: &T,
2060 community: &Community,
2061) -> Result<(), String> {
2062 fetch_and_apply_metadata_inner(transport, community, None).await
2063}
2064
2065async fn fetch_and_apply_metadata_inner<T: Transport + ?Sized>(
2066 transport: &T,
2067 community: &Community,
2068 prefolded: Option<super::roster::FoldedRoster>,
2069) -> Result<(), String> {
2070 let session = SessionGuard::capture();
2071 let cid = community.id.to_hex();
2072 let folded = match prefolded {
2073 Some(f) => f,
2074 None => fetch_control_folded(transport, community).await?,
2075 };
2076 if !session.is_valid() {
2077 return Err("account changed during metadata fetch".to_string());
2078 }
2079 let owner = proven_owner_hex(community);
2080 let authorized = super::roster::authorize_delegation(&folded, owner.as_deref());
2081 let manage = super::roles::Permissions::MANAGE_METADATA;
2084 let manage_channels = super::roles::Permissions::MANAGE_CHANNELS;
2085
2086 let mut current = match crate::db::community::load_community(&community.id)? {
2089 Some(c) => c,
2090 None => return Ok(()),
2091 };
2092 let mut dirty = false;
2093 let mut head_updates: Vec<(String, u64, [u8; 32], [u8; 32], bool)> = Vec::new();
2097
2098 let decide = |entity_hex: &str, head: &super::roster::EntityHead| -> Result<Option<bool>, String> {
2105 let held = crate::db::community::get_edition_head(&cid, entity_hex)?;
2106 let held_v = held.map(|(v, _)| v).unwrap_or(0);
2107 if head.version > held_v {
2108 return Ok(Some(false)); }
2110 if head.version == held_v && held.map(|(_, h)| h) != Some(head.self_hash) {
2111 let held_id = crate::db::community::get_edition_head_inner_id(&cid, entity_hex)?;
2112 if held_id.is_none() || Some(head.inner_id) < held_id {
2113 return Ok(Some(true)); }
2115 }
2116 Ok(None)
2117 };
2118
2119 if let Some(c) = folded.root_candidates.iter()
2124 .find(|c| authorized.is_authorized(&c.author.to_hex(), owner.as_deref(), manage))
2125 {
2126 let head = &c.head;
2127 if let Some(is_converge) = decide(&head.entity_hex, head)? {
2128 let meta = &c.meta;
2129 current.name = meta.name.clone();
2138 current.description = meta.description.clone();
2139 current.icon = meta.icon.clone();
2140 current.banner = meta.banner.clone();
2141 dirty = true;
2142 head_updates.push((head.entity_hex.clone(), head.version, head.self_hash, head.inner_id, is_converge));
2143 }
2144 }
2145 let mut resolved_channels: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
2151 for cm in &folded.channel_candidates {
2152 if resolved_channels.contains(&cm.channel_id) {
2153 continue; }
2155 if !authorized.is_authorized(&cm.author.to_hex(), owner.as_deref(), manage_channels) {
2156 continue; }
2158 resolved_channels.insert(cm.channel_id);
2159 let Some(is_converge) = decide(&cm.head.entity_hex, &cm.head)? else { continue };
2160 if let Some(ch) = current.channels.iter_mut().find(|c| c.id.0 == cm.channel_id) {
2161 ch.name = cm.meta.name.clone();
2162 dirty = true;
2163 head_updates.push((cm.head.entity_hex.clone(), cm.head.version, cm.head.self_hash, cm.head.inner_id, is_converge));
2164 }
2165 }
2166
2167 if dirty && session.is_valid() {
2168 crate::db::community::save_community(¤t)?;
2169 for (entity_hex, version, self_hash, inner_id, is_converge) in &head_updates {
2172 if *is_converge {
2173 crate::db::community::converge_edition_head(&cid, entity_hex, *version, self_hash, inner_id)?;
2174 } else {
2175 crate::db::community::set_edition_head_with_id(&cid, entity_hex, *version, self_hash, inner_id)?;
2176 }
2177 }
2178 }
2179 Ok(())
2180}
2181
2182pub fn is_public(community: &Community) -> Result<bool, String> {
2190 Ok(!crate::db::community::get_community_invite_registry(&community.id.to_hex())?.is_empty())
2191}
2192
2193async fn republish_my_invite_links<T: Transport + ?Sized>(
2198 transport: &T,
2199 community: &Community,
2200) -> Result<Vec<String>, String> {
2201 let cid = community.id.to_hex();
2202 let now = std::time::SystemTime::now()
2203 .duration_since(std::time::UNIX_EPOCH)
2204 .map(|d| d.as_secs())
2205 .unwrap_or(0);
2206 let locators: Vec<String> = crate::db::community::list_public_invites(&cid)?
2207 .iter()
2208 .filter(|r| r.expires_at.map_or(true, |e| (e as u64) > now))
2209 .map(|r| public_invite::locator_hex(&crate::simd::hex::hex_to_bytes_32(&r.token)))
2210 .collect();
2211 publish_my_invite_links(transport, community, &locators).await?;
2212 Ok(locators)
2213}
2214
2215async fn observe_channel_activity<T: Transport + ?Sized>(
2221 transport: &T,
2222 community: &Community,
2223) -> Result<(), String> {
2224 let session = SessionGuard::capture();
2225 let my_pk = crate::state::my_public_key().ok_or("no local identity to observe channel activity")?;
2226 for channel in &community.channels {
2227 let events = super::send::fetch_channel_events(transport, community, channel)
2228 .await
2229 .unwrap_or_default();
2230 if !session.is_valid() {
2231 return Err("account changed during activity observation".to_string());
2232 }
2233 let outcomes = {
2234 let mut st = crate::state::STATE.lock().await;
2235 super::inbound::process_channel_batch(&mut st, &events, channel, &my_pk)
2236 };
2237 let ch_hex = channel.id.to_hex();
2238 let mut pending: Vec<&crate::types::Message> = Vec::new();
2241 for o in &outcomes {
2242 match o {
2243 super::inbound::IncomingEvent::NewMessage(m)
2244 | super::inbound::IncomingEvent::Updated { message: m, .. } => {
2245 pending.push(m);
2246 }
2247 super::inbound::IncomingEvent::Presence { npub, joined, event_id, created_at, invited_by, invited_label } => {
2248 let et = if *joined {
2249 crate::stored_event::SystemEventType::MemberJoined
2250 } else {
2251 crate::stored_event::SystemEventType::MemberLeft
2252 };
2253 let note = invited_by.as_ref().map(|by| match invited_label {
2254 Some(l) if !l.is_empty() => format!("{by}|{l}"),
2255 _ => by.clone(),
2256 });
2257 let _ = crate::db::events::save_system_event_at(event_id, &ch_hex, et, npub, note.as_deref(), *created_at, invited_by.as_deref(), invited_label.as_deref()).await;
2258 }
2259 super::inbound::IncomingEvent::WebxdcPeer { npub, topic_id, node_addr, event_id, created_at } => {
2260 persist_webxdc_signal(&ch_hex, npub, topic_id, node_addr.as_deref(), event_id, *created_at).await;
2261 }
2262 _ => {}
2263 }
2264 }
2265 crate::db::events::flush_message_batch(&ch_hex, &mut pending, &session).await;
2266 }
2267 Ok(())
2268}
2269
2270pub async fn sync_before_admin_write<T: Transport + ?Sized>(
2281 transport: &T,
2282 community: &Community,
2283 observe_activity: bool,
2284) -> Result<Community, String> {
2285 if catch_up_server_root(transport, community).await?.removed {
2287 return Err("you have been removed from this community".to_string());
2288 }
2289 let community = crate::db::community::load_community(&community.id)?
2290 .ok_or("community gone during admin sync")?;
2291 let cid = community.id.to_hex();
2292 let responded = fetch_and_apply_control_full(transport, &community).await.map(|n| n > 0).unwrap_or(false);
2301 let hold_local_heads = !crate::db::community::get_all_edition_heads_epoched(&cid)?.is_empty();
2302 if hold_local_heads && !responded {
2303 return Err("can't reach any relay to confirm this community's current state — administrative actions are blocked while offline (try again when connected)".to_string());
2304 }
2305 let community = crate::db::community::load_community(&community.id)?
2306 .ok_or("community gone during admin sync")?;
2307 if observe_activity {
2310 let _ = observe_channel_activity(transport, &community).await;
2311 }
2312 crate::db::community::load_community(&community.id)?.ok_or("community gone during admin sync".to_string())
2313}
2314
2315async fn run_read_cut<T: Transport + ?Sized>(
2325 transport: &T,
2326 community: &Community,
2327 fresh: bool,
2328) -> Result<(), String> {
2329 let cid = community.id.to_hex();
2330 let session = SessionGuard::capture();
2331 if fresh {
2332 let base = crate::db::community::load_community(&community.id)?
2335 .map(|c| c.server_root_epoch.0)
2336 .unwrap_or(community.server_root_epoch.0);
2337 crate::db::community::set_read_cut_target_epoch(&cid, base.saturating_add(1))?;
2338 }
2339 crate::db::community::set_read_cut_pending(&cid, true)?;
2340 reseal_base_to_observed(transport, community).await?;
2341 if session.is_valid() {
2342 crate::db::community::set_read_cut_pending(&cid, false)?;
2343 }
2344 Ok(())
2345}
2346
2347async fn reseal_base_to_observed<T: Transport + ?Sized>(
2357 transport: &T,
2358 community: &Community,
2359) -> Result<(), String> {
2360 let session = SessionGuard::capture();
2361 let cid = community.id.to_hex();
2362 let community = &sync_before_admin_write(transport, community, true).await?;
2367 let participants: Vec<nostr_sdk::prelude::PublicKey> = crate::db::community::community_member_activity(&cid)?
2371 .into_iter()
2372 .filter_map(|(npub, _)| nostr_sdk::prelude::PublicKey::parse(&npub).ok())
2373 .collect();
2374 let target = crate::db::community::get_read_cut_target_epoch(&cid)?;
2384 if community.server_root_epoch.0 < target {
2385 rotate_server_root(transport, community, &participants).await?;
2386 if !session.is_valid() {
2387 return Err("account changed during re-founding".to_string());
2388 }
2389 }
2390 let community = crate::db::community::load_community(&community.id)?
2396 .ok_or("community gone after base rotation")?;
2397 let cut_epoch = community.server_root_epoch.0;
2398 let prior_root = crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, cut_epoch.saturating_sub(1))?
2404 .unwrap_or(*community.server_root_key.as_bytes()); for channel in &community.channels {
2406 let ch_hex = channel.id.to_hex();
2407 if crate::db::community::channel_rekeyed_at_server_epoch(&cid, &ch_hex)? >= cut_epoch {
2410 continue;
2411 }
2412 rotate_channel(transport, &community, &channel.id, &participants, &prior_root).await?;
2413 if !session.is_valid() {
2414 return Err("account changed during re-founding".to_string());
2415 }
2416 crate::db::community::mark_channel_rekeyed_at_server_epoch(&cid, &ch_hex, cut_epoch)?;
2417 }
2418 Ok(())
2419}
2420
2421#[derive(Debug, PartialEq, Eq)]
2423pub enum RekeyOutcome {
2424 Applied { head_advanced: bool },
2427 NotARecipient,
2430}
2431
2432pub fn apply_channel_rekey(
2443 community: &Community,
2444 parsed: &super::rekey::ParsedRekey,
2445) -> Result<RekeyOutcome, String> {
2446 let session = SessionGuard::capture();
2450
2451 let channel_id = match parsed.scope {
2453 super::derive::RekeyScope::Channel(c) => c,
2454 super::derive::RekeyScope::ServerRoot => {
2455 return Err("server-root rotation uses apply_server_root_rekey, not the channel path".to_string())
2456 }
2457 };
2458 if !community.channels.iter().any(|c| c.id == channel_id) {
2459 return Err("rekey targets a channel not in this community".to_string());
2460 }
2461 let cid = community.id.to_hex();
2462 let channel_hex = channel_id.to_hex();
2463
2464 let owner = proven_owner_hex(community);
2471 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_else(|e| {
2472 crate::log_warn!("rekey apply: roster read failed ({e}); authorizing owner only");
2475 Default::default()
2476 });
2477 if !roster.is_authorized(
2478 &parsed.rotator.to_hex(),
2479 owner.as_deref(),
2480 super::roles::Permissions::MANAGE_CHANNELS,
2481 ) {
2482 return Err("rekey rotator lacks MANAGE_CHANNELS authority".to_string());
2483 }
2484
2485 if let Some(prev_key) = crate::db::community::held_epoch_key(&cid, &channel_hex, parsed.prev_epoch.0)? {
2495 if super::rekey::epoch_key_commitment(parsed.prev_epoch, &prev_key) != parsed.prev_key_commitment {
2496 crate::log_warn!(
2497 "channel rekey to epoch {} cites a prior-epoch key I don't hold (I'm on a losing fork of epoch {}) — converging forward onto the authorized chain",
2498 parsed.new_epoch.0, parsed.prev_epoch.0
2499 );
2500 }
2501 }
2502
2503 let my_keys = crate::state::MY_SECRET_KEY
2505 .to_keys()
2506 .ok_or("no local identity to open the rekey blob")?;
2507 let secret = super::rekey::rekey_pairwise_secret(my_keys.secret_key(), &parsed.rotator)?;
2508 let my_locator = super::derive::recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
2509 let mine = match parsed.blobs.iter().find(|b| b.locator == my_locator) {
2510 Some(b) => b,
2511 None => return Ok(RekeyOutcome::NotARecipient),
2512 };
2513 let new_key =
2514 super::rekey::open_rekey_blob(my_keys.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine)?;
2515
2516 if !session.is_valid() {
2518 return Err("session changed during rekey apply".to_string());
2519 }
2520 let head_advanced =
2521 crate::db::community::advance_channel_epoch(&cid, &channel_hex, parsed.new_epoch.0, &new_key)?;
2522 Ok(RekeyOutcome::Applied { head_advanced })
2523}
2524
2525fn mint_or_reuse_rotation_key(cid: &str, scope_id: &str, epoch: u64) -> Result<zeroize::Zeroizing<[u8; 32]>, String> {
2531 if let Some(k) = crate::db::community::held_epoch_key(cid, scope_id, epoch)? {
2532 return Ok(zeroize::Zeroizing::new(k));
2533 }
2534 let k = zeroize::Zeroizing::new(super::random_32());
2535 crate::db::community::store_epoch_key(cid, scope_id, epoch, &k)?;
2536 Ok(k)
2537}
2538
2539async fn publish_rekey_chunked<T, F>(
2547 transport: &T,
2548 relays: &[String],
2549 blobs: &[super::rekey::RekeyBlob],
2550 build: F,
2551) -> Result<(), String>
2552where
2553 T: Transport + ?Sized,
2554 F: Fn(&[super::rekey::RekeyBlob]) -> Result<Event, String>,
2555{
2556 if blobs.is_empty() {
2557 return Err("rekey has no recipients".to_string());
2558 }
2559 for chunk in blobs.chunks(super::rekey::MAX_REKEY_BLOBS) {
2560 let event = build(chunk)?;
2561 transport.publish_durable(&event, relays).await?;
2562 }
2563 Ok(())
2564}
2565
2566pub async fn rotate_channel<T: Transport + ?Sized>(
2578 transport: &T,
2579 community: &Community,
2580 channel_id: &super::ChannelId,
2581 recipients: &[nostr_sdk::prelude::PublicKey],
2582 envelope_root: &[u8; 32],
2588) -> Result<u64, String> {
2589 let session = SessionGuard::capture();
2590 let cid = community.id.to_hex();
2591
2592 let my_keys = crate::state::MY_SECRET_KEY.to_keys().ok_or("a key rotation requires a local key (bunker/NIP-46 accounts can't rekey)")?;
2596 let owner = proven_owner_hex(community);
2597 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
2598 if !roster.is_authorized(&my_keys.public_key().to_hex(), owner.as_deref(), super::roles::Permissions::MANAGE_CHANNELS) {
2599 return Err("not authorized to rotate this channel (no MANAGE_CHANNELS)".to_string());
2600 }
2601
2602 let channel = community
2606 .channels
2607 .iter()
2608 .find(|c| &c.id == channel_id)
2609 .ok_or("channel not found in community")?;
2610 let prev_epoch = channel.epoch;
2611 let new_epoch = super::Epoch(prev_epoch.0.checked_add(1).ok_or("channel epoch overflow")?);
2612 let prev_commit = super::rekey::epoch_key_commitment(prev_epoch, channel.key.as_bytes());
2613 let new_key = mint_or_reuse_rotation_key(&cid, &channel_id.to_hex(), new_epoch.0)?;
2616
2617 let mut seen = std::collections::HashSet::new();
2621 let mut blobs = Vec::new();
2622 for pk in recipients.iter().chain(std::iter::once(&my_keys.public_key())) {
2623 if !seen.insert(pk.to_hex()) {
2624 continue;
2625 }
2626 blobs.push(super::rekey::build_rekey_blob(
2627 my_keys.secret_key(), pk, super::derive::RekeyScope::Channel(*channel_id), new_epoch, &new_key,
2628 )?);
2629 }
2630
2631 publish_rekey_chunked(transport, &community.relays, &blobs, |chunk| {
2633 super::rekey::build_channel_rekey_event(
2634 &Keys::generate(), &my_keys, envelope_root, channel_id,
2635 new_epoch, prev_epoch, &prev_commit, chunk,
2636 )
2637 })
2638 .await?;
2639 if !session.is_valid() {
2640 return Err("session changed during channel rotation".to_string());
2641 }
2642 crate::db::community::advance_channel_epoch(&cid, &channel_id.to_hex(), new_epoch.0, &new_key)?;
2643 Ok(new_epoch.0)
2644}
2645
2646fn emit_rekey_progress(label: &str, pct: u8) {
2650 crate::emit_event("community_rekey_progress", &serde_json::json!({ "label": label, "pct": pct }));
2651}
2652
2653pub(crate) async fn rotate_server_root<T: Transport + ?Sized>(
2669 transport: &T,
2670 community: &Community,
2671 recipients: &[nostr_sdk::prelude::PublicKey],
2672) -> Result<u64, String> {
2673 let session = SessionGuard::capture();
2674 let cid = community.id.to_hex();
2675
2676 if crate::db::community::get_community_dissolved(&cid)? {
2678 return Err("community is dissolved; it cannot be re-founded".to_string());
2679 }
2680
2681 let my_keys = crate::state::MY_SECRET_KEY.to_keys().ok_or("a base rotation (privatize / private-ban read-cut) requires a local key (bunker/NIP-46 accounts can't rekey)")?;
2692 let owner = proven_owner_hex(community);
2693 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
2694 if !roster.is_authorized(&my_keys.public_key().to_hex(), owner.as_deref(), super::roles::Permissions::BAN) {
2695 return Err("not authorized to rotate the server root (no BAN)".to_string());
2696 }
2697
2698 let fresh = crate::db::community::load_community(&community.id)?
2704 .ok_or("community gone before base rotation")?;
2705 let community = &fresh;
2706 let prev_epoch = community.server_root_epoch;
2707 let new_epoch = super::Epoch(prev_epoch.0.checked_add(1).ok_or("server-root epoch overflow")?);
2708 let prev_commit = super::rekey::epoch_key_commitment(prev_epoch, community.server_root_key.as_bytes());
2710 let new_root = mint_or_reuse_rotation_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch.0)?;
2712 emit_rekey_progress("Rerolling community keys...", 5);
2713
2714 let sealed = prepare_reanchor_control_plane(transport, community, &new_root, new_epoch).await?;
2720 if !session.is_valid() {
2721 return Err("session changed during re-founding acquire".to_string());
2722 }
2723
2724 let total_recipients = (recipients.len() + 1).max(1); let mut seen = std::collections::HashSet::new();
2726 let mut blobs = Vec::new();
2727 for pk in recipients.iter().chain(std::iter::once(&my_keys.public_key())) {
2728 if !seen.insert(pk.to_hex()) {
2729 continue;
2730 }
2731 blobs.push(super::rekey::build_rekey_blob(
2732 my_keys.secret_key(), pk, super::derive::RekeyScope::ServerRoot, new_epoch, &new_root,
2733 )?);
2734 emit_rekey_progress(
2735 &format!("Preparing keys for members ({}/{})...", blobs.len(), total_recipients),
2736 (5 + 35 * blobs.len() / total_recipients) as u8,
2737 );
2738 }
2739
2740 emit_rekey_progress("Sending keys to members...", 42);
2744 publish_rekey_chunked(transport, &community.relays, &blobs, |chunk| {
2745 super::rekey::build_server_root_rekey_event(
2746 &Keys::generate(), &my_keys, community.server_root_key.as_bytes(), &community.id,
2747 new_epoch, prev_epoch, &prev_commit, chunk,
2748 )
2749 })
2750 .await?;
2751
2752 let snapshot = publish_reanchor_snapshot(transport, &community.relays, sealed).await?;
2760 if snapshot.iter().any(|e| !e.published) {
2761 return Err(
2762 "re-founding aborted: a snapshot edition did not land (rate-limited / unreachable relay?); base head NOT advanced".to_string()
2763 );
2764 }
2765 if !session.is_valid() {
2766 return Err("session changed during server-root rotation".to_string());
2767 }
2768 emit_rekey_progress("Finalizing...", 98);
2769 crate::db::community::advance_server_root_epoch(&cid, new_epoch.0, &new_root)?;
2771 for e in &snapshot {
2775 crate::db::community::set_edition_head_with_id(&cid, &e.entity_hex, e.version, &e.self_hash, &e.inner_id)?;
2776 }
2777 Ok(new_epoch.0)
2778}
2779
2780pub(crate) struct SnapshotEntry {
2815 pub entity_hex: String,
2816 pub version: u64,
2817 pub self_hash: [u8; 32],
2818 pub inner_id: [u8; 32],
2819 pub published: bool,
2820}
2821
2822pub(crate) async fn prepare_reanchor_control_plane<T: Transport + ?Sized>(
2828 transport: &T,
2829 community: &Community,
2830 new_root: &[u8; 32],
2831 new_epoch: super::Epoch,
2832) -> Result<Vec<(Event, SnapshotEntry)>, String> {
2833 let session = SessionGuard::capture();
2834 let cid = community.id.to_hex();
2835
2836 let z = super::roster::control_pseudonym(&community.server_root_key, &community.id, community.server_root_epoch);
2844 let query = Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], evidence: Evidence::Full, ..Default::default() };
2848 let outers = transport.fetch(&query, &community.relays).await?;
2849 if !session.is_valid() {
2850 return Err("session changed during re-founding fetch".to_string());
2851 }
2852 let mut by_hash: std::collections::HashMap<[u8; 32], (Event, [u8; 32])> = std::collections::HashMap::new();
2854 for outer in &outers {
2855 if let Ok(inner) = super::roster::open_control_edition(outer, &community.server_root_key) {
2856 if let Ok(parsed) = super::edition::parse_edition_inner(&inner) {
2857 by_hash.insert(parsed.self_hash, (inner, parsed.inner_id));
2858 }
2859 }
2860 }
2861
2862 let new_root_key = super::ServerRootKey(*new_root);
2866 let mut sealed: Vec<(Event, SnapshotEntry)> = Vec::new();
2867 for (entity_hex, (epoch, version, self_hash)) in crate::db::community::get_all_edition_heads_epoched(&cid)? {
2868 if epoch != community.server_root_epoch.0 {
2869 continue; }
2871 let (inner, inner_id) = by_hash.get(&self_hash).ok_or_else(|| {
2872 format!("re-founding aborted: head edition for entity {entity_hex} (v{version}) not fetchable — aborting so no member is stranded")
2873 })?;
2874 let outer = super::roster::seal_control_edition(&Keys::generate(), inner, &new_root_key, &community.id, new_epoch)?;
2875 sealed.push((outer, SnapshotEntry { entity_hex, version, self_hash, inner_id: *inner_id, published: false }));
2876 }
2877 Ok(sealed)
2878}
2879
2880pub(crate) async fn publish_reanchor_snapshot<T: Transport + ?Sized>(
2884 transport: &T,
2885 relays: &[String],
2886 sealed: Vec<(Event, SnapshotEntry)>,
2887) -> Result<Vec<SnapshotEntry>, String> {
2888 use futures_util::stream::StreamExt;
2891 let total = sealed.len().max(1);
2892 let done = std::sync::atomic::AtomicUsize::new(0);
2893 let done_ref = &done;
2894 emit_rekey_progress(&format!("Re-founding community (0/{total})..."), 50);
2895 let out: Vec<SnapshotEntry> = futures_util::stream::iter(sealed.into_iter().map(|(ev, mut entry)| async move {
2896 entry.published = transport.publish_durable(&ev, relays).await.is_ok();
2897 let n = done_ref.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
2898 emit_rekey_progress(&format!("Re-founding community ({n}/{total})..."), (50 + 45 * n / total) as u8);
2899 entry
2900 }))
2901 .buffer_unordered(4)
2902 .collect()
2903 .await;
2904 Ok(out)
2905}
2906
2907#[cfg(test)]
2910pub(crate) async fn reanchor_control_plane<T: Transport + ?Sized>(
2911 transport: &T,
2912 community: &Community,
2913 new_root: &[u8; 32],
2914 new_epoch: super::Epoch,
2915) -> Result<Vec<SnapshotEntry>, String> {
2916 let sealed = prepare_reanchor_control_plane(transport, community, new_root, new_epoch).await?;
2917 publish_reanchor_snapshot(transport, &community.relays, sealed).await
2918}
2919
2920pub fn apply_server_root_rekey(
2928 community: &Community,
2929 parsed: &super::rekey::ParsedRekey,
2930) -> Result<RekeyOutcome, String> {
2931 let session = SessionGuard::capture();
2932
2933 if !matches!(parsed.scope, super::derive::RekeyScope::ServerRoot) {
2935 return Err("not a server-root rekey (channel rekeys use apply_channel_rekey)".to_string());
2936 }
2937 let cid = community.id.to_hex();
2938
2939 if crate::db::community::get_community_dissolved(&cid)? && !super::migration::catchup_exempt(&cid, parsed.new_epoch.0) {
2950 return Err("community is dissolved; base epoch cannot advance".to_string());
2951 }
2952
2953 let owner = proven_owner_hex(community);
2960 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_else(|e| {
2961 crate::log_warn!("base rekey apply: roster read failed ({e}); authorizing owner only");
2962 Default::default()
2963 });
2964 if !rotator_is_authorized(&cid, &roster, owner.as_deref(), &parsed.rotator.to_hex(), super::roles::Permissions::BAN) {
2965 return Err("base rekey rotator lacks server-wide rotation authority (BAN)".to_string());
2966 }
2967
2968 if let Some(prev_root) =
2975 crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, parsed.prev_epoch.0)?
2976 {
2977 if super::rekey::epoch_key_commitment(parsed.prev_epoch, &prev_root) != parsed.prev_key_commitment {
2978 crate::log_warn!(
2979 "base rekey to epoch {} cites a prior-root I don't hold (I'm on a losing fork of epoch {}) — converging forward onto the authorized chain",
2980 parsed.new_epoch.0, parsed.prev_epoch.0
2981 );
2982 }
2983 }
2984
2985 let my_keys = crate::state::MY_SECRET_KEY.to_keys().ok_or("no local identity to open the base rekey blob")?;
2987 let secret = super::rekey::rekey_pairwise_secret(my_keys.secret_key(), &parsed.rotator)?;
2988 let my_locator = super::derive::recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
2989 let mine = match parsed.blobs.iter().find(|b| b.locator == my_locator) {
2990 Some(b) => b,
2991 None => return Ok(RekeyOutcome::NotARecipient),
2992 };
2993 let new_root =
2994 super::rekey::open_rekey_blob(my_keys.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine)?;
2995
2996 if !session.is_valid() {
2997 return Err("session changed during base rekey apply".to_string());
2998 }
2999 let head_advanced = crate::db::community::advance_server_root_epoch(&cid, parsed.new_epoch.0, &new_root)?;
3000 Ok(RekeyOutcome::Applied { head_advanced })
3001}
3002
3003const REKEY_CATCHUP_WINDOW: u64 = 64;
3007const MAX_REKEY_CATCHUP_ROUNDS: usize = 64;
3010
3011async fn heal_channel_fork_epochs<T: Transport + ?Sized>(
3023 transport: &T,
3024 community: &Community,
3025 channel_id: &super::ChannelId,
3026 cid: &str,
3027 channel_hex: &str,
3028 epochs: &std::collections::BTreeSet<u64>,
3029 server_roots: &[[u8; 32]],
3030 session: &SessionGuard,
3031) -> Result<(), String> {
3032 if epochs.is_empty() {
3033 return Ok(());
3034 }
3035 let owner_hex = proven_owner_hex(community);
3036 let roster = crate::db::community::get_community_roles(cid).unwrap_or_default();
3037 let mut winner: std::collections::BTreeMap<u64, [u8; 32]> = std::collections::BTreeMap::new();
3040 for sr in server_roots {
3041 let z_tags: Vec<String> = epochs
3042 .iter()
3043 .map(|e| super::derive::rekey_pseudonym(&super::ServerRootKey(*sr), channel_id, super::Epoch(*e)).to_hex())
3044 .collect();
3045 let q = Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags, ..Default::default() };
3046 for ev in transport.fetch(&q, &community.relays).await.unwrap_or_default() {
3047 let Ok(p) = super::rekey::open_rekey_event(&ev, sr) else { continue };
3048 if !matches!(p.scope, super::derive::RekeyScope::Channel(c) if &c == channel_id) || !epochs.contains(&p.new_epoch.0) {
3049 continue;
3050 }
3051 if !rotator_is_authorized(cid, &roster, owner_hex.as_deref(), &p.rotator.to_hex(), super::roles::Permissions::MANAGE_CHANNELS) {
3052 continue;
3053 }
3054 let Some(key) = peek_my_channel_key(&p) else { continue }; winner.entry(p.new_epoch.0).and_modify(|best| { if key < *best { *best = key; } }).or_insert(key);
3056 }
3057 }
3058 for (epoch, win_key) in winner {
3059 if !session.is_valid() {
3060 return Err("session changed during channel convergence".to_string());
3061 }
3062 if let Ok(Some(cur)) = crate::db::community::held_epoch_key(cid, channel_hex, epoch) {
3067 if win_key < cur {
3068 match crate::db::community::converge_channel_epoch(cid, channel_hex, epoch, &win_key) {
3071 Ok(false) => crate::log_trace!("channel heal: converge of epoch {epoch} did not apply (head moved)"),
3072 Err(e) => crate::log_trace!("channel heal: converge of epoch {epoch} errored: {e}"),
3073 Ok(true) => {}
3074 }
3075 }
3076 }
3077 }
3078 Ok(())
3079}
3080
3081pub async fn catch_up_channel_rekeys<T: Transport + ?Sized>(
3096 transport: &T,
3097 community: &Community,
3098 channel_id: &super::ChannelId,
3099) -> Result<u64, String> {
3100 let session = SessionGuard::capture();
3101 let server_root = community.server_root_key.as_bytes();
3102 let cid = community.id.to_hex();
3103 let channel_hex = channel_id.to_hex();
3104 let mut server_roots: Vec<[u8; 32]> = crate::db::community::held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX)
3110 .unwrap_or_default()
3111 .into_iter()
3112 .map(|(_, k)| k)
3113 .collect();
3114 if !server_roots.iter().any(|r| r == server_root) {
3115 server_roots.push(*server_root); }
3117 let mut head = community
3118 .channels
3119 .iter()
3120 .find(|c| &c.id == channel_id)
3121 .ok_or("channel not found in community")?
3122 .epoch
3123 .0;
3124
3125 let mut forked_epochs: std::collections::BTreeSet<u64> = std::collections::BTreeSet::new();
3129
3130 for _round in 0..MAX_REKEY_CATCHUP_ROUNDS {
3131 let window_top = head.saturating_add(REKEY_CATCHUP_WINDOW);
3132 let mut parsed: Vec<super::rekey::ParsedRekey> = Vec::new();
3136 for sr in &server_roots {
3137 let z_tags: Vec<String> = (head.saturating_add(1)..=window_top)
3138 .map(|e| super::derive::rekey_pseudonym(&super::ServerRootKey(*sr), channel_id, super::Epoch(e)).to_hex())
3139 .collect();
3140 let query = Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags, ..Default::default() };
3141 for ev in transport.fetch(&query, &community.relays).await.unwrap_or_default() {
3146 if let Ok(p) = super::rekey::open_rekey_event(&ev, sr) {
3147 if matches!(p.scope, super::derive::RekeyScope::Channel(c) if &c == channel_id) {
3148 parsed.push(p);
3149 }
3150 }
3151 }
3152 }
3153 if parsed.is_empty() {
3154 break; }
3156 parsed.sort_by_key(|p| p.new_epoch.0);
3158 let max_found = parsed.last().map(|p| p.new_epoch.0).unwrap_or(head);
3159
3160 let head_before = head;
3161 let mut removed = false;
3162 let mut by_epoch: std::collections::BTreeMap<u64, Vec<&super::rekey::ParsedRekey>> = std::collections::BTreeMap::new();
3167 for p in &parsed {
3168 by_epoch.entry(p.new_epoch.0).or_default().push(p);
3169 }
3170 for (e, chunks) in by_epoch {
3171 if !session.is_valid() {
3172 return Err("session changed during rekey catch-up".to_string());
3173 }
3174 let mut applied = false;
3175 let mut saw_not_recipient = false;
3176 for p in &chunks {
3177 match apply_channel_rekey(community, p) {
3178 Ok(RekeyOutcome::Applied { .. }) => {
3179 applied = true;
3180 break;
3181 }
3182 Ok(RekeyOutcome::NotARecipient) => saw_not_recipient = true,
3183 Err(err) => crate::log_warn!("rekey catch-up: skipping epoch {e} chunk: {err}"),
3184 }
3185 }
3186 if applied {
3187 if let Some(p) = chunks.first() {
3193 let pe = p.prev_epoch.0;
3194 if let Ok(Some(prev_key)) = crate::db::community::held_epoch_key(&cid, &channel_hex, pe) {
3195 if super::rekey::epoch_key_commitment(p.prev_epoch, &prev_key) != p.prev_key_commitment {
3196 forked_epochs.insert(pe);
3197 }
3198 }
3199 }
3200 if e > head + 1 {
3203 crate::log_warn!(
3204 "rekey catch-up: channel epochs {}..={} not recovered (key gap; history unreadable until re-fetched)",
3205 head + 1, e - 1
3206 );
3207 }
3208 head = head.max(e);
3209 } else if saw_not_recipient {
3210 removed = true;
3213 break;
3214 }
3215 }
3217
3218 if removed || head == head_before || max_found < window_top {
3221 break;
3222 }
3223 }
3224
3225 let held: std::collections::HashSet<u64> = crate::db::community::held_epoch_keys(&cid, &channel_hex)
3232 .unwrap_or_default()
3233 .into_iter()
3234 .map(|(e, _)| e.0)
3235 .collect();
3236 let missing: Vec<u64> = (0..head).filter(|e| !held.contains(e)).collect();
3237 if !missing.is_empty() {
3238 for sr in &server_roots {
3239 if !session.is_valid() {
3240 return Err("session changed during rekey gap-fill".to_string());
3241 }
3242 let z_tags: Vec<String> = missing
3243 .iter()
3244 .map(|e| super::derive::rekey_pseudonym(&super::ServerRootKey(*sr), channel_id, super::Epoch(*e)).to_hex())
3245 .collect();
3246 let query = Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags, ..Default::default() };
3247 for ev in transport.fetch(&query, &community.relays).await.unwrap_or_default() {
3250 if let Ok(p) = super::rekey::open_rekey_event(&ev, sr) {
3251 if matches!(p.scope, super::derive::RekeyScope::Channel(c) if &c == channel_id) {
3252 let _ = apply_channel_rekey(community, &p); }
3254 }
3255 }
3256 }
3257 }
3258
3259 if head > 0 && session.is_valid() {
3266 let lo = head.saturating_sub(REKEY_CATCHUP_WINDOW).max(1);
3267 let mut epochs: std::collections::BTreeSet<u64> = (lo..=head).collect();
3268 epochs.append(&mut forked_epochs);
3269 let _ = heal_channel_fork_epochs(transport, community, channel_id, &cid, &channel_hex, &epochs, &server_roots, &session).await;
3270 }
3271 Ok(head)
3272}
3273
3274const MAX_BASE_CATCHUP_STEPS: usize = 256;
3277
3278fn peek_my_server_root(parsed: &super::rekey::ParsedRekey) -> Result<Option<[u8; 32]>, String> {
3292 if !matches!(parsed.scope, super::derive::RekeyScope::ServerRoot) {
3293 return Ok(None);
3294 }
3295 let my_keys = crate::state::MY_SECRET_KEY.to_keys().ok_or("no local key to open a base rekey blob")?;
3296 let secret = super::rekey::rekey_pairwise_secret(my_keys.secret_key(), &parsed.rotator)?;
3297 let my_locator = super::derive::recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
3298 let mine = match parsed.blobs.iter().find(|b| b.locator == my_locator) {
3299 Some(b) => b,
3300 None => return Ok(None),
3301 };
3302 super::rekey::open_rekey_blob(my_keys.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine).map(Some)
3303}
3304
3305fn peek_my_channel_key(parsed: &super::rekey::ParsedRekey) -> Option<[u8; 32]> {
3309 let my_keys = crate::state::MY_SECRET_KEY.to_keys()?;
3310 let secret = super::rekey::rekey_pairwise_secret(my_keys.secret_key(), &parsed.rotator).ok()?;
3311 let my_locator = super::derive::recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
3312 let mine = parsed.blobs.iter().find(|b| b.locator == my_locator)?;
3313 super::rekey::open_rekey_blob(my_keys.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine).ok()
3314}
3315
3316pub async fn catch_up_server_root<T: Transport + ?Sized>(
3317 transport: &T,
3318 community: &Community,
3319) -> Result<BaseCatchup, String> {
3320 let session = SessionGuard::capture();
3321 let cid = community.id.to_hex();
3322 let mut head = community.server_root_epoch.0;
3323 let mut removed = false;
3328 let mut current_root: [u8; 32] = *community.server_root_key.as_bytes();
3330
3331 for _step in 0..MAX_BASE_CATCHUP_STEPS {
3332 let next = match head.checked_add(1) {
3333 Some(n) => n,
3334 None => break,
3335 };
3336 let addr = super::derive::base_rekey_pseudonym(&super::ServerRootKey(current_root), &community.id, super::Epoch(next)).to_hex();
3337 let query = Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() };
3338 let events = transport.fetch(&query, &community.relays).await?;
3339 if events.is_empty() {
3340 break; }
3342
3343 let chunks: Vec<super::rekey::ParsedRekey> = events
3346 .iter()
3347 .filter_map(|ev| super::rekey::open_rekey_event(ev, ¤t_root).ok())
3348 .filter(|p| matches!(p.scope, super::derive::RekeyScope::ServerRoot) && p.new_epoch.0 == next)
3349 .collect();
3350 if chunks.is_empty() {
3351 break; }
3353
3354 if !session.is_valid() {
3355 return Err("session changed during base rekey catch-up".to_string());
3356 }
3357
3358 let owner_hex = proven_owner_hex(community);
3372 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
3373 let mut candidates: Vec<(&super::rekey::ParsedRekey, [u8; 32])> = Vec::new();
3374 for parsed in &chunks {
3375 if !rotator_is_authorized(&cid, &roster, owner_hex.as_deref(), &parsed.rotator.to_hex(), super::roles::Permissions::BAN) {
3376 continue;
3377 }
3378 match peek_my_server_root(parsed) {
3379 Ok(Some(root)) => candidates.push((parsed, root)),
3380 Ok(None) => {}
3381 Err(err) => crate::log_warn!("base rekey catch-up: epoch {next} peek: {err}"),
3382 }
3383 }
3384 let applied = match candidates.into_iter().min_by(|a, b| a.1.cmp(&b.1)) {
3385 Some((parsed, _)) => match apply_server_root_rekey(community, parsed) {
3386 Ok(RekeyOutcome::Applied { .. }) => true,
3387 Ok(RekeyOutcome::NotARecipient) => false, Err(err) => { crate::log_warn!("base rekey catch-up: epoch {next} apply: {err}"); false }
3389 },
3390 None => {
3391 let owner = proven_owner_hex(community);
3396 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
3397 if chunks.iter().any(|p| rotator_is_authorized(&cid, &roster, owner.as_deref(), &p.rotator.to_hex(), super::roles::Permissions::BAN)) {
3398 removed = true;
3399 }
3400 false }
3402 };
3403 if !applied {
3404 break;
3405 }
3406 match crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, next)? {
3408 Some(root) => {
3409 current_root = root;
3410 head = next;
3411 }
3412 None => {
3413 crate::log_warn!("base rekey catch-up: epoch {next} applied but its root is not archived; halting walk");
3416 break;
3417 }
3418 }
3419 }
3420
3421 if head > 0 && !removed {
3428 if let Ok(Some(prior_root)) = crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, head - 1) {
3429 let addr = super::derive::base_rekey_pseudonym(&super::ServerRootKey(prior_root), &community.id, super::Epoch(head)).to_hex();
3430 let query = Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() };
3431 let events = transport.fetch(&query, &community.relays).await.unwrap_or_default();
3432 let chunks: Vec<super::rekey::ParsedRekey> = events
3433 .iter()
3434 .filter_map(|ev| super::rekey::open_rekey_event(ev, &prior_root).ok())
3435 .filter(|p| matches!(p.scope, super::derive::RekeyScope::ServerRoot) && p.new_epoch.0 == head)
3436 .collect();
3437 let owner_hex = proven_owner_hex(community);
3438 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
3439 let mut best: Option<(&super::rekey::ParsedRekey, [u8; 32])> = None;
3440 for p in &chunks {
3441 if !rotator_is_authorized(&cid, &roster, owner_hex.as_deref(), &p.rotator.to_hex(), super::roles::Permissions::BAN) {
3445 continue;
3446 }
3447 if let Ok(Some(root)) = peek_my_server_root(p) {
3448 if best.as_ref().map_or(true, |(_, br)| root < *br) {
3449 best = Some((p, root));
3450 }
3451 }
3452 }
3453 let current_deauthorized = chunks.iter().any(|p| {
3463 matches!(peek_my_server_root(p), Ok(Some(r)) if r == current_root)
3464 && !rotator_is_authorized(&cid, &roster, owner_hex.as_deref(), &p.rotator.to_hex(), super::roles::Permissions::BAN)
3465 });
3466 if let Some((winner, win_root)) = best {
3467 let adopt = if current_deauthorized {
3468 win_root != current_root
3469 } else {
3470 win_root < current_root
3471 };
3472 if adopt {
3473 if !session.is_valid() {
3474 return Err("session changed during base convergence".to_string());
3475 }
3476 if apply_server_root_rekey(community, winner).is_ok() {
3479 match crate::db::community::converge_server_root_epoch(&cid, head, &win_root) {
3480 Ok(false) => crate::log_trace!("base heal: converge of epoch {head} did not apply (head moved)"),
3481 Err(e) => crate::log_trace!("base heal: converge of epoch {head} errored: {e}"),
3482 Ok(true) => {}
3483 }
3484 current_root = win_root;
3485 if let Ok(Some(fresh)) = crate::db::community::load_community(&community.id) {
3486 let _ = fetch_and_apply_control(transport, &fresh).await;
3487 }
3488 }
3489 }
3490 }
3491 }
3492 }
3493 let _ = current_root; Ok(BaseCatchup { epoch: head, removed })
3495}
3496
3497#[derive(Debug, Clone, Copy)]
3501pub struct BaseCatchup {
3502 pub epoch: u64,
3503 pub removed: bool,
3504}
3505
3506#[cfg(test)]
3507mod tests {
3508 use super::*;
3509 use crate::community::send::fetch_channel_messages;
3510 use crate::community::transport::{memory::MemoryRelay, Query, Transport};
3511 use nostr_sdk::prelude::{EventBuilder, Kind};
3512
3513 struct FailingRelay;
3516 #[async_trait::async_trait]
3517 impl Transport for FailingRelay {
3518 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
3519 async fn publish(&self, _event: &Event, _relays: &[String]) -> Result<(), String> {
3520 Err("relay unreachable".to_string())
3521 }
3522 async fn publish_durable(&self, _event: &Event, _relays: &[String]) -> Result<(), String> {
3523 Err("relay unreachable".to_string())
3524 }
3525 async fn fetch(&self, _query: &Query, _relays: &[String]) -> Result<Vec<Event>, String> {
3526 Ok(Vec::new())
3527 }
3528 }
3529
3530 struct RekeyFailingRelay {
3534 inner: MemoryRelay,
3535 fail_rekey: std::sync::atomic::AtomicBool,
3536 }
3537 impl RekeyFailingRelay {
3538 fn new() -> Self {
3539 Self { inner: MemoryRelay::new(), fail_rekey: std::sync::atomic::AtomicBool::new(true) }
3540 }
3541 fn allow_rekey(&self) {
3542 self.fail_rekey.store(false, std::sync::atomic::Ordering::Relaxed);
3543 }
3544 fn blocks(&self, event: &Event) -> bool {
3545 self.fail_rekey.load(std::sync::atomic::Ordering::Relaxed)
3546 && event.kind.as_u16() == crate::stored_event::event_kind::COMMUNITY_REKEY
3547 }
3548 }
3549 #[async_trait::async_trait]
3550 impl Transport for RekeyFailingRelay {
3551 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
3552 async fn publish(&self, event: &Event, relays: &[String]) -> Result<(), String> {
3553 if self.blocks(event) { return Err("rekey relay down".to_string()); }
3554 self.inner.publish(event, relays).await
3555 }
3556 async fn publish_durable(&self, event: &Event, relays: &[String]) -> Result<(), String> {
3557 if self.blocks(event) { return Err("rekey relay down".to_string()); }
3558 self.inner.publish_durable(event, relays).await
3559 }
3560 async fn fetch(&self, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
3561 self.inner.fetch(query, relays).await
3562 }
3563 }
3564
3565 static TEST_COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(5000);
3566
3567 fn make_test_npub(n: u32) -> String {
3568 const BECH32: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
3569 let mut payload = vec![b'q'; 58];
3570 let mut x = n as u64;
3571 let mut i = 58;
3572 while x > 0 && i > 0 {
3573 i -= 1;
3574 payload[i] = BECH32[(x as usize) % 32];
3575 x /= 32;
3576 }
3577 format!("npub1{}", std::str::from_utf8(&payload).unwrap())
3578 }
3579
3580 fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
3581 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
3582 crate::db::close_database();
3583 crate::db::clear_id_caches();
3586 crate::signer::set_test_signer(None);
3589 let tmp = tempfile::tempdir().unwrap();
3590 let n = TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3591 let account = make_test_npub(n);
3592 std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
3593 crate::db::set_app_data_dir(tmp.path().to_path_buf());
3594 crate::db::set_current_account(account.clone()).unwrap();
3595 crate::db::init_database(&account).unwrap();
3596 let _ = crate::state::take_nostr_client();
3599 let owner = Keys::generate();
3601 crate::state::MY_SECRET_KEY.store_from_keys(&owner, &[]);
3602 crate::state::set_my_public_key(owner.public_key());
3603 (tmp, guard)
3604 }
3605
3606 #[test]
3607 fn community_cap_rejects_a_new_membership_at_the_limit() {
3608 let (_tmp, _guard) = init_test_db();
3609 let mk = |i: usize| {
3610 let id = format!("{:064x}", i);
3611 crate::community::list::CommunityListEntry {
3612 community_id: id.clone(),
3613 seed: crate::community::invite::CommunityInvite {
3614 community_id: id,
3615 name: String::new(),
3616 server_root_key: String::new(),
3617 server_root_epoch: 0,
3618 relays: vec![],
3619 channels: vec![],
3620 owner_attestation: None,
3621 icon: None,
3622 },
3623 current: None,
3624 added_at: 0,
3625 }
3626 };
3627 let mut list = crate::community::list::CommunityList::default();
3628 for i in 0..(MAX_COMMUNITIES - 1) {
3629 list.entries.push(mk(i));
3630 }
3631 crate::db::settings::set_sql_setting("community_list_json".to_string(), list.to_json()).unwrap();
3632 assert!(enforce_community_cap().is_ok(), "under the cap a new join is allowed");
3633
3634 list.entries.push(mk(MAX_COMMUNITIES - 1)); crate::db::settings::set_sql_setting("community_list_json".to_string(), list.to_json()).unwrap();
3636 assert!(enforce_community_cap().is_err(), "at the cap a new join is rejected");
3637 }
3638
3639 fn saved_community_owned_by(owner: &Keys) -> Community {
3644 let mut community = Community::create("HQ", "general", vec!["r".into()]);
3645 let cid = community.id.to_hex();
3646 community.owner_attestation = Some(
3647 crate::community::owner::build_owner_attestation_unsigned(owner.public_key(), &cid)
3648 .finalize(owner)
3649 .unwrap()
3650 .as_json(),
3651 );
3652 crate::db::community::save_community(&community).unwrap();
3653 community
3654 }
3655
3656 fn attested_community(name: &str, channel: &str, relays: Vec<String>) -> Community {
3660 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
3661 let mut community = Community::create(name, channel, relays);
3662 community.owner_attestation = Some(
3663 crate::community::owner::build_owner_attestation_unsigned(owner.public_key(), &community.id.to_hex())
3664 .finalize(&owner).unwrap().as_json(),
3665 );
3666 community
3667 }
3668
3669 fn become_local(me: &Keys) {
3671 crate::state::MY_SECRET_KEY.store_from_keys(me, &[]);
3672 crate::state::set_my_public_key(me.public_key());
3673 }
3674
3675 fn owner_channel_rekey(
3678 owner: &Keys,
3679 community: &Community,
3680 recipient_pk: &nostr_sdk::prelude::PublicKey,
3681 new_epoch: u64,
3682 new_key: &[u8; 32],
3683 ) -> super::super::rekey::ParsedRekey {
3684 let chan = &community.channels[0];
3685 let scope = super::super::derive::RekeyScope::Channel(chan.id);
3686 let blob = super::super::rekey::build_rekey_blob(
3687 owner.secret_key(), recipient_pk, scope, crate::community::Epoch(new_epoch), new_key,
3688 )
3689 .unwrap();
3690 let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), chan.key.as_bytes());
3691 let outer = super::super::rekey::build_channel_rekey_event(
3692 &Keys::generate(), owner, community.server_root_key.as_bytes(), &chan.id,
3693 crate::community::Epoch(new_epoch), crate::community::Epoch(0), &commit, &[blob],
3694 )
3695 .unwrap();
3696 super::super::rekey::open_rekey_event(&outer, community.server_root_key.as_bytes()).unwrap()
3697 }
3698
3699 #[tokio::test]
3704 async fn outer_event_dedup_skips_an_already_persisted_wire_event() {
3705 let (_tmp, _guard) = init_test_db();
3706 let owner = Keys::generate();
3707 let me = Keys::generate();
3708 become_local(&me);
3709 let community = saved_community_owned_by(&owner);
3710 let channel = community.channels[0].clone();
3711 let chan_hex = channel.id.to_hex();
3712
3713 let author = Keys::generate();
3715 let outer = crate::community::envelope::seal_message(
3716 &author, &channel.key, &channel.id, channel.epoch, "gm", 1000,
3717 ).unwrap();
3718 let outer_hex = outer.id.to_hex();
3719
3720 let mut state = crate::state::ChatState::new();
3722 let msg = match crate::community::inbound::process_incoming(&mut state, &outer, &channel, &me.public_key()) {
3723 Some(crate::community::inbound::IncomingEvent::NewMessage(m)) => m,
3724 _ => panic!("expected NewMessage from a fresh wire event"),
3725 };
3726 assert_eq!(msg.wrapper_event_id.as_deref(), Some(outer_hex.as_str()),
3727 "the inner must carry its outer wire id as wrapper_event_id");
3728
3729 crate::db::events::save_message(&chan_hex, &msg).await.unwrap();
3731
3732 let mut state2 = crate::state::ChatState::new();
3734 let second = crate::community::inbound::process_incoming(&mut state2, &outer, &channel, &me.public_key());
3735 assert!(second.is_none(), "an already-processed wire event must dedup before decryption");
3736 }
3737
3738 #[tokio::test]
3741 async fn ledger_is_shared_but_negentropy_stays_nip17_only() {
3742 let (_tmp, _guard) = init_test_db();
3743 let dm = [0xA1u8; 32];
3744 let concord = [0xC0u8; 32];
3745 crate::db::wrappers::save_processed_wrapper(&dm, 100, crate::db::wrappers::TRANSPORT_NIP17).unwrap();
3746 crate::db::wrappers::save_processed_wrapper(&concord, 200, crate::db::wrappers::TRANSPORT_CONCORD).unwrap();
3747
3748 assert!(crate::db::wrappers::processed_wrapper_exists(&dm));
3750 assert!(crate::db::wrappers::processed_wrapper_exists(&concord));
3751
3752 let items = crate::db::wrappers::load_negentropy_items().unwrap();
3754 assert_eq!(items.len(), 1, "negentropy must exclude concord wrappers");
3755 assert_eq!(items[0].0.to_bytes(), dm);
3756 }
3757
3758 #[tokio::test]
3762 async fn non_message_subkind_dedups_via_the_shared_ledger() {
3763 let (_tmp, _guard) = init_test_db();
3764 let owner = Keys::generate();
3765 let me = Keys::generate();
3766 become_local(&me);
3767 let community = saved_community_owned_by(&owner);
3768 let channel = community.channels[0].clone();
3769
3770 let author = Keys::generate();
3772 let inner = super::super::envelope::build_inner_typed(
3773 author.public_key(), &channel.id, channel.epoch,
3774 crate::stored_event::event_kind::COMMUNITY_PRESENCE, "join", 5, None, &[],
3775 ).finalize(&author).unwrap();
3776 let outer = super::super::envelope::seal_with_signed_inner(
3777 &Keys::generate(), &inner, &channel.key, &channel.id, channel.epoch,
3778 ).unwrap();
3779
3780 let mut state = crate::state::ChatState::new();
3782 let first = crate::community::inbound::process_incoming(&mut state, &outer, &channel, &me.public_key());
3783 assert!(matches!(first, Some(crate::community::inbound::IncomingEvent::Presence { .. })),
3784 "expected a Presence outcome");
3785 assert!(crate::db::wrappers::processed_wrapper_exists(&outer.id.to_bytes()),
3786 "a non-message sub-kind must record its outer id in the shared ledger");
3787
3788 let second = crate::community::inbound::process_incoming(&mut crate::state::ChatState::new(), &outer, &channel, &me.public_key());
3790 assert!(second.is_none(), "a re-fetched presence must dedup via the shared ledger");
3791 }
3792
3793 #[test]
3794 fn apply_channel_rekey_recovers_and_advances_head() {
3795 let (_tmp, _guard) = init_test_db();
3796 let owner = Keys::generate(); let me = Keys::generate();
3798 become_local(&me);
3799 let community = saved_community_owned_by(&owner);
3800 let cid = community.id.to_hex();
3801 let chan_hex = community.channels[0].id.to_hex();
3802 let new_key = [0xCDu8; 32];
3803
3804 let parsed = owner_channel_rekey(&owner, &community, &me.public_key(), 1, &new_key);
3805 let outcome = apply_channel_rekey(&community, &parsed).unwrap();
3806 assert_eq!(outcome, RekeyOutcome::Applied { head_advanced: true });
3807
3808 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(new_key));
3810 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
3811 assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(1));
3812 assert_eq!(reloaded.channels[0].key.as_bytes(), &new_key);
3813 assert!(crate::db::community::held_epoch_key(&cid, &chan_hex, 0).unwrap().is_some());
3815 }
3816
3817 #[test]
3818 fn apply_channel_rekey_accepts_matching_continuity() {
3819 let (_tmp, _guard) = init_test_db();
3822 let owner = Keys::generate();
3823 let me = Keys::generate();
3824 become_local(&me);
3825 let community = saved_community_owned_by(&owner);
3826 let parsed = owner_channel_rekey(&owner, &community, &me.public_key(), 1, &[0x44u8; 32]);
3828 assert_eq!(
3829 apply_channel_rekey(&community, &parsed).unwrap(),
3830 RekeyOutcome::Applied { head_advanced: true },
3831 "a rekey whose prior-key commitment matches the held genesis key applies"
3832 );
3833 }
3834
3835 #[test]
3836 fn advance_channel_epoch_archives_when_no_head_row() {
3837 let (_tmp, _guard) = init_test_db();
3840 let cid = "f".repeat(64);
3841 let orphan_channel = "a".repeat(64);
3842 let advanced = crate::db::community::advance_channel_epoch(&cid, &orphan_channel, 2, &[0x77u8; 32]).unwrap();
3843 assert!(!advanced, "no head row → head not advanced");
3844 assert_eq!(crate::db::community::held_epoch_key(&cid, &orphan_channel, 2).unwrap(), Some([0x77u8; 32]), "key still archived");
3845 }
3846
3847 #[tokio::test]
3848 async fn rotate_channel_publishes_recoverable_rekey_and_advances_own_head() {
3849 use crate::community::derive::{recipient_pseudonym, rekey_pseudonym};
3850 use crate::community::rekey::{open_rekey_blob, open_rekey_event, rekey_pairwise_secret};
3851 let (_tmp, _guard) = init_test_db();
3852 let owner = Keys::generate();
3853 become_local(&owner); let community = saved_community_owned_by(&owner);
3855 let channel_id = community.channels[0].id;
3856 let member = Keys::generate(); let relay = MemoryRelay::new();
3858
3859 let new_epoch = rotate_channel(&relay, &community, &channel_id, &[member.public_key()], community.server_root_key.as_bytes())
3860 .await
3861 .expect("rotate");
3862 assert_eq!(new_epoch, 1);
3863
3864 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
3866 assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(1));
3867
3868 let addr = rekey_pseudonym(
3871 &crate::community::ServerRootKey(*community.server_root_key.as_bytes()),
3872 &channel_id, crate::community::Epoch(1),
3873 )
3874 .to_hex();
3875 let found = relay
3876 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() }, &community.relays)
3877 .await
3878 .unwrap();
3879 assert_eq!(found.len(), 1, "rekey addressable by its server-root pseudonym");
3880 let parsed = open_rekey_event(&found[0], community.server_root_key.as_bytes()).unwrap();
3881 assert_eq!(parsed.rotator, owner.public_key());
3882 assert_eq!(parsed.new_epoch, crate::community::Epoch(1));
3883 assert_eq!(parsed.prev_epoch, crate::community::Epoch(0));
3884 assert_eq!(parsed.blobs.len(), 2, "the member + me (multi-device) each get a blob");
3885
3886 let secret = rekey_pairwise_secret(member.secret_key(), &parsed.rotator).unwrap();
3888 let loc = recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
3889 let mine = parsed.blobs.iter().find(|b| b.locator == loc).expect("member's blob present");
3890 let recovered = open_rekey_blob(member.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine).unwrap();
3891 assert_eq!(reloaded.channels[0].key.as_bytes(), &recovered, "member's recovered key == my advanced head key");
3892 }
3893
3894 #[tokio::test]
3895 async fn rotate_channel_failed_publish_leaves_head_unadvanced() {
3896 let (_tmp, _guard) = init_test_db();
3899 let owner = Keys::generate();
3900 become_local(&owner);
3901 let community = saved_community_owned_by(&owner);
3902 let member = Keys::generate();
3903 let err = rotate_channel(&FailingRelay, &community, &community.channels[0].id, &[member.public_key()], community.server_root_key.as_bytes()).await;
3904 assert!(err.is_err(), "a failed publish must propagate, not silently advance");
3905 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
3906 assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(0), "head stays put on publish failure");
3907 }
3908
3909 fn build_rekey_chain(
3913 owner: &Keys, community: &Community, recipient_pk: &nostr_sdk::prelude::PublicKey, n: u64,
3914 ) -> (Vec<Event>, Vec<[u8; 32]>) {
3915 let chan = &community.channels[0];
3916 let scope = super::super::derive::RekeyScope::Channel(chan.id);
3917 let mut prev_key = *chan.key.as_bytes();
3918 let mut events = Vec::new();
3919 let mut keys = Vec::new();
3920 for e in 1..=n {
3921 let new_key = [e as u8; 32];
3922 let blob = super::super::rekey::build_rekey_blob(owner.secret_key(), recipient_pk, scope, crate::community::Epoch(e), &new_key).unwrap();
3923 let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(e - 1), &prev_key);
3924 let ev = super::super::rekey::build_channel_rekey_event(
3925 &Keys::generate(), owner, community.server_root_key.as_bytes(), &chan.id,
3926 crate::community::Epoch(e), crate::community::Epoch(e - 1), &commit, &[blob],
3927 ).unwrap();
3928 events.push(ev);
3929 keys.push(new_key);
3930 prev_key = new_key;
3931 }
3932 (events, keys)
3933 }
3934
3935 #[tokio::test]
3936 async fn catch_up_steps_over_a_missing_epoch() {
3937 let (_tmp, _guard) = init_test_db();
3940 let owner = Keys::generate();
3941 let me = Keys::generate();
3942 become_local(&me);
3943 let community = saved_community_owned_by(&owner);
3944 let channel_id = community.channels[0].id;
3945 let cid = community.id.to_hex();
3946 let chan_hex = channel_id.to_hex();
3947
3948 let (events, keys) = build_rekey_chain(&owner, &community, &me.public_key(), 3);
3949 let relay = MemoryRelay::new();
3950 relay.inject(&events[0], &community.relays); relay.inject(&events[2], &community.relays); let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
3953
3954 assert_eq!(reached, 3, "head reaches the latest present epoch, stepping over the gap");
3955 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(keys[0]));
3956 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 2).unwrap(), None, "missing epoch is a hole");
3957 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 3).unwrap(), Some(keys[2]));
3958 }
3959
3960 #[tokio::test]
3961 async fn catch_up_recovers_a_rekey_under_a_prior_server_root() {
3962 let (_tmp, _guard) = init_test_db();
3967 let owner = Keys::generate();
3968 let me = Keys::generate();
3969 become_local(&me);
3970 let root0_community = saved_community_owned_by(&owner);
3971 let cid = root0_community.id.to_hex();
3972 let channel_id = root0_community.channels[0].id;
3973 let chan_hex = channel_id.to_hex();
3974 let scope = super::super::derive::RekeyScope::Channel(channel_id);
3975 let genesis_key = *root0_community.channels[0].key.as_bytes();
3976
3977 let root1 = [0x99u8; 32];
3979 crate::db::community::advance_server_root_epoch(&cid, 1, &root1).unwrap();
3980 let community = crate::db::community::load_community(&root0_community.id).unwrap().unwrap();
3981 assert_eq!(community.server_root_epoch, crate::community::Epoch(1));
3982
3983 let (k1, k2) = ([0x11u8; 32], [0x22u8; 32]);
3985 let blob1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &k1).unwrap();
3986 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
3987 let ev1 = super::super::rekey::build_channel_rekey_event(
3988 &Keys::generate(), &owner, root0_community.server_root_key.as_bytes(), &channel_id,
3989 crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob1]).unwrap();
3990 let blob2 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(2), &k2).unwrap();
3991 let commit1 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(1), &k1);
3992 let ev2 = super::super::rekey::build_channel_rekey_event(
3993 &Keys::generate(), &owner, &root1, &channel_id,
3994 crate::community::Epoch(2), crate::community::Epoch(1), &commit1, &[blob2]).unwrap();
3995
3996 let relay = MemoryRelay::new();
3997 relay.inject(&ev1, &community.relays);
3998 relay.inject(&ev2, &community.relays);
3999
4000 let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4001 assert_eq!(reached, 2, "reached the latest channel epoch across the server-root rotation");
4002 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(k1),
4003 "epoch-1 key recovered from a rekey under the PRIOR server root");
4004 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 2).unwrap(), Some(k2));
4005 }
4006
4007 #[tokio::test]
4008 async fn catch_up_backfills_a_sub_head_gap() {
4009 let (_tmp, _guard) = init_test_db();
4012 let owner = Keys::generate();
4013 let me = Keys::generate();
4014 become_local(&me);
4015 let community = saved_community_owned_by(&owner);
4016 let cid = community.id.to_hex();
4017 let channel_id = community.channels[0].id;
4018 let chan_hex = channel_id.to_hex();
4019 let scope = super::super::derive::RekeyScope::Channel(channel_id);
4020 let genesis_key = *community.channels[0].key.as_bytes();
4021
4022 let k2 = [0x22u8; 32];
4024 crate::db::community::advance_channel_epoch(&cid, &chan_hex, 2, &k2).unwrap();
4025 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), None, "epoch 1 starts as a hole");
4026
4027 let k1 = [0x11u8; 32];
4029 let blob1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &k1).unwrap();
4030 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
4031 let ev1 = super::super::rekey::build_channel_rekey_event(
4032 &Keys::generate(), &owner, community.server_root_key.as_bytes(), &channel_id,
4033 crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob1]).unwrap();
4034 let relay = MemoryRelay::new();
4035 relay.inject(&ev1, &community.relays);
4036
4037 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4038 let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4039 assert_eq!(reached, 2, "head unchanged (gap-fill never regresses it)");
4040 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(k1),
4041 "the sub-head hole was backfilled");
4042 }
4043
4044 #[tokio::test]
4045 async fn catch_up_walks_a_chain_of_rotations_to_the_latest() {
4046 let (_tmp, _guard) = init_test_db();
4047 let owner = Keys::generate();
4048 let me = Keys::generate();
4049 become_local(&me); let community = saved_community_owned_by(&owner);
4051 let channel_id = community.channels[0].id;
4052 let cid = community.id.to_hex();
4053 let chan_hex = channel_id.to_hex();
4054
4055 let (events, keys) = build_rekey_chain(&owner, &community, &me.public_key(), 3);
4057 let relay = MemoryRelay::new();
4058 for ev in events.iter().rev() {
4059 relay.inject(ev, &community.relays);
4060 }
4061
4062 let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4063 assert_eq!(reached, 3, "caught up to the latest epoch");
4064 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4066 assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(3));
4067 assert_eq!(reloaded.channels[0].key.as_bytes(), &keys[2]);
4068 for (i, k) in keys.iter().enumerate() {
4069 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, (i + 1) as u64).unwrap(), Some(*k));
4070 }
4071 }
4072
4073 #[tokio::test]
4074 async fn catch_up_slides_across_the_window_boundary() {
4075 let (_tmp, _guard) = init_test_db();
4079 let owner = Keys::generate();
4080 let me = Keys::generate();
4081 become_local(&me);
4082 let community = saved_community_owned_by(&owner);
4083 let channel_id = community.channels[0].id;
4084 let cid = community.id.to_hex();
4085 let chan_hex = channel_id.to_hex();
4086
4087 let (events, keys) = build_rekey_chain(&owner, &community, &me.public_key(), 70);
4088 let relay = MemoryRelay::new();
4089 for ev in &events {
4090 relay.inject(ev, &community.relays);
4091 }
4092 let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4093 assert_eq!(reached, 70, "slid past the 64-epoch window boundary to the latest");
4094 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 70).unwrap(), Some(keys[69]));
4095 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 64).unwrap(), Some(keys[63]), "window-1 keys retained too");
4096 }
4097
4098 fn build_base_rekey_chain(
4104 owner: &Keys, community: &Community, recipient_pk: &nostr_sdk::prelude::PublicKey, n: u64,
4105 ) -> (Vec<Event>, Vec<[u8; 32]>) {
4106 let mut prior_root = *community.server_root_key.as_bytes();
4107 let mut events = Vec::new();
4108 let mut roots = Vec::new();
4109 for e in 1..=n {
4110 let new_root = [(e % 256) as u8; 32];
4111 let blob = super::super::rekey::build_rekey_blob(
4112 owner.secret_key(), recipient_pk, super::super::derive::RekeyScope::ServerRoot, crate::community::Epoch(e), &new_root,
4113 )
4114 .unwrap();
4115 let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(e - 1), &prior_root);
4116 events.push(super::super::rekey::build_server_root_rekey_event(
4117 &Keys::generate(), owner, &prior_root, &community.id,
4118 crate::community::Epoch(e), crate::community::Epoch(e - 1), &commit, &[blob],
4119 ).unwrap());
4120 roots.push(new_root);
4121 prior_root = new_root;
4122 }
4123 (events, roots)
4124 }
4125
4126 #[tokio::test]
4127 async fn catch_up_server_root_walks_a_chain_of_base_rotations() {
4128 let (_tmp, _guard) = init_test_db();
4129 let owner = Keys::generate();
4130 let me = Keys::generate();
4131 become_local(&me);
4132 let community = saved_community_owned_by(&owner);
4133 let cid = community.id.to_hex();
4134
4135 let (events, roots) = build_base_rekey_chain(&owner, &community, &me.public_key(), 3);
4136 let relay = MemoryRelay::new();
4137 for ev in events.iter().rev() {
4138 relay.inject(ev, &community.relays);
4139 }
4140 let reached = catch_up_server_root(&relay, &community).await.unwrap();
4141 assert_eq!(reached.epoch, 3, "walked the base chain to the latest epoch");
4142 assert!(!reached.removed, "a normal catch-up is not a removal");
4143 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4144 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(3));
4145 assert_eq!(reloaded.server_root_key.as_bytes(), &roots[2], "base head is the latest root");
4146 for (i, r) in roots.iter().enumerate() {
4148 assert_eq!(crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, (i + 1) as u64).unwrap(), Some(*r));
4149 }
4150 }
4151
4152 #[tokio::test]
4153 async fn catch_up_recovers_from_a_split_base_rotation_second_chunk() {
4154 let (_tmp, _guard) = init_test_db();
4158 let owner = Keys::generate();
4159 let me = Keys::generate();
4160 become_local(&me);
4161 let community = saved_community_owned_by(&owner);
4162 let genesis = *community.server_root_key.as_bytes();
4163 let new_root = [0x5Au8; 32];
4164 let scope = super::super::derive::RekeyScope::ServerRoot;
4165 let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis);
4166 let mk = |recipient: &nostr_sdk::prelude::PublicKey| {
4167 let blob = super::super::rekey::build_rekey_blob(owner.secret_key(), recipient, scope, crate::community::Epoch(1), &new_root).unwrap();
4168 super::super::rekey::build_server_root_rekey_event(
4169 &Keys::generate(), &owner, &genesis, &community.id,
4170 crate::community::Epoch(1), crate::community::Epoch(0), &commit, &[blob],
4171 ).unwrap()
4172 };
4173 let relay = MemoryRelay::new();
4174 relay.inject(&mk(&Keys::generate().public_key()), &community.relays); relay.inject(&mk(&me.public_key()), &community.relays); let reached = catch_up_server_root(&relay, &community).await.unwrap();
4178 assert_eq!(reached.epoch, 1, "recovered the split rotation via the second chunk");
4179 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4180 assert_eq!(reloaded.server_root_key.as_bytes(), &new_root, "recovered the new root from chunk 2");
4181 }
4182
4183 #[tokio::test]
4184 async fn catch_up_converges_concurrent_refoundings_on_the_lowest_root() {
4185 let (_tmp, _guard) = init_test_db();
4189 let owner = Keys::generate();
4190 let me = Keys::generate();
4191 become_local(&me);
4192 let community = saved_community_owned_by(&owner);
4193 let genesis = *community.server_root_key.as_bytes();
4194 let scope = super::super::derive::RekeyScope::ServerRoot;
4195 let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis);
4196 let root_lo = [0x10u8; 32];
4197 let root_hi = [0xF0u8; 32]; let mk = |root: &[u8; 32]| {
4199 let blob = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), root).unwrap();
4200 super::super::rekey::build_server_root_rekey_event(
4201 &Keys::generate(), &owner, &genesis, &community.id,
4202 crate::community::Epoch(1), crate::community::Epoch(0), &commit, &[blob],
4203 ).unwrap()
4204 };
4205 let relay = MemoryRelay::new();
4206 relay.inject(&mk(&root_hi), &community.relays);
4208 relay.inject(&mk(&root_lo), &community.relays);
4209
4210 let reached = catch_up_server_root(&relay, &community).await.unwrap();
4211 assert_eq!(reached.epoch, 1, "advanced one epoch");
4212 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4213 assert_eq!(reloaded.server_root_key.as_bytes(), &root_lo, "converged on the LOWEST root, not the first-arrived");
4214 }
4215
4216 #[tokio::test]
4217 async fn rotate_retry_reuses_the_archived_root_no_same_epoch_fork() {
4218 let (_tmp, _guard) = init_test_db();
4223 let owner = Keys::generate();
4224 become_local(&owner);
4225 let community = saved_community_owned_by(&owner);
4226 let cid = community.id.to_hex();
4227 let relay = RekeyFailingRelay::new(); let member = Keys::generate();
4229
4230 assert!(rotate_server_root(&relay, &community, &[member.public_key()]).await.is_err(), "the rekey publish fails");
4231 let k1 = crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap()
4232 .expect("the new root is archived before publishing (fork-safety)");
4233 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4234 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(0), "head not advanced on a failed publish");
4235
4236 relay.allow_rekey();
4237 rotate_server_root(&relay, &reloaded, &[member.public_key()]).await.unwrap();
4238 let k2 = crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap().unwrap();
4239 assert_eq!(k1, k2, "the retry REUSES the archived root — no second root for epoch 1, no fork");
4240 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
4241 assert_eq!(after.server_root_epoch, crate::community::Epoch(1), "the retry completed the rotation");
4242 assert_eq!(after.server_root_key.as_bytes(), &k1, "the committed root is the one minted on attempt 1");
4243 }
4244
4245 #[tokio::test]
4246 async fn rotate_server_root_splits_a_large_recipient_set_into_multiple_events() {
4247 let (_tmp, _guard) = init_test_db();
4249 let owner = Keys::generate();
4250 become_local(&owner);
4251 let community = saved_community_owned_by(&owner);
4252 let genesis = *community.server_root_key.as_bytes();
4253 let relay = MemoryRelay::new();
4254 let recipients: Vec<_> = (0..super::super::rekey::MAX_REKEY_BLOBS).map(|_| Keys::generate().public_key()).collect();
4256 rotate_server_root(&relay, &community, &recipients).await.unwrap();
4257 let addr = super::super::derive::base_rekey_pseudonym(&super::super::ServerRootKey(genesis), &community.id, crate::community::Epoch(1)).to_hex();
4258 let evs = relay
4259 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() }, &community.relays)
4260 .await
4261 .unwrap();
4262 assert_eq!(evs.len(), 2, "a >MAX_REKEY_BLOBS rotation splits into 2 events at one address");
4263 }
4264
4265 #[tokio::test]
4266 async fn catch_up_server_root_is_a_noop_with_no_rotations() {
4267 let (_tmp, _guard) = init_test_db();
4268 let owner = Keys::generate();
4269 let me = Keys::generate();
4270 become_local(&me);
4271 let community = saved_community_owned_by(&owner);
4272 let relay = MemoryRelay::new();
4273 assert_eq!(catch_up_server_root(&relay, &community).await.unwrap().epoch, 0, "no base rotations → stays at 0");
4274 }
4275
4276 #[tokio::test]
4277 async fn concurrent_refounders_converge_to_the_lowest_root() {
4278 let (_tmp, _guard) = init_test_db();
4283 let owner = Keys::generate();
4284 let me = Keys::generate();
4285 become_local(&me); let community = saved_community_owned_by(&owner);
4287 let cid = community.id.to_hex();
4288 let genesis_root = *community.server_root_key.as_bytes();
4289 let scope = super::super::derive::RekeyScope::ServerRoot;
4290
4291 let root_lo = [0x10u8; 32];
4294 let root_hi = [0x99u8; 32]; let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_root);
4296 let blob_lo = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &root_lo).unwrap();
4297 let ev_lo = super::super::rekey::build_server_root_rekey_event(
4298 &Keys::generate(), &owner, &genesis_root, &community.id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_lo]).unwrap();
4299
4300 let relay = MemoryRelay::new();
4301 relay.inject(&ev_lo, &community.relays);
4302
4303 crate::db::community::advance_server_root_epoch(&cid, 1, &root_hi).unwrap();
4305 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4306 assert_eq!(community.server_root_key.as_bytes(), &root_hi, "start on the higher root");
4307
4308 let out = catch_up_server_root(&relay, &community).await.unwrap();
4309 assert_eq!(out.epoch, 1, "converged in place at the same epoch (not advanced)");
4310 assert!(!out.removed);
4311 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
4312 assert_eq!(after.server_root_key.as_bytes(), &root_lo, "originator converged to the lowest authorized root");
4313
4314 let _ = catch_up_server_root(&relay, &after).await.unwrap();
4316 assert_eq!(crate::db::community::load_community(&community.id).unwrap().unwrap().server_root_key.as_bytes(), &root_lo, "no flip-flop");
4317 }
4318
4319 #[tokio::test]
4320 async fn banned_rotators_rekey_is_not_a_convergence_candidate() {
4321 let (_tmp, _guard) = init_test_db();
4326 let owner = Keys::generate();
4327 let me = Keys::generate();
4328 let banned_admin = Keys::generate();
4329 become_local(&me);
4330 let community = saved_community_owned_by(&owner);
4331 let cid = community.id.to_hex();
4332 let genesis_root = *community.server_root_key.as_bytes();
4333 let scope = super::super::derive::RekeyScope::ServerRoot;
4334
4335 let role_id = "e".repeat(64);
4337 let roster = crate::community::roles::CommunityRoles {
4338 roles: vec![crate::community::roles::Role::admin(role_id.clone())],
4339 grants: vec![crate::community::roles::MemberGrant { member: banned_admin.public_key().to_hex(), role_ids: vec![role_id] }],
4340 };
4341 crate::db::community::set_community_roles(&cid, &roster, 1).unwrap();
4342 crate::db::community::set_community_banlist(&cid, &[banned_admin.public_key().to_hex()], 2).unwrap();
4344
4345 let root_evil = [0x01u8; 32];
4347 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_root);
4348 let blob = super::super::rekey::build_rekey_blob(banned_admin.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &root_evil).unwrap();
4349 let ev = super::super::rekey::build_server_root_rekey_event(
4350 &Keys::generate(), &banned_admin, &genesis_root, &community.id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob]).unwrap();
4351 let relay = MemoryRelay::new();
4352 relay.inject(&ev, &community.relays);
4353
4354 let out = catch_up_server_root(&relay, &community).await.unwrap();
4357 assert_eq!(out.epoch, 0, "banned rotator's re-founding must not advance the base");
4358 assert!(!out.removed, "banned rotator's exclusion must not read as an authorized removal");
4359 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
4360 assert_eq!(after.server_root_key.as_bytes(), &genesis_root, "root unchanged");
4361
4362 let parsed = super::super::rekey::open_rekey_event(&ev, &genesis_root).unwrap();
4364 assert!(apply_server_root_rekey(&community, &parsed).is_err(), "apply must refuse a banned rotator");
4365 }
4366
4367 #[tokio::test]
4368 async fn heal_abandons_a_deauthorized_root_for_the_authorized_higher_sibling() {
4369 let (_tmp, _guard) = init_test_db();
4373 let owner = Keys::generate();
4374 let me = Keys::generate();
4375 let banned_admin = Keys::generate();
4376 become_local(&me);
4377 let community = saved_community_owned_by(&owner);
4378 let cid = community.id.to_hex();
4379 let genesis_root = *community.server_root_key.as_bytes();
4380 let scope = super::super::derive::RekeyScope::ServerRoot;
4381 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_root);
4382
4383 let root_evil = [0x01u8; 32];
4386 let root_owner = [0x77u8; 32];
4387 let blob_evil = super::super::rekey::build_rekey_blob(banned_admin.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &root_evil).unwrap();
4388 let ev_evil = super::super::rekey::build_server_root_rekey_event(
4389 &Keys::generate(), &banned_admin, &genesis_root, &community.id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_evil]).unwrap();
4390 let blob_owner = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &root_owner).unwrap();
4391 let ev_owner = super::super::rekey::build_server_root_rekey_event(
4392 &Keys::generate(), &owner, &genesis_root, &community.id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_owner]).unwrap();
4393 let relay = MemoryRelay::new();
4394 relay.inject(&ev_evil, &community.relays);
4395 relay.inject(&ev_owner, &community.relays);
4396
4397 crate::db::community::advance_server_root_epoch(&cid, 1, &root_evil).unwrap();
4399 crate::db::community::set_community_banlist(&cid, &[banned_admin.public_key().to_hex()], 2).unwrap();
4400 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4401 assert_eq!(community.server_root_key.as_bytes(), &root_evil, "start partitioned on the attacker's root");
4402
4403 let out = catch_up_server_root(&relay, &community).await.unwrap();
4404 assert_eq!(out.epoch, 1);
4405 assert!(!out.removed);
4406 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
4407 assert_eq!(after.server_root_key.as_bytes(), &root_owner,
4408 "heal must abandon the deauthorized root and adopt the owner's higher sibling");
4409
4410 let _ = catch_up_server_root(&relay, &after).await.unwrap();
4412 assert_eq!(crate::db::community::load_community(&community.id).unwrap().unwrap().server_root_key.as_bytes(), &root_owner, "no flap back to the banned root");
4413 }
4414
4415 #[tokio::test]
4416 async fn concurrent_channel_rekeyers_converge_to_the_lowest_key() {
4417 let (_tmp, _guard) = init_test_db();
4422 let owner = Keys::generate();
4423 let me = Keys::generate();
4424 become_local(&me); let community = saved_community_owned_by(&owner);
4426 let cid = community.id.to_hex();
4427 let channel_id = community.channels[0].id;
4428 let chan_hex = channel_id.to_hex();
4429 let scope = super::super::derive::RekeyScope::Channel(channel_id);
4430 let genesis_key = *community.channels[0].key.as_bytes();
4431 let root = *community.server_root_key.as_bytes();
4432 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
4433
4434 let key_lo = [0x10u8; 32];
4436 let key_hi = [0x99u8; 32];
4437 let blob_lo = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_lo).unwrap();
4438 let blob_hi = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_hi).unwrap();
4439 let ev_lo = super::super::rekey::build_channel_rekey_event(
4440 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_lo]).unwrap();
4441 let ev_hi = super::super::rekey::build_channel_rekey_event(
4442 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_hi]).unwrap();
4443
4444 let relay = MemoryRelay::new();
4445 relay.inject(&ev_hi, &community.relays); relay.inject(&ev_lo, &community.relays);
4447
4448 crate::db::community::advance_server_root_epoch(&cid, 1, &[0x42u8; 32]).unwrap();
4453 crate::db::community::advance_channel_epoch(&cid, &chan_hex, 1, &key_hi).unwrap();
4455 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4456
4457 let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4458 assert_eq!(reached, 1, "converged in place at the same channel epoch");
4459 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(key_lo),
4460 "adopted the lowest delivered key regardless of relay order");
4461
4462 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
4464 let _ = catch_up_channel_rekeys(&relay, &after, &channel_id).await.unwrap();
4465 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(key_lo), "no flip-flop");
4466 }
4467
4468 #[tokio::test]
4469 async fn concurrent_channel_rekeyers_converge_when_i_authored_the_losing_fork() {
4470 let (_tmp, _guard) = init_test_db();
4475 let owner = Keys::generate();
4476 let me = Keys::generate(); become_local(&me);
4478 let community = saved_community_owned_by(&owner);
4479 let cid = community.id.to_hex();
4480 let channel_id = community.channels[0].id;
4481 let chan_hex = channel_id.to_hex();
4482 let scope = super::super::derive::RekeyScope::Channel(channel_id);
4483 let genesis_key = *community.channels[0].key.as_bytes();
4484 let root = *community.server_root_key.as_bytes();
4485 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
4486
4487 let role_id = "d".repeat(64);
4491 let roster = crate::community::roles::CommunityRoles {
4492 roles: vec![crate::community::roles::Role::admin(role_id.clone())],
4493 grants: vec![crate::community::roles::MemberGrant { member: me.public_key().to_hex(), role_ids: vec![role_id] }],
4494 };
4495 crate::db::community::set_community_roles(&cid, &roster, 1).unwrap();
4496
4497 let key_lo = [0x10u8; 32]; let key_hi = [0x99u8; 32]; let blob_lo = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_lo).unwrap();
4501 let ev_lo = super::super::rekey::build_channel_rekey_event(
4502 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_lo]).unwrap();
4503 let blob_hi = super::super::rekey::build_rekey_blob(me.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_hi).unwrap();
4505 let ev_hi = super::super::rekey::build_channel_rekey_event(
4506 &Keys::generate(), &me, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_hi]).unwrap();
4507
4508 let relay = MemoryRelay::new();
4509 relay.inject(&ev_hi, &community.relays);
4510 relay.inject(&ev_lo, &community.relays);
4511
4512 crate::db::community::advance_server_root_epoch(&cid, 1, &[0x42u8; 32]).unwrap();
4514 crate::db::community::advance_channel_epoch(&cid, &chan_hex, 1, &key_hi).unwrap();
4516 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4517
4518 let _ = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4519 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(key_lo),
4520 "I authored the losing fork but must converge DOWN to the owner's lower key");
4521 }
4522
4523 #[tokio::test]
4524 async fn reorg_through_a_fork_heals_the_forked_past_epoch() {
4525 let (_tmp, _guard) = init_test_db();
4530 let owner = Keys::generate();
4531 let me = Keys::generate();
4532 become_local(&me);
4533 let community = saved_community_owned_by(&owner);
4534 let cid = community.id.to_hex();
4535 let channel_id = community.channels[0].id;
4536 let chan_hex = channel_id.to_hex();
4537 let scope = super::super::derive::RekeyScope::Channel(channel_id);
4538 let genesis_key = *community.channels[0].key.as_bytes();
4539 let root = *community.server_root_key.as_bytes();
4540 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
4541
4542 let key_lo1 = [0x10u8; 32]; let key_hi1 = [0x99u8; 32]; let key_e2 = [0x20u8; 32]; let blob_lo1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_lo1).unwrap();
4547 let blob_hi1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_hi1).unwrap();
4548 let ev_lo1 = super::super::rekey::build_channel_rekey_event(
4549 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_lo1]).unwrap();
4550 let ev_hi1 = super::super::rekey::build_channel_rekey_event(
4551 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_hi1]).unwrap();
4552 let commit1_win = super::super::rekey::epoch_key_commitment(crate::community::Epoch(1), &key_lo1);
4554 let blob_e2 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(2), &key_e2).unwrap();
4555 let ev_e2 = super::super::rekey::build_channel_rekey_event(
4556 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(2), crate::community::Epoch(1), &commit1_win, &[blob_e2]).unwrap();
4557
4558 let relay = MemoryRelay::new();
4559 relay.inject(&ev_lo1, &community.relays);
4560 relay.inject(&ev_hi1, &community.relays);
4561 relay.inject(&ev_e2, &community.relays);
4562
4563 crate::db::community::advance_server_root_epoch(&cid, 1, &[0x42u8; 32]).unwrap();
4565 crate::db::community::advance_channel_epoch(&cid, &chan_hex, 1, &key_hi1).unwrap();
4567 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4568
4569 let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4570 assert_eq!(reached, 2, "reorged forward to the head epoch");
4571 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 2).unwrap(), Some(key_e2), "head epoch adopted");
4572 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(key_lo1),
4573 "the FORKED past epoch re-converged to the lowest sibling (its messages become readable)");
4574 }
4575
4576 #[tokio::test]
4577 async fn window_heal_converges_an_already_reorged_past_fork() {
4578 let (_tmp, _guard) = init_test_db();
4583 let owner = Keys::generate();
4584 let me = Keys::generate();
4585 become_local(&me);
4586 let community = saved_community_owned_by(&owner);
4587 let cid = community.id.to_hex();
4588 let channel_id = community.channels[0].id;
4589 let chan_hex = channel_id.to_hex();
4590 let scope = super::super::derive::RekeyScope::Channel(channel_id);
4591 let genesis_key = *community.channels[0].key.as_bytes();
4592 let root = *community.server_root_key.as_bytes();
4593 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
4594
4595 let key_lo1 = [0x10u8; 32]; let key_hi1 = [0x99u8; 32]; let key_e2 = [0x20u8; 32]; let blob_lo1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_lo1).unwrap();
4599 let blob_hi1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_hi1).unwrap();
4600 let ev_lo1 = super::super::rekey::build_channel_rekey_event(
4601 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_lo1]).unwrap();
4602 let ev_hi1 = super::super::rekey::build_channel_rekey_event(
4603 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_hi1]).unwrap();
4604
4605 let relay = MemoryRelay::new();
4606 relay.inject(&ev_lo1, &community.relays);
4607 relay.inject(&ev_hi1, &community.relays);
4608 crate::db::community::advance_server_root_epoch(&cid, 1, &[0x42u8; 32]).unwrap();
4612 crate::db::community::advance_channel_epoch(&cid, &chan_hex, 1, &key_hi1).unwrap();
4614 crate::db::community::advance_channel_epoch(&cid, &chan_hex, 2, &key_e2).unwrap();
4615 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4616
4617 let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4618 assert_eq!(reached, 2, "head unchanged (no new rekey to apply)");
4619 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 2).unwrap(), Some(key_e2), "head epoch untouched");
4620 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(key_lo1),
4621 "the already-forked past epoch re-converged to the lowest sibling via the window heal (no in-sync reorg)");
4622 }
4623
4624 #[tokio::test]
4625 async fn channel_heal_cannot_converge_to_a_key_i_was_not_given() {
4626 let (_tmp, _guard) = init_test_db();
4633 let owner = Keys::generate();
4634 let me = Keys::generate();
4635 become_local(&me);
4636 let community = saved_community_owned_by(&owner);
4637 let cid = community.id.to_hex();
4638 let channel_id = community.channels[0].id;
4639 let chan_hex = channel_id.to_hex();
4640 let scope = super::super::derive::RekeyScope::Channel(channel_id);
4641 let genesis_key = *community.channels[0].key.as_bytes();
4642 let root = *community.server_root_key.as_bytes();
4643 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
4644
4645 let key_lo = [0x10u8; 32]; let key_hi = [0x99u8; 32]; let other = Keys::generate();
4649 let blob_lo = super::super::rekey::build_rekey_blob(owner.secret_key(), &other.public_key(), scope, crate::community::Epoch(1), &key_lo).unwrap();
4650 let ev_lo = super::super::rekey::build_channel_rekey_event(
4651 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_lo]).unwrap();
4652 let blob_hi = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_hi).unwrap();
4654 let ev_hi = super::super::rekey::build_channel_rekey_event(
4655 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_hi]).unwrap();
4656
4657 let relay = MemoryRelay::new();
4658 relay.inject(&ev_lo, &community.relays);
4659 relay.inject(&ev_hi, &community.relays);
4660 crate::db::community::advance_server_root_epoch(&cid, 1, &[0x42u8; 32]).unwrap();
4661 crate::db::community::advance_channel_epoch(&cid, &chan_hex, 1, &key_hi).unwrap();
4662 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4663
4664 let _ = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4665 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(key_hi),
4667 "excluded from the winning rekey ⇒ cannot converge");
4668 }
4669
4670 #[tokio::test]
4671 async fn refounding_channel_rekey_is_sealed_under_the_prior_root() {
4672 let (_tmp, _guard) = init_test_db();
4677 let owner = Keys::generate();
4678 become_local(&owner); let community = saved_community_owned_by(&owner);
4680 let channel_id = community.channels[0].id;
4681 let prior_root = [0x11u8; 32]; let relay = MemoryRelay::new();
4684 rotate_channel(&relay, &community, &channel_id, &[owner.public_key()], &prior_root).await.unwrap();
4685
4686 let z = super::super::derive::rekey_pseudonym(&crate::community::ServerRootKey(prior_root), &channel_id, crate::community::Epoch(1)).to_hex();
4688 let q = Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![z], ..Default::default() };
4689 let evs = relay.fetch(&q, &community.relays).await.unwrap();
4690 assert_eq!(evs.len(), 1, "channel rekey is addressed at the PRIOR-root pseudonym");
4691 assert!(super::super::rekey::open_rekey_event(&evs[0], &prior_root).is_ok(),
4693 "opens under the prior (shared) root every retained member still holds");
4694 assert!(super::super::rekey::open_rekey_event(&evs[0], community.server_root_key.as_bytes()).is_err(),
4695 "does NOT open under the current/new root (which a base-fork loser would have dropped)");
4696 }
4697
4698 #[tokio::test]
4699 async fn apply_channel_rekey_converges_past_a_divergent_prior_epoch() {
4700 let (_tmp, _guard) = init_test_db();
4705 let owner = Keys::generate();
4706 let me = Keys::generate();
4707 become_local(&me);
4708 let community = saved_community_owned_by(&owner);
4709 let cid = community.id.to_hex();
4710 let channel_id = community.channels[0].id;
4711 let chan_hex = channel_id.to_hex();
4712 let scope = super::super::derive::RekeyScope::Channel(channel_id);
4713 let root = *community.server_root_key.as_bytes();
4714
4715 let my_fork_key = [0xAAu8; 32];
4717 crate::db::community::advance_channel_epoch(&cid, &chan_hex, 1, &my_fork_key).unwrap();
4718
4719 let winner_epoch1 = [0xBBu8; 32];
4721 let new_key = [0x22u8; 32];
4722 let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(1), &winner_epoch1);
4723 let blob = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(2), &new_key).unwrap();
4724 let ev = super::super::rekey::build_channel_rekey_event(
4725 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(2), crate::community::Epoch(1), &commit, &[blob]).unwrap();
4726 let parsed = super::super::rekey::open_rekey_event(&ev, &root).unwrap();
4727
4728 let outcome = apply_channel_rekey(&community, &parsed).unwrap();
4729 assert!(matches!(outcome, RekeyOutcome::Applied { head_advanced: true }),
4730 "must converge forward past the divergent prior epoch, got {outcome:?}");
4731 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 2).unwrap(), Some(new_key),
4732 "adopted the winner's epoch-2 key");
4733 }
4734
4735 #[tokio::test]
4736 async fn catch_up_server_root_stops_when_removed_from_base() {
4737 let (_tmp, _guard) = init_test_db();
4740 let owner = Keys::generate();
4741 let me = Keys::generate();
4742 become_local(&me);
4743 let community = saved_community_owned_by(&owner);
4744 let scope = super::super::derive::RekeyScope::ServerRoot;
4745 let relay = MemoryRelay::new();
4746
4747 let root1 = [0x11u8; 32];
4749 let b1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &root1).unwrap();
4750 let e1 = super::super::rekey::build_server_root_rekey_event(
4751 &Keys::generate(), &owner, community.server_root_key.as_bytes(), &community.id,
4752 crate::community::Epoch(1), crate::community::Epoch(0),
4753 &super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), community.server_root_key.as_bytes()), &[b1],
4754 ).unwrap();
4755 let other = Keys::generate();
4757 let b2 = super::super::rekey::build_rekey_blob(owner.secret_key(), &other.public_key(), scope, crate::community::Epoch(2), &[0x22u8; 32]).unwrap();
4758 let e2 = super::super::rekey::build_server_root_rekey_event(
4759 &Keys::generate(), &owner, &root1, &community.id,
4760 crate::community::Epoch(2), crate::community::Epoch(1),
4761 &super::super::rekey::epoch_key_commitment(crate::community::Epoch(1), &root1), &[b2],
4762 ).unwrap();
4763 relay.inject(&e1, &community.relays);
4764 relay.inject(&e2, &community.relays);
4765
4766 let reached = catch_up_server_root(&relay, &community).await.unwrap();
4767 assert_eq!(reached.epoch, 1, "stops at the last base epoch I was a recipient of");
4768 assert!(reached.removed, "excluded by an AUTHORIZED (owner) base rotation → flagged removed so the caller erases");
4769 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4770 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(1));
4771 }
4772
4773 #[tokio::test]
4774 async fn catch_up_is_a_noop_with_no_rotations() {
4775 let (_tmp, _guard) = init_test_db();
4776 let owner = Keys::generate();
4777 let me = Keys::generate();
4778 become_local(&me);
4779 let community = saved_community_owned_by(&owner);
4780 let relay = MemoryRelay::new(); let reached = catch_up_channel_rekeys(&relay, &community, &community.channels[0].id).await.unwrap();
4782 assert_eq!(reached, 0, "no rotations → stays at the held epoch");
4783 }
4784
4785 #[tokio::test]
4786 async fn catch_up_stops_when_removed_midway() {
4787 let (_tmp, _guard) = init_test_db();
4790 let owner = Keys::generate();
4791 let me = Keys::generate();
4792 become_local(&me);
4793 let community = saved_community_owned_by(&owner);
4794 let channel_id = community.channels[0].id;
4795 let chan = &community.channels[0];
4796 let scope = super::super::derive::RekeyScope::Channel(channel_id);
4797 let relay = MemoryRelay::new();
4798
4799 let k1 = [0x11u8; 32];
4801 let b1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &k1).unwrap();
4802 let e1 = super::super::rekey::build_channel_rekey_event(
4803 &Keys::generate(), &owner, community.server_root_key.as_bytes(), &channel_id,
4804 crate::community::Epoch(1), crate::community::Epoch(0),
4805 &super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), chan.key.as_bytes()), &[b1],
4806 ).unwrap();
4807 let other = Keys::generate();
4809 let b2 = super::super::rekey::build_rekey_blob(owner.secret_key(), &other.public_key(), scope, crate::community::Epoch(2), &[0x22u8; 32]).unwrap();
4810 let e2 = super::super::rekey::build_channel_rekey_event(
4811 &Keys::generate(), &owner, community.server_root_key.as_bytes(), &channel_id,
4812 crate::community::Epoch(2), crate::community::Epoch(1),
4813 &super::super::rekey::epoch_key_commitment(crate::community::Epoch(1), &k1), &[b2],
4814 ).unwrap();
4815 relay.inject(&e1, &community.relays);
4816 relay.inject(&e2, &community.relays);
4817
4818 let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4819 assert_eq!(reached, 1, "stops at the last epoch I was a recipient of");
4820 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4821 assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(1));
4822 }
4823
4824 #[tokio::test]
4825 async fn rotate_channel_rejects_unauthorized() {
4826 let (_tmp, _guard) = init_test_db();
4827 let owner = Keys::generate();
4828 let rogue = Keys::generate();
4829 become_local(&rogue); let community = saved_community_owned_by(&owner);
4831 let relay = MemoryRelay::new();
4832 assert!(
4833 rotate_channel(&relay, &community, &community.channels[0].id, &[], community.server_root_key.as_bytes()).await.is_err(),
4834 "a non-authorized member cannot rotate"
4835 );
4836 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4838 assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(0));
4839 }
4840
4841 #[tokio::test]
4844 async fn rotate_server_root_publishes_recoverable_rekey_and_advances_base() {
4845 use crate::community::derive::{base_rekey_pseudonym, recipient_pseudonym};
4846 use crate::community::rekey::{open_rekey_blob, open_rekey_event, rekey_pairwise_secret};
4847 let (_tmp, _guard) = init_test_db();
4848 let owner = Keys::generate();
4849 become_local(&owner); let community = saved_community_owned_by(&owner);
4851 let genesis_root = *community.server_root_key.as_bytes();
4852 let member = Keys::generate();
4853 let relay = MemoryRelay::new();
4854
4855 let new_epoch = rotate_server_root(&relay, &community, &[member.public_key()]).await.expect("rotate base");
4856 assert_eq!(new_epoch, 1);
4857
4858 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4860 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(1));
4861 assert_ne!(reloaded.server_root_key.as_bytes(), &genesis_root, "base root is fresh-random, not the genesis");
4862
4863 let addr = base_rekey_pseudonym(&crate::community::ServerRootKey(genesis_root), &community.id, crate::community::Epoch(1)).to_hex();
4865 let found = relay
4866 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() }, &community.relays)
4867 .await
4868 .unwrap();
4869 assert_eq!(found.len(), 1, "base rekey addressable by its prior-root pseudonym");
4870 let parsed = open_rekey_event(&found[0], &genesis_root).unwrap();
4871 assert!(matches!(parsed.scope, crate::community::derive::RekeyScope::ServerRoot));
4872 assert_eq!(parsed.rotator, owner.public_key());
4873 assert_eq!(parsed.blobs.len(), 2, "member + me (multi-device)");
4874
4875 let secret = rekey_pairwise_secret(member.secret_key(), &parsed.rotator).unwrap();
4877 let loc = recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
4878 let mine = parsed.blobs.iter().find(|b| b.locator == loc).expect("member's blob present");
4879 let recovered = open_rekey_blob(member.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine).unwrap();
4880 assert_eq!(reloaded.server_root_key.as_bytes(), &recovered, "member's recovered root == owner's advanced base head");
4881 }
4882
4883 #[tokio::test]
4884 async fn rotate_server_root_failed_publish_leaves_base_unadvanced() {
4885 let (_tmp, _guard) = init_test_db();
4886 let owner = Keys::generate();
4887 become_local(&owner);
4888 let community = saved_community_owned_by(&owner);
4889 let member = Keys::generate();
4890 assert!(rotate_server_root(&FailingRelay, &community, &[member.public_key()]).await.is_err());
4891 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4892 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(0), "base head stays put on publish failure");
4893 }
4894
4895 #[tokio::test]
4896 async fn rotate_server_root_dedups_self_in_recipients() {
4897 use crate::community::rekey::open_rekey_event;
4899 let (_tmp, _guard) = init_test_db();
4900 let owner = Keys::generate();
4901 become_local(&owner);
4902 let community = saved_community_owned_by(&owner);
4903 let relay = MemoryRelay::new();
4904 rotate_server_root(&relay, &community, &[owner.public_key()]).await.unwrap();
4905 let addr = crate::community::derive::base_rekey_pseudonym(
4906 &crate::community::ServerRootKey(*community.server_root_key.as_bytes()), &community.id, crate::community::Epoch(1),
4907 )
4908 .to_hex();
4909 let found = relay
4910 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() }, &community.relays)
4911 .await
4912 .unwrap();
4913 let parsed = open_rekey_event(&found[0], community.server_root_key.as_bytes()).unwrap();
4914 assert_eq!(parsed.blobs.len(), 1, "self listed in recipients yields exactly one blob, not two");
4915 }
4916
4917 #[tokio::test]
4918 async fn rotate_server_root_rejects_unauthorized() {
4919 let (_tmp, _guard) = init_test_db();
4920 let owner = Keys::generate();
4921 let rogue = Keys::generate();
4922 become_local(&rogue); let community = saved_community_owned_by(&owner);
4924 let relay = MemoryRelay::new();
4925 assert!(rotate_server_root(&relay, &community, &[]).await.is_err(), "a non-BAN member cannot rotate the base");
4926 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4927 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(0));
4928 }
4929
4930 #[tokio::test]
4931 async fn rotate_server_root_reanchors_the_control_plane_to_the_new_epoch() {
4932 let (_tmp, _guard) = init_test_db();
4935 let relay = MemoryRelay::new();
4936 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
4939 let cid = community.id.to_hex();
4940 assert_eq!(crate::db::community::edition_head_entity_ids(&cid).unwrap().len(), 3);
4941
4942 let member = Keys::generate();
4943 assert_eq!(rotate_server_root(&relay, &community, &[member.public_key()]).await.unwrap(), 1);
4944 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4945 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(1), "base head advanced");
4946
4947 let z = crate::community::roster::control_pseudonym(&reloaded.server_root_key, &community.id, crate::community::Epoch(1));
4949 let evs = relay
4950 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], ..Default::default() }, &community.relays)
4951 .await
4952 .unwrap();
4953 let inners: Vec<_> = evs
4954 .iter()
4955 .filter_map(|o| crate::community::roster::open_control_edition(o, &reloaded.server_root_key).ok())
4956 .collect();
4957 let folded = crate::community::roster::fold_roster(&inners, &community.id, &Default::default());
4958 assert!(!folded.roles.roles.is_empty(), "control plane re-anchored at the new epoch as part of the rotation");
4959 }
4960
4961 #[tokio::test]
4962 async fn admin_refounding_carries_heads_verbatim_preserving_owner_and_peer_roles() {
4963 use crate::community::roles::Permissions;
4967 let (_tmp, _guard) = init_test_db();
4968 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
4969 let owner_hex = owner.public_key().to_hex();
4970 let relay = MemoryRelay::new();
4971 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
4972 let cid = community.id.to_hex();
4973 let admin_role = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
4974
4975 let alice = Keys::generate();
4977 let bob = Keys::generate();
4978 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role.clone()]).await.unwrap();
4979 set_member_grant(&relay, &community, &bob.public_key().to_hex(), vec![admin_role.clone()]).await.unwrap();
4980 let _ = fetch_and_apply_control(&relay, &community).await;
4981 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4982
4983 let mut edited = community.clone();
4986 edited.name = "HQ renamed".into();
4987 republish_community_metadata(&relay, &edited).await.unwrap();
4988 let _ = fetch_and_apply_control(&relay, &community).await;
4989 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4990 assert!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0 >= 2, "GroupRoot now above v1");
4991
4992 become_local(&alice);
4994 let new_epoch = rotate_server_root(&relay, &community, &[owner.public_key(), bob.public_key()]).await.unwrap();
4995 assert_eq!(new_epoch, 1);
4996 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4997 assert_eq!(community.server_root_epoch, crate::community::Epoch(1));
4998
4999 let z = crate::community::roster::control_pseudonym(&community.server_root_key, &community.id, crate::community::Epoch(1));
5001 let evs = relay.fetch(&Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], ..Default::default() }, &community.relays).await.unwrap();
5002 let inners: Vec<_> = evs.iter().filter_map(|o| crate::community::roster::open_control_edition(o, &community.server_root_key).ok()).collect();
5003 let folded = crate::community::roster::fold_roster(&inners, &community.id, &Default::default());
5004 let authed = crate::community::roster::authorize_delegation(&folded, Some(&owner_hex));
5005 assert!(authed.is_authorized(&alice.public_key().to_hex(), Some(&owner_hex), Permissions::BAN), "alice (re-founder) still admin");
5006 assert!(authed.is_authorized(&bob.public_key().to_hex(), Some(&owner_hex), Permissions::BAN), "bob (peer admin) NOT demoted by alice's re-founding");
5007 let new_owner = folded.root_meta.as_ref().and_then(|m| m.owner_attestation.as_ref())
5008 .and_then(|j| Event::from_json(j).ok()).map(|e| e.pubkey.to_hex());
5009 assert_eq!(new_owner.as_deref(), Some(owner_hex.as_str()), "owner deed carried verbatim — ownership intact after an admin re-founding");
5010 assert_eq!(folded.root_meta.as_ref().map(|m| m.name.as_str()), Some("HQ renamed"),
5011 "the >v1 GroupRoot head carried verbatim (content preserved across the re-founding)");
5012 let mut per_entity: std::collections::HashMap<[u8; 32], usize> = std::collections::HashMap::new();
5014 for i in &inners {
5015 if let Ok(p) = crate::community::edition::parse_edition_inner(i) { *per_entity.entry(p.entity_id).or_default() += 1; }
5016 }
5017 assert!(per_entity.values().all(|&c| c == 1), "one edition per entity at the new epoch (compacted)");
5018 }
5019
5020 #[tokio::test]
5024 async fn admin_write_blocked_when_isolated() {
5025 let (_tmp, _guard) = init_test_db();
5026 let me = Keys::generate();
5027 become_local(&me);
5028 let community = saved_community_owned_by(&me);
5029 let cid = community.id.to_hex();
5030 crate::db::community::set_edition_head_with_id(&cid, &cid, 1, &[1u8; 32], &[1u8; 32]).unwrap();
5032 crate::db::community::set_read_cut_target_epoch(&cid, 1).unwrap();
5033 let err = reseal_base_to_observed(&FailingRelay, &community).await.unwrap_err();
5035 assert!(err.contains("offline") || err.contains("can't reach any relay"),
5036 "isolated admin write must fail closed, got: {err}");
5037 assert_eq!(crate::db::community::load_community(&community.id).unwrap().unwrap().server_root_epoch,
5039 crate::community::Epoch(0), "no rotation while isolated");
5040 }
5041
5042 #[tokio::test]
5045 async fn refounding_rotates_channel_keys_too() {
5046 let (_tmp, _guard) = init_test_db();
5047 let relay = MemoryRelay::new();
5048 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5049 let channel_id = community.channels[0].id;
5050 assert_eq!(community.channels[0].epoch, crate::community::Epoch(0));
5051 assert_eq!(community.server_root_epoch, crate::community::Epoch(0));
5052
5053 run_read_cut(&relay, &community, true).await.unwrap();
5054
5055 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
5056 assert_eq!(after.server_root_epoch, crate::community::Epoch(1), "base rotated");
5057 let ch = after.channels.iter().find(|c| c.id == channel_id).unwrap();
5058 assert_eq!(ch.epoch, crate::community::Epoch(1), "channel key rotated too (O2)");
5059 assert_eq!(crate::db::community::channel_rekeyed_at_server_epoch(&community.id.to_hex(), &channel_id.to_hex()).unwrap(),
5060 1, "channel marked rekeyed for the new base epoch");
5061 assert!(!crate::db::community::get_read_cut_pending(&community.id.to_hex()).unwrap(),
5062 "a complete read-cut clears the pending flag");
5063 }
5064
5065 #[tokio::test]
5070 async fn read_cut_resumes_without_double_base_rotation_after_channel_failure() {
5071 struct ChannelRekeyFails {
5075 inner: MemoryRelay,
5076 rekeys: std::sync::atomic::AtomicUsize,
5077 fail_channel: std::sync::atomic::AtomicBool,
5078 }
5079 #[async_trait::async_trait]
5080 impl Transport for ChannelRekeyFails {
5081 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
5082 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> { self.inner.publish(e, r).await }
5083 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
5084 if e.kind.as_u16() == event_kind::COMMUNITY_REKEY {
5085 let n = self.rekeys.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5086 if n >= 1 && self.fail_channel.load(std::sync::atomic::Ordering::Relaxed) {
5087 return Err("channel rekey relay down".into());
5088 }
5089 }
5090 self.inner.publish_durable(e, r).await
5091 }
5092 async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> { self.inner.fetch(q, r).await }
5093 }
5094 let (_tmp, _guard) = init_test_db();
5095 let relay = ChannelRekeyFails {
5096 inner: MemoryRelay::new(),
5097 rekeys: std::sync::atomic::AtomicUsize::new(0),
5098 fail_channel: std::sync::atomic::AtomicBool::new(true),
5099 };
5100 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5101 let channel_id = community.channels[0].id;
5102 let cid = community.id.to_hex();
5103 let ch_hex = channel_id.to_hex();
5104
5105 assert!(run_read_cut(&relay, &community, true).await.is_err(), "the channel failure surfaces an error");
5107 let mid = crate::db::community::load_community(&community.id).unwrap().unwrap();
5108 assert_eq!(mid.server_root_epoch, crate::community::Epoch(1), "base advanced exactly once");
5109 assert_eq!(mid.channels.iter().find(|c| c.id == channel_id).unwrap().epoch, crate::community::Epoch(0),
5110 "channel NOT rotated (its rekey failed)");
5111 assert!(crate::db::community::get_read_cut_pending(&cid).unwrap(), "cut left pending after the failure");
5112 assert_eq!(crate::db::community::get_read_cut_target_epoch(&cid).unwrap(), 1, "target recorded durably");
5113 assert_eq!(crate::db::community::channel_rekeyed_at_server_epoch(&cid, &ch_hex).unwrap(), 0,
5114 "channel not yet marked for this cut");
5115
5116 relay.fail_channel.store(false, std::sync::atomic::Ordering::Relaxed);
5118 retry_pending_read_cut(&relay, &mid).await.unwrap();
5119 let done = crate::db::community::load_community(&community.id).unwrap().unwrap();
5120 assert_eq!(done.server_root_epoch, crate::community::Epoch(1),
5121 "base NOT rotated again — resumed at the same epoch (no double base rotation)");
5122 assert_eq!(done.channels.iter().find(|c| c.id == channel_id).unwrap().epoch, crate::community::Epoch(1),
5123 "the un-rotated channel finished on resume");
5124 assert_eq!(crate::db::community::channel_rekeyed_at_server_epoch(&cid, &ch_hex).unwrap(), 1,
5125 "channel marked rekeyed for the cut epoch");
5126 assert!(!crate::db::community::get_read_cut_pending(&cid).unwrap(), "pending cleared after the resume completes");
5127 }
5128
5129 #[tokio::test]
5130 async fn rotate_server_root_aborts_when_the_snapshot_does_not_land() {
5131 struct ControlPublishFails { inner: MemoryRelay, fail: std::sync::atomic::AtomicBool }
5136 #[async_trait::async_trait]
5137 impl Transport for ControlPublishFails {
5138 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
5139 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> { self.inner.publish(e, r).await }
5140 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
5141 if self.fail.load(std::sync::atomic::Ordering::Relaxed) && e.kind.as_u16() == event_kind::COMMUNITY_CONTROL {
5142 return Err("control relay down".into());
5143 }
5144 self.inner.publish_durable(e, r).await
5145 }
5146 async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> { self.inner.fetch(q, r).await }
5147 }
5148 let (_tmp, _guard) = init_test_db();
5149 let relay = ControlPublishFails { inner: MemoryRelay::new(), fail: std::sync::atomic::AtomicBool::new(false) };
5150 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5152 relay.fail.store(true, std::sync::atomic::Ordering::Relaxed);
5153
5154 assert!(
5155 rotate_server_root(&relay, &community, &[]).await.is_err(),
5156 "a snapshot whose editions can't be re-published must abort the rotation"
5157 );
5158 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5159 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(0), "base head NOT advanced when the snapshot doesn't land");
5160 }
5161
5162 #[tokio::test]
5163 async fn acquire_before_commit_a_reanchor_fetch_miss_publishes_no_base_rekey() {
5164 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
5169 struct ReanchorFetchEmpty { inner: MemoryRelay, drop_control: AtomicBool, base_rekeys: AtomicUsize }
5170 #[async_trait::async_trait]
5171 impl Transport for ReanchorFetchEmpty {
5172 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
5173 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> { self.inner.publish(e, r).await }
5174 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
5175 if e.kind.as_u16() == event_kind::COMMUNITY_REKEY {
5176 self.base_rekeys.fetch_add(1, Ordering::Relaxed);
5177 }
5178 self.inner.publish_durable(e, r).await
5179 }
5180 async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> {
5181 if self.drop_control.load(Ordering::Relaxed) && q.kinds.iter().any(|k| *k == event_kind::COMMUNITY_CONTROL) {
5182 return Ok(vec![]); }
5184 self.inner.fetch(q, r).await
5185 }
5186 }
5187 let (_tmp, _guard) = init_test_db();
5188 let relay = ReanchorFetchEmpty { inner: MemoryRelay::new(), drop_control: AtomicBool::new(false), base_rekeys: AtomicUsize::new(0) };
5189 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5190 relay.drop_control.store(true, Ordering::Relaxed);
5191
5192 assert!(rotate_server_root(&relay, &community, &[]).await.is_err(),
5193 "a re-anchor fetch miss must abort the rotation");
5194 assert_eq!(relay.base_rekeys.load(Ordering::Relaxed), 0,
5195 "the base rekey must NOT be published when the pre-publish fetch gate trips (acquire-before-commit)");
5196 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5197 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(0), "base head NOT advanced");
5198 }
5199
5200 #[tokio::test]
5203 async fn reanchor_carries_role_and_grant_to_the_new_epoch_under_the_new_root() {
5204 let (_tmp, _guard) = init_test_db();
5205 let relay = MemoryRelay::new();
5206 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5208 let cid = community.id.to_hex();
5209 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5210 let member = Keys::generate();
5211 set_member_grant(&relay, &community, &member.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
5213 let _ = fetch_and_apply_control(&relay, &community).await;
5214 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5215
5216 let new_root = [0x99u8; 32];
5218 let snap = reanchor_control_plane(&relay, &community, &new_root, crate::community::Epoch(1)).await.unwrap();
5219 assert!(snap.iter().all(|e| e.published), "every snapshot edition published");
5220 assert_eq!(snap.len(), 4, "GroupRoot + channel + Admin role + grant compacted to v1");
5221
5222 let new_z = crate::community::roster::control_pseudonym(
5224 &crate::community::ServerRootKey(new_root), &community.id, crate::community::Epoch(1),
5225 );
5226 let after = relay
5227 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![new_z], ..Default::default() }, &community.relays)
5228 .await
5229 .unwrap();
5230 let inners: Vec<_> = after
5231 .iter()
5232 .filter_map(|o| crate::community::roster::open_control_edition(o, &crate::community::ServerRootKey(new_root)).ok())
5233 .collect();
5234 let folded = crate::community::roster::fold_roster(&inners, &community.id, &Default::default());
5235 assert!(!folded.roles.roles.is_empty(), "Admin role reachable at the new epoch");
5236 assert!(
5237 folded.roles.grants.iter().any(|g| g.member == member.public_key().to_hex()),
5238 "grant carried to the new epoch under the new root"
5239 );
5240 }
5241
5242 #[tokio::test]
5243 async fn grant_after_a_rekey_survives_the_fold_at_the_new_epoch() {
5244 use crate::community::roles::Permissions;
5250 let (_tmp, _guard) = init_test_db();
5251 let relay = MemoryRelay::new();
5252 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5253 let cid = community.id.to_hex();
5254 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
5255 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5256
5257 rotate_server_root(&relay, &community, &[owner.public_key()]).await.expect("rotate base");
5259 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5260 assert_eq!(community.server_root_epoch, crate::community::Epoch(1), "advanced to the new epoch");
5261
5262 let alice = "aa".repeat(32);
5264 set_member_grant(&relay, &community, &alice, vec![admin_role_id]).await.unwrap();
5265
5266 let roster = fetch_and_apply_roles(&relay, &community).await.unwrap();
5268 assert!(
5269 roster.has_permission(&alice, Permissions::BAN),
5270 "post-rekey grant survives — Alice is Admin at the new epoch (pre-fix: dropped, role unreachable)"
5271 );
5272 assert_eq!(roster.highest_position(&alice), Some(1));
5273 }
5274
5275 #[tokio::test]
5279 async fn demote_re_asserts_the_demoted_members_metadata_head() {
5280 let (_tmp, _guard) = init_test_db();
5281 let relay = MemoryRelay::new();
5282 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5283 let cid = community.id.to_hex();
5284 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
5285 let admin_role = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5286 let alice = Keys::generate();
5287 let alice_hex = alice.public_key().to_hex();
5288
5289 set_member_grant(&relay, &community, &alice_hex, vec![admin_role]).await.unwrap();
5290 become_local(&alice);
5292 let mut as_alice = crate::db::community::load_community(&community.id).unwrap().unwrap();
5293 as_alice.name = "Alice's HQ".into();
5294 republish_community_metadata(&relay, &as_alice).await.unwrap();
5295 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5296 assert_eq!(
5297 fetch_control_folded(&relay, &community).await.unwrap().root_author.map(|a| a.to_hex()),
5298 Some(alice_hex.clone()), "alice heads the GroupRoot after her edit",
5299 );
5300
5301 become_local(&owner);
5303 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5304 set_member_grant(&relay, &community, &alice_hex, vec![]).await.unwrap();
5305
5306 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5307 let folded = fetch_control_folded(&relay, &community).await.unwrap();
5308 assert_eq!(folded.root_author.map(|a| a.to_hex()), Some(owner.public_key().to_hex()),
5309 "the demote re-asserted the GroupRoot under the owner");
5310 assert_eq!(folded.root_meta.as_ref().unwrap().name, "Alice's HQ",
5311 "the re-assert preserves the demoted member's content");
5312 }
5313
5314 #[tokio::test]
5317 async fn demote_skips_reassert_when_member_does_not_head() {
5318 let (_tmp, _guard) = init_test_db();
5319 let relay = MemoryRelay::new();
5320 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5321 let cid = community.id.to_hex();
5322 let admin_role = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5323 let alice = Keys::generate();
5324 let alice_hex = alice.public_key().to_hex();
5325
5326 set_member_grant(&relay, &community, &alice_hex, vec![admin_role]).await.unwrap();
5327 let mut c = crate::db::community::load_community(&community.id).unwrap().unwrap();
5329 c.name = "Owner's HQ".into();
5330 republish_community_metadata(&relay, &c).await.unwrap();
5331 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5332 let before = fetch_control_folded(&relay, &community).await.unwrap().root_head.unwrap().version;
5333
5334 set_member_grant(&relay, &community, &alice_hex, vec![]).await.unwrap();
5335 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5336 let after = fetch_control_folded(&relay, &community).await.unwrap().root_head.unwrap().version;
5337 assert_eq!(after, before, "no re-assert published — the demoted member didn't head the GroupRoot");
5338 }
5339
5340 #[tokio::test]
5341 async fn reanchor_carries_the_banlist_edition_to_the_new_epoch() {
5342 let (_tmp, _guard) = init_test_db();
5345 let relay = MemoryRelay::new();
5346 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5347 let carol = "cc".repeat(32);
5348 publish_banlist(&relay, &community, &[carol.clone()]).await.unwrap();
5350 let _ = fetch_and_apply_control(&relay, &community).await;
5351 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5352
5353 let new_root = [0x99u8; 32];
5355 let n = reanchor_control_plane(&relay, &community, &new_root, crate::community::Epoch(1)).await.unwrap();
5356 assert!(n.iter().all(|e| e.published), "every snapshot edition published");
5357 assert_eq!(n.len(), 4, "GroupRoot + channel + Admin role + banlist compacted to v1");
5358
5359 let new_z = crate::community::roster::control_pseudonym(
5361 &crate::community::ServerRootKey(new_root), &community.id, crate::community::Epoch(1),
5362 );
5363 let after = relay
5364 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![new_z], ..Default::default() }, &community.relays)
5365 .await
5366 .unwrap();
5367 let inners: Vec<_> = after
5368 .iter()
5369 .filter_map(|o| crate::community::roster::open_control_edition(o, &crate::community::ServerRootKey(new_root)).ok())
5370 .collect();
5371 let folded = crate::community::roster::fold_roster(&inners, &community.id, &Default::default());
5372 assert_eq!(folded.banned, vec![carol], "banlist reachable at the new epoch under the new root");
5373 }
5374
5375 fn owner_base_rekey(
5380 owner: &Keys, community: &Community, recipient_pk: &nostr_sdk::prelude::PublicKey, new_epoch: u64, new_root: &[u8; 32],
5381 ) -> super::super::rekey::ParsedRekey {
5382 let prev = community.server_root_epoch.0;
5383 let blob = super::super::rekey::build_rekey_blob(
5384 owner.secret_key(), recipient_pk, super::super::derive::RekeyScope::ServerRoot, crate::community::Epoch(new_epoch), new_root,
5385 )
5386 .unwrap();
5387 let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(prev), community.server_root_key.as_bytes());
5388 let outer = super::super::rekey::build_server_root_rekey_event(
5389 &Keys::generate(), owner, community.server_root_key.as_bytes(), &community.id,
5390 crate::community::Epoch(new_epoch), crate::community::Epoch(prev), &commit, &[blob],
5391 )
5392 .unwrap();
5393 super::super::rekey::open_rekey_event(&outer, community.server_root_key.as_bytes()).unwrap()
5394 }
5395
5396 #[test]
5397 fn apply_server_root_rekey_recovers_new_root_and_advances_base() {
5398 let (_tmp, _guard) = init_test_db();
5399 let owner = Keys::generate();
5400 let me = Keys::generate();
5401 become_local(&me);
5402 let community = saved_community_owned_by(&owner);
5403 let cid = community.id.to_hex();
5404 let new_root = [0xCDu8; 32];
5405
5406 let parsed = owner_base_rekey(&owner, &community, &me.public_key(), 1, &new_root);
5407 assert_eq!(apply_server_root_rekey(&community, &parsed).unwrap(), RekeyOutcome::Applied { head_advanced: true });
5408
5409 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5410 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(1));
5411 assert_eq!(reloaded.server_root_key.as_bytes(), &new_root, "base head advanced to the new root");
5412 assert!(crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 0).unwrap().is_some());
5414 assert_eq!(crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap(), Some(new_root));
5415 }
5416
5417 #[test]
5418 fn apply_server_root_rekey_not_a_recipient_leaves_base_unchanged() {
5419 let (_tmp, _guard) = init_test_db();
5420 let owner = Keys::generate();
5421 let me = Keys::generate();
5422 become_local(&me);
5423 let community = saved_community_owned_by(&owner);
5424 let other = Keys::generate(); let parsed = owner_base_rekey(&owner, &community, &other.public_key(), 1, &[0x11u8; 32]);
5426 assert_eq!(apply_server_root_rekey(&community, &parsed).unwrap(), RekeyOutcome::NotARecipient);
5427 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5428 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(0), "removed-from-base member's head unchanged");
5429 }
5430
5431 #[test]
5432 fn apply_server_root_rekey_rejects_rotator_without_ban() {
5433 let (_tmp, _guard) = init_test_db();
5434 let owner = Keys::generate();
5435 let me = Keys::generate();
5436 become_local(&me);
5437 let community = saved_community_owned_by(&owner);
5438 let rogue = Keys::generate();
5440 let parsed = owner_base_rekey(&rogue, &community, &me.public_key(), 1, &[0x22u8; 32]);
5441 assert!(apply_server_root_rekey(&community, &parsed).is_err(), "unauthorized base rotation rejected");
5442 }
5443
5444 #[test]
5445 fn apply_server_root_rekey_reorgs_onto_authorized_chain_despite_prior_mismatch() {
5446 let (_tmp, _guard) = init_test_db();
5451 let owner = Keys::generate();
5452 let me = Keys::generate();
5453 become_local(&me);
5454 let community = saved_community_owned_by(&owner);
5455 let blob = super::super::rekey::build_rekey_blob(
5456 owner.secret_key(), &me.public_key(), super::super::derive::RekeyScope::ServerRoot, crate::community::Epoch(1), &[0x33u8; 32],
5457 )
5458 .unwrap();
5459 let bad = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &[0xFFu8; 32]);
5461 let outer = super::super::rekey::build_server_root_rekey_event(
5462 &Keys::generate(), &owner, community.server_root_key.as_bytes(), &community.id,
5463 crate::community::Epoch(1), crate::community::Epoch(0), &bad, &[blob],
5464 )
5465 .unwrap();
5466 let parsed = super::super::rekey::open_rekey_event(&outer, community.server_root_key.as_bytes()).unwrap();
5467 let outcome = apply_server_root_rekey(&community, &parsed);
5468 assert!(
5469 matches!(outcome, Ok(RekeyOutcome::Applied { .. })),
5470 "an authorized base chain must be adopted (reorg), not rejected as foreign; got {outcome:?}"
5471 );
5472 }
5473
5474 #[test]
5475 fn apply_server_root_rekey_catchup_archives_without_regressing_base_head() {
5476 let (_tmp, _guard) = init_test_db();
5479 let owner = Keys::generate();
5480 let me = Keys::generate();
5481 become_local(&me);
5482 let community = saved_community_owned_by(&owner);
5483 let cid = community.id.to_hex();
5484
5485 let r5 = [0x55u8; 32];
5486 let p5 = owner_base_rekey(&owner, &community, &me.public_key(), 5, &r5);
5487 assert_eq!(apply_server_root_rekey(&community, &p5).unwrap(), RekeyOutcome::Applied { head_advanced: true });
5488 let r3 = [0x33u8; 32];
5489 let p3 = owner_base_rekey(&owner, &community, &me.public_key(), 3, &r3);
5490 assert_eq!(apply_server_root_rekey(&community, &p3).unwrap(), RekeyOutcome::Applied { head_advanced: false });
5491
5492 assert_eq!(crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 3).unwrap(), Some(r3));
5493 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5494 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(5), "base head stayed at newest");
5495 assert_eq!(reloaded.server_root_key.as_bytes(), &r5);
5496 }
5497
5498 #[test]
5499 fn apply_server_root_rekey_authorizes_a_granted_ban_admin() {
5500 let (_tmp, _guard) = init_test_db();
5504 let owner = Keys::generate();
5505 let me = Keys::generate();
5506 become_local(&me);
5507 let community = saved_community_owned_by(&owner);
5508 let cid = community.id.to_hex();
5509
5510 let admin = Keys::generate();
5511 let role_id = "d".repeat(64);
5512 let roster = crate::community::roles::CommunityRoles {
5513 roles: vec![crate::community::roles::Role::admin(role_id.clone())],
5514 grants: vec![crate::community::roles::MemberGrant { member: admin.public_key().to_hex(), role_ids: vec![role_id] }],
5515 };
5516 crate::db::community::set_community_roles(&cid, &roster, 1).unwrap();
5517
5518 let parsed = owner_base_rekey(&admin, &community, &me.public_key(), 1, &[0x77u8; 32]);
5519 assert_eq!(
5520 apply_server_root_rekey(&community, &parsed).unwrap(),
5521 RekeyOutcome::Applied { head_advanced: true },
5522 "a BAN-granted admin (not the owner) can rotate the base"
5523 );
5524 }
5525
5526 #[test]
5527 fn apply_server_root_rekey_accepts_when_prior_root_not_held() {
5528 let (_tmp, _guard) = init_test_db();
5531 let owner = Keys::generate();
5532 let me = Keys::generate();
5533 become_local(&me);
5534 let community = saved_community_owned_by(&owner);
5535
5536 let new_root = [0x99u8; 32];
5537 let blob = super::super::rekey::build_rekey_blob(
5538 owner.secret_key(), &me.public_key(), super::super::derive::RekeyScope::ServerRoot, crate::community::Epoch(5), &new_root,
5539 )
5540 .unwrap();
5541 let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(4), &[0xEEu8; 32]);
5543 let outer = super::super::rekey::build_server_root_rekey_event(
5544 &Keys::generate(), &owner, community.server_root_key.as_bytes(), &community.id,
5545 crate::community::Epoch(5), crate::community::Epoch(4), &commit, &[blob],
5546 )
5547 .unwrap();
5548 let parsed = super::super::rekey::open_rekey_event(&outer, community.server_root_key.as_bytes()).unwrap();
5549 assert_eq!(apply_server_root_rekey(&community, &parsed).unwrap(), RekeyOutcome::Applied { head_advanced: true });
5550 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5551 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(5));
5552 }
5553
5554 #[test]
5555 fn apply_server_root_rekey_rejects_channel_scope() {
5556 let (_tmp, _guard) = init_test_db();
5558 let owner = Keys::generate();
5559 let me = Keys::generate();
5560 become_local(&me);
5561 let community = saved_community_owned_by(&owner);
5562 let channel_parsed = owner_channel_rekey(&owner, &community, &me.public_key(), 1, &[0x44u8; 32]);
5563 assert!(apply_server_root_rekey(&community, &channel_parsed).is_err(), "channel scope rejected by base apply");
5564 }
5565
5566 #[test]
5567 fn apply_channel_rekey_not_a_recipient() {
5568 let (_tmp, _guard) = init_test_db();
5569 let owner = Keys::generate();
5570 let me = Keys::generate();
5571 become_local(&me);
5572 let community = saved_community_owned_by(&owner);
5573 let other = Keys::generate();
5575 let parsed = owner_channel_rekey(&owner, &community, &other.public_key(), 1, &[0x11u8; 32]);
5576 assert_eq!(apply_channel_rekey(&community, &parsed).unwrap(), RekeyOutcome::NotARecipient);
5577 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5579 assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(0));
5580 }
5581
5582 #[test]
5583 fn apply_channel_rekey_rejects_unauthorized_rotator() {
5584 let (_tmp, _guard) = init_test_db();
5585 let owner = Keys::generate();
5586 let me = Keys::generate();
5587 become_local(&me);
5588 let community = saved_community_owned_by(&owner);
5589 let rogue = Keys::generate();
5591 let parsed = owner_channel_rekey(&rogue, &community, &me.public_key(), 1, &[0x22u8; 32]);
5592 assert!(apply_channel_rekey(&community, &parsed).is_err(), "unauthorized rotation must be rejected");
5593 }
5594
5595 #[test]
5596 fn apply_channel_rekey_reorgs_onto_authorized_chain_despite_prior_mismatch() {
5597 let (_tmp, _guard) = init_test_db();
5604 let owner = Keys::generate();
5605 let me = Keys::generate();
5606 become_local(&me);
5607 let community = saved_community_owned_by(&owner);
5608 let chan = &community.channels[0];
5609 let scope = super::super::derive::RekeyScope::Channel(chan.id);
5610 let new_key = [0x33u8; 32];
5611 let blob = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &new_key).unwrap();
5612 let other_commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &[0xFFu8; 32]);
5614 let outer = super::super::rekey::build_channel_rekey_event(
5615 &Keys::generate(), &owner, community.server_root_key.as_bytes(), &chan.id,
5616 crate::community::Epoch(1), crate::community::Epoch(0), &other_commit, &[blob],
5617 )
5618 .unwrap();
5619 let parsed = super::super::rekey::open_rekey_event(&outer, community.server_root_key.as_bytes()).unwrap();
5620 let outcome = apply_channel_rekey(&community, &parsed).unwrap();
5621 assert!(matches!(outcome, RekeyOutcome::Applied { .. }),
5622 "an authorized chain must be adopted (reorg), not rejected as foreign; got {outcome:?}");
5623 assert_eq!(crate::db::community::held_epoch_key(&community.id.to_hex(), &chan.id.to_hex(), 1).unwrap(), Some(new_key));
5624 }
5625
5626 #[test]
5627 fn apply_channel_rekey_catchup_archives_without_regressing_head() {
5628 let (_tmp, _guard) = init_test_db();
5629 let owner = Keys::generate();
5630 let me = Keys::generate();
5631 become_local(&me);
5632 let community = saved_community_owned_by(&owner);
5633 let cid = community.id.to_hex();
5634 let chan_hex = community.channels[0].id.to_hex();
5635
5636 let k5 = [0x55u8; 32];
5638 let p5 = owner_channel_rekey(&owner, &community, &me.public_key(), 5, &k5);
5639 assert_eq!(apply_channel_rekey(&community, &p5).unwrap(), RekeyOutcome::Applied { head_advanced: true });
5640 let k3 = [0x33u8; 32];
5642 let p3 = owner_channel_rekey(&owner, &community, &me.public_key(), 3, &k3);
5643 assert_eq!(apply_channel_rekey(&community, &p3).unwrap(), RekeyOutcome::Applied { head_advanced: false });
5644
5645 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 3).unwrap(), Some(k3), "old epoch archived");
5646 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 5).unwrap(), Some(k5));
5647 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5648 assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(5), "head stayed at the newest epoch");
5649 assert_eq!(reloaded.channels[0].key.as_bytes(), &k5);
5650 }
5651
5652 #[tokio::test]
5653 async fn create_community_persists_and_publishes_metadata() {
5654 use crate::community::transport::Query;
5655 use crate::stored_event::event_kind;
5656
5657 let (_tmp, _guard) = init_test_db();
5658 let relay = MemoryRelay::new();
5659 let community = create_community(&relay, "Vector HQ", "general", vec!["r1".into()])
5660 .await
5661 .expect("create");
5662
5663 assert_eq!(community.name, "Vector HQ");
5665 assert_eq!(community.channels.len(), 1);
5666 assert_eq!(community.channels[0].name, "general");
5667
5668 let loaded = crate::db::community::load_community(&community.id).unwrap().expect("persisted");
5670 assert_eq!(loaded.channels[0].name, "general");
5671 assert_eq!(loaded.server_root_key.as_bytes(), community.server_root_key.as_bytes());
5672
5673 let meta_events = relay
5676 .fetch(
5677 &Query { kinds: vec![event_kind::APPLICATION_SPECIFIC], ..Default::default() },
5678 &community.relays,
5679 )
5680 .await
5681 .unwrap();
5682 assert!(meta_events.is_empty(), "no legacy 30078 metadata events");
5683
5684 let z = crate::community::roster::control_pseudonym(&community.server_root_key, &community.id, crate::community::Epoch(0));
5687 let control = relay
5688 .fetch(
5689 &Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], ..Default::default() },
5690 &community.relays,
5691 )
5692 .await
5693 .unwrap();
5694 assert_eq!(control.len(), 3, "GroupRoot + ChannelMetadata + Admin role editions");
5695 let owner_pk = crate::state::my_public_key().unwrap();
5696 let parsed: Vec<_> = control
5697 .iter()
5698 .filter_map(|o| crate::community::roster::open_control_edition(o, &community.server_root_key).ok())
5699 .filter_map(|i| crate::community::edition::parse_edition_inner(&i).ok())
5700 .collect();
5701 assert!(parsed.iter().all(|p| p.author == owner_pk), "every genesis edition authored by the owner");
5702 let root = parsed.iter().find(|p| p.entity_id == community.id.0).expect("GroupRoot edition");
5704 let root_meta: crate::community::metadata::CommunityMetadata = serde_json::from_str(&root.content).unwrap();
5705 assert_eq!(root_meta.name, "Vector HQ");
5706 assert!(root_meta.owner_attestation.is_some());
5707 let role: crate::community::roles::Role = parsed
5709 .iter()
5710 .find_map(|p| serde_json::from_str::<crate::community::roles::Role>(&p.content).ok().filter(|r| r.name == "Admin"))
5711 .expect("Admin role edition");
5712 assert_eq!(role.position, 1);
5713 assert!(role.permissions.contains(crate::community::roles::Permissions::ADMIN_ALL));
5714
5715 let cached = crate::db::community::get_community_roles(&community.id.to_hex()).unwrap();
5717 assert_eq!(cached.roles.len(), 1);
5718 assert!(cached.grants.is_empty(), "owner is implicit position 0, takes no grant");
5719 }
5720
5721 #[tokio::test]
5722 async fn role_grant_round_trips_through_relays_and_revokes() {
5723 use crate::community::roles::Permissions;
5724 let (_tmp, _guard) = init_test_db();
5725 let relay = MemoryRelay::new();
5726 let community = create_community(&relay, "HQ", "general", vec!["r1".into()])
5727 .await
5728 .expect("create");
5729 let cid = community.id.to_hex();
5730 let alice = "aa".repeat(32);
5731 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0]
5732 .role_id
5733 .clone();
5734
5735 set_member_grant(&relay, &community, &alice, vec![admin_role_id.clone()])
5737 .await
5738 .unwrap();
5739 assert!(
5740 crate::db::community::get_community_roles(&cid).unwrap().is_privileged(&alice),
5741 "local cache reflects the grant immediately"
5742 );
5743
5744 let roster = fetch_and_apply_roles(&relay, &community).await.unwrap();
5747 assert!(roster.has_permission(&alice, Permissions::BAN));
5748 assert!(roster.has_permission(&alice, Permissions::MANAGE_ROLES));
5749 assert_eq!(roster.roles.len(), 1);
5750 assert_eq!(roster.highest_position(&alice), Some(1));
5751
5752 set_member_grant(&relay, &community, &alice, vec![]).await.unwrap();
5754 let after = crate::db::community::get_community_roles(&cid).unwrap();
5755 assert!(!after.is_privileged(&alice), "revoked member holds no role");
5756 assert!(after.grants.is_empty(), "empty grant pruned");
5757 }
5758
5759 #[tokio::test]
5760 async fn admin_cannot_grant_a_peer_rank_role() {
5761 let (_tmp, _guard) = init_test_db();
5765 let relay = MemoryRelay::new();
5766 let community = create_community(&relay, "HQ", "general", vec!["r1".into()])
5767 .await
5768 .expect("create");
5769 let cid = community.id.to_hex();
5770 let admin_role_id =
5771 crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5772 let alice = Keys::generate();
5773 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id.clone()])
5775 .await
5776 .unwrap();
5777
5778 crate::state::set_my_public_key(alice.public_key());
5780 let bob = Keys::generate().public_key();
5781 let err = grant_role(&relay, &community, bob, &admin_role_id).await.unwrap_err();
5782 assert!(err.contains("below your own"), "peer-rank grant refused, got: {err}");
5783 }
5784
5785 #[tokio::test]
5786 async fn create_community_mints_a_verifiable_owner_attestation() {
5787 let (_tmp, _guard) = init_test_db();
5790 let me = crate::state::my_public_key().unwrap();
5791 let relay = MemoryRelay::new();
5792 let community = create_community(&relay, "HQ", "general", vec!["r1".into()])
5793 .await
5794 .expect("create");
5795 let att = community.owner_attestation.as_ref().expect("attestation is mandatory");
5796 let proven = super::super::owner::verify_owner_attestation(att, &community.id.to_hex());
5797 assert_eq!(proven, Some(me), "the creator is the proven owner");
5798 assert_eq!(
5800 super::super::owner::verify_owner_attestation(att, &"f".repeat(64)),
5801 None,
5802 );
5803 }
5804
5805 #[tokio::test]
5806 async fn admin_cannot_ban_a_peer_admin() {
5807 let (_tmp, _guard) = init_test_db();
5811 let relay = MemoryRelay::new();
5812 let community = create_community(&relay, "HQ", "general", vec!["r1".into()])
5813 .await
5814 .expect("create");
5815 let cid = community.id.to_hex();
5816 let admin_role_id =
5817 crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5818 let alice = Keys::generate();
5819 let bob = Keys::generate();
5820 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id.clone()])
5822 .await
5823 .unwrap();
5824 set_member_grant(&relay, &community, &bob.public_key().to_hex(), vec![admin_role_id.clone()])
5825 .await
5826 .unwrap();
5827
5828 become_local(&alice);
5831 let err = publish_banlist(&relay, &community, &[bob.public_key().to_hex()])
5832 .await
5833 .unwrap_err();
5834 assert!(err.contains("outranks you"), "peer-admin ban refused, got: {err}");
5835 }
5836
5837 #[tokio::test]
5838 async fn roster_reconstructs_purely_from_relay() {
5839 let (_tmp, _guard) = init_test_db();
5843 let relay = MemoryRelay::new();
5844 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5845 let cid = community.id.to_hex();
5846 let admin_role_id =
5847 crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5848 let alice = "aa".repeat(32);
5849 set_member_grant(&relay, &community, &alice, vec![admin_role_id.clone()]).await.unwrap();
5850
5851 crate::db::community::set_community_roles(&cid, &crate::community::roles::CommunityRoles::default(), 0).unwrap();
5853 assert!(crate::db::community::get_community_roles(&cid).unwrap().roles.is_empty(), "cache wiped");
5854
5855 let roster = fetch_and_apply_roles(&relay, &community).await.unwrap();
5856 assert!(roster.is_admin(&alice), "roster reconstructed from relay editions, not the cache");
5857 assert_eq!(roster.roles.len(), 1, "the Admin role edition folded back");
5858 }
5859
5860 #[tokio::test]
5861 async fn admin_cannot_unban_a_peer_admin() {
5862 let (_tmp, _guard) = init_test_db();
5865 let relay = MemoryRelay::new();
5866 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5867 let cid = community.id.to_hex();
5868 let admin_role_id =
5869 crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5870 let alice = Keys::generate();
5871 let bob = Keys::generate();
5872 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id.clone()])
5873 .await
5874 .unwrap();
5875 set_member_grant(&relay, &community, &bob.public_key().to_hex(), vec![admin_role_id.clone()])
5876 .await
5877 .unwrap();
5878 crate::db::community::set_community_banlist(&cid, &[bob.public_key().to_hex()], 1000).unwrap();
5880
5881 become_local(&alice);
5883 let err = publish_banlist(&relay, &community, &[]).await.unwrap_err();
5884 assert!(err.contains("unban"), "unbanning a peer admin refused, got: {err}");
5885 }
5886
5887 #[tokio::test]
5888 async fn create_community_rejects_signer_identity_mismatch() {
5889 let (_tmp, _guard) = init_test_db(); let other = Keys::generate();
5894 crate::state::set_my_public_key(other.public_key()); let relay = MemoryRelay::new();
5896 let err = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap_err();
5897 assert!(err.contains("identity signer"), "signer mismatch refused, got: {err}");
5898 }
5899
5900 #[tokio::test]
5901 async fn banlist_newer_edition_applies_older_is_refused() {
5902 let (_tmp, _guard) = init_test_db();
5903 let relay = MemoryRelay::new();
5904 let community = create_community(&relay, "HQ", "general", vec!["r1".into()])
5905 .await
5906 .expect("create");
5907 let id_hex = community.id.to_hex();
5908 let banlist_entity = crate::simd::hex::bytes_to_hex_32(&crate::community::derive::banlist_locator(&community.id));
5909 let mallory = "aa".repeat(32);
5910 let bob = "bb".repeat(32);
5911
5912 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
5915 let inner = crate::community::roster::build_banlist_edition(&owner, &community.id, &[mallory.clone()], 1, None, 1000, None).unwrap();
5916 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
5917 relay.inject(&outer, &community.relays);
5918
5919 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
5921 assert_eq!(applied, vec![mallory.clone()]);
5922 let (head_v, _) = crate::db::community::get_edition_head(&id_hex, &banlist_entity).unwrap().unwrap();
5923 assert_eq!(head_v, 1, "banlist edition head advanced to v1");
5924
5925 crate::db::community::set_community_banlist(&id_hex, &[mallory.clone(), bob.clone()], 2).unwrap();
5928 crate::db::community::set_edition_head(&id_hex, &banlist_entity, 2, &[0x22u8; 32]).unwrap();
5929 let after = fetch_and_apply_banlist(&relay, &community).await.unwrap();
5930 assert_eq!(after, vec![mallory, bob], "older relay edition refused, local banlist preserved");
5931 }
5932
5933 #[tokio::test]
5934 async fn unauthorized_banlist_edition_is_rejected() {
5935 let (_tmp, _guard) = init_test_db();
5939 let relay = MemoryRelay::new();
5940 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5941 let bob = "bb".repeat(32);
5942
5943 let mallory = Keys::generate();
5945 let inner = crate::community::roster::build_banlist_edition(&mallory, &community.id, &[bob], 1, None, 1000, None).unwrap();
5946 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
5947 relay.inject(&outer, &community.relays);
5948
5949 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
5951 assert!(applied.is_empty(), "an unauthorized signer's banlist edition is rejected");
5952 }
5953
5954 #[tokio::test]
5955 async fn banlist_receiver_enforces_per_target_outrank() {
5956 let (_tmp, _guard) = init_test_db();
5959 let relay = MemoryRelay::new();
5960 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5961 let cid = community.id.to_hex();
5962 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5963 let alice = Keys::generate();
5964 let bob = Keys::generate();
5965 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id.clone()]).await.unwrap();
5967 set_member_grant(&relay, &community, &bob.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
5968
5969 let cite = authority_citation(&community, &alice.public_key().to_hex());
5972 let inner = crate::community::roster::build_banlist_edition(&alice, &community.id, &[bob.public_key().to_hex()], 1, None, 1000, cite.as_ref()).unwrap();
5973 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
5974 relay.inject(&outer, &community.relays);
5975
5976 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
5978 assert!(applied.is_empty(), "an admin can't ban a peer admin (receiver-side outrank)");
5979 }
5980
5981 #[tokio::test]
5982 async fn banlist_admin_bans_regular_member_applies() {
5983 let (_tmp, _guard) = init_test_db();
5986 let relay = MemoryRelay::new();
5987 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5988 let cid = community.id.to_hex();
5989 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5990 let alice = Keys::generate();
5991 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
5992
5993 let carol = "cc".repeat(32);
5994 let cite = authority_citation(&community, &alice.public_key().to_hex());
5996 let inner = crate::community::roster::build_banlist_edition(&alice, &community.id, &[carol.clone()], 1, None, 1000, cite.as_ref()).unwrap();
5997 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
5998 relay.inject(&outer, &community.relays);
5999
6000 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
6001 assert_eq!(applied, vec![carol], "an admin's ban of a regular member applies");
6002 }
6003
6004 #[tokio::test]
6005 async fn owner_banlist_needs_no_citation() {
6006 let (_tmp, _guard) = init_test_db();
6009 let relay = MemoryRelay::new();
6010 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6011 let victim = "cc".repeat(32);
6012
6013 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6015 let inner = crate::community::roster::build_banlist_edition(&owner, &community.id, &[victim.clone()], 1, None, 1000, None).unwrap();
6016 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6017 relay.inject(&outer, &community.relays);
6018
6019 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
6020 assert_eq!(applied, vec![victim], "an owner's uncited ban applies");
6021 }
6022
6023 #[tokio::test]
6024 async fn banlist_with_forged_citation_hash_is_rejected() {
6025 let (_tmp, _guard) = init_test_db();
6028 let relay = MemoryRelay::new();
6029 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6030 let cid = community.id.to_hex();
6031 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
6032 let alice = Keys::generate();
6033 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
6034
6035 let carol = "cc".repeat(32);
6036 let mut cite = authority_citation(&community, &alice.public_key().to_hex()).unwrap();
6038 cite.edition_hash = [0xEE; 32];
6039 let inner = crate::community::roster::build_banlist_edition(&alice, &community.id, &[carol], 1, None, 1000, Some(&cite)).unwrap();
6040 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6041 relay.inject(&outer, &community.relays);
6042
6043 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
6044 assert!(applied.is_empty(), "a forged-hash citation is rejected");
6045 }
6046
6047 #[tokio::test]
6048 async fn banlist_citing_unsynced_future_version_is_rejected() {
6049 let (_tmp, _guard) = init_test_db();
6053 let relay = MemoryRelay::new();
6054 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6055 let cid = community.id.to_hex();
6056 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
6057 let alice = Keys::generate();
6058 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
6059
6060 let carol = "cc".repeat(32);
6061 let mut cite = authority_citation(&community, &alice.public_key().to_hex()).unwrap();
6062 cite.version += 5; let inner = crate::community::roster::build_banlist_edition(&alice, &community.id, &[carol], 1, None, 1000, Some(&cite)).unwrap();
6064 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6065 relay.inject(&outer, &community.relays);
6066
6067 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
6068 assert!(applied.is_empty(), "citing an unsynced future grant version fails closed");
6069 }
6070
6071 #[tokio::test]
6072 async fn demoted_banner_superseded_ban_is_rejected() {
6073 let (_tmp, _guard) = init_test_db();
6077 let relay = MemoryRelay::new();
6078 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6079 let cid = community.id.to_hex();
6080 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
6081 let alice = Keys::generate();
6082 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
6083
6084 let carol = "cc".repeat(32);
6085 let cite = authority_citation(&community, &alice.public_key().to_hex());
6087 let inner = crate::community::roster::build_banlist_edition(&alice, &community.id, &[carol], 1, None, 1000, cite.as_ref()).unwrap();
6088 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6089 relay.inject(&outer, &community.relays);
6090
6091 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![]).await.unwrap();
6093
6094 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
6095 assert!(applied.is_empty(), "a since-demoted banner's stale ban is rejected (refuse-superseded)");
6096 }
6097
6098 #[tokio::test]
6099 async fn withheld_revocation_cannot_resurrect_a_demoted_banners_grant() {
6100 let (_tmp, _guard) = init_test_db();
6106 let relay = MemoryRelay::new();
6107 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6108 let cid = community.id.to_hex();
6109 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
6110 let alice = Keys::generate();
6111 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
6112
6113 let carol = "cc".repeat(32);
6115 let cite = authority_citation(&community, &alice.public_key().to_hex());
6116 let inner = crate::community::roster::build_banlist_edition(&alice, &community.id, &[carol], 1, None, 1000, cite.as_ref()).unwrap();
6117 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6118 relay.inject(&outer, &community.relays);
6119
6120 let alice_bytes = alice.public_key().to_bytes();
6123 let grant_entity = crate::simd::hex::bytes_to_hex_32(&crate::community::derive::grant_locator(&community.id, &alice_bytes));
6124 crate::db::community::set_edition_head(&cid, &grant_entity, 2, &[0xAB; 32]).unwrap();
6125
6126 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
6127 assert!(applied.is_empty(), "a withheld revocation can't roll the banner's grant back to re-authorize them");
6128 }
6129
6130 #[tokio::test]
6131 async fn invite_registry_round_trips_and_drives_is_public() {
6132 let (_tmp, _guard) = init_test_db();
6136 let relay = MemoryRelay::new();
6137 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6138 assert!(!is_public(&community).unwrap(), "a fresh community is Private");
6139
6140 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6143 let loc = "1a".repeat(32);
6144 let inner = crate::community::roster::build_invite_links_edition(&owner, &community.id, &[loc.clone()], 1, None, 1000, None).unwrap();
6145 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6146 relay.inject(&outer, &community.relays);
6147
6148 let applied = fetch_and_apply_invite_links(&relay, &community).await.unwrap();
6149 assert_eq!(applied, vec![loc], "the owner's link edition folds + unions from the relay");
6150 assert!(is_public(&community).unwrap(), "mode recomputed Public from the folded aggregate");
6151
6152 publish_my_invite_links(&relay, &community, &[]).await.unwrap();
6154 let applied = fetch_and_apply_invite_links(&relay, &community).await.unwrap();
6155 assert!(applied.is_empty() && !is_public(&community).unwrap(), "an empty aggregate is Private");
6156 }
6157
6158 #[tokio::test]
6159 async fn metadata_edit_round_trips_to_a_lagging_member() {
6160 let (_tmp, _guard) = init_test_db();
6164 let relay = MemoryRelay::new();
6165 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6166 let cid = community.id.to_hex();
6167 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6168 let (genesis_v, genesis_hash) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
6169 assert_eq!(genesis_v, 1);
6170
6171 let mut edited = crate::community::metadata::CommunityMetadata::of(&community);
6172 edited.name = "Renamed HQ".into();
6173 edited.description = Some("now with a topic".into());
6174 let inner = crate::community::roster::build_community_root_edition(&owner, &community.id, &edited, 2, Some(&genesis_hash), 4000, None).unwrap();
6175 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6176 relay.inject(&outer, &community.relays);
6177
6178 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6179 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6180 assert_eq!(after.name, "Renamed HQ", "the owner's GroupRoot edit folded from the relay");
6181 assert_eq!(after.description.as_deref(), Some("now with a topic"));
6182 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0, 2, "head advanced to v2");
6183 }
6184
6185 #[tokio::test]
6186 async fn unauthorized_metadata_edit_is_ignored() {
6187 let (_tmp, _guard) = init_test_db();
6190 let relay = MemoryRelay::new();
6191 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6192 let cid = community.id.to_hex();
6193 let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
6194
6195 let mallory = Keys::generate();
6196 let mut hacked = crate::community::metadata::CommunityMetadata::of(&community);
6197 hacked.name = "Pwned".into();
6198 let inner = crate::community::roster::build_community_root_edition(&mallory, &community.id, &hacked, 2, Some(&genesis_hash), 5000, None).unwrap();
6199 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6200 relay.inject(&outer, &community.relays);
6201
6202 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6203 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6204 assert_eq!(after.name, "HQ", "a non-manage-metadata signer's GroupRoot edit is rejected");
6205 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0, 1, "an unauthorized edit never advances the head");
6206 }
6207
6208 #[tokio::test]
6209 async fn channel_rename_round_trips_from_owner_edition() {
6210 let (_tmp, _guard) = init_test_db();
6213 let relay = MemoryRelay::new();
6214 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6215 let cid = community.id.to_hex();
6216 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6217 let channel = community.channels[0].clone();
6218 let ch_hex = channel.id.to_hex();
6219 let (_, genesis_ch_hash) = crate::db::community::get_edition_head(&cid, &ch_hex).unwrap().unwrap();
6220
6221 let meta = crate::community::metadata::ChannelMetadata { name: "announcements".into() };
6222 let inner = crate::community::roster::build_channel_metadata_edition(&owner, &channel.id, &meta, 2, Some(&genesis_ch_hash), 6000, None).unwrap();
6223 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6224 relay.inject(&outer, &community.relays);
6225
6226 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6227 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6228 assert_eq!(after.channels[0].name, "announcements", "the owner's channel rename folded + applied");
6229 assert_eq!(crate::db::community::get_edition_head(&cid, &ch_hex).unwrap().unwrap().0, 2, "channel head advanced to v2");
6230 }
6231
6232 fn root_fork_v2(author: &Keys, community: &Community, name: &str, created: u64, genesis_hash: &[u8; 32]) -> (Event, [u8; 32], [u8; 32]) {
6235 let mut meta = crate::community::metadata::CommunityMetadata::of(community);
6236 meta.name = name.into();
6237 let inner = crate::community::roster::build_community_root_edition(author, &community.id, &meta, 2, Some(genesis_hash), created, None).unwrap();
6238 let self_hash = crate::community::version::edition_hash(&community.id.0, 2, Some(genesis_hash), inner.content.as_bytes());
6239 let inner_id = inner.id.to_bytes();
6240 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6241 (outer, self_hash, inner_id)
6242 }
6243
6244 fn channel_fork_v2(author: &Keys, community: &Community, channel_id: &crate::community::ChannelId, name: &str, created: u64, genesis_hash: &[u8; 32]) -> (Event, [u8; 32], [u8; 32]) {
6247 let meta = crate::community::metadata::ChannelMetadata { name: name.into() };
6248 let inner = crate::community::roster::build_channel_metadata_edition(author, channel_id, &meta, 2, Some(genesis_hash), created, None).unwrap();
6249 let self_hash = crate::community::version::edition_hash(&channel_id.0, 2, Some(genesis_hash), inner.content.as_bytes());
6250 let inner_id = inner.id.to_bytes();
6251 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6252 (outer, self_hash, inner_id)
6253 }
6254
6255 #[tokio::test]
6259 async fn channel_same_version_fork_converges_to_the_lower_inner_id() {
6260 let (_tmp, _guard) = init_test_db();
6261 let relay = MemoryRelay::new();
6262 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6263 let cid = community.id.to_hex();
6264 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6265 let channel_id = community.channels[0].id;
6266 let ch_hex = channel_id.to_hex();
6267 let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &ch_hex).unwrap().unwrap();
6268
6269 let (out_a, ha, ida) = channel_fork_v2(&owner, &community, &channel_id, "alpha", 1000, &genesis_hash);
6270 let (out_b, hb, idb) = channel_fork_v2(&owner, &community, &channel_id, "bravo", 2000, &genesis_hash);
6271 let (win_name, win_h, win_id, lose_name, lose_h, lose_id) = if ida < idb {
6272 ("alpha", ha, ida, "bravo", hb, idb)
6273 } else {
6274 ("bravo", hb, idb, "alpha", ha, ida)
6275 };
6276 crate::db::community::set_edition_head_with_id(&cid, &ch_hex, 2, &lose_h, &lose_id).unwrap();
6278 {
6279 let mut c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6280 c.channels.iter_mut().find(|ch| ch.id == channel_id).unwrap().name = lose_name.into();
6281 crate::db::community::save_community(&c).unwrap();
6282 }
6283 relay.inject(&out_a, &community.relays);
6284 relay.inject(&out_b, &community.relays);
6285
6286 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6287 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6288 let ch_name = &after.channels.iter().find(|c| c.id == channel_id).unwrap().name;
6289 assert_eq!(ch_name, win_name, "channel converged on the lower-inner-id winner, not our held fork");
6290 assert_eq!(crate::db::community::get_edition_head(&cid, &ch_hex).unwrap().unwrap(), (2, win_h), "channel head self_hash converged at the SAME version");
6291 assert_eq!(crate::db::community::get_edition_head_inner_id(&cid, &ch_hex).unwrap(), Some(win_id), "channel head inner_id moved to the winner");
6292
6293 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6295 let after2 = crate::db::community::load_community(&community.id).unwrap().unwrap();
6296 assert_eq!(&after2.channels.iter().find(|c| c.id == channel_id).unwrap().name, win_name, "no flip back to the higher-id fork");
6297 }
6298
6299 #[tokio::test]
6302 async fn channel_same_version_fork_excludes_an_unauthorized_lower_id_edition() {
6303 let (_tmp, _guard) = init_test_db();
6304 let relay = MemoryRelay::new();
6305 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6306 let cid = community.id.to_hex();
6307 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6308 let channel_id = community.channels[0].id;
6309 let ch_hex = channel_id.to_hex();
6310 let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &ch_hex).unwrap().unwrap();
6311
6312 let (owner_out, owner_h, owner_id) = channel_fork_v2(&owner, &community, &channel_id, "legit", 1000, &genesis_hash);
6313 let mallory = Keys::generate();
6315 let mal_out = {
6316 let mut chosen = None;
6317 for t in 1..=10_000u64 {
6318 let cand = channel_fork_v2(&mallory, &community, &channel_id, "forged", t, &genesis_hash);
6319 if cand.2 < owner_id { chosen = Some(cand.0); break; }
6320 }
6321 chosen.expect("a mallory channel edition with a lower inner id")
6322 };
6323 relay.inject(&owner_out, &community.relays);
6324 relay.inject(&mal_out, &community.relays);
6325
6326 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6327 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6328 assert_eq!(&after.channels.iter().find(|c| c.id == channel_id).unwrap().name, &"legit".to_string(), "the channel forgery never wins despite a lower inner id");
6329 assert_eq!(crate::db::community::get_edition_head(&cid, &ch_hex).unwrap().unwrap(), (2, owner_h), "the authorized channel edition is the head");
6330 }
6331
6332 #[tokio::test]
6336 async fn epoch_primary_floor_lets_a_refounding_v1_supersede_a_held_high_version() {
6337 let (_tmp, _guard) = init_test_db();
6338 let relay = MemoryRelay::new();
6339 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6340 let cid = community.id.to_hex();
6341 for v in 2..=5u64 {
6343 crate::db::community::set_edition_head_with_id(&cid, &cid, v, &[v as u8; 32], &[v as u8; 32]).unwrap();
6344 }
6345 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0, 5);
6346 crate::db::community::set_edition_head_with_id(&cid, &cid, 3, &[0x33; 32], &[0x33; 32]).unwrap();
6348 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0, 5, "in-epoch downgrade refused");
6349
6350 crate::db::community::advance_server_root_epoch(&cid, 1, &[0xEE; 32]).unwrap();
6352 crate::db::community::set_edition_head_with_id(&cid, &cid, 1, &[0x01; 32], &[0x01; 32]).unwrap();
6353 let (v, h) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
6354 assert_eq!((v, h), (1, [0x01; 32]), "epoch-1 v1 supersedes epoch-0 v5 (epoch-primary)");
6355 assert_eq!(
6356 crate::db::community::get_all_edition_heads_epoched(&cid).unwrap().get(&cid).map(|(e, v, _)| (*e, *v)),
6357 Some((1, 1)),
6358 "head now recorded at epoch 1",
6359 );
6360 crate::db::community::set_edition_head_with_id(&cid, &cid, 1, &[0xAA; 32], &[0x02; 32]).unwrap();
6362 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().1, [0x01; 32], "same-epoch same-version is not an advance");
6363 }
6364
6365 #[tokio::test]
6369 async fn same_version_fork_converges_to_the_lower_inner_id() {
6370 let (_tmp, _guard) = init_test_db();
6371 let relay = MemoryRelay::new();
6372 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6373 let cid = community.id.to_hex();
6374 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6375 let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
6376
6377 let (out_a, ha, ida) = root_fork_v2(&owner, &community, "Alpha", 1000, &genesis_hash);
6378 let (out_b, hb, idb) = root_fork_v2(&owner, &community, "Bravo", 2000, &genesis_hash);
6379 let (win_name, win_h, win_id, lose_name, lose_h, lose_id) = if ida < idb {
6381 ("Alpha", ha, ida, "Bravo", hb, idb)
6382 } else {
6383 ("Bravo", hb, idb, "Alpha", ha, ida)
6384 };
6385 crate::db::community::set_edition_head_with_id(&cid, &cid, 2, &lose_h, &lose_id).unwrap();
6386 {
6387 let mut c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6388 c.name = lose_name.into();
6389 crate::db::community::save_community(&c).unwrap();
6390 }
6391 relay.inject(&out_a, &community.relays);
6392 relay.inject(&out_b, &community.relays);
6393
6394 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6395 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6396 assert_eq!(after.name, win_name, "converged on the lower-inner-id winner, not our own held fork");
6397 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap(), (2, win_h), "head self_hash converged at the SAME version");
6398 assert_eq!(crate::db::community::get_edition_head_inner_id(&cid, &cid).unwrap(), Some(win_id), "head inner_id moved to the winner");
6399
6400 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6402 assert_eq!(crate::db::community::load_community(&community.id).unwrap().unwrap().name, win_name, "no flip back to the higher-id fork");
6403 }
6404
6405 #[tokio::test]
6408 async fn converged_head_chains_the_next_edit_without_reforking() {
6409 let (_tmp, _guard) = init_test_db();
6410 let relay = MemoryRelay::new();
6411 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6412 let cid = community.id.to_hex();
6413 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6414 let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
6415
6416 let (out_a, ha, ida) = root_fork_v2(&owner, &community, "Alpha", 1000, &genesis_hash);
6417 let (out_b, hb, idb) = root_fork_v2(&owner, &community, "Bravo", 2000, &genesis_hash);
6418 let (lose_h, lose_id) = if ida < idb { (hb, idb) } else { (ha, ida) };
6419 crate::db::community::set_edition_head_with_id(&cid, &cid, 2, &lose_h, &lose_id).unwrap();
6420 relay.inject(&out_a, &community.relays);
6421 relay.inject(&out_b, &community.relays);
6422 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6423 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0, 2, "converged at v2");
6424
6425 let mut c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6426 c.name = "Third".into();
6427 republish_community_metadata(&relay, &c).await.unwrap();
6428 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0, 3, "advanced to v3 off the converged head");
6429
6430 let empty: std::collections::HashMap<String, (u64, [u8; 32])> = std::collections::HashMap::new();
6431 let folded = crate::community::roster::fold_roster(&fetch_control_inners(&relay, &community).await, &community.id, &empty);
6432 assert_eq!(folded.root_head.as_ref().map(|h| h.version), Some(3), "a fresh fold reaches v3");
6433 assert!(!folded.gapped_entities.contains(&community.id.0), "the chain is contiguous genesis -> winner -> v3");
6434 }
6435
6436 #[tokio::test]
6439 async fn same_version_fork_excludes_an_unauthorized_lower_id_edition() {
6440 let (_tmp, _guard) = init_test_db();
6441 let relay = MemoryRelay::new();
6442 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6443 let cid = community.id.to_hex();
6444 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6445 let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
6446
6447 let (owner_out, owner_h, owner_id) = root_fork_v2(&owner, &community, "Legit", 1000, &genesis_hash);
6448 let mallory = Keys::generate();
6450 let (mal_out, mal_id) = {
6451 let mut chosen = None;
6452 for t in 1..=10_000u64 {
6453 let cand = root_fork_v2(&mallory, &community, "Forged", t, &genesis_hash);
6454 if cand.2 < owner_id { chosen = Some((cand.0, cand.2)); break; }
6455 }
6456 chosen.expect("a mallory edition with a lower inner id")
6457 };
6458 assert!(mal_id < owner_id, "premise: the forgery sorts first author-blind");
6459 relay.inject(&owner_out, &community.relays);
6460 relay.inject(&mal_out, &community.relays);
6461
6462 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6464 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6465 assert_eq!(after.name, "Legit", "the forgery never wins despite a lower inner id");
6466 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap(), (2, owner_h), "the authorized edition is the head");
6467 }
6468
6469 #[tokio::test]
6473 async fn same_version_fork_on_an_authority_record_fails_closed() {
6474 let (_tmp, _guard) = init_test_db();
6475 let relay = MemoryRelay::new();
6476 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6477 let cid = community.id.to_hex();
6478 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6479 let bl_eid = crate::community::derive::banlist_locator(&community.id);
6480 let bl_hex = crate::simd::hex::bytes_to_hex_32(&bl_eid);
6481
6482 let prev = [0x99u8; 32]; let build_ban = |list: &[String], created: u64| {
6484 let inner = crate::community::roster::build_banlist_edition(&owner, &community.id, list, 2, Some(&prev), created, None).unwrap();
6485 let self_hash = crate::community::version::edition_hash(&bl_eid, 2, Some(&prev), inner.content.as_bytes());
6486 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6487 (outer, self_hash, inner.id.to_bytes())
6488 };
6489 let (out_a, ha, ida) = build_ban(&["aa".repeat(32)], 1000);
6490 let (out_b, hb, idb) = build_ban(&["bb".repeat(32)], 2000);
6491 let (lose_h, lose_id) = if ida < idb { (hb, idb) } else { (ha, ida) };
6494 crate::db::community::set_edition_head_with_id(&cid, &bl_hex, 2, &lose_h, &lose_id).unwrap();
6495 relay.inject(&out_a, &community.relays);
6496 relay.inject(&out_b, &community.relays);
6497
6498 let floors = crate::db::community::get_all_edition_heads(&cid).unwrap();
6499 let folded = crate::community::roster::fold_roster(&fetch_control_inners(&relay, &community).await, &community.id, &floors);
6500 assert!(folded.gapped_entities.contains(&bl_eid), "the authority-record fork is quarantined");
6501 assert!(folded.banlist_head.is_none() && folded.banlist_author.is_none(), "no banlist folded off the withheld view");
6502 }
6503
6504 #[tokio::test]
6505 async fn editions_sign_through_the_active_client_signer() {
6506 let (_tmp, _guard) = init_test_db();
6511 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6512 crate::state::set_nostr_client(nostr_sdk::prelude::Client::builder().build());
6513
6514 let relay = MemoryRelay::new();
6515 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6516 let cid = community.id.to_hex();
6517
6518 publish_banlist(&relay, &community, &["dd".repeat(32)]).await.unwrap();
6520 let floors = crate::db::community::get_all_edition_heads(&cid).unwrap();
6521 let folded = crate::community::roster::fold_roster(
6522 &fetch_control_inners(&relay, &community).await, &community.id, &floors);
6523 assert_eq!(folded.banlist_author, Some(owner.public_key()), "banlist signed by the client signer");
6524 assert!(folded.root_author.is_some(), "genesis GroupRoot folded");
6525 let _ = crate::state::take_nostr_client();
6526 }
6527
6528 async fn fetch_control_inners(relay: &MemoryRelay, community: &Community) -> Vec<Event> {
6530 let z = crate::community::roster::control_pseudonym(&community.server_root_key, &community.id, crate::community::Epoch(0));
6531 let query = Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], ..Default::default() };
6532 let mut out = Vec::new();
6533 for ev in relay.fetch(&query, &community.relays).await.unwrap() {
6534 if let Ok(inner) = crate::community::roster::open_control_edition(&ev, &community.server_root_key) {
6535 out.push(inner);
6536 }
6537 }
6538 out
6539 }
6540
6541 fn simulate_bunker(owner: &Keys) {
6544 crate::state::set_nostr_client(nostr_sdk::prelude::Client::builder().build());
6545 crate::signer::set_test_signer(Some(crate::signer::ActiveSigner::Keys(owner.clone())));
6548 crate::state::MY_SECRET_KEY.clear(&[]);
6549 assert!(crate::state::MY_SECRET_KEY.to_keys().is_none(), "bunker sim: no local key");
6550 }
6551
6552 #[tokio::test]
6553 async fn am_i_banned_detects_own_npub_in_banlist() {
6554 let (_tmp, _guard) = init_test_db();
6556 let relay = MemoryRelay::new();
6557 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6558 let me = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key().to_hex();
6559 let cid = community.id.to_hex();
6560 assert!(!am_i_banned(&community), "not banned on a fresh community");
6561 crate::db::community::set_community_banlist(&cid, &[me], 1).unwrap();
6563 assert!(am_i_banned(&community), "own npub in the banlist → banned → self-remove");
6564 crate::db::community::set_community_banlist(&cid, &[], 2).unwrap();
6565 assert!(!am_i_banned(&community), "cleared banlist → not banned");
6566 }
6567
6568 #[tokio::test]
6569 async fn bunker_owner_cannot_ban_in_private_community() {
6570 let (_tmp, _guard) = init_test_db();
6573 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6574 let relay = MemoryRelay::new();
6575 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6576 simulate_bunker(&owner);
6577
6578 let victim = "cc".repeat(32);
6579 let err = publish_banlist(&relay, &community, &[victim]).await.unwrap_err();
6580 assert!(err.contains("private community") && err.contains("bunker"), "clear bunker explanation: {err}");
6581 assert!(
6582 crate::db::community::get_community_banlist(&community.id.to_hex()).unwrap().is_empty(),
6583 "the ban must NOT half-apply (nothing published or persisted)"
6584 );
6585 let _ = crate::state::take_nostr_client();
6586 }
6587
6588 #[tokio::test]
6589 async fn bunker_owner_can_ban_in_public_community() {
6590 let (_tmp, _guard) = init_test_db();
6593 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6594 let relay = MemoryRelay::new();
6595 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6596 create_public_invite(&relay, &community, None, None).await.unwrap();
6597 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
6598 assert!(is_public(&community).unwrap(), "minting a link made it Public");
6599 simulate_bunker(&owner);
6600
6601 let victim = "cc".repeat(32);
6602 publish_banlist(&relay, &community, &[victim.clone()]).await.unwrap();
6603 assert_eq!(
6604 crate::db::community::get_community_banlist(&community.id.to_hex()).unwrap(),
6605 vec![victim],
6606 "a public ban from a bunker account succeeds (no rekey needed)"
6607 );
6608 let _ = crate::state::take_nostr_client();
6609 }
6610
6611 #[tokio::test]
6612 async fn bunker_owner_cannot_privatize() {
6613 let (_tmp, _guard) = init_test_db();
6616 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6617 let relay = MemoryRelay::new();
6618 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6619 let (token, _) = create_public_invite(&relay, &community, None, None).await.unwrap();
6620 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
6621 simulate_bunker(&owner);
6622
6623 let err = revoke_public_invite(&relay, &community, &crate::simd::hex::hex_to_bytes_32(&token)).await.unwrap_err();
6624 assert!(err.contains("private") && err.contains("bunker"), "clear bunker explanation: {err}");
6625 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6626 assert!(is_public(&after).unwrap(), "the revoke must NOT half-apply — community stays Public");
6627 let _ = crate::state::take_nostr_client();
6628 }
6629
6630 #[tokio::test]
6631 async fn non_owner_admin_can_edit_community_metadata() {
6632 let (_tmp, _guard) = init_test_db();
6636 let relay = MemoryRelay::new();
6637 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6638 let cid = community.id.to_hex();
6639 let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
6640
6641 let admin = Keys::generate();
6643 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
6644 set_member_grant(&relay, &community, &admin.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
6645
6646 let mut edited = crate::community::metadata::CommunityMetadata::of(&community);
6648 edited.name = "Admin Renamed".into();
6649 let inner = crate::community::roster::build_community_root_edition(&admin, &community.id, &edited, 2, Some(&genesis_hash), 7000, None).unwrap();
6650 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6651 relay.inject(&outer, &community.relays);
6652
6653 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6654 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6655 assert_eq!(after.name, "Admin Renamed", "a MANAGE_METADATA admin (not the owner) can edit metadata");
6656 }
6657
6658 #[tokio::test]
6659 async fn banning_an_admin_revokes_their_role() {
6660 let (_tmp, _guard) = init_test_db();
6664 let relay = MemoryRelay::new();
6665 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6666 let cid = community.id.to_hex();
6667 create_public_invite(&relay, &community, None, None).await.unwrap();
6668 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
6669
6670 let alice = Keys::generate();
6671 let alice_hex = alice.public_key().to_hex();
6672 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
6673 set_member_grant(&relay, &community, &alice_hex, vec![admin_role_id]).await.unwrap();
6674 let holds_role = |hex: &str| crate::db::community::get_community_roles(&cid).unwrap()
6675 .grants.iter().any(|g| g.member == hex && !g.role_ids.is_empty());
6676 assert!(holds_role(&alice_hex), "alice is admin pre-ban");
6677
6678 publish_banlist(&relay, &community, &[alice_hex.clone()]).await.unwrap();
6679 assert!(!holds_role(&alice_hex), "banning an admin revokes their role — no dangling grant");
6680 }
6681
6682 #[tokio::test]
6683 async fn kicking_an_admin_revokes_their_role() {
6684 let (_tmp, _guard) = init_test_db();
6687 let relay = MemoryRelay::new();
6688 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6689 let cid = community.id.to_hex();
6690 let alice = Keys::generate();
6691 let alice_hex = alice.public_key().to_hex();
6692 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
6693 set_member_grant(&relay, &community, &alice_hex, vec![admin_role_id]).await.unwrap();
6694 let holds_role = |hex: &str| crate::db::community::get_community_roles(&cid).unwrap()
6695 .grants.iter().any(|g| g.member == hex && !g.role_ids.is_empty());
6696 assert!(holds_role(&alice_hex), "alice is admin pre-kick");
6697
6698 publish_kick(&relay, &community, &community.channels[0], &alice_hex).await.unwrap();
6699 assert!(!holds_role(&alice_hex), "kicking an admin revokes their role");
6700 }
6701
6702 #[tokio::test]
6703 async fn republish_channel_metadata_renames_and_publishes() {
6704 let (_tmp, _guard) = init_test_db();
6707 let relay = MemoryRelay::new();
6708 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6709 let cid = community.id.to_hex();
6710 let channel = community.channels[0].clone();
6711 let ch_hex = channel.id.to_hex();
6712
6713 republish_channel_metadata(&relay, &community, &channel.id, "lobby").await.unwrap();
6714 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6715 assert_eq!(after.channels[0].name, "lobby", "the producer renamed the channel locally");
6716 assert_eq!(crate::db::community::get_edition_head(&cid, &ch_hex).unwrap().unwrap().0, 2, "channel head advanced");
6717 }
6718
6719 #[tokio::test]
6720 async fn revoking_the_last_link_privatizes_and_rotates_the_base() {
6721 let (_tmp, _guard) = init_test_db();
6726 let relay = MemoryRelay::new();
6727 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6728 assert!(!is_public(&community).unwrap(), "a fresh community is Private");
6729 assert_eq!(community.server_root_epoch, crate::community::Epoch(0));
6730
6731 let (t1, _) = create_public_invite(&relay, &community, None, None).await.unwrap();
6733 let (t2, _) = create_public_invite(&relay, &community, None, None).await.unwrap();
6734 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6735 assert!(is_public(&c).unwrap(), "minting a link flips the mode to Public");
6736 assert_eq!(c.server_root_epoch, crate::community::Epoch(0), "minting links does NOT rotate the base");
6737
6738 revoke_public_invite(&relay, &c, &crate::simd::hex::hex_to_bytes_32(&t1)).await.unwrap();
6740 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6741 assert!(is_public(&c).unwrap(), "one link remains → still Public");
6742 assert_eq!(c.server_root_epoch, crate::community::Epoch(0), "revoking a non-last link does NOT rotate");
6743
6744 revoke_public_invite(&relay, &c, &crate::simd::hex::hex_to_bytes_32(&t2)).await.unwrap();
6746 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6747 assert!(!is_public(&c).unwrap(), "revoking the last link flips to Private");
6748 assert_eq!(c.server_root_epoch, crate::community::Epoch(1), "privatize re-founded: the base key rotated");
6749
6750 revoke_public_invite(&relay, &c, &crate::simd::hex::hex_to_bytes_32(&t2)).await.unwrap();
6753 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6754 assert_eq!(c.server_root_epoch, crate::community::Epoch(1), "a no-op re-revoke does not double-rotate");
6755 }
6756
6757 #[tokio::test]
6758 async fn private_ban_reseals_base_public_ban_does_not() {
6759 let (_tmp, _guard) = init_test_db();
6762 let relay = MemoryRelay::new();
6763 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6764 let victim = "cc".repeat(32);
6765
6766 assert!(!is_public(&community).unwrap(), "fresh community is Private");
6768 publish_banlist(&relay, &community, &[victim.clone()]).await.unwrap();
6769 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6770 assert_eq!(c.server_root_epoch, crate::community::Epoch(1), "a private-community ban re-seals the base");
6771
6772 create_public_invite(&relay, &c, None, None).await.unwrap();
6774 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6775 assert!(is_public(&c).unwrap(), "minted a link → Public");
6776 publish_banlist(&relay, &c, &[victim.clone(), "dd".repeat(32)]).await.unwrap();
6777 let c2 = crate::db::community::load_community(&community.id).unwrap().unwrap();
6778 assert_eq!(c2.server_root_epoch, crate::community::Epoch(1), "a public-community ban does NOT rotate the base");
6779 }
6780
6781 #[tokio::test]
6782 async fn private_ban_seals_the_banned_member_out_of_the_new_root() {
6783 use crate::community::derive::{base_rekey_pseudonym, recipient_pseudonym};
6789 use crate::community::rekey::{open_rekey_event, rekey_pairwise_secret};
6790 use crate::types::Message;
6791 use nostr_sdk::prelude::ToBech32;
6792 let (_tmp, _guard) = init_test_db();
6793 let relay = MemoryRelay::new();
6794 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6795 let cid = community.id.to_hex();
6796 let genesis_root = *community.server_root_key.as_bytes();
6797 let channel_hex = community.channels[0].id.to_hex();
6798
6799 let victim = Keys::generate();
6801 let victim_b32 = victim.public_key().to_bech32().unwrap();
6802 let mut m = Message::default();
6803 m.id = "aa".repeat(32);
6804 m.npub = Some(victim_b32.clone());
6805 m.at = 1000;
6806 crate::db::events::save_message(&channel_hex, &m).await.unwrap();
6807 assert!(
6808 crate::db::community::community_member_activity(&cid).unwrap().iter().any(|(np, _)| np == &victim_b32),
6809 "victim is observed before the ban"
6810 );
6811
6812 publish_banlist(&relay, &community, &[victim.public_key().to_hex()]).await.unwrap();
6814 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
6815 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(1), "private ban re-seals the base");
6816 assert!(
6817 !crate::db::community::community_member_activity(&cid).unwrap().iter().any(|(np, _)| np == &victim_b32),
6818 "the banned victim is no longer observed (banlist hex → bech32 reconciliation worked)"
6819 );
6820
6821 let addr = base_rekey_pseudonym(&crate::community::ServerRootKey(genesis_root), &community.id, crate::community::Epoch(1)).to_hex();
6823 let found = relay
6824 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() }, &community.relays)
6825 .await
6826 .unwrap();
6827 assert_eq!(found.len(), 1, "the base rekey is published");
6828 let parsed = open_rekey_event(&found[0], &genesis_root).unwrap();
6829 let secret = rekey_pairwise_secret(victim.secret_key(), &parsed.rotator).unwrap();
6830 let loc = recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
6831 assert!(
6832 parsed.blobs.iter().all(|b| b.locator != loc),
6833 "the BANNED victim has NO blob — sealed OUT of the new root (read access is actually cut)"
6834 );
6835 }
6836
6837 struct SwapDuringPublishRelay {
6841 inner: MemoryRelay,
6842 }
6843 #[async_trait::async_trait]
6844 impl Transport for SwapDuringPublishRelay {
6845 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
6846 async fn publish(&self, event: &Event, relays: &[String]) -> Result<(), String> {
6847 crate::state::bump_session_generation();
6848 self.inner.publish(event, relays).await
6849 }
6850 async fn publish_durable(&self, event: &Event, relays: &[String]) -> Result<(), String> {
6851 crate::state::bump_session_generation();
6852 self.inner.publish_durable(event, relays).await
6853 }
6854 async fn fetch(&self, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
6855 self.inner.fetch(query, relays).await
6856 }
6857 }
6858
6859 #[tokio::test]
6863 async fn account_swap_during_grant_publish_skips_the_local_persist() {
6864 let (_tmp, _guard) = init_test_db();
6865 let setup = MemoryRelay::new();
6866 let community = create_community(&setup, "HQ", "general", vec!["r1".into()]).await.unwrap();
6867 let cid = community.id.to_hex();
6868 let member = "cc".repeat(32);
6869 let entity_hex = crate::simd::hex::bytes_to_hex_32(
6870 &crate::community::derive::grant_locator(&community.id, &crate::simd::hex::hex_to_bytes_32(&member)));
6871 assert!(crate::db::community::get_edition_head(&cid, &entity_hex).unwrap().is_none(), "no grant head yet");
6872
6873 let swap = SwapDuringPublishRelay { inner: MemoryRelay::new() };
6874 set_member_grant(&swap, &community, &member, vec!["a".repeat(64)]).await.unwrap();
6875
6876 assert!(
6877 crate::db::community::get_edition_head(&cid, &entity_hex).unwrap().is_none(),
6878 "session straddled a swap → persist skipped → no local grant head (account B uncorrupted)"
6879 );
6880 }
6881
6882 #[tokio::test]
6886 async fn account_swap_during_ban_publish_applies_nothing_locally() {
6887 let (_tmp, _guard) = init_test_db();
6888 let setup = MemoryRelay::new();
6889 let community = create_community(&setup, "HQ", "general", vec!["r1".into()]).await.unwrap();
6890 let cid = community.id.to_hex();
6891 assert!(!is_public(&community).unwrap(), "fresh community is Private (a ban would normally re-seal)");
6892
6893 let swap = SwapDuringPublishRelay { inner: MemoryRelay::new() };
6894 publish_banlist(&swap, &community, &["cc".repeat(32)]).await.unwrap();
6895
6896 assert!(crate::db::community::get_community_banlist(&cid).unwrap().is_empty(),
6897 "banlist persist skipped on the stale session");
6898 assert_eq!(crate::db::community::load_community(&community.id).unwrap().unwrap().server_root_epoch,
6899 crate::community::Epoch(0), "no read-cut re-seal → base NOT rotated into the wrong account");
6900 assert!(!crate::db::community::get_read_cut_pending(&cid).unwrap(),
6901 "read_cut_pending untouched (need_cut requires is_valid())");
6902 }
6903
6904 #[tokio::test]
6907 async fn swap_session_clears_per_account_state_and_keys() {
6908 let (_tmp, _guard) = init_test_db();
6909 {
6910 let mut st = crate::state::STATE.lock().await;
6911 st.db_loaded = true;
6912 st.is_syncing = true;
6913 }
6914 assert!(crate::state::MY_SECRET_KEY.has_key(), "account A holds a live key");
6915
6916 crate::VectorCore.swap_session().await;
6917
6918 let st = crate::state::STATE.lock().await;
6919 assert!(st.chats.is_empty() && st.profiles.is_empty(), "STATE chats/profiles cleared on swap");
6920 assert!(!st.db_loaded && !st.is_syncing, "db_loaded / is_syncing reset");
6921 assert!(!crate::state::MY_SECRET_KEY.has_key(), "key vault cleared — no leak into account B");
6922 }
6923
6924 #[tokio::test]
6928 async fn join_finalization_persists_and_registers_the_channel() {
6929 let (_tmp, _guard) = init_test_db();
6930 crate::state::STATE.lock().await.chats.clear(); let relay = MemoryRelay::new();
6932 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6933 become_local(&Keys::generate());
6935
6936 crate::VectorCore.finalize_member_join(community.clone(), &relay, None).await.unwrap();
6937
6938 assert!(crate::db::community::load_community(&community.id).unwrap().is_some(), "community persisted on join");
6939 assert!(!crate::state::STATE.lock().await.chats.is_empty(), "the channel is registered as a chat");
6940 }
6941
6942 #[tokio::test]
6946 async fn join_finalization_tears_down_a_banned_joiner() {
6947 let (_tmp, _guard) = init_test_db();
6948 let relay = MemoryRelay::new();
6949 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6950 create_public_invite(&relay, &community, None, None).await.unwrap();
6952 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
6953
6954 let joiner = Keys::generate();
6956 publish_banlist(&relay, &community, &[joiner.public_key().to_hex()]).await.unwrap();
6957 become_local(&joiner);
6958 assert!(crate::db::community::load_community(&community.id).unwrap().is_some(), "community present pre-join");
6959
6960 let result = crate::VectorCore.finalize_member_join(community.clone(), &relay, None).await;
6961 assert!(result.is_err(), "a banned joiner's finalize must fail");
6962 assert!(result.unwrap_err().to_string().contains("banned"), "the error names the ban");
6963 assert!(
6964 crate::db::community::load_community(&community.id).unwrap().is_none(),
6965 "the just-saved community is torn back down — no orphaned row for a banned joiner"
6966 );
6967 }
6968
6969 #[tokio::test]
6975 async fn delete_community_wipes_every_community_scoped_table() {
6976 let (_tmp, _guard) = init_test_db();
6977 let relay = MemoryRelay::new();
6978 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6979 let cid = community.id.to_hex();
6980
6981 crate::db::community::store_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 1, &[0x11u8; 32]).unwrap();
6983 crate::db::community::save_public_invite("tok", &cid, "https://x/invite#y", None, None).unwrap();
6984 crate::db::community::save_pending_invite(&cid, "{}", "npub1inviter", 0).unwrap();
6985 crate::db::community::set_edition_head(&cid, &cid, 1, &[0x22u8; 32]).unwrap();
6986 crate::db::community::set_community_banlist(&cid, &["cc".repeat(32)], 100).unwrap();
6987
6988 assert!(crate::db::community::community_exists(&community.id).unwrap());
6990 assert!(!crate::db::community::held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty());
6991 assert!(!crate::db::community::list_public_invites(&cid).unwrap().is_empty());
6992 assert!(crate::db::community::list_pending_invites().unwrap().iter().any(|p| p.community_id == cid));
6993 assert!(!crate::db::community::get_all_edition_heads(&cid).unwrap().is_empty());
6994 assert!(!crate::db::community::get_community_banlist(&cid).unwrap().is_empty());
6995
6996 crate::db::community::delete_community(&cid).unwrap();
6997
6998 assert!(!crate::db::community::community_exists(&community.id).unwrap(), "communities row gone");
7000 assert!(crate::db::community::load_community(&community.id).unwrap().is_none(), "community not loadable");
7001 assert!(crate::db::community::held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty(), "epoch keys wiped");
7002 assert!(crate::db::community::list_public_invites(&cid).unwrap().is_empty(), "public invites wiped");
7003 assert!(!crate::db::community::list_pending_invites().unwrap().iter().any(|p| p.community_id == cid), "pending invites wiped");
7004 assert!(crate::db::community::get_all_edition_heads(&cid).unwrap().is_empty(), "edition heads wiped");
7005 assert!(crate::db::community::get_community_banlist(&cid).unwrap().is_empty(), "banlist wiped with the channels");
7006 }
7007
7008 #[tokio::test]
7012 async fn fetch_control_folded_skips_junk_injected_at_the_coordinate() {
7013 let (_tmp, _guard) = init_test_db();
7014 let relay = MemoryRelay::new();
7015 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7016 let owner_hex = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key().to_hex();
7017
7018 let z = crate::community::roster::control_pseudonym(&community.server_root_key, &community.id, community.server_root_epoch);
7020 let junk = nostr_sdk::prelude::EventBuilder::new(nostr_sdk::prelude::Kind::Custom(event_kind::COMMUNITY_CONTROL), "not a sealed edition")
7021 .tags([nostr_sdk::prelude::Tag::custom("z", [z])])
7022 .finalize(&Keys::generate())
7023 .unwrap();
7024 relay.publish(&junk, &community.relays).await.unwrap();
7025
7026 let folded = fetch_control_folded(&relay, &community).await.unwrap();
7027 assert!(
7028 !crate::community::roster::authorize_delegation(&folded, Some(&owner_hex)).roles.is_empty(),
7029 "the genuine Admin role still folds; the un-openable junk is silently dropped"
7030 );
7031 }
7032
7033 #[tokio::test]
7036 async fn fetch_control_folded_on_dead_relays_is_empty_not_a_panic() {
7037 let (_tmp, _guard) = init_test_db();
7038 let community = saved_community_owned_by(&Keys::generate());
7039 let folded = fetch_control_folded(&FailingRelay, &community).await.unwrap();
7040 assert!(folded.roles.roles.is_empty() && folded.root_meta.is_none(), "dead relays → empty fold, no panic");
7041 }
7042
7043 #[tokio::test]
7044 async fn successful_private_ban_leaves_no_read_cut_pending() {
7045 let (_tmp, _guard) = init_test_db();
7047 let relay = MemoryRelay::new();
7048 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7049 let cid = community.id.to_hex();
7050 publish_banlist(&relay, &community, &["cc".repeat(32)]).await.unwrap();
7051 assert!(!crate::db::community::get_read_cut_pending(&cid).unwrap(), "a successful re-seal leaves no pending read-cut");
7052 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
7053 assert_eq!(c.server_root_epoch, crate::community::Epoch(1));
7054 }
7055
7056 #[tokio::test]
7057 async fn failed_reseal_sets_pending_then_sync_retry_recovers() {
7058 let (_tmp, _guard) = init_test_db();
7063 let relay = RekeyFailingRelay::new(); let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7065 let cid = community.id.to_hex();
7066 let victim = "cc".repeat(32);
7067
7068 assert!(publish_banlist(&relay, &community, &[victim.clone()]).await.is_err(), "the re-seal's base rekey fails");
7070 assert!(crate::db::community::get_read_cut_pending(&cid).unwrap(), "a failed re-seal leaves read_cut_pending set");
7071 assert_eq!(crate::db::community::get_community_banlist(&cid).unwrap(), vec![victim.clone()], "the ban itself still applied");
7072 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
7073 assert_eq!(c.server_root_epoch, crate::community::Epoch(0), "base NOT rotated while the re-seal is pending");
7074
7075 relay.allow_rekey();
7077 retry_pending_read_cut(&relay, &c).await.unwrap();
7078 assert!(!crate::db::community::get_read_cut_pending(&cid).unwrap(), "pending cleared after the retry succeeds");
7079 let c2 = crate::db::community::load_community(&community.id).unwrap().unwrap();
7080 assert_eq!(c2.server_root_epoch, crate::community::Epoch(1), "the read-cut finally rotated the base");
7081 }
7082
7083 #[tokio::test]
7084 async fn privatize_reseals_to_observed_participants_not_just_owner() {
7085 use crate::community::derive::{base_rekey_pseudonym, recipient_pseudonym};
7089 use crate::community::rekey::{open_rekey_blob, open_rekey_event, rekey_pairwise_secret};
7090 use crate::types::Message;
7091 use nostr_sdk::prelude::ToBech32;
7092 let (_tmp, _guard) = init_test_db();
7093 let relay = MemoryRelay::new();
7094 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7095 let cid = community.id.to_hex();
7096 let genesis_root = *community.server_root_key.as_bytes();
7097 let channel_hex = community.channels[0].id.to_hex();
7098
7099 let alice = Keys::generate();
7101 let alice_b32 = alice.public_key().to_bech32().unwrap();
7102 let mut m = Message::default();
7103 m.id = "aa".repeat(32);
7104 m.npub = Some(alice_b32.clone());
7105 m.at = 1000;
7106 crate::db::events::save_message(&channel_hex, &m).await.unwrap();
7107 assert!(
7108 crate::db::community::community_member_activity(&cid).unwrap().iter().any(|(np, _)| np == &alice_b32),
7109 "alice is an observed participant"
7110 );
7111
7112 let (token_hex, _) = create_public_invite(&relay, &community, None, None).await.unwrap();
7114 revoke_public_invite(&relay, &community, &crate::simd::hex::hex_to_bytes_32(&token_hex)).await.unwrap();
7115 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
7116 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(1), "privatize rotated the base");
7117
7118 let addr = base_rekey_pseudonym(&crate::community::ServerRootKey(genesis_root), &community.id, crate::community::Epoch(1)).to_hex();
7121 let found = relay
7122 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() }, &community.relays)
7123 .await
7124 .unwrap();
7125 assert_eq!(found.len(), 1, "the base rekey is published");
7126 let parsed = open_rekey_event(&found[0], &genesis_root).unwrap();
7127 let secret = rekey_pairwise_secret(alice.secret_key(), &parsed.rotator).unwrap();
7128 let loc = recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
7129 let alice_blob = parsed.blobs.iter().find(|b| b.locator == loc).expect("alice's blob present (NOT sealed out)");
7130 let recovered = open_rekey_blob(alice.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, alice_blob).unwrap();
7131 assert_eq!(reloaded.server_root_key.as_bytes(), &recovered, "alice recovers the new root = owner's advanced base");
7132 }
7133
7134 #[tokio::test]
7135 async fn unpermissioned_invite_links_edition_is_rejected() {
7136 let (_tmp, _guard) = init_test_db();
7140 let relay = MemoryRelay::new();
7141 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7142
7143 let mallory = Keys::generate();
7144 let loc = "2b".repeat(32);
7145 let inner = crate::community::roster::build_invite_links_edition(&mallory, &community.id, &[loc], 1, None, 1000, None).unwrap();
7146 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
7147 relay.inject(&outer, &community.relays);
7148
7149 let applied = fetch_and_apply_invite_links(&relay, &community).await.unwrap();
7150 assert!(applied.is_empty(), "an unpermissioned member's link edition is rejected");
7151 assert!(!is_public(&community).unwrap(), "mode stays Private despite the forged edition");
7152 }
7153
7154 #[tokio::test]
7155 async fn invite_links_union_across_authorized_creators() {
7156 let (_tmp, _guard) = init_test_db();
7160 let relay = MemoryRelay::new();
7161 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7162 let cid = community.id.to_hex();
7163
7164 create_public_invite(&relay, &community, None, None).await.unwrap();
7166 let owner_loc = public_invite::locator_hex(&crate::simd::hex::hex_to_bytes_32(
7167 &crate::db::community::list_public_invites(&cid).unwrap()[0].token));
7168
7169 let admin = Keys::generate();
7171 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
7172 set_member_grant(&relay, &community, &admin.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
7173 let admin_loc = "ab".repeat(32);
7174 let inner = crate::community::roster::build_invite_links_edition(&admin, &community.id, &[admin_loc.clone()], 1, None, 2000, None).unwrap();
7175 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
7176 relay.inject(&outer, &community.relays);
7177
7178 let agg = fetch_and_apply_invite_links(&relay, &community).await.unwrap();
7179 assert!(agg.contains(&owner_loc), "owner's link in the aggregate");
7180 assert!(agg.contains(&admin_loc), "the granted admin's link unions in too");
7181 assert!(is_public(&community).unwrap());
7182
7183 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
7187 let owner_token = crate::db::community::list_public_invites(&cid).unwrap()[0].token.clone();
7188 revoke_public_invite(&relay, &c, &crate::simd::hex::hex_to_bytes_32(&owner_token)).await.unwrap();
7189 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
7190 assert_eq!(c.server_root_epoch, crate::community::Epoch(0), "another creator's link remains → no privatize rekey");
7191 assert!(is_public(&c).unwrap(), "still Public (admin's link is live)");
7192 }
7193
7194 #[tokio::test]
7195 async fn invite_registry_retains_a_persisted_creator_on_a_partial_fold() {
7196 let (_tmp, _guard) = init_test_db();
7201 let relay = MemoryRelay::new();
7202 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7203 let cid = community.id.to_hex();
7204
7205 create_public_invite(&relay, &community, None, None).await.unwrap();
7206 let owner_loc = public_invite::locator_hex(&crate::simd::hex::hex_to_bytes_32(
7207 &crate::db::community::list_public_invites(&cid).unwrap()[0].token));
7208 let agg = fetch_and_apply_invite_links(&relay, &community).await.unwrap();
7209 assert!(agg.contains(&owner_loc), "the mint folds + persists normally");
7210
7211 let partial = MemoryRelay::new();
7213 let agg = fetch_and_apply_invite_links(&partial, &community).await.unwrap();
7214 assert!(agg.contains(&owner_loc), "an absent edition retains the persisted locators");
7215 assert!(is_public(&community).unwrap(), "mode survives the partial view");
7216 }
7217
7218 #[tokio::test]
7219 async fn invite_registry_drops_a_demoted_creator_whose_edition_is_present() {
7220 let (_tmp, _guard) = init_test_db();
7225 let relay = MemoryRelay::new();
7226 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7227 let cid = community.id.to_hex();
7228
7229 let admin = Keys::generate();
7230 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
7231 set_member_grant(&relay, &community, &admin.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
7232 let admin_loc = "ab".repeat(32);
7233 let inner = crate::community::roster::build_invite_links_edition(&admin, &community.id, &[admin_loc.clone()], 1, None, 2000, None).unwrap();
7234 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
7235 relay.inject(&outer, &community.relays);
7236 let agg = fetch_and_apply_invite_links(&relay, &community).await.unwrap();
7237 assert!(agg.contains(&admin_loc), "the granted admin's link folds + persists");
7238
7239 set_member_grant(&relay, &community, &admin.public_key().to_hex(), vec![]).await.unwrap();
7242 let agg = fetch_and_apply_invite_links(&relay, &community).await.unwrap();
7243 assert!(!agg.contains(&admin_loc), "a present-but-unauthorized edition drops the persisted row");
7244 assert!(!is_public(&community).unwrap(), "no live authorized link → Private");
7245 }
7246
7247 #[tokio::test]
7248 async fn failed_banlist_publish_does_not_persist_locally() {
7249 let (_tmp, _guard) = init_test_db();
7252 let relay = MemoryRelay::new();
7253 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7254 let id_hex = community.id.to_hex();
7255 assert!(crate::db::community::get_community_banlist(&id_hex).unwrap().is_empty());
7256
7257 let victim = "cc".repeat(32);
7258 let err = publish_banlist(&FailingRelay, &community, &[victim]).await;
7259 assert!(err.is_err(), "a failed publish must propagate");
7260 assert!(
7261 crate::db::community::get_community_banlist(&id_hex).unwrap().is_empty(),
7262 "local banlist must be untouched when the publish failed"
7263 );
7264 }
7265
7266 #[tokio::test]
7267 async fn metadata_failed_publish_does_not_persist_locally() {
7268 let (_tmp, _guard) = init_test_db();
7272 let relay = MemoryRelay::new();
7273 let mut community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7274 community.name = "Renamed HQ".to_string();
7275 assert!(republish_community_metadata(&FailingRelay, &community).await.is_err());
7276 let loaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
7277 assert_eq!(loaded.name, "HQ", "a failed metadata publish leaves the local name unchanged");
7278 }
7279
7280 #[tokio::test]
7281 async fn send_persists_key_then_delete_round_trip() {
7282 let (_tmp, _guard) = init_test_db();
7283 let relay = MemoryRelay::new();
7284 let community = Community::create("HQ", "general", vec!["r1".into()]);
7285 let channel = community.channels[0].clone();
7286 let alice = Keys::generate();
7287
7288 let _outer = send_message(&relay, &community, &channel, &alice, "deletable", 1).await.unwrap();
7290 let before = fetch_channel_messages(&relay, &community, &channel).await.unwrap();
7291 assert_eq!(before.len(), 1);
7292 let message_id = before[0].message_id.to_hex();
7293
7294 delete_message(&relay, &message_id).await.unwrap();
7296 let after = fetch_channel_messages(&relay, &community, &channel).await.unwrap();
7297 assert!(after.is_empty(), "message should be deleted after delete_message");
7298
7299 assert!(delete_message(&relay, &message_id).await.is_err());
7301 }
7302
7303 #[tokio::test]
7304 async fn failed_delete_publish_preserves_key() {
7305 let (_tmp, _guard) = init_test_db();
7308 let relay = MemoryRelay::new();
7309 let community = Community::create("HQ", "general", vec!["r1".into()]);
7310 let channel = community.channels[0].clone();
7311 let alice = Keys::generate();
7312 send_message(&relay, &community, &channel, &alice, "delete me", 1).await.unwrap();
7313 let message_id = fetch_channel_messages(&relay, &community, &channel).await.unwrap()[0]
7314 .message_id
7315 .to_hex();
7316
7317 assert!(delete_message(&FailingRelay, &message_id).await.is_err());
7319
7320 delete_message(&relay, &message_id).await.unwrap();
7322 assert!(fetch_channel_messages(&relay, &community, &channel).await.unwrap().is_empty());
7323 }
7324
7325 #[tokio::test]
7326 async fn delete_unknown_message_errors() {
7327 let (_tmp, _guard) = init_test_db();
7328 let relay = MemoryRelay::new();
7329 let fake = Keys::generate();
7331 let bogus = EventBuilder::new(Kind::Custom(1), "x").finalize(&fake).unwrap().id;
7332 assert!(delete_message(&relay, &bogus.to_hex()).await.is_err());
7333 }
7334
7335 #[tokio::test]
7336 async fn accept_invite_persists_member_view() {
7337 let (_tmp, _guard) = init_test_db();
7338 let owner = Community::create("HQ", "general", vec!["r1".into()]);
7339 let invite = crate::community::invite::build_invite(&owner);
7340
7341 let joined = accept_invite(&invite).expect("accept");
7342 assert!(!is_proven_owner(&joined), "joined as member, not owner");
7343 let loaded = crate::db::community::load_community(&owner.id).unwrap().expect("saved");
7345 assert_eq!(loaded.channels[0].key.as_bytes(), owner.channels[0].key.as_bytes());
7346 }
7347
7348 #[tokio::test]
7349 async fn accept_invite_does_not_downgrade_owned_community() {
7350 let (_tmp, _guard) = init_test_db();
7353 let relay = MemoryRelay::new();
7354 let owner = create_community(&relay, "HQ", "general", vec![]).await.unwrap();
7355 assert!(is_proven_owner(&owner), "we are the proven owner");
7356
7357 let invite = crate::community::invite::build_invite(&owner);
7358 let err = accept_invite(&invite).unwrap_err();
7359 assert!(err.contains("already own"), "must refuse to downgrade an owned community, got: {err}");
7360
7361 let reloaded = crate::db::community::load_community(&owner.id).unwrap().unwrap();
7363 assert_eq!(reloaded.server_root_key.as_bytes(), owner.server_root_key.as_bytes());
7364 }
7365
7366 #[tokio::test]
7371 async fn stale_v1_invite_cannot_reparent_a_migrated_communitys_channels() {
7372 let (_tmp, _guard) = init_test_db();
7373 let v1 = Community::create("Guild", "general", vec!["wss://r1".into()]);
7375 let stale_invite = crate::community::invite::build_invite(&v1);
7376 accept_invite(&stale_invite).expect("initial join");
7377 let v1_cid = v1.id.to_hex();
7378 let channel_hex = v1.channels[0].id.to_hex();
7379 assert_eq!(
7380 crate::db::community::community_id_for_channel(&channel_hex).unwrap().as_deref(),
7381 Some(v1_cid.as_str()),
7382 "precondition: the channel row starts parented to v1"
7383 );
7384
7385 let v2_cid = "9f".repeat(32);
7387 crate::db::community::reparent_channels_and_fence(&v1_cid, &v2_cid).unwrap();
7388 assert_eq!(
7389 crate::db::community::community_id_for_channel(&channel_hex).unwrap().as_deref(),
7390 Some(v2_cid.as_str()),
7391 "precondition: the flip moved the channel to the twin"
7392 );
7393
7394 let err = accept_invite(&stale_invite).unwrap_err();
7396 assert!(
7397 err.contains("upgraded to Concord v2"),
7398 "a migrated community must refuse a v1 re-accept, got: {err}"
7399 );
7400
7401 assert_eq!(
7403 crate::db::community::community_id_for_channel(&channel_hex).unwrap().as_deref(),
7404 Some(v2_cid.as_str()),
7405 "the refused accept must not have re-parented the channel back to v1"
7406 );
7407 assert_eq!(
7409 crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(),
7410 Some(v2_cid.as_str())
7411 );
7412 }
7413
7414 #[tokio::test]
7418 async fn accept_invite_still_works_for_a_live_v1_community() {
7419 let (_tmp, _guard) = init_test_db();
7420 let v1 = Community::create("Guild", "general", vec!["wss://r1".into()]);
7421 let invite = crate::community::invite::build_invite(&v1);
7422 accept_invite(&invite).expect("initial join");
7423 accept_invite(&invite).expect("re-accept on a live v1 community must still work");
7425 assert!(crate::db::community::get_migrated_to(&v1.id.to_hex()).unwrap().is_none());
7426 }
7427
7428 #[tokio::test]
7433 async fn a_fresh_join_is_never_gated_by_another_communitys_fence() {
7434 let (_tmp, _guard) = init_test_db();
7435 let migrated = Community::create("Old", "general", vec!["wss://r1".into()]);
7437 accept_invite(&crate::community::invite::build_invite(&migrated)).unwrap();
7438 crate::db::community::reparent_channels_and_fence(&migrated.id.to_hex(), &"9f".repeat(32)).unwrap();
7439
7440 let fresh = Community::create("New", "general", vec!["wss://r2".into()]);
7442 accept_invite(&crate::community::invite::build_invite(&fresh)).expect("fresh join must not be gated");
7443 assert!(crate::db::community::load_community(&fresh.id).unwrap().is_some());
7444 }
7445
7446 #[tokio::test]
7451 async fn a_fresh_v1_join_past_the_timelock_needs_a_migration_carrier() {
7452 let (_tmp, _guard) = init_test_db();
7453 let relay = MemoryRelay::new();
7454 let unlock = crate::community::migration::MIGRATION_UNLOCK_AT;
7455
7456 let owner_keys = Keys::generate();
7457 become_local(&owner_keys);
7458 let owned = attested_community("Legacy", "general", vec!["wss://r1".into()]);
7459 let invite = crate::community::invite::build_invite(&owned);
7460 let member_view = crate::community::invite::accept_invite(&invite).expect("decode");
7461
7462 crate::community::migration::gate_fresh_v1_join(&relay, &member_view, unlock - 1)
7463 .await
7464 .expect("pre-unlock fresh join passes without a probe");
7465
7466 let err = crate::community::migration::gate_fresh_v1_join(&relay, &member_view, unlock)
7467 .await
7468 .unwrap_err();
7469 assert!(err.contains("legacy protocol"), "a live v1 community refuses post-unlock, got: {err}");
7470
7471 let sp = crate::community::migration::MigrationSignpost {
7473 v2_community_id: "ab".repeat(32),
7474 owner_xonly: owner_keys.public_key().to_hex(),
7475 owner_salt: "cd".repeat(32),
7476 relays: vec!["wss://r1".into()],
7477 name: "Legacy".into(),
7478 primary_channel: owned.channels[0].id.to_hex(),
7479 root_epoch: 0,
7480 };
7481 let content = crate::community::migration::build_migration_content(&sp, None).unwrap();
7482 publish_migration_carrier(&relay, &owned, &content).await.expect("carrier lands");
7483 crate::community::migration::gate_fresh_v1_join(&relay, &member_view, unlock)
7484 .await
7485 .expect("a carrier-bearing community stays joinable (v2 on-ramp)");
7486
7487 crate::db::community::save_community(&member_view).unwrap();
7489 crate::community::migration::gate_fresh_v1_join(&relay, &member_view, unlock)
7490 .await
7491 .expect("a held community passes post-unlock");
7492 }
7493
7494 #[tokio::test]
7497 async fn metadata_republish_refuses_after_migration() {
7498 let (_tmp, _guard) = init_test_db();
7499 let owner = Keys::generate();
7500 become_local(&owner);
7501 let community = saved_community_owned_by(&owner);
7502 let cid = community.id.to_hex();
7503 let channel_id = community.channels[0].id;
7504 let relay = MemoryRelay::new();
7505
7506 crate::db::community::reparent_channels_and_fence(&cid, &"9f".repeat(32)).unwrap();
7507
7508 let err = republish_community_metadata(&relay, &community).await.unwrap_err();
7509 assert!(err.contains("upgraded to Concord v2"), "community metadata edit gated, got: {err}");
7510 let err = republish_channel_metadata(&relay, &community, &channel_id, "renamed").await.unwrap_err();
7511 assert!(err.contains("upgraded to Concord v2"), "channel rename gated, got: {err}");
7512 assert!(
7515 crate::db::community::get_edition_head(&cid, &cid).unwrap().is_none(),
7516 "no community edition was published"
7517 );
7518 assert!(
7519 crate::db::community::get_edition_head(&cid, &channel_id.to_hex()).unwrap().is_none(),
7520 "no channel edition was published"
7521 );
7522 }
7523
7524 #[tokio::test]
7525 async fn accept_invite_rejects_id_collision_under_different_authority() {
7526 let (_tmp, _guard) = init_test_db();
7531 let legit = Community::create("X", "general", vec!["wss://legit".into()]);
7532 let member_x = accept_invite(&crate::community::invite::build_invite(&legit)).unwrap();
7533 let original_key = member_x.channels[0].key.as_bytes().to_vec();
7534
7535 let attacker = Community::create("evil", "general", vec!["wss://evil".into()]);
7537 let mut hostile = crate::community::invite::build_invite(&attacker);
7538 hostile.community_id = legit.id.to_hex();
7539 assert_ne!(hostile.server_root_key, crate::simd::hex::bytes_to_hex_32(member_x.server_root_key.as_bytes()));
7542
7543 assert!(accept_invite(&hostile).is_err(), "id-collision under new authority must be rejected");
7544
7545 let reloaded = crate::db::community::load_community(&legit.id).unwrap().unwrap();
7547 assert_eq!(reloaded.channels[0].key.as_bytes().to_vec(), original_key);
7548 assert_eq!(reloaded.relays, vec!["wss://legit".to_string()]);
7549 }
7550
7551 #[tokio::test]
7552 async fn rejected_accept_leaves_pending_invite_intact() {
7553 let (_tmp, _guard) = init_test_db();
7556
7557 let owner = attested_community("HQ", "general", vec![]);
7559 crate::db::community::save_community(&owner).unwrap();
7560 let bundle = crate::community::invite::build_invite(&owner).to_json().unwrap();
7561 let cid = owner.id.to_hex();
7562 crate::db::community::save_pending_invite(&cid, &bundle, "npub1inviter", 0).unwrap();
7563
7564 let peeked = crate::db::community::get_pending_invite(&cid).unwrap().expect("parked");
7566 let invite = crate::community::invite::CommunityInvite::from_json(&peeked).unwrap();
7567 assert!(accept_invite(&invite).is_err(), "owning the id → reject");
7568 assert!(
7569 crate::db::community::pending_invite_exists(&cid).unwrap(),
7570 "rejected accept must leave the invite parked"
7571 );
7572
7573 let other = Community::create("Other", "general", vec![]);
7575 let ob = crate::community::invite::build_invite(&other).to_json().unwrap();
7576 let ocid = other.id.to_hex();
7577 crate::db::community::save_pending_invite(&ocid, &ob, "npub1inviter", 0).unwrap();
7578 let op = crate::db::community::get_pending_invite(&ocid).unwrap().unwrap();
7579 let oinvite = crate::community::invite::CommunityInvite::from_json(&op).unwrap();
7580 accept_invite(&oinvite).expect("accept ok");
7581 crate::db::community::delete_pending_invite(&ocid).unwrap();
7582 assert!(!crate::db::community::pending_invite_exists(&ocid).unwrap(), "cleared on success");
7583 }
7584
7585 #[tokio::test]
7586 async fn public_invite_create_fetch_accept_revoke_round_trip() {
7587 let (_tmp, _guard) = init_test_db();
7588 let relay = MemoryRelay::new();
7589 let mut owner = Community::create("Public HQ", "general", vec!["r1".into(), "r2".into()]);
7590 owner.description = Some("everyone welcome".into());
7591 let owner_keys = crate::state::MY_SECRET_KEY.to_keys().unwrap();
7595 owner.owner_attestation = Some(
7596 crate::community::owner::build_owner_attestation_unsigned(owner_keys.public_key(), &owner.id.to_hex())
7597 .finalize(&owner_keys).unwrap().as_json(),
7598 );
7599 let (token_hex, url) = create_public_invite(&relay, &owner, None, None).await.expect("mint");
7601 assert!(url.contains('#'));
7602 assert_eq!(crate::db::community::list_public_invites(&owner.id.to_hex()).unwrap().len(), 1);
7603
7604 let (relays, token) = public_invite::parse_invite_url(&url).unwrap();
7606 assert_eq!(crate::simd::hex::bytes_to_hex_32(&token), token_hex);
7607 let bundle = fetch_public_invite(&relay, &relays, &token).await.expect("fetch");
7608 assert_eq!(bundle.preview.name, "Public HQ");
7609 assert_eq!(bundle.preview.description.as_deref(), Some("everyone welcome"));
7610
7611 let joined = accept_public_invite(&bundle, 0).expect("accept");
7612 assert_eq!(joined.id, owner.id);
7613 assert_eq!(joined.description.as_deref(), Some("everyone welcome"), "preview patched in");
7614
7615 revoke_public_invite(&relay, &owner, &token).await.expect("revoke");
7617 assert!(fetch_public_invite(&relay, &relays, &token).await.is_err(), "revoked link is dead");
7618 assert!(crate::db::community::list_public_invites(&owner.id.to_hex()).unwrap().is_empty());
7619 }
7620
7621 #[tokio::test]
7622 async fn revoked_invite_dies_even_if_one_relay_kept_the_bundle() {
7623 let (_tmp, _guard) = init_test_db();
7627 let relay = MemoryRelay::new();
7628 let owner = attested_community("HQ", "general", vec!["r1".into(), "r2".into()]);
7629 let (_token_hex, url) = create_public_invite(&relay, &owner, None, None).await.unwrap();
7630 let (relays, token) = public_invite::parse_invite_url(&url).unwrap();
7631 assert!(fetch_public_invite(&relay, &relays, &token).await.is_ok(), "live on both relays");
7632
7633 let tombstone = public_invite::build_public_invite_tombstone(&token).unwrap();
7635 relay.inject(&tombstone, &["r1".to_string()]);
7636
7637 assert!(
7638 fetch_public_invite(&relay, &relays, &token).await.is_err(),
7639 "a tombstone on any one relay kills the link, even with a stale live bundle elsewhere",
7640 );
7641 }
7642
7643 #[tokio::test]
7644 async fn fetch_skips_relay_shadow_junk_to_genuine_bundle() {
7645 use nostr_sdk::prelude::{EventBuilder, Keys, Kind, Tag, Timestamp};
7649
7650 let (_tmp, _guard) = init_test_db();
7651 let relay = MemoryRelay::new();
7652 let owner = attested_community("HQ", "general", vec!["r1".into()]);
7653 let (_t, url) = create_public_invite(&relay, &owner, None, None).await.unwrap();
7654 let (relays, token) = public_invite::parse_invite_url(&url).unwrap();
7655
7656 let attacker = Keys::generate();
7659 let junk = EventBuilder::new(Kind::Custom(event_kind::APPLICATION_SPECIFIC), "garbage")
7660 .tags([
7661 Tag::identifier(public_invite::locator_hex(&token)),
7662 Tag::custom("vsk", ["6".to_string()]),
7663 Tag::custom("v", ["1".to_string()]),
7664 ])
7665 .custom_created_at(Timestamp::from_secs(9_000_000_000))
7666 .finalize(&attacker)
7667 .unwrap();
7668 relay.publish(&junk, &relays).await.unwrap();
7669
7670 let bundle = fetch_public_invite(&relay, &relays, &token).await.expect("genuine survives shadow");
7672 assert_eq!(bundle.preview.name, "HQ");
7673 }
7674
7675 #[tokio::test]
7676 async fn expired_public_invite_is_refused() {
7677 let (_tmp, _guard) = init_test_db();
7678 let relay = MemoryRelay::new();
7679 let owner = attested_community("HQ", "general", vec!["r1".into()]);
7680 let (_t, url) = create_public_invite(&relay, &owner, Some(1000), None).await.unwrap();
7681 let (relays, token) = public_invite::parse_invite_url(&url).unwrap();
7682 let bundle = fetch_public_invite(&relay, &relays, &token).await.unwrap();
7683 assert!(accept_public_invite(&bundle, 2000).is_err());
7685 assert!(crate::db::community::load_community(&owner.id).unwrap().is_none());
7686 }
7687
7688 #[tokio::test]
7689 async fn republish_metadata_saves_and_publishes() {
7690 use crate::community::CommunityImage;
7691 let (_tmp, _guard) = init_test_db();
7692 let relay = MemoryRelay::new();
7693 let mut owner = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7696 let cid = owner.id.to_hex();
7697
7698 owner.name = "HQ Renamed".into();
7700 owner.description = Some("now with topic".into());
7701 owner.icon = Some(CommunityImage {
7702 url: "https://b/x".into(), key: "aa".repeat(32), nonce: "bb".repeat(12),
7703 hash: "cc".repeat(32), ext: "png".into(),
7704 });
7705 republish_community_metadata(&relay, &owner).await.expect("republish");
7706
7707 let loaded = crate::db::community::load_community(&owner.id).unwrap().unwrap();
7709 assert_eq!(loaded.name, "HQ Renamed");
7710 assert_eq!(loaded.description.as_deref(), Some("now with topic"));
7711 assert_eq!(loaded.icon.unwrap().url, "https://b/x");
7712
7713 let (head_v, _) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
7716 assert_eq!(head_v, 2, "GroupRoot edition advanced v1 (create) → v2 (republish)");
7717 let z = crate::community::roster::control_pseudonym(&owner.server_root_key, &owner.id, crate::community::Epoch(0));
7718 let control = relay
7719 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], ..Default::default() }, &owner.relays)
7720 .await
7721 .unwrap();
7722 let newest = control
7723 .iter()
7724 .filter_map(|o| crate::community::roster::open_control_edition(o, &owner.server_root_key).ok())
7725 .filter_map(|i| crate::community::edition::parse_edition_inner(&i).ok())
7726 .filter(|p| p.entity_id == owner.id.0)
7727 .max_by_key(|p| p.version)
7728 .expect("GroupRoot edition on the relay");
7729 let meta: crate::community::metadata::CommunityMetadata = serde_json::from_str(&newest.content).unwrap();
7730 assert_eq!(meta.name, "HQ Renamed");
7731 assert_eq!(meta.icon.unwrap().ext, "png");
7732 }
7733
7734 #[tokio::test]
7735 async fn member_cannot_republish_metadata() {
7736 let (_tmp, _guard) = init_test_db();
7737 let relay = MemoryRelay::new();
7738 let owner = Community::create("HQ", "general", vec!["r1".into()]);
7739 let member = crate::community::invite::accept_invite(&crate::community::invite::build_invite(&owner)).unwrap();
7740 assert!(republish_community_metadata(&relay, &member).await.is_err());
7741 }
7742
7743 #[tokio::test]
7744 async fn member_cannot_mint_public_invite() {
7745 let (_tmp, _guard) = init_test_db();
7746 let relay = MemoryRelay::new();
7747 let owner = Community::create("HQ", "general", vec!["r1".into()]);
7748 let member = crate::community::invite::accept_invite(&crate::community::invite::build_invite(&owner)).unwrap();
7749 assert!(create_public_invite(&relay, &member, None, None).await.is_err(), "members can't mint links");
7750 }
7751
7752 #[tokio::test]
7753 async fn accept_oversized_bundle_rejected() {
7754 let (_tmp, _guard) = init_test_db();
7755 let owner = Community::create("HQ", "general", vec![]);
7756 let mut invite = crate::community::invite::build_invite(&owner);
7757 let template = invite.channels[0].clone();
7759 for _ in 0..300 {
7760 invite.channels.push(template.clone());
7761 }
7762 assert!(accept_invite(&invite).is_err(), "oversized bundle must be rejected");
7763 assert!(crate::db::community::load_community(&owner.id).unwrap().is_none(), "nothing persisted");
7764 }
7765
7766 async fn publish_tombstone<T: Transport + ?Sized>(transport: &T, community: &Community, author: &Keys, created_at: u64) {
7772 let inner = crate::community::roster::build_group_dissolved_edition_unsigned(author.public_key(), &community.id, created_at)
7773 .finalize(author).unwrap();
7774 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch).unwrap();
7775 transport.publish_durable(&outer, &community.relays).await.unwrap();
7776 }
7777
7778 struct RekeyCountingRelay {
7781 inner: MemoryRelay,
7782 rekeys: std::sync::atomic::AtomicUsize,
7783 }
7784 impl RekeyCountingRelay {
7785 fn new() -> Self { Self { inner: MemoryRelay::new(), rekeys: std::sync::atomic::AtomicUsize::new(0) } }
7786 fn count(&self, e: &Event) {
7787 if e.kind.as_u16() == event_kind::COMMUNITY_REKEY {
7788 self.rekeys.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
7789 }
7790 }
7791 }
7792 #[async_trait::async_trait]
7793 impl Transport for RekeyCountingRelay {
7794 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
7795 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> { self.count(e); self.inner.publish(e, r).await }
7796 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> { self.count(e); self.inner.publish_durable(e, r).await }
7797 async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> { self.inner.fetch(q, r).await }
7798 }
7799
7800 #[tokio::test]
7801 async fn owner_tombstone_folds_to_dissolved() {
7802 let (_tmp, _guard) = init_test_db();
7803 let relay = MemoryRelay::new();
7804 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7806 let cid = community.id.to_hex();
7807 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
7808 publish_tombstone(&relay, &community, &owner, 1000).await;
7809
7810 assert!(!crate::db::community::get_community_dissolved(&cid).unwrap(), "alive before the fold");
7811 fetch_and_apply_control(&relay, &community).await.unwrap();
7812 assert!(crate::db::community::get_community_dissolved(&cid).unwrap(), "owner tombstone seals the community");
7813 }
7814
7815 #[tokio::test]
7816 async fn non_owner_tombstone_is_ignored() {
7817 let (_tmp, _guard) = init_test_db();
7818 let relay = MemoryRelay::new();
7819 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7820 let cid = community.id.to_hex();
7821 let mallory = Keys::generate();
7824 publish_tombstone(&relay, &community, &mallory, 1000).await;
7825
7826 fetch_and_apply_control(&relay, &community).await.unwrap();
7827 assert!(!crate::db::community::get_community_dissolved(&cid).unwrap(), "a non-owner tombstone is ignored");
7828 }
7829
7830 #[tokio::test]
7831 async fn unreadable_deed_rejects_the_tombstone() {
7832 let (_tmp, _guard) = init_test_db();
7833 let relay = MemoryRelay::new();
7834 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
7835 let mut community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7836 let cid = community.id.to_hex();
7837 publish_tombstone(&relay, &community, &owner, 1000).await;
7838 community.owner_attestation = None;
7840 crate::db::community::save_community(&community).unwrap();
7841 let stripped = crate::db::community::load_community(&community.id).unwrap().unwrap();
7842
7843 fetch_and_apply_control(&relay, &stripped).await.unwrap();
7844 assert!(!crate::db::community::get_community_dissolved(&cid).unwrap(), "unverifiable tombstone is rejected, not death-by-default");
7845 }
7846
7847 #[tokio::test]
7848 async fn binary_seal_drops_every_subsequent_event_with_no_timestamp_test() {
7849 let (_tmp, _guard) = init_test_db();
7850 let relay = MemoryRelay::new();
7851 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
7852 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7853 let cid = community.id.to_hex();
7854 publish_tombstone(&relay, &community, &owner, 1000).await;
7855 fetch_and_apply_control(&relay, &community).await.unwrap();
7856 assert!(crate::db::community::get_community_dissolved(&cid).unwrap());
7857
7858 let sealed = crate::db::community::load_community(&community.id).unwrap().unwrap();
7860 let channel = sealed.channels[0].clone();
7861 let me = owner.public_key();
7862
7863 let backdated = super::super::envelope::seal_message(
7865 &Keys::generate(), &channel.key, &channel.id, channel.epoch, "ghost", 1,
7866 ).unwrap();
7867 let mut state = crate::state::ChatState::new();
7868 assert!(super::super::inbound::process_incoming(&mut state, &backdated, &channel, &me).is_none(),
7869 "a backdated message after the seal is dropped (binary seal, no timestamp test)");
7870
7871 publish_tombstone(&relay, &sealed, &owner, 2000).await;
7873 assert_eq!(fetch_and_apply_control(&relay, &sealed).await.unwrap(), 0,
7874 "control fold stops advancing once sealed");
7875 }
7876
7877 #[tokio::test]
7878 async fn dissolve_community_emits_no_rekey_and_no_epoch_bump() {
7879 let (_tmp, _guard) = init_test_db();
7880 let relay = RekeyCountingRelay::new();
7881 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7882 let cid = community.id.to_hex();
7883 create_public_invite(&relay, &community, None, None).await.unwrap();
7885 let before_epoch = crate::db::community::load_community(&community.id).unwrap().unwrap().server_root_epoch;
7886
7887 dissolve_community(&relay, &community).await.unwrap();
7888
7889 assert!(crate::db::community::get_community_dissolved(&cid).unwrap(), "sealed locally");
7890 assert_eq!(relay.rekeys.load(std::sync::atomic::Ordering::Relaxed), 0,
7891 "dissolution publishes NO 3303 rekey (no last-link privatize re-founding)");
7892 assert_eq!(crate::db::community::load_community(&community.id).unwrap().unwrap().server_root_epoch, before_epoch,
7893 "base epoch unchanged — dissolution rotates nothing");
7894 }
7895
7896 #[tokio::test]
7900 async fn migration_carrier_tombstone_seals_and_persists_the_pointer() {
7901 let (_tmp, _guard) = init_test_db();
7902 let relay = MemoryRelay::new();
7903 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
7904 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7905 let cid = community.id.to_hex();
7906
7907 let signpost = crate::community::migration::MigrationSignpost {
7909 v2_community_id: "ab".repeat(32),
7910 owner_xonly: owner.public_key().to_hex(),
7911 owner_salt: "cd".repeat(32),
7912 relays: vec!["r1".into()],
7913 name: "HQ".into(),
7914 primary_channel: community.channels[0].id.to_hex(),
7915 root_epoch: 0,
7916 };
7917 let m = crate::community::migration::seal_m(community.server_root_key.as_bytes(), b"jm").unwrap();
7918 let content = crate::community::migration::build_migration_content(&signpost, Some(m)).unwrap();
7919 let inner = crate::community::roster::build_group_dissolved_edition_with_content(&owner, &community.id, 1000, &content).unwrap();
7920 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch).unwrap();
7921 relay.publish_durable(&outer, &community.relays).await.unwrap();
7922
7923 fetch_and_apply_control(&relay, &community).await.unwrap();
7924
7925 assert!(crate::db::community::get_community_dissolved(&cid).unwrap(), "carrier still seals");
7926 let stored = crate::db::community::get_migration_pointer(&cid).unwrap().expect("pointer persisted");
7927 let parsed = crate::community::migration::parse_migration_payload(&stored).unwrap();
7928 assert_eq!(parsed.signpost.v2_community_id, "ab".repeat(32));
7929 assert!(parsed.m.is_some(), "the sealed key material rode along");
7930 }
7931
7932 #[test]
7936 fn migration_exemption_gates_the_dissolved_base_rekey() {
7937 let (_tmp, _guard) = init_test_db();
7938 let owner = Keys::generate();
7939 let me = Keys::generate();
7940 become_local(&me);
7941 let community = saved_community_owned_by(&owner);
7942 let cid = community.id.to_hex();
7943 crate::db::community::set_community_dissolved(&cid).unwrap();
7944
7945 let parsed = owner_base_rekey(&owner, &community, &me.public_key(), 1, &[0xCDu8; 32]);
7946 assert!(apply_server_root_rekey(&community, &parsed).is_err());
7948
7949 let signpost = crate::community::migration::MigrationSignpost {
7951 v2_community_id: "ab".repeat(32), owner_xonly: owner.public_key().to_hex(),
7952 owner_salt: "cd".repeat(32), relays: vec![], name: "x".into(),
7953 primary_channel: "ef".repeat(32), root_epoch: 5,
7954 };
7955 let content = crate::community::migration::build_migration_content(&signpost, Some("bTE=".into())).unwrap();
7956 crate::db::community::set_migration_pointer(&cid, &content).unwrap();
7957 assert!(crate::community::migration::catchup_exempt(&cid, 1), "epoch 1 <= publish epoch 5 → exempt");
7958 assert!(!crate::community::migration::catchup_exempt(&cid, 6), "beyond the publish epoch → not exempt");
7959
7960 crate::db::community::set_migrated_to(&cid, &"ab".repeat(32)).unwrap();
7962 assert!(!crate::community::migration::catchup_exempt(&cid, 1), "flipped → fence stands");
7963 }
7964
7965 #[tokio::test]
7966 async fn duplicate_owner_tombstones_are_idempotent() {
7967 let (_tmp, _guard) = init_test_db();
7968 let relay = MemoryRelay::new();
7969 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
7970 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7971 let cid = community.id.to_hex();
7972 publish_tombstone(&relay, &community, &owner, 1000).await;
7974 publish_tombstone(&relay, &community, &owner, 2000).await;
7975
7976 fetch_and_apply_control(&relay, &community).await.unwrap();
7977 assert!(crate::db::community::get_community_dissolved(&cid).unwrap(), "duplicates still just dissolve, no error");
7978 assert_eq!(fetch_and_apply_control(&relay, &community).await.unwrap(), 0);
7980 }
7981
7982 #[test]
7983 fn apply_server_root_rekey_refuses_once_dissolved() {
7984 let (_tmp, _guard) = init_test_db();
7985 let owner = Keys::generate();
7986 let me = Keys::generate();
7987 become_local(&me);
7988 let community = saved_community_owned_by(&owner);
7989 let cid = community.id.to_hex();
7990 crate::db::community::set_community_dissolved(&cid).unwrap();
7991
7992 let parsed = owner_base_rekey(&owner, &community, &me.public_key(), 1, &[0xCDu8; 32]);
7993 assert!(apply_server_root_rekey(&community, &parsed).is_err(),
7994 "a base rekey cannot cross a tombstone");
7995 assert_eq!(crate::db::community::load_community(&community.id).unwrap().unwrap().server_root_epoch,
7996 crate::community::Epoch(0), "base epoch did not advance");
7997 }
7998
7999 #[tokio::test]
8000 async fn tombstone_detected_after_a_base_rotation() {
8001 let (_tmp, _guard) = init_test_db();
8002 let relay = MemoryRelay::new();
8003 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
8004 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
8005 let cid = community.id.to_hex();
8006 rotate_server_root(&relay, &community, &[owner.public_key()]).await.unwrap();
8009 let rotated = crate::db::community::load_community(&community.id).unwrap().unwrap();
8010 assert_eq!(rotated.server_root_epoch, crate::community::Epoch(1));
8011 publish_tombstone(&relay, &rotated, &owner, 1000).await;
8012
8013 fetch_and_apply_control(&relay, &rotated).await.unwrap();
8014 assert!(crate::db::community::get_community_dissolved(&cid).unwrap(),
8015 "tombstone at the rotation-stable locator is detected post-rotation");
8016 }
8017
8018 #[tokio::test]
8019 async fn stable_coordinate_tombstone_survives_a_concurrent_rotation() {
8020 let (_tmp, _guard) = init_test_db();
8025 let relay = MemoryRelay::new();
8026 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
8027 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
8028 let cid = community.id.to_hex();
8029 let inner = crate::community::roster::build_group_dissolved_edition_unsigned(owner.public_key(), &community.id, 1000)
8031 .finalize(&owner).unwrap();
8032 let stable = crate::community::roster::seal_dissolved_edition(&Keys::generate(), &inner, &community.id).unwrap();
8033 relay.inject(&stable, &community.relays);
8034 rotate_server_root(&relay, &community, &[owner.public_key()]).await.unwrap();
8037 let rotated = crate::db::community::load_community(&community.id).unwrap().unwrap();
8038 assert_eq!(rotated.server_root_epoch, crate::community::Epoch(1));
8039 assert!(!crate::db::community::get_community_dissolved(&cid).unwrap(), "not folded yet");
8040 fetch_and_apply_control(&relay, &rotated).await.unwrap();
8043 assert!(crate::db::community::get_community_dissolved(&cid).unwrap(),
8044 "stable-coordinate probe discovers the tombstone cross-epoch (C3 closed)");
8045 }
8046}