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::stored_event::event_kind;
21
22pub const MAX_COMMUNITIES: usize = 50;
32
33fn enforce_community_cap() -> Result<(), String> {
37 let held = super::list::load_local_list().entries.len();
38 if held >= MAX_COMMUNITIES {
39 return Err(format!(
40 "You've reached the limit of {} communities. Leave one to join another.",
41 MAX_COMMUNITIES
42 ));
43 }
44 Ok(())
45}
46
47pub async fn create_community<T: Transport + ?Sized>(
52 transport: &T,
53 name: &str,
54 default_channel_name: &str,
55 relays: Vec<String>,
56) -> Result<Community, String> {
57 crate::db::scoped(async move {
58 enforce_community_cap()?;
59 let mut community = Community::create(name, default_channel_name, relays);
60 let owner_pk = crate::state::my_public_key().ok_or("cannot create a community without an identity")?;
66 let unsigned = super::owner::build_owner_attestation_unsigned(owner_pk, &community.id.to_hex());
67 let attestation = if let Some(keys) = crate::state::MY_SECRET_KEY.to_keys().filter(|k| k.public_key() == owner_pk) {
71 unsigned.finalize(&keys).map_err(|e| format!("sign owner attestation: {e}"))?
72 } else {
73 let signer = crate::signer::active_signer()
77 .map_err(|e| format!("cannot create a community without an identity signer: {e}"))?;
80 unsigned.finalize_async(&signer).await.map_err(|e| format!("sign owner attestation: {e}"))?
81 };
82 community.owner_attestation = Some(attestation.as_json());
83 crate::db::community::save_community(&community)?;
90
91 let signer = crate::signer::active_signer()?;
94 let cid = community.id.to_hex();
95 let created = std::time::SystemTime::now()
96 .duration_since(std::time::UNIX_EPOCH)
97 .map(|d| d.as_secs())
98 .unwrap_or(0);
99
100 let admin = super::roles::Role::admin(crate::simd::hex::bytes_to_hex_32(&super::random_32()));
108 let root_meta = super::metadata::CommunityMetadata::of(&community);
109 let root_inner = super::roster::build_community_root_edition_unsigned(owner_pk, &community.id, &root_meta, 1, None, created, None)?
110 .finalize_async(&signer).await.map_err(|e| format!("sign genesis group-root: {e}"))?;
111 let role_inner = super::roster::build_role_edition_unsigned(owner_pk, &admin, 1, None, created, None)?
112 .finalize_async(&signer).await.map_err(|e| format!("sign genesis admin-role: {e}"))?;
113 let mut heads: Vec<(String, [u8; 32], Option<[u8; 32]>)> = vec![
117 (cid.clone(), super::version::edition_hash(&community.id.0, 1, None, root_inner.content.as_bytes()), Some(root_inner.id.to_bytes())),
118 (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),
119 ];
120 let mut to_publish: Vec<Event> = vec![
121 super::roster::seal_control_edition(&Keys::generate(), &root_inner, &community.server_root_key, &community.id, community.server_root_epoch)?,
122 super::roster::seal_control_edition(&Keys::generate(), &role_inner, &community.server_root_key, &community.id, community.server_root_epoch)?,
123 ];
124 for channel in &community.channels {
125 let meta = super::metadata::ChannelMetadata { name: channel.name.clone() };
126 let inner = super::roster::build_channel_metadata_edition_unsigned(owner_pk, &channel.id, &meta, 1, None, created, None)?
127 .finalize_async(&signer).await.map_err(|e| format!("sign genesis channel-metadata: {e}"))?;
128 heads.push((channel.id.to_hex(), super::version::edition_hash(&channel.id.0, 1, None, inner.content.as_bytes()), Some(inner.id.to_bytes())));
129 to_publish.push(super::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch)?);
130 }
131 for outer in &to_publish {
135 transport.publish_durable(outer, &community.relays).await?;
136 }
137 for (entity_hex, hash, inner_id) in &heads {
139 let _ = match inner_id {
140 Some(id) => crate::db::community::set_edition_head_with_id(&cid, entity_hex, 1, hash, id),
141 None => crate::db::community::set_edition_head(&cid, entity_hex, 1, hash),
142 };
143 }
144 let roster = super::roles::CommunityRoles { roles: vec![admin], grants: Vec::new() };
145 let _ = crate::db::community::set_community_roles(&cid, &roster, created as i64);
146 Ok(community)
147 })
148 .await
149}
150
151pub async fn send_message<T: Transport + ?Sized>(
154 transport: &T,
155 community: &Community,
156 channel: &Channel,
157 author: &Keys,
158 content: &str,
159 ms: u64,
160) -> Result<Event, String> {
161 crate::db::scoped(async move {
162 let session = crate::db::current_session();
163 let inner = super::envelope::build_inner_event(author.public_key(), &channel.id, channel.epoch, content, ms, None)
167 .finalize(author)
168 .map_err(|e| e.to_string())?;
169 let (outer, ephemeral) = publish_signed_message(transport, community, channel, &inner, false).await?;
170 if !session.is_live() {
173 return Err("account changed during send; not persisting message key".to_string());
174 }
175 crate::db::community::store_message_key(&inner.id.to_hex(), &outer.id.to_hex(), &ephemeral, &community.relays)?;
176 Ok(outer)
177 })
178 .await
179}
180
181pub async fn send_signed_message<T: Transport + ?Sized>(
186 transport: &T,
187 community: &Community,
188 channel: &Channel,
189 inner: &Event,
190) -> Result<Event, String> {
191 crate::db::scoped(async move {
192 let session = crate::db::current_session();
193 let (outer, ephemeral) = publish_signed_message(transport, community, channel, inner, false).await?;
194 if !session.is_live() {
195 return Err("account changed during send; not persisting message key".to_string());
196 }
197 crate::db::community::store_message_key(&inner.id.to_hex(), &outer.id.to_hex(), &ephemeral, &community.relays)?;
198 Ok(outer)
199 })
200 .await
201}
202
203pub async fn build_presence(
213 channel: &Channel,
214 joined: bool,
215 attribution: Option<(String, Option<String>)>,
216) -> Result<nostr_sdk::prelude::Event, String> {
217 let author_pk = crate::state::my_public_key().ok_or("not logged in")?;
218 let ms = std::time::SystemTime::now()
219 .duration_since(std::time::UNIX_EPOCH)
220 .map(|d| d.as_millis() as u64)
221 .unwrap_or(0);
222 let content = match (joined, attribution) {
223 (false, _) => "leave".to_string(),
224 (true, Some((by, label))) => serde_json::json!({ "by": by, "l": label }).to_string(),
225 (true, None) => "join".to_string(),
226 };
227 let unsigned = super::envelope::build_inner_typed(
228 author_pk, &channel.id, channel.epoch, event_kind::COMMUNITY_PRESENCE, &content, ms, None, &[],
229 );
230 let signer = crate::signer::active_signer()?;
231 unsigned.finalize_async(&signer).await.map_err(|e| format!("Failed to sign presence: {e}"))
232}
233
234pub async fn publish_presence_event<T: Transport + ?Sized>(
236 transport: &T,
237 community: &Community,
238 channel: &Channel,
239 inner: &nostr_sdk::prelude::Event,
240) -> Result<(), String> {
241 let _ = publish_signed_message(transport, community, channel, inner, true).await?;
242 Ok(())
243}
244
245pub async fn publish_presence<T: Transport + ?Sized>(
246 transport: &T,
247 community: &Community,
248 channel: &Channel,
249 joined: bool,
250 attribution: Option<(String, Option<String>)>,
251) -> Result<(), String> {
252 let inner = build_presence(channel, joined, attribution).await?;
253 publish_presence_event(transport, community, channel, &inner).await
254}
255
256pub async fn publish_webxdc_signal<T: Transport + ?Sized>(
263 transport: &T,
264 community: &Community,
265 channel: &Channel,
266 topic_id: &str,
267 node_addr: Option<&str>,
268) -> Result<(), String> {
269 let author_pk = crate::state::my_public_key().ok_or("not logged in")?;
270 let ms = std::time::SystemTime::now()
271 .duration_since(std::time::UNIX_EPOCH)
272 .map(|d| d.as_millis() as u64)
273 .unwrap_or(0);
274 let content = crate::webxdc::peer_signal_content(topic_id, node_addr);
275 let unsigned = super::envelope::build_inner_typed(
276 author_pk, &channel.id, channel.epoch, event_kind::COMMUNITY_WEBXDC, &content, ms, None, &[],
277 );
278 let signer = crate::signer::active_signer()?;
279 let inner = unsigned.finalize_async(&signer).await.map_err(|e| format!("Failed to sign webxdc signal: {e}"))?;
280 let _ = publish_signed_message(transport, community, channel, &inner, true).await?;
281 Ok(())
282}
283
284pub async fn publish_typing_signal<T: Transport + ?Sized>(
290 transport: &T,
291 community: &Community,
292 channel: &Channel,
293) -> Result<(), String> {
294 let author_pk = crate::state::my_public_key().ok_or("not logged in")?;
295 let ms = std::time::SystemTime::now()
296 .duration_since(std::time::UNIX_EPOCH)
297 .map(|d| d.as_millis() as u64)
298 .unwrap_or(0);
299 let unsigned = super::envelope::build_inner_typed(
300 author_pk, &channel.id, channel.epoch, event_kind::COMMUNITY_TYPING, "typing", ms, None, &[],
301 );
302 let signer = crate::signer::active_signer()?;
303 let inner = unsigned.finalize_async(&signer).await.map_err(|e| format!("Failed to sign typing signal: {e}"))?;
304 let _ = publish_signed_message(transport, community, channel, &inner, false).await?;
305 Ok(())
306}
307
308pub async fn persist_webxdc_signal(
315 channel_hex: &str,
316 npub: &str,
317 topic_id: &str,
318 node_addr: Option<&str>,
319 event_id: &str,
320 created_at: u64,
321) {
322 if crate::db::events::event_exists(event_id).unwrap_or(true) {
323 return;
324 }
325 let now_secs = std::time::SystemTime::now()
328 .duration_since(std::time::UNIX_EPOCH)
329 .unwrap_or_default()
330 .as_secs();
331 let created_at = created_at.min(now_secs + 300);
332 let Ok(chat_id) = crate::db::id_cache::get_or_create_chat_id(channel_hex) else { return };
333 let mut tags = vec![
334 vec!["webxdc-topic".to_string(), topic_id.to_string()],
335 vec!["d".to_string(), "vector-webxdc-peer".to_string()],
336 ];
337 if let Some(addr) = node_addr {
338 tags.push(vec!["webxdc-node-addr".to_string(), addr.to_string()]);
339 }
340 let event = crate::stored_event::StoredEvent {
341 id: event_id.to_string(),
342 kind: crate::stored_event::event_kind::APPLICATION_SPECIFIC,
343 chat_id,
344 user_id: None,
345 content: if node_addr.is_some() { "peer-advertisement" } else { "peer-left" }.to_string(),
346 tags,
347 reference_id: Some(topic_id.to_string()),
348 created_at,
349 received_at: std::time::SystemTime::now()
350 .duration_since(std::time::UNIX_EPOCH)
351 .unwrap_or_default()
352 .as_millis() as u64,
353 mine: false,
354 pending: false,
355 failed: false,
356 wrapper_event_id: None,
357 npub: Some(npub.to_string()),
358 preview_metadata: None,
359 };
360 if let Err(e) = crate::db::events::save_event(&event).await {
361 crate::log_warn!("[community] failed to persist webxdc peer signal: {e}");
362 }
363}
364
365async fn strip_member_roles_on_removal<T: Transport + ?Sized>(
379 transport: &T,
380 community: &Community,
381 member_hex: &str,
382) {
383 let cid = community.id.to_hex();
384 let roster = match crate::db::community::get_community_roles(&cid) {
385 Ok(r) => r,
386 Err(_) => return,
387 };
388 let held: Vec<String> = roster
389 .grants
390 .iter()
391 .find(|g| g.member == member_hex)
392 .map(|g| g.role_ids.clone())
393 .unwrap_or_default();
394 if held.is_empty() {
395 return; }
397 for role_id in &held {
398 if caller_can_manage_role(community, &roster, role_id, member_hex).is_err() {
399 crate::log_warn!(
400 "removal: not authorized to revoke role {role_id} of {member_hex}; leaving the grant (kick/ban still neutralizes)"
401 );
402 return;
403 }
404 }
405 if let Err(e) = set_member_grant(transport, community, member_hex, Vec::new()).await {
406 crate::log_warn!("removal: role-strip publish failed for {member_hex}: {e}");
407 }
408}
409
410pub async fn publish_kick<T: Transport + ?Sized>(
411 transport: &T,
412 community: &Community,
413 channel: &Channel,
414 target_hex: &str,
415) -> Result<String, String> {
416 let author_pk = crate::state::my_public_key().ok_or("not logged in")?;
417 let me = author_pk.to_hex();
418 let cid = community.id.to_hex();
419 {
422 let owner = proven_owner_hex(community);
423 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
424 if !roster.can_act_on_member(&me, owner.as_deref(), target_hex, super::roles::Permissions::KICK) {
425 return Err("you can't kick a member who outranks you (or the owner)".to_string());
426 }
427 }
428 let ms = std::time::SystemTime::now()
429 .duration_since(std::time::UNIX_EPOCH)
430 .map(|d| d.as_millis() as u64)
431 .unwrap_or(0);
432 let citation = authority_citation(community, &me);
434 let extra: Vec<nostr_sdk::prelude::Tag> = citation.iter().map(|c| c.to_tag()).collect();
435 let unsigned = super::envelope::build_inner_full(
436 author_pk, &channel.id, channel.epoch, event_kind::COMMUNITY_KICK, target_hex, ms, None, &[], &extra,
437 );
438 let signer = crate::signer::active_signer()?;
439 let inner = unsigned.finalize_async(&signer).await.map_err(|e| format!("Failed to sign kick: {e}"))?;
440 publish_signed_message(transport, community, channel, &inner, true).await?;
441 strip_member_roles_on_removal(transport, community, target_hex).await;
444 Ok(inner.id.to_hex())
446}
447
448
449pub async fn publish_banlist<T: Transport + ?Sized>(
455 transport: &T,
456 community: &Community,
457 banned_hex: &[String],
458) -> Result<(), String> {
459 crate::db::scoped(async move {
460 let cid = community.id.to_hex();
461 let signer = crate::signer::active_signer()?;
464 let actor_pk = crate::state::my_public_key().ok_or("no local identity to sign the banlist edition")?;
465 {
470 let me = actor_pk.to_hex();
471 let owner = proven_owner_hex(community);
472 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
473 let current: std::collections::HashSet<String> =
474 crate::db::community::get_community_banlist(&cid).unwrap_or_default().into_iter().collect();
475 let next: std::collections::HashSet<&str> = banned_hex.iter().map(|s| s.as_str()).collect();
476 let added = banned_hex.iter().filter(|n| !current.contains(n.as_str()));
477 let removed = current.iter().filter(|n| !next.contains(n.as_str()));
478 for target in added.chain(removed) {
479 if !roster.can_act_on_member(&me, owner.as_deref(), target, super::roles::Permissions::BAN) {
480 return Err("you can't ban or unban a member who outranks you (or the owner)".to_string());
481 }
482 }
483 }
484 {
490 let prev: std::collections::HashSet<String> =
491 crate::db::community::get_community_banlist(&cid).unwrap_or_default().into_iter().collect();
492 let adds = banned_hex.iter().any(|n| !prev.contains(n.as_str()));
493 let cut_needed = (adds || crate::db::community::get_read_cut_pending(&cid)?) && !is_public(community)?;
494 if cut_needed && crate::state::MY_SECRET_KEY.to_keys().is_none() {
495 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());
496 }
497 }
498 let entity_id = super::derive::banlist_locator(&community.id);
500 let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
501 let (version, prev_hash) = match crate::db::community::get_edition_head(&cid, &entity_hex)? {
502 Some((v, h)) => (v + 1, Some(h)),
503 None => (1, None),
504 };
505 let created_at = std::time::SystemTime::now()
506 .duration_since(std::time::UNIX_EPOCH)
507 .map(|d| d.as_secs())
508 .unwrap_or(0);
509 let citation = authority_citation(community, &actor_pk.to_hex());
512 let unsigned = super::roster::build_banlist_edition_unsigned(actor_pk, &community.id, banned_hex, version, prev_hash.as_ref(), created_at, citation.as_ref())?;
513 let inner = unsigned.finalize_async(&signer).await.map_err(|e| format!("sign banlist edition: {e}"))?;
514 let outer = super::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch)?;
515 let self_hash = super::version::edition_hash(&entity_id, version, prev_hash.as_ref(), inner.content.as_bytes());
516
517 let newly_added: Vec<String> = {
520 let prev: std::collections::HashSet<String> =
521 crate::db::community::get_community_banlist(&cid).unwrap_or_default().into_iter().collect();
522 banned_hex.iter().filter(|n| !prev.contains(n.as_str())).cloned().collect()
523 };
524 let newly_banned = !newly_added.is_empty();
525
526 transport.publish_durable(&outer, &community.relays).await?;
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 for member_hex in &newly_added {
538 strip_member_roles_on_removal(transport, community, member_hex).await;
539 }
540
541 let need_cut = (newly_banned || crate::db::community::get_read_cut_pending(&cid)?)
556
557 && !is_public(community)?;
558 if need_cut {
559 run_read_cut(transport, community, newly_banned).await?;
562 }
563 Ok(())
564 })
565 .await
566}
567
568pub fn am_i_banned(community: &Community) -> bool {
574 let me = match crate::state::my_public_key() {
575 Some(p) => p.to_hex(),
576 None => return false,
577 };
578 crate::db::community::get_community_banlist(&community.id.to_hex())
579 .unwrap_or_default()
580 .iter()
581 .any(|b| b == &me)
582}
583
584pub async fn retry_pending_read_cut<T: Transport + ?Sized>(
590 transport: &T,
591 community: &Community,
592) -> Result<(), String> {
593 let cid = community.id.to_hex();
594 if !crate::db::community::get_read_cut_pending(&cid)? {
595 return Ok(());
596 }
597 if is_public(community)? {
598 crate::db::community::set_read_cut_pending(&cid, false)?; return Ok(());
600 }
601 let fresh = crate::db::community::load_community(&community.id)?.ok_or("community no longer present")?;
606 run_read_cut(transport, &fresh, false).await
607}
608
609async fn fetch_control_folded<T: Transport + ?Sized>(
619 transport: &T,
620 community: &Community,
621) -> Result<super::roster::FoldedRoster, String> {
622 fetch_control_folded_with(transport, community, Evidence::Quorum).await
623}
624
625async fn fetch_control_folded_with<T: Transport + ?Sized>(
626 transport: &T,
627 community: &Community,
628 evidence: Evidence,
629) -> Result<super::roster::FoldedRoster, String> {
630 let z_tags = vec![super::roster::control_pseudonym(&community.server_root_key, &community.id, community.server_root_epoch)];
635 let query = Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags, evidence, ..Default::default() };
640 let raw = transport.fetch(&query, &community.relays).await?;
641 let inner_editions: Vec<Event> = raw
644 .iter()
645 .take(super::roster::MAX_CONTROL_EDITIONS)
646 .filter_map(|ev| super::roster::open_control_edition(ev, &community.server_root_key).ok())
647 .collect();
648 let fetched = inner_editions.len();
653 let current_epoch = community.server_root_epoch.0;
659 let floors: std::collections::HashMap<String, (u64, [u8; 32])> =
660 crate::db::community::get_all_edition_heads_epoched(&community.id.to_hex())?
661 .into_iter()
662 .filter(|(_, (epoch, _, _))| *epoch == current_epoch)
663 .map(|(entity, (_epoch, version, hash))| (entity, (version, hash)))
664 .collect();
665 let mut folded = super::roster::fold_roster(&inner_editions, &community.id, &floors);
666 folded.fetched = fetched; Ok(folded)
668}
669
670pub async fn fetch_and_apply_control<T: Transport + ?Sized>(
676 transport: &T,
677 community: &Community,
678) -> Result<usize, String> {
679 fetch_and_apply_control_with(transport, community, Evidence::Quorum).await
680}
681
682pub async fn fetch_and_apply_control_full<T: Transport + ?Sized>(
687 transport: &T,
688 community: &Community,
689) -> Result<usize, String> {
690 fetch_and_apply_control_with(transport, community, Evidence::Full).await
691}
692
693async fn fetch_and_apply_control_with<T: Transport + ?Sized>(
694 transport: &T,
695 community: &Community,
696 evidence: Evidence,
697) -> Result<usize, String> {
698 crate::db::scoped(async move {
699 let cid = community.id.to_hex();
700 if crate::db::community::get_community_dissolved(&cid)? {
703 return Ok(0);
704 }
705 let folded = fetch_control_folded_with(transport, community, evidence).await?;
706 if let Some(owner) = proven_owner_hex(community) {
716 let by_fold = folded.dissolved_by.iter().any(|s| s.to_hex() == owner);
717 let probe_records = if by_fold {
718 Vec::new()
719 } else {
720 dissolved_tombstone_records(transport, community).await
721 };
722 let by_probe = !by_fold && probe_records.iter().any(|d| d.author.to_hex() == owner);
723 if by_fold || by_probe {
724 let mut tombstones = folded.dissolved_editions.clone();
730 tombstones.extend(probe_records);
731 let mut migration_pointer_found = false;
732 if let Some((_, raw)) = super::migration::select_pointer(&tombstones, &owner) {
733 let _ = crate::db::community::set_migration_pointer(&cid, &raw);
734 migration_pointer_found = true;
735 }
736 let _ = fetch_and_apply_banlist_inner(transport, community, Some(folded.clone())).await;
739 let _ = fetch_and_apply_roles_inner(transport, community, Some(folded.clone())).await;
740 let _ = fetch_and_apply_invite_links_inner(transport, community, Some(folded.clone())).await;
741 let _ = fetch_and_apply_metadata_inner(transport, community, Some(folded.clone())).await;
742 crate::db::community::set_community_dissolved(&cid)?;
743 crate::emit_event("community_refreshed", &serde_json::json!({ "community_id": cid }));
747 if migration_pointer_found {
752 match Box::pin(super::migration::drive_migration(transport, community)).await {
753 Ok(Some(v2_hex)) => super::migration::spawn_finalize_migration(cid.clone(), v2_hex),
754 Ok(None) => {}
755 Err(e) => crate::log_warn!("migration drive for {cid}: {e}"),
756 }
757 }
758 return Ok(folded.fetched);
759 }
760 }
761 let fetched = folded.fetched;
764 let _ = fetch_and_apply_banlist_inner(transport, community, Some(folded.clone())).await;
765 let _ = fetch_and_apply_roles_inner(transport, community, Some(folded.clone())).await;
766 let _ = fetch_and_apply_invite_links_inner(transport, community, Some(folded.clone())).await;
767 let _ = fetch_and_apply_metadata_inner(transport, community, Some(folded)).await;
768 Ok(fetched)
769 })
770 .await
771}
772
773pub async fn fetch_and_apply_banlist<T: Transport + ?Sized>(
774 transport: &T,
775 community: &Community,
776) -> Result<Vec<String>, String> {
777 fetch_and_apply_banlist_inner(transport, community, None).await
778}
779
780async fn fetch_and_apply_banlist_inner<T: Transport + ?Sized>(
781 transport: &T,
782 community: &Community,
783 prefolded: Option<super::roster::FoldedRoster>,
784) -> Result<Vec<String>, String> {
785 crate::db::scoped(async move {
786 let cid = community.id.to_hex();
787 let folded = match prefolded {
788 Some(f) => f,
789 None => fetch_control_folded(transport, community).await?,
790 };
791 let owner = proven_owner_hex(community);
794 let authorized = super::roster::authorize_delegation(&folded, owner.as_deref());
795 if let (Some(author), Some(head)) = (folded.banlist_author, &folded.banlist_head) {
796 let author_hex = author.to_hex();
801 let held: std::collections::HashSet<String> =
802 crate::db::community::get_community_banlist(&cid)?.into_iter().collect();
803 let next: std::collections::HashSet<&str> = folded.banned.iter().map(|s| s.as_str()).collect();
804 let added = folded.banned.iter().filter(|n| !held.contains(n.as_str()));
805 let removed = held.iter().filter(|n| !next.contains(n.as_str()));
806 let citation = folded.banlist_head.as_ref().and_then(|h| h.citation.as_ref());
812 let banner_grant_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(&community.id, &author.to_bytes()));
813 let pinned = super::roster::authority_citation_satisfied(&folded.heads, owner.as_deref(), &author_hex, &banner_grant_hex, citation);
814 let authed = pinned
815 && added.chain(removed).all(|target| {
816 authorized.can_act_on_member(&author_hex, owner.as_deref(), target, super::roles::Permissions::BAN)
817 });
818 let held_version = crate::db::community::get_edition_head(&cid, &head.entity_hex)?.map(|(v, _)| v).unwrap_or(0);
819 if authed && head.version > held_version {
820 crate::db::community::set_community_banlist(&cid, &folded.banned, head.version as i64)?;
821 crate::db::community::set_edition_head(&cid, &head.entity_hex, head.version, &head.self_hash)?;
822 return Ok(folded.banned);
823 }
824 }
825 crate::db::community::get_community_banlist(&cid)
827 })
828 .await
829}
830
831pub async fn set_member_grant<T: Transport + ?Sized>(
836 transport: &T,
837 community: &Community,
838 member_hex: &str,
839 role_ids: Vec<String>,
840) -> Result<(), String> {
841 crate::db::scoped(async move {
842 let signer = crate::signer::active_signer()?;
845 let actor_pk = crate::state::my_public_key().ok_or("no local identity to sign the grant edition")?;
846 let cid = community.id.to_hex();
847 let grant = super::roles::MemberGrant { member: member_hex.to_string(), role_ids };
848
849 let member_bytes = crate::simd::hex::hex_to_bytes_32(member_hex);
852 let entity_id = super::derive::grant_locator(&community.id, &member_bytes);
853 let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
854 let (version, prev_hash) = match crate::db::community::get_edition_head(&cid, &entity_hex)? {
855 Some((v, h)) => (v + 1, Some(h)),
856 None => (1, None),
857 };
858 let created_at = std::time::SystemTime::now()
859 .duration_since(std::time::UNIX_EPOCH)
860 .map(|d| d.as_secs())
861 .unwrap_or(0);
862
863 let citation = authority_citation(community, &actor_pk.to_hex());
870 let unsigned = super::roster::build_grant_edition_unsigned(actor_pk, &community.id, &grant, version, prev_hash.as_ref(), created_at, citation.as_ref())?;
871 let inner = unsigned.finalize_async(&signer).await.map_err(|e| format!("sign grant edition: {e}"))?;
872 let outer = super::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch)?;
873 let self_hash = super::version::edition_hash(&entity_id, version, prev_hash.as_ref(), inner.content.as_bytes());
877
878 let is_full_revoke = grant.role_ids.is_empty();
879 let mut roster = crate::db::community::get_community_roles(&cid)?;
881 roster.grants.retain(|g| g.member != member_hex);
882 if !grant.role_ids.is_empty() {
883 roster.grants.push(grant);
884 }
885
886 transport.publish_durable(&outer, &community.relays).await?;
892 crate::db::community::set_community_roles(&cid, &roster, created_at as i64)?;
893 crate::db::community::set_edition_head(&cid, &entity_hex, version, &self_hash)?;
894
895 if is_full_revoke {
903 if let Ok(folded) = fetch_control_folded(transport, community).await {
904 let current = crate::db::community::load_community(&community.id)?.unwrap_or_else(|| community.clone());
905 if folded.root_author.map(|a| a.to_hex()).as_deref() == Some(member_hex) {
906 if let Some(meta) = &folded.root_meta {
907 let mut c = current.clone();
908 c.name = meta.name.clone();
909 c.description = meta.description.clone();
910 c.icon = meta.icon.clone();
911 c.banner = meta.banner.clone();
912 let _ = republish_community_metadata(transport, &c).await;
913 }
914 }
915 for cm in &folded.channel_meta {
916 if cm.author.to_hex() == member_hex
917 && current.channels.iter().any(|ch| ch.id.0 == cm.channel_id)
918 {
919 let _ = republish_channel_metadata(
920 transport, ¤t, &crate::community::ChannelId(cm.channel_id), &cm.meta.name,
921 ).await;
922 }
923 }
924 }
925 }
926 Ok(())
927 })
928 .await
929}
930
931pub fn is_proven_owner(community: &Community) -> bool {
936 match crate::state::my_public_key() {
937 Some(me) => proven_owner_hex(community).as_deref() == Some(me.to_hex().as_str()),
938 None => false,
939 }
940}
941
942pub fn caller_can_manage_roles(community: &Community) -> bool {
946 let me = match crate::state::my_public_key() {
947 Some(p) => p,
948 None => return false,
949 };
950 let cid = community.id.to_hex();
951 let is_owner = community
952 .owner_attestation
953 .as_ref()
954 .and_then(|a| super::owner::verify_owner_attestation(a, &cid))
955 .map(|pk| pk == me)
956 .unwrap_or(false);
957 if is_owner {
958 return true; }
960 crate::db::community::get_community_roles(&cid)
961 .unwrap_or_default()
962 .has_permission(&me.to_hex(), super::roles::Permissions::MANAGE_ROLES)
963}
964
965pub fn caller_has_permission(community: &Community, permission: u64) -> bool {
969 let me = match crate::state::my_public_key() {
970 Some(p) => p,
971 None => return false,
972 };
973 crate::db::community::get_community_roles(&community.id.to_hex())
974 .unwrap_or_default()
975 .is_authorized(&me.to_hex(), proven_owner_hex(community).as_deref(), permission)
976}
977
978pub fn caller_can_manage_role_id(community: &Community, role_id: &str) -> bool {
983 let me = match crate::state::my_public_key() {
984 Some(p) => p.to_hex(),
985 None => return false,
986 };
987 let roster = crate::db::community::get_community_roles(&community.id.to_hex()).unwrap_or_default();
988 let position = match roster.role(role_id) {
989 Some(r) => r.position,
990 None => return false,
991 };
992 roster.can_manage_position(&me, proven_owner_hex(community).as_deref(), position)
993}
994
995#[derive(Debug, Clone, Default, serde::Serialize)]
1000pub struct CommunityCapabilities {
1001 pub manage_metadata: bool,
1002 pub manage_channels: bool,
1003 pub create_invite: bool,
1004 pub kick: bool,
1005 pub ban: bool,
1006 pub manage_messages: bool,
1007 pub manage_roles: bool,
1008}
1009
1010pub fn caller_capabilities(community: &Community) -> CommunityCapabilities {
1011 use super::roles::Permissions as P;
1012 let me_hex = match crate::state::my_public_key() {
1013 Some(p) => p.to_hex(),
1014 None => return CommunityCapabilities::default(),
1015 };
1016 let owner = proven_owner_hex(community);
1017 let roster = crate::db::community::get_community_roles(&community.id.to_hex()).unwrap_or_default();
1018 let has = |bit: u64| roster.is_authorized(&me_hex, owner.as_deref(), bit);
1019 CommunityCapabilities {
1020 manage_metadata: has(P::MANAGE_METADATA),
1021 manage_channels: has(P::MANAGE_CHANNELS),
1022 create_invite: has(P::CREATE_INVITE),
1023 kick: has(P::KICK),
1024 ban: has(P::BAN),
1025 manage_messages: has(P::MANAGE_MESSAGES),
1026 manage_roles: has(P::MANAGE_ROLES),
1027 }
1028}
1029
1030fn authority_citation(community: &Community, actor_hex: &str) -> Option<super::edition::AuthorityCitation> {
1038 if proven_owner_hex(community).as_deref() == Some(actor_hex) {
1039 return None;
1040 }
1041 let cid = community.id.to_hex();
1042 let actor_bytes = crate::simd::hex::hex_to_bytes_32(actor_hex);
1043 let entity_id = super::derive::grant_locator(&community.id, &actor_bytes);
1044 let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
1045 crate::db::community::get_edition_head(&cid, &entity_hex)
1046 .ok()
1047 .flatten()
1048 .map(|(version, edition_hash)| super::edition::AuthorityCitation { entity_id, version, edition_hash })
1049}
1050
1051pub(crate) fn proven_owner_hex(community: &Community) -> Option<String> {
1054 let cid = community.id.to_hex();
1055 community
1056 .owner_attestation
1057 .as_ref()
1058 .and_then(|a| super::owner::verify_owner_attestation(a, &cid))
1059 .map(|pk| pk.to_hex())
1060}
1061
1062pub fn can_moderation_hide(community: &Community, actor_hex: &str, author_hex: &str) -> bool {
1068 let owner = proven_owner_hex(community)
1072 .or_else(|| super::moderation::owner_hex(&community.id.to_hex()));
1073 let roster = crate::db::community::get_community_roles(&community.id.to_hex()).unwrap_or_default();
1074 super::moderation::can_hide(owner.as_deref(), &roster, actor_hex, author_hex)
1075}
1076
1077fn rotator_is_authorized(
1085 cid: &str,
1086 roster: &super::roles::CommunityRoles,
1087 owner_hex: Option<&str>,
1088 rotator_hex: &str,
1089 permission: u64,
1090) -> bool {
1091 if owner_hex != Some(rotator_hex)
1092 && crate::db::community::get_community_banlist(cid)
1093 .unwrap_or_default()
1094 .iter()
1095 .any(|b| b == rotator_hex)
1096 {
1097 return false;
1098 }
1099 roster.is_authorized(rotator_hex, owner_hex, permission)
1100}
1101
1102fn caller_can_manage_role(
1108 community: &Community,
1109 roster: &super::roles::CommunityRoles,
1110 role_id: &str,
1111 member_hex: &str,
1112) -> Result<(), String> {
1113 let me = crate::state::my_public_key().ok_or("no active identity")?.to_hex();
1114 let owner = proven_owner_hex(community);
1115 let owner_ref = owner.as_deref();
1116 let role = roster.role(role_id).ok_or("no such role")?;
1117 if !roster.can_manage_position(&me, owner_ref, role.position) {
1118 return Err("you can only manage roles below your own".to_string());
1119 }
1120 if !roster.can_manage_member(&me, owner_ref, member_hex) {
1121 return Err("you can't manage a member who outranks you".to_string());
1122 }
1123 Ok(())
1124}
1125
1126pub async fn grant_role<T: Transport + ?Sized>(
1130 transport: &T,
1131 community: &Community,
1132 member: nostr_sdk::prelude::PublicKey,
1133 role_id: &str,
1134) -> Result<(), String> {
1135 let cid = community.id.to_hex();
1136 let member_hex = member.to_hex();
1137 let roster = crate::db::community::get_community_roles(&cid)?;
1138 caller_can_manage_role(community, &roster, role_id, &member_hex)?;
1139 let mut role_ids: Vec<String> = roster
1141 .grants
1142 .iter()
1143 .find(|g| g.member == member_hex)
1144 .map(|g| g.role_ids.clone())
1145 .unwrap_or_default();
1146 if !role_ids.iter().any(|r| r == role_id) {
1147 role_ids.push(role_id.to_string());
1148 }
1149
1150 set_member_grant(transport, community, &member_hex, role_ids).await
1154}
1155
1156pub async fn revoke_role<T: Transport + ?Sized>(
1163 transport: &T,
1164 community: &Community,
1165 member: nostr_sdk::prelude::PublicKey,
1166 role_id: &str,
1167) -> Result<(), String> {
1168 let cid = community.id.to_hex();
1169 let member_hex = member.to_hex();
1170 let roster = crate::db::community::get_community_roles(&cid)?;
1171 caller_can_manage_role(community, &roster, role_id, &member_hex)?;
1172 let role_ids: Vec<String> = roster
1173 .grants
1174 .iter()
1175 .find(|g| g.member == member_hex)
1176 .map(|g| g.role_ids.iter().filter(|r| r.as_str() != role_id).cloned().collect())
1177 .unwrap_or_default();
1178 set_member_grant(transport, community, &member_hex, role_ids).await
1179}
1180
1181pub async fn fetch_and_apply_roles<T: Transport + ?Sized>(
1187 transport: &T,
1188 community: &Community,
1189) -> Result<super::roles::CommunityRoles, String> {
1190 fetch_and_apply_roles_inner(transport, community, None).await
1191}
1192
1193async fn fetch_and_apply_roles_inner<T: Transport + ?Sized>(
1194 transport: &T,
1195 community: &Community,
1196 prefolded: Option<super::roster::FoldedRoster>,
1197) -> Result<super::roles::CommunityRoles, String> {
1198 crate::db::scoped(async move {
1199 let cid = community.id.to_hex();
1200 let folded = match prefolded {
1201 Some(f) => f,
1202 None => fetch_control_folded(transport, community).await?,
1203 };
1204
1205 for head in &folded.heads {
1214 crate::db::community::set_edition_head(&cid, &head.entity_hex, head.version, &head.self_hash)?;
1215 }
1216 if folded.heads.is_empty() {
1221 return crate::db::community::get_community_roles(&cid);
1222 }
1223 let authorized = super::roster::authorize_delegation(&folded, proven_owner_hex(community).as_deref());
1227 crate::db::community::set_community_roles(&cid, &authorized, 0)?;
1228 Ok(authorized)
1229 })
1230 .await
1231}
1232
1233pub async fn publish_owner_hide<T: Transport + ?Sized>(
1238 transport: &T,
1239 community: &Community,
1240 channel: &Channel,
1241 target_message_id: &str,
1242) -> Result<(), String> {
1243 let signer = crate::signer::active_signer()?;
1248 let me_pk = crate::state::my_public_key().ok_or("no local identity to sign the hide")?;
1249 let me = me_pk.to_hex();
1250 {
1251 let target_author = {
1252 let st = crate::state::STATE.lock().await;
1253 st.find_message(target_message_id).and_then(|(_, m)| m.npub)
1254 };
1255 let author = target_author
1256 .ok_or("can't resolve the target message's author to authorize the hide")?;
1257 if !can_moderation_hide(community, &me, &author) {
1258 return Err("you can't hide a message from a member who outranks you (or the owner)".to_string());
1259 }
1260 }
1261 let ms = std::time::SystemTime::now()
1262 .duration_since(std::time::UNIX_EPOCH)
1263 .map(|d| d.as_millis() as u64)
1264 .unwrap_or(0);
1265 let citation = authority_citation(community, &me);
1271 let extra: Vec<Tag> = citation.iter().map(|c| c.to_tag()).collect();
1272 let inner = super::envelope::build_inner_full(
1273 me_pk, &channel.id, channel.epoch,
1274 event_kind::COMMUNITY_DELETE, "", ms, Some(target_message_id), &[], &extra,
1275 )
1276 .finalize_async(&signer)
1277 .await
1278 .map_err(|e| format!("sign hide: {e}"))?;
1279 let _ = publish_signed_message(transport, community, channel, &inner, true).await?;
1280 Ok(())
1281}
1282
1283pub async fn delete_message<T: Transport + ?Sized>(
1288 transport: &T,
1289 message_id: &str,
1290) -> Result<(), String> {
1291 crate::db::scoped(async move {
1292 let session = crate::db::current_session();
1293 if !session.is_live() {
1294 return Err("account changed; aborting delete".to_string());
1295 }
1296 let (ephemeral, outer_event_id_hex, relays) = match crate::db::community::get_message_key(message_id)? {
1300 Some(v) => v,
1301 None => {
1302 return Err("no retained key for this message (not yours, or already deleted)".to_string())
1303 }
1304 };
1305 let id = EventId::from_hex(&outer_event_id_hex).map_err(|e| e.to_string())?;
1306 delete_own_message(transport, &relays, &ephemeral, id).await?;
1307 crate::db::community::delete_message_key(message_id)?;
1309 Ok(())
1310 })
1311 .await
1312}
1313
1314pub fn accept_invite(invite: &CommunityInvite) -> Result<Community, String> {
1327 let session = crate::db::current_session();
1328 let community = super::invite::accept_invite(invite)?; match crate::db::community::load_community(&community.id)? {
1331 Some(existing) => {
1333 if crate::db::community::get_migrated_to(&existing.id.to_hex())?.is_some() {
1337 return Err("This community has upgraded to Concord v2. Ask a member for a fresh invite.".to_string());
1338 }
1339 if is_proven_owner(&existing) {
1340 return Err("you already own this Community".to_string());
1341 }
1342 if existing.server_root_key.as_bytes() != community.server_root_key.as_bytes() {
1346 return Err(
1347 "invite reuses a known Community id under a different authority — rejected"
1348 .to_string(),
1349 );
1350 }
1351 }
1352 None => enforce_community_cap()?,
1354 }
1355
1356 if !session.is_live() {
1357 return Err("account changed during invite accept".to_string());
1358 }
1359 crate::db::community::save_community(&community)?;
1360 Ok(community)
1361}
1362
1363pub async fn preload_community(invite: &super::invite::CommunityInvite) {
1370 let Ok(community) = super::invite::accept_invite(invite) else { return };
1371 let Some(channel) = community.channels.first() else { return };
1372 let cid = community.id.to_hex();
1373 crate::community::cache::begin_preload(&cid);
1375 let transport = super::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1376 match super::send::fetch_channel_page(&transport, &community, channel, None, None, 50).await {
1378 Ok(page) if !page.is_empty() => crate::community::cache::finish_preload(&cid, page),
1379 _ => crate::community::cache::abort_preload(&cid),
1381 }
1382
1383 let prune_relays = community.relays.clone();
1387 let prune_id = community.id;
1388 crate::db::spawn_bound(async move {
1389 tokio::time::sleep(crate::community::cache::PRELOAD_TTL).await;
1390 if matches!(crate::db::community::load_community(&prune_id), Ok(Some(_))) {
1393 return;
1394 }
1395 crate::community::cache::abort_preload(&prune_id.to_hex());
1397 super::transport::prune_unneeded_community_relays(&prune_relays).await;
1398 });
1399}
1400
1401pub async fn republish_community_metadata<T: Transport + ?Sized>(
1406 transport: &T,
1407 community: &Community,
1408) -> Result<(), String> {
1409 crate::db::scoped(async move {
1410 let cid = community.id.to_hex();
1411 if crate::db::community::get_migrated_to(&cid)?.is_some() {
1414 return Err("this community has upgraded to Concord v2".to_string());
1415 }
1416 let signer = crate::signer::active_signer()?;
1417 let actor_pk = crate::state::my_public_key().ok_or("no local identity to sign the metadata edition")?;
1418 let owner = proven_owner_hex(community);
1419 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
1420 if !roster.is_authorized(&actor_pk.to_hex(), owner.as_deref(), super::roles::Permissions::MANAGE_METADATA) {
1421 return Err("only a member with manage-metadata authority can edit the community".to_string());
1422 }
1423 let (version, prev_hash) = match crate::db::community::get_edition_head(&cid, &cid)? {
1428 Some((v, h)) => (v + 1, Some(h)),
1429 None => (1, None),
1430 };
1431 let created = std::time::SystemTime::now()
1432 .duration_since(std::time::UNIX_EPOCH)
1433 .map(|d| d.as_secs())
1434 .unwrap_or(0);
1435 let meta = super::metadata::CommunityMetadata::of(community);
1436 let citation = authority_citation(community, &actor_pk.to_hex());
1441 let unsigned = super::roster::build_community_root_edition_unsigned(actor_pk, &community.id, &meta, version, prev_hash.as_ref(), created, citation.as_ref())?;
1442 let inner = unsigned.finalize_async(&signer).await.map_err(|e| format!("sign community-root edition: {e}"))?;
1443 let outer = super::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch)?;
1444 transport.publish_durable(&outer, &community.relays).await?;
1445 crate::db::community::save_community(community)?;
1446 let h = super::version::edition_hash(&community.id.0, version, prev_hash.as_ref(), inner.content.as_bytes());
1447 crate::db::community::set_edition_head_with_id(&cid, &cid, version, &h, &inner.id.to_bytes())?;
1450 Ok(())
1451 })
1452 .await
1453}
1454
1455pub async fn republish_channel_metadata<T: Transport + ?Sized>(
1461 transport: &T,
1462 community: &Community,
1463 channel_id: &crate::community::ChannelId,
1464 new_name: &str,
1465) -> Result<(), String> {
1466 crate::db::scoped(async move {
1467 let cid = community.id.to_hex();
1468 let ch_hex = channel_id.to_hex();
1469 if crate::db::community::get_migrated_to(&cid)?.is_some() {
1471 return Err("this community has upgraded to Concord v2".to_string());
1472 }
1473 if !community.channels.iter().any(|c| &c.id == channel_id) {
1474 return Err("no such channel in this community".to_string());
1475 }
1476 let signer = crate::signer::active_signer()?;
1477 let actor_pk = crate::state::my_public_key().ok_or("no local identity to sign the channel metadata edition")?;
1478 let owner = proven_owner_hex(community);
1479 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
1480 if !roster.is_authorized(&actor_pk.to_hex(), owner.as_deref(), super::roles::Permissions::MANAGE_CHANNELS) {
1481 return Err("only a member with manage-channels authority can rename a channel".to_string());
1482 }
1483 let (version, prev_hash) = match crate::db::community::get_edition_head(&cid, &ch_hex)? {
1484 Some((v, h)) => (v + 1, Some(h)),
1485 None => (1, None),
1486 };
1487 let created = std::time::SystemTime::now()
1488 .duration_since(std::time::UNIX_EPOCH)
1489 .map(|d| d.as_secs())
1490 .unwrap_or(0);
1491 let meta = super::metadata::ChannelMetadata { name: new_name.to_string() };
1492 let citation = authority_citation(community, &actor_pk.to_hex());
1495 let unsigned = super::roster::build_channel_metadata_edition_unsigned(actor_pk, channel_id, &meta, version, prev_hash.as_ref(), created, citation.as_ref())?;
1496 let inner = unsigned.finalize_async(&signer).await.map_err(|e| format!("sign channel-metadata edition: {e}"))?;
1497 let outer = super::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch)?;
1498 transport.publish_durable(&outer, &community.relays).await?;
1499 let mut current = crate::db::community::load_community(&community.id)?.ok_or("community no longer present")?;
1500 if let Some(ch) = current.channels.iter_mut().find(|c| &c.id == channel_id) {
1501 ch.name = new_name.to_string();
1502 }
1503 crate::db::community::save_community(¤t)?;
1504 let h = super::version::edition_hash(&channel_id.0, version, prev_hash.as_ref(), inner.content.as_bytes());
1505 crate::db::community::set_edition_head_with_id(&cid, &ch_hex, version, &h, &inner.id.to_bytes())?;
1506 Ok(())
1507 })
1508 .await
1509}
1510
1511fn generate_invite_label() -> String {
1524 use rand::Rng;
1525 const ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
1526 let mut rng = rand::thread_rng();
1527 (0..6).map(|_| ALPHABET[rng.gen_range(0..ALPHABET.len())] as char).collect()
1528}
1529
1530pub async fn create_public_invite<T: Transport + ?Sized>(
1531 transport: &T,
1532 community: &Community,
1533 expires_at: Option<u64>,
1534 label: Option<String>,
1535) -> Result<(String, String), String> {
1536 crate::db::scoped(async move {
1537 if !caller_has_permission(community, super::roles::Permissions::CREATE_INVITE) {
1538 return Err("you need the create-invite permission to mint a public invite".to_string());
1539 }
1540
1541 let existing = crate::db::community::list_public_invites(&community.id.to_hex()).unwrap_or_default();
1545 let label_taken = |cand: &str| {
1546 existing.iter().any(|r| r.label.as_deref().map(|e| e.eq_ignore_ascii_case(cand)).unwrap_or(false))
1547 };
1548 let label = match label {
1549 Some(l) if !l.trim().is_empty() => {
1550 let l = l.trim().to_string();
1551 if label_taken(&l) {
1552 return Err(format!("You already have an invite link labeled \u{201c}{l}\u{201d}. Pick a different label."));
1553 }
1554 Some(l)
1555 }
1556 _ => {
1558 let mut l = generate_invite_label();
1559 while label_taken(&l) {
1560 l = generate_invite_label();
1561 }
1562 Some(l)
1563 }
1564 };
1565
1566 let creator_npub = crate::state::my_public_key().and_then(|pk| pk.to_bech32().ok());
1569 let token = public_invite::new_token();
1570 let event = build_public_invite_event(community, &token, expires_at, creator_npub, label.clone()).map_err(|e| e.to_string())?;
1571 transport.publish_durable(&event, &community.relays).await?;
1572
1573 let token_hex = crate::simd::hex::bytes_to_hex_32(&token);
1574 let url = public_invite::encode_invite_url(&community.relays, &token);
1575 crate::db::community::save_public_invite(
1576 &token_hex,
1577 &community.id.to_hex(),
1578 &url,
1579 expires_at.map(|e| e as i64),
1580 label.as_deref(),
1581 )?;
1582 super::invite_list::add_invite(super::invite_list::InviteEntry {
1585 token: token_hex.clone(),
1586 community_id: community.id.to_hex(),
1587 url: url.clone(),
1588 label: label.clone(),
1589 created_at: std::time::SystemTime::now()
1590 .duration_since(std::time::UNIX_EPOCH)
1591 .map(|d| d.as_secs())
1592 .unwrap_or(0),
1593 expires_at,
1594 });
1595 republish_my_invite_links(transport, community).await?;
1598 Ok((token_hex, url))
1599 })
1600 .await
1601}
1602
1603pub async fn latest_invite_preview<T: Transport + ?Sized>(
1609 transport: &T,
1610 bundle: &public_invite::PublicInviteBundle,
1611) -> public_invite::PublicInvitePreview {
1612 let snapshot = bundle.preview.clone();
1613 let Ok(community) = super::invite::accept_invite(&bundle.join) else {
1614 return snapshot;
1615 };
1616 let Ok(folded) = fetch_control_folded(transport, &community).await else {
1617 return snapshot;
1618 };
1619 let owner = proven_owner_hex(&community);
1620 let authorized = super::roster::authorize_delegation(&folded, owner.as_deref());
1621 match folded.root_candidates.iter().find(|c| {
1622 authorized.is_authorized(&c.author.to_hex(), owner.as_deref(), super::roles::Permissions::MANAGE_METADATA)
1623 }) {
1624 Some(c) => public_invite::PublicInvitePreview {
1625 name: c.meta.name.clone(),
1626 description: c.meta.description.clone(),
1627 icon: c.meta.icon.clone(),
1628 },
1629 None => snapshot,
1630 }
1631}
1632
1633pub async fn fetch_public_invite<T: Transport + ?Sized>(
1637 transport: &T,
1638 relays: &[String],
1639 token: &[u8; 32],
1640) -> Result<PublicInviteBundle, String> {
1641 let query = Query {
1645 kinds: vec![event_kind::APPLICATION_SPECIFIC],
1646 d_tags: vec![locator_hex(token)],
1647 ..Default::default()
1648 };
1649 let events = transport.fetch(&query, relays).await?;
1650 let (mut bundle_at, mut bundle, mut revoked_at) = (0u64, None, None::<u64>);
1656 for ev in &events {
1657 match parse_public_invite_event(ev, token) {
1658 Ok(b) => if bundle.is_none() || ev.created_at.as_secs() > bundle_at {
1659 bundle_at = ev.created_at.as_secs();
1660 bundle = Some(b);
1661 },
1662 Err(super::public_invite::PublicInviteError::Revoked) => {
1663 let at = ev.created_at.as_secs();
1664 if revoked_at.map_or(true, |r| at > r) { revoked_at = Some(at); }
1665 }
1666 Err(_) => {} }
1668 }
1669 match (bundle, revoked_at) {
1670 (Some(b), Some(r)) if bundle_at > r => Ok(b), (_, Some(_)) => Err("this invite was revoked".to_string()),
1672 (Some(b), None) => Ok(b),
1673 (None, None) => Err("no public invite found at that link (revoked, never posted, or shadowed)".to_string()),
1674 }
1675}
1676
1677pub fn accept_public_invite(bundle: &PublicInviteBundle, now_secs: u64) -> Result<Community, String> {
1681 if bundle.is_expired(now_secs) {
1682 return Err("this invite link has expired".to_string());
1683 }
1684 let mut community = accept_invite(&bundle.join)?;
1685 if bundle.preview.description.is_some() || bundle.preview.icon.is_some() {
1688 community.description = bundle.preview.description.clone();
1689 community.icon = bundle.preview.icon.clone();
1690 crate::db::community::save_community(&community)?;
1691 }
1692 Ok(community)
1693}
1694
1695pub async fn revoke_public_invite<T: Transport + ?Sized>(
1702 transport: &T,
1703 community: &Community,
1704 token: &[u8; 32],
1705) -> Result<(), String> {
1706 crate::db::scoped(async move {
1707 let cid = community.id.to_hex();
1708 let token_hex = crate::simd::hex::bytes_to_hex_32(token);
1709 let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
1710 if !crate::db::community::list_public_invites(&cid)?.iter().any(|r| r.token == token_hex) {
1713 return Ok(());
1714 }
1715 let my_locators_before: Vec<String> = crate::db::community::list_public_invites(&cid)?
1716 .iter()
1717 .filter(|r| r.expires_at.map_or(true, |e| (e as u64) > now))
1718 .map(|r| public_invite::locator_hex(&crate::simd::hex::hex_to_bytes_32(&r.token)))
1719 .collect();
1720 let _ = fetch_and_apply_invite_links(transport, community).await;
1724 let this_locator = public_invite::locator_hex(token);
1730 let cached_aggregate: std::collections::BTreeSet<String> =
1731 crate::db::community::get_community_invite_registry(&cid)?.into_iter().collect();
1732 let my_before: std::collections::BTreeSet<String> = my_locators_before.iter().cloned().collect();
1733 let others: std::collections::BTreeSet<String> = cached_aggregate.difference(&my_before).cloned().collect();
1734 let my_after: std::collections::BTreeSet<String> =
1735 my_before.iter().filter(|l| **l != this_locator).cloned().collect();
1736 let would_empty_aggregate = others.is_empty() && my_after.is_empty();
1737 if would_empty_aggregate && crate::state::MY_SECRET_KEY.to_keys().is_none() {
1738 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());
1739 }
1740 if let Ok(tombstone) = public_invite::build_public_invite_tombstone(token) {
1750 let _ = transport.publish_durable(&tombstone, &community.relays).await;
1751 }
1752 crate::db::community::delete_public_invite(&token_hex)?;
1753 super::invite_list::revoke_invite(&token_hex, &cid);
1756 republish_my_invite_links(transport, community).await?;
1758 let aggregate_after: Vec<String> = others.union(&my_after).cloned().collect();
1759 crate::db::community::set_community_invite_registry(&cid, &aggregate_after)?;
1760 if would_empty_aggregate {
1761 run_read_cut(transport, community, true).await?;
1765 }
1766 Ok(())
1767 })
1768 .await
1769}
1770
1771pub(crate) async fn dissolved_tombstone_records<T: Transport + ?Sized>(
1787 transport: &T,
1788 community: &Community,
1789) -> Vec<super::roster::DissolvedEdition> {
1790 let z = super::derive::dissolved_pseudonym(&community.id);
1791 let q = Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], ..Default::default() };
1792 transport
1793 .fetch(&q, &community.relays)
1794 .await
1795 .unwrap_or_default()
1796 .iter()
1797 .filter_map(|ev| super::roster::dissolved_tombstone_open(ev, &community.id))
1798 .collect()
1799}
1800
1801pub async fn publish_migration_carrier<T: Transport + ?Sized>(
1809 transport: &T,
1810 community: &Community,
1811 payload_content: &str,
1812) -> Result<(), String> {
1813 crate::db::scoped(async move {
1814 if !is_proven_owner(community) {
1815 return Err("only the community owner can migrate the community".to_string());
1816 }
1817 let signer = crate::signer::active_signer()?;
1818 let actor_pk = crate::state::my_public_key().ok_or("no local identity to sign the migration")?;
1819 let created_at = std::time::SystemTime::now()
1820 .duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
1821 let unsigned = super::roster::build_group_dissolved_edition_unsigned_with_content(actor_pk, &community.id, created_at, payload_content);
1822 let inner = unsigned.finalize_async(&signer).await.map_err(|e| format!("sign migration carrier: {e}"))?;
1823 let stable = super::roster::seal_dissolved_edition(&Keys::generate(), &inner, &community.id)?;
1826 super::migration::check_outer_size(&stable)?;
1827 transport.publish_durable(&stable, &community.relays).await?;
1828 if let Ok(fast) = super::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch) {
1829 let _ = transport.publish_durable(&fast, &community.relays).await;
1830 }
1831 Ok(())
1832 })
1833 .await
1834}
1835
1836pub async fn dissolve_community<T: Transport + ?Sized>(
1837 transport: &T,
1838 community: &Community,
1839) -> Result<(), String> {
1840 crate::db::scoped(async move {
1841 let cid = community.id.to_hex();
1842
1843 if !is_proven_owner(community) {
1845 return Err("only the community owner can dissolve (delete) the community".to_string());
1846 }
1847 let signer = crate::signer::active_signer()?;
1848 let actor_pk = crate::state::my_public_key().ok_or("no local identity to sign the dissolution")?;
1849
1850 let created_at = std::time::SystemTime::now()
1854 .duration_since(std::time::UNIX_EPOCH)
1855 .map(|d| d.as_secs())
1856 .unwrap_or(0);
1857 let unsigned = super::roster::build_group_dissolved_edition_unsigned(actor_pk, &community.id, created_at);
1858 let inner = unsigned.finalize_async(&signer).await.map_err(|e| format!("sign dissolution tombstone: {e}"))?;
1859 let stable = super::roster::seal_dissolved_edition(&Keys::generate(), &inner, &community.id)?;
1863 transport.publish_durable(&stable, &community.relays).await?;
1864 if let Ok(outer) = super::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch) {
1867 let _ = transport.publish_durable(&outer, &community.relays).await;
1868 }
1869
1870 if caller_has_permission(community, super::roles::Permissions::CREATE_INVITE) {
1876 let _ = publish_my_invite_links(transport, community, &[]).await;
1877 if let Ok(records) = crate::db::community::list_public_invites(&cid) {
1878 for r in records {
1879 let token = crate::simd::hex::hex_to_bytes_32(&r.token);
1880 if let Ok(tombstone) = public_invite::build_public_invite_tombstone(&token) {
1881 let _ = transport.publish_durable(&tombstone, &community.relays).await;
1882 }
1883 let _ = crate::db::community::delete_public_invite(&r.token);
1884 }
1885 }
1886 }
1887
1888 crate::db::community::set_community_dissolved(&cid)?;
1889 Ok(())
1890 })
1891 .await
1892}
1893
1894pub async fn publish_my_invite_links<T: Transport + ?Sized>(
1901 transport: &T,
1902 community: &Community,
1903 my_locators: &[String],
1904) -> Result<(), String> {
1905 crate::db::scoped(async move {
1906 if !caller_has_permission(community, super::roles::Permissions::CREATE_INVITE) {
1907 return Err("you need the create-invite permission to publish invite links".to_string());
1908 }
1909 let cid = community.id.to_hex();
1910 let signer = crate::signer::active_signer()?;
1911 let actor_pk = crate::state::my_public_key().ok_or("no local identity to sign the invite links")?;
1912 let entity_id = super::derive::invite_links_locator(&community.id, &actor_pk.to_bytes());
1913 let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
1914 let (version, prev_hash) = match crate::db::community::get_edition_head(&cid, &entity_hex)? {
1915 Some((v, h)) => (v + 1, Some(h)),
1916 None => (1, None),
1917 };
1918 let created_at = std::time::SystemTime::now()
1919 .duration_since(std::time::UNIX_EPOCH)
1920 .map(|d| d.as_secs())
1921 .unwrap_or(0);
1922 let citation = authority_citation(community, &actor_pk.to_hex());
1924 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())?;
1925 let inner = unsigned.finalize_async(&signer).await.map_err(|e| format!("sign invite-links edition: {e}"))?;
1926 let outer = super::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch)?;
1927 let self_hash = super::version::edition_hash(&entity_id, version, prev_hash.as_ref(), inner.content.as_bytes());
1928 transport.publish_durable(&outer, &community.relays).await?;
1929 crate::db::community::set_edition_head(&cid, &entity_hex, version, &self_hash)?;
1930 let mut agg: std::collections::BTreeSet<String> =
1933 crate::db::community::get_community_invite_registry(&cid)?.into_iter().collect();
1934 agg.extend(my_locators.iter().cloned());
1935 crate::db::community::set_community_invite_registry(&cid, &agg.into_iter().collect::<Vec<_>>())?;
1936 crate::db::community::upsert_invite_link_set(&cid, &actor_pk.to_hex(), my_locators)?;
1937 Ok(())
1938 })
1939 .await
1940}
1941
1942pub async fn fetch_and_apply_invite_links<T: Transport + ?Sized>(
1948 transport: &T,
1949 community: &Community,
1950) -> Result<Vec<String>, String> {
1951 fetch_and_apply_invite_links_inner(transport, community, None).await
1952}
1953
1954async fn fetch_and_apply_invite_links_inner<T: Transport + ?Sized>(
1955 transport: &T,
1956 community: &Community,
1957 prefolded: Option<super::roster::FoldedRoster>,
1958) -> Result<Vec<String>, String> {
1959 crate::db::scoped(async move {
1960 let cid = community.id.to_hex();
1961 let folded = match prefolded {
1962 Some(f) => f,
1963 None => fetch_control_folded(transport, community).await?,
1964 };
1965 let owner = proven_owner_hex(community);
1966 let authorized = super::roster::authorize_delegation(&folded, owner.as_deref());
1967 let mut aggregate: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
1968 let mut per_creator: Vec<crate::db::community::InviteLinkSetRow> = Vec::new();
1970 for set in &folded.invite_link_sets {
1971 if !authorized.is_authorized(&set.creator.to_hex(), owner.as_deref(), super::roles::Permissions::CREATE_INVITE) {
1974 continue;
1975 }
1976 let held = crate::db::community::get_edition_head(&cid, &set.head.entity_hex)?.map(|(v, _)| v).unwrap_or(0);
1977 if set.head.version > held {
1978 crate::db::community::set_edition_head(&cid, &set.head.entity_hex, set.head.version, &set.head.self_hash)?;
1979 }
1980 aggregate.extend(set.locators.iter().cloned());
1981 per_creator.push(crate::db::community::InviteLinkSetRow {
1982 creator_hex: set.creator.to_hex(),
1983 locators: set.locators.clone(),
1984 });
1985 }
1986 {
2002 let present_creators: std::collections::HashSet<String> =
2003 folded.invite_link_sets.iter().map(|s| s.creator.to_hex()).collect();
2004 for row in crate::db::community::get_invite_link_sets(&cid)? {
2005 if present_creators.contains(&row.creator_hex) {
2006 continue;
2007 }
2008 aggregate.extend(row.locators.iter().cloned());
2009 per_creator.push(row);
2010 }
2011 }
2012 let aggregate: Vec<String> = aggregate.into_iter().collect();
2013 crate::db::community::set_community_invite_registry(&cid, &aggregate)?;
2014 crate::db::community::replace_invite_link_sets(&cid, &per_creator)?;
2015 Ok(aggregate)
2016 })
2017 .await
2018}
2019
2020pub async fn fetch_and_apply_metadata<T: Transport + ?Sized>(
2028 transport: &T,
2029 community: &Community,
2030) -> Result<(), String> {
2031 fetch_and_apply_metadata_inner(transport, community, None).await
2032}
2033
2034async fn fetch_and_apply_metadata_inner<T: Transport + ?Sized>(
2035 transport: &T,
2036 community: &Community,
2037 prefolded: Option<super::roster::FoldedRoster>,
2038) -> Result<(), String> {
2039 crate::db::scoped(async move {
2040 let cid = community.id.to_hex();
2041 let folded = match prefolded {
2042 Some(f) => f,
2043 None => fetch_control_folded(transport, community).await?,
2044 };
2045 let owner = proven_owner_hex(community);
2046 let authorized = super::roster::authorize_delegation(&folded, owner.as_deref());
2047 let manage = super::roles::Permissions::MANAGE_METADATA;
2050 let manage_channels = super::roles::Permissions::MANAGE_CHANNELS;
2051
2052 let mut current = match crate::db::community::load_community(&community.id)? {
2055 Some(c) => c,
2056 None => return Ok(()),
2057 };
2058 let mut dirty = false;
2059 let mut head_updates: Vec<(String, u64, [u8; 32], [u8; 32], bool)> = Vec::new();
2063
2064 let decide = |entity_hex: &str, head: &super::roster::EntityHead| -> Result<Option<bool>, String> {
2071 let held = crate::db::community::get_edition_head(&cid, entity_hex)?;
2072 let held_v = held.map(|(v, _)| v).unwrap_or(0);
2073 if head.version > held_v {
2074 return Ok(Some(false)); }
2076 if head.version == held_v && held.map(|(_, h)| h) != Some(head.self_hash) {
2077 let held_id = crate::db::community::get_edition_head_inner_id(&cid, entity_hex)?;
2078 if held_id.is_none() || Some(head.inner_id) < held_id {
2079 return Ok(Some(true)); }
2081 }
2082 Ok(None)
2083 };
2084
2085 if let Some(c) = folded.root_candidates.iter()
2090 .find(|c| authorized.is_authorized(&c.author.to_hex(), owner.as_deref(), manage))
2091 {
2092 let head = &c.head;
2093 if let Some(is_converge) = decide(&head.entity_hex, head)? {
2094 let meta = &c.meta;
2095 current.name = meta.name.clone();
2104 current.description = meta.description.clone();
2105 current.icon = meta.icon.clone();
2106 current.banner = meta.banner.clone();
2107 dirty = true;
2108 head_updates.push((head.entity_hex.clone(), head.version, head.self_hash, head.inner_id, is_converge));
2109 }
2110 }
2111 let mut resolved_channels: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
2117 for cm in &folded.channel_candidates {
2118 if resolved_channels.contains(&cm.channel_id) {
2119 continue; }
2121 if !authorized.is_authorized(&cm.author.to_hex(), owner.as_deref(), manage_channels) {
2122 continue; }
2124 resolved_channels.insert(cm.channel_id);
2125 let Some(is_converge) = decide(&cm.head.entity_hex, &cm.head)? else { continue };
2126 if let Some(ch) = current.channels.iter_mut().find(|c| c.id.0 == cm.channel_id) {
2127 ch.name = cm.meta.name.clone();
2128 dirty = true;
2129 head_updates.push((cm.head.entity_hex.clone(), cm.head.version, cm.head.self_hash, cm.head.inner_id, is_converge));
2130 }
2131 }
2132
2133 if dirty {
2134 crate::db::community::save_community(¤t)?;
2135 for (entity_hex, version, self_hash, inner_id, is_converge) in &head_updates {
2138 if *is_converge {
2139 crate::db::community::converge_edition_head(&cid, entity_hex, *version, self_hash, inner_id)?;
2140 } else {
2141 crate::db::community::set_edition_head_with_id(&cid, entity_hex, *version, self_hash, inner_id)?;
2142 }
2143 }
2144 }
2145 Ok(())
2146 })
2147 .await
2148}
2149
2150pub fn is_public(community: &Community) -> Result<bool, String> {
2158 Ok(!crate::db::community::get_community_invite_registry(&community.id.to_hex())?.is_empty())
2159}
2160
2161async fn republish_my_invite_links<T: Transport + ?Sized>(
2166 transport: &T,
2167 community: &Community,
2168) -> Result<Vec<String>, String> {
2169 let cid = community.id.to_hex();
2170 let now = std::time::SystemTime::now()
2171 .duration_since(std::time::UNIX_EPOCH)
2172 .map(|d| d.as_secs())
2173 .unwrap_or(0);
2174 let locators: Vec<String> = crate::db::community::list_public_invites(&cid)?
2175 .iter()
2176 .filter(|r| r.expires_at.map_or(true, |e| (e as u64) > now))
2177 .map(|r| public_invite::locator_hex(&crate::simd::hex::hex_to_bytes_32(&r.token)))
2178 .collect();
2179 publish_my_invite_links(transport, community, &locators).await?;
2180 Ok(locators)
2181}
2182
2183async fn observe_channel_activity<T: Transport + ?Sized>(
2189 transport: &T,
2190 community: &Community,
2191) -> Result<(), String> {
2192 crate::db::scoped(async move {
2193 let session = crate::db::current_session();
2194 let my_pk = crate::state::my_public_key().ok_or("no local identity to observe channel activity")?;
2195 for channel in &community.channels {
2196 let events = super::send::fetch_channel_events(transport, community, channel)
2197 .await
2198 .unwrap_or_default();
2199 let outcomes = {
2200 let mut st = crate::state::STATE.lock().await;
2201 super::inbound::process_channel_batch(&mut st, &events, channel, &my_pk)
2202 };
2203 let ch_hex = channel.id.to_hex();
2204 let mut pending: Vec<&crate::types::Message> = Vec::new();
2207 for o in &outcomes {
2208 match o {
2209 super::inbound::IncomingEvent::NewMessage(m)
2210 | super::inbound::IncomingEvent::Updated { message: m, .. } => {
2211 pending.push(m);
2212 }
2213 super::inbound::IncomingEvent::Presence { npub, joined, event_id, created_at, invited_by, invited_label } => {
2214 let et = if *joined {
2215 crate::stored_event::SystemEventType::MemberJoined
2216 } else {
2217 crate::stored_event::SystemEventType::MemberLeft
2218 };
2219 let note = invited_by.as_ref().map(|by| match invited_label {
2220 Some(l) if !l.is_empty() => format!("{by}|{l}"),
2221 _ => by.clone(),
2222 });
2223 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;
2224 }
2225 super::inbound::IncomingEvent::WebxdcPeer { npub, topic_id, node_addr, event_id, created_at } => {
2226 persist_webxdc_signal(&ch_hex, npub, topic_id, node_addr.as_deref(), event_id, *created_at).await;
2227 }
2228 _ => {}
2229 }
2230 }
2231 crate::db::events::flush_message_batch(&ch_hex, &mut pending, &session).await;
2232 }
2233 Ok(())
2234 })
2235 .await
2236}
2237
2238pub async fn sync_before_admin_write<T: Transport + ?Sized>(
2249 transport: &T,
2250 community: &Community,
2251 observe_activity: bool,
2252) -> Result<Community, String> {
2253 if catch_up_server_root(transport, community).await?.removed {
2255 return Err("you have been removed from this community".to_string());
2256 }
2257 let community = crate::db::community::load_community(&community.id)?
2258 .ok_or("community gone during admin sync")?;
2259 let cid = community.id.to_hex();
2260 let responded = fetch_and_apply_control_full(transport, &community).await.map(|n| n > 0).unwrap_or(false);
2269 let hold_local_heads = !crate::db::community::get_all_edition_heads_epoched(&cid)?.is_empty();
2270 if hold_local_heads && !responded {
2271 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());
2272 }
2273 let community = crate::db::community::load_community(&community.id)?
2274 .ok_or("community gone during admin sync")?;
2275 if observe_activity {
2278 let _ = observe_channel_activity(transport, &community).await;
2279 }
2280 crate::db::community::load_community(&community.id)?.ok_or("community gone during admin sync".to_string())
2281}
2282
2283async fn run_read_cut<T: Transport + ?Sized>(
2293 transport: &T,
2294 community: &Community,
2295 fresh: bool,
2296) -> Result<(), String> {
2297 crate::db::scoped(async move {
2298 let cid = community.id.to_hex();
2299 if fresh {
2300 let base = crate::db::community::load_community(&community.id)?
2303 .map(|c| c.server_root_epoch.0)
2304 .unwrap_or(community.server_root_epoch.0);
2305 crate::db::community::set_read_cut_target_epoch(&cid, base.saturating_add(1))?;
2306 }
2307 crate::db::community::set_read_cut_pending(&cid, true)?;
2308 reseal_base_to_observed(transport, community).await?;
2309 crate::db::community::set_read_cut_pending(&cid, false)?;
2310 Ok(())
2311 })
2312 .await
2313}
2314
2315async fn reseal_base_to_observed<T: Transport + ?Sized>(
2325 transport: &T,
2326 community: &Community,
2327) -> Result<(), String> {
2328 crate::db::scoped(async move {
2329 let cid = community.id.to_hex();
2330 let community = &sync_before_admin_write(transport, community, true).await?;
2335 let participants: Vec<nostr_sdk::prelude::PublicKey> = crate::db::community::community_member_activity(&cid)?
2339 .into_iter()
2340 .filter_map(|(npub, _)| nostr_sdk::prelude::PublicKey::parse(&npub).ok())
2341 .collect();
2342 let target = crate::db::community::get_read_cut_target_epoch(&cid)?;
2352 if community.server_root_epoch.0 < target {
2353 rotate_server_root(transport, community, &participants).await?;
2354 }
2355 let community = crate::db::community::load_community(&community.id)?
2361 .ok_or("community gone after base rotation")?;
2362 let cut_epoch = community.server_root_epoch.0;
2363 let prior_root = crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, cut_epoch.saturating_sub(1))?
2369 .unwrap_or(*community.server_root_key.as_bytes()); for channel in &community.channels {
2371 let ch_hex = channel.id.to_hex();
2372 if crate::db::community::channel_rekeyed_at_server_epoch(&cid, &ch_hex)? >= cut_epoch {
2375 continue;
2376 }
2377 rotate_channel(transport, &community, &channel.id, &participants, &prior_root).await?;
2378 crate::db::community::mark_channel_rekeyed_at_server_epoch(&cid, &ch_hex, cut_epoch)?;
2379 }
2380 Ok(())
2381 })
2382 .await
2383}
2384
2385#[derive(Debug, PartialEq, Eq)]
2387pub enum RekeyOutcome {
2388 Applied { head_advanced: bool },
2391 NotARecipient,
2394}
2395
2396pub fn apply_channel_rekey(
2407 community: &Community,
2408 parsed: &super::rekey::ParsedRekey,
2409) -> Result<RekeyOutcome, String> {
2410 let session = crate::db::current_session();
2414
2415 let channel_id = match parsed.scope {
2417 super::derive::RekeyScope::Channel(c) => c,
2418 super::derive::RekeyScope::ServerRoot => {
2419 return Err("server-root rotation uses apply_server_root_rekey, not the channel path".to_string())
2420 }
2421 };
2422 if !community.channels.iter().any(|c| c.id == channel_id) {
2423 return Err("rekey targets a channel not in this community".to_string());
2424 }
2425 let cid = community.id.to_hex();
2426 let channel_hex = channel_id.to_hex();
2427
2428 let owner = proven_owner_hex(community);
2435 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_else(|e| {
2436 crate::log_warn!("rekey apply: roster read failed ({e}); authorizing owner only");
2439 Default::default()
2440 });
2441 if !roster.is_authorized(
2442 &parsed.rotator.to_hex(),
2443 owner.as_deref(),
2444 super::roles::Permissions::MANAGE_CHANNELS,
2445 ) {
2446 return Err("rekey rotator lacks MANAGE_CHANNELS authority".to_string());
2447 }
2448
2449 if let Some(prev_key) = crate::db::community::held_epoch_key(&cid, &channel_hex, parsed.prev_epoch.0)? {
2459 if super::rekey::epoch_key_commitment(parsed.prev_epoch, &prev_key) != parsed.prev_key_commitment {
2460 crate::log_warn!(
2461 "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",
2462 parsed.new_epoch.0, parsed.prev_epoch.0
2463 );
2464 }
2465 }
2466
2467 let my_keys = crate::state::MY_SECRET_KEY
2469 .to_keys()
2470 .ok_or("no local identity to open the rekey blob")?;
2471 let secret = super::rekey::rekey_pairwise_secret(my_keys.secret_key(), &parsed.rotator)?;
2472 let my_locator = super::derive::recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
2473 let mine = match parsed.blobs.iter().find(|b| b.locator == my_locator) {
2474 Some(b) => b,
2475 None => return Ok(RekeyOutcome::NotARecipient),
2476 };
2477 let new_key =
2478 super::rekey::open_rekey_blob(my_keys.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine)?;
2479
2480 if !session.is_live() {
2482 return Err("session changed during rekey apply".to_string());
2483 }
2484 let head_advanced =
2485 crate::db::community::advance_channel_epoch(&cid, &channel_hex, parsed.new_epoch.0, &new_key)?;
2486 Ok(RekeyOutcome::Applied { head_advanced })
2487}
2488
2489fn mint_or_reuse_rotation_key(cid: &str, scope_id: &str, epoch: u64) -> Result<zeroize::Zeroizing<[u8; 32]>, String> {
2495 if let Some(k) = crate::db::community::held_epoch_key(cid, scope_id, epoch)? {
2496 return Ok(zeroize::Zeroizing::new(k));
2497 }
2498 let k = zeroize::Zeroizing::new(super::random_32());
2499 crate::db::community::store_epoch_key(cid, scope_id, epoch, &k)?;
2500 Ok(k)
2501}
2502
2503async fn publish_rekey_chunked<T, F>(
2511 transport: &T,
2512 relays: &[String],
2513 blobs: &[super::rekey::RekeyBlob],
2514 build: F,
2515) -> Result<(), String>
2516where
2517 T: Transport + ?Sized,
2518 F: Fn(&[super::rekey::RekeyBlob]) -> Result<Event, String>,
2519{
2520 if blobs.is_empty() {
2521 return Err("rekey has no recipients".to_string());
2522 }
2523 for chunk in blobs.chunks(super::rekey::MAX_REKEY_BLOBS) {
2524 let event = build(chunk)?;
2525 transport.publish_durable(&event, relays).await?;
2526 }
2527 Ok(())
2528}
2529
2530pub async fn rotate_channel<T: Transport + ?Sized>(
2542 transport: &T,
2543 community: &Community,
2544 channel_id: &super::ChannelId,
2545 recipients: &[nostr_sdk::prelude::PublicKey],
2546 envelope_root: &[u8; 32],
2552) -> Result<u64, String> {
2553 crate::db::scoped(async move {
2554 let cid = community.id.to_hex();
2555
2556 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)")?;
2560 let owner = proven_owner_hex(community);
2561 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
2562 if !roster.is_authorized(&my_keys.public_key().to_hex(), owner.as_deref(), super::roles::Permissions::MANAGE_CHANNELS) {
2563 return Err("not authorized to rotate this channel (no MANAGE_CHANNELS)".to_string());
2564 }
2565
2566 let channel = community
2570 .channels
2571 .iter()
2572 .find(|c| &c.id == channel_id)
2573 .ok_or("channel not found in community")?;
2574 let prev_epoch = channel.epoch;
2575 let new_epoch = super::Epoch(prev_epoch.0.checked_add(1).ok_or("channel epoch overflow")?);
2576 let prev_commit = super::rekey::epoch_key_commitment(prev_epoch, channel.key.as_bytes());
2577 let new_key = mint_or_reuse_rotation_key(&cid, &channel_id.to_hex(), new_epoch.0)?;
2580
2581 let mut seen = std::collections::HashSet::new();
2585 let mut blobs = Vec::new();
2586 for pk in recipients.iter().chain(std::iter::once(&my_keys.public_key())) {
2587 if !seen.insert(pk.to_hex()) {
2588 continue;
2589 }
2590 blobs.push(super::rekey::build_rekey_blob(
2591 my_keys.secret_key(), pk, super::derive::RekeyScope::Channel(*channel_id), new_epoch, &new_key,
2592 )?);
2593 }
2594
2595 publish_rekey_chunked(transport, &community.relays, &blobs, |chunk| {
2597 super::rekey::build_channel_rekey_event(
2598 &Keys::generate(), &my_keys, envelope_root, channel_id,
2599 new_epoch, prev_epoch, &prev_commit, chunk,
2600 )
2601 })
2602 .await?;
2603 crate::db::community::advance_channel_epoch(&cid, &channel_id.to_hex(), new_epoch.0, &new_key)?;
2604 Ok(new_epoch.0)
2605 })
2606 .await
2607}
2608
2609fn emit_rekey_progress(label: &str, pct: u8) {
2613 crate::emit_event("community_rekey_progress", &serde_json::json!({ "label": label, "pct": pct }));
2614}
2615
2616pub(crate) async fn rotate_server_root<T: Transport + ?Sized>(
2632 transport: &T,
2633 community: &Community,
2634 recipients: &[nostr_sdk::prelude::PublicKey],
2635) -> Result<u64, String> {
2636 crate::db::scoped(async move {
2637 let cid = community.id.to_hex();
2638
2639 if crate::db::community::get_community_dissolved(&cid)? {
2641 return Err("community is dissolved; it cannot be re-founded".to_string());
2642 }
2643
2644 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)")?;
2655 let owner = proven_owner_hex(community);
2656 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
2657 if !roster.is_authorized(&my_keys.public_key().to_hex(), owner.as_deref(), super::roles::Permissions::BAN) {
2658 return Err("not authorized to rotate the server root (no BAN)".to_string());
2659 }
2660
2661 let fresh = crate::db::community::load_community(&community.id)?
2667 .ok_or("community gone before base rotation")?;
2668 let community = &fresh;
2669 let prev_epoch = community.server_root_epoch;
2670 let new_epoch = super::Epoch(prev_epoch.0.checked_add(1).ok_or("server-root epoch overflow")?);
2671 let prev_commit = super::rekey::epoch_key_commitment(prev_epoch, community.server_root_key.as_bytes());
2673 let new_root = mint_or_reuse_rotation_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch.0)?;
2675 emit_rekey_progress("Rerolling community keys...", 5);
2676
2677 let sealed = prepare_reanchor_control_plane(transport, community, &new_root, new_epoch).await?;
2683
2684 let total_recipients = (recipients.len() + 1).max(1); let mut seen = std::collections::HashSet::new();
2686 let mut blobs = Vec::new();
2687 for pk in recipients.iter().chain(std::iter::once(&my_keys.public_key())) {
2688 if !seen.insert(pk.to_hex()) {
2689 continue;
2690 }
2691 blobs.push(super::rekey::build_rekey_blob(
2692 my_keys.secret_key(), pk, super::derive::RekeyScope::ServerRoot, new_epoch, &new_root,
2693 )?);
2694 emit_rekey_progress(
2695 &format!("Preparing keys for members ({}/{})...", blobs.len(), total_recipients),
2696 (5 + 35 * blobs.len() / total_recipients) as u8,
2697 );
2698 }
2699
2700 emit_rekey_progress("Sending keys to members...", 42);
2704 publish_rekey_chunked(transport, &community.relays, &blobs, |chunk| {
2705 super::rekey::build_server_root_rekey_event(
2706 &Keys::generate(), &my_keys, community.server_root_key.as_bytes(), &community.id,
2707 new_epoch, prev_epoch, &prev_commit, chunk,
2708 )
2709 })
2710 .await?;
2711
2712 let snapshot = publish_reanchor_snapshot(transport, &community.relays, sealed).await?;
2720 if snapshot.iter().any(|e| !e.published) {
2721 return Err(
2722 "re-founding aborted: a snapshot edition did not land (rate-limited / unreachable relay?); base head NOT advanced".to_string()
2723 );
2724 }
2725 emit_rekey_progress("Finalizing...", 98);
2726 crate::db::community::advance_server_root_epoch(&cid, new_epoch.0, &new_root)?;
2728 for e in &snapshot {
2732 crate::db::community::set_edition_head_with_id(&cid, &e.entity_hex, e.version, &e.self_hash, &e.inner_id)?;
2733 }
2734 Ok(new_epoch.0)
2735 })
2736 .await
2737}
2738
2739pub(crate) struct SnapshotEntry {
2774 pub entity_hex: String,
2775 pub version: u64,
2776 pub self_hash: [u8; 32],
2777 pub inner_id: [u8; 32],
2778 pub published: bool,
2779}
2780
2781pub(crate) async fn prepare_reanchor_control_plane<T: Transport + ?Sized>(
2787 transport: &T,
2788 community: &Community,
2789 new_root: &[u8; 32],
2790 new_epoch: super::Epoch,
2791) -> Result<Vec<(Event, SnapshotEntry)>, String> {
2792 crate::db::scoped(async move {
2793 let cid = community.id.to_hex();
2794
2795 let z = super::roster::control_pseudonym(&community.server_root_key, &community.id, community.server_root_epoch);
2803 let query = Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], evidence: Evidence::Full, ..Default::default() };
2807 let outers = transport.fetch(&query, &community.relays).await?;
2808 let mut by_hash: std::collections::HashMap<[u8; 32], (Event, [u8; 32])> = std::collections::HashMap::new();
2810 for outer in &outers {
2811 if let Ok(inner) = super::roster::open_control_edition(outer, &community.server_root_key) {
2812 if let Ok(parsed) = super::edition::parse_edition_inner(&inner) {
2813 by_hash.insert(parsed.self_hash, (inner, parsed.inner_id));
2814 }
2815 }
2816 }
2817
2818 let new_root_key = super::ServerRootKey(*new_root);
2822 let mut sealed: Vec<(Event, SnapshotEntry)> = Vec::new();
2823 for (entity_hex, (epoch, version, self_hash)) in crate::db::community::get_all_edition_heads_epoched(&cid)? {
2824 if epoch != community.server_root_epoch.0 {
2825 continue; }
2827 let (inner, inner_id) = by_hash.get(&self_hash).ok_or_else(|| {
2828 format!("re-founding aborted: head edition for entity {entity_hex} (v{version}) not fetchable — aborting so no member is stranded")
2829 })?;
2830 let outer = super::roster::seal_control_edition(&Keys::generate(), inner, &new_root_key, &community.id, new_epoch)?;
2831 sealed.push((outer, SnapshotEntry { entity_hex, version, self_hash, inner_id: *inner_id, published: false }));
2832 }
2833 Ok(sealed)
2834 })
2835 .await
2836}
2837
2838pub(crate) async fn publish_reanchor_snapshot<T: Transport + ?Sized>(
2842 transport: &T,
2843 relays: &[String],
2844 sealed: Vec<(Event, SnapshotEntry)>,
2845) -> Result<Vec<SnapshotEntry>, String> {
2846 use futures_util::stream::StreamExt;
2849 let total = sealed.len().max(1);
2850 let done = std::sync::atomic::AtomicUsize::new(0);
2851 let done_ref = &done;
2852 emit_rekey_progress(&format!("Re-founding community (0/{total})..."), 50);
2853 let out: Vec<SnapshotEntry> = futures_util::stream::iter(sealed.into_iter().map(|(ev, mut entry)| async move {
2854 entry.published = transport.publish_durable(&ev, relays).await.is_ok();
2855 let n = done_ref.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
2856 emit_rekey_progress(&format!("Re-founding community ({n}/{total})..."), (50 + 45 * n / total) as u8);
2857 entry
2858 }))
2859 .buffer_unordered(4)
2860 .collect()
2861 .await;
2862 Ok(out)
2863}
2864
2865#[cfg(test)]
2868pub(crate) async fn reanchor_control_plane<T: Transport + ?Sized>(
2869 transport: &T,
2870 community: &Community,
2871 new_root: &[u8; 32],
2872 new_epoch: super::Epoch,
2873) -> Result<Vec<SnapshotEntry>, String> {
2874 let sealed = prepare_reanchor_control_plane(transport, community, new_root, new_epoch).await?;
2875 publish_reanchor_snapshot(transport, &community.relays, sealed).await
2876}
2877
2878pub fn apply_server_root_rekey(
2886 community: &Community,
2887 parsed: &super::rekey::ParsedRekey,
2888) -> Result<RekeyOutcome, String> {
2889 let session = crate::db::current_session();
2890
2891 if !matches!(parsed.scope, super::derive::RekeyScope::ServerRoot) {
2893 return Err("not a server-root rekey (channel rekeys use apply_channel_rekey)".to_string());
2894 }
2895 let cid = community.id.to_hex();
2896
2897 if crate::db::community::get_community_dissolved(&cid)? && !super::migration::catchup_exempt(&cid, parsed.new_epoch.0) {
2908 return Err("community is dissolved; base epoch cannot advance".to_string());
2909 }
2910
2911 let owner = proven_owner_hex(community);
2918 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_else(|e| {
2919 crate::log_warn!("base rekey apply: roster read failed ({e}); authorizing owner only");
2920 Default::default()
2921 });
2922 if !rotator_is_authorized(&cid, &roster, owner.as_deref(), &parsed.rotator.to_hex(), super::roles::Permissions::BAN) {
2923 return Err("base rekey rotator lacks server-wide rotation authority (BAN)".to_string());
2924 }
2925
2926 if let Some(prev_root) =
2933 crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, parsed.prev_epoch.0)?
2934 {
2935 if super::rekey::epoch_key_commitment(parsed.prev_epoch, &prev_root) != parsed.prev_key_commitment {
2936 crate::log_warn!(
2937 "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",
2938 parsed.new_epoch.0, parsed.prev_epoch.0
2939 );
2940 }
2941 }
2942
2943 let my_keys = crate::state::MY_SECRET_KEY.to_keys().ok_or("no local identity to open the base rekey blob")?;
2945 let secret = super::rekey::rekey_pairwise_secret(my_keys.secret_key(), &parsed.rotator)?;
2946 let my_locator = super::derive::recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
2947 let mine = match parsed.blobs.iter().find(|b| b.locator == my_locator) {
2948 Some(b) => b,
2949 None => return Ok(RekeyOutcome::NotARecipient),
2950 };
2951 let new_root =
2952 super::rekey::open_rekey_blob(my_keys.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine)?;
2953
2954 if !session.is_live() {
2955 return Err("session changed during base rekey apply".to_string());
2956 }
2957 let head_advanced = crate::db::community::advance_server_root_epoch(&cid, parsed.new_epoch.0, &new_root)?;
2958 Ok(RekeyOutcome::Applied { head_advanced })
2959}
2960
2961const REKEY_CATCHUP_WINDOW: u64 = 64;
2965const MAX_REKEY_CATCHUP_ROUNDS: usize = 64;
2968
2969async fn heal_channel_fork_epochs<T: Transport + ?Sized>(
2981 transport: &T,
2982 community: &Community,
2983 channel_id: &super::ChannelId,
2984 cid: &str,
2985 channel_hex: &str,
2986 epochs: &std::collections::BTreeSet<u64>,
2987 server_roots: &[[u8; 32]],
2988 session: &std::sync::Arc<crate::db::Session>,
2989) -> Result<(), String> {
2990 if epochs.is_empty() {
2991 return Ok(());
2992 }
2993 let owner_hex = proven_owner_hex(community);
2994 let roster = crate::db::community::get_community_roles(cid).unwrap_or_default();
2995 let mut winner: std::collections::BTreeMap<u64, [u8; 32]> = std::collections::BTreeMap::new();
2998 for sr in server_roots {
2999 let z_tags: Vec<String> = epochs
3000 .iter()
3001 .map(|e| super::derive::rekey_pseudonym(&super::ServerRootKey(*sr), channel_id, super::Epoch(*e)).to_hex())
3002 .collect();
3003 let q = Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags, ..Default::default() };
3004 for ev in transport.fetch(&q, &community.relays).await.unwrap_or_default() {
3005 let Ok(p) = super::rekey::open_rekey_event(&ev, sr) else { continue };
3006 if !matches!(p.scope, super::derive::RekeyScope::Channel(c) if &c == channel_id) || !epochs.contains(&p.new_epoch.0) {
3007 continue;
3008 }
3009 if !rotator_is_authorized(cid, &roster, owner_hex.as_deref(), &p.rotator.to_hex(), super::roles::Permissions::MANAGE_CHANNELS) {
3010 continue;
3011 }
3012 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);
3014 }
3015 }
3016 for (epoch, win_key) in winner {
3017 if !session.is_live() {
3018 return Err("session changed during channel convergence".to_string());
3019 }
3020 if let Ok(Some(cur)) = crate::db::community::held_epoch_key(cid, channel_hex, epoch) {
3025 if win_key < cur {
3026 match crate::db::community::converge_channel_epoch(cid, channel_hex, epoch, &win_key) {
3029 Ok(false) => crate::log_trace!("channel heal: converge of epoch {epoch} did not apply (head moved)"),
3030 Err(e) => crate::log_trace!("channel heal: converge of epoch {epoch} errored: {e}"),
3031 Ok(true) => {}
3032 }
3033 }
3034 }
3035 }
3036 Ok(())
3037}
3038
3039pub async fn catch_up_channel_rekeys<T: Transport + ?Sized>(
3054 transport: &T,
3055 community: &Community,
3056 channel_id: &super::ChannelId,
3057) -> Result<u64, String> {
3058 let session = crate::db::current_session();
3059 let server_root = community.server_root_key.as_bytes();
3060 let cid = community.id.to_hex();
3061 let channel_hex = channel_id.to_hex();
3062 let mut server_roots: Vec<[u8; 32]> = crate::db::community::held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX)
3068 .unwrap_or_default()
3069 .into_iter()
3070 .map(|(_, k)| k)
3071 .collect();
3072 if !server_roots.iter().any(|r| r == server_root) {
3073 server_roots.push(*server_root); }
3075 let mut head = community
3076 .channels
3077 .iter()
3078 .find(|c| &c.id == channel_id)
3079 .ok_or("channel not found in community")?
3080 .epoch
3081 .0;
3082
3083 let mut forked_epochs: std::collections::BTreeSet<u64> = std::collections::BTreeSet::new();
3087
3088 for _round in 0..MAX_REKEY_CATCHUP_ROUNDS {
3089 let window_top = head.saturating_add(REKEY_CATCHUP_WINDOW);
3090 let mut parsed: Vec<super::rekey::ParsedRekey> = Vec::new();
3094 for sr in &server_roots {
3095 let z_tags: Vec<String> = (head.saturating_add(1)..=window_top)
3096 .map(|e| super::derive::rekey_pseudonym(&super::ServerRootKey(*sr), channel_id, super::Epoch(e)).to_hex())
3097 .collect();
3098 let query = Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags, ..Default::default() };
3099 for ev in transport.fetch(&query, &community.relays).await.unwrap_or_default() {
3104 if let Ok(p) = super::rekey::open_rekey_event(&ev, sr) {
3105 if matches!(p.scope, super::derive::RekeyScope::Channel(c) if &c == channel_id) {
3106 parsed.push(p);
3107 }
3108 }
3109 }
3110 }
3111 if parsed.is_empty() {
3112 break; }
3114 parsed.sort_by_key(|p| p.new_epoch.0);
3116 let max_found = parsed.last().map(|p| p.new_epoch.0).unwrap_or(head);
3117
3118 let head_before = head;
3119 let mut removed = false;
3120 let mut by_epoch: std::collections::BTreeMap<u64, Vec<&super::rekey::ParsedRekey>> = std::collections::BTreeMap::new();
3125 for p in &parsed {
3126 by_epoch.entry(p.new_epoch.0).or_default().push(p);
3127 }
3128 for (e, chunks) in by_epoch {
3129 if !session.is_live() {
3130 return Err("session changed during rekey catch-up".to_string());
3131 }
3132 let mut applied = false;
3133 let mut saw_not_recipient = false;
3134 for p in &chunks {
3135 match apply_channel_rekey(community, p) {
3136 Ok(RekeyOutcome::Applied { .. }) => {
3137 applied = true;
3138 break;
3139 }
3140 Ok(RekeyOutcome::NotARecipient) => saw_not_recipient = true,
3141 Err(err) => crate::log_warn!("rekey catch-up: skipping epoch {e} chunk: {err}"),
3142 }
3143 }
3144 if applied {
3145 if let Some(p) = chunks.first() {
3151 let pe = p.prev_epoch.0;
3152 if let Ok(Some(prev_key)) = crate::db::community::held_epoch_key(&cid, &channel_hex, pe) {
3153 if super::rekey::epoch_key_commitment(p.prev_epoch, &prev_key) != p.prev_key_commitment {
3154 forked_epochs.insert(pe);
3155 }
3156 }
3157 }
3158 if e > head + 1 {
3161 crate::log_warn!(
3162 "rekey catch-up: channel epochs {}..={} not recovered (key gap; history unreadable until re-fetched)",
3163 head + 1, e - 1
3164 );
3165 }
3166 head = head.max(e);
3167 } else if saw_not_recipient {
3168 removed = true;
3171 break;
3172 }
3173 }
3175
3176 if removed || head == head_before || max_found < window_top {
3179 break;
3180 }
3181 }
3182
3183 let held: std::collections::HashSet<u64> = crate::db::community::held_epoch_keys(&cid, &channel_hex)
3190 .unwrap_or_default()
3191 .into_iter()
3192 .map(|(e, _)| e.0)
3193 .collect();
3194 let missing: Vec<u64> = (0..head).filter(|e| !held.contains(e)).collect();
3195 if !missing.is_empty() {
3196 for sr in &server_roots {
3197 if !session.is_live() {
3198 return Err("session changed during rekey gap-fill".to_string());
3199 }
3200 let z_tags: Vec<String> = missing
3201 .iter()
3202 .map(|e| super::derive::rekey_pseudonym(&super::ServerRootKey(*sr), channel_id, super::Epoch(*e)).to_hex())
3203 .collect();
3204 let query = Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags, ..Default::default() };
3205 for ev in transport.fetch(&query, &community.relays).await.unwrap_or_default() {
3208 if let Ok(p) = super::rekey::open_rekey_event(&ev, sr) {
3209 if matches!(p.scope, super::derive::RekeyScope::Channel(c) if &c == channel_id) {
3210 let _ = apply_channel_rekey(community, &p); }
3212 }
3213 }
3214 }
3215 }
3216
3217 if head > 0 && session.is_live() {
3224 let lo = head.saturating_sub(REKEY_CATCHUP_WINDOW).max(1);
3225 let mut epochs: std::collections::BTreeSet<u64> = (lo..=head).collect();
3226 epochs.append(&mut forked_epochs);
3227 let _ = heal_channel_fork_epochs(transport, community, channel_id, &cid, &channel_hex, &epochs, &server_roots, &session).await;
3228 }
3229 Ok(head)
3230}
3231
3232const MAX_BASE_CATCHUP_STEPS: usize = 256;
3235
3236fn peek_my_server_root(parsed: &super::rekey::ParsedRekey) -> Result<Option<[u8; 32]>, String> {
3250 if !matches!(parsed.scope, super::derive::RekeyScope::ServerRoot) {
3251 return Ok(None);
3252 }
3253 let my_keys = crate::state::MY_SECRET_KEY.to_keys().ok_or("no local key to open a base rekey blob")?;
3254 let secret = super::rekey::rekey_pairwise_secret(my_keys.secret_key(), &parsed.rotator)?;
3255 let my_locator = super::derive::recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
3256 let mine = match parsed.blobs.iter().find(|b| b.locator == my_locator) {
3257 Some(b) => b,
3258 None => return Ok(None),
3259 };
3260 super::rekey::open_rekey_blob(my_keys.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine).map(Some)
3261}
3262
3263fn peek_my_channel_key(parsed: &super::rekey::ParsedRekey) -> Option<[u8; 32]> {
3267 let my_keys = crate::state::MY_SECRET_KEY.to_keys()?;
3268 let secret = super::rekey::rekey_pairwise_secret(my_keys.secret_key(), &parsed.rotator).ok()?;
3269 let my_locator = super::derive::recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
3270 let mine = parsed.blobs.iter().find(|b| b.locator == my_locator)?;
3271 super::rekey::open_rekey_blob(my_keys.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine).ok()
3272}
3273
3274pub async fn catch_up_server_root<T: Transport + ?Sized>(
3275 transport: &T,
3276 community: &Community,
3277) -> Result<BaseCatchup, String> {
3278 let session = crate::db::current_session();
3279 let cid = community.id.to_hex();
3280 let mut head = community.server_root_epoch.0;
3281 let mut removed = false;
3286 let mut current_root: [u8; 32] = *community.server_root_key.as_bytes();
3288
3289 for _step in 0..MAX_BASE_CATCHUP_STEPS {
3290 let next = match head.checked_add(1) {
3291 Some(n) => n,
3292 None => break,
3293 };
3294 let addr = super::derive::base_rekey_pseudonym(&super::ServerRootKey(current_root), &community.id, super::Epoch(next)).to_hex();
3295 let query = Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() };
3296 let events = transport.fetch(&query, &community.relays).await?;
3297 if events.is_empty() {
3298 break; }
3300
3301 let chunks: Vec<super::rekey::ParsedRekey> = events
3304 .iter()
3305 .filter_map(|ev| super::rekey::open_rekey_event(ev, ¤t_root).ok())
3306 .filter(|p| matches!(p.scope, super::derive::RekeyScope::ServerRoot) && p.new_epoch.0 == next)
3307 .collect();
3308 if chunks.is_empty() {
3309 break; }
3311
3312 if !session.is_live() {
3313 return Err("session changed during base rekey catch-up".to_string());
3314 }
3315
3316 let owner_hex = proven_owner_hex(community);
3330 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
3331 let mut candidates: Vec<(&super::rekey::ParsedRekey, [u8; 32])> = Vec::new();
3332 for parsed in &chunks {
3333 if !rotator_is_authorized(&cid, &roster, owner_hex.as_deref(), &parsed.rotator.to_hex(), super::roles::Permissions::BAN) {
3334 continue;
3335 }
3336 match peek_my_server_root(parsed) {
3337 Ok(Some(root)) => candidates.push((parsed, root)),
3338 Ok(None) => {}
3339 Err(err) => crate::log_warn!("base rekey catch-up: epoch {next} peek: {err}"),
3340 }
3341 }
3342 let applied = match candidates.into_iter().min_by(|a, b| a.1.cmp(&b.1)) {
3343 Some((parsed, _)) => match apply_server_root_rekey(community, parsed) {
3344 Ok(RekeyOutcome::Applied { .. }) => true,
3345 Ok(RekeyOutcome::NotARecipient) => false, Err(err) => { crate::log_warn!("base rekey catch-up: epoch {next} apply: {err}"); false }
3347 },
3348 None => {
3349 let owner = proven_owner_hex(community);
3354 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
3355 if chunks.iter().any(|p| rotator_is_authorized(&cid, &roster, owner.as_deref(), &p.rotator.to_hex(), super::roles::Permissions::BAN)) {
3356 removed = true;
3357 }
3358 false }
3360 };
3361 if !applied {
3362 break;
3363 }
3364 match crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, next)? {
3366 Some(root) => {
3367 current_root = root;
3368 head = next;
3369 }
3370 None => {
3371 crate::log_warn!("base rekey catch-up: epoch {next} applied but its root is not archived; halting walk");
3374 break;
3375 }
3376 }
3377 }
3378
3379 if head > 0 && !removed {
3386 if let Ok(Some(prior_root)) = crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, head - 1) {
3387 let addr = super::derive::base_rekey_pseudonym(&super::ServerRootKey(prior_root), &community.id, super::Epoch(head)).to_hex();
3388 let query = Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() };
3389 let events = transport.fetch(&query, &community.relays).await.unwrap_or_default();
3390 let chunks: Vec<super::rekey::ParsedRekey> = events
3391 .iter()
3392 .filter_map(|ev| super::rekey::open_rekey_event(ev, &prior_root).ok())
3393 .filter(|p| matches!(p.scope, super::derive::RekeyScope::ServerRoot) && p.new_epoch.0 == head)
3394 .collect();
3395 let owner_hex = proven_owner_hex(community);
3396 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
3397 let mut best: Option<(&super::rekey::ParsedRekey, [u8; 32])> = None;
3398 for p in &chunks {
3399 if !rotator_is_authorized(&cid, &roster, owner_hex.as_deref(), &p.rotator.to_hex(), super::roles::Permissions::BAN) {
3403 continue;
3404 }
3405 if let Ok(Some(root)) = peek_my_server_root(p) {
3406 if best.as_ref().map_or(true, |(_, br)| root < *br) {
3407 best = Some((p, root));
3408 }
3409 }
3410 }
3411 let current_deauthorized = chunks.iter().any(|p| {
3421 matches!(peek_my_server_root(p), Ok(Some(r)) if r == current_root)
3422 && !rotator_is_authorized(&cid, &roster, owner_hex.as_deref(), &p.rotator.to_hex(), super::roles::Permissions::BAN)
3423 });
3424 if let Some((winner, win_root)) = best {
3425 let adopt = if current_deauthorized {
3426 win_root != current_root
3427 } else {
3428 win_root < current_root
3429 };
3430 if adopt {
3431 if !session.is_live() {
3432 return Err("session changed during base convergence".to_string());
3433 }
3434 if apply_server_root_rekey(community, winner).is_ok() {
3437 match crate::db::community::converge_server_root_epoch(&cid, head, &win_root) {
3438 Ok(false) => crate::log_trace!("base heal: converge of epoch {head} did not apply (head moved)"),
3439 Err(e) => crate::log_trace!("base heal: converge of epoch {head} errored: {e}"),
3440 Ok(true) => {}
3441 }
3442 current_root = win_root;
3443 if let Ok(Some(fresh)) = crate::db::community::load_community(&community.id) {
3444 let _ = fetch_and_apply_control(transport, &fresh).await;
3445 }
3446 }
3447 }
3448 }
3449 }
3450 }
3451 let _ = current_root; Ok(BaseCatchup { epoch: head, removed })
3453}
3454
3455#[derive(Debug, Clone, Copy)]
3459pub struct BaseCatchup {
3460 pub epoch: u64,
3461 pub removed: bool,
3462}
3463
3464#[cfg(test)]
3465mod tests {
3466 use super::*;
3467 use crate::community::send::fetch_channel_messages;
3468 use crate::community::transport::{memory::MemoryRelay, Query, Transport};
3469 use nostr_sdk::prelude::{EventBuilder, Kind};
3470
3471 struct FailingRelay;
3474 #[async_trait::async_trait]
3475 impl Transport for FailingRelay {
3476 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
3477 async fn publish(&self, _event: &Event, _relays: &[String]) -> Result<(), String> {
3478 Err("relay unreachable".to_string())
3479 }
3480 async fn publish_durable(&self, _event: &Event, _relays: &[String]) -> Result<(), String> {
3481 Err("relay unreachable".to_string())
3482 }
3483 async fn fetch(&self, _query: &Query, _relays: &[String]) -> Result<Vec<Event>, String> {
3484 Ok(Vec::new())
3485 }
3486 }
3487
3488 struct RekeyFailingRelay {
3492 inner: MemoryRelay,
3493 fail_rekey: std::sync::atomic::AtomicBool,
3494 }
3495 impl RekeyFailingRelay {
3496 fn new() -> Self {
3497 Self { inner: MemoryRelay::new(), fail_rekey: std::sync::atomic::AtomicBool::new(true) }
3498 }
3499 fn allow_rekey(&self) {
3500 self.fail_rekey.store(false, std::sync::atomic::Ordering::Relaxed);
3501 }
3502 fn blocks(&self, event: &Event) -> bool {
3503 self.fail_rekey.load(std::sync::atomic::Ordering::Relaxed)
3504 && event.kind.as_u16() == crate::stored_event::event_kind::COMMUNITY_REKEY
3505 }
3506 }
3507 #[async_trait::async_trait]
3508 impl Transport for RekeyFailingRelay {
3509 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
3510 async fn publish(&self, event: &Event, relays: &[String]) -> Result<(), String> {
3511 if self.blocks(event) { return Err("rekey relay down".to_string()); }
3512 self.inner.publish(event, relays).await
3513 }
3514 async fn publish_durable(&self, event: &Event, relays: &[String]) -> Result<(), String> {
3515 if self.blocks(event) { return Err("rekey relay down".to_string()); }
3516 self.inner.publish_durable(event, relays).await
3517 }
3518 async fn fetch(&self, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
3519 self.inner.fetch(query, relays).await
3520 }
3521 }
3522
3523 static TEST_COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(5000);
3524
3525 fn make_test_npub(n: u32) -> String {
3526 const BECH32: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
3527 let mut payload = vec![b'q'; 58];
3528 let mut x = n as u64;
3529 let mut i = 58;
3530 while x > 0 && i > 0 {
3531 i -= 1;
3532 payload[i] = BECH32[(x as usize) % 32];
3533 x /= 32;
3534 }
3535 format!("npub1{}", std::str::from_utf8(&payload).unwrap())
3536 }
3537
3538 fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
3539 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
3540 crate::db::close_database();
3541 crate::db::clear_id_caches();
3544 crate::signer::set_test_signer(None);
3547 let tmp = tempfile::tempdir().unwrap();
3548 let n = TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3549 let account = make_test_npub(n);
3550 std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
3551 crate::db::set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
3552 crate::db::set_current_account(account.clone()).unwrap();
3553 crate::db::init_database(&account).unwrap();
3554 let _ = crate::state::take_nostr_client();
3557 let owner = Keys::generate();
3559 crate::state::MY_SECRET_KEY.store_from_keys(&owner, &[]);
3560 crate::state::set_my_public_key(owner.public_key());
3561 (tmp, guard)
3562 }
3563
3564 #[test]
3565 fn community_cap_rejects_a_new_membership_at_the_limit() {
3566 let (_tmp, _guard) = init_test_db();
3567 let mk = |i: usize| {
3568 let id = format!("{:064x}", i);
3569 crate::community::list::CommunityListEntry {
3570 community_id: id.clone(),
3571 seed: crate::community::invite::CommunityInvite {
3572 community_id: id,
3573 name: String::new(),
3574 server_root_key: String::new(),
3575 server_root_epoch: 0,
3576 relays: vec![],
3577 channels: vec![],
3578 owner_attestation: None,
3579 icon: None,
3580 },
3581 current: None,
3582 added_at: 0,
3583 }
3584 };
3585 let mut list = crate::community::list::CommunityList::default();
3586 for i in 0..(MAX_COMMUNITIES - 1) {
3587 list.entries.push(mk(i));
3588 }
3589 crate::db::settings::set_sql_setting("community_list_json".to_string(), list.to_json()).unwrap();
3590 assert!(enforce_community_cap().is_ok(), "under the cap a new join is allowed");
3591
3592 list.entries.push(mk(MAX_COMMUNITIES - 1)); crate::db::settings::set_sql_setting("community_list_json".to_string(), list.to_json()).unwrap();
3594 assert!(enforce_community_cap().is_err(), "at the cap a new join is rejected");
3595 }
3596
3597 fn saved_community_owned_by(owner: &Keys) -> Community {
3602 let mut community = Community::create("HQ", "general", vec!["r".into()]);
3603 let cid = community.id.to_hex();
3604 community.owner_attestation = Some(
3605 crate::community::owner::build_owner_attestation_unsigned(owner.public_key(), &cid)
3606 .finalize(owner)
3607 .unwrap()
3608 .as_json(),
3609 );
3610 crate::db::community::save_community(&community).unwrap();
3611 community
3612 }
3613
3614 fn attested_community(name: &str, channel: &str, relays: Vec<String>) -> Community {
3618 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
3619 let mut community = Community::create(name, channel, relays);
3620 community.owner_attestation = Some(
3621 crate::community::owner::build_owner_attestation_unsigned(owner.public_key(), &community.id.to_hex())
3622 .finalize(&owner).unwrap().as_json(),
3623 );
3624 community
3625 }
3626
3627 fn become_local(me: &Keys) {
3629 crate::state::MY_SECRET_KEY.store_from_keys(me, &[]);
3630 crate::state::set_my_public_key(me.public_key());
3631 }
3632
3633 fn owner_channel_rekey(
3636 owner: &Keys,
3637 community: &Community,
3638 recipient_pk: &nostr_sdk::prelude::PublicKey,
3639 new_epoch: u64,
3640 new_key: &[u8; 32],
3641 ) -> super::super::rekey::ParsedRekey {
3642 let chan = &community.channels[0];
3643 let scope = super::super::derive::RekeyScope::Channel(chan.id);
3644 let blob = super::super::rekey::build_rekey_blob(
3645 owner.secret_key(), recipient_pk, scope, crate::community::Epoch(new_epoch), new_key,
3646 )
3647 .unwrap();
3648 let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), chan.key.as_bytes());
3649 let outer = super::super::rekey::build_channel_rekey_event(
3650 &Keys::generate(), owner, community.server_root_key.as_bytes(), &chan.id,
3651 crate::community::Epoch(new_epoch), crate::community::Epoch(0), &commit, &[blob],
3652 )
3653 .unwrap();
3654 super::super::rekey::open_rekey_event(&outer, community.server_root_key.as_bytes()).unwrap()
3655 }
3656
3657 #[tokio::test]
3662 async fn outer_event_dedup_skips_an_already_persisted_wire_event() {
3663 let (_tmp, _guard) = init_test_db();
3664 let owner = Keys::generate();
3665 let me = Keys::generate();
3666 become_local(&me);
3667 let community = saved_community_owned_by(&owner);
3668 let channel = community.channels[0].clone();
3669 let chan_hex = channel.id.to_hex();
3670
3671 let author = Keys::generate();
3673 let outer = crate::community::envelope::seal_message(
3674 &author, &channel.key, &channel.id, channel.epoch, "gm", 1000,
3675 ).unwrap();
3676 let outer_hex = outer.id.to_hex();
3677
3678 let mut state = crate::state::ChatState::new();
3680 let msg = match crate::community::inbound::process_incoming(&mut state, &outer, &channel, &me.public_key()) {
3681 Some(crate::community::inbound::IncomingEvent::NewMessage(m)) => m,
3682 _ => panic!("expected NewMessage from a fresh wire event"),
3683 };
3684 assert_eq!(msg.wrapper_event_id.as_deref(), Some(outer_hex.as_str()),
3685 "the inner must carry its outer wire id as wrapper_event_id");
3686
3687 crate::db::events::save_message(&chan_hex, &msg).await.unwrap();
3689
3690 let mut state2 = crate::state::ChatState::new();
3692 let second = crate::community::inbound::process_incoming(&mut state2, &outer, &channel, &me.public_key());
3693 assert!(second.is_none(), "an already-processed wire event must dedup before decryption");
3694 }
3695
3696 #[tokio::test]
3699 async fn ledger_is_shared_but_negentropy_stays_nip17_only() {
3700 let (_tmp, _guard) = init_test_db();
3701 let dm = [0xA1u8; 32];
3702 let concord = [0xC0u8; 32];
3703 crate::db::wrappers::save_processed_wrapper(&dm, 100, crate::db::wrappers::TRANSPORT_NIP17).unwrap();
3704 crate::db::wrappers::save_processed_wrapper(&concord, 200, crate::db::wrappers::TRANSPORT_CONCORD).unwrap();
3705
3706 assert!(crate::db::wrappers::processed_wrapper_exists(&dm));
3708 assert!(crate::db::wrappers::processed_wrapper_exists(&concord));
3709
3710 let items = crate::db::wrappers::load_negentropy_items().unwrap();
3712 assert_eq!(items.len(), 1, "negentropy must exclude concord wrappers");
3713 assert_eq!(items[0].0.to_bytes(), dm);
3714 }
3715
3716 #[tokio::test]
3720 async fn non_message_subkind_dedups_via_the_shared_ledger() {
3721 let (_tmp, _guard) = init_test_db();
3722 let owner = Keys::generate();
3723 let me = Keys::generate();
3724 become_local(&me);
3725 let community = saved_community_owned_by(&owner);
3726 let channel = community.channels[0].clone();
3727
3728 let author = Keys::generate();
3730 let inner = super::super::envelope::build_inner_typed(
3731 author.public_key(), &channel.id, channel.epoch,
3732 crate::stored_event::event_kind::COMMUNITY_PRESENCE, "join", 5, None, &[],
3733 ).finalize(&author).unwrap();
3734 let outer = super::super::envelope::seal_with_signed_inner(
3735 &Keys::generate(), &inner, &channel.key, &channel.id, channel.epoch,
3736 ).unwrap();
3737
3738 let mut state = crate::state::ChatState::new();
3740 let first = crate::community::inbound::process_incoming(&mut state, &outer, &channel, &me.public_key());
3741 assert!(matches!(first, Some(crate::community::inbound::IncomingEvent::Presence { .. })),
3742 "expected a Presence outcome");
3743 assert!(crate::db::wrappers::processed_wrapper_exists(&outer.id.to_bytes()),
3744 "a non-message sub-kind must record its outer id in the shared ledger");
3745
3746 let second = crate::community::inbound::process_incoming(&mut crate::state::ChatState::new(), &outer, &channel, &me.public_key());
3748 assert!(second.is_none(), "a re-fetched presence must dedup via the shared ledger");
3749 }
3750
3751 #[test]
3752 fn apply_channel_rekey_recovers_and_advances_head() {
3753 let (_tmp, _guard) = init_test_db();
3754 let owner = Keys::generate(); let me = Keys::generate();
3756 become_local(&me);
3757 let community = saved_community_owned_by(&owner);
3758 let cid = community.id.to_hex();
3759 let chan_hex = community.channels[0].id.to_hex();
3760 let new_key = [0xCDu8; 32];
3761
3762 let parsed = owner_channel_rekey(&owner, &community, &me.public_key(), 1, &new_key);
3763 let outcome = apply_channel_rekey(&community, &parsed).unwrap();
3764 assert_eq!(outcome, RekeyOutcome::Applied { head_advanced: true });
3765
3766 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(new_key));
3768 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
3769 assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(1));
3770 assert_eq!(reloaded.channels[0].key.as_bytes(), &new_key);
3771 assert!(crate::db::community::held_epoch_key(&cid, &chan_hex, 0).unwrap().is_some());
3773 }
3774
3775 #[test]
3776 fn apply_channel_rekey_accepts_matching_continuity() {
3777 let (_tmp, _guard) = init_test_db();
3780 let owner = Keys::generate();
3781 let me = Keys::generate();
3782 become_local(&me);
3783 let community = saved_community_owned_by(&owner);
3784 let parsed = owner_channel_rekey(&owner, &community, &me.public_key(), 1, &[0x44u8; 32]);
3786 assert_eq!(
3787 apply_channel_rekey(&community, &parsed).unwrap(),
3788 RekeyOutcome::Applied { head_advanced: true },
3789 "a rekey whose prior-key commitment matches the held genesis key applies"
3790 );
3791 }
3792
3793 #[test]
3794 fn advance_channel_epoch_archives_when_no_head_row() {
3795 let (_tmp, _guard) = init_test_db();
3798 let cid = "f".repeat(64);
3799 let orphan_channel = "a".repeat(64);
3800 let advanced = crate::db::community::advance_channel_epoch(&cid, &orphan_channel, 2, &[0x77u8; 32]).unwrap();
3801 assert!(!advanced, "no head row → head not advanced");
3802 assert_eq!(crate::db::community::held_epoch_key(&cid, &orphan_channel, 2).unwrap(), Some([0x77u8; 32]), "key still archived");
3803 }
3804
3805 #[tokio::test]
3806 async fn rotate_channel_publishes_recoverable_rekey_and_advances_own_head() {
3807 use crate::community::derive::{recipient_pseudonym, rekey_pseudonym};
3808 use crate::community::rekey::{open_rekey_blob, open_rekey_event, rekey_pairwise_secret};
3809 let (_tmp, _guard) = init_test_db();
3810 let owner = Keys::generate();
3811 become_local(&owner); let community = saved_community_owned_by(&owner);
3813 let channel_id = community.channels[0].id;
3814 let member = Keys::generate(); let relay = MemoryRelay::new();
3816
3817 let new_epoch = rotate_channel(&relay, &community, &channel_id, &[member.public_key()], community.server_root_key.as_bytes())
3818 .await
3819 .expect("rotate");
3820 assert_eq!(new_epoch, 1);
3821
3822 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
3824 assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(1));
3825
3826 let addr = rekey_pseudonym(
3829 &crate::community::ServerRootKey(*community.server_root_key.as_bytes()),
3830 &channel_id, crate::community::Epoch(1),
3831 )
3832 .to_hex();
3833 let found = relay
3834 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() }, &community.relays)
3835 .await
3836 .unwrap();
3837 assert_eq!(found.len(), 1, "rekey addressable by its server-root pseudonym");
3838 let parsed = open_rekey_event(&found[0], community.server_root_key.as_bytes()).unwrap();
3839 assert_eq!(parsed.rotator, owner.public_key());
3840 assert_eq!(parsed.new_epoch, crate::community::Epoch(1));
3841 assert_eq!(parsed.prev_epoch, crate::community::Epoch(0));
3842 assert_eq!(parsed.blobs.len(), 2, "the member + me (multi-device) each get a blob");
3843
3844 let secret = rekey_pairwise_secret(member.secret_key(), &parsed.rotator).unwrap();
3846 let loc = recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
3847 let mine = parsed.blobs.iter().find(|b| b.locator == loc).expect("member's blob present");
3848 let recovered = open_rekey_blob(member.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine).unwrap();
3849 assert_eq!(reloaded.channels[0].key.as_bytes(), &recovered, "member's recovered key == my advanced head key");
3850 }
3851
3852 #[tokio::test]
3853 async fn rotate_channel_failed_publish_leaves_head_unadvanced() {
3854 let (_tmp, _guard) = init_test_db();
3857 let owner = Keys::generate();
3858 become_local(&owner);
3859 let community = saved_community_owned_by(&owner);
3860 let member = Keys::generate();
3861 let err = rotate_channel(&FailingRelay, &community, &community.channels[0].id, &[member.public_key()], community.server_root_key.as_bytes()).await;
3862 assert!(err.is_err(), "a failed publish must propagate, not silently advance");
3863 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
3864 assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(0), "head stays put on publish failure");
3865 }
3866
3867 fn build_rekey_chain(
3871 owner: &Keys, community: &Community, recipient_pk: &nostr_sdk::prelude::PublicKey, n: u64,
3872 ) -> (Vec<Event>, Vec<[u8; 32]>) {
3873 let chan = &community.channels[0];
3874 let scope = super::super::derive::RekeyScope::Channel(chan.id);
3875 let mut prev_key = *chan.key.as_bytes();
3876 let mut events = Vec::new();
3877 let mut keys = Vec::new();
3878 for e in 1..=n {
3879 let new_key = [e as u8; 32];
3880 let blob = super::super::rekey::build_rekey_blob(owner.secret_key(), recipient_pk, scope, crate::community::Epoch(e), &new_key).unwrap();
3881 let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(e - 1), &prev_key);
3882 let ev = super::super::rekey::build_channel_rekey_event(
3883 &Keys::generate(), owner, community.server_root_key.as_bytes(), &chan.id,
3884 crate::community::Epoch(e), crate::community::Epoch(e - 1), &commit, &[blob],
3885 ).unwrap();
3886 events.push(ev);
3887 keys.push(new_key);
3888 prev_key = new_key;
3889 }
3890 (events, keys)
3891 }
3892
3893 #[tokio::test]
3894 async fn catch_up_steps_over_a_missing_epoch() {
3895 let (_tmp, _guard) = init_test_db();
3898 let owner = Keys::generate();
3899 let me = Keys::generate();
3900 become_local(&me);
3901 let community = saved_community_owned_by(&owner);
3902 let channel_id = community.channels[0].id;
3903 let cid = community.id.to_hex();
3904 let chan_hex = channel_id.to_hex();
3905
3906 let (events, keys) = build_rekey_chain(&owner, &community, &me.public_key(), 3);
3907 let relay = MemoryRelay::new();
3908 relay.inject(&events[0], &community.relays); relay.inject(&events[2], &community.relays); let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
3911
3912 assert_eq!(reached, 3, "head reaches the latest present epoch, stepping over the gap");
3913 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(keys[0]));
3914 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 2).unwrap(), None, "missing epoch is a hole");
3915 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 3).unwrap(), Some(keys[2]));
3916 }
3917
3918 #[tokio::test]
3919 async fn catch_up_recovers_a_rekey_under_a_prior_server_root() {
3920 let (_tmp, _guard) = init_test_db();
3925 let owner = Keys::generate();
3926 let me = Keys::generate();
3927 become_local(&me);
3928 let root0_community = saved_community_owned_by(&owner);
3929 let cid = root0_community.id.to_hex();
3930 let channel_id = root0_community.channels[0].id;
3931 let chan_hex = channel_id.to_hex();
3932 let scope = super::super::derive::RekeyScope::Channel(channel_id);
3933 let genesis_key = *root0_community.channels[0].key.as_bytes();
3934
3935 let root1 = [0x99u8; 32];
3937 crate::db::community::advance_server_root_epoch(&cid, 1, &root1).unwrap();
3938 let community = crate::db::community::load_community(&root0_community.id).unwrap().unwrap();
3939 assert_eq!(community.server_root_epoch, crate::community::Epoch(1));
3940
3941 let (k1, k2) = ([0x11u8; 32], [0x22u8; 32]);
3943 let blob1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &k1).unwrap();
3944 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
3945 let ev1 = super::super::rekey::build_channel_rekey_event(
3946 &Keys::generate(), &owner, root0_community.server_root_key.as_bytes(), &channel_id,
3947 crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob1]).unwrap();
3948 let blob2 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(2), &k2).unwrap();
3949 let commit1 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(1), &k1);
3950 let ev2 = super::super::rekey::build_channel_rekey_event(
3951 &Keys::generate(), &owner, &root1, &channel_id,
3952 crate::community::Epoch(2), crate::community::Epoch(1), &commit1, &[blob2]).unwrap();
3953
3954 let relay = MemoryRelay::new();
3955 relay.inject(&ev1, &community.relays);
3956 relay.inject(&ev2, &community.relays);
3957
3958 let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
3959 assert_eq!(reached, 2, "reached the latest channel epoch across the server-root rotation");
3960 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(k1),
3961 "epoch-1 key recovered from a rekey under the PRIOR server root");
3962 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 2).unwrap(), Some(k2));
3963 }
3964
3965 #[tokio::test]
3966 async fn catch_up_backfills_a_sub_head_gap() {
3967 let (_tmp, _guard) = init_test_db();
3970 let owner = Keys::generate();
3971 let me = Keys::generate();
3972 become_local(&me);
3973 let community = saved_community_owned_by(&owner);
3974 let cid = community.id.to_hex();
3975 let channel_id = community.channels[0].id;
3976 let chan_hex = channel_id.to_hex();
3977 let scope = super::super::derive::RekeyScope::Channel(channel_id);
3978 let genesis_key = *community.channels[0].key.as_bytes();
3979
3980 let k2 = [0x22u8; 32];
3982 crate::db::community::advance_channel_epoch(&cid, &chan_hex, 2, &k2).unwrap();
3983 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), None, "epoch 1 starts as a hole");
3984
3985 let k1 = [0x11u8; 32];
3987 let blob1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &k1).unwrap();
3988 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
3989 let ev1 = super::super::rekey::build_channel_rekey_event(
3990 &Keys::generate(), &owner, community.server_root_key.as_bytes(), &channel_id,
3991 crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob1]).unwrap();
3992 let relay = MemoryRelay::new();
3993 relay.inject(&ev1, &community.relays);
3994
3995 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
3996 let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
3997 assert_eq!(reached, 2, "head unchanged (gap-fill never regresses it)");
3998 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(k1),
3999 "the sub-head hole was backfilled");
4000 }
4001
4002 #[tokio::test]
4003 async fn catch_up_walks_a_chain_of_rotations_to_the_latest() {
4004 let (_tmp, _guard) = init_test_db();
4005 let owner = Keys::generate();
4006 let me = Keys::generate();
4007 become_local(&me); let community = saved_community_owned_by(&owner);
4009 let channel_id = community.channels[0].id;
4010 let cid = community.id.to_hex();
4011 let chan_hex = channel_id.to_hex();
4012
4013 let (events, keys) = build_rekey_chain(&owner, &community, &me.public_key(), 3);
4015 let relay = MemoryRelay::new();
4016 for ev in events.iter().rev() {
4017 relay.inject(ev, &community.relays);
4018 }
4019
4020 let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4021 assert_eq!(reached, 3, "caught up to the latest epoch");
4022 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4024 assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(3));
4025 assert_eq!(reloaded.channels[0].key.as_bytes(), &keys[2]);
4026 for (i, k) in keys.iter().enumerate() {
4027 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, (i + 1) as u64).unwrap(), Some(*k));
4028 }
4029 }
4030
4031 #[tokio::test]
4032 async fn catch_up_slides_across_the_window_boundary() {
4033 let (_tmp, _guard) = init_test_db();
4037 let owner = Keys::generate();
4038 let me = Keys::generate();
4039 become_local(&me);
4040 let community = saved_community_owned_by(&owner);
4041 let channel_id = community.channels[0].id;
4042 let cid = community.id.to_hex();
4043 let chan_hex = channel_id.to_hex();
4044
4045 let (events, keys) = build_rekey_chain(&owner, &community, &me.public_key(), 70);
4046 let relay = MemoryRelay::new();
4047 for ev in &events {
4048 relay.inject(ev, &community.relays);
4049 }
4050 let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4051 assert_eq!(reached, 70, "slid past the 64-epoch window boundary to the latest");
4052 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 70).unwrap(), Some(keys[69]));
4053 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 64).unwrap(), Some(keys[63]), "window-1 keys retained too");
4054 }
4055
4056 fn build_base_rekey_chain(
4062 owner: &Keys, community: &Community, recipient_pk: &nostr_sdk::prelude::PublicKey, n: u64,
4063 ) -> (Vec<Event>, Vec<[u8; 32]>) {
4064 let mut prior_root = *community.server_root_key.as_bytes();
4065 let mut events = Vec::new();
4066 let mut roots = Vec::new();
4067 for e in 1..=n {
4068 let new_root = [(e % 256) as u8; 32];
4069 let blob = super::super::rekey::build_rekey_blob(
4070 owner.secret_key(), recipient_pk, super::super::derive::RekeyScope::ServerRoot, crate::community::Epoch(e), &new_root,
4071 )
4072 .unwrap();
4073 let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(e - 1), &prior_root);
4074 events.push(super::super::rekey::build_server_root_rekey_event(
4075 &Keys::generate(), owner, &prior_root, &community.id,
4076 crate::community::Epoch(e), crate::community::Epoch(e - 1), &commit, &[blob],
4077 ).unwrap());
4078 roots.push(new_root);
4079 prior_root = new_root;
4080 }
4081 (events, roots)
4082 }
4083
4084 #[tokio::test]
4085 async fn catch_up_server_root_walks_a_chain_of_base_rotations() {
4086 let (_tmp, _guard) = init_test_db();
4087 let owner = Keys::generate();
4088 let me = Keys::generate();
4089 become_local(&me);
4090 let community = saved_community_owned_by(&owner);
4091 let cid = community.id.to_hex();
4092
4093 let (events, roots) = build_base_rekey_chain(&owner, &community, &me.public_key(), 3);
4094 let relay = MemoryRelay::new();
4095 for ev in events.iter().rev() {
4096 relay.inject(ev, &community.relays);
4097 }
4098 let reached = catch_up_server_root(&relay, &community).await.unwrap();
4099 assert_eq!(reached.epoch, 3, "walked the base chain to the latest epoch");
4100 assert!(!reached.removed, "a normal catch-up is not a removal");
4101 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4102 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(3));
4103 assert_eq!(reloaded.server_root_key.as_bytes(), &roots[2], "base head is the latest root");
4104 for (i, r) in roots.iter().enumerate() {
4106 assert_eq!(crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, (i + 1) as u64).unwrap(), Some(*r));
4107 }
4108 }
4109
4110 #[tokio::test]
4111 async fn catch_up_recovers_from_a_split_base_rotation_second_chunk() {
4112 let (_tmp, _guard) = init_test_db();
4116 let owner = Keys::generate();
4117 let me = Keys::generate();
4118 become_local(&me);
4119 let community = saved_community_owned_by(&owner);
4120 let genesis = *community.server_root_key.as_bytes();
4121 let new_root = [0x5Au8; 32];
4122 let scope = super::super::derive::RekeyScope::ServerRoot;
4123 let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis);
4124 let mk = |recipient: &nostr_sdk::prelude::PublicKey| {
4125 let blob = super::super::rekey::build_rekey_blob(owner.secret_key(), recipient, scope, crate::community::Epoch(1), &new_root).unwrap();
4126 super::super::rekey::build_server_root_rekey_event(
4127 &Keys::generate(), &owner, &genesis, &community.id,
4128 crate::community::Epoch(1), crate::community::Epoch(0), &commit, &[blob],
4129 ).unwrap()
4130 };
4131 let relay = MemoryRelay::new();
4132 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();
4136 assert_eq!(reached.epoch, 1, "recovered the split rotation via the second chunk");
4137 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4138 assert_eq!(reloaded.server_root_key.as_bytes(), &new_root, "recovered the new root from chunk 2");
4139 }
4140
4141 #[tokio::test]
4142 async fn catch_up_converges_concurrent_refoundings_on_the_lowest_root() {
4143 let (_tmp, _guard) = init_test_db();
4147 let owner = Keys::generate();
4148 let me = Keys::generate();
4149 become_local(&me);
4150 let community = saved_community_owned_by(&owner);
4151 let genesis = *community.server_root_key.as_bytes();
4152 let scope = super::super::derive::RekeyScope::ServerRoot;
4153 let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis);
4154 let root_lo = [0x10u8; 32];
4155 let root_hi = [0xF0u8; 32]; let mk = |root: &[u8; 32]| {
4157 let blob = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), root).unwrap();
4158 super::super::rekey::build_server_root_rekey_event(
4159 &Keys::generate(), &owner, &genesis, &community.id,
4160 crate::community::Epoch(1), crate::community::Epoch(0), &commit, &[blob],
4161 ).unwrap()
4162 };
4163 let relay = MemoryRelay::new();
4164 relay.inject(&mk(&root_hi), &community.relays);
4166 relay.inject(&mk(&root_lo), &community.relays);
4167
4168 let reached = catch_up_server_root(&relay, &community).await.unwrap();
4169 assert_eq!(reached.epoch, 1, "advanced one epoch");
4170 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4171 assert_eq!(reloaded.server_root_key.as_bytes(), &root_lo, "converged on the LOWEST root, not the first-arrived");
4172 }
4173
4174 #[tokio::test]
4175 async fn rotate_retry_reuses_the_archived_root_no_same_epoch_fork() {
4176 let (_tmp, _guard) = init_test_db();
4181 let owner = Keys::generate();
4182 become_local(&owner);
4183 let community = saved_community_owned_by(&owner);
4184 let cid = community.id.to_hex();
4185 let relay = RekeyFailingRelay::new(); let member = Keys::generate();
4187
4188 assert!(rotate_server_root(&relay, &community, &[member.public_key()]).await.is_err(), "the rekey publish fails");
4189 let k1 = crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap()
4190 .expect("the new root is archived before publishing (fork-safety)");
4191 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4192 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(0), "head not advanced on a failed publish");
4193
4194 relay.allow_rekey();
4195 rotate_server_root(&relay, &reloaded, &[member.public_key()]).await.unwrap();
4196 let k2 = crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap().unwrap();
4197 assert_eq!(k1, k2, "the retry REUSES the archived root — no second root for epoch 1, no fork");
4198 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
4199 assert_eq!(after.server_root_epoch, crate::community::Epoch(1), "the retry completed the rotation");
4200 assert_eq!(after.server_root_key.as_bytes(), &k1, "the committed root is the one minted on attempt 1");
4201 }
4202
4203 #[tokio::test]
4204 async fn rotate_server_root_splits_a_large_recipient_set_into_multiple_events() {
4205 let (_tmp, _guard) = init_test_db();
4207 let owner = Keys::generate();
4208 become_local(&owner);
4209 let community = saved_community_owned_by(&owner);
4210 let genesis = *community.server_root_key.as_bytes();
4211 let relay = MemoryRelay::new();
4212 let recipients: Vec<_> = (0..super::super::rekey::MAX_REKEY_BLOBS).map(|_| Keys::generate().public_key()).collect();
4214 rotate_server_root(&relay, &community, &recipients).await.unwrap();
4215 let addr = super::super::derive::base_rekey_pseudonym(&super::super::ServerRootKey(genesis), &community.id, crate::community::Epoch(1)).to_hex();
4216 let evs = relay
4217 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() }, &community.relays)
4218 .await
4219 .unwrap();
4220 assert_eq!(evs.len(), 2, "a >MAX_REKEY_BLOBS rotation splits into 2 events at one address");
4221 }
4222
4223 #[tokio::test]
4224 async fn catch_up_server_root_is_a_noop_with_no_rotations() {
4225 let (_tmp, _guard) = init_test_db();
4226 let owner = Keys::generate();
4227 let me = Keys::generate();
4228 become_local(&me);
4229 let community = saved_community_owned_by(&owner);
4230 let relay = MemoryRelay::new();
4231 assert_eq!(catch_up_server_root(&relay, &community).await.unwrap().epoch, 0, "no base rotations → stays at 0");
4232 }
4233
4234 #[tokio::test]
4235 async fn concurrent_refounders_converge_to_the_lowest_root() {
4236 let (_tmp, _guard) = init_test_db();
4241 let owner = Keys::generate();
4242 let me = Keys::generate();
4243 become_local(&me); let community = saved_community_owned_by(&owner);
4245 let cid = community.id.to_hex();
4246 let genesis_root = *community.server_root_key.as_bytes();
4247 let scope = super::super::derive::RekeyScope::ServerRoot;
4248
4249 let root_lo = [0x10u8; 32];
4252 let root_hi = [0x99u8; 32]; let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_root);
4254 let blob_lo = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &root_lo).unwrap();
4255 let ev_lo = super::super::rekey::build_server_root_rekey_event(
4256 &Keys::generate(), &owner, &genesis_root, &community.id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_lo]).unwrap();
4257
4258 let relay = MemoryRelay::new();
4259 relay.inject(&ev_lo, &community.relays);
4260
4261 crate::db::community::advance_server_root_epoch(&cid, 1, &root_hi).unwrap();
4263 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4264 assert_eq!(community.server_root_key.as_bytes(), &root_hi, "start on the higher root");
4265
4266 let out = catch_up_server_root(&relay, &community).await.unwrap();
4267 assert_eq!(out.epoch, 1, "converged in place at the same epoch (not advanced)");
4268 assert!(!out.removed);
4269 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
4270 assert_eq!(after.server_root_key.as_bytes(), &root_lo, "originator converged to the lowest authorized root");
4271
4272 let _ = catch_up_server_root(&relay, &after).await.unwrap();
4274 assert_eq!(crate::db::community::load_community(&community.id).unwrap().unwrap().server_root_key.as_bytes(), &root_lo, "no flip-flop");
4275 }
4276
4277 #[tokio::test]
4278 async fn banned_rotators_rekey_is_not_a_convergence_candidate() {
4279 let (_tmp, _guard) = init_test_db();
4284 let owner = Keys::generate();
4285 let me = Keys::generate();
4286 let banned_admin = Keys::generate();
4287 become_local(&me);
4288 let community = saved_community_owned_by(&owner);
4289 let cid = community.id.to_hex();
4290 let genesis_root = *community.server_root_key.as_bytes();
4291 let scope = super::super::derive::RekeyScope::ServerRoot;
4292
4293 let role_id = "e".repeat(64);
4295 let roster = crate::community::roles::CommunityRoles {
4296 roles: vec![crate::community::roles::Role::admin(role_id.clone())],
4297 grants: vec![crate::community::roles::MemberGrant { member: banned_admin.public_key().to_hex(), role_ids: vec![role_id] }],
4298 };
4299 crate::db::community::set_community_roles(&cid, &roster, 1).unwrap();
4300 crate::db::community::set_community_banlist(&cid, &[banned_admin.public_key().to_hex()], 2).unwrap();
4302
4303 let root_evil = [0x01u8; 32];
4305 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_root);
4306 let blob = super::super::rekey::build_rekey_blob(banned_admin.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &root_evil).unwrap();
4307 let ev = super::super::rekey::build_server_root_rekey_event(
4308 &Keys::generate(), &banned_admin, &genesis_root, &community.id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob]).unwrap();
4309 let relay = MemoryRelay::new();
4310 relay.inject(&ev, &community.relays);
4311
4312 let out = catch_up_server_root(&relay, &community).await.unwrap();
4315 assert_eq!(out.epoch, 0, "banned rotator's re-founding must not advance the base");
4316 assert!(!out.removed, "banned rotator's exclusion must not read as an authorized removal");
4317 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
4318 assert_eq!(after.server_root_key.as_bytes(), &genesis_root, "root unchanged");
4319
4320 let parsed = super::super::rekey::open_rekey_event(&ev, &genesis_root).unwrap();
4322 assert!(apply_server_root_rekey(&community, &parsed).is_err(), "apply must refuse a banned rotator");
4323 }
4324
4325 #[tokio::test]
4326 async fn heal_abandons_a_deauthorized_root_for_the_authorized_higher_sibling() {
4327 let (_tmp, _guard) = init_test_db();
4331 let owner = Keys::generate();
4332 let me = Keys::generate();
4333 let banned_admin = Keys::generate();
4334 become_local(&me);
4335 let community = saved_community_owned_by(&owner);
4336 let cid = community.id.to_hex();
4337 let genesis_root = *community.server_root_key.as_bytes();
4338 let scope = super::super::derive::RekeyScope::ServerRoot;
4339 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_root);
4340
4341 let root_evil = [0x01u8; 32];
4344 let root_owner = [0x77u8; 32];
4345 let blob_evil = super::super::rekey::build_rekey_blob(banned_admin.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &root_evil).unwrap();
4346 let ev_evil = super::super::rekey::build_server_root_rekey_event(
4347 &Keys::generate(), &banned_admin, &genesis_root, &community.id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_evil]).unwrap();
4348 let blob_owner = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &root_owner).unwrap();
4349 let ev_owner = super::super::rekey::build_server_root_rekey_event(
4350 &Keys::generate(), &owner, &genesis_root, &community.id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_owner]).unwrap();
4351 let relay = MemoryRelay::new();
4352 relay.inject(&ev_evil, &community.relays);
4353 relay.inject(&ev_owner, &community.relays);
4354
4355 crate::db::community::advance_server_root_epoch(&cid, 1, &root_evil).unwrap();
4357 crate::db::community::set_community_banlist(&cid, &[banned_admin.public_key().to_hex()], 2).unwrap();
4358 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4359 assert_eq!(community.server_root_key.as_bytes(), &root_evil, "start partitioned on the attacker's root");
4360
4361 let out = catch_up_server_root(&relay, &community).await.unwrap();
4362 assert_eq!(out.epoch, 1);
4363 assert!(!out.removed);
4364 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
4365 assert_eq!(after.server_root_key.as_bytes(), &root_owner,
4366 "heal must abandon the deauthorized root and adopt the owner's higher sibling");
4367
4368 let _ = catch_up_server_root(&relay, &after).await.unwrap();
4370 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");
4371 }
4372
4373 #[tokio::test]
4374 async fn concurrent_channel_rekeyers_converge_to_the_lowest_key() {
4375 let (_tmp, _guard) = init_test_db();
4380 let owner = Keys::generate();
4381 let me = Keys::generate();
4382 become_local(&me); let community = saved_community_owned_by(&owner);
4384 let cid = community.id.to_hex();
4385 let channel_id = community.channels[0].id;
4386 let chan_hex = channel_id.to_hex();
4387 let scope = super::super::derive::RekeyScope::Channel(channel_id);
4388 let genesis_key = *community.channels[0].key.as_bytes();
4389 let root = *community.server_root_key.as_bytes();
4390 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
4391
4392 let key_lo = [0x10u8; 32];
4394 let key_hi = [0x99u8; 32];
4395 let blob_lo = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_lo).unwrap();
4396 let blob_hi = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_hi).unwrap();
4397 let ev_lo = super::super::rekey::build_channel_rekey_event(
4398 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_lo]).unwrap();
4399 let ev_hi = super::super::rekey::build_channel_rekey_event(
4400 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_hi]).unwrap();
4401
4402 let relay = MemoryRelay::new();
4403 relay.inject(&ev_hi, &community.relays); relay.inject(&ev_lo, &community.relays);
4405
4406 crate::db::community::advance_server_root_epoch(&cid, 1, &[0x42u8; 32]).unwrap();
4411 crate::db::community::advance_channel_epoch(&cid, &chan_hex, 1, &key_hi).unwrap();
4413 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4414
4415 let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4416 assert_eq!(reached, 1, "converged in place at the same channel epoch");
4417 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(key_lo),
4418 "adopted the lowest delivered key regardless of relay order");
4419
4420 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
4422 let _ = catch_up_channel_rekeys(&relay, &after, &channel_id).await.unwrap();
4423 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(key_lo), "no flip-flop");
4424 }
4425
4426 #[tokio::test]
4427 async fn concurrent_channel_rekeyers_converge_when_i_authored_the_losing_fork() {
4428 let (_tmp, _guard) = init_test_db();
4433 let owner = Keys::generate();
4434 let me = Keys::generate(); become_local(&me);
4436 let community = saved_community_owned_by(&owner);
4437 let cid = community.id.to_hex();
4438 let channel_id = community.channels[0].id;
4439 let chan_hex = channel_id.to_hex();
4440 let scope = super::super::derive::RekeyScope::Channel(channel_id);
4441 let genesis_key = *community.channels[0].key.as_bytes();
4442 let root = *community.server_root_key.as_bytes();
4443 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
4444
4445 let role_id = "d".repeat(64);
4449 let roster = crate::community::roles::CommunityRoles {
4450 roles: vec![crate::community::roles::Role::admin(role_id.clone())],
4451 grants: vec![crate::community::roles::MemberGrant { member: me.public_key().to_hex(), role_ids: vec![role_id] }],
4452 };
4453 crate::db::community::set_community_roles(&cid, &roster, 1).unwrap();
4454
4455 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();
4459 let ev_lo = super::super::rekey::build_channel_rekey_event(
4460 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_lo]).unwrap();
4461 let blob_hi = super::super::rekey::build_rekey_blob(me.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_hi).unwrap();
4463 let ev_hi = super::super::rekey::build_channel_rekey_event(
4464 &Keys::generate(), &me, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_hi]).unwrap();
4465
4466 let relay = MemoryRelay::new();
4467 relay.inject(&ev_hi, &community.relays);
4468 relay.inject(&ev_lo, &community.relays);
4469
4470 crate::db::community::advance_server_root_epoch(&cid, 1, &[0x42u8; 32]).unwrap();
4472 crate::db::community::advance_channel_epoch(&cid, &chan_hex, 1, &key_hi).unwrap();
4474 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4475
4476 let _ = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4477 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(key_lo),
4478 "I authored the losing fork but must converge DOWN to the owner's lower key");
4479 }
4480
4481 #[tokio::test]
4482 async fn reorg_through_a_fork_heals_the_forked_past_epoch() {
4483 let (_tmp, _guard) = init_test_db();
4488 let owner = Keys::generate();
4489 let me = Keys::generate();
4490 become_local(&me);
4491 let community = saved_community_owned_by(&owner);
4492 let cid = community.id.to_hex();
4493 let channel_id = community.channels[0].id;
4494 let chan_hex = channel_id.to_hex();
4495 let scope = super::super::derive::RekeyScope::Channel(channel_id);
4496 let genesis_key = *community.channels[0].key.as_bytes();
4497 let root = *community.server_root_key.as_bytes();
4498 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
4499
4500 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();
4505 let blob_hi1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_hi1).unwrap();
4506 let ev_lo1 = super::super::rekey::build_channel_rekey_event(
4507 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_lo1]).unwrap();
4508 let ev_hi1 = super::super::rekey::build_channel_rekey_event(
4509 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_hi1]).unwrap();
4510 let commit1_win = super::super::rekey::epoch_key_commitment(crate::community::Epoch(1), &key_lo1);
4512 let blob_e2 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(2), &key_e2).unwrap();
4513 let ev_e2 = super::super::rekey::build_channel_rekey_event(
4514 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(2), crate::community::Epoch(1), &commit1_win, &[blob_e2]).unwrap();
4515
4516 let relay = MemoryRelay::new();
4517 relay.inject(&ev_lo1, &community.relays);
4518 relay.inject(&ev_hi1, &community.relays);
4519 relay.inject(&ev_e2, &community.relays);
4520
4521 crate::db::community::advance_server_root_epoch(&cid, 1, &[0x42u8; 32]).unwrap();
4523 crate::db::community::advance_channel_epoch(&cid, &chan_hex, 1, &key_hi1).unwrap();
4525 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4526
4527 let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4528 assert_eq!(reached, 2, "reorged forward to the head epoch");
4529 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 2).unwrap(), Some(key_e2), "head epoch adopted");
4530 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(key_lo1),
4531 "the FORKED past epoch re-converged to the lowest sibling (its messages become readable)");
4532 }
4533
4534 #[tokio::test]
4535 async fn window_heal_converges_an_already_reorged_past_fork() {
4536 let (_tmp, _guard) = init_test_db();
4541 let owner = Keys::generate();
4542 let me = Keys::generate();
4543 become_local(&me);
4544 let community = saved_community_owned_by(&owner);
4545 let cid = community.id.to_hex();
4546 let channel_id = community.channels[0].id;
4547 let chan_hex = channel_id.to_hex();
4548 let scope = super::super::derive::RekeyScope::Channel(channel_id);
4549 let genesis_key = *community.channels[0].key.as_bytes();
4550 let root = *community.server_root_key.as_bytes();
4551 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
4552
4553 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();
4557 let blob_hi1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_hi1).unwrap();
4558 let ev_lo1 = super::super::rekey::build_channel_rekey_event(
4559 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_lo1]).unwrap();
4560 let ev_hi1 = super::super::rekey::build_channel_rekey_event(
4561 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_hi1]).unwrap();
4562
4563 let relay = MemoryRelay::new();
4564 relay.inject(&ev_lo1, &community.relays);
4565 relay.inject(&ev_hi1, &community.relays);
4566 crate::db::community::advance_server_root_epoch(&cid, 1, &[0x42u8; 32]).unwrap();
4570 crate::db::community::advance_channel_epoch(&cid, &chan_hex, 1, &key_hi1).unwrap();
4572 crate::db::community::advance_channel_epoch(&cid, &chan_hex, 2, &key_e2).unwrap();
4573 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4574
4575 let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4576 assert_eq!(reached, 2, "head unchanged (no new rekey to apply)");
4577 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 2).unwrap(), Some(key_e2), "head epoch untouched");
4578 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(key_lo1),
4579 "the already-forked past epoch re-converged to the lowest sibling via the window heal (no in-sync reorg)");
4580 }
4581
4582 #[tokio::test]
4583 async fn channel_heal_cannot_converge_to_a_key_i_was_not_given() {
4584 let (_tmp, _guard) = init_test_db();
4591 let owner = Keys::generate();
4592 let me = Keys::generate();
4593 become_local(&me);
4594 let community = saved_community_owned_by(&owner);
4595 let cid = community.id.to_hex();
4596 let channel_id = community.channels[0].id;
4597 let chan_hex = channel_id.to_hex();
4598 let scope = super::super::derive::RekeyScope::Channel(channel_id);
4599 let genesis_key = *community.channels[0].key.as_bytes();
4600 let root = *community.server_root_key.as_bytes();
4601 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
4602
4603 let key_lo = [0x10u8; 32]; let key_hi = [0x99u8; 32]; let other = Keys::generate();
4607 let blob_lo = super::super::rekey::build_rekey_blob(owner.secret_key(), &other.public_key(), scope, crate::community::Epoch(1), &key_lo).unwrap();
4608 let ev_lo = super::super::rekey::build_channel_rekey_event(
4609 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_lo]).unwrap();
4610 let blob_hi = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_hi).unwrap();
4612 let ev_hi = super::super::rekey::build_channel_rekey_event(
4613 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_hi]).unwrap();
4614
4615 let relay = MemoryRelay::new();
4616 relay.inject(&ev_lo, &community.relays);
4617 relay.inject(&ev_hi, &community.relays);
4618 crate::db::community::advance_server_root_epoch(&cid, 1, &[0x42u8; 32]).unwrap();
4619 crate::db::community::advance_channel_epoch(&cid, &chan_hex, 1, &key_hi).unwrap();
4620 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4621
4622 let _ = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4623 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(key_hi),
4625 "excluded from the winning rekey ⇒ cannot converge");
4626 }
4627
4628 #[tokio::test]
4629 async fn refounding_channel_rekey_is_sealed_under_the_prior_root() {
4630 let (_tmp, _guard) = init_test_db();
4635 let owner = Keys::generate();
4636 become_local(&owner); let community = saved_community_owned_by(&owner);
4638 let channel_id = community.channels[0].id;
4639 let prior_root = [0x11u8; 32]; let relay = MemoryRelay::new();
4642 rotate_channel(&relay, &community, &channel_id, &[owner.public_key()], &prior_root).await.unwrap();
4643
4644 let z = super::super::derive::rekey_pseudonym(&crate::community::ServerRootKey(prior_root), &channel_id, crate::community::Epoch(1)).to_hex();
4646 let q = Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![z], ..Default::default() };
4647 let evs = relay.fetch(&q, &community.relays).await.unwrap();
4648 assert_eq!(evs.len(), 1, "channel rekey is addressed at the PRIOR-root pseudonym");
4649 assert!(super::super::rekey::open_rekey_event(&evs[0], &prior_root).is_ok(),
4651 "opens under the prior (shared) root every retained member still holds");
4652 assert!(super::super::rekey::open_rekey_event(&evs[0], community.server_root_key.as_bytes()).is_err(),
4653 "does NOT open under the current/new root (which a base-fork loser would have dropped)");
4654 }
4655
4656 #[tokio::test]
4657 async fn apply_channel_rekey_converges_past_a_divergent_prior_epoch() {
4658 let (_tmp, _guard) = init_test_db();
4663 let owner = Keys::generate();
4664 let me = Keys::generate();
4665 become_local(&me);
4666 let community = saved_community_owned_by(&owner);
4667 let cid = community.id.to_hex();
4668 let channel_id = community.channels[0].id;
4669 let chan_hex = channel_id.to_hex();
4670 let scope = super::super::derive::RekeyScope::Channel(channel_id);
4671 let root = *community.server_root_key.as_bytes();
4672
4673 let my_fork_key = [0xAAu8; 32];
4675 crate::db::community::advance_channel_epoch(&cid, &chan_hex, 1, &my_fork_key).unwrap();
4676
4677 let winner_epoch1 = [0xBBu8; 32];
4679 let new_key = [0x22u8; 32];
4680 let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(1), &winner_epoch1);
4681 let blob = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(2), &new_key).unwrap();
4682 let ev = super::super::rekey::build_channel_rekey_event(
4683 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(2), crate::community::Epoch(1), &commit, &[blob]).unwrap();
4684 let parsed = super::super::rekey::open_rekey_event(&ev, &root).unwrap();
4685
4686 let outcome = apply_channel_rekey(&community, &parsed).unwrap();
4687 assert!(matches!(outcome, RekeyOutcome::Applied { head_advanced: true }),
4688 "must converge forward past the divergent prior epoch, got {outcome:?}");
4689 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 2).unwrap(), Some(new_key),
4690 "adopted the winner's epoch-2 key");
4691 }
4692
4693 #[tokio::test]
4694 async fn catch_up_server_root_stops_when_removed_from_base() {
4695 let (_tmp, _guard) = init_test_db();
4698 let owner = Keys::generate();
4699 let me = Keys::generate();
4700 become_local(&me);
4701 let community = saved_community_owned_by(&owner);
4702 let scope = super::super::derive::RekeyScope::ServerRoot;
4703 let relay = MemoryRelay::new();
4704
4705 let root1 = [0x11u8; 32];
4707 let b1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &root1).unwrap();
4708 let e1 = super::super::rekey::build_server_root_rekey_event(
4709 &Keys::generate(), &owner, community.server_root_key.as_bytes(), &community.id,
4710 crate::community::Epoch(1), crate::community::Epoch(0),
4711 &super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), community.server_root_key.as_bytes()), &[b1],
4712 ).unwrap();
4713 let other = Keys::generate();
4715 let b2 = super::super::rekey::build_rekey_blob(owner.secret_key(), &other.public_key(), scope, crate::community::Epoch(2), &[0x22u8; 32]).unwrap();
4716 let e2 = super::super::rekey::build_server_root_rekey_event(
4717 &Keys::generate(), &owner, &root1, &community.id,
4718 crate::community::Epoch(2), crate::community::Epoch(1),
4719 &super::super::rekey::epoch_key_commitment(crate::community::Epoch(1), &root1), &[b2],
4720 ).unwrap();
4721 relay.inject(&e1, &community.relays);
4722 relay.inject(&e2, &community.relays);
4723
4724 let reached = catch_up_server_root(&relay, &community).await.unwrap();
4725 assert_eq!(reached.epoch, 1, "stops at the last base epoch I was a recipient of");
4726 assert!(reached.removed, "excluded by an AUTHORIZED (owner) base rotation → flagged removed so the caller erases");
4727 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4728 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(1));
4729 }
4730
4731 #[tokio::test]
4732 async fn catch_up_is_a_noop_with_no_rotations() {
4733 let (_tmp, _guard) = init_test_db();
4734 let owner = Keys::generate();
4735 let me = Keys::generate();
4736 become_local(&me);
4737 let community = saved_community_owned_by(&owner);
4738 let relay = MemoryRelay::new(); let reached = catch_up_channel_rekeys(&relay, &community, &community.channels[0].id).await.unwrap();
4740 assert_eq!(reached, 0, "no rotations → stays at the held epoch");
4741 }
4742
4743 #[tokio::test]
4744 async fn catch_up_stops_when_removed_midway() {
4745 let (_tmp, _guard) = init_test_db();
4748 let owner = Keys::generate();
4749 let me = Keys::generate();
4750 become_local(&me);
4751 let community = saved_community_owned_by(&owner);
4752 let channel_id = community.channels[0].id;
4753 let chan = &community.channels[0];
4754 let scope = super::super::derive::RekeyScope::Channel(channel_id);
4755 let relay = MemoryRelay::new();
4756
4757 let k1 = [0x11u8; 32];
4759 let b1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &k1).unwrap();
4760 let e1 = super::super::rekey::build_channel_rekey_event(
4761 &Keys::generate(), &owner, community.server_root_key.as_bytes(), &channel_id,
4762 crate::community::Epoch(1), crate::community::Epoch(0),
4763 &super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), chan.key.as_bytes()), &[b1],
4764 ).unwrap();
4765 let other = Keys::generate();
4767 let b2 = super::super::rekey::build_rekey_blob(owner.secret_key(), &other.public_key(), scope, crate::community::Epoch(2), &[0x22u8; 32]).unwrap();
4768 let e2 = super::super::rekey::build_channel_rekey_event(
4769 &Keys::generate(), &owner, community.server_root_key.as_bytes(), &channel_id,
4770 crate::community::Epoch(2), crate::community::Epoch(1),
4771 &super::super::rekey::epoch_key_commitment(crate::community::Epoch(1), &k1), &[b2],
4772 ).unwrap();
4773 relay.inject(&e1, &community.relays);
4774 relay.inject(&e2, &community.relays);
4775
4776 let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4777 assert_eq!(reached, 1, "stops at the last epoch I was a recipient of");
4778 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4779 assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(1));
4780 }
4781
4782 #[tokio::test]
4783 async fn rotate_channel_rejects_unauthorized() {
4784 let (_tmp, _guard) = init_test_db();
4785 let owner = Keys::generate();
4786 let rogue = Keys::generate();
4787 become_local(&rogue); let community = saved_community_owned_by(&owner);
4789 let relay = MemoryRelay::new();
4790 assert!(
4791 rotate_channel(&relay, &community, &community.channels[0].id, &[], community.server_root_key.as_bytes()).await.is_err(),
4792 "a non-authorized member cannot rotate"
4793 );
4794 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4796 assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(0));
4797 }
4798
4799 #[tokio::test]
4802 async fn rotate_server_root_publishes_recoverable_rekey_and_advances_base() {
4803 use crate::community::derive::{base_rekey_pseudonym, recipient_pseudonym};
4804 use crate::community::rekey::{open_rekey_blob, open_rekey_event, rekey_pairwise_secret};
4805 let (_tmp, _guard) = init_test_db();
4806 let owner = Keys::generate();
4807 become_local(&owner); let community = saved_community_owned_by(&owner);
4809 let genesis_root = *community.server_root_key.as_bytes();
4810 let member = Keys::generate();
4811 let relay = MemoryRelay::new();
4812
4813 let new_epoch = rotate_server_root(&relay, &community, &[member.public_key()]).await.expect("rotate base");
4814 assert_eq!(new_epoch, 1);
4815
4816 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4818 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(1));
4819 assert_ne!(reloaded.server_root_key.as_bytes(), &genesis_root, "base root is fresh-random, not the genesis");
4820
4821 let addr = base_rekey_pseudonym(&crate::community::ServerRootKey(genesis_root), &community.id, crate::community::Epoch(1)).to_hex();
4823 let found = relay
4824 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() }, &community.relays)
4825 .await
4826 .unwrap();
4827 assert_eq!(found.len(), 1, "base rekey addressable by its prior-root pseudonym");
4828 let parsed = open_rekey_event(&found[0], &genesis_root).unwrap();
4829 assert!(matches!(parsed.scope, crate::community::derive::RekeyScope::ServerRoot));
4830 assert_eq!(parsed.rotator, owner.public_key());
4831 assert_eq!(parsed.blobs.len(), 2, "member + me (multi-device)");
4832
4833 let secret = rekey_pairwise_secret(member.secret_key(), &parsed.rotator).unwrap();
4835 let loc = recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
4836 let mine = parsed.blobs.iter().find(|b| b.locator == loc).expect("member's blob present");
4837 let recovered = open_rekey_blob(member.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine).unwrap();
4838 assert_eq!(reloaded.server_root_key.as_bytes(), &recovered, "member's recovered root == owner's advanced base head");
4839 }
4840
4841 #[tokio::test]
4842 async fn rotate_server_root_failed_publish_leaves_base_unadvanced() {
4843 let (_tmp, _guard) = init_test_db();
4844 let owner = Keys::generate();
4845 become_local(&owner);
4846 let community = saved_community_owned_by(&owner);
4847 let member = Keys::generate();
4848 assert!(rotate_server_root(&FailingRelay, &community, &[member.public_key()]).await.is_err());
4849 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4850 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(0), "base head stays put on publish failure");
4851 }
4852
4853 #[tokio::test]
4854 async fn rotate_server_root_dedups_self_in_recipients() {
4855 use crate::community::rekey::open_rekey_event;
4857 let (_tmp, _guard) = init_test_db();
4858 let owner = Keys::generate();
4859 become_local(&owner);
4860 let community = saved_community_owned_by(&owner);
4861 let relay = MemoryRelay::new();
4862 rotate_server_root(&relay, &community, &[owner.public_key()]).await.unwrap();
4863 let addr = crate::community::derive::base_rekey_pseudonym(
4864 &crate::community::ServerRootKey(*community.server_root_key.as_bytes()), &community.id, crate::community::Epoch(1),
4865 )
4866 .to_hex();
4867 let found = relay
4868 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() }, &community.relays)
4869 .await
4870 .unwrap();
4871 let parsed = open_rekey_event(&found[0], community.server_root_key.as_bytes()).unwrap();
4872 assert_eq!(parsed.blobs.len(), 1, "self listed in recipients yields exactly one blob, not two");
4873 }
4874
4875 #[tokio::test]
4876 async fn rotate_server_root_rejects_unauthorized() {
4877 let (_tmp, _guard) = init_test_db();
4878 let owner = Keys::generate();
4879 let rogue = Keys::generate();
4880 become_local(&rogue); let community = saved_community_owned_by(&owner);
4882 let relay = MemoryRelay::new();
4883 assert!(rotate_server_root(&relay, &community, &[]).await.is_err(), "a non-BAN member cannot rotate the base");
4884 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4885 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(0));
4886 }
4887
4888 #[tokio::test]
4889 async fn rotate_server_root_reanchors_the_control_plane_to_the_new_epoch() {
4890 let (_tmp, _guard) = init_test_db();
4893 let relay = MemoryRelay::new();
4894 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
4897 let cid = community.id.to_hex();
4898 assert_eq!(crate::db::community::edition_head_entity_ids(&cid).unwrap().len(), 3);
4899
4900 let member = Keys::generate();
4901 assert_eq!(rotate_server_root(&relay, &community, &[member.public_key()]).await.unwrap(), 1);
4902 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4903 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(1), "base head advanced");
4904
4905 let z = crate::community::roster::control_pseudonym(&reloaded.server_root_key, &community.id, crate::community::Epoch(1));
4907 let evs = relay
4908 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], ..Default::default() }, &community.relays)
4909 .await
4910 .unwrap();
4911 let inners: Vec<_> = evs
4912 .iter()
4913 .filter_map(|o| crate::community::roster::open_control_edition(o, &reloaded.server_root_key).ok())
4914 .collect();
4915 let folded = crate::community::roster::fold_roster(&inners, &community.id, &Default::default());
4916 assert!(!folded.roles.roles.is_empty(), "control plane re-anchored at the new epoch as part of the rotation");
4917 }
4918
4919 #[tokio::test]
4920 async fn admin_refounding_carries_heads_verbatim_preserving_owner_and_peer_roles() {
4921 use crate::community::roles::Permissions;
4925 let (_tmp, _guard) = init_test_db();
4926 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
4927 let owner_hex = owner.public_key().to_hex();
4928 let relay = MemoryRelay::new();
4929 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
4930 let cid = community.id.to_hex();
4931 let admin_role = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
4932
4933 let alice = Keys::generate();
4935 let bob = Keys::generate();
4936 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role.clone()]).await.unwrap();
4937 set_member_grant(&relay, &community, &bob.public_key().to_hex(), vec![admin_role.clone()]).await.unwrap();
4938 let _ = fetch_and_apply_control(&relay, &community).await;
4939 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4940
4941 let mut edited = community.clone();
4944 edited.name = "HQ renamed".into();
4945 republish_community_metadata(&relay, &edited).await.unwrap();
4946 let _ = fetch_and_apply_control(&relay, &community).await;
4947 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4948 assert!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0 >= 2, "GroupRoot now above v1");
4949
4950 become_local(&alice);
4952 let new_epoch = rotate_server_root(&relay, &community, &[owner.public_key(), bob.public_key()]).await.unwrap();
4953 assert_eq!(new_epoch, 1);
4954 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4955 assert_eq!(community.server_root_epoch, crate::community::Epoch(1));
4956
4957 let z = crate::community::roster::control_pseudonym(&community.server_root_key, &community.id, crate::community::Epoch(1));
4959 let evs = relay.fetch(&Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], ..Default::default() }, &community.relays).await.unwrap();
4960 let inners: Vec<_> = evs.iter().filter_map(|o| crate::community::roster::open_control_edition(o, &community.server_root_key).ok()).collect();
4961 let folded = crate::community::roster::fold_roster(&inners, &community.id, &Default::default());
4962 let authed = crate::community::roster::authorize_delegation(&folded, Some(&owner_hex));
4963 assert!(authed.is_authorized(&alice.public_key().to_hex(), Some(&owner_hex), Permissions::BAN), "alice (re-founder) still admin");
4964 assert!(authed.is_authorized(&bob.public_key().to_hex(), Some(&owner_hex), Permissions::BAN), "bob (peer admin) NOT demoted by alice's re-founding");
4965 let new_owner = folded.root_meta.as_ref().and_then(|m| m.owner_attestation.as_ref())
4966 .and_then(|j| Event::from_json(j).ok()).map(|e| e.pubkey.to_hex());
4967 assert_eq!(new_owner.as_deref(), Some(owner_hex.as_str()), "owner deed carried verbatim — ownership intact after an admin re-founding");
4968 assert_eq!(folded.root_meta.as_ref().map(|m| m.name.as_str()), Some("HQ renamed"),
4969 "the >v1 GroupRoot head carried verbatim (content preserved across the re-founding)");
4970 let mut per_entity: std::collections::HashMap<[u8; 32], usize> = std::collections::HashMap::new();
4972 for i in &inners {
4973 if let Ok(p) = crate::community::edition::parse_edition_inner(i) { *per_entity.entry(p.entity_id).or_default() += 1; }
4974 }
4975 assert!(per_entity.values().all(|&c| c == 1), "one edition per entity at the new epoch (compacted)");
4976 }
4977
4978 #[tokio::test]
4982 async fn admin_write_blocked_when_isolated() {
4983 let (_tmp, _guard) = init_test_db();
4984 let me = Keys::generate();
4985 become_local(&me);
4986 let community = saved_community_owned_by(&me);
4987 let cid = community.id.to_hex();
4988 crate::db::community::set_edition_head_with_id(&cid, &cid, 1, &[1u8; 32], &[1u8; 32]).unwrap();
4990 crate::db::community::set_read_cut_target_epoch(&cid, 1).unwrap();
4991 let err = reseal_base_to_observed(&FailingRelay, &community).await.unwrap_err();
4993 assert!(err.contains("offline") || err.contains("can't reach any relay"),
4994 "isolated admin write must fail closed, got: {err}");
4995 assert_eq!(crate::db::community::load_community(&community.id).unwrap().unwrap().server_root_epoch,
4997 crate::community::Epoch(0), "no rotation while isolated");
4998 }
4999
5000 #[tokio::test]
5003 async fn refounding_rotates_channel_keys_too() {
5004 let (_tmp, _guard) = init_test_db();
5005 let relay = MemoryRelay::new();
5006 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5007 let channel_id = community.channels[0].id;
5008 assert_eq!(community.channels[0].epoch, crate::community::Epoch(0));
5009 assert_eq!(community.server_root_epoch, crate::community::Epoch(0));
5010
5011 run_read_cut(&relay, &community, true).await.unwrap();
5012
5013 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
5014 assert_eq!(after.server_root_epoch, crate::community::Epoch(1), "base rotated");
5015 let ch = after.channels.iter().find(|c| c.id == channel_id).unwrap();
5016 assert_eq!(ch.epoch, crate::community::Epoch(1), "channel key rotated too (O2)");
5017 assert_eq!(crate::db::community::channel_rekeyed_at_server_epoch(&community.id.to_hex(), &channel_id.to_hex()).unwrap(),
5018 1, "channel marked rekeyed for the new base epoch");
5019 assert!(!crate::db::community::get_read_cut_pending(&community.id.to_hex()).unwrap(),
5020 "a complete read-cut clears the pending flag");
5021 }
5022
5023 #[tokio::test]
5028 async fn read_cut_resumes_without_double_base_rotation_after_channel_failure() {
5029 struct ChannelRekeyFails {
5033 inner: MemoryRelay,
5034 rekeys: std::sync::atomic::AtomicUsize,
5035 fail_channel: std::sync::atomic::AtomicBool,
5036 }
5037 #[async_trait::async_trait]
5038 impl Transport for ChannelRekeyFails {
5039 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
5040 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> { self.inner.publish(e, r).await }
5041 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
5042 if e.kind.as_u16() == event_kind::COMMUNITY_REKEY {
5043 let n = self.rekeys.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5044 if n >= 1 && self.fail_channel.load(std::sync::atomic::Ordering::Relaxed) {
5045 return Err("channel rekey relay down".into());
5046 }
5047 }
5048 self.inner.publish_durable(e, r).await
5049 }
5050 async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> { self.inner.fetch(q, r).await }
5051 }
5052 let (_tmp, _guard) = init_test_db();
5053 let relay = ChannelRekeyFails {
5054 inner: MemoryRelay::new(),
5055 rekeys: std::sync::atomic::AtomicUsize::new(0),
5056 fail_channel: std::sync::atomic::AtomicBool::new(true),
5057 };
5058 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5059 let channel_id = community.channels[0].id;
5060 let cid = community.id.to_hex();
5061 let ch_hex = channel_id.to_hex();
5062
5063 assert!(run_read_cut(&relay, &community, true).await.is_err(), "the channel failure surfaces an error");
5065 let mid = crate::db::community::load_community(&community.id).unwrap().unwrap();
5066 assert_eq!(mid.server_root_epoch, crate::community::Epoch(1), "base advanced exactly once");
5067 assert_eq!(mid.channels.iter().find(|c| c.id == channel_id).unwrap().epoch, crate::community::Epoch(0),
5068 "channel NOT rotated (its rekey failed)");
5069 assert!(crate::db::community::get_read_cut_pending(&cid).unwrap(), "cut left pending after the failure");
5070 assert_eq!(crate::db::community::get_read_cut_target_epoch(&cid).unwrap(), 1, "target recorded durably");
5071 assert_eq!(crate::db::community::channel_rekeyed_at_server_epoch(&cid, &ch_hex).unwrap(), 0,
5072 "channel not yet marked for this cut");
5073
5074 relay.fail_channel.store(false, std::sync::atomic::Ordering::Relaxed);
5076 retry_pending_read_cut(&relay, &mid).await.unwrap();
5077 let done = crate::db::community::load_community(&community.id).unwrap().unwrap();
5078 assert_eq!(done.server_root_epoch, crate::community::Epoch(1),
5079 "base NOT rotated again — resumed at the same epoch (no double base rotation)");
5080 assert_eq!(done.channels.iter().find(|c| c.id == channel_id).unwrap().epoch, crate::community::Epoch(1),
5081 "the un-rotated channel finished on resume");
5082 assert_eq!(crate::db::community::channel_rekeyed_at_server_epoch(&cid, &ch_hex).unwrap(), 1,
5083 "channel marked rekeyed for the cut epoch");
5084 assert!(!crate::db::community::get_read_cut_pending(&cid).unwrap(), "pending cleared after the resume completes");
5085 }
5086
5087 #[tokio::test]
5088 async fn rotate_server_root_aborts_when_the_snapshot_does_not_land() {
5089 struct ControlPublishFails { inner: MemoryRelay, fail: std::sync::atomic::AtomicBool }
5094 #[async_trait::async_trait]
5095 impl Transport for ControlPublishFails {
5096 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
5097 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> { self.inner.publish(e, r).await }
5098 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
5099 if self.fail.load(std::sync::atomic::Ordering::Relaxed) && e.kind.as_u16() == event_kind::COMMUNITY_CONTROL {
5100 return Err("control relay down".into());
5101 }
5102 self.inner.publish_durable(e, r).await
5103 }
5104 async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> { self.inner.fetch(q, r).await }
5105 }
5106 let (_tmp, _guard) = init_test_db();
5107 let relay = ControlPublishFails { inner: MemoryRelay::new(), fail: std::sync::atomic::AtomicBool::new(false) };
5108 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5110 relay.fail.store(true, std::sync::atomic::Ordering::Relaxed);
5111
5112 assert!(
5113 rotate_server_root(&relay, &community, &[]).await.is_err(),
5114 "a snapshot whose editions can't be re-published must abort the rotation"
5115 );
5116 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5117 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(0), "base head NOT advanced when the snapshot doesn't land");
5118 }
5119
5120 #[tokio::test]
5121 async fn acquire_before_commit_a_reanchor_fetch_miss_publishes_no_base_rekey() {
5122 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
5127 struct ReanchorFetchEmpty { inner: MemoryRelay, drop_control: AtomicBool, base_rekeys: AtomicUsize }
5128 #[async_trait::async_trait]
5129 impl Transport for ReanchorFetchEmpty {
5130 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
5131 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> { self.inner.publish(e, r).await }
5132 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
5133 if e.kind.as_u16() == event_kind::COMMUNITY_REKEY {
5134 self.base_rekeys.fetch_add(1, Ordering::Relaxed);
5135 }
5136 self.inner.publish_durable(e, r).await
5137 }
5138 async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> {
5139 if self.drop_control.load(Ordering::Relaxed) && q.kinds.iter().any(|k| *k == event_kind::COMMUNITY_CONTROL) {
5140 return Ok(vec![]); }
5142 self.inner.fetch(q, r).await
5143 }
5144 }
5145 let (_tmp, _guard) = init_test_db();
5146 let relay = ReanchorFetchEmpty { inner: MemoryRelay::new(), drop_control: AtomicBool::new(false), base_rekeys: AtomicUsize::new(0) };
5147 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5148 relay.drop_control.store(true, Ordering::Relaxed);
5149
5150 assert!(rotate_server_root(&relay, &community, &[]).await.is_err(),
5151 "a re-anchor fetch miss must abort the rotation");
5152 assert_eq!(relay.base_rekeys.load(Ordering::Relaxed), 0,
5153 "the base rekey must NOT be published when the pre-publish fetch gate trips (acquire-before-commit)");
5154 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5155 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(0), "base head NOT advanced");
5156 }
5157
5158 #[tokio::test]
5161 async fn reanchor_carries_role_and_grant_to_the_new_epoch_under_the_new_root() {
5162 let (_tmp, _guard) = init_test_db();
5163 let relay = MemoryRelay::new();
5164 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5166 let cid = community.id.to_hex();
5167 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5168 let member = Keys::generate();
5169 set_member_grant(&relay, &community, &member.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
5171 let _ = fetch_and_apply_control(&relay, &community).await;
5172 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5173
5174 let new_root = [0x99u8; 32];
5176 let snap = reanchor_control_plane(&relay, &community, &new_root, crate::community::Epoch(1)).await.unwrap();
5177 assert!(snap.iter().all(|e| e.published), "every snapshot edition published");
5178 assert_eq!(snap.len(), 4, "GroupRoot + channel + Admin role + grant compacted to v1");
5179
5180 let new_z = crate::community::roster::control_pseudonym(
5182 &crate::community::ServerRootKey(new_root), &community.id, crate::community::Epoch(1),
5183 );
5184 let after = relay
5185 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![new_z], ..Default::default() }, &community.relays)
5186 .await
5187 .unwrap();
5188 let inners: Vec<_> = after
5189 .iter()
5190 .filter_map(|o| crate::community::roster::open_control_edition(o, &crate::community::ServerRootKey(new_root)).ok())
5191 .collect();
5192 let folded = crate::community::roster::fold_roster(&inners, &community.id, &Default::default());
5193 assert!(!folded.roles.roles.is_empty(), "Admin role reachable at the new epoch");
5194 assert!(
5195 folded.roles.grants.iter().any(|g| g.member == member.public_key().to_hex()),
5196 "grant carried to the new epoch under the new root"
5197 );
5198 }
5199
5200 #[tokio::test]
5201 async fn grant_after_a_rekey_survives_the_fold_at_the_new_epoch() {
5202 use crate::community::roles::Permissions;
5208 let (_tmp, _guard) = init_test_db();
5209 let relay = MemoryRelay::new();
5210 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5211 let cid = community.id.to_hex();
5212 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
5213 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5214
5215 rotate_server_root(&relay, &community, &[owner.public_key()]).await.expect("rotate base");
5217 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5218 assert_eq!(community.server_root_epoch, crate::community::Epoch(1), "advanced to the new epoch");
5219
5220 let alice = "aa".repeat(32);
5222 set_member_grant(&relay, &community, &alice, vec![admin_role_id]).await.unwrap();
5223
5224 let roster = fetch_and_apply_roles(&relay, &community).await.unwrap();
5226 assert!(
5227 roster.has_permission(&alice, Permissions::BAN),
5228 "post-rekey grant survives — Alice is Admin at the new epoch (pre-fix: dropped, role unreachable)"
5229 );
5230 assert_eq!(roster.highest_position(&alice), Some(1));
5231 }
5232
5233 #[tokio::test]
5237 async fn demote_re_asserts_the_demoted_members_metadata_head() {
5238 let (_tmp, _guard) = init_test_db();
5239 let relay = MemoryRelay::new();
5240 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5241 let cid = community.id.to_hex();
5242 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
5243 let admin_role = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5244 let alice = Keys::generate();
5245 let alice_hex = alice.public_key().to_hex();
5246
5247 set_member_grant(&relay, &community, &alice_hex, vec![admin_role]).await.unwrap();
5248 become_local(&alice);
5250 let mut as_alice = crate::db::community::load_community(&community.id).unwrap().unwrap();
5251 as_alice.name = "Alice's HQ".into();
5252 republish_community_metadata(&relay, &as_alice).await.unwrap();
5253 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5254 assert_eq!(
5255 fetch_control_folded(&relay, &community).await.unwrap().root_author.map(|a| a.to_hex()),
5256 Some(alice_hex.clone()), "alice heads the GroupRoot after her edit",
5257 );
5258
5259 become_local(&owner);
5261 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5262 set_member_grant(&relay, &community, &alice_hex, vec![]).await.unwrap();
5263
5264 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5265 let folded = fetch_control_folded(&relay, &community).await.unwrap();
5266 assert_eq!(folded.root_author.map(|a| a.to_hex()), Some(owner.public_key().to_hex()),
5267 "the demote re-asserted the GroupRoot under the owner");
5268 assert_eq!(folded.root_meta.as_ref().unwrap().name, "Alice's HQ",
5269 "the re-assert preserves the demoted member's content");
5270 }
5271
5272 #[tokio::test]
5275 async fn demote_skips_reassert_when_member_does_not_head() {
5276 let (_tmp, _guard) = init_test_db();
5277 let relay = MemoryRelay::new();
5278 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5279 let cid = community.id.to_hex();
5280 let admin_role = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5281 let alice = Keys::generate();
5282 let alice_hex = alice.public_key().to_hex();
5283
5284 set_member_grant(&relay, &community, &alice_hex, vec![admin_role]).await.unwrap();
5285 let mut c = crate::db::community::load_community(&community.id).unwrap().unwrap();
5287 c.name = "Owner's HQ".into();
5288 republish_community_metadata(&relay, &c).await.unwrap();
5289 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5290 let before = fetch_control_folded(&relay, &community).await.unwrap().root_head.unwrap().version;
5291
5292 set_member_grant(&relay, &community, &alice_hex, vec![]).await.unwrap();
5293 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5294 let after = fetch_control_folded(&relay, &community).await.unwrap().root_head.unwrap().version;
5295 assert_eq!(after, before, "no re-assert published — the demoted member didn't head the GroupRoot");
5296 }
5297
5298 #[tokio::test]
5299 async fn reanchor_carries_the_banlist_edition_to_the_new_epoch() {
5300 let (_tmp, _guard) = init_test_db();
5303 let relay = MemoryRelay::new();
5304 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5305 let carol = "cc".repeat(32);
5306 publish_banlist(&relay, &community, &[carol.clone()]).await.unwrap();
5308 let _ = fetch_and_apply_control(&relay, &community).await;
5309 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5310
5311 let new_root = [0x99u8; 32];
5313 let n = reanchor_control_plane(&relay, &community, &new_root, crate::community::Epoch(1)).await.unwrap();
5314 assert!(n.iter().all(|e| e.published), "every snapshot edition published");
5315 assert_eq!(n.len(), 4, "GroupRoot + channel + Admin role + banlist compacted to v1");
5316
5317 let new_z = crate::community::roster::control_pseudonym(
5319 &crate::community::ServerRootKey(new_root), &community.id, crate::community::Epoch(1),
5320 );
5321 let after = relay
5322 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![new_z], ..Default::default() }, &community.relays)
5323 .await
5324 .unwrap();
5325 let inners: Vec<_> = after
5326 .iter()
5327 .filter_map(|o| crate::community::roster::open_control_edition(o, &crate::community::ServerRootKey(new_root)).ok())
5328 .collect();
5329 let folded = crate::community::roster::fold_roster(&inners, &community.id, &Default::default());
5330 assert_eq!(folded.banned, vec![carol], "banlist reachable at the new epoch under the new root");
5331 }
5332
5333 fn owner_base_rekey(
5338 owner: &Keys, community: &Community, recipient_pk: &nostr_sdk::prelude::PublicKey, new_epoch: u64, new_root: &[u8; 32],
5339 ) -> super::super::rekey::ParsedRekey {
5340 let prev = community.server_root_epoch.0;
5341 let blob = super::super::rekey::build_rekey_blob(
5342 owner.secret_key(), recipient_pk, super::super::derive::RekeyScope::ServerRoot, crate::community::Epoch(new_epoch), new_root,
5343 )
5344 .unwrap();
5345 let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(prev), community.server_root_key.as_bytes());
5346 let outer = super::super::rekey::build_server_root_rekey_event(
5347 &Keys::generate(), owner, community.server_root_key.as_bytes(), &community.id,
5348 crate::community::Epoch(new_epoch), crate::community::Epoch(prev), &commit, &[blob],
5349 )
5350 .unwrap();
5351 super::super::rekey::open_rekey_event(&outer, community.server_root_key.as_bytes()).unwrap()
5352 }
5353
5354 #[test]
5355 fn apply_server_root_rekey_recovers_new_root_and_advances_base() {
5356 let (_tmp, _guard) = init_test_db();
5357 let owner = Keys::generate();
5358 let me = Keys::generate();
5359 become_local(&me);
5360 let community = saved_community_owned_by(&owner);
5361 let cid = community.id.to_hex();
5362 let new_root = [0xCDu8; 32];
5363
5364 let parsed = owner_base_rekey(&owner, &community, &me.public_key(), 1, &new_root);
5365 assert_eq!(apply_server_root_rekey(&community, &parsed).unwrap(), RekeyOutcome::Applied { head_advanced: true });
5366
5367 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5368 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(1));
5369 assert_eq!(reloaded.server_root_key.as_bytes(), &new_root, "base head advanced to the new root");
5370 assert!(crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 0).unwrap().is_some());
5372 assert_eq!(crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap(), Some(new_root));
5373 }
5374
5375 #[test]
5376 fn apply_server_root_rekey_not_a_recipient_leaves_base_unchanged() {
5377 let (_tmp, _guard) = init_test_db();
5378 let owner = Keys::generate();
5379 let me = Keys::generate();
5380 become_local(&me);
5381 let community = saved_community_owned_by(&owner);
5382 let other = Keys::generate(); let parsed = owner_base_rekey(&owner, &community, &other.public_key(), 1, &[0x11u8; 32]);
5384 assert_eq!(apply_server_root_rekey(&community, &parsed).unwrap(), RekeyOutcome::NotARecipient);
5385 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5386 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(0), "removed-from-base member's head unchanged");
5387 }
5388
5389 #[test]
5390 fn apply_server_root_rekey_rejects_rotator_without_ban() {
5391 let (_tmp, _guard) = init_test_db();
5392 let owner = Keys::generate();
5393 let me = Keys::generate();
5394 become_local(&me);
5395 let community = saved_community_owned_by(&owner);
5396 let rogue = Keys::generate();
5398 let parsed = owner_base_rekey(&rogue, &community, &me.public_key(), 1, &[0x22u8; 32]);
5399 assert!(apply_server_root_rekey(&community, &parsed).is_err(), "unauthorized base rotation rejected");
5400 }
5401
5402 #[test]
5403 fn apply_server_root_rekey_reorgs_onto_authorized_chain_despite_prior_mismatch() {
5404 let (_tmp, _guard) = init_test_db();
5409 let owner = Keys::generate();
5410 let me = Keys::generate();
5411 become_local(&me);
5412 let community = saved_community_owned_by(&owner);
5413 let blob = super::super::rekey::build_rekey_blob(
5414 owner.secret_key(), &me.public_key(), super::super::derive::RekeyScope::ServerRoot, crate::community::Epoch(1), &[0x33u8; 32],
5415 )
5416 .unwrap();
5417 let bad = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &[0xFFu8; 32]);
5419 let outer = super::super::rekey::build_server_root_rekey_event(
5420 &Keys::generate(), &owner, community.server_root_key.as_bytes(), &community.id,
5421 crate::community::Epoch(1), crate::community::Epoch(0), &bad, &[blob],
5422 )
5423 .unwrap();
5424 let parsed = super::super::rekey::open_rekey_event(&outer, community.server_root_key.as_bytes()).unwrap();
5425 let outcome = apply_server_root_rekey(&community, &parsed);
5426 assert!(
5427 matches!(outcome, Ok(RekeyOutcome::Applied { .. })),
5428 "an authorized base chain must be adopted (reorg), not rejected as foreign; got {outcome:?}"
5429 );
5430 }
5431
5432 #[test]
5433 fn apply_server_root_rekey_catchup_archives_without_regressing_base_head() {
5434 let (_tmp, _guard) = init_test_db();
5437 let owner = Keys::generate();
5438 let me = Keys::generate();
5439 become_local(&me);
5440 let community = saved_community_owned_by(&owner);
5441 let cid = community.id.to_hex();
5442
5443 let r5 = [0x55u8; 32];
5444 let p5 = owner_base_rekey(&owner, &community, &me.public_key(), 5, &r5);
5445 assert_eq!(apply_server_root_rekey(&community, &p5).unwrap(), RekeyOutcome::Applied { head_advanced: true });
5446 let r3 = [0x33u8; 32];
5447 let p3 = owner_base_rekey(&owner, &community, &me.public_key(), 3, &r3);
5448 assert_eq!(apply_server_root_rekey(&community, &p3).unwrap(), RekeyOutcome::Applied { head_advanced: false });
5449
5450 assert_eq!(crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 3).unwrap(), Some(r3));
5451 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5452 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(5), "base head stayed at newest");
5453 assert_eq!(reloaded.server_root_key.as_bytes(), &r5);
5454 }
5455
5456 #[test]
5457 fn apply_server_root_rekey_authorizes_a_granted_ban_admin() {
5458 let (_tmp, _guard) = init_test_db();
5462 let owner = Keys::generate();
5463 let me = Keys::generate();
5464 become_local(&me);
5465 let community = saved_community_owned_by(&owner);
5466 let cid = community.id.to_hex();
5467
5468 let admin = Keys::generate();
5469 let role_id = "d".repeat(64);
5470 let roster = crate::community::roles::CommunityRoles {
5471 roles: vec![crate::community::roles::Role::admin(role_id.clone())],
5472 grants: vec![crate::community::roles::MemberGrant { member: admin.public_key().to_hex(), role_ids: vec![role_id] }],
5473 };
5474 crate::db::community::set_community_roles(&cid, &roster, 1).unwrap();
5475
5476 let parsed = owner_base_rekey(&admin, &community, &me.public_key(), 1, &[0x77u8; 32]);
5477 assert_eq!(
5478 apply_server_root_rekey(&community, &parsed).unwrap(),
5479 RekeyOutcome::Applied { head_advanced: true },
5480 "a BAN-granted admin (not the owner) can rotate the base"
5481 );
5482 }
5483
5484 #[test]
5485 fn apply_server_root_rekey_accepts_when_prior_root_not_held() {
5486 let (_tmp, _guard) = init_test_db();
5489 let owner = Keys::generate();
5490 let me = Keys::generate();
5491 become_local(&me);
5492 let community = saved_community_owned_by(&owner);
5493
5494 let new_root = [0x99u8; 32];
5495 let blob = super::super::rekey::build_rekey_blob(
5496 owner.secret_key(), &me.public_key(), super::super::derive::RekeyScope::ServerRoot, crate::community::Epoch(5), &new_root,
5497 )
5498 .unwrap();
5499 let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(4), &[0xEEu8; 32]);
5501 let outer = super::super::rekey::build_server_root_rekey_event(
5502 &Keys::generate(), &owner, community.server_root_key.as_bytes(), &community.id,
5503 crate::community::Epoch(5), crate::community::Epoch(4), &commit, &[blob],
5504 )
5505 .unwrap();
5506 let parsed = super::super::rekey::open_rekey_event(&outer, community.server_root_key.as_bytes()).unwrap();
5507 assert_eq!(apply_server_root_rekey(&community, &parsed).unwrap(), RekeyOutcome::Applied { head_advanced: true });
5508 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5509 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(5));
5510 }
5511
5512 #[test]
5513 fn apply_server_root_rekey_rejects_channel_scope() {
5514 let (_tmp, _guard) = init_test_db();
5516 let owner = Keys::generate();
5517 let me = Keys::generate();
5518 become_local(&me);
5519 let community = saved_community_owned_by(&owner);
5520 let channel_parsed = owner_channel_rekey(&owner, &community, &me.public_key(), 1, &[0x44u8; 32]);
5521 assert!(apply_server_root_rekey(&community, &channel_parsed).is_err(), "channel scope rejected by base apply");
5522 }
5523
5524 #[test]
5525 fn apply_channel_rekey_not_a_recipient() {
5526 let (_tmp, _guard) = init_test_db();
5527 let owner = Keys::generate();
5528 let me = Keys::generate();
5529 become_local(&me);
5530 let community = saved_community_owned_by(&owner);
5531 let other = Keys::generate();
5533 let parsed = owner_channel_rekey(&owner, &community, &other.public_key(), 1, &[0x11u8; 32]);
5534 assert_eq!(apply_channel_rekey(&community, &parsed).unwrap(), RekeyOutcome::NotARecipient);
5535 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5537 assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(0));
5538 }
5539
5540 #[test]
5541 fn apply_channel_rekey_rejects_unauthorized_rotator() {
5542 let (_tmp, _guard) = init_test_db();
5543 let owner = Keys::generate();
5544 let me = Keys::generate();
5545 become_local(&me);
5546 let community = saved_community_owned_by(&owner);
5547 let rogue = Keys::generate();
5549 let parsed = owner_channel_rekey(&rogue, &community, &me.public_key(), 1, &[0x22u8; 32]);
5550 assert!(apply_channel_rekey(&community, &parsed).is_err(), "unauthorized rotation must be rejected");
5551 }
5552
5553 #[test]
5554 fn apply_channel_rekey_reorgs_onto_authorized_chain_despite_prior_mismatch() {
5555 let (_tmp, _guard) = init_test_db();
5562 let owner = Keys::generate();
5563 let me = Keys::generate();
5564 become_local(&me);
5565 let community = saved_community_owned_by(&owner);
5566 let chan = &community.channels[0];
5567 let scope = super::super::derive::RekeyScope::Channel(chan.id);
5568 let new_key = [0x33u8; 32];
5569 let blob = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &new_key).unwrap();
5570 let other_commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &[0xFFu8; 32]);
5572 let outer = super::super::rekey::build_channel_rekey_event(
5573 &Keys::generate(), &owner, community.server_root_key.as_bytes(), &chan.id,
5574 crate::community::Epoch(1), crate::community::Epoch(0), &other_commit, &[blob],
5575 )
5576 .unwrap();
5577 let parsed = super::super::rekey::open_rekey_event(&outer, community.server_root_key.as_bytes()).unwrap();
5578 let outcome = apply_channel_rekey(&community, &parsed).unwrap();
5579 assert!(matches!(outcome, RekeyOutcome::Applied { .. }),
5580 "an authorized chain must be adopted (reorg), not rejected as foreign; got {outcome:?}");
5581 assert_eq!(crate::db::community::held_epoch_key(&community.id.to_hex(), &chan.id.to_hex(), 1).unwrap(), Some(new_key));
5582 }
5583
5584 #[test]
5585 fn apply_channel_rekey_catchup_archives_without_regressing_head() {
5586 let (_tmp, _guard) = init_test_db();
5587 let owner = Keys::generate();
5588 let me = Keys::generate();
5589 become_local(&me);
5590 let community = saved_community_owned_by(&owner);
5591 let cid = community.id.to_hex();
5592 let chan_hex = community.channels[0].id.to_hex();
5593
5594 let k5 = [0x55u8; 32];
5596 let p5 = owner_channel_rekey(&owner, &community, &me.public_key(), 5, &k5);
5597 assert_eq!(apply_channel_rekey(&community, &p5).unwrap(), RekeyOutcome::Applied { head_advanced: true });
5598 let k3 = [0x33u8; 32];
5600 let p3 = owner_channel_rekey(&owner, &community, &me.public_key(), 3, &k3);
5601 assert_eq!(apply_channel_rekey(&community, &p3).unwrap(), RekeyOutcome::Applied { head_advanced: false });
5602
5603 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 3).unwrap(), Some(k3), "old epoch archived");
5604 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 5).unwrap(), Some(k5));
5605 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5606 assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(5), "head stayed at the newest epoch");
5607 assert_eq!(reloaded.channels[0].key.as_bytes(), &k5);
5608 }
5609
5610 #[tokio::test]
5611 async fn create_community_persists_and_publishes_metadata() {
5612 use crate::community::transport::Query;
5613 use crate::stored_event::event_kind;
5614
5615 let (_tmp, _guard) = init_test_db();
5616 let relay = MemoryRelay::new();
5617 let community = create_community(&relay, "Vector HQ", "general", vec!["r1".into()])
5618 .await
5619 .expect("create");
5620
5621 assert_eq!(community.name, "Vector HQ");
5623 assert_eq!(community.channels.len(), 1);
5624 assert_eq!(community.channels[0].name, "general");
5625
5626 let loaded = crate::db::community::load_community(&community.id).unwrap().expect("persisted");
5628 assert_eq!(loaded.channels[0].name, "general");
5629 assert_eq!(loaded.server_root_key.as_bytes(), community.server_root_key.as_bytes());
5630
5631 let meta_events = relay
5634 .fetch(
5635 &Query { kinds: vec![event_kind::APPLICATION_SPECIFIC], ..Default::default() },
5636 &community.relays,
5637 )
5638 .await
5639 .unwrap();
5640 assert!(meta_events.is_empty(), "no legacy 30078 metadata events");
5641
5642 let z = crate::community::roster::control_pseudonym(&community.server_root_key, &community.id, crate::community::Epoch(0));
5645 let control = relay
5646 .fetch(
5647 &Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], ..Default::default() },
5648 &community.relays,
5649 )
5650 .await
5651 .unwrap();
5652 assert_eq!(control.len(), 3, "GroupRoot + ChannelMetadata + Admin role editions");
5653 let owner_pk = crate::state::my_public_key().unwrap();
5654 let parsed: Vec<_> = control
5655 .iter()
5656 .filter_map(|o| crate::community::roster::open_control_edition(o, &community.server_root_key).ok())
5657 .filter_map(|i| crate::community::edition::parse_edition_inner(&i).ok())
5658 .collect();
5659 assert!(parsed.iter().all(|p| p.author == owner_pk), "every genesis edition authored by the owner");
5660 let root = parsed.iter().find(|p| p.entity_id == community.id.0).expect("GroupRoot edition");
5662 let root_meta: crate::community::metadata::CommunityMetadata = serde_json::from_str(&root.content).unwrap();
5663 assert_eq!(root_meta.name, "Vector HQ");
5664 assert!(root_meta.owner_attestation.is_some());
5665 let role: crate::community::roles::Role = parsed
5667 .iter()
5668 .find_map(|p| serde_json::from_str::<crate::community::roles::Role>(&p.content).ok().filter(|r| r.name == "Admin"))
5669 .expect("Admin role edition");
5670 assert_eq!(role.position, 1);
5671 assert!(role.permissions.contains(crate::community::roles::Permissions::ADMIN_ALL));
5672
5673 let cached = crate::db::community::get_community_roles(&community.id.to_hex()).unwrap();
5675 assert_eq!(cached.roles.len(), 1);
5676 assert!(cached.grants.is_empty(), "owner is implicit position 0, takes no grant");
5677 }
5678
5679 #[tokio::test]
5680 async fn role_grant_round_trips_through_relays_and_revokes() {
5681 use crate::community::roles::Permissions;
5682 let (_tmp, _guard) = init_test_db();
5683 let relay = MemoryRelay::new();
5684 let community = create_community(&relay, "HQ", "general", vec!["r1".into()])
5685 .await
5686 .expect("create");
5687 let cid = community.id.to_hex();
5688 let alice = "aa".repeat(32);
5689 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0]
5690 .role_id
5691 .clone();
5692
5693 set_member_grant(&relay, &community, &alice, vec![admin_role_id.clone()])
5695 .await
5696 .unwrap();
5697 assert!(
5698 crate::db::community::get_community_roles(&cid).unwrap().is_privileged(&alice),
5699 "local cache reflects the grant immediately"
5700 );
5701
5702 let roster = fetch_and_apply_roles(&relay, &community).await.unwrap();
5705 assert!(roster.has_permission(&alice, Permissions::BAN));
5706 assert!(roster.has_permission(&alice, Permissions::MANAGE_ROLES));
5707 assert_eq!(roster.roles.len(), 1);
5708 assert_eq!(roster.highest_position(&alice), Some(1));
5709
5710 set_member_grant(&relay, &community, &alice, vec![]).await.unwrap();
5712 let after = crate::db::community::get_community_roles(&cid).unwrap();
5713 assert!(!after.is_privileged(&alice), "revoked member holds no role");
5714 assert!(after.grants.is_empty(), "empty grant pruned");
5715 }
5716
5717 #[tokio::test]
5718 async fn admin_cannot_grant_a_peer_rank_role() {
5719 let (_tmp, _guard) = init_test_db();
5723 let relay = MemoryRelay::new();
5724 let community = create_community(&relay, "HQ", "general", vec!["r1".into()])
5725 .await
5726 .expect("create");
5727 let cid = community.id.to_hex();
5728 let admin_role_id =
5729 crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5730 let alice = Keys::generate();
5731 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id.clone()])
5733 .await
5734 .unwrap();
5735
5736 crate::state::set_my_public_key(alice.public_key());
5738 let bob = Keys::generate().public_key();
5739 let err = grant_role(&relay, &community, bob, &admin_role_id).await.unwrap_err();
5740 assert!(err.contains("below your own"), "peer-rank grant refused, got: {err}");
5741 }
5742
5743 #[tokio::test]
5744 async fn create_community_mints_a_verifiable_owner_attestation() {
5745 let (_tmp, _guard) = init_test_db();
5748 let me = crate::state::my_public_key().unwrap();
5749 let relay = MemoryRelay::new();
5750 let community = create_community(&relay, "HQ", "general", vec!["r1".into()])
5751 .await
5752 .expect("create");
5753 let att = community.owner_attestation.as_ref().expect("attestation is mandatory");
5754 let proven = super::super::owner::verify_owner_attestation(att, &community.id.to_hex());
5755 assert_eq!(proven, Some(me), "the creator is the proven owner");
5756 assert_eq!(
5758 super::super::owner::verify_owner_attestation(att, &"f".repeat(64)),
5759 None,
5760 );
5761 }
5762
5763 #[tokio::test]
5764 async fn admin_cannot_ban_a_peer_admin() {
5765 let (_tmp, _guard) = init_test_db();
5769 let relay = MemoryRelay::new();
5770 let community = create_community(&relay, "HQ", "general", vec!["r1".into()])
5771 .await
5772 .expect("create");
5773 let cid = community.id.to_hex();
5774 let admin_role_id =
5775 crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5776 let alice = Keys::generate();
5777 let bob = Keys::generate();
5778 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id.clone()])
5780 .await
5781 .unwrap();
5782 set_member_grant(&relay, &community, &bob.public_key().to_hex(), vec![admin_role_id.clone()])
5783 .await
5784 .unwrap();
5785
5786 become_local(&alice);
5789 let err = publish_banlist(&relay, &community, &[bob.public_key().to_hex()])
5790 .await
5791 .unwrap_err();
5792 assert!(err.contains("outranks you"), "peer-admin ban refused, got: {err}");
5793 }
5794
5795 #[tokio::test]
5796 async fn roster_reconstructs_purely_from_relay() {
5797 let (_tmp, _guard) = init_test_db();
5801 let relay = MemoryRelay::new();
5802 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5803 let cid = community.id.to_hex();
5804 let admin_role_id =
5805 crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5806 let alice = "aa".repeat(32);
5807 set_member_grant(&relay, &community, &alice, vec![admin_role_id.clone()]).await.unwrap();
5808
5809 crate::db::community::set_community_roles(&cid, &crate::community::roles::CommunityRoles::default(), 0).unwrap();
5811 assert!(crate::db::community::get_community_roles(&cid).unwrap().roles.is_empty(), "cache wiped");
5812
5813 let roster = fetch_and_apply_roles(&relay, &community).await.unwrap();
5814 assert!(roster.is_admin(&alice), "roster reconstructed from relay editions, not the cache");
5815 assert_eq!(roster.roles.len(), 1, "the Admin role edition folded back");
5816 }
5817
5818 #[tokio::test]
5819 async fn admin_cannot_unban_a_peer_admin() {
5820 let (_tmp, _guard) = init_test_db();
5823 let relay = MemoryRelay::new();
5824 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5825 let cid = community.id.to_hex();
5826 let admin_role_id =
5827 crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5828 let alice = Keys::generate();
5829 let bob = Keys::generate();
5830 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id.clone()])
5831 .await
5832 .unwrap();
5833 set_member_grant(&relay, &community, &bob.public_key().to_hex(), vec![admin_role_id.clone()])
5834 .await
5835 .unwrap();
5836 crate::db::community::set_community_banlist(&cid, &[bob.public_key().to_hex()], 1000).unwrap();
5838
5839 become_local(&alice);
5841 let err = publish_banlist(&relay, &community, &[]).await.unwrap_err();
5842 assert!(err.contains("unban"), "unbanning a peer admin refused, got: {err}");
5843 }
5844
5845 #[tokio::test]
5846 async fn create_community_rejects_signer_identity_mismatch() {
5847 let (_tmp, _guard) = init_test_db(); let other = Keys::generate();
5852 crate::state::set_my_public_key(other.public_key()); let relay = MemoryRelay::new();
5854 let err = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap_err();
5855 assert!(err.contains("identity signer"), "signer mismatch refused, got: {err}");
5856 }
5857
5858 #[tokio::test]
5859 async fn banlist_newer_edition_applies_older_is_refused() {
5860 let (_tmp, _guard) = init_test_db();
5861 let relay = MemoryRelay::new();
5862 let community = create_community(&relay, "HQ", "general", vec!["r1".into()])
5863 .await
5864 .expect("create");
5865 let id_hex = community.id.to_hex();
5866 let banlist_entity = crate::simd::hex::bytes_to_hex_32(&crate::community::derive::banlist_locator(&community.id));
5867 let mallory = "aa".repeat(32);
5868 let bob = "bb".repeat(32);
5869
5870 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
5873 let inner = crate::community::roster::build_banlist_edition(&owner, &community.id, &[mallory.clone()], 1, None, 1000, None).unwrap();
5874 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
5875 relay.inject(&outer, &community.relays);
5876
5877 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
5879 assert_eq!(applied, vec![mallory.clone()]);
5880 let (head_v, _) = crate::db::community::get_edition_head(&id_hex, &banlist_entity).unwrap().unwrap();
5881 assert_eq!(head_v, 1, "banlist edition head advanced to v1");
5882
5883 crate::db::community::set_community_banlist(&id_hex, &[mallory.clone(), bob.clone()], 2).unwrap();
5886 crate::db::community::set_edition_head(&id_hex, &banlist_entity, 2, &[0x22u8; 32]).unwrap();
5887 let after = fetch_and_apply_banlist(&relay, &community).await.unwrap();
5888 assert_eq!(after, vec![mallory, bob], "older relay edition refused, local banlist preserved");
5889 }
5890
5891 #[tokio::test]
5892 async fn unauthorized_banlist_edition_is_rejected() {
5893 let (_tmp, _guard) = init_test_db();
5897 let relay = MemoryRelay::new();
5898 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5899 let bob = "bb".repeat(32);
5900
5901 let mallory = Keys::generate();
5903 let inner = crate::community::roster::build_banlist_edition(&mallory, &community.id, &[bob], 1, None, 1000, None).unwrap();
5904 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
5905 relay.inject(&outer, &community.relays);
5906
5907 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
5909 assert!(applied.is_empty(), "an unauthorized signer's banlist edition is rejected");
5910 }
5911
5912 #[tokio::test]
5913 async fn banlist_receiver_enforces_per_target_outrank() {
5914 let (_tmp, _guard) = init_test_db();
5917 let relay = MemoryRelay::new();
5918 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5919 let cid = community.id.to_hex();
5920 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5921 let alice = Keys::generate();
5922 let bob = Keys::generate();
5923 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id.clone()]).await.unwrap();
5925 set_member_grant(&relay, &community, &bob.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
5926
5927 let cite = authority_citation(&community, &alice.public_key().to_hex());
5930 let inner = crate::community::roster::build_banlist_edition(&alice, &community.id, &[bob.public_key().to_hex()], 1, None, 1000, cite.as_ref()).unwrap();
5931 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
5932 relay.inject(&outer, &community.relays);
5933
5934 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
5936 assert!(applied.is_empty(), "an admin can't ban a peer admin (receiver-side outrank)");
5937 }
5938
5939 #[tokio::test]
5940 async fn banlist_admin_bans_regular_member_applies() {
5941 let (_tmp, _guard) = init_test_db();
5944 let relay = MemoryRelay::new();
5945 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5946 let cid = community.id.to_hex();
5947 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5948 let alice = Keys::generate();
5949 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
5950
5951 let carol = "cc".repeat(32);
5952 let cite = authority_citation(&community, &alice.public_key().to_hex());
5954 let inner = crate::community::roster::build_banlist_edition(&alice, &community.id, &[carol.clone()], 1, None, 1000, cite.as_ref()).unwrap();
5955 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
5956 relay.inject(&outer, &community.relays);
5957
5958 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
5959 assert_eq!(applied, vec![carol], "an admin's ban of a regular member applies");
5960 }
5961
5962 #[tokio::test]
5963 async fn owner_banlist_needs_no_citation() {
5964 let (_tmp, _guard) = init_test_db();
5967 let relay = MemoryRelay::new();
5968 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5969 let victim = "cc".repeat(32);
5970
5971 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
5973 let inner = crate::community::roster::build_banlist_edition(&owner, &community.id, &[victim.clone()], 1, None, 1000, None).unwrap();
5974 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
5975 relay.inject(&outer, &community.relays);
5976
5977 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
5978 assert_eq!(applied, vec![victim], "an owner's uncited ban applies");
5979 }
5980
5981 #[tokio::test]
5982 async fn banlist_with_forged_citation_hash_is_rejected() {
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 mut cite = authority_citation(&community, &alice.public_key().to_hex()).unwrap();
5996 cite.edition_hash = [0xEE; 32];
5997 let inner = crate::community::roster::build_banlist_edition(&alice, &community.id, &[carol], 1, None, 1000, Some(&cite)).unwrap();
5998 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
5999 relay.inject(&outer, &community.relays);
6000
6001 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
6002 assert!(applied.is_empty(), "a forged-hash citation is rejected");
6003 }
6004
6005 #[tokio::test]
6006 async fn banlist_citing_unsynced_future_version_is_rejected() {
6007 let (_tmp, _guard) = init_test_db();
6011 let relay = MemoryRelay::new();
6012 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6013 let cid = community.id.to_hex();
6014 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
6015 let alice = Keys::generate();
6016 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
6017
6018 let carol = "cc".repeat(32);
6019 let mut cite = authority_citation(&community, &alice.public_key().to_hex()).unwrap();
6020 cite.version += 5; let inner = crate::community::roster::build_banlist_edition(&alice, &community.id, &[carol], 1, None, 1000, Some(&cite)).unwrap();
6022 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6023 relay.inject(&outer, &community.relays);
6024
6025 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
6026 assert!(applied.is_empty(), "citing an unsynced future grant version fails closed");
6027 }
6028
6029 #[tokio::test]
6030 async fn demoted_banner_superseded_ban_is_rejected() {
6031 let (_tmp, _guard) = init_test_db();
6035 let relay = MemoryRelay::new();
6036 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6037 let cid = community.id.to_hex();
6038 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
6039 let alice = Keys::generate();
6040 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
6041
6042 let carol = "cc".repeat(32);
6043 let cite = authority_citation(&community, &alice.public_key().to_hex());
6045 let inner = crate::community::roster::build_banlist_edition(&alice, &community.id, &[carol], 1, None, 1000, cite.as_ref()).unwrap();
6046 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6047 relay.inject(&outer, &community.relays);
6048
6049 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![]).await.unwrap();
6051
6052 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
6053 assert!(applied.is_empty(), "a since-demoted banner's stale ban is rejected (refuse-superseded)");
6054 }
6055
6056 #[tokio::test]
6057 async fn withheld_revocation_cannot_resurrect_a_demoted_banners_grant() {
6058 let (_tmp, _guard) = init_test_db();
6064 let relay = MemoryRelay::new();
6065 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6066 let cid = community.id.to_hex();
6067 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
6068 let alice = Keys::generate();
6069 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
6070
6071 let carol = "cc".repeat(32);
6073 let cite = authority_citation(&community, &alice.public_key().to_hex());
6074 let inner = crate::community::roster::build_banlist_edition(&alice, &community.id, &[carol], 1, None, 1000, cite.as_ref()).unwrap();
6075 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6076 relay.inject(&outer, &community.relays);
6077
6078 let alice_bytes = alice.public_key().to_bytes();
6081 let grant_entity = crate::simd::hex::bytes_to_hex_32(&crate::community::derive::grant_locator(&community.id, &alice_bytes));
6082 crate::db::community::set_edition_head(&cid, &grant_entity, 2, &[0xAB; 32]).unwrap();
6083
6084 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
6085 assert!(applied.is_empty(), "a withheld revocation can't roll the banner's grant back to re-authorize them");
6086 }
6087
6088 #[tokio::test]
6089 async fn invite_registry_round_trips_and_drives_is_public() {
6090 let (_tmp, _guard) = init_test_db();
6094 let relay = MemoryRelay::new();
6095 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6096 assert!(!is_public(&community).unwrap(), "a fresh community is Private");
6097
6098 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6101 let loc = "1a".repeat(32);
6102 let inner = crate::community::roster::build_invite_links_edition(&owner, &community.id, &[loc.clone()], 1, None, 1000, None).unwrap();
6103 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6104 relay.inject(&outer, &community.relays);
6105
6106 let applied = fetch_and_apply_invite_links(&relay, &community).await.unwrap();
6107 assert_eq!(applied, vec![loc], "the owner's link edition folds + unions from the relay");
6108 assert!(is_public(&community).unwrap(), "mode recomputed Public from the folded aggregate");
6109
6110 publish_my_invite_links(&relay, &community, &[]).await.unwrap();
6112 let applied = fetch_and_apply_invite_links(&relay, &community).await.unwrap();
6113 assert!(applied.is_empty() && !is_public(&community).unwrap(), "an empty aggregate is Private");
6114 }
6115
6116 #[tokio::test]
6117 async fn metadata_edit_round_trips_to_a_lagging_member() {
6118 let (_tmp, _guard) = init_test_db();
6122 let relay = MemoryRelay::new();
6123 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6124 let cid = community.id.to_hex();
6125 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6126 let (genesis_v, genesis_hash) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
6127 assert_eq!(genesis_v, 1);
6128
6129 let mut edited = crate::community::metadata::CommunityMetadata::of(&community);
6130 edited.name = "Renamed HQ".into();
6131 edited.description = Some("now with a topic".into());
6132 let inner = crate::community::roster::build_community_root_edition(&owner, &community.id, &edited, 2, Some(&genesis_hash), 4000, None).unwrap();
6133 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6134 relay.inject(&outer, &community.relays);
6135
6136 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6137 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6138 assert_eq!(after.name, "Renamed HQ", "the owner's GroupRoot edit folded from the relay");
6139 assert_eq!(after.description.as_deref(), Some("now with a topic"));
6140 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0, 2, "head advanced to v2");
6141 }
6142
6143 #[tokio::test]
6144 async fn unauthorized_metadata_edit_is_ignored() {
6145 let (_tmp, _guard) = init_test_db();
6148 let relay = MemoryRelay::new();
6149 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6150 let cid = community.id.to_hex();
6151 let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
6152
6153 let mallory = Keys::generate();
6154 let mut hacked = crate::community::metadata::CommunityMetadata::of(&community);
6155 hacked.name = "Pwned".into();
6156 let inner = crate::community::roster::build_community_root_edition(&mallory, &community.id, &hacked, 2, Some(&genesis_hash), 5000, None).unwrap();
6157 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6158 relay.inject(&outer, &community.relays);
6159
6160 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6161 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6162 assert_eq!(after.name, "HQ", "a non-manage-metadata signer's GroupRoot edit is rejected");
6163 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0, 1, "an unauthorized edit never advances the head");
6164 }
6165
6166 #[tokio::test]
6167 async fn channel_rename_round_trips_from_owner_edition() {
6168 let (_tmp, _guard) = init_test_db();
6171 let relay = MemoryRelay::new();
6172 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6173 let cid = community.id.to_hex();
6174 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6175 let channel = community.channels[0].clone();
6176 let ch_hex = channel.id.to_hex();
6177 let (_, genesis_ch_hash) = crate::db::community::get_edition_head(&cid, &ch_hex).unwrap().unwrap();
6178
6179 let meta = crate::community::metadata::ChannelMetadata { name: "announcements".into() };
6180 let inner = crate::community::roster::build_channel_metadata_edition(&owner, &channel.id, &meta, 2, Some(&genesis_ch_hash), 6000, None).unwrap();
6181 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6182 relay.inject(&outer, &community.relays);
6183
6184 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6185 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6186 assert_eq!(after.channels[0].name, "announcements", "the owner's channel rename folded + applied");
6187 assert_eq!(crate::db::community::get_edition_head(&cid, &ch_hex).unwrap().unwrap().0, 2, "channel head advanced to v2");
6188 }
6189
6190 fn root_fork_v2(author: &Keys, community: &Community, name: &str, created: u64, genesis_hash: &[u8; 32]) -> (Event, [u8; 32], [u8; 32]) {
6193 let mut meta = crate::community::metadata::CommunityMetadata::of(community);
6194 meta.name = name.into();
6195 let inner = crate::community::roster::build_community_root_edition(author, &community.id, &meta, 2, Some(genesis_hash), created, None).unwrap();
6196 let self_hash = crate::community::version::edition_hash(&community.id.0, 2, Some(genesis_hash), inner.content.as_bytes());
6197 let inner_id = inner.id.to_bytes();
6198 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6199 (outer, self_hash, inner_id)
6200 }
6201
6202 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]) {
6205 let meta = crate::community::metadata::ChannelMetadata { name: name.into() };
6206 let inner = crate::community::roster::build_channel_metadata_edition(author, channel_id, &meta, 2, Some(genesis_hash), created, None).unwrap();
6207 let self_hash = crate::community::version::edition_hash(&channel_id.0, 2, Some(genesis_hash), inner.content.as_bytes());
6208 let inner_id = inner.id.to_bytes();
6209 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6210 (outer, self_hash, inner_id)
6211 }
6212
6213 #[tokio::test]
6217 async fn channel_same_version_fork_converges_to_the_lower_inner_id() {
6218 let (_tmp, _guard) = init_test_db();
6219 let relay = MemoryRelay::new();
6220 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6221 let cid = community.id.to_hex();
6222 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6223 let channel_id = community.channels[0].id;
6224 let ch_hex = channel_id.to_hex();
6225 let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &ch_hex).unwrap().unwrap();
6226
6227 let (out_a, ha, ida) = channel_fork_v2(&owner, &community, &channel_id, "alpha", 1000, &genesis_hash);
6228 let (out_b, hb, idb) = channel_fork_v2(&owner, &community, &channel_id, "bravo", 2000, &genesis_hash);
6229 let (win_name, win_h, win_id, lose_name, lose_h, lose_id) = if ida < idb {
6230 ("alpha", ha, ida, "bravo", hb, idb)
6231 } else {
6232 ("bravo", hb, idb, "alpha", ha, ida)
6233 };
6234 crate::db::community::set_edition_head_with_id(&cid, &ch_hex, 2, &lose_h, &lose_id).unwrap();
6236 {
6237 let mut c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6238 c.channels.iter_mut().find(|ch| ch.id == channel_id).unwrap().name = lose_name.into();
6239 crate::db::community::save_community(&c).unwrap();
6240 }
6241 relay.inject(&out_a, &community.relays);
6242 relay.inject(&out_b, &community.relays);
6243
6244 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6245 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6246 let ch_name = &after.channels.iter().find(|c| c.id == channel_id).unwrap().name;
6247 assert_eq!(ch_name, win_name, "channel converged on the lower-inner-id winner, not our held fork");
6248 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");
6249 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");
6250
6251 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6253 let after2 = crate::db::community::load_community(&community.id).unwrap().unwrap();
6254 assert_eq!(&after2.channels.iter().find(|c| c.id == channel_id).unwrap().name, win_name, "no flip back to the higher-id fork");
6255 }
6256
6257 #[tokio::test]
6260 async fn channel_same_version_fork_excludes_an_unauthorized_lower_id_edition() {
6261 let (_tmp, _guard) = init_test_db();
6262 let relay = MemoryRelay::new();
6263 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6264 let cid = community.id.to_hex();
6265 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6266 let channel_id = community.channels[0].id;
6267 let ch_hex = channel_id.to_hex();
6268 let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &ch_hex).unwrap().unwrap();
6269
6270 let (owner_out, owner_h, owner_id) = channel_fork_v2(&owner, &community, &channel_id, "legit", 1000, &genesis_hash);
6271 let mallory = Keys::generate();
6273 let mal_out = {
6274 let mut chosen = None;
6275 for t in 1..=10_000u64 {
6276 let cand = channel_fork_v2(&mallory, &community, &channel_id, "forged", t, &genesis_hash);
6277 if cand.2 < owner_id { chosen = Some(cand.0); break; }
6278 }
6279 chosen.expect("a mallory channel edition with a lower inner id")
6280 };
6281 relay.inject(&owner_out, &community.relays);
6282 relay.inject(&mal_out, &community.relays);
6283
6284 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6285 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6286 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");
6287 assert_eq!(crate::db::community::get_edition_head(&cid, &ch_hex).unwrap().unwrap(), (2, owner_h), "the authorized channel edition is the head");
6288 }
6289
6290 #[tokio::test]
6294 async fn epoch_primary_floor_lets_a_refounding_v1_supersede_a_held_high_version() {
6295 let (_tmp, _guard) = init_test_db();
6296 let relay = MemoryRelay::new();
6297 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6298 let cid = community.id.to_hex();
6299 for v in 2..=5u64 {
6301 crate::db::community::set_edition_head_with_id(&cid, &cid, v, &[v as u8; 32], &[v as u8; 32]).unwrap();
6302 }
6303 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0, 5);
6304 crate::db::community::set_edition_head_with_id(&cid, &cid, 3, &[0x33; 32], &[0x33; 32]).unwrap();
6306 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0, 5, "in-epoch downgrade refused");
6307
6308 crate::db::community::advance_server_root_epoch(&cid, 1, &[0xEE; 32]).unwrap();
6310 crate::db::community::set_edition_head_with_id(&cid, &cid, 1, &[0x01; 32], &[0x01; 32]).unwrap();
6311 let (v, h) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
6312 assert_eq!((v, h), (1, [0x01; 32]), "epoch-1 v1 supersedes epoch-0 v5 (epoch-primary)");
6313 assert_eq!(
6314 crate::db::community::get_all_edition_heads_epoched(&cid).unwrap().get(&cid).map(|(e, v, _)| (*e, *v)),
6315 Some((1, 1)),
6316 "head now recorded at epoch 1",
6317 );
6318 crate::db::community::set_edition_head_with_id(&cid, &cid, 1, &[0xAA; 32], &[0x02; 32]).unwrap();
6320 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().1, [0x01; 32], "same-epoch same-version is not an advance");
6321 }
6322
6323 #[tokio::test]
6327 async fn same_version_fork_converges_to_the_lower_inner_id() {
6328 let (_tmp, _guard) = init_test_db();
6329 let relay = MemoryRelay::new();
6330 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6331 let cid = community.id.to_hex();
6332 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6333 let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
6334
6335 let (out_a, ha, ida) = root_fork_v2(&owner, &community, "Alpha", 1000, &genesis_hash);
6336 let (out_b, hb, idb) = root_fork_v2(&owner, &community, "Bravo", 2000, &genesis_hash);
6337 let (win_name, win_h, win_id, lose_name, lose_h, lose_id) = if ida < idb {
6339 ("Alpha", ha, ida, "Bravo", hb, idb)
6340 } else {
6341 ("Bravo", hb, idb, "Alpha", ha, ida)
6342 };
6343 crate::db::community::set_edition_head_with_id(&cid, &cid, 2, &lose_h, &lose_id).unwrap();
6344 {
6345 let mut c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6346 c.name = lose_name.into();
6347 crate::db::community::save_community(&c).unwrap();
6348 }
6349 relay.inject(&out_a, &community.relays);
6350 relay.inject(&out_b, &community.relays);
6351
6352 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6353 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6354 assert_eq!(after.name, win_name, "converged on the lower-inner-id winner, not our own held fork");
6355 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap(), (2, win_h), "head self_hash converged at the SAME version");
6356 assert_eq!(crate::db::community::get_edition_head_inner_id(&cid, &cid).unwrap(), Some(win_id), "head inner_id moved to the winner");
6357
6358 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6360 assert_eq!(crate::db::community::load_community(&community.id).unwrap().unwrap().name, win_name, "no flip back to the higher-id fork");
6361 }
6362
6363 #[tokio::test]
6366 async fn converged_head_chains_the_next_edit_without_reforking() {
6367 let (_tmp, _guard) = init_test_db();
6368 let relay = MemoryRelay::new();
6369 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6370 let cid = community.id.to_hex();
6371 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6372 let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
6373
6374 let (out_a, ha, ida) = root_fork_v2(&owner, &community, "Alpha", 1000, &genesis_hash);
6375 let (out_b, hb, idb) = root_fork_v2(&owner, &community, "Bravo", 2000, &genesis_hash);
6376 let (lose_h, lose_id) = if ida < idb { (hb, idb) } else { (ha, ida) };
6377 crate::db::community::set_edition_head_with_id(&cid, &cid, 2, &lose_h, &lose_id).unwrap();
6378 relay.inject(&out_a, &community.relays);
6379 relay.inject(&out_b, &community.relays);
6380 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6381 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0, 2, "converged at v2");
6382
6383 let mut c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6384 c.name = "Third".into();
6385 republish_community_metadata(&relay, &c).await.unwrap();
6386 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0, 3, "advanced to v3 off the converged head");
6387
6388 let empty: std::collections::HashMap<String, (u64, [u8; 32])> = std::collections::HashMap::new();
6389 let folded = crate::community::roster::fold_roster(&fetch_control_inners(&relay, &community).await, &community.id, &empty);
6390 assert_eq!(folded.root_head.as_ref().map(|h| h.version), Some(3), "a fresh fold reaches v3");
6391 assert!(!folded.gapped_entities.contains(&community.id.0), "the chain is contiguous genesis -> winner -> v3");
6392 }
6393
6394 #[tokio::test]
6397 async fn same_version_fork_excludes_an_unauthorized_lower_id_edition() {
6398 let (_tmp, _guard) = init_test_db();
6399 let relay = MemoryRelay::new();
6400 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6401 let cid = community.id.to_hex();
6402 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6403 let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
6404
6405 let (owner_out, owner_h, owner_id) = root_fork_v2(&owner, &community, "Legit", 1000, &genesis_hash);
6406 let mallory = Keys::generate();
6408 let (mal_out, mal_id) = {
6409 let mut chosen = None;
6410 for t in 1..=10_000u64 {
6411 let cand = root_fork_v2(&mallory, &community, "Forged", t, &genesis_hash);
6412 if cand.2 < owner_id { chosen = Some((cand.0, cand.2)); break; }
6413 }
6414 chosen.expect("a mallory edition with a lower inner id")
6415 };
6416 assert!(mal_id < owner_id, "premise: the forgery sorts first author-blind");
6417 relay.inject(&owner_out, &community.relays);
6418 relay.inject(&mal_out, &community.relays);
6419
6420 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6422 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6423 assert_eq!(after.name, "Legit", "the forgery never wins despite a lower inner id");
6424 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap(), (2, owner_h), "the authorized edition is the head");
6425 }
6426
6427 #[tokio::test]
6431 async fn same_version_fork_on_an_authority_record_fails_closed() {
6432 let (_tmp, _guard) = init_test_db();
6433 let relay = MemoryRelay::new();
6434 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6435 let cid = community.id.to_hex();
6436 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6437 let bl_eid = crate::community::derive::banlist_locator(&community.id);
6438 let bl_hex = crate::simd::hex::bytes_to_hex_32(&bl_eid);
6439
6440 let prev = [0x99u8; 32]; let build_ban = |list: &[String], created: u64| {
6442 let inner = crate::community::roster::build_banlist_edition(&owner, &community.id, list, 2, Some(&prev), created, None).unwrap();
6443 let self_hash = crate::community::version::edition_hash(&bl_eid, 2, Some(&prev), inner.content.as_bytes());
6444 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6445 (outer, self_hash, inner.id.to_bytes())
6446 };
6447 let (out_a, ha, ida) = build_ban(&["aa".repeat(32)], 1000);
6448 let (out_b, hb, idb) = build_ban(&["bb".repeat(32)], 2000);
6449 let (lose_h, lose_id) = if ida < idb { (hb, idb) } else { (ha, ida) };
6452 crate::db::community::set_edition_head_with_id(&cid, &bl_hex, 2, &lose_h, &lose_id).unwrap();
6453 relay.inject(&out_a, &community.relays);
6454 relay.inject(&out_b, &community.relays);
6455
6456 let floors = crate::db::community::get_all_edition_heads(&cid).unwrap();
6457 let folded = crate::community::roster::fold_roster(&fetch_control_inners(&relay, &community).await, &community.id, &floors);
6458 assert!(folded.gapped_entities.contains(&bl_eid), "the authority-record fork is quarantined");
6459 assert!(folded.banlist_head.is_none() && folded.banlist_author.is_none(), "no banlist folded off the withheld view");
6460 }
6461
6462 #[tokio::test]
6463 async fn editions_sign_through_the_active_client_signer() {
6464 let (_tmp, _guard) = init_test_db();
6469 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6470 crate::state::set_nostr_client(nostr_sdk::prelude::Client::builder().build());
6471
6472 let relay = MemoryRelay::new();
6473 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6474 let cid = community.id.to_hex();
6475
6476 publish_banlist(&relay, &community, &["dd".repeat(32)]).await.unwrap();
6478 let floors = crate::db::community::get_all_edition_heads(&cid).unwrap();
6479 let folded = crate::community::roster::fold_roster(
6480 &fetch_control_inners(&relay, &community).await, &community.id, &floors);
6481 assert_eq!(folded.banlist_author, Some(owner.public_key()), "banlist signed by the client signer");
6482 assert!(folded.root_author.is_some(), "genesis GroupRoot folded");
6483 let _ = crate::state::take_nostr_client();
6484 }
6485
6486 async fn fetch_control_inners(relay: &MemoryRelay, community: &Community) -> Vec<Event> {
6488 let z = crate::community::roster::control_pseudonym(&community.server_root_key, &community.id, crate::community::Epoch(0));
6489 let query = Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], ..Default::default() };
6490 let mut out = Vec::new();
6491 for ev in relay.fetch(&query, &community.relays).await.unwrap() {
6492 if let Ok(inner) = crate::community::roster::open_control_edition(&ev, &community.server_root_key) {
6493 out.push(inner);
6494 }
6495 }
6496 out
6497 }
6498
6499 fn simulate_bunker(owner: &Keys) {
6502 crate::state::set_nostr_client(nostr_sdk::prelude::Client::builder().build());
6503 crate::signer::set_test_signer(Some(crate::signer::ActiveSigner::Keys(owner.clone())));
6506 crate::state::MY_SECRET_KEY.clear(&[]);
6507 assert!(crate::state::MY_SECRET_KEY.to_keys().is_none(), "bunker sim: no local key");
6508 }
6509
6510 #[tokio::test]
6511 async fn am_i_banned_detects_own_npub_in_banlist() {
6512 let (_tmp, _guard) = init_test_db();
6514 let relay = MemoryRelay::new();
6515 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6516 let me = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key().to_hex();
6517 let cid = community.id.to_hex();
6518 assert!(!am_i_banned(&community), "not banned on a fresh community");
6519 crate::db::community::set_community_banlist(&cid, &[me], 1).unwrap();
6521 assert!(am_i_banned(&community), "own npub in the banlist → banned → self-remove");
6522 crate::db::community::set_community_banlist(&cid, &[], 2).unwrap();
6523 assert!(!am_i_banned(&community), "cleared banlist → not banned");
6524 }
6525
6526 #[tokio::test]
6527 async fn bunker_owner_cannot_ban_in_private_community() {
6528 let (_tmp, _guard) = init_test_db();
6531 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6532 let relay = MemoryRelay::new();
6533 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6534 simulate_bunker(&owner);
6535
6536 let victim = "cc".repeat(32);
6537 let err = publish_banlist(&relay, &community, &[victim]).await.unwrap_err();
6538 assert!(err.contains("private community") && err.contains("bunker"), "clear bunker explanation: {err}");
6539 assert!(
6540 crate::db::community::get_community_banlist(&community.id.to_hex()).unwrap().is_empty(),
6541 "the ban must NOT half-apply (nothing published or persisted)"
6542 );
6543 let _ = crate::state::take_nostr_client();
6544 }
6545
6546 #[tokio::test]
6547 async fn bunker_owner_can_ban_in_public_community() {
6548 let (_tmp, _guard) = init_test_db();
6551 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6552 let relay = MemoryRelay::new();
6553 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6554 create_public_invite(&relay, &community, None, None).await.unwrap();
6555 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
6556 assert!(is_public(&community).unwrap(), "minting a link made it Public");
6557 simulate_bunker(&owner);
6558
6559 let victim = "cc".repeat(32);
6560 publish_banlist(&relay, &community, &[victim.clone()]).await.unwrap();
6561 assert_eq!(
6562 crate::db::community::get_community_banlist(&community.id.to_hex()).unwrap(),
6563 vec![victim],
6564 "a public ban from a bunker account succeeds (no rekey needed)"
6565 );
6566 let _ = crate::state::take_nostr_client();
6567 }
6568
6569 #[tokio::test]
6570 async fn bunker_owner_cannot_privatize() {
6571 let (_tmp, _guard) = init_test_db();
6574 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6575 let relay = MemoryRelay::new();
6576 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6577 let (token, _) = create_public_invite(&relay, &community, None, None).await.unwrap();
6578 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
6579 simulate_bunker(&owner);
6580
6581 let err = revoke_public_invite(&relay, &community, &crate::simd::hex::hex_to_bytes_32(&token)).await.unwrap_err();
6582 assert!(err.contains("private") && err.contains("bunker"), "clear bunker explanation: {err}");
6583 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6584 assert!(is_public(&after).unwrap(), "the revoke must NOT half-apply — community stays Public");
6585 let _ = crate::state::take_nostr_client();
6586 }
6587
6588 #[tokio::test]
6589 async fn non_owner_admin_can_edit_community_metadata() {
6590 let (_tmp, _guard) = init_test_db();
6594 let relay = MemoryRelay::new();
6595 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6596 let cid = community.id.to_hex();
6597 let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
6598
6599 let admin = Keys::generate();
6601 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
6602 set_member_grant(&relay, &community, &admin.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
6603
6604 let mut edited = crate::community::metadata::CommunityMetadata::of(&community);
6606 edited.name = "Admin Renamed".into();
6607 let inner = crate::community::roster::build_community_root_edition(&admin, &community.id, &edited, 2, Some(&genesis_hash), 7000, None).unwrap();
6608 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6609 relay.inject(&outer, &community.relays);
6610
6611 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6612 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6613 assert_eq!(after.name, "Admin Renamed", "a MANAGE_METADATA admin (not the owner) can edit metadata");
6614 }
6615
6616 #[tokio::test]
6617 async fn banning_an_admin_revokes_their_role() {
6618 let (_tmp, _guard) = init_test_db();
6622 let relay = MemoryRelay::new();
6623 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6624 let cid = community.id.to_hex();
6625 create_public_invite(&relay, &community, None, None).await.unwrap();
6626 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
6627
6628 let alice = Keys::generate();
6629 let alice_hex = alice.public_key().to_hex();
6630 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
6631 set_member_grant(&relay, &community, &alice_hex, vec![admin_role_id]).await.unwrap();
6632 let holds_role = |hex: &str| crate::db::community::get_community_roles(&cid).unwrap()
6633 .grants.iter().any(|g| g.member == hex && !g.role_ids.is_empty());
6634 assert!(holds_role(&alice_hex), "alice is admin pre-ban");
6635
6636 publish_banlist(&relay, &community, &[alice_hex.clone()]).await.unwrap();
6637 assert!(!holds_role(&alice_hex), "banning an admin revokes their role — no dangling grant");
6638 }
6639
6640 #[tokio::test]
6641 async fn kicking_an_admin_revokes_their_role() {
6642 let (_tmp, _guard) = init_test_db();
6645 let relay = MemoryRelay::new();
6646 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6647 let cid = community.id.to_hex();
6648 let alice = Keys::generate();
6649 let alice_hex = alice.public_key().to_hex();
6650 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
6651 set_member_grant(&relay, &community, &alice_hex, vec![admin_role_id]).await.unwrap();
6652 let holds_role = |hex: &str| crate::db::community::get_community_roles(&cid).unwrap()
6653 .grants.iter().any(|g| g.member == hex && !g.role_ids.is_empty());
6654 assert!(holds_role(&alice_hex), "alice is admin pre-kick");
6655
6656 publish_kick(&relay, &community, &community.channels[0], &alice_hex).await.unwrap();
6657 assert!(!holds_role(&alice_hex), "kicking an admin revokes their role");
6658 }
6659
6660 #[tokio::test]
6661 async fn republish_channel_metadata_renames_and_publishes() {
6662 let (_tmp, _guard) = init_test_db();
6665 let relay = MemoryRelay::new();
6666 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6667 let cid = community.id.to_hex();
6668 let channel = community.channels[0].clone();
6669 let ch_hex = channel.id.to_hex();
6670
6671 republish_channel_metadata(&relay, &community, &channel.id, "lobby").await.unwrap();
6672 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6673 assert_eq!(after.channels[0].name, "lobby", "the producer renamed the channel locally");
6674 assert_eq!(crate::db::community::get_edition_head(&cid, &ch_hex).unwrap().unwrap().0, 2, "channel head advanced");
6675 }
6676
6677 #[tokio::test]
6678 async fn revoking_the_last_link_privatizes_and_rotates_the_base() {
6679 let (_tmp, _guard) = init_test_db();
6684 let relay = MemoryRelay::new();
6685 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6686 assert!(!is_public(&community).unwrap(), "a fresh community is Private");
6687 assert_eq!(community.server_root_epoch, crate::community::Epoch(0));
6688
6689 let (t1, _) = create_public_invite(&relay, &community, None, None).await.unwrap();
6691 let (t2, _) = create_public_invite(&relay, &community, None, None).await.unwrap();
6692 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6693 assert!(is_public(&c).unwrap(), "minting a link flips the mode to Public");
6694 assert_eq!(c.server_root_epoch, crate::community::Epoch(0), "minting links does NOT rotate the base");
6695
6696 revoke_public_invite(&relay, &c, &crate::simd::hex::hex_to_bytes_32(&t1)).await.unwrap();
6698 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6699 assert!(is_public(&c).unwrap(), "one link remains → still Public");
6700 assert_eq!(c.server_root_epoch, crate::community::Epoch(0), "revoking a non-last link does NOT rotate");
6701
6702 revoke_public_invite(&relay, &c, &crate::simd::hex::hex_to_bytes_32(&t2)).await.unwrap();
6704 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6705 assert!(!is_public(&c).unwrap(), "revoking the last link flips to Private");
6706 assert_eq!(c.server_root_epoch, crate::community::Epoch(1), "privatize re-founded: the base key rotated");
6707
6708 revoke_public_invite(&relay, &c, &crate::simd::hex::hex_to_bytes_32(&t2)).await.unwrap();
6711 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6712 assert_eq!(c.server_root_epoch, crate::community::Epoch(1), "a no-op re-revoke does not double-rotate");
6713 }
6714
6715 #[tokio::test]
6716 async fn private_ban_reseals_base_public_ban_does_not() {
6717 let (_tmp, _guard) = init_test_db();
6720 let relay = MemoryRelay::new();
6721 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6722 let victim = "cc".repeat(32);
6723
6724 assert!(!is_public(&community).unwrap(), "fresh community is Private");
6726 publish_banlist(&relay, &community, &[victim.clone()]).await.unwrap();
6727 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6728 assert_eq!(c.server_root_epoch, crate::community::Epoch(1), "a private-community ban re-seals the base");
6729
6730 create_public_invite(&relay, &c, None, None).await.unwrap();
6732 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6733 assert!(is_public(&c).unwrap(), "minted a link → Public");
6734 publish_banlist(&relay, &c, &[victim.clone(), "dd".repeat(32)]).await.unwrap();
6735 let c2 = crate::db::community::load_community(&community.id).unwrap().unwrap();
6736 assert_eq!(c2.server_root_epoch, crate::community::Epoch(1), "a public-community ban does NOT rotate the base");
6737 }
6738
6739 #[tokio::test]
6740 async fn private_ban_seals_the_banned_member_out_of_the_new_root() {
6741 use crate::community::derive::{base_rekey_pseudonym, recipient_pseudonym};
6747 use crate::community::rekey::{open_rekey_event, rekey_pairwise_secret};
6748 use crate::types::Message;
6749 use nostr_sdk::prelude::ToBech32;
6750 let (_tmp, _guard) = init_test_db();
6751 let relay = MemoryRelay::new();
6752 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6753 let cid = community.id.to_hex();
6754 let genesis_root = *community.server_root_key.as_bytes();
6755 let channel_hex = community.channels[0].id.to_hex();
6756
6757 let victim = Keys::generate();
6759 let victim_b32 = victim.public_key().to_bech32().unwrap();
6760 let mut m = Message::default();
6761 m.id = "aa".repeat(32);
6762 m.npub = Some(victim_b32.clone());
6763 m.at = 1000;
6764 crate::db::events::save_message(&channel_hex, &m).await.unwrap();
6765 assert!(
6766 crate::db::community::community_member_activity(&cid).unwrap().iter().any(|(np, _)| np == &victim_b32),
6767 "victim is observed before the ban"
6768 );
6769
6770 publish_banlist(&relay, &community, &[victim.public_key().to_hex()]).await.unwrap();
6772 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
6773 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(1), "private ban re-seals the base");
6774 assert!(
6775 !crate::db::community::community_member_activity(&cid).unwrap().iter().any(|(np, _)| np == &victim_b32),
6776 "the banned victim is no longer observed (banlist hex → bech32 reconciliation worked)"
6777 );
6778
6779 let addr = base_rekey_pseudonym(&crate::community::ServerRootKey(genesis_root), &community.id, crate::community::Epoch(1)).to_hex();
6781 let found = relay
6782 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() }, &community.relays)
6783 .await
6784 .unwrap();
6785 assert_eq!(found.len(), 1, "the base rekey is published");
6786 let parsed = open_rekey_event(&found[0], &genesis_root).unwrap();
6787 let secret = rekey_pairwise_secret(victim.secret_key(), &parsed.rotator).unwrap();
6788 let loc = recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
6789 assert!(
6790 parsed.blobs.iter().all(|b| b.locator != loc),
6791 "the BANNED victim has NO blob — sealed OUT of the new root (read access is actually cut)"
6792 );
6793 }
6794
6795 struct SwapDuringPublishRelay {
6802 inner: MemoryRelay,
6803 to: String,
6804 armed: std::sync::atomic::AtomicBool,
6805 }
6806
6807 impl SwapDuringPublishRelay {
6808 fn new() -> Self {
6809 let to = make_test_npub(TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed));
6810 std::fs::create_dir_all(crate::db::shared_test_data_dir().join(&to)).unwrap();
6811 assert_ne!(to, crate::db::get_current_account().unwrap(), "the fixture must swap to a DIFFERENT account");
6812 Self { inner: MemoryRelay::new(), to, armed: std::sync::atomic::AtomicBool::new(false) }
6813 }
6814 fn arm(&self) {
6819 self.armed.store(true, std::sync::atomic::Ordering::SeqCst);
6820 }
6821 fn swap_if_armed(&self) {
6822 if self.armed.swap(false, std::sync::atomic::Ordering::SeqCst) {
6823 crate::db::set_current_account(self.to.clone()).unwrap();
6824 crate::db::init_database(&self.to).unwrap();
6825 }
6826 }
6827 }
6828 #[async_trait::async_trait]
6829 impl Transport for SwapDuringPublishRelay {
6830 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
6831 async fn publish(&self, event: &Event, relays: &[String]) -> Result<(), String> {
6832 self.swap_if_armed();
6833 self.inner.publish(event, relays).await
6834 }
6835 async fn publish_durable(&self, event: &Event, relays: &[String]) -> Result<(), String> {
6836 self.swap_if_armed();
6837 self.inner.publish_durable(event, relays).await
6838 }
6839 async fn fetch(&self, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
6840 self.inner.fetch(query, relays).await
6841 }
6842 }
6843
6844 #[tokio::test]
6853 async fn account_swap_during_grant_publish_lands_in_the_issuing_account() {
6854 let (_tmp, _guard) = init_test_db();
6855 let issuer = crate::db::current_session();
6856 let swap = SwapDuringPublishRelay::new();
6857 let community = create_community(&swap, "HQ", "general", vec!["r1".into()]).await.unwrap();
6858 let cid = community.id.to_hex();
6859 let member = "cc".repeat(32);
6860 let entity_hex = crate::simd::hex::bytes_to_hex_32(
6861 &crate::community::derive::grant_locator(&community.id, &crate::simd::hex::hex_to_bytes_32(&member)));
6862 assert!(crate::db::community::get_edition_head(&cid, &entity_hex).unwrap().is_none(), "no grant head yet");
6863
6864 swap.arm();
6865 set_member_grant(&swap, &community, &member, vec!["a".repeat(64)]).await.unwrap();
6866
6867 assert!(
6868 crate::db::with_session(issuer, async {
6869 crate::db::community::get_edition_head(&cid, &entity_hex).unwrap().is_some()
6870 }).await,
6871 "the grant recorded against the account that issued it"
6872 );
6873 assert_eq!(crate::db::get_current_account().unwrap(), swap.to, "the swap really happened");
6874 assert!(
6875 crate::db::community::get_edition_head(&cid, &entity_hex).unwrap_or(None).is_none(),
6876 "and the account swapped in has no trace of it"
6877 );
6878 }
6879
6880 #[tokio::test]
6884 async fn account_swap_during_ban_publish_applies_to_the_banning_account() {
6885 let (_tmp, _guard) = init_test_db();
6886 let banner = crate::db::current_session();
6887 let swap = SwapDuringPublishRelay::new();
6888 let community = create_community(&swap, "HQ", "general", vec!["r1".into()]).await.unwrap();
6889 let cid = community.id.to_hex();
6890 assert!(!is_public(&community).unwrap(), "fresh community is Private (a ban re-seals)");
6891
6892 swap.arm();
6893 publish_banlist(&swap, &community, &["cc".repeat(32)]).await.unwrap();
6894
6895 assert!(
6896 crate::db::with_session(banner, async {
6897 !crate::db::community::get_community_banlist(&cid).unwrap().is_empty()
6898 }).await,
6899 "the ban applied to the account that issued it — losing it would leave the \
6900 relays holding a ban the owner cannot see"
6901 );
6902 assert!(
6903 crate::db::community::get_community_banlist(&cid).unwrap_or_default().is_empty(),
6904 "and the account swapped in inherits no banlist"
6905 );
6906 }
6907
6908 #[tokio::test]
6911 async fn swap_session_clears_per_account_state_and_keys() {
6912 let (_tmp, _guard) = init_test_db();
6913 {
6914 let mut st = crate::state::STATE.lock().await;
6915 st.db_loaded = true;
6916 st.is_syncing = true;
6917 }
6918 assert!(crate::state::MY_SECRET_KEY.has_key(), "account A holds a live key");
6919
6920 crate::VectorCore.swap_session().await;
6921
6922 let st = crate::state::STATE.lock().await;
6923 assert!(st.chats.is_empty() && st.profiles.is_empty(), "STATE chats/profiles cleared on swap");
6924 assert!(!st.db_loaded && !st.is_syncing, "db_loaded / is_syncing reset");
6925 assert!(!crate::state::MY_SECRET_KEY.has_key(), "key vault cleared — no leak into account B");
6926 }
6927
6928 #[tokio::test]
6932 async fn join_finalization_persists_and_registers_the_channel() {
6933 let (_tmp, _guard) = init_test_db();
6934 crate::state::STATE.lock().await.chats.clear(); let relay = MemoryRelay::new();
6936 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6937 become_local(&Keys::generate());
6939
6940 crate::VectorCore.finalize_member_join(community.clone(), &relay, None).await.unwrap();
6941
6942 assert!(crate::db::community::load_community(&community.id).unwrap().is_some(), "community persisted on join");
6943 assert!(!crate::state::STATE.lock().await.chats.is_empty(), "the channel is registered as a chat");
6944 }
6945
6946 #[tokio::test]
6950 async fn join_finalization_tears_down_a_banned_joiner() {
6951 let (_tmp, _guard) = init_test_db();
6952 let relay = MemoryRelay::new();
6953 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6954 create_public_invite(&relay, &community, None, None).await.unwrap();
6956 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
6957
6958 let joiner = Keys::generate();
6960 publish_banlist(&relay, &community, &[joiner.public_key().to_hex()]).await.unwrap();
6961 become_local(&joiner);
6962 assert!(crate::db::community::load_community(&community.id).unwrap().is_some(), "community present pre-join");
6963
6964 let result = crate::VectorCore.finalize_member_join(community.clone(), &relay, None).await;
6965 assert!(result.is_err(), "a banned joiner's finalize must fail");
6966 assert!(result.unwrap_err().to_string().contains("banned"), "the error names the ban");
6967 assert!(
6968 crate::db::community::load_community(&community.id).unwrap().is_none(),
6969 "the just-saved community is torn back down — no orphaned row for a banned joiner"
6970 );
6971 }
6972
6973 #[tokio::test]
6979 async fn delete_community_wipes_every_community_scoped_table() {
6980 let (_tmp, _guard) = init_test_db();
6981 let relay = MemoryRelay::new();
6982 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6983 let cid = community.id.to_hex();
6984
6985 crate::db::community::store_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 1, &[0x11u8; 32]).unwrap();
6987 crate::db::community::save_public_invite("tok", &cid, "https://x/invite#y", None, None).unwrap();
6988 crate::db::community::save_pending_invite(&cid, "{}", "npub1inviter", 0).unwrap();
6989 crate::db::community::set_edition_head(&cid, &cid, 1, &[0x22u8; 32]).unwrap();
6990 crate::db::community::set_community_banlist(&cid, &["cc".repeat(32)], 100).unwrap();
6991
6992 assert!(crate::db::community::community_exists(&community.id).unwrap());
6994 assert!(!crate::db::community::held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty());
6995 assert!(!crate::db::community::list_public_invites(&cid).unwrap().is_empty());
6996 assert!(crate::db::community::list_pending_invites().unwrap().iter().any(|p| p.community_id == cid));
6997 assert!(!crate::db::community::get_all_edition_heads(&cid).unwrap().is_empty());
6998 assert!(!crate::db::community::get_community_banlist(&cid).unwrap().is_empty());
6999
7000 crate::db::community::delete_community(&cid).unwrap();
7001
7002 assert!(!crate::db::community::community_exists(&community.id).unwrap(), "communities row gone");
7004 assert!(crate::db::community::load_community(&community.id).unwrap().is_none(), "community not loadable");
7005 assert!(crate::db::community::held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty(), "epoch keys wiped");
7006 assert!(crate::db::community::list_public_invites(&cid).unwrap().is_empty(), "public invites wiped");
7007 assert!(!crate::db::community::list_pending_invites().unwrap().iter().any(|p| p.community_id == cid), "pending invites wiped");
7008 assert!(crate::db::community::get_all_edition_heads(&cid).unwrap().is_empty(), "edition heads wiped");
7009 assert!(crate::db::community::get_community_banlist(&cid).unwrap().is_empty(), "banlist wiped with the channels");
7010 }
7011
7012 #[tokio::test]
7016 async fn fetch_control_folded_skips_junk_injected_at_the_coordinate() {
7017 let (_tmp, _guard) = init_test_db();
7018 let relay = MemoryRelay::new();
7019 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7020 let owner_hex = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key().to_hex();
7021
7022 let z = crate::community::roster::control_pseudonym(&community.server_root_key, &community.id, community.server_root_epoch);
7024 let junk = nostr_sdk::prelude::EventBuilder::new(nostr_sdk::prelude::Kind::Custom(event_kind::COMMUNITY_CONTROL), "not a sealed edition")
7025 .tags([nostr_sdk::prelude::Tag::custom("z", [z])])
7026 .finalize(&Keys::generate())
7027 .unwrap();
7028 relay.publish(&junk, &community.relays).await.unwrap();
7029
7030 let folded = fetch_control_folded(&relay, &community).await.unwrap();
7031 assert!(
7032 !crate::community::roster::authorize_delegation(&folded, Some(&owner_hex)).roles.is_empty(),
7033 "the genuine Admin role still folds; the un-openable junk is silently dropped"
7034 );
7035 }
7036
7037 #[tokio::test]
7040 async fn fetch_control_folded_on_dead_relays_is_empty_not_a_panic() {
7041 let (_tmp, _guard) = init_test_db();
7042 let community = saved_community_owned_by(&Keys::generate());
7043 let folded = fetch_control_folded(&FailingRelay, &community).await.unwrap();
7044 assert!(folded.roles.roles.is_empty() && folded.root_meta.is_none(), "dead relays → empty fold, no panic");
7045 }
7046
7047 #[tokio::test]
7048 async fn successful_private_ban_leaves_no_read_cut_pending() {
7049 let (_tmp, _guard) = init_test_db();
7051 let relay = MemoryRelay::new();
7052 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7053 let cid = community.id.to_hex();
7054 publish_banlist(&relay, &community, &["cc".repeat(32)]).await.unwrap();
7055 assert!(!crate::db::community::get_read_cut_pending(&cid).unwrap(), "a successful re-seal leaves no pending read-cut");
7056 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
7057 assert_eq!(c.server_root_epoch, crate::community::Epoch(1));
7058 }
7059
7060 #[tokio::test]
7061 async fn failed_reseal_sets_pending_then_sync_retry_recovers() {
7062 let (_tmp, _guard) = init_test_db();
7067 let relay = RekeyFailingRelay::new(); let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7069 let cid = community.id.to_hex();
7070 let victim = "cc".repeat(32);
7071
7072 assert!(publish_banlist(&relay, &community, &[victim.clone()]).await.is_err(), "the re-seal's base rekey fails");
7074 assert!(crate::db::community::get_read_cut_pending(&cid).unwrap(), "a failed re-seal leaves read_cut_pending set");
7075 assert_eq!(crate::db::community::get_community_banlist(&cid).unwrap(), vec![victim.clone()], "the ban itself still applied");
7076 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
7077 assert_eq!(c.server_root_epoch, crate::community::Epoch(0), "base NOT rotated while the re-seal is pending");
7078
7079 relay.allow_rekey();
7081 retry_pending_read_cut(&relay, &c).await.unwrap();
7082 assert!(!crate::db::community::get_read_cut_pending(&cid).unwrap(), "pending cleared after the retry succeeds");
7083 let c2 = crate::db::community::load_community(&community.id).unwrap().unwrap();
7084 assert_eq!(c2.server_root_epoch, crate::community::Epoch(1), "the read-cut finally rotated the base");
7085 }
7086
7087 #[tokio::test]
7088 async fn privatize_reseals_to_observed_participants_not_just_owner() {
7089 use crate::community::derive::{base_rekey_pseudonym, recipient_pseudonym};
7093 use crate::community::rekey::{open_rekey_blob, open_rekey_event, rekey_pairwise_secret};
7094 use crate::types::Message;
7095 use nostr_sdk::prelude::ToBech32;
7096 let (_tmp, _guard) = init_test_db();
7097 let relay = MemoryRelay::new();
7098 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7099 let cid = community.id.to_hex();
7100 let genesis_root = *community.server_root_key.as_bytes();
7101 let channel_hex = community.channels[0].id.to_hex();
7102
7103 let alice = Keys::generate();
7105 let alice_b32 = alice.public_key().to_bech32().unwrap();
7106 let mut m = Message::default();
7107 m.id = "aa".repeat(32);
7108 m.npub = Some(alice_b32.clone());
7109 m.at = 1000;
7110 crate::db::events::save_message(&channel_hex, &m).await.unwrap();
7111 assert!(
7112 crate::db::community::community_member_activity(&cid).unwrap().iter().any(|(np, _)| np == &alice_b32),
7113 "alice is an observed participant"
7114 );
7115
7116 let (token_hex, _) = create_public_invite(&relay, &community, None, None).await.unwrap();
7118 revoke_public_invite(&relay, &community, &crate::simd::hex::hex_to_bytes_32(&token_hex)).await.unwrap();
7119 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
7120 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(1), "privatize rotated the base");
7121
7122 let addr = base_rekey_pseudonym(&crate::community::ServerRootKey(genesis_root), &community.id, crate::community::Epoch(1)).to_hex();
7125 let found = relay
7126 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() }, &community.relays)
7127 .await
7128 .unwrap();
7129 assert_eq!(found.len(), 1, "the base rekey is published");
7130 let parsed = open_rekey_event(&found[0], &genesis_root).unwrap();
7131 let secret = rekey_pairwise_secret(alice.secret_key(), &parsed.rotator).unwrap();
7132 let loc = recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
7133 let alice_blob = parsed.blobs.iter().find(|b| b.locator == loc).expect("alice's blob present (NOT sealed out)");
7134 let recovered = open_rekey_blob(alice.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, alice_blob).unwrap();
7135 assert_eq!(reloaded.server_root_key.as_bytes(), &recovered, "alice recovers the new root = owner's advanced base");
7136 }
7137
7138 #[tokio::test]
7139 async fn unpermissioned_invite_links_edition_is_rejected() {
7140 let (_tmp, _guard) = init_test_db();
7144 let relay = MemoryRelay::new();
7145 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7146
7147 let mallory = Keys::generate();
7148 let loc = "2b".repeat(32);
7149 let inner = crate::community::roster::build_invite_links_edition(&mallory, &community.id, &[loc], 1, None, 1000, None).unwrap();
7150 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
7151 relay.inject(&outer, &community.relays);
7152
7153 let applied = fetch_and_apply_invite_links(&relay, &community).await.unwrap();
7154 assert!(applied.is_empty(), "an unpermissioned member's link edition is rejected");
7155 assert!(!is_public(&community).unwrap(), "mode stays Private despite the forged edition");
7156 }
7157
7158 #[tokio::test]
7159 async fn invite_links_union_across_authorized_creators() {
7160 let (_tmp, _guard) = init_test_db();
7164 let relay = MemoryRelay::new();
7165 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7166 let cid = community.id.to_hex();
7167
7168 create_public_invite(&relay, &community, None, None).await.unwrap();
7170 let owner_loc = public_invite::locator_hex(&crate::simd::hex::hex_to_bytes_32(
7171 &crate::db::community::list_public_invites(&cid).unwrap()[0].token));
7172
7173 let admin = Keys::generate();
7175 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
7176 set_member_grant(&relay, &community, &admin.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
7177 let admin_loc = "ab".repeat(32);
7178 let inner = crate::community::roster::build_invite_links_edition(&admin, &community.id, &[admin_loc.clone()], 1, None, 2000, None).unwrap();
7179 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
7180 relay.inject(&outer, &community.relays);
7181
7182 let agg = fetch_and_apply_invite_links(&relay, &community).await.unwrap();
7183 assert!(agg.contains(&owner_loc), "owner's link in the aggregate");
7184 assert!(agg.contains(&admin_loc), "the granted admin's link unions in too");
7185 assert!(is_public(&community).unwrap());
7186
7187 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
7191 let owner_token = crate::db::community::list_public_invites(&cid).unwrap()[0].token.clone();
7192 revoke_public_invite(&relay, &c, &crate::simd::hex::hex_to_bytes_32(&owner_token)).await.unwrap();
7193 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
7194 assert_eq!(c.server_root_epoch, crate::community::Epoch(0), "another creator's link remains → no privatize rekey");
7195 assert!(is_public(&c).unwrap(), "still Public (admin's link is live)");
7196 }
7197
7198 #[tokio::test]
7199 async fn invite_registry_retains_a_persisted_creator_on_a_partial_fold() {
7200 let (_tmp, _guard) = init_test_db();
7205 let relay = MemoryRelay::new();
7206 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7207 let cid = community.id.to_hex();
7208
7209 create_public_invite(&relay, &community, None, None).await.unwrap();
7210 let owner_loc = public_invite::locator_hex(&crate::simd::hex::hex_to_bytes_32(
7211 &crate::db::community::list_public_invites(&cid).unwrap()[0].token));
7212 let agg = fetch_and_apply_invite_links(&relay, &community).await.unwrap();
7213 assert!(agg.contains(&owner_loc), "the mint folds + persists normally");
7214
7215 let partial = MemoryRelay::new();
7217 let agg = fetch_and_apply_invite_links(&partial, &community).await.unwrap();
7218 assert!(agg.contains(&owner_loc), "an absent edition retains the persisted locators");
7219 assert!(is_public(&community).unwrap(), "mode survives the partial view");
7220 }
7221
7222 #[tokio::test]
7223 async fn invite_registry_drops_a_demoted_creator_whose_edition_is_present() {
7224 let (_tmp, _guard) = init_test_db();
7229 let relay = MemoryRelay::new();
7230 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7231 let cid = community.id.to_hex();
7232
7233 let admin = Keys::generate();
7234 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
7235 set_member_grant(&relay, &community, &admin.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
7236 let admin_loc = "ab".repeat(32);
7237 let inner = crate::community::roster::build_invite_links_edition(&admin, &community.id, &[admin_loc.clone()], 1, None, 2000, None).unwrap();
7238 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
7239 relay.inject(&outer, &community.relays);
7240 let agg = fetch_and_apply_invite_links(&relay, &community).await.unwrap();
7241 assert!(agg.contains(&admin_loc), "the granted admin's link folds + persists");
7242
7243 set_member_grant(&relay, &community, &admin.public_key().to_hex(), vec![]).await.unwrap();
7246 let agg = fetch_and_apply_invite_links(&relay, &community).await.unwrap();
7247 assert!(!agg.contains(&admin_loc), "a present-but-unauthorized edition drops the persisted row");
7248 assert!(!is_public(&community).unwrap(), "no live authorized link → Private");
7249 }
7250
7251 #[tokio::test]
7252 async fn failed_banlist_publish_does_not_persist_locally() {
7253 let (_tmp, _guard) = init_test_db();
7256 let relay = MemoryRelay::new();
7257 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7258 let id_hex = community.id.to_hex();
7259 assert!(crate::db::community::get_community_banlist(&id_hex).unwrap().is_empty());
7260
7261 let victim = "cc".repeat(32);
7262 let err = publish_banlist(&FailingRelay, &community, &[victim]).await;
7263 assert!(err.is_err(), "a failed publish must propagate");
7264 assert!(
7265 crate::db::community::get_community_banlist(&id_hex).unwrap().is_empty(),
7266 "local banlist must be untouched when the publish failed"
7267 );
7268 }
7269
7270 #[tokio::test]
7271 async fn metadata_failed_publish_does_not_persist_locally() {
7272 let (_tmp, _guard) = init_test_db();
7276 let relay = MemoryRelay::new();
7277 let mut community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7278 community.name = "Renamed HQ".to_string();
7279 assert!(republish_community_metadata(&FailingRelay, &community).await.is_err());
7280 let loaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
7281 assert_eq!(loaded.name, "HQ", "a failed metadata publish leaves the local name unchanged");
7282 }
7283
7284 #[tokio::test]
7285 async fn send_persists_key_then_delete_round_trip() {
7286 let (_tmp, _guard) = init_test_db();
7287 let relay = MemoryRelay::new();
7288 let community = Community::create("HQ", "general", vec!["r1".into()]);
7289 let channel = community.channels[0].clone();
7290 let alice = Keys::generate();
7291
7292 let _outer = send_message(&relay, &community, &channel, &alice, "deletable", 1).await.unwrap();
7294 let before = fetch_channel_messages(&relay, &community, &channel).await.unwrap();
7295 assert_eq!(before.len(), 1);
7296 let message_id = before[0].message_id.to_hex();
7297
7298 delete_message(&relay, &message_id).await.unwrap();
7300 let after = fetch_channel_messages(&relay, &community, &channel).await.unwrap();
7301 assert!(after.is_empty(), "message should be deleted after delete_message");
7302
7303 assert!(delete_message(&relay, &message_id).await.is_err());
7305 }
7306
7307 #[tokio::test]
7308 async fn failed_delete_publish_preserves_key() {
7309 let (_tmp, _guard) = init_test_db();
7312 let relay = MemoryRelay::new();
7313 let community = Community::create("HQ", "general", vec!["r1".into()]);
7314 let channel = community.channels[0].clone();
7315 let alice = Keys::generate();
7316 send_message(&relay, &community, &channel, &alice, "delete me", 1).await.unwrap();
7317 let message_id = fetch_channel_messages(&relay, &community, &channel).await.unwrap()[0]
7318 .message_id
7319 .to_hex();
7320
7321 assert!(delete_message(&FailingRelay, &message_id).await.is_err());
7323
7324 delete_message(&relay, &message_id).await.unwrap();
7326 assert!(fetch_channel_messages(&relay, &community, &channel).await.unwrap().is_empty());
7327 }
7328
7329 #[tokio::test]
7330 async fn delete_unknown_message_errors() {
7331 let (_tmp, _guard) = init_test_db();
7332 let relay = MemoryRelay::new();
7333 let fake = Keys::generate();
7335 let bogus = EventBuilder::new(Kind::Custom(1), "x").finalize(&fake).unwrap().id;
7336 assert!(delete_message(&relay, &bogus.to_hex()).await.is_err());
7337 }
7338
7339 #[tokio::test]
7340 async fn accept_invite_persists_member_view() {
7341 let (_tmp, _guard) = init_test_db();
7342 let owner = Community::create("HQ", "general", vec!["r1".into()]);
7343 let invite = crate::community::invite::build_invite(&owner);
7344
7345 let joined = accept_invite(&invite).expect("accept");
7346 assert!(!is_proven_owner(&joined), "joined as member, not owner");
7347 let loaded = crate::db::community::load_community(&owner.id).unwrap().expect("saved");
7349 assert_eq!(loaded.channels[0].key.as_bytes(), owner.channels[0].key.as_bytes());
7350 }
7351
7352 #[tokio::test]
7353 async fn accept_invite_does_not_downgrade_owned_community() {
7354 let (_tmp, _guard) = init_test_db();
7357 let relay = MemoryRelay::new();
7358 let owner = create_community(&relay, "HQ", "general", vec![]).await.unwrap();
7359 assert!(is_proven_owner(&owner), "we are the proven owner");
7360
7361 let invite = crate::community::invite::build_invite(&owner);
7362 let err = accept_invite(&invite).unwrap_err();
7363 assert!(err.contains("already own"), "must refuse to downgrade an owned community, got: {err}");
7364
7365 let reloaded = crate::db::community::load_community(&owner.id).unwrap().unwrap();
7367 assert_eq!(reloaded.server_root_key.as_bytes(), owner.server_root_key.as_bytes());
7368 }
7369
7370 #[tokio::test]
7375 async fn stale_v1_invite_cannot_reparent_a_migrated_communitys_channels() {
7376 let (_tmp, _guard) = init_test_db();
7377 let v1 = Community::create("Guild", "general", vec!["wss://r1".into()]);
7379 let stale_invite = crate::community::invite::build_invite(&v1);
7380 accept_invite(&stale_invite).expect("initial join");
7381 let v1_cid = v1.id.to_hex();
7382 let channel_hex = v1.channels[0].id.to_hex();
7383 assert_eq!(
7384 crate::db::community::community_id_for_channel(&channel_hex).unwrap().as_deref(),
7385 Some(v1_cid.as_str()),
7386 "precondition: the channel row starts parented to v1"
7387 );
7388
7389 let v2_cid = "9f".repeat(32);
7391 crate::db::community::reparent_channels_and_fence(&v1_cid, &v2_cid).unwrap();
7392 assert_eq!(
7393 crate::db::community::community_id_for_channel(&channel_hex).unwrap().as_deref(),
7394 Some(v2_cid.as_str()),
7395 "precondition: the flip moved the channel to the twin"
7396 );
7397
7398 let err = accept_invite(&stale_invite).unwrap_err();
7400 assert!(
7401 err.contains("upgraded to Concord v2"),
7402 "a migrated community must refuse a v1 re-accept, got: {err}"
7403 );
7404
7405 assert_eq!(
7407 crate::db::community::community_id_for_channel(&channel_hex).unwrap().as_deref(),
7408 Some(v2_cid.as_str()),
7409 "the refused accept must not have re-parented the channel back to v1"
7410 );
7411 assert_eq!(
7413 crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(),
7414 Some(v2_cid.as_str())
7415 );
7416 }
7417
7418 #[tokio::test]
7422 async fn accept_invite_still_works_for_a_live_v1_community() {
7423 let (_tmp, _guard) = init_test_db();
7424 let v1 = Community::create("Guild", "general", vec!["wss://r1".into()]);
7425 let invite = crate::community::invite::build_invite(&v1);
7426 accept_invite(&invite).expect("initial join");
7427 accept_invite(&invite).expect("re-accept on a live v1 community must still work");
7429 assert!(crate::db::community::get_migrated_to(&v1.id.to_hex()).unwrap().is_none());
7430 }
7431
7432 #[tokio::test]
7437 async fn a_fresh_join_is_never_gated_by_another_communitys_fence() {
7438 let (_tmp, _guard) = init_test_db();
7439 let migrated = Community::create("Old", "general", vec!["wss://r1".into()]);
7441 accept_invite(&crate::community::invite::build_invite(&migrated)).unwrap();
7442 crate::db::community::reparent_channels_and_fence(&migrated.id.to_hex(), &"9f".repeat(32)).unwrap();
7443
7444 let fresh = Community::create("New", "general", vec!["wss://r2".into()]);
7446 accept_invite(&crate::community::invite::build_invite(&fresh)).expect("fresh join must not be gated");
7447 assert!(crate::db::community::load_community(&fresh.id).unwrap().is_some());
7448 }
7449
7450 #[tokio::test]
7455 async fn a_fresh_v1_join_past_the_timelock_needs_a_migration_carrier() {
7456 let (_tmp, _guard) = init_test_db();
7457 let relay = MemoryRelay::new();
7458 let unlock = crate::community::migration::MIGRATION_UNLOCK_AT;
7459
7460 let owner_keys = Keys::generate();
7461 become_local(&owner_keys);
7462 let owned = attested_community("Legacy", "general", vec!["wss://r1".into()]);
7463 let invite = crate::community::invite::build_invite(&owned);
7464 let member_view = crate::community::invite::accept_invite(&invite).expect("decode");
7465
7466 crate::community::migration::gate_fresh_v1_join(&relay, &member_view, unlock - 1)
7467 .await
7468 .expect("pre-unlock fresh join passes without a probe");
7469
7470 let err = crate::community::migration::gate_fresh_v1_join(&relay, &member_view, unlock)
7471 .await
7472 .unwrap_err();
7473 assert!(err.contains("legacy protocol"), "a live v1 community refuses post-unlock, got: {err}");
7474
7475 let sp = crate::community::migration::MigrationSignpost {
7477 v2_community_id: "ab".repeat(32),
7478 owner_xonly: owner_keys.public_key().to_hex(),
7479 owner_salt: "cd".repeat(32),
7480 relays: vec!["wss://r1".into()],
7481 name: "Legacy".into(),
7482 primary_channel: owned.channels[0].id.to_hex(),
7483 root_epoch: 0,
7484 };
7485 let content = crate::community::migration::build_migration_content(&sp, None).unwrap();
7486 publish_migration_carrier(&relay, &owned, &content).await.expect("carrier lands");
7487 crate::community::migration::gate_fresh_v1_join(&relay, &member_view, unlock)
7488 .await
7489 .expect("a carrier-bearing community stays joinable (v2 on-ramp)");
7490
7491 crate::db::community::save_community(&member_view).unwrap();
7493 crate::community::migration::gate_fresh_v1_join(&relay, &member_view, unlock)
7494 .await
7495 .expect("a held community passes post-unlock");
7496 }
7497
7498 #[tokio::test]
7501 async fn metadata_republish_refuses_after_migration() {
7502 let (_tmp, _guard) = init_test_db();
7503 let owner = Keys::generate();
7504 become_local(&owner);
7505 let community = saved_community_owned_by(&owner);
7506 let cid = community.id.to_hex();
7507 let channel_id = community.channels[0].id;
7508 let relay = MemoryRelay::new();
7509
7510 crate::db::community::reparent_channels_and_fence(&cid, &"9f".repeat(32)).unwrap();
7511
7512 let err = republish_community_metadata(&relay, &community).await.unwrap_err();
7513 assert!(err.contains("upgraded to Concord v2"), "community metadata edit gated, got: {err}");
7514 let err = republish_channel_metadata(&relay, &community, &channel_id, "renamed").await.unwrap_err();
7515 assert!(err.contains("upgraded to Concord v2"), "channel rename gated, got: {err}");
7516 assert!(
7519 crate::db::community::get_edition_head(&cid, &cid).unwrap().is_none(),
7520 "no community edition was published"
7521 );
7522 assert!(
7523 crate::db::community::get_edition_head(&cid, &channel_id.to_hex()).unwrap().is_none(),
7524 "no channel edition was published"
7525 );
7526 }
7527
7528 #[tokio::test]
7529 async fn accept_invite_rejects_id_collision_under_different_authority() {
7530 let (_tmp, _guard) = init_test_db();
7535 let legit = Community::create("X", "general", vec!["wss://legit".into()]);
7536 let member_x = accept_invite(&crate::community::invite::build_invite(&legit)).unwrap();
7537 let original_key = member_x.channels[0].key.as_bytes().to_vec();
7538
7539 let attacker = Community::create("evil", "general", vec!["wss://evil".into()]);
7541 let mut hostile = crate::community::invite::build_invite(&attacker);
7542 hostile.community_id = legit.id.to_hex();
7543 assert_ne!(hostile.server_root_key, crate::simd::hex::bytes_to_hex_32(member_x.server_root_key.as_bytes()));
7546
7547 assert!(accept_invite(&hostile).is_err(), "id-collision under new authority must be rejected");
7548
7549 let reloaded = crate::db::community::load_community(&legit.id).unwrap().unwrap();
7551 assert_eq!(reloaded.channels[0].key.as_bytes().to_vec(), original_key);
7552 assert_eq!(reloaded.relays, vec!["wss://legit".to_string()]);
7553 }
7554
7555 #[tokio::test]
7556 async fn rejected_accept_leaves_pending_invite_intact() {
7557 let (_tmp, _guard) = init_test_db();
7560
7561 let owner = attested_community("HQ", "general", vec![]);
7563 crate::db::community::save_community(&owner).unwrap();
7564 let bundle = crate::community::invite::build_invite(&owner).to_json().unwrap();
7565 let cid = owner.id.to_hex();
7566 crate::db::community::save_pending_invite(&cid, &bundle, "npub1inviter", 0).unwrap();
7567
7568 let peeked = crate::db::community::get_pending_invite(&cid).unwrap().expect("parked");
7570 let invite = crate::community::invite::CommunityInvite::from_json(&peeked).unwrap();
7571 assert!(accept_invite(&invite).is_err(), "owning the id → reject");
7572 assert!(
7573 crate::db::community::pending_invite_exists(&cid).unwrap(),
7574 "rejected accept must leave the invite parked"
7575 );
7576
7577 let other = Community::create("Other", "general", vec![]);
7579 let ob = crate::community::invite::build_invite(&other).to_json().unwrap();
7580 let ocid = other.id.to_hex();
7581 crate::db::community::save_pending_invite(&ocid, &ob, "npub1inviter", 0).unwrap();
7582 let op = crate::db::community::get_pending_invite(&ocid).unwrap().unwrap();
7583 let oinvite = crate::community::invite::CommunityInvite::from_json(&op).unwrap();
7584 accept_invite(&oinvite).expect("accept ok");
7585 crate::db::community::delete_pending_invite(&ocid).unwrap();
7586 assert!(!crate::db::community::pending_invite_exists(&ocid).unwrap(), "cleared on success");
7587 }
7588
7589 #[tokio::test]
7590 async fn public_invite_create_fetch_accept_revoke_round_trip() {
7591 let (_tmp, _guard) = init_test_db();
7592 let relay = MemoryRelay::new();
7593 let mut owner = Community::create("Public HQ", "general", vec!["r1".into(), "r2".into()]);
7594 owner.description = Some("everyone welcome".into());
7595 let owner_keys = crate::state::MY_SECRET_KEY.to_keys().unwrap();
7599 owner.owner_attestation = Some(
7600 crate::community::owner::build_owner_attestation_unsigned(owner_keys.public_key(), &owner.id.to_hex())
7601 .finalize(&owner_keys).unwrap().as_json(),
7602 );
7603 let (token_hex, url) = create_public_invite(&relay, &owner, None, None).await.expect("mint");
7605 assert!(url.contains('#'));
7606 assert_eq!(crate::db::community::list_public_invites(&owner.id.to_hex()).unwrap().len(), 1);
7607
7608 let (relays, token) = public_invite::parse_invite_url(&url).unwrap();
7610 assert_eq!(crate::simd::hex::bytes_to_hex_32(&token), token_hex);
7611 let bundle = fetch_public_invite(&relay, &relays, &token).await.expect("fetch");
7612 assert_eq!(bundle.preview.name, "Public HQ");
7613 assert_eq!(bundle.preview.description.as_deref(), Some("everyone welcome"));
7614
7615 let joined = accept_public_invite(&bundle, 0).expect("accept");
7616 assert_eq!(joined.id, owner.id);
7617 assert_eq!(joined.description.as_deref(), Some("everyone welcome"), "preview patched in");
7618
7619 revoke_public_invite(&relay, &owner, &token).await.expect("revoke");
7621 assert!(fetch_public_invite(&relay, &relays, &token).await.is_err(), "revoked link is dead");
7622 assert!(crate::db::community::list_public_invites(&owner.id.to_hex()).unwrap().is_empty());
7623 }
7624
7625 #[tokio::test]
7626 async fn revoked_invite_dies_even_if_one_relay_kept_the_bundle() {
7627 let (_tmp, _guard) = init_test_db();
7631 let relay = MemoryRelay::new();
7632 let owner = attested_community("HQ", "general", vec!["r1".into(), "r2".into()]);
7633 let (_token_hex, url) = create_public_invite(&relay, &owner, None, None).await.unwrap();
7634 let (relays, token) = public_invite::parse_invite_url(&url).unwrap();
7635 assert!(fetch_public_invite(&relay, &relays, &token).await.is_ok(), "live on both relays");
7636
7637 let tombstone = public_invite::build_public_invite_tombstone(&token).unwrap();
7639 relay.inject(&tombstone, &["r1".to_string()]);
7640
7641 assert!(
7642 fetch_public_invite(&relay, &relays, &token).await.is_err(),
7643 "a tombstone on any one relay kills the link, even with a stale live bundle elsewhere",
7644 );
7645 }
7646
7647 #[tokio::test]
7648 async fn fetch_skips_relay_shadow_junk_to_genuine_bundle() {
7649 use nostr_sdk::prelude::{EventBuilder, Keys, Kind, Tag, Timestamp};
7653
7654 let (_tmp, _guard) = init_test_db();
7655 let relay = MemoryRelay::new();
7656 let owner = attested_community("HQ", "general", vec!["r1".into()]);
7657 let (_t, url) = create_public_invite(&relay, &owner, None, None).await.unwrap();
7658 let (relays, token) = public_invite::parse_invite_url(&url).unwrap();
7659
7660 let attacker = Keys::generate();
7663 let junk = EventBuilder::new(Kind::Custom(event_kind::APPLICATION_SPECIFIC), "garbage")
7664 .tags([
7665 Tag::identifier(public_invite::locator_hex(&token)),
7666 Tag::custom("vsk", ["6".to_string()]),
7667 Tag::custom("v", ["1".to_string()]),
7668 ])
7669 .custom_created_at(Timestamp::from_secs(9_000_000_000))
7670 .finalize(&attacker)
7671 .unwrap();
7672 relay.publish(&junk, &relays).await.unwrap();
7673
7674 let bundle = fetch_public_invite(&relay, &relays, &token).await.expect("genuine survives shadow");
7676 assert_eq!(bundle.preview.name, "HQ");
7677 }
7678
7679 #[tokio::test]
7680 async fn expired_public_invite_is_refused() {
7681 let (_tmp, _guard) = init_test_db();
7682 let relay = MemoryRelay::new();
7683 let owner = attested_community("HQ", "general", vec!["r1".into()]);
7684 let (_t, url) = create_public_invite(&relay, &owner, Some(1000), None).await.unwrap();
7685 let (relays, token) = public_invite::parse_invite_url(&url).unwrap();
7686 let bundle = fetch_public_invite(&relay, &relays, &token).await.unwrap();
7687 assert!(accept_public_invite(&bundle, 2000).is_err());
7689 assert!(crate::db::community::load_community(&owner.id).unwrap().is_none());
7690 }
7691
7692 #[tokio::test]
7693 async fn republish_metadata_saves_and_publishes() {
7694 use crate::community::CommunityImage;
7695 let (_tmp, _guard) = init_test_db();
7696 let relay = MemoryRelay::new();
7697 let mut owner = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7700 let cid = owner.id.to_hex();
7701
7702 owner.name = "HQ Renamed".into();
7704 owner.description = Some("now with topic".into());
7705 owner.icon = Some(CommunityImage {
7706 url: "https://b/x".into(), key: "aa".repeat(32), nonce: "bb".repeat(12),
7707 hash: "cc".repeat(32), ext: "png".into(),
7708 });
7709 republish_community_metadata(&relay, &owner).await.expect("republish");
7710
7711 let loaded = crate::db::community::load_community(&owner.id).unwrap().unwrap();
7713 assert_eq!(loaded.name, "HQ Renamed");
7714 assert_eq!(loaded.description.as_deref(), Some("now with topic"));
7715 assert_eq!(loaded.icon.unwrap().url, "https://b/x");
7716
7717 let (head_v, _) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
7720 assert_eq!(head_v, 2, "GroupRoot edition advanced v1 (create) → v2 (republish)");
7721 let z = crate::community::roster::control_pseudonym(&owner.server_root_key, &owner.id, crate::community::Epoch(0));
7722 let control = relay
7723 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], ..Default::default() }, &owner.relays)
7724 .await
7725 .unwrap();
7726 let newest = control
7727 .iter()
7728 .filter_map(|o| crate::community::roster::open_control_edition(o, &owner.server_root_key).ok())
7729 .filter_map(|i| crate::community::edition::parse_edition_inner(&i).ok())
7730 .filter(|p| p.entity_id == owner.id.0)
7731 .max_by_key(|p| p.version)
7732 .expect("GroupRoot edition on the relay");
7733 let meta: crate::community::metadata::CommunityMetadata = serde_json::from_str(&newest.content).unwrap();
7734 assert_eq!(meta.name, "HQ Renamed");
7735 assert_eq!(meta.icon.unwrap().ext, "png");
7736 }
7737
7738 #[tokio::test]
7739 async fn member_cannot_republish_metadata() {
7740 let (_tmp, _guard) = init_test_db();
7741 let relay = MemoryRelay::new();
7742 let owner = Community::create("HQ", "general", vec!["r1".into()]);
7743 let member = crate::community::invite::accept_invite(&crate::community::invite::build_invite(&owner)).unwrap();
7744 assert!(republish_community_metadata(&relay, &member).await.is_err());
7745 }
7746
7747 #[tokio::test]
7748 async fn member_cannot_mint_public_invite() {
7749 let (_tmp, _guard) = init_test_db();
7750 let relay = MemoryRelay::new();
7751 let owner = Community::create("HQ", "general", vec!["r1".into()]);
7752 let member = crate::community::invite::accept_invite(&crate::community::invite::build_invite(&owner)).unwrap();
7753 assert!(create_public_invite(&relay, &member, None, None).await.is_err(), "members can't mint links");
7754 }
7755
7756 #[tokio::test]
7757 async fn accept_oversized_bundle_rejected() {
7758 let (_tmp, _guard) = init_test_db();
7759 let owner = Community::create("HQ", "general", vec![]);
7760 let mut invite = crate::community::invite::build_invite(&owner);
7761 let template = invite.channels[0].clone();
7763 for _ in 0..300 {
7764 invite.channels.push(template.clone());
7765 }
7766 assert!(accept_invite(&invite).is_err(), "oversized bundle must be rejected");
7767 assert!(crate::db::community::load_community(&owner.id).unwrap().is_none(), "nothing persisted");
7768 }
7769
7770 async fn publish_tombstone<T: Transport + ?Sized>(transport: &T, community: &Community, author: &Keys, created_at: u64) {
7776 let inner = crate::community::roster::build_group_dissolved_edition_unsigned(author.public_key(), &community.id, created_at)
7777 .finalize(author).unwrap();
7778 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch).unwrap();
7779 transport.publish_durable(&outer, &community.relays).await.unwrap();
7780 }
7781
7782 struct RekeyCountingRelay {
7785 inner: MemoryRelay,
7786 rekeys: std::sync::atomic::AtomicUsize,
7787 }
7788 impl RekeyCountingRelay {
7789 fn new() -> Self { Self { inner: MemoryRelay::new(), rekeys: std::sync::atomic::AtomicUsize::new(0) } }
7790 fn count(&self, e: &Event) {
7791 if e.kind.as_u16() == event_kind::COMMUNITY_REKEY {
7792 self.rekeys.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
7793 }
7794 }
7795 }
7796 #[async_trait::async_trait]
7797 impl Transport for RekeyCountingRelay {
7798 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
7799 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> { self.count(e); self.inner.publish(e, r).await }
7800 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> { self.count(e); self.inner.publish_durable(e, r).await }
7801 async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> { self.inner.fetch(q, r).await }
7802 }
7803
7804 #[tokio::test]
7805 async fn owner_tombstone_folds_to_dissolved() {
7806 let (_tmp, _guard) = init_test_db();
7807 let relay = MemoryRelay::new();
7808 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7810 let cid = community.id.to_hex();
7811 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
7812 publish_tombstone(&relay, &community, &owner, 1000).await;
7813
7814 assert!(!crate::db::community::get_community_dissolved(&cid).unwrap(), "alive before the fold");
7815 fetch_and_apply_control(&relay, &community).await.unwrap();
7816 assert!(crate::db::community::get_community_dissolved(&cid).unwrap(), "owner tombstone seals the community");
7817 }
7818
7819 #[tokio::test]
7820 async fn non_owner_tombstone_is_ignored() {
7821 let (_tmp, _guard) = init_test_db();
7822 let relay = MemoryRelay::new();
7823 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7824 let cid = community.id.to_hex();
7825 let mallory = Keys::generate();
7828 publish_tombstone(&relay, &community, &mallory, 1000).await;
7829
7830 fetch_and_apply_control(&relay, &community).await.unwrap();
7831 assert!(!crate::db::community::get_community_dissolved(&cid).unwrap(), "a non-owner tombstone is ignored");
7832 }
7833
7834 #[tokio::test]
7835 async fn unreadable_deed_rejects_the_tombstone() {
7836 let (_tmp, _guard) = init_test_db();
7837 let relay = MemoryRelay::new();
7838 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
7839 let mut community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7840 let cid = community.id.to_hex();
7841 publish_tombstone(&relay, &community, &owner, 1000).await;
7842 community.owner_attestation = None;
7844 crate::db::community::save_community(&community).unwrap();
7845 let stripped = crate::db::community::load_community(&community.id).unwrap().unwrap();
7846
7847 fetch_and_apply_control(&relay, &stripped).await.unwrap();
7848 assert!(!crate::db::community::get_community_dissolved(&cid).unwrap(), "unverifiable tombstone is rejected, not death-by-default");
7849 }
7850
7851 #[tokio::test]
7852 async fn binary_seal_drops_every_subsequent_event_with_no_timestamp_test() {
7853 let (_tmp, _guard) = init_test_db();
7854 let relay = MemoryRelay::new();
7855 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
7856 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7857 let cid = community.id.to_hex();
7858 publish_tombstone(&relay, &community, &owner, 1000).await;
7859 fetch_and_apply_control(&relay, &community).await.unwrap();
7860 assert!(crate::db::community::get_community_dissolved(&cid).unwrap());
7861
7862 let sealed = crate::db::community::load_community(&community.id).unwrap().unwrap();
7864 let channel = sealed.channels[0].clone();
7865 let me = owner.public_key();
7866
7867 let backdated = super::super::envelope::seal_message(
7869 &Keys::generate(), &channel.key, &channel.id, channel.epoch, "ghost", 1,
7870 ).unwrap();
7871 let mut state = crate::state::ChatState::new();
7872 assert!(super::super::inbound::process_incoming(&mut state, &backdated, &channel, &me).is_none(),
7873 "a backdated message after the seal is dropped (binary seal, no timestamp test)");
7874
7875 publish_tombstone(&relay, &sealed, &owner, 2000).await;
7877 assert_eq!(fetch_and_apply_control(&relay, &sealed).await.unwrap(), 0,
7878 "control fold stops advancing once sealed");
7879 }
7880
7881 #[tokio::test]
7882 async fn dissolve_community_emits_no_rekey_and_no_epoch_bump() {
7883 let (_tmp, _guard) = init_test_db();
7884 let relay = RekeyCountingRelay::new();
7885 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7886 let cid = community.id.to_hex();
7887 create_public_invite(&relay, &community, None, None).await.unwrap();
7889 let before_epoch = crate::db::community::load_community(&community.id).unwrap().unwrap().server_root_epoch;
7890
7891 dissolve_community(&relay, &community).await.unwrap();
7892
7893 assert!(crate::db::community::get_community_dissolved(&cid).unwrap(), "sealed locally");
7894 assert_eq!(relay.rekeys.load(std::sync::atomic::Ordering::Relaxed), 0,
7895 "dissolution publishes NO 3303 rekey (no last-link privatize re-founding)");
7896 assert_eq!(crate::db::community::load_community(&community.id).unwrap().unwrap().server_root_epoch, before_epoch,
7897 "base epoch unchanged — dissolution rotates nothing");
7898 }
7899
7900 #[tokio::test]
7904 async fn migration_carrier_tombstone_seals_and_persists_the_pointer() {
7905 let (_tmp, _guard) = init_test_db();
7906 let relay = MemoryRelay::new();
7907 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
7908 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7909 let cid = community.id.to_hex();
7910
7911 let signpost = crate::community::migration::MigrationSignpost {
7913 v2_community_id: "ab".repeat(32),
7914 owner_xonly: owner.public_key().to_hex(),
7915 owner_salt: "cd".repeat(32),
7916 relays: vec!["r1".into()],
7917 name: "HQ".into(),
7918 primary_channel: community.channels[0].id.to_hex(),
7919 root_epoch: 0,
7920 };
7921 let m = crate::community::migration::seal_m(community.server_root_key.as_bytes(), b"jm").unwrap();
7922 let content = crate::community::migration::build_migration_content(&signpost, Some(m)).unwrap();
7923 let inner = crate::community::roster::build_group_dissolved_edition_with_content(&owner, &community.id, 1000, &content).unwrap();
7924 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch).unwrap();
7925 relay.publish_durable(&outer, &community.relays).await.unwrap();
7926
7927 fetch_and_apply_control(&relay, &community).await.unwrap();
7928
7929 assert!(crate::db::community::get_community_dissolved(&cid).unwrap(), "carrier still seals");
7930 let stored = crate::db::community::get_migration_pointer(&cid).unwrap().expect("pointer persisted");
7931 let parsed = crate::community::migration::parse_migration_payload(&stored).unwrap();
7932 assert_eq!(parsed.signpost.v2_community_id, "ab".repeat(32));
7933 assert!(parsed.m.is_some(), "the sealed key material rode along");
7934 }
7935
7936 #[test]
7940 fn migration_exemption_gates_the_dissolved_base_rekey() {
7941 let (_tmp, _guard) = init_test_db();
7942 let owner = Keys::generate();
7943 let me = Keys::generate();
7944 become_local(&me);
7945 let community = saved_community_owned_by(&owner);
7946 let cid = community.id.to_hex();
7947 crate::db::community::set_community_dissolved(&cid).unwrap();
7948
7949 let parsed = owner_base_rekey(&owner, &community, &me.public_key(), 1, &[0xCDu8; 32]);
7950 assert!(apply_server_root_rekey(&community, &parsed).is_err());
7952
7953 let signpost = crate::community::migration::MigrationSignpost {
7955 v2_community_id: "ab".repeat(32), owner_xonly: owner.public_key().to_hex(),
7956 owner_salt: "cd".repeat(32), relays: vec![], name: "x".into(),
7957 primary_channel: "ef".repeat(32), root_epoch: 5,
7958 };
7959 let content = crate::community::migration::build_migration_content(&signpost, Some("bTE=".into())).unwrap();
7960 crate::db::community::set_migration_pointer(&cid, &content).unwrap();
7961 assert!(crate::community::migration::catchup_exempt(&cid, 1), "epoch 1 <= publish epoch 5 → exempt");
7962 assert!(!crate::community::migration::catchup_exempt(&cid, 6), "beyond the publish epoch → not exempt");
7963
7964 crate::db::community::set_migrated_to(&cid, &"ab".repeat(32)).unwrap();
7966 assert!(!crate::community::migration::catchup_exempt(&cid, 1), "flipped → fence stands");
7967 }
7968
7969 #[tokio::test]
7970 async fn duplicate_owner_tombstones_are_idempotent() {
7971 let (_tmp, _guard) = init_test_db();
7972 let relay = MemoryRelay::new();
7973 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
7974 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7975 let cid = community.id.to_hex();
7976 publish_tombstone(&relay, &community, &owner, 1000).await;
7978 publish_tombstone(&relay, &community, &owner, 2000).await;
7979
7980 fetch_and_apply_control(&relay, &community).await.unwrap();
7981 assert!(crate::db::community::get_community_dissolved(&cid).unwrap(), "duplicates still just dissolve, no error");
7982 assert_eq!(fetch_and_apply_control(&relay, &community).await.unwrap(), 0);
7984 }
7985
7986 #[test]
7987 fn apply_server_root_rekey_refuses_once_dissolved() {
7988 let (_tmp, _guard) = init_test_db();
7989 let owner = Keys::generate();
7990 let me = Keys::generate();
7991 become_local(&me);
7992 let community = saved_community_owned_by(&owner);
7993 let cid = community.id.to_hex();
7994 crate::db::community::set_community_dissolved(&cid).unwrap();
7995
7996 let parsed = owner_base_rekey(&owner, &community, &me.public_key(), 1, &[0xCDu8; 32]);
7997 assert!(apply_server_root_rekey(&community, &parsed).is_err(),
7998 "a base rekey cannot cross a tombstone");
7999 assert_eq!(crate::db::community::load_community(&community.id).unwrap().unwrap().server_root_epoch,
8000 crate::community::Epoch(0), "base epoch did not advance");
8001 }
8002
8003 #[tokio::test]
8004 async fn tombstone_detected_after_a_base_rotation() {
8005 let (_tmp, _guard) = init_test_db();
8006 let relay = MemoryRelay::new();
8007 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
8008 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
8009 let cid = community.id.to_hex();
8010 rotate_server_root(&relay, &community, &[owner.public_key()]).await.unwrap();
8013 let rotated = crate::db::community::load_community(&community.id).unwrap().unwrap();
8014 assert_eq!(rotated.server_root_epoch, crate::community::Epoch(1));
8015 publish_tombstone(&relay, &rotated, &owner, 1000).await;
8016
8017 fetch_and_apply_control(&relay, &rotated).await.unwrap();
8018 assert!(crate::db::community::get_community_dissolved(&cid).unwrap(),
8019 "tombstone at the rotation-stable locator is detected post-rotation");
8020 }
8021
8022 #[tokio::test]
8023 async fn stable_coordinate_tombstone_survives_a_concurrent_rotation() {
8024 let (_tmp, _guard) = init_test_db();
8029 let relay = MemoryRelay::new();
8030 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
8031 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
8032 let cid = community.id.to_hex();
8033 let inner = crate::community::roster::build_group_dissolved_edition_unsigned(owner.public_key(), &community.id, 1000)
8035 .finalize(&owner).unwrap();
8036 let stable = crate::community::roster::seal_dissolved_edition(&Keys::generate(), &inner, &community.id).unwrap();
8037 relay.inject(&stable, &community.relays);
8038 rotate_server_root(&relay, &community, &[owner.public_key()]).await.unwrap();
8041 let rotated = crate::db::community::load_community(&community.id).unwrap().unwrap();
8042 assert_eq!(rotated.server_root_epoch, crate::community::Epoch(1));
8043 assert!(!crate::db::community::get_community_dissolved(&cid).unwrap(), "not folded yet");
8044 fetch_and_apply_control(&relay, &rotated).await.unwrap();
8047 assert!(crate::db::community::get_community_dissolved(&cid).unwrap(),
8048 "stable-coordinate probe discovers the tombstone cross-epoch (C3 closed)");
8049 }
8050}