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 = web_time::SystemTime::now()
96 .duration_since(web_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 = web_time::SystemTime::now()
219 .duration_since(web_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 = web_time::SystemTime::now()
271 .duration_since(web_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 = web_time::SystemTime::now()
296 .duration_since(web_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 = web_time::SystemTime::now()
328 .duration_since(web_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: web_time::SystemTime::now()
350 .duration_since(web_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 = web_time::SystemTime::now()
429 .duration_since(web_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 = web_time::SystemTime::now()
506 .duration_since(web_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 = web_time::SystemTime::now()
859 .duration_since(web_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 = web_time::SystemTime::now()
1262 .duration_since(web_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 crate::rt::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 = web_time::SystemTime::now()
1432 .duration_since(web_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 = web_time::SystemTime::now()
1488 .duration_since(web_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: web_time::SystemTime::now()
1590 .duration_since(web_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.is_none_or(|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 = web_time::SystemTime::now().duration_since(web_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.is_none_or(|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 = web_time::SystemTime::now()
1820 .duration_since(web_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 = web_time::SystemTime::now()
1854 .duration_since(web_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 = web_time::SystemTime::now()
1919 .duration_since(web_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 type HeadUpdate = (String, u64, [u8; 32], [u8; 32], bool);
2063 let mut head_updates: Vec<HeadUpdate> = Vec::new();
2064
2065 let decide = |entity_hex: &str, head: &super::roster::EntityHead| -> Result<Option<bool>, String> {
2072 let held = crate::db::community::get_edition_head(&cid, entity_hex)?;
2073 let held_v = held.map(|(v, _)| v).unwrap_or(0);
2074 if head.version > held_v {
2075 return Ok(Some(false)); }
2077 if head.version == held_v && held.map(|(_, h)| h) != Some(head.self_hash) {
2078 let held_id = crate::db::community::get_edition_head_inner_id(&cid, entity_hex)?;
2079 if held_id.is_none() || Some(head.inner_id) < held_id {
2080 return Ok(Some(true)); }
2082 }
2083 Ok(None)
2084 };
2085
2086 if let Some(c) = folded.root_candidates.iter()
2091 .find(|c| authorized.is_authorized(&c.author.to_hex(), owner.as_deref(), manage))
2092 {
2093 let head = &c.head;
2094 if let Some(is_converge) = decide(&head.entity_hex, head)? {
2095 let meta = &c.meta;
2096 current.name = meta.name.clone();
2105 current.description = meta.description.clone();
2106 current.icon = meta.icon.clone();
2107 current.banner = meta.banner.clone();
2108 dirty = true;
2109 head_updates.push((head.entity_hex.clone(), head.version, head.self_hash, head.inner_id, is_converge));
2110 }
2111 }
2112 let mut resolved_channels: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
2118 for cm in &folded.channel_candidates {
2119 if resolved_channels.contains(&cm.channel_id) {
2120 continue; }
2122 if !authorized.is_authorized(&cm.author.to_hex(), owner.as_deref(), manage_channels) {
2123 continue; }
2125 resolved_channels.insert(cm.channel_id);
2126 let Some(is_converge) = decide(&cm.head.entity_hex, &cm.head)? else { continue };
2127 if let Some(ch) = current.channels.iter_mut().find(|c| c.id.0 == cm.channel_id) {
2128 ch.name = cm.meta.name.clone();
2129 dirty = true;
2130 head_updates.push((cm.head.entity_hex.clone(), cm.head.version, cm.head.self_hash, cm.head.inner_id, is_converge));
2131 }
2132 }
2133
2134 if dirty {
2135 crate::db::community::save_community(¤t)?;
2136 for (entity_hex, version, self_hash, inner_id, is_converge) in &head_updates {
2139 if *is_converge {
2140 crate::db::community::converge_edition_head(&cid, entity_hex, *version, self_hash, inner_id)?;
2141 } else {
2142 crate::db::community::set_edition_head_with_id(&cid, entity_hex, *version, self_hash, inner_id)?;
2143 }
2144 }
2145 }
2146 Ok(())
2147 })
2148 .await
2149}
2150
2151pub fn is_public(community: &Community) -> Result<bool, String> {
2159 Ok(!crate::db::community::get_community_invite_registry(&community.id.to_hex())?.is_empty())
2160}
2161
2162async fn republish_my_invite_links<T: Transport + ?Sized>(
2167 transport: &T,
2168 community: &Community,
2169) -> Result<Vec<String>, String> {
2170 let cid = community.id.to_hex();
2171 let now = web_time::SystemTime::now()
2172 .duration_since(web_time::UNIX_EPOCH)
2173 .map(|d| d.as_secs())
2174 .unwrap_or(0);
2175 let locators: Vec<String> = crate::db::community::list_public_invites(&cid)?
2176 .iter()
2177 .filter(|r| r.expires_at.is_none_or(|e| (e as u64) > now))
2178 .map(|r| public_invite::locator_hex(&crate::simd::hex::hex_to_bytes_32(&r.token)))
2179 .collect();
2180 publish_my_invite_links(transport, community, &locators).await?;
2181 Ok(locators)
2182}
2183
2184async fn observe_channel_activity<T: Transport + ?Sized>(
2190 transport: &T,
2191 community: &Community,
2192) -> Result<(), String> {
2193 crate::db::scoped(async move {
2194 let session = crate::db::current_session();
2195 let my_pk = crate::state::my_public_key().ok_or("no local identity to observe channel activity")?;
2196 for channel in &community.channels {
2197 let events = super::send::fetch_channel_events(transport, community, channel)
2198 .await
2199 .unwrap_or_default();
2200 let outcomes = {
2201 let mut st = crate::state::STATE.lock().await;
2202 super::inbound::process_channel_batch(&mut st, &events, channel, &my_pk)
2203 };
2204 let ch_hex = channel.id.to_hex();
2205 let mut pending: Vec<&crate::types::Message> = Vec::new();
2208 for o in &outcomes {
2209 match o {
2210 super::inbound::IncomingEvent::NewMessage(m)
2211 | super::inbound::IncomingEvent::Updated { message: m, .. } => {
2212 pending.push(m);
2213 }
2214 super::inbound::IncomingEvent::Presence { npub, joined, event_id, created_at, invited_by, invited_label } => {
2215 let et = if *joined {
2216 crate::stored_event::SystemEventType::MemberJoined
2217 } else {
2218 crate::stored_event::SystemEventType::MemberLeft
2219 };
2220 let note = invited_by.as_ref().map(|by| match invited_label {
2221 Some(l) if !l.is_empty() => format!("{by}|{l}"),
2222 _ => by.clone(),
2223 });
2224 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;
2225 }
2226 super::inbound::IncomingEvent::WebxdcPeer { npub, topic_id, node_addr, event_id, created_at } => {
2227 persist_webxdc_signal(&ch_hex, npub, topic_id, node_addr.as_deref(), event_id, *created_at).await;
2228 }
2229 _ => {}
2230 }
2231 }
2232 crate::db::events::flush_message_batch(&ch_hex, &mut pending, &session).await;
2233 }
2234 Ok(())
2235 })
2236 .await
2237}
2238
2239pub async fn sync_before_admin_write<T: Transport + ?Sized>(
2250 transport: &T,
2251 community: &Community,
2252 observe_activity: bool,
2253) -> Result<Community, String> {
2254 if catch_up_server_root(transport, community).await?.removed {
2256 return Err("you have been removed from this community".to_string());
2257 }
2258 let community = crate::db::community::load_community(&community.id)?
2259 .ok_or("community gone during admin sync")?;
2260 let cid = community.id.to_hex();
2261 let responded = fetch_and_apply_control_full(transport, &community).await.map(|n| n > 0).unwrap_or(false);
2270 let hold_local_heads = !crate::db::community::get_all_edition_heads_epoched(&cid)?.is_empty();
2271 if hold_local_heads && !responded {
2272 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());
2273 }
2274 let community = crate::db::community::load_community(&community.id)?
2275 .ok_or("community gone during admin sync")?;
2276 if observe_activity {
2279 let _ = observe_channel_activity(transport, &community).await;
2280 }
2281 crate::db::community::load_community(&community.id)?.ok_or("community gone during admin sync".to_string())
2282}
2283
2284async fn run_read_cut<T: Transport + ?Sized>(
2294 transport: &T,
2295 community: &Community,
2296 fresh: bool,
2297) -> Result<(), String> {
2298 crate::db::scoped(async move {
2299 let cid = community.id.to_hex();
2300 if fresh {
2301 let base = crate::db::community::load_community(&community.id)?
2304 .map(|c| c.server_root_epoch.0)
2305 .unwrap_or(community.server_root_epoch.0);
2306 crate::db::community::set_read_cut_target_epoch(&cid, base.saturating_add(1))?;
2307 }
2308 crate::db::community::set_read_cut_pending(&cid, true)?;
2309 reseal_base_to_observed(transport, community).await?;
2310 crate::db::community::set_read_cut_pending(&cid, false)?;
2311 Ok(())
2312 })
2313 .await
2314}
2315
2316async fn reseal_base_to_observed<T: Transport + ?Sized>(
2326 transport: &T,
2327 community: &Community,
2328) -> Result<(), String> {
2329 crate::db::scoped(async move {
2330 let cid = community.id.to_hex();
2331 let community = &sync_before_admin_write(transport, community, true).await?;
2336 let participants: Vec<nostr_sdk::prelude::PublicKey> = crate::db::community::community_member_activity(&cid)?
2340 .into_iter()
2341 .filter_map(|(npub, _)| nostr_sdk::prelude::PublicKey::parse(&npub).ok())
2342 .collect();
2343 let target = crate::db::community::get_read_cut_target_epoch(&cid)?;
2353 if community.server_root_epoch.0 < target {
2354 rotate_server_root(transport, community, &participants).await?;
2355 }
2356 let community = crate::db::community::load_community(&community.id)?
2362 .ok_or("community gone after base rotation")?;
2363 let cut_epoch = community.server_root_epoch.0;
2364 let prior_root = crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, cut_epoch.saturating_sub(1))?
2370 .unwrap_or(*community.server_root_key.as_bytes()); for channel in &community.channels {
2372 let ch_hex = channel.id.to_hex();
2373 if crate::db::community::channel_rekeyed_at_server_epoch(&cid, &ch_hex)? >= cut_epoch {
2376 continue;
2377 }
2378 rotate_channel(transport, &community, &channel.id, &participants, &prior_root).await?;
2379 crate::db::community::mark_channel_rekeyed_at_server_epoch(&cid, &ch_hex, cut_epoch)?;
2380 }
2381 Ok(())
2382 })
2383 .await
2384}
2385
2386#[derive(Debug, PartialEq, Eq)]
2388pub enum RekeyOutcome {
2389 Applied { head_advanced: bool },
2392 NotARecipient,
2395}
2396
2397pub fn apply_channel_rekey(
2408 community: &Community,
2409 parsed: &super::rekey::ParsedRekey,
2410) -> Result<RekeyOutcome, String> {
2411 let session = crate::db::current_session();
2415
2416 let channel_id = match parsed.scope {
2418 super::derive::RekeyScope::Channel(c) => c,
2419 super::derive::RekeyScope::ServerRoot => {
2420 return Err("server-root rotation uses apply_server_root_rekey, not the channel path".to_string())
2421 }
2422 };
2423 if !community.channels.iter().any(|c| c.id == channel_id) {
2424 return Err("rekey targets a channel not in this community".to_string());
2425 }
2426 let cid = community.id.to_hex();
2427 let channel_hex = channel_id.to_hex();
2428
2429 let owner = proven_owner_hex(community);
2436 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_else(|e| {
2437 crate::log_warn!("rekey apply: roster read failed ({e}); authorizing owner only");
2440 Default::default()
2441 });
2442 if !roster.is_authorized(
2443 &parsed.rotator.to_hex(),
2444 owner.as_deref(),
2445 super::roles::Permissions::MANAGE_CHANNELS,
2446 ) {
2447 return Err("rekey rotator lacks MANAGE_CHANNELS authority".to_string());
2448 }
2449
2450 if let Some(prev_key) = crate::db::community::held_epoch_key(&cid, &channel_hex, parsed.prev_epoch.0)? {
2460 if super::rekey::epoch_key_commitment(parsed.prev_epoch, &prev_key) != parsed.prev_key_commitment {
2461 crate::log_warn!(
2462 "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",
2463 parsed.new_epoch.0, parsed.prev_epoch.0
2464 );
2465 }
2466 }
2467
2468 let my_keys = crate::state::MY_SECRET_KEY
2470 .to_keys()
2471 .ok_or("no local identity to open the rekey blob")?;
2472 let secret = super::rekey::rekey_pairwise_secret(my_keys.secret_key(), &parsed.rotator)?;
2473 let my_locator = super::derive::recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
2474 let mine = match parsed.blobs.iter().find(|b| b.locator == my_locator) {
2475 Some(b) => b,
2476 None => return Ok(RekeyOutcome::NotARecipient),
2477 };
2478 let new_key =
2479 super::rekey::open_rekey_blob(my_keys.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine)?;
2480
2481 if !session.is_live() {
2483 return Err("session changed during rekey apply".to_string());
2484 }
2485 let head_advanced =
2486 crate::db::community::advance_channel_epoch(&cid, &channel_hex, parsed.new_epoch.0, &new_key)?;
2487 Ok(RekeyOutcome::Applied { head_advanced })
2488}
2489
2490fn mint_or_reuse_rotation_key(cid: &str, scope_id: &str, epoch: u64) -> Result<zeroize::Zeroizing<[u8; 32]>, String> {
2496 if let Some(k) = crate::db::community::held_epoch_key(cid, scope_id, epoch)? {
2497 return Ok(zeroize::Zeroizing::new(k));
2498 }
2499 let k = zeroize::Zeroizing::new(super::random_32());
2500 crate::db::community::store_epoch_key(cid, scope_id, epoch, &k)?;
2501 Ok(k)
2502}
2503
2504async fn publish_rekey_chunked<T, F>(
2512 transport: &T,
2513 relays: &[String],
2514 blobs: &[super::rekey::RekeyBlob],
2515 build: F,
2516) -> Result<(), String>
2517where
2518 T: Transport + ?Sized,
2519 F: Fn(&[super::rekey::RekeyBlob]) -> Result<Event, String>,
2520{
2521 if blobs.is_empty() {
2522 return Err("rekey has no recipients".to_string());
2523 }
2524 for chunk in blobs.chunks(super::rekey::MAX_REKEY_BLOBS) {
2525 let event = build(chunk)?;
2526 transport.publish_durable(&event, relays).await?;
2527 }
2528 Ok(())
2529}
2530
2531pub async fn rotate_channel<T: Transport + ?Sized>(
2543 transport: &T,
2544 community: &Community,
2545 channel_id: &super::ChannelId,
2546 recipients: &[nostr_sdk::prelude::PublicKey],
2547 envelope_root: &[u8; 32],
2553) -> Result<u64, String> {
2554 crate::db::scoped(async move {
2555 let cid = community.id.to_hex();
2556
2557 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)")?;
2561 let owner = proven_owner_hex(community);
2562 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
2563 if !roster.is_authorized(&my_keys.public_key().to_hex(), owner.as_deref(), super::roles::Permissions::MANAGE_CHANNELS) {
2564 return Err("not authorized to rotate this channel (no MANAGE_CHANNELS)".to_string());
2565 }
2566
2567 let channel = community
2571 .channels
2572 .iter()
2573 .find(|c| &c.id == channel_id)
2574 .ok_or("channel not found in community")?;
2575 let prev_epoch = channel.epoch;
2576 let new_epoch = super::Epoch(prev_epoch.0.checked_add(1).ok_or("channel epoch overflow")?);
2577 let prev_commit = super::rekey::epoch_key_commitment(prev_epoch, channel.key.as_bytes());
2578 let new_key = mint_or_reuse_rotation_key(&cid, &channel_id.to_hex(), new_epoch.0)?;
2581
2582 let mut seen = std::collections::HashSet::new();
2586 let mut blobs = Vec::new();
2587 for pk in recipients.iter().chain(std::iter::once(&my_keys.public_key())) {
2588 if !seen.insert(pk.to_hex()) {
2589 continue;
2590 }
2591 blobs.push(super::rekey::build_rekey_blob(
2592 my_keys.secret_key(), pk, super::derive::RekeyScope::Channel(*channel_id), new_epoch, &new_key,
2593 )?);
2594 }
2595
2596 publish_rekey_chunked(transport, &community.relays, &blobs, |chunk| {
2598 super::rekey::build_channel_rekey_event(
2599 &Keys::generate(), &my_keys, envelope_root, channel_id,
2600 new_epoch, prev_epoch, &prev_commit, chunk,
2601 )
2602 })
2603 .await?;
2604 crate::db::community::advance_channel_epoch(&cid, &channel_id.to_hex(), new_epoch.0, &new_key)?;
2605 Ok(new_epoch.0)
2606 })
2607 .await
2608}
2609
2610fn emit_rekey_progress(label: &str, pct: u8) {
2614 crate::emit_event("community_rekey_progress", &serde_json::json!({ "label": label, "pct": pct }));
2615}
2616
2617pub(crate) async fn rotate_server_root<T: Transport + ?Sized>(
2633 transport: &T,
2634 community: &Community,
2635 recipients: &[nostr_sdk::prelude::PublicKey],
2636) -> Result<u64, String> {
2637 crate::db::scoped(async move {
2638 let cid = community.id.to_hex();
2639
2640 if crate::db::community::get_community_dissolved(&cid)? {
2642 return Err("community is dissolved; it cannot be re-founded".to_string());
2643 }
2644
2645 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)")?;
2656 let owner = proven_owner_hex(community);
2657 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
2658 if !roster.is_authorized(&my_keys.public_key().to_hex(), owner.as_deref(), super::roles::Permissions::BAN) {
2659 return Err("not authorized to rotate the server root (no BAN)".to_string());
2660 }
2661
2662 let fresh = crate::db::community::load_community(&community.id)?
2668 .ok_or("community gone before base rotation")?;
2669 let community = &fresh;
2670 let prev_epoch = community.server_root_epoch;
2671 let new_epoch = super::Epoch(prev_epoch.0.checked_add(1).ok_or("server-root epoch overflow")?);
2672 let prev_commit = super::rekey::epoch_key_commitment(prev_epoch, community.server_root_key.as_bytes());
2674 let new_root = mint_or_reuse_rotation_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch.0)?;
2676 emit_rekey_progress("Rerolling community keys...", 5);
2677
2678 let sealed = prepare_reanchor_control_plane(transport, community, &new_root, new_epoch).await?;
2684
2685 let total_recipients = (recipients.len() + 1).max(1); let mut seen = std::collections::HashSet::new();
2687 let mut blobs = Vec::new();
2688 for pk in recipients.iter().chain(std::iter::once(&my_keys.public_key())) {
2689 if !seen.insert(pk.to_hex()) {
2690 continue;
2691 }
2692 blobs.push(super::rekey::build_rekey_blob(
2693 my_keys.secret_key(), pk, super::derive::RekeyScope::ServerRoot, new_epoch, &new_root,
2694 )?);
2695 emit_rekey_progress(
2696 &format!("Preparing keys for members ({}/{})...", blobs.len(), total_recipients),
2697 (5 + 35 * blobs.len() / total_recipients) as u8,
2698 );
2699 }
2700
2701 emit_rekey_progress("Sending keys to members...", 42);
2705 publish_rekey_chunked(transport, &community.relays, &blobs, |chunk| {
2706 super::rekey::build_server_root_rekey_event(
2707 &Keys::generate(), &my_keys, community.server_root_key.as_bytes(), &community.id,
2708 new_epoch, prev_epoch, &prev_commit, chunk,
2709 )
2710 })
2711 .await?;
2712
2713 let snapshot = publish_reanchor_snapshot(transport, &community.relays, sealed).await?;
2721 if snapshot.iter().any(|e| !e.published) {
2722 return Err(
2723 "re-founding aborted: a snapshot edition did not land (rate-limited / unreachable relay?); base head NOT advanced".to_string()
2724 );
2725 }
2726 emit_rekey_progress("Finalizing...", 98);
2727 crate::db::community::advance_server_root_epoch(&cid, new_epoch.0, &new_root)?;
2729 for e in &snapshot {
2733 crate::db::community::set_edition_head_with_id(&cid, &e.entity_hex, e.version, &e.self_hash, &e.inner_id)?;
2734 }
2735 Ok(new_epoch.0)
2736 })
2737 .await
2738}
2739
2740pub(crate) struct SnapshotEntry {
2775 pub entity_hex: String,
2776 pub version: u64,
2777 pub self_hash: [u8; 32],
2778 pub inner_id: [u8; 32],
2779 pub published: bool,
2780}
2781
2782pub(crate) async fn prepare_reanchor_control_plane<T: Transport + ?Sized>(
2788 transport: &T,
2789 community: &Community,
2790 new_root: &[u8; 32],
2791 new_epoch: super::Epoch,
2792) -> Result<Vec<(Event, SnapshotEntry)>, String> {
2793 crate::db::scoped(async move {
2794 let cid = community.id.to_hex();
2795
2796 let z = super::roster::control_pseudonym(&community.server_root_key, &community.id, community.server_root_epoch);
2804 let query = Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], evidence: Evidence::Full, ..Default::default() };
2808 let outers = transport.fetch(&query, &community.relays).await?;
2809 let mut by_hash: std::collections::HashMap<[u8; 32], (Event, [u8; 32])> = std::collections::HashMap::new();
2811 for outer in &outers {
2812 if let Ok(inner) = super::roster::open_control_edition(outer, &community.server_root_key) {
2813 if let Ok(parsed) = super::edition::parse_edition_inner(&inner) {
2814 by_hash.insert(parsed.self_hash, (inner, parsed.inner_id));
2815 }
2816 }
2817 }
2818
2819 let new_root_key = super::ServerRootKey(*new_root);
2823 let mut sealed: Vec<(Event, SnapshotEntry)> = Vec::new();
2824 for (entity_hex, (epoch, version, self_hash)) in crate::db::community::get_all_edition_heads_epoched(&cid)? {
2825 if epoch != community.server_root_epoch.0 {
2826 continue; }
2828 let (inner, inner_id) = by_hash.get(&self_hash).ok_or_else(|| {
2829 format!("re-founding aborted: head edition for entity {entity_hex} (v{version}) not fetchable — aborting so no member is stranded")
2830 })?;
2831 let outer = super::roster::seal_control_edition(&Keys::generate(), inner, &new_root_key, &community.id, new_epoch)?;
2832 sealed.push((outer, SnapshotEntry { entity_hex, version, self_hash, inner_id: *inner_id, published: false }));
2833 }
2834 Ok(sealed)
2835 })
2836 .await
2837}
2838
2839pub(crate) async fn publish_reanchor_snapshot<T: Transport + ?Sized>(
2843 transport: &T,
2844 relays: &[String],
2845 sealed: Vec<(Event, SnapshotEntry)>,
2846) -> Result<Vec<SnapshotEntry>, String> {
2847 use futures_util::stream::StreamExt;
2850 let total = sealed.len().max(1);
2851 let done = std::sync::atomic::AtomicUsize::new(0);
2852 let done_ref = &done;
2853 emit_rekey_progress(&format!("Re-founding community (0/{total})..."), 50);
2854 let out: Vec<SnapshotEntry> = futures_util::stream::iter(sealed.into_iter().map(|(ev, mut entry)| async move {
2855 entry.published = transport.publish_durable(&ev, relays).await.is_ok();
2856 let n = done_ref.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
2857 emit_rekey_progress(&format!("Re-founding community ({n}/{total})..."), (50 + 45 * n / total) as u8);
2858 entry
2859 }))
2860 .buffer_unordered(4)
2861 .collect()
2862 .await;
2863 Ok(out)
2864}
2865
2866#[cfg(test)]
2869pub(crate) async fn reanchor_control_plane<T: Transport + ?Sized>(
2870 transport: &T,
2871 community: &Community,
2872 new_root: &[u8; 32],
2873 new_epoch: super::Epoch,
2874) -> Result<Vec<SnapshotEntry>, String> {
2875 let sealed = prepare_reanchor_control_plane(transport, community, new_root, new_epoch).await?;
2876 publish_reanchor_snapshot(transport, &community.relays, sealed).await
2877}
2878
2879pub fn apply_server_root_rekey(
2887 community: &Community,
2888 parsed: &super::rekey::ParsedRekey,
2889) -> Result<RekeyOutcome, String> {
2890 let session = crate::db::current_session();
2891
2892 if !matches!(parsed.scope, super::derive::RekeyScope::ServerRoot) {
2894 return Err("not a server-root rekey (channel rekeys use apply_channel_rekey)".to_string());
2895 }
2896 let cid = community.id.to_hex();
2897
2898 if crate::db::community::get_community_dissolved(&cid)? && !super::migration::catchup_exempt(&cid, parsed.new_epoch.0) {
2909 return Err("community is dissolved; base epoch cannot advance".to_string());
2910 }
2911
2912 let owner = proven_owner_hex(community);
2919 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_else(|e| {
2920 crate::log_warn!("base rekey apply: roster read failed ({e}); authorizing owner only");
2921 Default::default()
2922 });
2923 if !rotator_is_authorized(&cid, &roster, owner.as_deref(), &parsed.rotator.to_hex(), super::roles::Permissions::BAN) {
2924 return Err("base rekey rotator lacks server-wide rotation authority (BAN)".to_string());
2925 }
2926
2927 if let Some(prev_root) =
2934 crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, parsed.prev_epoch.0)?
2935 {
2936 if super::rekey::epoch_key_commitment(parsed.prev_epoch, &prev_root) != parsed.prev_key_commitment {
2937 crate::log_warn!(
2938 "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",
2939 parsed.new_epoch.0, parsed.prev_epoch.0
2940 );
2941 }
2942 }
2943
2944 let my_keys = crate::state::MY_SECRET_KEY.to_keys().ok_or("no local identity to open the base rekey blob")?;
2946 let secret = super::rekey::rekey_pairwise_secret(my_keys.secret_key(), &parsed.rotator)?;
2947 let my_locator = super::derive::recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
2948 let mine = match parsed.blobs.iter().find(|b| b.locator == my_locator) {
2949 Some(b) => b,
2950 None => return Ok(RekeyOutcome::NotARecipient),
2951 };
2952 let new_root =
2953 super::rekey::open_rekey_blob(my_keys.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine)?;
2954
2955 if !session.is_live() {
2956 return Err("session changed during base rekey apply".to_string());
2957 }
2958 let head_advanced = crate::db::community::advance_server_root_epoch(&cid, parsed.new_epoch.0, &new_root)?;
2959 Ok(RekeyOutcome::Applied { head_advanced })
2960}
2961
2962const REKEY_CATCHUP_WINDOW: u64 = 64;
2966const MAX_REKEY_CATCHUP_ROUNDS: usize = 64;
2969
2970#[allow(clippy::too_many_arguments)]
2982async fn heal_channel_fork_epochs<T: Transport + ?Sized>(
2983 transport: &T,
2984 community: &Community,
2985 channel_id: &super::ChannelId,
2986 cid: &str,
2987 channel_hex: &str,
2988 epochs: &std::collections::BTreeSet<u64>,
2989 server_roots: &[[u8; 32]],
2990 session: &std::sync::Arc<crate::db::Session>,
2991) -> Result<(), String> {
2992 if epochs.is_empty() {
2993 return Ok(());
2994 }
2995 let owner_hex = proven_owner_hex(community);
2996 let roster = crate::db::community::get_community_roles(cid).unwrap_or_default();
2997 let mut winner: std::collections::BTreeMap<u64, [u8; 32]> = std::collections::BTreeMap::new();
3000 for sr in server_roots {
3001 let z_tags: Vec<String> = epochs
3002 .iter()
3003 .map(|e| super::derive::rekey_pseudonym(&super::ServerRootKey(*sr), channel_id, super::Epoch(*e)).to_hex())
3004 .collect();
3005 let q = Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags, ..Default::default() };
3006 for ev in transport.fetch(&q, &community.relays).await.unwrap_or_default() {
3007 let Ok(p) = super::rekey::open_rekey_event(&ev, sr) else { continue };
3008 if !matches!(p.scope, super::derive::RekeyScope::Channel(c) if &c == channel_id) || !epochs.contains(&p.new_epoch.0) {
3009 continue;
3010 }
3011 if !rotator_is_authorized(cid, &roster, owner_hex.as_deref(), &p.rotator.to_hex(), super::roles::Permissions::MANAGE_CHANNELS) {
3012 continue;
3013 }
3014 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);
3016 }
3017 }
3018 for (epoch, win_key) in winner {
3019 if !session.is_live() {
3020 return Err("session changed during channel convergence".to_string());
3021 }
3022 if let Ok(Some(cur)) = crate::db::community::held_epoch_key(cid, channel_hex, epoch) {
3027 if win_key < cur {
3028 match crate::db::community::converge_channel_epoch(cid, channel_hex, epoch, &win_key) {
3031 Ok(false) => crate::log_trace!("channel heal: converge of epoch {epoch} did not apply (head moved)"),
3032 Err(e) => crate::log_trace!("channel heal: converge of epoch {epoch} errored: {e}"),
3033 Ok(true) => {}
3034 }
3035 }
3036 }
3037 }
3038 Ok(())
3039}
3040
3041pub async fn catch_up_channel_rekeys<T: Transport + ?Sized>(
3056 transport: &T,
3057 community: &Community,
3058 channel_id: &super::ChannelId,
3059) -> Result<u64, String> {
3060 let session = crate::db::current_session();
3061 let server_root = community.server_root_key.as_bytes();
3062 let cid = community.id.to_hex();
3063 let channel_hex = channel_id.to_hex();
3064 let mut server_roots: Vec<[u8; 32]> = crate::db::community::held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX)
3070 .unwrap_or_default()
3071 .into_iter()
3072 .map(|(_, k)| k)
3073 .collect();
3074 if !server_roots.iter().any(|r| r == server_root) {
3075 server_roots.push(*server_root); }
3077 let mut head = community
3078 .channels
3079 .iter()
3080 .find(|c| &c.id == channel_id)
3081 .ok_or("channel not found in community")?
3082 .epoch
3083 .0;
3084
3085 let mut forked_epochs: std::collections::BTreeSet<u64> = std::collections::BTreeSet::new();
3089
3090 for _round in 0..MAX_REKEY_CATCHUP_ROUNDS {
3091 let window_top = head.saturating_add(REKEY_CATCHUP_WINDOW);
3092 let mut parsed: Vec<super::rekey::ParsedRekey> = Vec::new();
3096 for sr in &server_roots {
3097 let z_tags: Vec<String> = (head.saturating_add(1)..=window_top)
3098 .map(|e| super::derive::rekey_pseudonym(&super::ServerRootKey(*sr), channel_id, super::Epoch(e)).to_hex())
3099 .collect();
3100 let query = Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags, ..Default::default() };
3101 for ev in transport.fetch(&query, &community.relays).await.unwrap_or_default() {
3106 if let Ok(p) = super::rekey::open_rekey_event(&ev, sr) {
3107 if matches!(p.scope, super::derive::RekeyScope::Channel(c) if &c == channel_id) {
3108 parsed.push(p);
3109 }
3110 }
3111 }
3112 }
3113 if parsed.is_empty() {
3114 break; }
3116 parsed.sort_by_key(|p| p.new_epoch.0);
3118 let max_found = parsed.last().map(|p| p.new_epoch.0).unwrap_or(head);
3119
3120 let head_before = head;
3121 let mut removed = false;
3122 let mut by_epoch: std::collections::BTreeMap<u64, Vec<&super::rekey::ParsedRekey>> = std::collections::BTreeMap::new();
3127 for p in &parsed {
3128 by_epoch.entry(p.new_epoch.0).or_default().push(p);
3129 }
3130 for (e, chunks) in by_epoch {
3131 if !session.is_live() {
3132 return Err("session changed during rekey catch-up".to_string());
3133 }
3134 let mut applied = false;
3135 let mut saw_not_recipient = false;
3136 for p in &chunks {
3137 match apply_channel_rekey(community, p) {
3138 Ok(RekeyOutcome::Applied { .. }) => {
3139 applied = true;
3140 break;
3141 }
3142 Ok(RekeyOutcome::NotARecipient) => saw_not_recipient = true,
3143 Err(err) => crate::log_warn!("rekey catch-up: skipping epoch {e} chunk: {err}"),
3144 }
3145 }
3146 if applied {
3147 if let Some(p) = chunks.first() {
3153 let pe = p.prev_epoch.0;
3154 if let Ok(Some(prev_key)) = crate::db::community::held_epoch_key(&cid, &channel_hex, pe) {
3155 if super::rekey::epoch_key_commitment(p.prev_epoch, &prev_key) != p.prev_key_commitment {
3156 forked_epochs.insert(pe);
3157 }
3158 }
3159 }
3160 if e > head + 1 {
3163 crate::log_warn!(
3164 "rekey catch-up: channel epochs {}..={} not recovered (key gap; history unreadable until re-fetched)",
3165 head + 1, e - 1
3166 );
3167 }
3168 head = head.max(e);
3169 } else if saw_not_recipient {
3170 removed = true;
3173 break;
3174 }
3175 }
3177
3178 if removed || head == head_before || max_found < window_top {
3181 break;
3182 }
3183 }
3184
3185 let held: std::collections::HashSet<u64> = crate::db::community::held_epoch_keys(&cid, &channel_hex)
3192 .unwrap_or_default()
3193 .into_iter()
3194 .map(|(e, _)| e.0)
3195 .collect();
3196 let missing: Vec<u64> = (0..head).filter(|e| !held.contains(e)).collect();
3197 if !missing.is_empty() {
3198 for sr in &server_roots {
3199 if !session.is_live() {
3200 return Err("session changed during rekey gap-fill".to_string());
3201 }
3202 let z_tags: Vec<String> = missing
3203 .iter()
3204 .map(|e| super::derive::rekey_pseudonym(&super::ServerRootKey(*sr), channel_id, super::Epoch(*e)).to_hex())
3205 .collect();
3206 let query = Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags, ..Default::default() };
3207 for ev in transport.fetch(&query, &community.relays).await.unwrap_or_default() {
3210 if let Ok(p) = super::rekey::open_rekey_event(&ev, sr) {
3211 if matches!(p.scope, super::derive::RekeyScope::Channel(c) if &c == channel_id) {
3212 let _ = apply_channel_rekey(community, &p); }
3214 }
3215 }
3216 }
3217 }
3218
3219 if head > 0 && session.is_live() {
3226 let lo = head.saturating_sub(REKEY_CATCHUP_WINDOW).max(1);
3227 let mut epochs: std::collections::BTreeSet<u64> = (lo..=head).collect();
3228 epochs.append(&mut forked_epochs);
3229 let _ = heal_channel_fork_epochs(transport, community, channel_id, &cid, &channel_hex, &epochs, &server_roots, &session).await;
3230 }
3231 Ok(head)
3232}
3233
3234const MAX_BASE_CATCHUP_STEPS: usize = 256;
3237
3238fn peek_my_server_root(parsed: &super::rekey::ParsedRekey) -> Result<Option<[u8; 32]>, String> {
3252 if !matches!(parsed.scope, super::derive::RekeyScope::ServerRoot) {
3253 return Ok(None);
3254 }
3255 let my_keys = crate::state::MY_SECRET_KEY.to_keys().ok_or("no local key to open a base rekey blob")?;
3256 let secret = super::rekey::rekey_pairwise_secret(my_keys.secret_key(), &parsed.rotator)?;
3257 let my_locator = super::derive::recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
3258 let mine = match parsed.blobs.iter().find(|b| b.locator == my_locator) {
3259 Some(b) => b,
3260 None => return Ok(None),
3261 };
3262 super::rekey::open_rekey_blob(my_keys.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine).map(Some)
3263}
3264
3265fn peek_my_channel_key(parsed: &super::rekey::ParsedRekey) -> Option<[u8; 32]> {
3269 let my_keys = crate::state::MY_SECRET_KEY.to_keys()?;
3270 let secret = super::rekey::rekey_pairwise_secret(my_keys.secret_key(), &parsed.rotator).ok()?;
3271 let my_locator = super::derive::recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
3272 let mine = parsed.blobs.iter().find(|b| b.locator == my_locator)?;
3273 super::rekey::open_rekey_blob(my_keys.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine).ok()
3274}
3275
3276pub async fn catch_up_server_root<T: Transport + ?Sized>(
3277 transport: &T,
3278 community: &Community,
3279) -> Result<BaseCatchup, String> {
3280 let session = crate::db::current_session();
3281 let cid = community.id.to_hex();
3282 let mut head = community.server_root_epoch.0;
3283 let mut removed = false;
3288 let mut current_root: [u8; 32] = *community.server_root_key.as_bytes();
3290
3291 for _step in 0..MAX_BASE_CATCHUP_STEPS {
3292 let next = match head.checked_add(1) {
3293 Some(n) => n,
3294 None => break,
3295 };
3296 let addr = super::derive::base_rekey_pseudonym(&super::ServerRootKey(current_root), &community.id, super::Epoch(next)).to_hex();
3297 let query = Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() };
3298 let events = transport.fetch(&query, &community.relays).await?;
3299 if events.is_empty() {
3300 break; }
3302
3303 let chunks: Vec<super::rekey::ParsedRekey> = events
3306 .iter()
3307 .filter_map(|ev| super::rekey::open_rekey_event(ev, ¤t_root).ok())
3308 .filter(|p| matches!(p.scope, super::derive::RekeyScope::ServerRoot) && p.new_epoch.0 == next)
3309 .collect();
3310 if chunks.is_empty() {
3311 break; }
3313
3314 if !session.is_live() {
3315 return Err("session changed during base rekey catch-up".to_string());
3316 }
3317
3318 let owner_hex = proven_owner_hex(community);
3332 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
3333 let mut candidates: Vec<(&super::rekey::ParsedRekey, [u8; 32])> = Vec::new();
3334 for parsed in &chunks {
3335 if !rotator_is_authorized(&cid, &roster, owner_hex.as_deref(), &parsed.rotator.to_hex(), super::roles::Permissions::BAN) {
3336 continue;
3337 }
3338 match peek_my_server_root(parsed) {
3339 Ok(Some(root)) => candidates.push((parsed, root)),
3340 Ok(None) => {}
3341 Err(err) => crate::log_warn!("base rekey catch-up: epoch {next} peek: {err}"),
3342 }
3343 }
3344 let applied = match candidates.into_iter().min_by(|a, b| a.1.cmp(&b.1)) {
3345 Some((parsed, _)) => match apply_server_root_rekey(community, parsed) {
3346 Ok(RekeyOutcome::Applied { .. }) => true,
3347 Ok(RekeyOutcome::NotARecipient) => false, Err(err) => { crate::log_warn!("base rekey catch-up: epoch {next} apply: {err}"); false }
3349 },
3350 None => {
3351 let owner = proven_owner_hex(community);
3356 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
3357 if chunks.iter().any(|p| rotator_is_authorized(&cid, &roster, owner.as_deref(), &p.rotator.to_hex(), super::roles::Permissions::BAN)) {
3358 removed = true;
3359 }
3360 false }
3362 };
3363 if !applied {
3364 break;
3365 }
3366 match crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, next)? {
3368 Some(root) => {
3369 current_root = root;
3370 head = next;
3371 }
3372 None => {
3373 crate::log_warn!("base rekey catch-up: epoch {next} applied but its root is not archived; halting walk");
3376 break;
3377 }
3378 }
3379 }
3380
3381 if head > 0 && !removed {
3388 if let Ok(Some(prior_root)) = crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, head - 1) {
3389 let addr = super::derive::base_rekey_pseudonym(&super::ServerRootKey(prior_root), &community.id, super::Epoch(head)).to_hex();
3390 let query = Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() };
3391 let events = transport.fetch(&query, &community.relays).await.unwrap_or_default();
3392 let chunks: Vec<super::rekey::ParsedRekey> = events
3393 .iter()
3394 .filter_map(|ev| super::rekey::open_rekey_event(ev, &prior_root).ok())
3395 .filter(|p| matches!(p.scope, super::derive::RekeyScope::ServerRoot) && p.new_epoch.0 == head)
3396 .collect();
3397 let owner_hex = proven_owner_hex(community);
3398 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
3399 let mut best: Option<(&super::rekey::ParsedRekey, [u8; 32])> = None;
3400 for p in &chunks {
3401 if !rotator_is_authorized(&cid, &roster, owner_hex.as_deref(), &p.rotator.to_hex(), super::roles::Permissions::BAN) {
3405 continue;
3406 }
3407 if let Ok(Some(root)) = peek_my_server_root(p) {
3408 if best.as_ref().is_none_or(|(_, br)| root < *br) {
3409 best = Some((p, root));
3410 }
3411 }
3412 }
3413 let current_deauthorized = chunks.iter().any(|p| {
3423 matches!(peek_my_server_root(p), Ok(Some(r)) if r == current_root)
3424 && !rotator_is_authorized(&cid, &roster, owner_hex.as_deref(), &p.rotator.to_hex(), super::roles::Permissions::BAN)
3425 });
3426 if let Some((winner, win_root)) = best {
3427 let adopt = if current_deauthorized {
3428 win_root != current_root
3429 } else {
3430 win_root < current_root
3431 };
3432 if adopt {
3433 if !session.is_live() {
3434 return Err("session changed during base convergence".to_string());
3435 }
3436 if apply_server_root_rekey(community, winner).is_ok() {
3439 match crate::db::community::converge_server_root_epoch(&cid, head, &win_root) {
3440 Ok(false) => crate::log_trace!("base heal: converge of epoch {head} did not apply (head moved)"),
3441 Err(e) => crate::log_trace!("base heal: converge of epoch {head} errored: {e}"),
3442 Ok(true) => {}
3443 }
3444 current_root = win_root;
3445 if let Ok(Some(fresh)) = crate::db::community::load_community(&community.id) {
3446 let _ = fetch_and_apply_control(transport, &fresh).await;
3447 }
3448 }
3449 }
3450 }
3451 }
3452 }
3453 let _ = current_root; Ok(BaseCatchup { epoch: head, removed })
3455}
3456
3457#[derive(Debug, Clone, Copy)]
3461pub struct BaseCatchup {
3462 pub epoch: u64,
3463 pub removed: bool,
3464}
3465
3466#[cfg(test)]
3467mod tests {
3468 use super::*;
3469 use crate::community::send::fetch_channel_messages;
3470 use crate::community::transport::{memory::MemoryRelay, Query, Transport};
3471 use nostr_sdk::prelude::{EventBuilder, Kind};
3472
3473 struct FailingRelay;
3476 #[async_trait::async_trait]
3477 impl Transport for FailingRelay {
3478 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> { Ok(crate::community::transport::PlaneFetch::whole(self.fetch(query, relays).await?)) }
3479 async fn publish(&self, _event: &Event, _relays: &[String]) -> Result<(), String> {
3480 Err("relay unreachable".to_string())
3481 }
3482 async fn publish_durable(&self, _event: &Event, _relays: &[String]) -> Result<(), String> {
3483 Err("relay unreachable".to_string())
3484 }
3485 async fn fetch(&self, _query: &Query, _relays: &[String]) -> Result<Vec<Event>, String> {
3486 Ok(Vec::new())
3487 }
3488 }
3489
3490 struct RekeyFailingRelay {
3494 inner: MemoryRelay,
3495 fail_rekey: std::sync::atomic::AtomicBool,
3496 }
3497 impl RekeyFailingRelay {
3498 fn new() -> Self {
3499 Self { inner: MemoryRelay::new(), fail_rekey: std::sync::atomic::AtomicBool::new(true) }
3500 }
3501 fn allow_rekey(&self) {
3502 self.fail_rekey.store(false, std::sync::atomic::Ordering::Relaxed);
3503 }
3504 fn blocks(&self, event: &Event) -> bool {
3505 self.fail_rekey.load(std::sync::atomic::Ordering::Relaxed)
3506 && event.kind.as_u16() == crate::stored_event::event_kind::COMMUNITY_REKEY
3507 }
3508 }
3509 #[async_trait::async_trait]
3510 impl Transport for RekeyFailingRelay {
3511 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> { Ok(crate::community::transport::PlaneFetch::whole(self.fetch(query, relays).await?)) }
3512 async fn publish(&self, event: &Event, relays: &[String]) -> Result<(), String> {
3513 if self.blocks(event) { return Err("rekey relay down".to_string()); }
3514 self.inner.publish(event, relays).await
3515 }
3516 async fn publish_durable(&self, event: &Event, relays: &[String]) -> Result<(), String> {
3517 if self.blocks(event) { return Err("rekey relay down".to_string()); }
3518 self.inner.publish_durable(event, relays).await
3519 }
3520 async fn fetch(&self, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
3521 self.inner.fetch(query, relays).await
3522 }
3523 }
3524
3525 static TEST_COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(5000);
3526
3527 fn make_test_npub(n: u32) -> String {
3528 const BECH32: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
3529 let mut payload = vec![b'q'; 58];
3530 let mut x = n as u64;
3531 let mut i = 58;
3532 while x > 0 && i > 0 {
3533 i -= 1;
3534 payload[i] = BECH32[(x as usize) % 32];
3535 x /= 32;
3536 }
3537 format!("npub1{}", std::str::from_utf8(&payload).unwrap())
3538 }
3539
3540 fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
3541 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
3542 crate::db::close_database();
3543 crate::db::clear_id_caches();
3546 crate::signer::set_test_signer(None);
3549 let tmp = tempfile::tempdir().unwrap();
3550 let n = TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3551 let account = make_test_npub(n);
3552 std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
3553 crate::db::set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
3554 crate::db::set_current_account(account.clone()).unwrap();
3555 crate::db::init_database(&account).unwrap();
3556 let _ = crate::state::take_nostr_client();
3559 let owner = Keys::generate();
3561 crate::state::MY_SECRET_KEY.store_from_keys(&owner, &[]);
3562 crate::state::set_my_public_key(owner.public_key());
3563 (tmp, guard)
3564 }
3565
3566 #[test]
3567 fn community_cap_rejects_a_new_membership_at_the_limit() {
3568 let (_tmp, _guard) = init_test_db();
3569 let mk = |i: usize| {
3570 let id = format!("{:064x}", i);
3571 crate::community::list::CommunityListEntry {
3572 community_id: id.clone(),
3573 seed: crate::community::invite::CommunityInvite {
3574 community_id: id,
3575 name: String::new(),
3576 server_root_key: String::new(),
3577 server_root_epoch: 0,
3578 relays: vec![],
3579 channels: vec![],
3580 owner_attestation: None,
3581 icon: None,
3582 },
3583 current: None,
3584 added_at: 0,
3585 }
3586 };
3587 let mut list = crate::community::list::CommunityList::default();
3588 for i in 0..(MAX_COMMUNITIES - 1) {
3589 list.entries.push(mk(i));
3590 }
3591 crate::db::settings::set_sql_setting("community_list_json".to_string(), list.to_json()).unwrap();
3592 assert!(enforce_community_cap().is_ok(), "under the cap a new join is allowed");
3593
3594 list.entries.push(mk(MAX_COMMUNITIES - 1)); crate::db::settings::set_sql_setting("community_list_json".to_string(), list.to_json()).unwrap();
3596 assert!(enforce_community_cap().is_err(), "at the cap a new join is rejected");
3597 }
3598
3599 fn saved_community_owned_by(owner: &Keys) -> Community {
3604 let mut community = Community::create("HQ", "general", vec!["r".into()]);
3605 let cid = community.id.to_hex();
3606 community.owner_attestation = Some(
3607 crate::community::owner::build_owner_attestation_unsigned(owner.public_key(), &cid)
3608 .finalize(owner)
3609 .unwrap()
3610 .as_json(),
3611 );
3612 crate::db::community::save_community(&community).unwrap();
3613 community
3614 }
3615
3616 fn attested_community(name: &str, channel: &str, relays: Vec<String>) -> Community {
3620 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
3621 let mut community = Community::create(name, channel, relays);
3622 community.owner_attestation = Some(
3623 crate::community::owner::build_owner_attestation_unsigned(owner.public_key(), &community.id.to_hex())
3624 .finalize(&owner).unwrap().as_json(),
3625 );
3626 community
3627 }
3628
3629 fn become_local(me: &Keys) {
3631 crate::state::MY_SECRET_KEY.store_from_keys(me, &[]);
3632 crate::state::set_my_public_key(me.public_key());
3633 }
3634
3635 fn owner_channel_rekey(
3638 owner: &Keys,
3639 community: &Community,
3640 recipient_pk: &nostr_sdk::prelude::PublicKey,
3641 new_epoch: u64,
3642 new_key: &[u8; 32],
3643 ) -> super::super::rekey::ParsedRekey {
3644 let chan = &community.channels[0];
3645 let scope = super::super::derive::RekeyScope::Channel(chan.id);
3646 let blob = super::super::rekey::build_rekey_blob(
3647 owner.secret_key(), recipient_pk, scope, crate::community::Epoch(new_epoch), new_key,
3648 )
3649 .unwrap();
3650 let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), chan.key.as_bytes());
3651 let outer = super::super::rekey::build_channel_rekey_event(
3652 &Keys::generate(), owner, community.server_root_key.as_bytes(), &chan.id,
3653 crate::community::Epoch(new_epoch), crate::community::Epoch(0), &commit, &[blob],
3654 )
3655 .unwrap();
3656 super::super::rekey::open_rekey_event(&outer, community.server_root_key.as_bytes()).unwrap()
3657 }
3658
3659 #[tokio::test]
3664 async fn outer_event_dedup_skips_an_already_persisted_wire_event() {
3665 let (_tmp, _guard) = init_test_db();
3666 let owner = Keys::generate();
3667 let me = Keys::generate();
3668 become_local(&me);
3669 let community = saved_community_owned_by(&owner);
3670 let channel = community.channels[0].clone();
3671 let chan_hex = channel.id.to_hex();
3672
3673 let author = Keys::generate();
3675 let outer = crate::community::envelope::seal_message(
3676 &author, &channel.key, &channel.id, channel.epoch, "gm", 1000,
3677 ).unwrap();
3678 let outer_hex = outer.id.to_hex();
3679
3680 let mut state = crate::state::ChatState::new();
3682 let msg = match crate::community::inbound::process_incoming(&mut state, &outer, &channel, &me.public_key()) {
3683 Some(crate::community::inbound::IncomingEvent::NewMessage(m)) => m,
3684 _ => panic!("expected NewMessage from a fresh wire event"),
3685 };
3686 assert_eq!(msg.wrapper_event_id.as_deref(), Some(outer_hex.as_str()),
3687 "the inner must carry its outer wire id as wrapper_event_id");
3688
3689 crate::db::events::save_message(&chan_hex, &msg).await.unwrap();
3691
3692 let mut state2 = crate::state::ChatState::new();
3694 let second = crate::community::inbound::process_incoming(&mut state2, &outer, &channel, &me.public_key());
3695 assert!(second.is_none(), "an already-processed wire event must dedup before decryption");
3696 }
3697
3698 #[tokio::test]
3701 async fn ledger_is_shared_but_negentropy_stays_nip17_only() {
3702 let (_tmp, _guard) = init_test_db();
3703 let dm = [0xA1u8; 32];
3704 let concord = [0xC0u8; 32];
3705 crate::db::wrappers::save_processed_wrapper(&dm, 100, crate::db::wrappers::TRANSPORT_NIP17).unwrap();
3706 crate::db::wrappers::save_processed_wrapper(&concord, 200, crate::db::wrappers::TRANSPORT_CONCORD).unwrap();
3707
3708 assert!(crate::db::wrappers::processed_wrapper_exists(&dm));
3710 assert!(crate::db::wrappers::processed_wrapper_exists(&concord));
3711
3712 let items = crate::db::wrappers::load_negentropy_items().unwrap();
3714 assert_eq!(items.len(), 1, "negentropy must exclude concord wrappers");
3715 assert_eq!(items[0].0.to_bytes(), dm);
3716 }
3717
3718 #[tokio::test]
3722 async fn non_message_subkind_dedups_via_the_shared_ledger() {
3723 let (_tmp, _guard) = init_test_db();
3724 let owner = Keys::generate();
3725 let me = Keys::generate();
3726 become_local(&me);
3727 let community = saved_community_owned_by(&owner);
3728 let channel = community.channels[0].clone();
3729
3730 let author = Keys::generate();
3732 let inner = super::super::envelope::build_inner_typed(
3733 author.public_key(), &channel.id, channel.epoch,
3734 crate::stored_event::event_kind::COMMUNITY_PRESENCE, "join", 5, None, &[],
3735 ).finalize(&author).unwrap();
3736 let outer = super::super::envelope::seal_with_signed_inner(
3737 &Keys::generate(), &inner, &channel.key, &channel.id, channel.epoch,
3738 ).unwrap();
3739
3740 let mut state = crate::state::ChatState::new();
3742 let first = crate::community::inbound::process_incoming(&mut state, &outer, &channel, &me.public_key());
3743 assert!(matches!(first, Some(crate::community::inbound::IncomingEvent::Presence { .. })),
3744 "expected a Presence outcome");
3745 assert!(crate::db::wrappers::processed_wrapper_exists(&outer.id.to_bytes()),
3746 "a non-message sub-kind must record its outer id in the shared ledger");
3747
3748 let second = crate::community::inbound::process_incoming(&mut crate::state::ChatState::new(), &outer, &channel, &me.public_key());
3750 assert!(second.is_none(), "a re-fetched presence must dedup via the shared ledger");
3751 }
3752
3753 #[test]
3754 fn apply_channel_rekey_recovers_and_advances_head() {
3755 let (_tmp, _guard) = init_test_db();
3756 let owner = Keys::generate(); let me = Keys::generate();
3758 become_local(&me);
3759 let community = saved_community_owned_by(&owner);
3760 let cid = community.id.to_hex();
3761 let chan_hex = community.channels[0].id.to_hex();
3762 let new_key = [0xCDu8; 32];
3763
3764 let parsed = owner_channel_rekey(&owner, &community, &me.public_key(), 1, &new_key);
3765 let outcome = apply_channel_rekey(&community, &parsed).unwrap();
3766 assert_eq!(outcome, RekeyOutcome::Applied { head_advanced: true });
3767
3768 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(new_key));
3770 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
3771 assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(1));
3772 assert_eq!(reloaded.channels[0].key.as_bytes(), &new_key);
3773 assert!(crate::db::community::held_epoch_key(&cid, &chan_hex, 0).unwrap().is_some());
3775 }
3776
3777 #[test]
3778 fn apply_channel_rekey_accepts_matching_continuity() {
3779 let (_tmp, _guard) = init_test_db();
3782 let owner = Keys::generate();
3783 let me = Keys::generate();
3784 become_local(&me);
3785 let community = saved_community_owned_by(&owner);
3786 let parsed = owner_channel_rekey(&owner, &community, &me.public_key(), 1, &[0x44u8; 32]);
3788 assert_eq!(
3789 apply_channel_rekey(&community, &parsed).unwrap(),
3790 RekeyOutcome::Applied { head_advanced: true },
3791 "a rekey whose prior-key commitment matches the held genesis key applies"
3792 );
3793 }
3794
3795 #[test]
3796 fn advance_channel_epoch_archives_when_no_head_row() {
3797 let (_tmp, _guard) = init_test_db();
3800 let cid = "f".repeat(64);
3801 let orphan_channel = "a".repeat(64);
3802 let advanced = crate::db::community::advance_channel_epoch(&cid, &orphan_channel, 2, &[0x77u8; 32]).unwrap();
3803 assert!(!advanced, "no head row → head not advanced");
3804 assert_eq!(crate::db::community::held_epoch_key(&cid, &orphan_channel, 2).unwrap(), Some([0x77u8; 32]), "key still archived");
3805 }
3806
3807 #[tokio::test]
3808 async fn rotate_channel_publishes_recoverable_rekey_and_advances_own_head() {
3809 use crate::community::derive::{recipient_pseudonym, rekey_pseudonym};
3810 use crate::community::rekey::{open_rekey_blob, open_rekey_event, rekey_pairwise_secret};
3811 let (_tmp, _guard) = init_test_db();
3812 let owner = Keys::generate();
3813 become_local(&owner); let community = saved_community_owned_by(&owner);
3815 let channel_id = community.channels[0].id;
3816 let member = Keys::generate(); let relay = MemoryRelay::new();
3818
3819 let new_epoch = rotate_channel(&relay, &community, &channel_id, &[member.public_key()], community.server_root_key.as_bytes())
3820 .await
3821 .expect("rotate");
3822 assert_eq!(new_epoch, 1);
3823
3824 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
3826 assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(1));
3827
3828 let addr = rekey_pseudonym(
3831 &crate::community::ServerRootKey(*community.server_root_key.as_bytes()),
3832 &channel_id, crate::community::Epoch(1),
3833 )
3834 .to_hex();
3835 let found = relay
3836 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() }, &community.relays)
3837 .await
3838 .unwrap();
3839 assert_eq!(found.len(), 1, "rekey addressable by its server-root pseudonym");
3840 let parsed = open_rekey_event(&found[0], community.server_root_key.as_bytes()).unwrap();
3841 assert_eq!(parsed.rotator, owner.public_key());
3842 assert_eq!(parsed.new_epoch, crate::community::Epoch(1));
3843 assert_eq!(parsed.prev_epoch, crate::community::Epoch(0));
3844 assert_eq!(parsed.blobs.len(), 2, "the member + me (multi-device) each get a blob");
3845
3846 let secret = rekey_pairwise_secret(member.secret_key(), &parsed.rotator).unwrap();
3848 let loc = recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
3849 let mine = parsed.blobs.iter().find(|b| b.locator == loc).expect("member's blob present");
3850 let recovered = open_rekey_blob(member.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine).unwrap();
3851 assert_eq!(reloaded.channels[0].key.as_bytes(), &recovered, "member's recovered key == my advanced head key");
3852 }
3853
3854 #[tokio::test]
3855 async fn rotate_channel_failed_publish_leaves_head_unadvanced() {
3856 let (_tmp, _guard) = init_test_db();
3859 let owner = Keys::generate();
3860 become_local(&owner);
3861 let community = saved_community_owned_by(&owner);
3862 let member = Keys::generate();
3863 let err = rotate_channel(&FailingRelay, &community, &community.channels[0].id, &[member.public_key()], community.server_root_key.as_bytes()).await;
3864 assert!(err.is_err(), "a failed publish must propagate, not silently advance");
3865 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
3866 assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(0), "head stays put on publish failure");
3867 }
3868
3869 fn build_rekey_chain(
3873 owner: &Keys, community: &Community, recipient_pk: &nostr_sdk::prelude::PublicKey, n: u64,
3874 ) -> (Vec<Event>, Vec<[u8; 32]>) {
3875 let chan = &community.channels[0];
3876 let scope = super::super::derive::RekeyScope::Channel(chan.id);
3877 let mut prev_key = *chan.key.as_bytes();
3878 let mut events = Vec::new();
3879 let mut keys = Vec::new();
3880 for e in 1..=n {
3881 let new_key = [e as u8; 32];
3882 let blob = super::super::rekey::build_rekey_blob(owner.secret_key(), recipient_pk, scope, crate::community::Epoch(e), &new_key).unwrap();
3883 let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(e - 1), &prev_key);
3884 let ev = super::super::rekey::build_channel_rekey_event(
3885 &Keys::generate(), owner, community.server_root_key.as_bytes(), &chan.id,
3886 crate::community::Epoch(e), crate::community::Epoch(e - 1), &commit, &[blob],
3887 ).unwrap();
3888 events.push(ev);
3889 keys.push(new_key);
3890 prev_key = new_key;
3891 }
3892 (events, keys)
3893 }
3894
3895 #[tokio::test]
3896 async fn catch_up_steps_over_a_missing_epoch() {
3897 let (_tmp, _guard) = init_test_db();
3900 let owner = Keys::generate();
3901 let me = Keys::generate();
3902 become_local(&me);
3903 let community = saved_community_owned_by(&owner);
3904 let channel_id = community.channels[0].id;
3905 let cid = community.id.to_hex();
3906 let chan_hex = channel_id.to_hex();
3907
3908 let (events, keys) = build_rekey_chain(&owner, &community, &me.public_key(), 3);
3909 let relay = MemoryRelay::new();
3910 relay.inject(&events[0], &community.relays); relay.inject(&events[2], &community.relays); let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
3913
3914 assert_eq!(reached, 3, "head reaches the latest present epoch, stepping over the gap");
3915 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(keys[0]));
3916 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 2).unwrap(), None, "missing epoch is a hole");
3917 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 3).unwrap(), Some(keys[2]));
3918 }
3919
3920 #[tokio::test]
3921 async fn catch_up_recovers_a_rekey_under_a_prior_server_root() {
3922 let (_tmp, _guard) = init_test_db();
3927 let owner = Keys::generate();
3928 let me = Keys::generate();
3929 become_local(&me);
3930 let root0_community = saved_community_owned_by(&owner);
3931 let cid = root0_community.id.to_hex();
3932 let channel_id = root0_community.channels[0].id;
3933 let chan_hex = channel_id.to_hex();
3934 let scope = super::super::derive::RekeyScope::Channel(channel_id);
3935 let genesis_key = *root0_community.channels[0].key.as_bytes();
3936
3937 let root1 = [0x99u8; 32];
3939 crate::db::community::advance_server_root_epoch(&cid, 1, &root1).unwrap();
3940 let community = crate::db::community::load_community(&root0_community.id).unwrap().unwrap();
3941 assert_eq!(community.server_root_epoch, crate::community::Epoch(1));
3942
3943 let (k1, k2) = ([0x11u8; 32], [0x22u8; 32]);
3945 let blob1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &k1).unwrap();
3946 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
3947 let ev1 = super::super::rekey::build_channel_rekey_event(
3948 &Keys::generate(), &owner, root0_community.server_root_key.as_bytes(), &channel_id,
3949 crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob1]).unwrap();
3950 let blob2 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(2), &k2).unwrap();
3951 let commit1 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(1), &k1);
3952 let ev2 = super::super::rekey::build_channel_rekey_event(
3953 &Keys::generate(), &owner, &root1, &channel_id,
3954 crate::community::Epoch(2), crate::community::Epoch(1), &commit1, &[blob2]).unwrap();
3955
3956 let relay = MemoryRelay::new();
3957 relay.inject(&ev1, &community.relays);
3958 relay.inject(&ev2, &community.relays);
3959
3960 let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
3961 assert_eq!(reached, 2, "reached the latest channel epoch across the server-root rotation");
3962 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(k1),
3963 "epoch-1 key recovered from a rekey under the PRIOR server root");
3964 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 2).unwrap(), Some(k2));
3965 }
3966
3967 #[tokio::test]
3968 async fn catch_up_backfills_a_sub_head_gap() {
3969 let (_tmp, _guard) = init_test_db();
3972 let owner = Keys::generate();
3973 let me = Keys::generate();
3974 become_local(&me);
3975 let community = saved_community_owned_by(&owner);
3976 let cid = community.id.to_hex();
3977 let channel_id = community.channels[0].id;
3978 let chan_hex = channel_id.to_hex();
3979 let scope = super::super::derive::RekeyScope::Channel(channel_id);
3980 let genesis_key = *community.channels[0].key.as_bytes();
3981
3982 let k2 = [0x22u8; 32];
3984 crate::db::community::advance_channel_epoch(&cid, &chan_hex, 2, &k2).unwrap();
3985 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), None, "epoch 1 starts as a hole");
3986
3987 let k1 = [0x11u8; 32];
3989 let blob1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &k1).unwrap();
3990 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
3991 let ev1 = super::super::rekey::build_channel_rekey_event(
3992 &Keys::generate(), &owner, community.server_root_key.as_bytes(), &channel_id,
3993 crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob1]).unwrap();
3994 let relay = MemoryRelay::new();
3995 relay.inject(&ev1, &community.relays);
3996
3997 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
3998 let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
3999 assert_eq!(reached, 2, "head unchanged (gap-fill never regresses it)");
4000 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(k1),
4001 "the sub-head hole was backfilled");
4002 }
4003
4004 #[tokio::test]
4005 async fn catch_up_walks_a_chain_of_rotations_to_the_latest() {
4006 let (_tmp, _guard) = init_test_db();
4007 let owner = Keys::generate();
4008 let me = Keys::generate();
4009 become_local(&me); let community = saved_community_owned_by(&owner);
4011 let channel_id = community.channels[0].id;
4012 let cid = community.id.to_hex();
4013 let chan_hex = channel_id.to_hex();
4014
4015 let (events, keys) = build_rekey_chain(&owner, &community, &me.public_key(), 3);
4017 let relay = MemoryRelay::new();
4018 for ev in events.iter().rev() {
4019 relay.inject(ev, &community.relays);
4020 }
4021
4022 let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4023 assert_eq!(reached, 3, "caught up to the latest epoch");
4024 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4026 assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(3));
4027 assert_eq!(reloaded.channels[0].key.as_bytes(), &keys[2]);
4028 for (i, k) in keys.iter().enumerate() {
4029 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, (i + 1) as u64).unwrap(), Some(*k));
4030 }
4031 }
4032
4033 #[tokio::test]
4034 async fn catch_up_slides_across_the_window_boundary() {
4035 let (_tmp, _guard) = init_test_db();
4039 let owner = Keys::generate();
4040 let me = Keys::generate();
4041 become_local(&me);
4042 let community = saved_community_owned_by(&owner);
4043 let channel_id = community.channels[0].id;
4044 let cid = community.id.to_hex();
4045 let chan_hex = channel_id.to_hex();
4046
4047 let (events, keys) = build_rekey_chain(&owner, &community, &me.public_key(), 70);
4048 let relay = MemoryRelay::new();
4049 for ev in &events {
4050 relay.inject(ev, &community.relays);
4051 }
4052 let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4053 assert_eq!(reached, 70, "slid past the 64-epoch window boundary to the latest");
4054 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 70).unwrap(), Some(keys[69]));
4055 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 64).unwrap(), Some(keys[63]), "window-1 keys retained too");
4056 }
4057
4058 fn build_base_rekey_chain(
4064 owner: &Keys, community: &Community, recipient_pk: &nostr_sdk::prelude::PublicKey, n: u64,
4065 ) -> (Vec<Event>, Vec<[u8; 32]>) {
4066 let mut prior_root = *community.server_root_key.as_bytes();
4067 let mut events = Vec::new();
4068 let mut roots = Vec::new();
4069 for e in 1..=n {
4070 let new_root = [(e % 256) as u8; 32];
4071 let blob = super::super::rekey::build_rekey_blob(
4072 owner.secret_key(), recipient_pk, super::super::derive::RekeyScope::ServerRoot, crate::community::Epoch(e), &new_root,
4073 )
4074 .unwrap();
4075 let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(e - 1), &prior_root);
4076 events.push(super::super::rekey::build_server_root_rekey_event(
4077 &Keys::generate(), owner, &prior_root, &community.id,
4078 crate::community::Epoch(e), crate::community::Epoch(e - 1), &commit, &[blob],
4079 ).unwrap());
4080 roots.push(new_root);
4081 prior_root = new_root;
4082 }
4083 (events, roots)
4084 }
4085
4086 #[tokio::test]
4087 async fn catch_up_server_root_walks_a_chain_of_base_rotations() {
4088 let (_tmp, _guard) = init_test_db();
4089 let owner = Keys::generate();
4090 let me = Keys::generate();
4091 become_local(&me);
4092 let community = saved_community_owned_by(&owner);
4093 let cid = community.id.to_hex();
4094
4095 let (events, roots) = build_base_rekey_chain(&owner, &community, &me.public_key(), 3);
4096 let relay = MemoryRelay::new();
4097 for ev in events.iter().rev() {
4098 relay.inject(ev, &community.relays);
4099 }
4100 let reached = catch_up_server_root(&relay, &community).await.unwrap();
4101 assert_eq!(reached.epoch, 3, "walked the base chain to the latest epoch");
4102 assert!(!reached.removed, "a normal catch-up is not a removal");
4103 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4104 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(3));
4105 assert_eq!(reloaded.server_root_key.as_bytes(), &roots[2], "base head is the latest root");
4106 for (i, r) in roots.iter().enumerate() {
4108 assert_eq!(crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, (i + 1) as u64).unwrap(), Some(*r));
4109 }
4110 }
4111
4112 #[tokio::test]
4113 async fn catch_up_recovers_from_a_split_base_rotation_second_chunk() {
4114 let (_tmp, _guard) = init_test_db();
4118 let owner = Keys::generate();
4119 let me = Keys::generate();
4120 become_local(&me);
4121 let community = saved_community_owned_by(&owner);
4122 let genesis = *community.server_root_key.as_bytes();
4123 let new_root = [0x5Au8; 32];
4124 let scope = super::super::derive::RekeyScope::ServerRoot;
4125 let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis);
4126 let mk = |recipient: &nostr_sdk::prelude::PublicKey| {
4127 let blob = super::super::rekey::build_rekey_blob(owner.secret_key(), recipient, scope, crate::community::Epoch(1), &new_root).unwrap();
4128 super::super::rekey::build_server_root_rekey_event(
4129 &Keys::generate(), &owner, &genesis, &community.id,
4130 crate::community::Epoch(1), crate::community::Epoch(0), &commit, &[blob],
4131 ).unwrap()
4132 };
4133 let relay = MemoryRelay::new();
4134 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();
4138 assert_eq!(reached.epoch, 1, "recovered the split rotation via the second chunk");
4139 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4140 assert_eq!(reloaded.server_root_key.as_bytes(), &new_root, "recovered the new root from chunk 2");
4141 }
4142
4143 #[tokio::test]
4144 async fn catch_up_converges_concurrent_refoundings_on_the_lowest_root() {
4145 let (_tmp, _guard) = init_test_db();
4149 let owner = Keys::generate();
4150 let me = Keys::generate();
4151 become_local(&me);
4152 let community = saved_community_owned_by(&owner);
4153 let genesis = *community.server_root_key.as_bytes();
4154 let scope = super::super::derive::RekeyScope::ServerRoot;
4155 let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis);
4156 let root_lo = [0x10u8; 32];
4157 let root_hi = [0xF0u8; 32]; let mk = |root: &[u8; 32]| {
4159 let blob = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), root).unwrap();
4160 super::super::rekey::build_server_root_rekey_event(
4161 &Keys::generate(), &owner, &genesis, &community.id,
4162 crate::community::Epoch(1), crate::community::Epoch(0), &commit, &[blob],
4163 ).unwrap()
4164 };
4165 let relay = MemoryRelay::new();
4166 relay.inject(&mk(&root_hi), &community.relays);
4168 relay.inject(&mk(&root_lo), &community.relays);
4169
4170 let reached = catch_up_server_root(&relay, &community).await.unwrap();
4171 assert_eq!(reached.epoch, 1, "advanced one epoch");
4172 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4173 assert_eq!(reloaded.server_root_key.as_bytes(), &root_lo, "converged on the LOWEST root, not the first-arrived");
4174 }
4175
4176 #[tokio::test]
4177 async fn rotate_retry_reuses_the_archived_root_no_same_epoch_fork() {
4178 let (_tmp, _guard) = init_test_db();
4183 let owner = Keys::generate();
4184 become_local(&owner);
4185 let community = saved_community_owned_by(&owner);
4186 let cid = community.id.to_hex();
4187 let relay = RekeyFailingRelay::new(); let member = Keys::generate();
4189
4190 assert!(rotate_server_root(&relay, &community, &[member.public_key()]).await.is_err(), "the rekey publish fails");
4191 let k1 = crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap()
4192 .expect("the new root is archived before publishing (fork-safety)");
4193 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4194 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(0), "head not advanced on a failed publish");
4195
4196 relay.allow_rekey();
4197 rotate_server_root(&relay, &reloaded, &[member.public_key()]).await.unwrap();
4198 let k2 = crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap().unwrap();
4199 assert_eq!(k1, k2, "the retry REUSES the archived root — no second root for epoch 1, no fork");
4200 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
4201 assert_eq!(after.server_root_epoch, crate::community::Epoch(1), "the retry completed the rotation");
4202 assert_eq!(after.server_root_key.as_bytes(), &k1, "the committed root is the one minted on attempt 1");
4203 }
4204
4205 #[tokio::test]
4206 async fn rotate_server_root_splits_a_large_recipient_set_into_multiple_events() {
4207 let (_tmp, _guard) = init_test_db();
4209 let owner = Keys::generate();
4210 become_local(&owner);
4211 let community = saved_community_owned_by(&owner);
4212 let genesis = *community.server_root_key.as_bytes();
4213 let relay = MemoryRelay::new();
4214 let recipients: Vec<_> = (0..super::super::rekey::MAX_REKEY_BLOBS).map(|_| Keys::generate().public_key()).collect();
4216 rotate_server_root(&relay, &community, &recipients).await.unwrap();
4217 let addr = super::super::derive::base_rekey_pseudonym(&super::super::ServerRootKey(genesis), &community.id, crate::community::Epoch(1)).to_hex();
4218 let evs = relay
4219 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() }, &community.relays)
4220 .await
4221 .unwrap();
4222 assert_eq!(evs.len(), 2, "a >MAX_REKEY_BLOBS rotation splits into 2 events at one address");
4223 }
4224
4225 #[tokio::test]
4226 async fn catch_up_server_root_is_a_noop_with_no_rotations() {
4227 let (_tmp, _guard) = init_test_db();
4228 let owner = Keys::generate();
4229 let me = Keys::generate();
4230 become_local(&me);
4231 let community = saved_community_owned_by(&owner);
4232 let relay = MemoryRelay::new();
4233 assert_eq!(catch_up_server_root(&relay, &community).await.unwrap().epoch, 0, "no base rotations → stays at 0");
4234 }
4235
4236 #[tokio::test]
4237 async fn concurrent_refounders_converge_to_the_lowest_root() {
4238 let (_tmp, _guard) = init_test_db();
4243 let owner = Keys::generate();
4244 let me = Keys::generate();
4245 become_local(&me); let community = saved_community_owned_by(&owner);
4247 let cid = community.id.to_hex();
4248 let genesis_root = *community.server_root_key.as_bytes();
4249 let scope = super::super::derive::RekeyScope::ServerRoot;
4250
4251 let root_lo = [0x10u8; 32];
4254 let root_hi = [0x99u8; 32]; let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_root);
4256 let blob_lo = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &root_lo).unwrap();
4257 let ev_lo = super::super::rekey::build_server_root_rekey_event(
4258 &Keys::generate(), &owner, &genesis_root, &community.id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_lo]).unwrap();
4259
4260 let relay = MemoryRelay::new();
4261 relay.inject(&ev_lo, &community.relays);
4262
4263 crate::db::community::advance_server_root_epoch(&cid, 1, &root_hi).unwrap();
4265 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4266 assert_eq!(community.server_root_key.as_bytes(), &root_hi, "start on the higher root");
4267
4268 let out = catch_up_server_root(&relay, &community).await.unwrap();
4269 assert_eq!(out.epoch, 1, "converged in place at the same epoch (not advanced)");
4270 assert!(!out.removed);
4271 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
4272 assert_eq!(after.server_root_key.as_bytes(), &root_lo, "originator converged to the lowest authorized root");
4273
4274 let _ = catch_up_server_root(&relay, &after).await.unwrap();
4276 assert_eq!(crate::db::community::load_community(&community.id).unwrap().unwrap().server_root_key.as_bytes(), &root_lo, "no flip-flop");
4277 }
4278
4279 #[tokio::test]
4280 async fn banned_rotators_rekey_is_not_a_convergence_candidate() {
4281 let (_tmp, _guard) = init_test_db();
4286 let owner = Keys::generate();
4287 let me = Keys::generate();
4288 let banned_admin = Keys::generate();
4289 become_local(&me);
4290 let community = saved_community_owned_by(&owner);
4291 let cid = community.id.to_hex();
4292 let genesis_root = *community.server_root_key.as_bytes();
4293 let scope = super::super::derive::RekeyScope::ServerRoot;
4294
4295 let role_id = "e".repeat(64);
4297 let roster = crate::community::roles::CommunityRoles {
4298 roles: vec![crate::community::roles::Role::admin(role_id.clone())],
4299 grants: vec![crate::community::roles::MemberGrant { member: banned_admin.public_key().to_hex(), role_ids: vec![role_id] }],
4300 };
4301 crate::db::community::set_community_roles(&cid, &roster, 1).unwrap();
4302 crate::db::community::set_community_banlist(&cid, &[banned_admin.public_key().to_hex()], 2).unwrap();
4304
4305 let root_evil = [0x01u8; 32];
4307 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_root);
4308 let blob = super::super::rekey::build_rekey_blob(banned_admin.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &root_evil).unwrap();
4309 let ev = super::super::rekey::build_server_root_rekey_event(
4310 &Keys::generate(), &banned_admin, &genesis_root, &community.id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob]).unwrap();
4311 let relay = MemoryRelay::new();
4312 relay.inject(&ev, &community.relays);
4313
4314 let out = catch_up_server_root(&relay, &community).await.unwrap();
4317 assert_eq!(out.epoch, 0, "banned rotator's re-founding must not advance the base");
4318 assert!(!out.removed, "banned rotator's exclusion must not read as an authorized removal");
4319 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
4320 assert_eq!(after.server_root_key.as_bytes(), &genesis_root, "root unchanged");
4321
4322 let parsed = super::super::rekey::open_rekey_event(&ev, &genesis_root).unwrap();
4324 assert!(apply_server_root_rekey(&community, &parsed).is_err(), "apply must refuse a banned rotator");
4325 }
4326
4327 #[tokio::test]
4328 async fn heal_abandons_a_deauthorized_root_for_the_authorized_higher_sibling() {
4329 let (_tmp, _guard) = init_test_db();
4333 let owner = Keys::generate();
4334 let me = Keys::generate();
4335 let banned_admin = Keys::generate();
4336 become_local(&me);
4337 let community = saved_community_owned_by(&owner);
4338 let cid = community.id.to_hex();
4339 let genesis_root = *community.server_root_key.as_bytes();
4340 let scope = super::super::derive::RekeyScope::ServerRoot;
4341 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_root);
4342
4343 let root_evil = [0x01u8; 32];
4346 let root_owner = [0x77u8; 32];
4347 let blob_evil = super::super::rekey::build_rekey_blob(banned_admin.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &root_evil).unwrap();
4348 let ev_evil = super::super::rekey::build_server_root_rekey_event(
4349 &Keys::generate(), &banned_admin, &genesis_root, &community.id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_evil]).unwrap();
4350 let blob_owner = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &root_owner).unwrap();
4351 let ev_owner = super::super::rekey::build_server_root_rekey_event(
4352 &Keys::generate(), &owner, &genesis_root, &community.id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_owner]).unwrap();
4353 let relay = MemoryRelay::new();
4354 relay.inject(&ev_evil, &community.relays);
4355 relay.inject(&ev_owner, &community.relays);
4356
4357 crate::db::community::advance_server_root_epoch(&cid, 1, &root_evil).unwrap();
4359 crate::db::community::set_community_banlist(&cid, &[banned_admin.public_key().to_hex()], 2).unwrap();
4360 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4361 assert_eq!(community.server_root_key.as_bytes(), &root_evil, "start partitioned on the attacker's root");
4362
4363 let out = catch_up_server_root(&relay, &community).await.unwrap();
4364 assert_eq!(out.epoch, 1);
4365 assert!(!out.removed);
4366 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
4367 assert_eq!(after.server_root_key.as_bytes(), &root_owner,
4368 "heal must abandon the deauthorized root and adopt the owner's higher sibling");
4369
4370 let _ = catch_up_server_root(&relay, &after).await.unwrap();
4372 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");
4373 }
4374
4375 #[tokio::test]
4376 async fn concurrent_channel_rekeyers_converge_to_the_lowest_key() {
4377 let (_tmp, _guard) = init_test_db();
4382 let owner = Keys::generate();
4383 let me = Keys::generate();
4384 become_local(&me); let community = saved_community_owned_by(&owner);
4386 let cid = community.id.to_hex();
4387 let channel_id = community.channels[0].id;
4388 let chan_hex = channel_id.to_hex();
4389 let scope = super::super::derive::RekeyScope::Channel(channel_id);
4390 let genesis_key = *community.channels[0].key.as_bytes();
4391 let root = *community.server_root_key.as_bytes();
4392 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
4393
4394 let key_lo = [0x10u8; 32];
4396 let key_hi = [0x99u8; 32];
4397 let blob_lo = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_lo).unwrap();
4398 let blob_hi = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_hi).unwrap();
4399 let ev_lo = super::super::rekey::build_channel_rekey_event(
4400 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_lo]).unwrap();
4401 let ev_hi = super::super::rekey::build_channel_rekey_event(
4402 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_hi]).unwrap();
4403
4404 let relay = MemoryRelay::new();
4405 relay.inject(&ev_hi, &community.relays); relay.inject(&ev_lo, &community.relays);
4407
4408 crate::db::community::advance_server_root_epoch(&cid, 1, &[0x42u8; 32]).unwrap();
4413 crate::db::community::advance_channel_epoch(&cid, &chan_hex, 1, &key_hi).unwrap();
4415 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4416
4417 let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4418 assert_eq!(reached, 1, "converged in place at the same channel epoch");
4419 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(key_lo),
4420 "adopted the lowest delivered key regardless of relay order");
4421
4422 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
4424 let _ = catch_up_channel_rekeys(&relay, &after, &channel_id).await.unwrap();
4425 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(key_lo), "no flip-flop");
4426 }
4427
4428 #[tokio::test]
4429 async fn concurrent_channel_rekeyers_converge_when_i_authored_the_losing_fork() {
4430 let (_tmp, _guard) = init_test_db();
4435 let owner = Keys::generate();
4436 let me = Keys::generate(); become_local(&me);
4438 let community = saved_community_owned_by(&owner);
4439 let cid = community.id.to_hex();
4440 let channel_id = community.channels[0].id;
4441 let chan_hex = channel_id.to_hex();
4442 let scope = super::super::derive::RekeyScope::Channel(channel_id);
4443 let genesis_key = *community.channels[0].key.as_bytes();
4444 let root = *community.server_root_key.as_bytes();
4445 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
4446
4447 let role_id = "d".repeat(64);
4451 let roster = crate::community::roles::CommunityRoles {
4452 roles: vec![crate::community::roles::Role::admin(role_id.clone())],
4453 grants: vec![crate::community::roles::MemberGrant { member: me.public_key().to_hex(), role_ids: vec![role_id] }],
4454 };
4455 crate::db::community::set_community_roles(&cid, &roster, 1).unwrap();
4456
4457 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();
4461 let ev_lo = super::super::rekey::build_channel_rekey_event(
4462 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_lo]).unwrap();
4463 let blob_hi = super::super::rekey::build_rekey_blob(me.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_hi).unwrap();
4465 let ev_hi = super::super::rekey::build_channel_rekey_event(
4466 &Keys::generate(), &me, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_hi]).unwrap();
4467
4468 let relay = MemoryRelay::new();
4469 relay.inject(&ev_hi, &community.relays);
4470 relay.inject(&ev_lo, &community.relays);
4471
4472 crate::db::community::advance_server_root_epoch(&cid, 1, &[0x42u8; 32]).unwrap();
4474 crate::db::community::advance_channel_epoch(&cid, &chan_hex, 1, &key_hi).unwrap();
4476 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4477
4478 let _ = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4479 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(key_lo),
4480 "I authored the losing fork but must converge DOWN to the owner's lower key");
4481 }
4482
4483 #[tokio::test]
4484 async fn reorg_through_a_fork_heals_the_forked_past_epoch() {
4485 let (_tmp, _guard) = init_test_db();
4490 let owner = Keys::generate();
4491 let me = Keys::generate();
4492 become_local(&me);
4493 let community = saved_community_owned_by(&owner);
4494 let cid = community.id.to_hex();
4495 let channel_id = community.channels[0].id;
4496 let chan_hex = channel_id.to_hex();
4497 let scope = super::super::derive::RekeyScope::Channel(channel_id);
4498 let genesis_key = *community.channels[0].key.as_bytes();
4499 let root = *community.server_root_key.as_bytes();
4500 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
4501
4502 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();
4507 let blob_hi1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_hi1).unwrap();
4508 let ev_lo1 = super::super::rekey::build_channel_rekey_event(
4509 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_lo1]).unwrap();
4510 let ev_hi1 = super::super::rekey::build_channel_rekey_event(
4511 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_hi1]).unwrap();
4512 let commit1_win = super::super::rekey::epoch_key_commitment(crate::community::Epoch(1), &key_lo1);
4514 let blob_e2 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(2), &key_e2).unwrap();
4515 let ev_e2 = super::super::rekey::build_channel_rekey_event(
4516 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(2), crate::community::Epoch(1), &commit1_win, &[blob_e2]).unwrap();
4517
4518 let relay = MemoryRelay::new();
4519 relay.inject(&ev_lo1, &community.relays);
4520 relay.inject(&ev_hi1, &community.relays);
4521 relay.inject(&ev_e2, &community.relays);
4522
4523 crate::db::community::advance_server_root_epoch(&cid, 1, &[0x42u8; 32]).unwrap();
4525 crate::db::community::advance_channel_epoch(&cid, &chan_hex, 1, &key_hi1).unwrap();
4527 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4528
4529 let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4530 assert_eq!(reached, 2, "reorged forward to the head epoch");
4531 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 2).unwrap(), Some(key_e2), "head epoch adopted");
4532 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(key_lo1),
4533 "the FORKED past epoch re-converged to the lowest sibling (its messages become readable)");
4534 }
4535
4536 #[tokio::test]
4537 async fn window_heal_converges_an_already_reorged_past_fork() {
4538 let (_tmp, _guard) = init_test_db();
4543 let owner = Keys::generate();
4544 let me = Keys::generate();
4545 become_local(&me);
4546 let community = saved_community_owned_by(&owner);
4547 let cid = community.id.to_hex();
4548 let channel_id = community.channels[0].id;
4549 let chan_hex = channel_id.to_hex();
4550 let scope = super::super::derive::RekeyScope::Channel(channel_id);
4551 let genesis_key = *community.channels[0].key.as_bytes();
4552 let root = *community.server_root_key.as_bytes();
4553 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
4554
4555 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();
4559 let blob_hi1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_hi1).unwrap();
4560 let ev_lo1 = super::super::rekey::build_channel_rekey_event(
4561 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_lo1]).unwrap();
4562 let ev_hi1 = super::super::rekey::build_channel_rekey_event(
4563 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_hi1]).unwrap();
4564
4565 let relay = MemoryRelay::new();
4566 relay.inject(&ev_lo1, &community.relays);
4567 relay.inject(&ev_hi1, &community.relays);
4568 crate::db::community::advance_server_root_epoch(&cid, 1, &[0x42u8; 32]).unwrap();
4572 crate::db::community::advance_channel_epoch(&cid, &chan_hex, 1, &key_hi1).unwrap();
4574 crate::db::community::advance_channel_epoch(&cid, &chan_hex, 2, &key_e2).unwrap();
4575 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4576
4577 let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4578 assert_eq!(reached, 2, "head unchanged (no new rekey to apply)");
4579 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 2).unwrap(), Some(key_e2), "head epoch untouched");
4580 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(key_lo1),
4581 "the already-forked past epoch re-converged to the lowest sibling via the window heal (no in-sync reorg)");
4582 }
4583
4584 #[tokio::test]
4585 async fn channel_heal_cannot_converge_to_a_key_i_was_not_given() {
4586 let (_tmp, _guard) = init_test_db();
4593 let owner = Keys::generate();
4594 let me = Keys::generate();
4595 become_local(&me);
4596 let community = saved_community_owned_by(&owner);
4597 let cid = community.id.to_hex();
4598 let channel_id = community.channels[0].id;
4599 let chan_hex = channel_id.to_hex();
4600 let scope = super::super::derive::RekeyScope::Channel(channel_id);
4601 let genesis_key = *community.channels[0].key.as_bytes();
4602 let root = *community.server_root_key.as_bytes();
4603 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
4604
4605 let key_lo = [0x10u8; 32]; let key_hi = [0x99u8; 32]; let other = Keys::generate();
4609 let blob_lo = super::super::rekey::build_rekey_blob(owner.secret_key(), &other.public_key(), scope, crate::community::Epoch(1), &key_lo).unwrap();
4610 let ev_lo = super::super::rekey::build_channel_rekey_event(
4611 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_lo]).unwrap();
4612 let blob_hi = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_hi).unwrap();
4614 let ev_hi = super::super::rekey::build_channel_rekey_event(
4615 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_hi]).unwrap();
4616
4617 let relay = MemoryRelay::new();
4618 relay.inject(&ev_lo, &community.relays);
4619 relay.inject(&ev_hi, &community.relays);
4620 crate::db::community::advance_server_root_epoch(&cid, 1, &[0x42u8; 32]).unwrap();
4621 crate::db::community::advance_channel_epoch(&cid, &chan_hex, 1, &key_hi).unwrap();
4622 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4623
4624 let _ = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4625 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(key_hi),
4627 "excluded from the winning rekey ⇒ cannot converge");
4628 }
4629
4630 #[tokio::test]
4631 async fn refounding_channel_rekey_is_sealed_under_the_prior_root() {
4632 let (_tmp, _guard) = init_test_db();
4637 let owner = Keys::generate();
4638 become_local(&owner); let community = saved_community_owned_by(&owner);
4640 let channel_id = community.channels[0].id;
4641 let prior_root = [0x11u8; 32]; let relay = MemoryRelay::new();
4644 rotate_channel(&relay, &community, &channel_id, &[owner.public_key()], &prior_root).await.unwrap();
4645
4646 let z = super::super::derive::rekey_pseudonym(&crate::community::ServerRootKey(prior_root), &channel_id, crate::community::Epoch(1)).to_hex();
4648 let q = Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![z], ..Default::default() };
4649 let evs = relay.fetch(&q, &community.relays).await.unwrap();
4650 assert_eq!(evs.len(), 1, "channel rekey is addressed at the PRIOR-root pseudonym");
4651 assert!(super::super::rekey::open_rekey_event(&evs[0], &prior_root).is_ok(),
4653 "opens under the prior (shared) root every retained member still holds");
4654 assert!(super::super::rekey::open_rekey_event(&evs[0], community.server_root_key.as_bytes()).is_err(),
4655 "does NOT open under the current/new root (which a base-fork loser would have dropped)");
4656 }
4657
4658 #[tokio::test]
4659 async fn apply_channel_rekey_converges_past_a_divergent_prior_epoch() {
4660 let (_tmp, _guard) = init_test_db();
4665 let owner = Keys::generate();
4666 let me = Keys::generate();
4667 become_local(&me);
4668 let community = saved_community_owned_by(&owner);
4669 let cid = community.id.to_hex();
4670 let channel_id = community.channels[0].id;
4671 let chan_hex = channel_id.to_hex();
4672 let scope = super::super::derive::RekeyScope::Channel(channel_id);
4673 let root = *community.server_root_key.as_bytes();
4674
4675 let my_fork_key = [0xAAu8; 32];
4677 crate::db::community::advance_channel_epoch(&cid, &chan_hex, 1, &my_fork_key).unwrap();
4678
4679 let winner_epoch1 = [0xBBu8; 32];
4681 let new_key = [0x22u8; 32];
4682 let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(1), &winner_epoch1);
4683 let blob = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(2), &new_key).unwrap();
4684 let ev = super::super::rekey::build_channel_rekey_event(
4685 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(2), crate::community::Epoch(1), &commit, &[blob]).unwrap();
4686 let parsed = super::super::rekey::open_rekey_event(&ev, &root).unwrap();
4687
4688 let outcome = apply_channel_rekey(&community, &parsed).unwrap();
4689 assert!(matches!(outcome, RekeyOutcome::Applied { head_advanced: true }),
4690 "must converge forward past the divergent prior epoch, got {outcome:?}");
4691 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 2).unwrap(), Some(new_key),
4692 "adopted the winner's epoch-2 key");
4693 }
4694
4695 #[tokio::test]
4696 async fn catch_up_server_root_stops_when_removed_from_base() {
4697 let (_tmp, _guard) = init_test_db();
4700 let owner = Keys::generate();
4701 let me = Keys::generate();
4702 become_local(&me);
4703 let community = saved_community_owned_by(&owner);
4704 let scope = super::super::derive::RekeyScope::ServerRoot;
4705 let relay = MemoryRelay::new();
4706
4707 let root1 = [0x11u8; 32];
4709 let b1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &root1).unwrap();
4710 let e1 = super::super::rekey::build_server_root_rekey_event(
4711 &Keys::generate(), &owner, community.server_root_key.as_bytes(), &community.id,
4712 crate::community::Epoch(1), crate::community::Epoch(0),
4713 &super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), community.server_root_key.as_bytes()), &[b1],
4714 ).unwrap();
4715 let other = Keys::generate();
4717 let b2 = super::super::rekey::build_rekey_blob(owner.secret_key(), &other.public_key(), scope, crate::community::Epoch(2), &[0x22u8; 32]).unwrap();
4718 let e2 = super::super::rekey::build_server_root_rekey_event(
4719 &Keys::generate(), &owner, &root1, &community.id,
4720 crate::community::Epoch(2), crate::community::Epoch(1),
4721 &super::super::rekey::epoch_key_commitment(crate::community::Epoch(1), &root1), &[b2],
4722 ).unwrap();
4723 relay.inject(&e1, &community.relays);
4724 relay.inject(&e2, &community.relays);
4725
4726 let reached = catch_up_server_root(&relay, &community).await.unwrap();
4727 assert_eq!(reached.epoch, 1, "stops at the last base epoch I was a recipient of");
4728 assert!(reached.removed, "excluded by an AUTHORIZED (owner) base rotation → flagged removed so the caller erases");
4729 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4730 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(1));
4731 }
4732
4733 #[tokio::test]
4734 async fn catch_up_is_a_noop_with_no_rotations() {
4735 let (_tmp, _guard) = init_test_db();
4736 let owner = Keys::generate();
4737 let me = Keys::generate();
4738 become_local(&me);
4739 let community = saved_community_owned_by(&owner);
4740 let relay = MemoryRelay::new(); let reached = catch_up_channel_rekeys(&relay, &community, &community.channels[0].id).await.unwrap();
4742 assert_eq!(reached, 0, "no rotations → stays at the held epoch");
4743 }
4744
4745 #[tokio::test]
4746 async fn catch_up_stops_when_removed_midway() {
4747 let (_tmp, _guard) = init_test_db();
4750 let owner = Keys::generate();
4751 let me = Keys::generate();
4752 become_local(&me);
4753 let community = saved_community_owned_by(&owner);
4754 let channel_id = community.channels[0].id;
4755 let chan = &community.channels[0];
4756 let scope = super::super::derive::RekeyScope::Channel(channel_id);
4757 let relay = MemoryRelay::new();
4758
4759 let k1 = [0x11u8; 32];
4761 let b1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &k1).unwrap();
4762 let e1 = super::super::rekey::build_channel_rekey_event(
4763 &Keys::generate(), &owner, community.server_root_key.as_bytes(), &channel_id,
4764 crate::community::Epoch(1), crate::community::Epoch(0),
4765 &super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), chan.key.as_bytes()), &[b1],
4766 ).unwrap();
4767 let other = Keys::generate();
4769 let b2 = super::super::rekey::build_rekey_blob(owner.secret_key(), &other.public_key(), scope, crate::community::Epoch(2), &[0x22u8; 32]).unwrap();
4770 let e2 = super::super::rekey::build_channel_rekey_event(
4771 &Keys::generate(), &owner, community.server_root_key.as_bytes(), &channel_id,
4772 crate::community::Epoch(2), crate::community::Epoch(1),
4773 &super::super::rekey::epoch_key_commitment(crate::community::Epoch(1), &k1), &[b2],
4774 ).unwrap();
4775 relay.inject(&e1, &community.relays);
4776 relay.inject(&e2, &community.relays);
4777
4778 let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4779 assert_eq!(reached, 1, "stops at the last epoch I was a recipient of");
4780 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4781 assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(1));
4782 }
4783
4784 #[tokio::test]
4785 async fn rotate_channel_rejects_unauthorized() {
4786 let (_tmp, _guard) = init_test_db();
4787 let owner = Keys::generate();
4788 let rogue = Keys::generate();
4789 become_local(&rogue); let community = saved_community_owned_by(&owner);
4791 let relay = MemoryRelay::new();
4792 assert!(
4793 rotate_channel(&relay, &community, &community.channels[0].id, &[], community.server_root_key.as_bytes()).await.is_err(),
4794 "a non-authorized member cannot rotate"
4795 );
4796 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4798 assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(0));
4799 }
4800
4801 #[tokio::test]
4804 async fn rotate_server_root_publishes_recoverable_rekey_and_advances_base() {
4805 use crate::community::derive::{base_rekey_pseudonym, recipient_pseudonym};
4806 use crate::community::rekey::{open_rekey_blob, open_rekey_event, rekey_pairwise_secret};
4807 let (_tmp, _guard) = init_test_db();
4808 let owner = Keys::generate();
4809 become_local(&owner); let community = saved_community_owned_by(&owner);
4811 let genesis_root = *community.server_root_key.as_bytes();
4812 let member = Keys::generate();
4813 let relay = MemoryRelay::new();
4814
4815 let new_epoch = rotate_server_root(&relay, &community, &[member.public_key()]).await.expect("rotate base");
4816 assert_eq!(new_epoch, 1);
4817
4818 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4820 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(1));
4821 assert_ne!(reloaded.server_root_key.as_bytes(), &genesis_root, "base root is fresh-random, not the genesis");
4822
4823 let addr = base_rekey_pseudonym(&crate::community::ServerRootKey(genesis_root), &community.id, crate::community::Epoch(1)).to_hex();
4825 let found = relay
4826 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() }, &community.relays)
4827 .await
4828 .unwrap();
4829 assert_eq!(found.len(), 1, "base rekey addressable by its prior-root pseudonym");
4830 let parsed = open_rekey_event(&found[0], &genesis_root).unwrap();
4831 assert!(matches!(parsed.scope, crate::community::derive::RekeyScope::ServerRoot));
4832 assert_eq!(parsed.rotator, owner.public_key());
4833 assert_eq!(parsed.blobs.len(), 2, "member + me (multi-device)");
4834
4835 let secret = rekey_pairwise_secret(member.secret_key(), &parsed.rotator).unwrap();
4837 let loc = recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
4838 let mine = parsed.blobs.iter().find(|b| b.locator == loc).expect("member's blob present");
4839 let recovered = open_rekey_blob(member.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine).unwrap();
4840 assert_eq!(reloaded.server_root_key.as_bytes(), &recovered, "member's recovered root == owner's advanced base head");
4841 }
4842
4843 #[tokio::test]
4844 async fn rotate_server_root_failed_publish_leaves_base_unadvanced() {
4845 let (_tmp, _guard) = init_test_db();
4846 let owner = Keys::generate();
4847 become_local(&owner);
4848 let community = saved_community_owned_by(&owner);
4849 let member = Keys::generate();
4850 assert!(rotate_server_root(&FailingRelay, &community, &[member.public_key()]).await.is_err());
4851 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4852 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(0), "base head stays put on publish failure");
4853 }
4854
4855 #[tokio::test]
4856 async fn rotate_server_root_dedups_self_in_recipients() {
4857 use crate::community::rekey::open_rekey_event;
4859 let (_tmp, _guard) = init_test_db();
4860 let owner = Keys::generate();
4861 become_local(&owner);
4862 let community = saved_community_owned_by(&owner);
4863 let relay = MemoryRelay::new();
4864 rotate_server_root(&relay, &community, &[owner.public_key()]).await.unwrap();
4865 let addr = crate::community::derive::base_rekey_pseudonym(
4866 &crate::community::ServerRootKey(*community.server_root_key.as_bytes()), &community.id, crate::community::Epoch(1),
4867 )
4868 .to_hex();
4869 let found = relay
4870 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() }, &community.relays)
4871 .await
4872 .unwrap();
4873 let parsed = open_rekey_event(&found[0], community.server_root_key.as_bytes()).unwrap();
4874 assert_eq!(parsed.blobs.len(), 1, "self listed in recipients yields exactly one blob, not two");
4875 }
4876
4877 #[tokio::test]
4878 async fn rotate_server_root_rejects_unauthorized() {
4879 let (_tmp, _guard) = init_test_db();
4880 let owner = Keys::generate();
4881 let rogue = Keys::generate();
4882 become_local(&rogue); let community = saved_community_owned_by(&owner);
4884 let relay = MemoryRelay::new();
4885 assert!(rotate_server_root(&relay, &community, &[]).await.is_err(), "a non-BAN member cannot rotate the base");
4886 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4887 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(0));
4888 }
4889
4890 #[tokio::test]
4891 async fn rotate_server_root_reanchors_the_control_plane_to_the_new_epoch() {
4892 let (_tmp, _guard) = init_test_db();
4895 let relay = MemoryRelay::new();
4896 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
4899 let cid = community.id.to_hex();
4900 assert_eq!(crate::db::community::edition_head_entity_ids(&cid).unwrap().len(), 3);
4901
4902 let member = Keys::generate();
4903 assert_eq!(rotate_server_root(&relay, &community, &[member.public_key()]).await.unwrap(), 1);
4904 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4905 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(1), "base head advanced");
4906
4907 let z = crate::community::roster::control_pseudonym(&reloaded.server_root_key, &community.id, crate::community::Epoch(1));
4909 let evs = relay
4910 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], ..Default::default() }, &community.relays)
4911 .await
4912 .unwrap();
4913 let inners: Vec<_> = evs
4914 .iter()
4915 .filter_map(|o| crate::community::roster::open_control_edition(o, &reloaded.server_root_key).ok())
4916 .collect();
4917 let folded = crate::community::roster::fold_roster(&inners, &community.id, &Default::default());
4918 assert!(!folded.roles.roles.is_empty(), "control plane re-anchored at the new epoch as part of the rotation");
4919 }
4920
4921 #[tokio::test]
4922 async fn admin_refounding_carries_heads_verbatim_preserving_owner_and_peer_roles() {
4923 use crate::community::roles::Permissions;
4927 let (_tmp, _guard) = init_test_db();
4928 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
4929 let owner_hex = owner.public_key().to_hex();
4930 let relay = MemoryRelay::new();
4931 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
4932 let cid = community.id.to_hex();
4933 let admin_role = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
4934
4935 let alice = Keys::generate();
4937 let bob = Keys::generate();
4938 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role.clone()]).await.unwrap();
4939 set_member_grant(&relay, &community, &bob.public_key().to_hex(), vec![admin_role.clone()]).await.unwrap();
4940 let _ = fetch_and_apply_control(&relay, &community).await;
4941 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4942
4943 let mut edited = community.clone();
4946 edited.name = "HQ renamed".into();
4947 republish_community_metadata(&relay, &edited).await.unwrap();
4948 let _ = fetch_and_apply_control(&relay, &community).await;
4949 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4950 assert!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0 >= 2, "GroupRoot now above v1");
4951
4952 become_local(&alice);
4954 let new_epoch = rotate_server_root(&relay, &community, &[owner.public_key(), bob.public_key()]).await.unwrap();
4955 assert_eq!(new_epoch, 1);
4956 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4957 assert_eq!(community.server_root_epoch, crate::community::Epoch(1));
4958
4959 let z = crate::community::roster::control_pseudonym(&community.server_root_key, &community.id, crate::community::Epoch(1));
4961 let evs = relay.fetch(&Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], ..Default::default() }, &community.relays).await.unwrap();
4962 let inners: Vec<_> = evs.iter().filter_map(|o| crate::community::roster::open_control_edition(o, &community.server_root_key).ok()).collect();
4963 let folded = crate::community::roster::fold_roster(&inners, &community.id, &Default::default());
4964 let authed = crate::community::roster::authorize_delegation(&folded, Some(&owner_hex));
4965 assert!(authed.is_authorized(&alice.public_key().to_hex(), Some(&owner_hex), Permissions::BAN), "alice (re-founder) still admin");
4966 assert!(authed.is_authorized(&bob.public_key().to_hex(), Some(&owner_hex), Permissions::BAN), "bob (peer admin) NOT demoted by alice's re-founding");
4967 let new_owner = folded.root_meta.as_ref().and_then(|m| m.owner_attestation.as_ref())
4968 .and_then(|j| Event::from_json(j).ok()).map(|e| e.pubkey.to_hex());
4969 assert_eq!(new_owner.as_deref(), Some(owner_hex.as_str()), "owner deed carried verbatim — ownership intact after an admin re-founding");
4970 assert_eq!(folded.root_meta.as_ref().map(|m| m.name.as_str()), Some("HQ renamed"),
4971 "the >v1 GroupRoot head carried verbatim (content preserved across the re-founding)");
4972 let mut per_entity: std::collections::HashMap<[u8; 32], usize> = std::collections::HashMap::new();
4974 for i in &inners {
4975 if let Ok(p) = crate::community::edition::parse_edition_inner(i) { *per_entity.entry(p.entity_id).or_default() += 1; }
4976 }
4977 assert!(per_entity.values().all(|&c| c == 1), "one edition per entity at the new epoch (compacted)");
4978 }
4979
4980 #[tokio::test]
4984 async fn admin_write_blocked_when_isolated() {
4985 let (_tmp, _guard) = init_test_db();
4986 let me = Keys::generate();
4987 become_local(&me);
4988 let community = saved_community_owned_by(&me);
4989 let cid = community.id.to_hex();
4990 crate::db::community::set_edition_head_with_id(&cid, &cid, 1, &[1u8; 32], &[1u8; 32]).unwrap();
4992 crate::db::community::set_read_cut_target_epoch(&cid, 1).unwrap();
4993 let err = reseal_base_to_observed(&FailingRelay, &community).await.unwrap_err();
4995 assert!(err.contains("offline") || err.contains("can't reach any relay"),
4996 "isolated admin write must fail closed, got: {err}");
4997 assert_eq!(crate::db::community::load_community(&community.id).unwrap().unwrap().server_root_epoch,
4999 crate::community::Epoch(0), "no rotation while isolated");
5000 }
5001
5002 #[tokio::test]
5005 async fn refounding_rotates_channel_keys_too() {
5006 let (_tmp, _guard) = init_test_db();
5007 let relay = MemoryRelay::new();
5008 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5009 let channel_id = community.channels[0].id;
5010 assert_eq!(community.channels[0].epoch, crate::community::Epoch(0));
5011 assert_eq!(community.server_root_epoch, crate::community::Epoch(0));
5012
5013 run_read_cut(&relay, &community, true).await.unwrap();
5014
5015 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
5016 assert_eq!(after.server_root_epoch, crate::community::Epoch(1), "base rotated");
5017 let ch = after.channels.iter().find(|c| c.id == channel_id).unwrap();
5018 assert_eq!(ch.epoch, crate::community::Epoch(1), "channel key rotated too (O2)");
5019 assert_eq!(crate::db::community::channel_rekeyed_at_server_epoch(&community.id.to_hex(), &channel_id.to_hex()).unwrap(),
5020 1, "channel marked rekeyed for the new base epoch");
5021 assert!(!crate::db::community::get_read_cut_pending(&community.id.to_hex()).unwrap(),
5022 "a complete read-cut clears the pending flag");
5023 }
5024
5025 #[tokio::test]
5030 async fn read_cut_resumes_without_double_base_rotation_after_channel_failure() {
5031 struct ChannelRekeyFails {
5035 inner: MemoryRelay,
5036 rekeys: std::sync::atomic::AtomicUsize,
5037 fail_channel: std::sync::atomic::AtomicBool,
5038 }
5039 #[async_trait::async_trait]
5040 impl Transport for ChannelRekeyFails {
5041 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> { Ok(crate::community::transport::PlaneFetch::whole(self.fetch(query, relays).await?)) }
5042 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> { self.inner.publish(e, r).await }
5043 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
5044 if e.kind.as_u16() == event_kind::COMMUNITY_REKEY {
5045 let n = self.rekeys.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5046 if n >= 1 && self.fail_channel.load(std::sync::atomic::Ordering::Relaxed) {
5047 return Err("channel rekey relay down".into());
5048 }
5049 }
5050 self.inner.publish_durable(e, r).await
5051 }
5052 async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> { self.inner.fetch(q, r).await }
5053 }
5054 let (_tmp, _guard) = init_test_db();
5055 let relay = ChannelRekeyFails {
5056 inner: MemoryRelay::new(),
5057 rekeys: std::sync::atomic::AtomicUsize::new(0),
5058 fail_channel: std::sync::atomic::AtomicBool::new(true),
5059 };
5060 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5061 let channel_id = community.channels[0].id;
5062 let cid = community.id.to_hex();
5063 let ch_hex = channel_id.to_hex();
5064
5065 assert!(run_read_cut(&relay, &community, true).await.is_err(), "the channel failure surfaces an error");
5067 let mid = crate::db::community::load_community(&community.id).unwrap().unwrap();
5068 assert_eq!(mid.server_root_epoch, crate::community::Epoch(1), "base advanced exactly once");
5069 assert_eq!(mid.channels.iter().find(|c| c.id == channel_id).unwrap().epoch, crate::community::Epoch(0),
5070 "channel NOT rotated (its rekey failed)");
5071 assert!(crate::db::community::get_read_cut_pending(&cid).unwrap(), "cut left pending after the failure");
5072 assert_eq!(crate::db::community::get_read_cut_target_epoch(&cid).unwrap(), 1, "target recorded durably");
5073 assert_eq!(crate::db::community::channel_rekeyed_at_server_epoch(&cid, &ch_hex).unwrap(), 0,
5074 "channel not yet marked for this cut");
5075
5076 relay.fail_channel.store(false, std::sync::atomic::Ordering::Relaxed);
5078 retry_pending_read_cut(&relay, &mid).await.unwrap();
5079 let done = crate::db::community::load_community(&community.id).unwrap().unwrap();
5080 assert_eq!(done.server_root_epoch, crate::community::Epoch(1),
5081 "base NOT rotated again — resumed at the same epoch (no double base rotation)");
5082 assert_eq!(done.channels.iter().find(|c| c.id == channel_id).unwrap().epoch, crate::community::Epoch(1),
5083 "the un-rotated channel finished on resume");
5084 assert_eq!(crate::db::community::channel_rekeyed_at_server_epoch(&cid, &ch_hex).unwrap(), 1,
5085 "channel marked rekeyed for the cut epoch");
5086 assert!(!crate::db::community::get_read_cut_pending(&cid).unwrap(), "pending cleared after the resume completes");
5087 }
5088
5089 #[tokio::test]
5090 async fn rotate_server_root_aborts_when_the_snapshot_does_not_land() {
5091 struct ControlPublishFails { inner: MemoryRelay, fail: std::sync::atomic::AtomicBool }
5096 #[async_trait::async_trait]
5097 impl Transport for ControlPublishFails {
5098 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> { Ok(crate::community::transport::PlaneFetch::whole(self.fetch(query, relays).await?)) }
5099 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> { self.inner.publish(e, r).await }
5100 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
5101 if self.fail.load(std::sync::atomic::Ordering::Relaxed) && e.kind.as_u16() == event_kind::COMMUNITY_CONTROL {
5102 return Err("control relay down".into());
5103 }
5104 self.inner.publish_durable(e, r).await
5105 }
5106 async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> { self.inner.fetch(q, r).await }
5107 }
5108 let (_tmp, _guard) = init_test_db();
5109 let relay = ControlPublishFails { inner: MemoryRelay::new(), fail: std::sync::atomic::AtomicBool::new(false) };
5110 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5112 relay.fail.store(true, std::sync::atomic::Ordering::Relaxed);
5113
5114 assert!(
5115 rotate_server_root(&relay, &community, &[]).await.is_err(),
5116 "a snapshot whose editions can't be re-published must abort the rotation"
5117 );
5118 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5119 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(0), "base head NOT advanced when the snapshot doesn't land");
5120 }
5121
5122 #[tokio::test]
5123 async fn acquire_before_commit_a_reanchor_fetch_miss_publishes_no_base_rekey() {
5124 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
5129 struct ReanchorFetchEmpty { inner: MemoryRelay, drop_control: AtomicBool, base_rekeys: AtomicUsize }
5130 #[async_trait::async_trait]
5131 impl Transport for ReanchorFetchEmpty {
5132 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> { Ok(crate::community::transport::PlaneFetch::whole(self.fetch(query, relays).await?)) }
5133 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> { self.inner.publish(e, r).await }
5134 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
5135 if e.kind.as_u16() == event_kind::COMMUNITY_REKEY {
5136 self.base_rekeys.fetch_add(1, Ordering::Relaxed);
5137 }
5138 self.inner.publish_durable(e, r).await
5139 }
5140 async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> {
5141 if self.drop_control.load(Ordering::Relaxed) && q.kinds.iter().any(|k| *k == event_kind::COMMUNITY_CONTROL) {
5142 return Ok(vec![]); }
5144 self.inner.fetch(q, r).await
5145 }
5146 }
5147 let (_tmp, _guard) = init_test_db();
5148 let relay = ReanchorFetchEmpty { inner: MemoryRelay::new(), drop_control: AtomicBool::new(false), base_rekeys: AtomicUsize::new(0) };
5149 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5150 relay.drop_control.store(true, Ordering::Relaxed);
5151
5152 assert!(rotate_server_root(&relay, &community, &[]).await.is_err(),
5153 "a re-anchor fetch miss must abort the rotation");
5154 assert_eq!(relay.base_rekeys.load(Ordering::Relaxed), 0,
5155 "the base rekey must NOT be published when the pre-publish fetch gate trips (acquire-before-commit)");
5156 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5157 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(0), "base head NOT advanced");
5158 }
5159
5160 #[tokio::test]
5163 async fn reanchor_carries_role_and_grant_to_the_new_epoch_under_the_new_root() {
5164 let (_tmp, _guard) = init_test_db();
5165 let relay = MemoryRelay::new();
5166 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5168 let cid = community.id.to_hex();
5169 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5170 let member = Keys::generate();
5171 set_member_grant(&relay, &community, &member.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
5173 let _ = fetch_and_apply_control(&relay, &community).await;
5174 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5175
5176 let new_root = [0x99u8; 32];
5178 let snap = reanchor_control_plane(&relay, &community, &new_root, crate::community::Epoch(1)).await.unwrap();
5179 assert!(snap.iter().all(|e| e.published), "every snapshot edition published");
5180 assert_eq!(snap.len(), 4, "GroupRoot + channel + Admin role + grant compacted to v1");
5181
5182 let new_z = crate::community::roster::control_pseudonym(
5184 &crate::community::ServerRootKey(new_root), &community.id, crate::community::Epoch(1),
5185 );
5186 let after = relay
5187 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![new_z], ..Default::default() }, &community.relays)
5188 .await
5189 .unwrap();
5190 let inners: Vec<_> = after
5191 .iter()
5192 .filter_map(|o| crate::community::roster::open_control_edition(o, &crate::community::ServerRootKey(new_root)).ok())
5193 .collect();
5194 let folded = crate::community::roster::fold_roster(&inners, &community.id, &Default::default());
5195 assert!(!folded.roles.roles.is_empty(), "Admin role reachable at the new epoch");
5196 assert!(
5197 folded.roles.grants.iter().any(|g| g.member == member.public_key().to_hex()),
5198 "grant carried to the new epoch under the new root"
5199 );
5200 }
5201
5202 #[tokio::test]
5203 async fn grant_after_a_rekey_survives_the_fold_at_the_new_epoch() {
5204 use crate::community::roles::Permissions;
5210 let (_tmp, _guard) = init_test_db();
5211 let relay = MemoryRelay::new();
5212 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5213 let cid = community.id.to_hex();
5214 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
5215 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5216
5217 rotate_server_root(&relay, &community, &[owner.public_key()]).await.expect("rotate base");
5219 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5220 assert_eq!(community.server_root_epoch, crate::community::Epoch(1), "advanced to the new epoch");
5221
5222 let alice = "aa".repeat(32);
5224 set_member_grant(&relay, &community, &alice, vec![admin_role_id]).await.unwrap();
5225
5226 let roster = fetch_and_apply_roles(&relay, &community).await.unwrap();
5228 assert!(
5229 roster.has_permission(&alice, Permissions::BAN),
5230 "post-rekey grant survives — Alice is Admin at the new epoch (pre-fix: dropped, role unreachable)"
5231 );
5232 assert_eq!(roster.highest_position(&alice), Some(1));
5233 }
5234
5235 #[tokio::test]
5239 async fn demote_re_asserts_the_demoted_members_metadata_head() {
5240 let (_tmp, _guard) = init_test_db();
5241 let relay = MemoryRelay::new();
5242 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5243 let cid = community.id.to_hex();
5244 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
5245 let admin_role = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5246 let alice = Keys::generate();
5247 let alice_hex = alice.public_key().to_hex();
5248
5249 set_member_grant(&relay, &community, &alice_hex, vec![admin_role]).await.unwrap();
5250 become_local(&alice);
5252 let mut as_alice = crate::db::community::load_community(&community.id).unwrap().unwrap();
5253 as_alice.name = "Alice's HQ".into();
5254 republish_community_metadata(&relay, &as_alice).await.unwrap();
5255 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5256 assert_eq!(
5257 fetch_control_folded(&relay, &community).await.unwrap().root_author.map(|a| a.to_hex()),
5258 Some(alice_hex.clone()), "alice heads the GroupRoot after her edit",
5259 );
5260
5261 become_local(&owner);
5263 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5264 set_member_grant(&relay, &community, &alice_hex, vec![]).await.unwrap();
5265
5266 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5267 let folded = fetch_control_folded(&relay, &community).await.unwrap();
5268 assert_eq!(folded.root_author.map(|a| a.to_hex()), Some(owner.public_key().to_hex()),
5269 "the demote re-asserted the GroupRoot under the owner");
5270 assert_eq!(folded.root_meta.as_ref().unwrap().name, "Alice's HQ",
5271 "the re-assert preserves the demoted member's content");
5272 }
5273
5274 #[tokio::test]
5277 async fn demote_skips_reassert_when_member_does_not_head() {
5278 let (_tmp, _guard) = init_test_db();
5279 let relay = MemoryRelay::new();
5280 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5281 let cid = community.id.to_hex();
5282 let admin_role = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5283 let alice = Keys::generate();
5284 let alice_hex = alice.public_key().to_hex();
5285
5286 set_member_grant(&relay, &community, &alice_hex, vec![admin_role]).await.unwrap();
5287 let mut c = crate::db::community::load_community(&community.id).unwrap().unwrap();
5289 c.name = "Owner's HQ".into();
5290 republish_community_metadata(&relay, &c).await.unwrap();
5291 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5292 let before = fetch_control_folded(&relay, &community).await.unwrap().root_head.unwrap().version;
5293
5294 set_member_grant(&relay, &community, &alice_hex, vec![]).await.unwrap();
5295 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5296 let after = fetch_control_folded(&relay, &community).await.unwrap().root_head.unwrap().version;
5297 assert_eq!(after, before, "no re-assert published — the demoted member didn't head the GroupRoot");
5298 }
5299
5300 #[tokio::test]
5301 async fn reanchor_carries_the_banlist_edition_to_the_new_epoch() {
5302 let (_tmp, _guard) = init_test_db();
5305 let relay = MemoryRelay::new();
5306 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5307 let carol = "cc".repeat(32);
5308 publish_banlist(&relay, &community, &[carol.clone()]).await.unwrap();
5310 let _ = fetch_and_apply_control(&relay, &community).await;
5311 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5312
5313 let new_root = [0x99u8; 32];
5315 let n = reanchor_control_plane(&relay, &community, &new_root, crate::community::Epoch(1)).await.unwrap();
5316 assert!(n.iter().all(|e| e.published), "every snapshot edition published");
5317 assert_eq!(n.len(), 4, "GroupRoot + channel + Admin role + banlist compacted to v1");
5318
5319 let new_z = crate::community::roster::control_pseudonym(
5321 &crate::community::ServerRootKey(new_root), &community.id, crate::community::Epoch(1),
5322 );
5323 let after = relay
5324 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![new_z], ..Default::default() }, &community.relays)
5325 .await
5326 .unwrap();
5327 let inners: Vec<_> = after
5328 .iter()
5329 .filter_map(|o| crate::community::roster::open_control_edition(o, &crate::community::ServerRootKey(new_root)).ok())
5330 .collect();
5331 let folded = crate::community::roster::fold_roster(&inners, &community.id, &Default::default());
5332 assert_eq!(folded.banned, vec![carol], "banlist reachable at the new epoch under the new root");
5333 }
5334
5335 fn owner_base_rekey(
5340 owner: &Keys, community: &Community, recipient_pk: &nostr_sdk::prelude::PublicKey, new_epoch: u64, new_root: &[u8; 32],
5341 ) -> super::super::rekey::ParsedRekey {
5342 let prev = community.server_root_epoch.0;
5343 let blob = super::super::rekey::build_rekey_blob(
5344 owner.secret_key(), recipient_pk, super::super::derive::RekeyScope::ServerRoot, crate::community::Epoch(new_epoch), new_root,
5345 )
5346 .unwrap();
5347 let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(prev), community.server_root_key.as_bytes());
5348 let outer = super::super::rekey::build_server_root_rekey_event(
5349 &Keys::generate(), owner, community.server_root_key.as_bytes(), &community.id,
5350 crate::community::Epoch(new_epoch), crate::community::Epoch(prev), &commit, &[blob],
5351 )
5352 .unwrap();
5353 super::super::rekey::open_rekey_event(&outer, community.server_root_key.as_bytes()).unwrap()
5354 }
5355
5356 #[test]
5357 fn apply_server_root_rekey_recovers_new_root_and_advances_base() {
5358 let (_tmp, _guard) = init_test_db();
5359 let owner = Keys::generate();
5360 let me = Keys::generate();
5361 become_local(&me);
5362 let community = saved_community_owned_by(&owner);
5363 let cid = community.id.to_hex();
5364 let new_root = [0xCDu8; 32];
5365
5366 let parsed = owner_base_rekey(&owner, &community, &me.public_key(), 1, &new_root);
5367 assert_eq!(apply_server_root_rekey(&community, &parsed).unwrap(), RekeyOutcome::Applied { head_advanced: true });
5368
5369 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5370 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(1));
5371 assert_eq!(reloaded.server_root_key.as_bytes(), &new_root, "base head advanced to the new root");
5372 assert!(crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 0).unwrap().is_some());
5374 assert_eq!(crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap(), Some(new_root));
5375 }
5376
5377 #[test]
5378 fn apply_server_root_rekey_not_a_recipient_leaves_base_unchanged() {
5379 let (_tmp, _guard) = init_test_db();
5380 let owner = Keys::generate();
5381 let me = Keys::generate();
5382 become_local(&me);
5383 let community = saved_community_owned_by(&owner);
5384 let other = Keys::generate(); let parsed = owner_base_rekey(&owner, &community, &other.public_key(), 1, &[0x11u8; 32]);
5386 assert_eq!(apply_server_root_rekey(&community, &parsed).unwrap(), RekeyOutcome::NotARecipient);
5387 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5388 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(0), "removed-from-base member's head unchanged");
5389 }
5390
5391 #[test]
5392 fn apply_server_root_rekey_rejects_rotator_without_ban() {
5393 let (_tmp, _guard) = init_test_db();
5394 let owner = Keys::generate();
5395 let me = Keys::generate();
5396 become_local(&me);
5397 let community = saved_community_owned_by(&owner);
5398 let rogue = Keys::generate();
5400 let parsed = owner_base_rekey(&rogue, &community, &me.public_key(), 1, &[0x22u8; 32]);
5401 assert!(apply_server_root_rekey(&community, &parsed).is_err(), "unauthorized base rotation rejected");
5402 }
5403
5404 #[test]
5405 fn apply_server_root_rekey_reorgs_onto_authorized_chain_despite_prior_mismatch() {
5406 let (_tmp, _guard) = init_test_db();
5411 let owner = Keys::generate();
5412 let me = Keys::generate();
5413 become_local(&me);
5414 let community = saved_community_owned_by(&owner);
5415 let blob = super::super::rekey::build_rekey_blob(
5416 owner.secret_key(), &me.public_key(), super::super::derive::RekeyScope::ServerRoot, crate::community::Epoch(1), &[0x33u8; 32],
5417 )
5418 .unwrap();
5419 let bad = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &[0xFFu8; 32]);
5421 let outer = super::super::rekey::build_server_root_rekey_event(
5422 &Keys::generate(), &owner, community.server_root_key.as_bytes(), &community.id,
5423 crate::community::Epoch(1), crate::community::Epoch(0), &bad, &[blob],
5424 )
5425 .unwrap();
5426 let parsed = super::super::rekey::open_rekey_event(&outer, community.server_root_key.as_bytes()).unwrap();
5427 let outcome = apply_server_root_rekey(&community, &parsed);
5428 assert!(
5429 matches!(outcome, Ok(RekeyOutcome::Applied { .. })),
5430 "an authorized base chain must be adopted (reorg), not rejected as foreign; got {outcome:?}"
5431 );
5432 }
5433
5434 #[test]
5435 fn apply_server_root_rekey_catchup_archives_without_regressing_base_head() {
5436 let (_tmp, _guard) = init_test_db();
5439 let owner = Keys::generate();
5440 let me = Keys::generate();
5441 become_local(&me);
5442 let community = saved_community_owned_by(&owner);
5443 let cid = community.id.to_hex();
5444
5445 let r5 = [0x55u8; 32];
5446 let p5 = owner_base_rekey(&owner, &community, &me.public_key(), 5, &r5);
5447 assert_eq!(apply_server_root_rekey(&community, &p5).unwrap(), RekeyOutcome::Applied { head_advanced: true });
5448 let r3 = [0x33u8; 32];
5449 let p3 = owner_base_rekey(&owner, &community, &me.public_key(), 3, &r3);
5450 assert_eq!(apply_server_root_rekey(&community, &p3).unwrap(), RekeyOutcome::Applied { head_advanced: false });
5451
5452 assert_eq!(crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 3).unwrap(), Some(r3));
5453 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5454 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(5), "base head stayed at newest");
5455 assert_eq!(reloaded.server_root_key.as_bytes(), &r5);
5456 }
5457
5458 #[test]
5459 fn apply_server_root_rekey_authorizes_a_granted_ban_admin() {
5460 let (_tmp, _guard) = init_test_db();
5464 let owner = Keys::generate();
5465 let me = Keys::generate();
5466 become_local(&me);
5467 let community = saved_community_owned_by(&owner);
5468 let cid = community.id.to_hex();
5469
5470 let admin = Keys::generate();
5471 let role_id = "d".repeat(64);
5472 let roster = crate::community::roles::CommunityRoles {
5473 roles: vec![crate::community::roles::Role::admin(role_id.clone())],
5474 grants: vec![crate::community::roles::MemberGrant { member: admin.public_key().to_hex(), role_ids: vec![role_id] }],
5475 };
5476 crate::db::community::set_community_roles(&cid, &roster, 1).unwrap();
5477
5478 let parsed = owner_base_rekey(&admin, &community, &me.public_key(), 1, &[0x77u8; 32]);
5479 assert_eq!(
5480 apply_server_root_rekey(&community, &parsed).unwrap(),
5481 RekeyOutcome::Applied { head_advanced: true },
5482 "a BAN-granted admin (not the owner) can rotate the base"
5483 );
5484 }
5485
5486 #[test]
5487 fn apply_server_root_rekey_accepts_when_prior_root_not_held() {
5488 let (_tmp, _guard) = init_test_db();
5491 let owner = Keys::generate();
5492 let me = Keys::generate();
5493 become_local(&me);
5494 let community = saved_community_owned_by(&owner);
5495
5496 let new_root = [0x99u8; 32];
5497 let blob = super::super::rekey::build_rekey_blob(
5498 owner.secret_key(), &me.public_key(), super::super::derive::RekeyScope::ServerRoot, crate::community::Epoch(5), &new_root,
5499 )
5500 .unwrap();
5501 let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(4), &[0xEEu8; 32]);
5503 let outer = super::super::rekey::build_server_root_rekey_event(
5504 &Keys::generate(), &owner, community.server_root_key.as_bytes(), &community.id,
5505 crate::community::Epoch(5), crate::community::Epoch(4), &commit, &[blob],
5506 )
5507 .unwrap();
5508 let parsed = super::super::rekey::open_rekey_event(&outer, community.server_root_key.as_bytes()).unwrap();
5509 assert_eq!(apply_server_root_rekey(&community, &parsed).unwrap(), RekeyOutcome::Applied { head_advanced: true });
5510 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5511 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(5));
5512 }
5513
5514 #[test]
5515 fn apply_server_root_rekey_rejects_channel_scope() {
5516 let (_tmp, _guard) = init_test_db();
5518 let owner = Keys::generate();
5519 let me = Keys::generate();
5520 become_local(&me);
5521 let community = saved_community_owned_by(&owner);
5522 let channel_parsed = owner_channel_rekey(&owner, &community, &me.public_key(), 1, &[0x44u8; 32]);
5523 assert!(apply_server_root_rekey(&community, &channel_parsed).is_err(), "channel scope rejected by base apply");
5524 }
5525
5526 #[test]
5527 fn apply_channel_rekey_not_a_recipient() {
5528 let (_tmp, _guard) = init_test_db();
5529 let owner = Keys::generate();
5530 let me = Keys::generate();
5531 become_local(&me);
5532 let community = saved_community_owned_by(&owner);
5533 let other = Keys::generate();
5535 let parsed = owner_channel_rekey(&owner, &community, &other.public_key(), 1, &[0x11u8; 32]);
5536 assert_eq!(apply_channel_rekey(&community, &parsed).unwrap(), RekeyOutcome::NotARecipient);
5537 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5539 assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(0));
5540 }
5541
5542 #[test]
5543 fn apply_channel_rekey_rejects_unauthorized_rotator() {
5544 let (_tmp, _guard) = init_test_db();
5545 let owner = Keys::generate();
5546 let me = Keys::generate();
5547 become_local(&me);
5548 let community = saved_community_owned_by(&owner);
5549 let rogue = Keys::generate();
5551 let parsed = owner_channel_rekey(&rogue, &community, &me.public_key(), 1, &[0x22u8; 32]);
5552 assert!(apply_channel_rekey(&community, &parsed).is_err(), "unauthorized rotation must be rejected");
5553 }
5554
5555 #[test]
5556 fn apply_channel_rekey_reorgs_onto_authorized_chain_despite_prior_mismatch() {
5557 let (_tmp, _guard) = init_test_db();
5564 let owner = Keys::generate();
5565 let me = Keys::generate();
5566 become_local(&me);
5567 let community = saved_community_owned_by(&owner);
5568 let chan = &community.channels[0];
5569 let scope = super::super::derive::RekeyScope::Channel(chan.id);
5570 let new_key = [0x33u8; 32];
5571 let blob = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &new_key).unwrap();
5572 let other_commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &[0xFFu8; 32]);
5574 let outer = super::super::rekey::build_channel_rekey_event(
5575 &Keys::generate(), &owner, community.server_root_key.as_bytes(), &chan.id,
5576 crate::community::Epoch(1), crate::community::Epoch(0), &other_commit, &[blob],
5577 )
5578 .unwrap();
5579 let parsed = super::super::rekey::open_rekey_event(&outer, community.server_root_key.as_bytes()).unwrap();
5580 let outcome = apply_channel_rekey(&community, &parsed).unwrap();
5581 assert!(matches!(outcome, RekeyOutcome::Applied { .. }),
5582 "an authorized chain must be adopted (reorg), not rejected as foreign; got {outcome:?}");
5583 assert_eq!(crate::db::community::held_epoch_key(&community.id.to_hex(), &chan.id.to_hex(), 1).unwrap(), Some(new_key));
5584 }
5585
5586 #[test]
5587 fn apply_channel_rekey_catchup_archives_without_regressing_head() {
5588 let (_tmp, _guard) = init_test_db();
5589 let owner = Keys::generate();
5590 let me = Keys::generate();
5591 become_local(&me);
5592 let community = saved_community_owned_by(&owner);
5593 let cid = community.id.to_hex();
5594 let chan_hex = community.channels[0].id.to_hex();
5595
5596 let k5 = [0x55u8; 32];
5598 let p5 = owner_channel_rekey(&owner, &community, &me.public_key(), 5, &k5);
5599 assert_eq!(apply_channel_rekey(&community, &p5).unwrap(), RekeyOutcome::Applied { head_advanced: true });
5600 let k3 = [0x33u8; 32];
5602 let p3 = owner_channel_rekey(&owner, &community, &me.public_key(), 3, &k3);
5603 assert_eq!(apply_channel_rekey(&community, &p3).unwrap(), RekeyOutcome::Applied { head_advanced: false });
5604
5605 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 3).unwrap(), Some(k3), "old epoch archived");
5606 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 5).unwrap(), Some(k5));
5607 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5608 assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(5), "head stayed at the newest epoch");
5609 assert_eq!(reloaded.channels[0].key.as_bytes(), &k5);
5610 }
5611
5612 #[tokio::test]
5613 async fn create_community_persists_and_publishes_metadata() {
5614 use crate::community::transport::Query;
5615 use crate::stored_event::event_kind;
5616
5617 let (_tmp, _guard) = init_test_db();
5618 let relay = MemoryRelay::new();
5619 let community = create_community(&relay, "Vector HQ", "general", vec!["r1".into()])
5620 .await
5621 .expect("create");
5622
5623 assert_eq!(community.name, "Vector HQ");
5625 assert_eq!(community.channels.len(), 1);
5626 assert_eq!(community.channels[0].name, "general");
5627
5628 let loaded = crate::db::community::load_community(&community.id).unwrap().expect("persisted");
5630 assert_eq!(loaded.channels[0].name, "general");
5631 assert_eq!(loaded.server_root_key.as_bytes(), community.server_root_key.as_bytes());
5632
5633 let meta_events = relay
5636 .fetch(
5637 &Query { kinds: vec![event_kind::APPLICATION_SPECIFIC], ..Default::default() },
5638 &community.relays,
5639 )
5640 .await
5641 .unwrap();
5642 assert!(meta_events.is_empty(), "no legacy 30078 metadata events");
5643
5644 let z = crate::community::roster::control_pseudonym(&community.server_root_key, &community.id, crate::community::Epoch(0));
5647 let control = relay
5648 .fetch(
5649 &Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], ..Default::default() },
5650 &community.relays,
5651 )
5652 .await
5653 .unwrap();
5654 assert_eq!(control.len(), 3, "GroupRoot + ChannelMetadata + Admin role editions");
5655 let owner_pk = crate::state::my_public_key().unwrap();
5656 let parsed: Vec<_> = control
5657 .iter()
5658 .filter_map(|o| crate::community::roster::open_control_edition(o, &community.server_root_key).ok())
5659 .filter_map(|i| crate::community::edition::parse_edition_inner(&i).ok())
5660 .collect();
5661 assert!(parsed.iter().all(|p| p.author == owner_pk), "every genesis edition authored by the owner");
5662 let root = parsed.iter().find(|p| p.entity_id == community.id.0).expect("GroupRoot edition");
5664 let root_meta: crate::community::metadata::CommunityMetadata = serde_json::from_str(&root.content).unwrap();
5665 assert_eq!(root_meta.name, "Vector HQ");
5666 assert!(root_meta.owner_attestation.is_some());
5667 let role: crate::community::roles::Role = parsed
5669 .iter()
5670 .find_map(|p| serde_json::from_str::<crate::community::roles::Role>(&p.content).ok().filter(|r| r.name == "Admin"))
5671 .expect("Admin role edition");
5672 assert_eq!(role.position, 1);
5673 assert!(role.permissions.contains(crate::community::roles::Permissions::ADMIN_ALL));
5674
5675 let cached = crate::db::community::get_community_roles(&community.id.to_hex()).unwrap();
5677 assert_eq!(cached.roles.len(), 1);
5678 assert!(cached.grants.is_empty(), "owner is implicit position 0, takes no grant");
5679 }
5680
5681 #[tokio::test]
5682 async fn role_grant_round_trips_through_relays_and_revokes() {
5683 use crate::community::roles::Permissions;
5684 let (_tmp, _guard) = init_test_db();
5685 let relay = MemoryRelay::new();
5686 let community = create_community(&relay, "HQ", "general", vec!["r1".into()])
5687 .await
5688 .expect("create");
5689 let cid = community.id.to_hex();
5690 let alice = "aa".repeat(32);
5691 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0]
5692 .role_id
5693 .clone();
5694
5695 set_member_grant(&relay, &community, &alice, vec![admin_role_id.clone()])
5697 .await
5698 .unwrap();
5699 assert!(
5700 crate::db::community::get_community_roles(&cid).unwrap().is_privileged(&alice),
5701 "local cache reflects the grant immediately"
5702 );
5703
5704 let roster = fetch_and_apply_roles(&relay, &community).await.unwrap();
5707 assert!(roster.has_permission(&alice, Permissions::BAN));
5708 assert!(roster.has_permission(&alice, Permissions::MANAGE_ROLES));
5709 assert_eq!(roster.roles.len(), 1);
5710 assert_eq!(roster.highest_position(&alice), Some(1));
5711
5712 set_member_grant(&relay, &community, &alice, vec![]).await.unwrap();
5714 let after = crate::db::community::get_community_roles(&cid).unwrap();
5715 assert!(!after.is_privileged(&alice), "revoked member holds no role");
5716 assert!(after.grants.is_empty(), "empty grant pruned");
5717 }
5718
5719 #[tokio::test]
5720 async fn admin_cannot_grant_a_peer_rank_role() {
5721 let (_tmp, _guard) = init_test_db();
5725 let relay = MemoryRelay::new();
5726 let community = create_community(&relay, "HQ", "general", vec!["r1".into()])
5727 .await
5728 .expect("create");
5729 let cid = community.id.to_hex();
5730 let admin_role_id =
5731 crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5732 let alice = Keys::generate();
5733 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id.clone()])
5735 .await
5736 .unwrap();
5737
5738 crate::state::set_my_public_key(alice.public_key());
5740 let bob = Keys::generate().public_key();
5741 let err = grant_role(&relay, &community, bob, &admin_role_id).await.unwrap_err();
5742 assert!(err.contains("below your own"), "peer-rank grant refused, got: {err}");
5743 }
5744
5745 #[tokio::test]
5746 async fn create_community_mints_a_verifiable_owner_attestation() {
5747 let (_tmp, _guard) = init_test_db();
5750 let me = crate::state::my_public_key().unwrap();
5751 let relay = MemoryRelay::new();
5752 let community = create_community(&relay, "HQ", "general", vec!["r1".into()])
5753 .await
5754 .expect("create");
5755 let att = community.owner_attestation.as_ref().expect("attestation is mandatory");
5756 let proven = super::super::owner::verify_owner_attestation(att, &community.id.to_hex());
5757 assert_eq!(proven, Some(me), "the creator is the proven owner");
5758 assert_eq!(
5760 super::super::owner::verify_owner_attestation(att, &"f".repeat(64)),
5761 None,
5762 );
5763 }
5764
5765 #[tokio::test]
5766 async fn admin_cannot_ban_a_peer_admin() {
5767 let (_tmp, _guard) = init_test_db();
5771 let relay = MemoryRelay::new();
5772 let community = create_community(&relay, "HQ", "general", vec!["r1".into()])
5773 .await
5774 .expect("create");
5775 let cid = community.id.to_hex();
5776 let admin_role_id =
5777 crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5778 let alice = Keys::generate();
5779 let bob = Keys::generate();
5780 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id.clone()])
5782 .await
5783 .unwrap();
5784 set_member_grant(&relay, &community, &bob.public_key().to_hex(), vec![admin_role_id.clone()])
5785 .await
5786 .unwrap();
5787
5788 become_local(&alice);
5791 let err = publish_banlist(&relay, &community, &[bob.public_key().to_hex()])
5792 .await
5793 .unwrap_err();
5794 assert!(err.contains("outranks you"), "peer-admin ban refused, got: {err}");
5795 }
5796
5797 #[tokio::test]
5798 async fn roster_reconstructs_purely_from_relay() {
5799 let (_tmp, _guard) = init_test_db();
5803 let relay = MemoryRelay::new();
5804 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5805 let cid = community.id.to_hex();
5806 let admin_role_id =
5807 crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5808 let alice = "aa".repeat(32);
5809 set_member_grant(&relay, &community, &alice, vec![admin_role_id.clone()]).await.unwrap();
5810
5811 crate::db::community::set_community_roles(&cid, &crate::community::roles::CommunityRoles::default(), 0).unwrap();
5813 assert!(crate::db::community::get_community_roles(&cid).unwrap().roles.is_empty(), "cache wiped");
5814
5815 let roster = fetch_and_apply_roles(&relay, &community).await.unwrap();
5816 assert!(roster.is_admin(&alice), "roster reconstructed from relay editions, not the cache");
5817 assert_eq!(roster.roles.len(), 1, "the Admin role edition folded back");
5818 }
5819
5820 #[tokio::test]
5821 async fn admin_cannot_unban_a_peer_admin() {
5822 let (_tmp, _guard) = init_test_db();
5825 let relay = MemoryRelay::new();
5826 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5827 let cid = community.id.to_hex();
5828 let admin_role_id =
5829 crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5830 let alice = Keys::generate();
5831 let bob = Keys::generate();
5832 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id.clone()])
5833 .await
5834 .unwrap();
5835 set_member_grant(&relay, &community, &bob.public_key().to_hex(), vec![admin_role_id.clone()])
5836 .await
5837 .unwrap();
5838 crate::db::community::set_community_banlist(&cid, &[bob.public_key().to_hex()], 1000).unwrap();
5840
5841 become_local(&alice);
5843 let err = publish_banlist(&relay, &community, &[]).await.unwrap_err();
5844 assert!(err.contains("unban"), "unbanning a peer admin refused, got: {err}");
5845 }
5846
5847 #[tokio::test]
5848 async fn create_community_rejects_signer_identity_mismatch() {
5849 let (_tmp, _guard) = init_test_db(); let other = Keys::generate();
5854 crate::state::set_my_public_key(other.public_key()); let relay = MemoryRelay::new();
5856 let err = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap_err();
5857 assert!(err.contains("identity signer"), "signer mismatch refused, got: {err}");
5858 }
5859
5860 #[tokio::test]
5861 async fn banlist_newer_edition_applies_older_is_refused() {
5862 let (_tmp, _guard) = init_test_db();
5863 let relay = MemoryRelay::new();
5864 let community = create_community(&relay, "HQ", "general", vec!["r1".into()])
5865 .await
5866 .expect("create");
5867 let id_hex = community.id.to_hex();
5868 let banlist_entity = crate::simd::hex::bytes_to_hex_32(&crate::community::derive::banlist_locator(&community.id));
5869 let mallory = "aa".repeat(32);
5870 let bob = "bb".repeat(32);
5871
5872 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
5875 let inner = crate::community::roster::build_banlist_edition(&owner, &community.id, &[mallory.clone()], 1, None, 1000, None).unwrap();
5876 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
5877 relay.inject(&outer, &community.relays);
5878
5879 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
5881 assert_eq!(applied, vec![mallory.clone()]);
5882 let (head_v, _) = crate::db::community::get_edition_head(&id_hex, &banlist_entity).unwrap().unwrap();
5883 assert_eq!(head_v, 1, "banlist edition head advanced to v1");
5884
5885 crate::db::community::set_community_banlist(&id_hex, &[mallory.clone(), bob.clone()], 2).unwrap();
5888 crate::db::community::set_edition_head(&id_hex, &banlist_entity, 2, &[0x22u8; 32]).unwrap();
5889 let after = fetch_and_apply_banlist(&relay, &community).await.unwrap();
5890 assert_eq!(after, vec![mallory, bob], "older relay edition refused, local banlist preserved");
5891 }
5892
5893 #[tokio::test]
5894 async fn unauthorized_banlist_edition_is_rejected() {
5895 let (_tmp, _guard) = init_test_db();
5899 let relay = MemoryRelay::new();
5900 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5901 let bob = "bb".repeat(32);
5902
5903 let mallory = Keys::generate();
5905 let inner = crate::community::roster::build_banlist_edition(&mallory, &community.id, &[bob], 1, None, 1000, None).unwrap();
5906 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
5907 relay.inject(&outer, &community.relays);
5908
5909 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
5911 assert!(applied.is_empty(), "an unauthorized signer's banlist edition is rejected");
5912 }
5913
5914 #[tokio::test]
5915 async fn banlist_receiver_enforces_per_target_outrank() {
5916 let (_tmp, _guard) = init_test_db();
5919 let relay = MemoryRelay::new();
5920 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5921 let cid = community.id.to_hex();
5922 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5923 let alice = Keys::generate();
5924 let bob = Keys::generate();
5925 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id.clone()]).await.unwrap();
5927 set_member_grant(&relay, &community, &bob.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
5928
5929 let cite = authority_citation(&community, &alice.public_key().to_hex());
5932 let inner = crate::community::roster::build_banlist_edition(&alice, &community.id, &[bob.public_key().to_hex()], 1, None, 1000, cite.as_ref()).unwrap();
5933 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
5934 relay.inject(&outer, &community.relays);
5935
5936 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
5938 assert!(applied.is_empty(), "an admin can't ban a peer admin (receiver-side outrank)");
5939 }
5940
5941 #[tokio::test]
5942 async fn banlist_admin_bans_regular_member_applies() {
5943 let (_tmp, _guard) = init_test_db();
5946 let relay = MemoryRelay::new();
5947 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5948 let cid = community.id.to_hex();
5949 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5950 let alice = Keys::generate();
5951 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
5952
5953 let carol = "cc".repeat(32);
5954 let cite = authority_citation(&community, &alice.public_key().to_hex());
5956 let inner = crate::community::roster::build_banlist_edition(&alice, &community.id, &[carol.clone()], 1, None, 1000, cite.as_ref()).unwrap();
5957 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
5958 relay.inject(&outer, &community.relays);
5959
5960 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
5961 assert_eq!(applied, vec![carol], "an admin's ban of a regular member applies");
5962 }
5963
5964 #[tokio::test]
5965 async fn owner_banlist_needs_no_citation() {
5966 let (_tmp, _guard) = init_test_db();
5969 let relay = MemoryRelay::new();
5970 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5971 let victim = "cc".repeat(32);
5972
5973 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
5975 let inner = crate::community::roster::build_banlist_edition(&owner, &community.id, &[victim.clone()], 1, None, 1000, None).unwrap();
5976 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
5977 relay.inject(&outer, &community.relays);
5978
5979 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
5980 assert_eq!(applied, vec![victim], "an owner's uncited ban applies");
5981 }
5982
5983 #[tokio::test]
5984 async fn banlist_with_forged_citation_hash_is_rejected() {
5985 let (_tmp, _guard) = init_test_db();
5988 let relay = MemoryRelay::new();
5989 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5990 let cid = community.id.to_hex();
5991 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5992 let alice = Keys::generate();
5993 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
5994
5995 let carol = "cc".repeat(32);
5996 let mut cite = authority_citation(&community, &alice.public_key().to_hex()).unwrap();
5998 cite.edition_hash = [0xEE; 32];
5999 let inner = crate::community::roster::build_banlist_edition(&alice, &community.id, &[carol], 1, None, 1000, Some(&cite)).unwrap();
6000 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6001 relay.inject(&outer, &community.relays);
6002
6003 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
6004 assert!(applied.is_empty(), "a forged-hash citation is rejected");
6005 }
6006
6007 #[tokio::test]
6008 async fn banlist_citing_unsynced_future_version_is_rejected() {
6009 let (_tmp, _guard) = init_test_db();
6013 let relay = MemoryRelay::new();
6014 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6015 let cid = community.id.to_hex();
6016 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
6017 let alice = Keys::generate();
6018 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
6019
6020 let carol = "cc".repeat(32);
6021 let mut cite = authority_citation(&community, &alice.public_key().to_hex()).unwrap();
6022 cite.version += 5; let inner = crate::community::roster::build_banlist_edition(&alice, &community.id, &[carol], 1, None, 1000, Some(&cite)).unwrap();
6024 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6025 relay.inject(&outer, &community.relays);
6026
6027 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
6028 assert!(applied.is_empty(), "citing an unsynced future grant version fails closed");
6029 }
6030
6031 #[tokio::test]
6032 async fn demoted_banner_superseded_ban_is_rejected() {
6033 let (_tmp, _guard) = init_test_db();
6037 let relay = MemoryRelay::new();
6038 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6039 let cid = community.id.to_hex();
6040 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
6041 let alice = Keys::generate();
6042 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
6043
6044 let carol = "cc".repeat(32);
6045 let cite = authority_citation(&community, &alice.public_key().to_hex());
6047 let inner = crate::community::roster::build_banlist_edition(&alice, &community.id, &[carol], 1, None, 1000, cite.as_ref()).unwrap();
6048 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6049 relay.inject(&outer, &community.relays);
6050
6051 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![]).await.unwrap();
6053
6054 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
6055 assert!(applied.is_empty(), "a since-demoted banner's stale ban is rejected (refuse-superseded)");
6056 }
6057
6058 #[tokio::test]
6059 async fn withheld_revocation_cannot_resurrect_a_demoted_banners_grant() {
6060 let (_tmp, _guard) = init_test_db();
6066 let relay = MemoryRelay::new();
6067 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6068 let cid = community.id.to_hex();
6069 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
6070 let alice = Keys::generate();
6071 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
6072
6073 let carol = "cc".repeat(32);
6075 let cite = authority_citation(&community, &alice.public_key().to_hex());
6076 let inner = crate::community::roster::build_banlist_edition(&alice, &community.id, &[carol], 1, None, 1000, cite.as_ref()).unwrap();
6077 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6078 relay.inject(&outer, &community.relays);
6079
6080 let alice_bytes = alice.public_key().to_bytes();
6083 let grant_entity = crate::simd::hex::bytes_to_hex_32(&crate::community::derive::grant_locator(&community.id, &alice_bytes));
6084 crate::db::community::set_edition_head(&cid, &grant_entity, 2, &[0xAB; 32]).unwrap();
6085
6086 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
6087 assert!(applied.is_empty(), "a withheld revocation can't roll the banner's grant back to re-authorize them");
6088 }
6089
6090 #[tokio::test]
6091 async fn invite_registry_round_trips_and_drives_is_public() {
6092 let (_tmp, _guard) = init_test_db();
6096 let relay = MemoryRelay::new();
6097 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6098 assert!(!is_public(&community).unwrap(), "a fresh community is Private");
6099
6100 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6103 let loc = "1a".repeat(32);
6104 let inner = crate::community::roster::build_invite_links_edition(&owner, &community.id, &[loc.clone()], 1, None, 1000, None).unwrap();
6105 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6106 relay.inject(&outer, &community.relays);
6107
6108 let applied = fetch_and_apply_invite_links(&relay, &community).await.unwrap();
6109 assert_eq!(applied, vec![loc], "the owner's link edition folds + unions from the relay");
6110 assert!(is_public(&community).unwrap(), "mode recomputed Public from the folded aggregate");
6111
6112 publish_my_invite_links(&relay, &community, &[]).await.unwrap();
6114 let applied = fetch_and_apply_invite_links(&relay, &community).await.unwrap();
6115 assert!(applied.is_empty() && !is_public(&community).unwrap(), "an empty aggregate is Private");
6116 }
6117
6118 #[tokio::test]
6119 async fn metadata_edit_round_trips_to_a_lagging_member() {
6120 let (_tmp, _guard) = init_test_db();
6124 let relay = MemoryRelay::new();
6125 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6126 let cid = community.id.to_hex();
6127 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6128 let (genesis_v, genesis_hash) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
6129 assert_eq!(genesis_v, 1);
6130
6131 let mut edited = crate::community::metadata::CommunityMetadata::of(&community);
6132 edited.name = "Renamed HQ".into();
6133 edited.description = Some("now with a topic".into());
6134 let inner = crate::community::roster::build_community_root_edition(&owner, &community.id, &edited, 2, Some(&genesis_hash), 4000, None).unwrap();
6135 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6136 relay.inject(&outer, &community.relays);
6137
6138 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6139 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6140 assert_eq!(after.name, "Renamed HQ", "the owner's GroupRoot edit folded from the relay");
6141 assert_eq!(after.description.as_deref(), Some("now with a topic"));
6142 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0, 2, "head advanced to v2");
6143 }
6144
6145 #[tokio::test]
6146 async fn unauthorized_metadata_edit_is_ignored() {
6147 let (_tmp, _guard) = init_test_db();
6150 let relay = MemoryRelay::new();
6151 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6152 let cid = community.id.to_hex();
6153 let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
6154
6155 let mallory = Keys::generate();
6156 let mut hacked = crate::community::metadata::CommunityMetadata::of(&community);
6157 hacked.name = "Pwned".into();
6158 let inner = crate::community::roster::build_community_root_edition(&mallory, &community.id, &hacked, 2, Some(&genesis_hash), 5000, None).unwrap();
6159 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6160 relay.inject(&outer, &community.relays);
6161
6162 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6163 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6164 assert_eq!(after.name, "HQ", "a non-manage-metadata signer's GroupRoot edit is rejected");
6165 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0, 1, "an unauthorized edit never advances the head");
6166 }
6167
6168 #[tokio::test]
6169 async fn channel_rename_round_trips_from_owner_edition() {
6170 let (_tmp, _guard) = init_test_db();
6173 let relay = MemoryRelay::new();
6174 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6175 let cid = community.id.to_hex();
6176 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6177 let channel = community.channels[0].clone();
6178 let ch_hex = channel.id.to_hex();
6179 let (_, genesis_ch_hash) = crate::db::community::get_edition_head(&cid, &ch_hex).unwrap().unwrap();
6180
6181 let meta = crate::community::metadata::ChannelMetadata { name: "announcements".into() };
6182 let inner = crate::community::roster::build_channel_metadata_edition(&owner, &channel.id, &meta, 2, Some(&genesis_ch_hash), 6000, None).unwrap();
6183 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6184 relay.inject(&outer, &community.relays);
6185
6186 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6187 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6188 assert_eq!(after.channels[0].name, "announcements", "the owner's channel rename folded + applied");
6189 assert_eq!(crate::db::community::get_edition_head(&cid, &ch_hex).unwrap().unwrap().0, 2, "channel head advanced to v2");
6190 }
6191
6192 fn root_fork_v2(author: &Keys, community: &Community, name: &str, created: u64, genesis_hash: &[u8; 32]) -> (Event, [u8; 32], [u8; 32]) {
6195 let mut meta = crate::community::metadata::CommunityMetadata::of(community);
6196 meta.name = name.into();
6197 let inner = crate::community::roster::build_community_root_edition(author, &community.id, &meta, 2, Some(genesis_hash), created, None).unwrap();
6198 let self_hash = crate::community::version::edition_hash(&community.id.0, 2, Some(genesis_hash), inner.content.as_bytes());
6199 let inner_id = inner.id.to_bytes();
6200 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6201 (outer, self_hash, inner_id)
6202 }
6203
6204 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]) {
6207 let meta = crate::community::metadata::ChannelMetadata { name: name.into() };
6208 let inner = crate::community::roster::build_channel_metadata_edition(author, channel_id, &meta, 2, Some(genesis_hash), created, None).unwrap();
6209 let self_hash = crate::community::version::edition_hash(&channel_id.0, 2, Some(genesis_hash), inner.content.as_bytes());
6210 let inner_id = inner.id.to_bytes();
6211 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6212 (outer, self_hash, inner_id)
6213 }
6214
6215 #[tokio::test]
6219 async fn channel_same_version_fork_converges_to_the_lower_inner_id() {
6220 let (_tmp, _guard) = init_test_db();
6221 let relay = MemoryRelay::new();
6222 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6223 let cid = community.id.to_hex();
6224 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6225 let channel_id = community.channels[0].id;
6226 let ch_hex = channel_id.to_hex();
6227 let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &ch_hex).unwrap().unwrap();
6228
6229 let (out_a, ha, ida) = channel_fork_v2(&owner, &community, &channel_id, "alpha", 1000, &genesis_hash);
6230 let (out_b, hb, idb) = channel_fork_v2(&owner, &community, &channel_id, "bravo", 2000, &genesis_hash);
6231 let (win_name, win_h, win_id, lose_name, lose_h, lose_id) = if ida < idb {
6232 ("alpha", ha, ida, "bravo", hb, idb)
6233 } else {
6234 ("bravo", hb, idb, "alpha", ha, ida)
6235 };
6236 crate::db::community::set_edition_head_with_id(&cid, &ch_hex, 2, &lose_h, &lose_id).unwrap();
6238 {
6239 let mut c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6240 c.channels.iter_mut().find(|ch| ch.id == channel_id).unwrap().name = lose_name.into();
6241 crate::db::community::save_community(&c).unwrap();
6242 }
6243 relay.inject(&out_a, &community.relays);
6244 relay.inject(&out_b, &community.relays);
6245
6246 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6247 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6248 let ch_name = &after.channels.iter().find(|c| c.id == channel_id).unwrap().name;
6249 assert_eq!(ch_name, win_name, "channel converged on the lower-inner-id winner, not our held fork");
6250 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");
6251 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");
6252
6253 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6255 let after2 = crate::db::community::load_community(&community.id).unwrap().unwrap();
6256 assert_eq!(&after2.channels.iter().find(|c| c.id == channel_id).unwrap().name, win_name, "no flip back to the higher-id fork");
6257 }
6258
6259 #[tokio::test]
6262 async fn channel_same_version_fork_excludes_an_unauthorized_lower_id_edition() {
6263 let (_tmp, _guard) = init_test_db();
6264 let relay = MemoryRelay::new();
6265 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6266 let cid = community.id.to_hex();
6267 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6268 let channel_id = community.channels[0].id;
6269 let ch_hex = channel_id.to_hex();
6270 let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &ch_hex).unwrap().unwrap();
6271
6272 let (owner_out, owner_h, owner_id) = channel_fork_v2(&owner, &community, &channel_id, "legit", 1000, &genesis_hash);
6273 let mallory = Keys::generate();
6275 let mal_out = {
6276 let mut chosen = None;
6277 for t in 1..=10_000u64 {
6278 let cand = channel_fork_v2(&mallory, &community, &channel_id, "forged", t, &genesis_hash);
6279 if cand.2 < owner_id { chosen = Some(cand.0); break; }
6280 }
6281 chosen.expect("a mallory channel edition with a lower inner id")
6282 };
6283 relay.inject(&owner_out, &community.relays);
6284 relay.inject(&mal_out, &community.relays);
6285
6286 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6287 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6288 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");
6289 assert_eq!(crate::db::community::get_edition_head(&cid, &ch_hex).unwrap().unwrap(), (2, owner_h), "the authorized channel edition is the head");
6290 }
6291
6292 #[tokio::test]
6296 async fn epoch_primary_floor_lets_a_refounding_v1_supersede_a_held_high_version() {
6297 let (_tmp, _guard) = init_test_db();
6298 let relay = MemoryRelay::new();
6299 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6300 let cid = community.id.to_hex();
6301 for v in 2..=5u64 {
6303 crate::db::community::set_edition_head_with_id(&cid, &cid, v, &[v as u8; 32], &[v as u8; 32]).unwrap();
6304 }
6305 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0, 5);
6306 crate::db::community::set_edition_head_with_id(&cid, &cid, 3, &[0x33; 32], &[0x33; 32]).unwrap();
6308 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0, 5, "in-epoch downgrade refused");
6309
6310 crate::db::community::advance_server_root_epoch(&cid, 1, &[0xEE; 32]).unwrap();
6312 crate::db::community::set_edition_head_with_id(&cid, &cid, 1, &[0x01; 32], &[0x01; 32]).unwrap();
6313 let (v, h) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
6314 assert_eq!((v, h), (1, [0x01; 32]), "epoch-1 v1 supersedes epoch-0 v5 (epoch-primary)");
6315 assert_eq!(
6316 crate::db::community::get_all_edition_heads_epoched(&cid).unwrap().get(&cid).map(|(e, v, _)| (*e, *v)),
6317 Some((1, 1)),
6318 "head now recorded at epoch 1",
6319 );
6320 crate::db::community::set_edition_head_with_id(&cid, &cid, 1, &[0xAA; 32], &[0x02; 32]).unwrap();
6322 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().1, [0x01; 32], "same-epoch same-version is not an advance");
6323 }
6324
6325 #[tokio::test]
6329 async fn same_version_fork_converges_to_the_lower_inner_id() {
6330 let (_tmp, _guard) = init_test_db();
6331 let relay = MemoryRelay::new();
6332 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6333 let cid = community.id.to_hex();
6334 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6335 let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
6336
6337 let (out_a, ha, ida) = root_fork_v2(&owner, &community, "Alpha", 1000, &genesis_hash);
6338 let (out_b, hb, idb) = root_fork_v2(&owner, &community, "Bravo", 2000, &genesis_hash);
6339 let (win_name, win_h, win_id, lose_name, lose_h, lose_id) = if ida < idb {
6341 ("Alpha", ha, ida, "Bravo", hb, idb)
6342 } else {
6343 ("Bravo", hb, idb, "Alpha", ha, ida)
6344 };
6345 crate::db::community::set_edition_head_with_id(&cid, &cid, 2, &lose_h, &lose_id).unwrap();
6346 {
6347 let mut c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6348 c.name = lose_name.into();
6349 crate::db::community::save_community(&c).unwrap();
6350 }
6351 relay.inject(&out_a, &community.relays);
6352 relay.inject(&out_b, &community.relays);
6353
6354 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6355 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6356 assert_eq!(after.name, win_name, "converged on the lower-inner-id winner, not our own held fork");
6357 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap(), (2, win_h), "head self_hash converged at the SAME version");
6358 assert_eq!(crate::db::community::get_edition_head_inner_id(&cid, &cid).unwrap(), Some(win_id), "head inner_id moved to the winner");
6359
6360 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6362 assert_eq!(crate::db::community::load_community(&community.id).unwrap().unwrap().name, win_name, "no flip back to the higher-id fork");
6363 }
6364
6365 #[tokio::test]
6368 async fn converged_head_chains_the_next_edit_without_reforking() {
6369 let (_tmp, _guard) = init_test_db();
6370 let relay = MemoryRelay::new();
6371 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6372 let cid = community.id.to_hex();
6373 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6374 let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
6375
6376 let (out_a, ha, ida) = root_fork_v2(&owner, &community, "Alpha", 1000, &genesis_hash);
6377 let (out_b, hb, idb) = root_fork_v2(&owner, &community, "Bravo", 2000, &genesis_hash);
6378 let (lose_h, lose_id) = if ida < idb { (hb, idb) } else { (ha, ida) };
6379 crate::db::community::set_edition_head_with_id(&cid, &cid, 2, &lose_h, &lose_id).unwrap();
6380 relay.inject(&out_a, &community.relays);
6381 relay.inject(&out_b, &community.relays);
6382 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6383 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0, 2, "converged at v2");
6384
6385 let mut c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6386 c.name = "Third".into();
6387 republish_community_metadata(&relay, &c).await.unwrap();
6388 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0, 3, "advanced to v3 off the converged head");
6389
6390 let empty: std::collections::HashMap<String, (u64, [u8; 32])> = std::collections::HashMap::new();
6391 let folded = crate::community::roster::fold_roster(&fetch_control_inners(&relay, &community).await, &community.id, &empty);
6392 assert_eq!(folded.root_head.as_ref().map(|h| h.version), Some(3), "a fresh fold reaches v3");
6393 assert!(!folded.gapped_entities.contains(&community.id.0), "the chain is contiguous genesis -> winner -> v3");
6394 }
6395
6396 #[tokio::test]
6399 async fn same_version_fork_excludes_an_unauthorized_lower_id_edition() {
6400 let (_tmp, _guard) = init_test_db();
6401 let relay = MemoryRelay::new();
6402 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6403 let cid = community.id.to_hex();
6404 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6405 let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
6406
6407 let (owner_out, owner_h, owner_id) = root_fork_v2(&owner, &community, "Legit", 1000, &genesis_hash);
6408 let mallory = Keys::generate();
6410 let (mal_out, mal_id) = {
6411 let mut chosen = None;
6412 for t in 1..=10_000u64 {
6413 let cand = root_fork_v2(&mallory, &community, "Forged", t, &genesis_hash);
6414 if cand.2 < owner_id { chosen = Some((cand.0, cand.2)); break; }
6415 }
6416 chosen.expect("a mallory edition with a lower inner id")
6417 };
6418 assert!(mal_id < owner_id, "premise: the forgery sorts first author-blind");
6419 relay.inject(&owner_out, &community.relays);
6420 relay.inject(&mal_out, &community.relays);
6421
6422 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6424 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6425 assert_eq!(after.name, "Legit", "the forgery never wins despite a lower inner id");
6426 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap(), (2, owner_h), "the authorized edition is the head");
6427 }
6428
6429 #[tokio::test]
6433 async fn same_version_fork_on_an_authority_record_fails_closed() {
6434 let (_tmp, _guard) = init_test_db();
6435 let relay = MemoryRelay::new();
6436 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6437 let cid = community.id.to_hex();
6438 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6439 let bl_eid = crate::community::derive::banlist_locator(&community.id);
6440 let bl_hex = crate::simd::hex::bytes_to_hex_32(&bl_eid);
6441
6442 let prev = [0x99u8; 32]; let build_ban = |list: &[String], created: u64| {
6444 let inner = crate::community::roster::build_banlist_edition(&owner, &community.id, list, 2, Some(&prev), created, None).unwrap();
6445 let self_hash = crate::community::version::edition_hash(&bl_eid, 2, Some(&prev), inner.content.as_bytes());
6446 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6447 (outer, self_hash, inner.id.to_bytes())
6448 };
6449 let (out_a, ha, ida) = build_ban(&["aa".repeat(32)], 1000);
6450 let (out_b, hb, idb) = build_ban(&["bb".repeat(32)], 2000);
6451 let (lose_h, lose_id) = if ida < idb { (hb, idb) } else { (ha, ida) };
6454 crate::db::community::set_edition_head_with_id(&cid, &bl_hex, 2, &lose_h, &lose_id).unwrap();
6455 relay.inject(&out_a, &community.relays);
6456 relay.inject(&out_b, &community.relays);
6457
6458 let floors = crate::db::community::get_all_edition_heads(&cid).unwrap();
6459 let folded = crate::community::roster::fold_roster(&fetch_control_inners(&relay, &community).await, &community.id, &floors);
6460 assert!(folded.gapped_entities.contains(&bl_eid), "the authority-record fork is quarantined");
6461 assert!(folded.banlist_head.is_none() && folded.banlist_author.is_none(), "no banlist folded off the withheld view");
6462 }
6463
6464 #[tokio::test]
6465 async fn editions_sign_through_the_active_client_signer() {
6466 let (_tmp, _guard) = init_test_db();
6471 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6472 crate::state::set_nostr_client(nostr_sdk::prelude::Client::builder().build());
6473
6474 let relay = MemoryRelay::new();
6475 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6476 let cid = community.id.to_hex();
6477
6478 publish_banlist(&relay, &community, &["dd".repeat(32)]).await.unwrap();
6480 let floors = crate::db::community::get_all_edition_heads(&cid).unwrap();
6481 let folded = crate::community::roster::fold_roster(
6482 &fetch_control_inners(&relay, &community).await, &community.id, &floors);
6483 assert_eq!(folded.banlist_author, Some(owner.public_key()), "banlist signed by the client signer");
6484 assert!(folded.root_author.is_some(), "genesis GroupRoot folded");
6485 let _ = crate::state::take_nostr_client();
6486 }
6487
6488 async fn fetch_control_inners(relay: &MemoryRelay, community: &Community) -> Vec<Event> {
6490 let z = crate::community::roster::control_pseudonym(&community.server_root_key, &community.id, crate::community::Epoch(0));
6491 let query = Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], ..Default::default() };
6492 let mut out = Vec::new();
6493 for ev in relay.fetch(&query, &community.relays).await.unwrap() {
6494 if let Ok(inner) = crate::community::roster::open_control_edition(&ev, &community.server_root_key) {
6495 out.push(inner);
6496 }
6497 }
6498 out
6499 }
6500
6501 fn simulate_bunker(owner: &Keys) {
6504 crate::state::set_nostr_client(nostr_sdk::prelude::Client::builder().build());
6505 crate::signer::set_test_signer(Some(crate::signer::ActiveSigner::Keys(owner.clone())));
6508 crate::state::MY_SECRET_KEY.clear(&[]);
6509 assert!(crate::state::MY_SECRET_KEY.to_keys().is_none(), "bunker sim: no local key");
6510 }
6511
6512 #[tokio::test]
6513 async fn am_i_banned_detects_own_npub_in_banlist() {
6514 let (_tmp, _guard) = init_test_db();
6516 let relay = MemoryRelay::new();
6517 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6518 let me = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key().to_hex();
6519 let cid = community.id.to_hex();
6520 assert!(!am_i_banned(&community), "not banned on a fresh community");
6521 crate::db::community::set_community_banlist(&cid, &[me], 1).unwrap();
6523 assert!(am_i_banned(&community), "own npub in the banlist → banned → self-remove");
6524 crate::db::community::set_community_banlist(&cid, &[], 2).unwrap();
6525 assert!(!am_i_banned(&community), "cleared banlist → not banned");
6526 }
6527
6528 #[tokio::test]
6529 async fn bunker_owner_cannot_ban_in_private_community() {
6530 let (_tmp, _guard) = init_test_db();
6533 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6534 let relay = MemoryRelay::new();
6535 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6536 simulate_bunker(&owner);
6537
6538 let victim = "cc".repeat(32);
6539 let err = publish_banlist(&relay, &community, &[victim]).await.unwrap_err();
6540 assert!(err.contains("private community") && err.contains("bunker"), "clear bunker explanation: {err}");
6541 assert!(
6542 crate::db::community::get_community_banlist(&community.id.to_hex()).unwrap().is_empty(),
6543 "the ban must NOT half-apply (nothing published or persisted)"
6544 );
6545 let _ = crate::state::take_nostr_client();
6546 }
6547
6548 #[tokio::test]
6549 async fn bunker_owner_can_ban_in_public_community() {
6550 let (_tmp, _guard) = init_test_db();
6553 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6554 let relay = MemoryRelay::new();
6555 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6556 create_public_invite(&relay, &community, None, None).await.unwrap();
6557 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
6558 assert!(is_public(&community).unwrap(), "minting a link made it Public");
6559 simulate_bunker(&owner);
6560
6561 let victim = "cc".repeat(32);
6562 publish_banlist(&relay, &community, &[victim.clone()]).await.unwrap();
6563 assert_eq!(
6564 crate::db::community::get_community_banlist(&community.id.to_hex()).unwrap(),
6565 vec![victim],
6566 "a public ban from a bunker account succeeds (no rekey needed)"
6567 );
6568 let _ = crate::state::take_nostr_client();
6569 }
6570
6571 #[tokio::test]
6572 async fn bunker_owner_cannot_privatize() {
6573 let (_tmp, _guard) = init_test_db();
6576 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6577 let relay = MemoryRelay::new();
6578 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6579 let (token, _) = create_public_invite(&relay, &community, None, None).await.unwrap();
6580 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
6581 simulate_bunker(&owner);
6582
6583 let err = revoke_public_invite(&relay, &community, &crate::simd::hex::hex_to_bytes_32(&token)).await.unwrap_err();
6584 assert!(err.contains("private") && err.contains("bunker"), "clear bunker explanation: {err}");
6585 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6586 assert!(is_public(&after).unwrap(), "the revoke must NOT half-apply — community stays Public");
6587 let _ = crate::state::take_nostr_client();
6588 }
6589
6590 #[tokio::test]
6591 async fn non_owner_admin_can_edit_community_metadata() {
6592 let (_tmp, _guard) = init_test_db();
6596 let relay = MemoryRelay::new();
6597 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6598 let cid = community.id.to_hex();
6599 let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
6600
6601 let admin = Keys::generate();
6603 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
6604 set_member_grant(&relay, &community, &admin.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
6605
6606 let mut edited = crate::community::metadata::CommunityMetadata::of(&community);
6608 edited.name = "Admin Renamed".into();
6609 let inner = crate::community::roster::build_community_root_edition(&admin, &community.id, &edited, 2, Some(&genesis_hash), 7000, None).unwrap();
6610 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6611 relay.inject(&outer, &community.relays);
6612
6613 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6614 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6615 assert_eq!(after.name, "Admin Renamed", "a MANAGE_METADATA admin (not the owner) can edit metadata");
6616 }
6617
6618 #[tokio::test]
6619 async fn banning_an_admin_revokes_their_role() {
6620 let (_tmp, _guard) = init_test_db();
6624 let relay = MemoryRelay::new();
6625 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6626 let cid = community.id.to_hex();
6627 create_public_invite(&relay, &community, None, None).await.unwrap();
6628 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
6629
6630 let alice = Keys::generate();
6631 let alice_hex = alice.public_key().to_hex();
6632 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
6633 set_member_grant(&relay, &community, &alice_hex, vec![admin_role_id]).await.unwrap();
6634 let holds_role = |hex: &str| crate::db::community::get_community_roles(&cid).unwrap()
6635 .grants.iter().any(|g| g.member == hex && !g.role_ids.is_empty());
6636 assert!(holds_role(&alice_hex), "alice is admin pre-ban");
6637
6638 publish_banlist(&relay, &community, &[alice_hex.clone()]).await.unwrap();
6639 assert!(!holds_role(&alice_hex), "banning an admin revokes their role — no dangling grant");
6640 }
6641
6642 #[tokio::test]
6643 async fn kicking_an_admin_revokes_their_role() {
6644 let (_tmp, _guard) = init_test_db();
6647 let relay = MemoryRelay::new();
6648 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6649 let cid = community.id.to_hex();
6650 let alice = Keys::generate();
6651 let alice_hex = alice.public_key().to_hex();
6652 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
6653 set_member_grant(&relay, &community, &alice_hex, vec![admin_role_id]).await.unwrap();
6654 let holds_role = |hex: &str| crate::db::community::get_community_roles(&cid).unwrap()
6655 .grants.iter().any(|g| g.member == hex && !g.role_ids.is_empty());
6656 assert!(holds_role(&alice_hex), "alice is admin pre-kick");
6657
6658 publish_kick(&relay, &community, &community.channels[0], &alice_hex).await.unwrap();
6659 assert!(!holds_role(&alice_hex), "kicking an admin revokes their role");
6660 }
6661
6662 #[tokio::test]
6663 async fn republish_channel_metadata_renames_and_publishes() {
6664 let (_tmp, _guard) = init_test_db();
6667 let relay = MemoryRelay::new();
6668 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6669 let cid = community.id.to_hex();
6670 let channel = community.channels[0].clone();
6671 let ch_hex = channel.id.to_hex();
6672
6673 republish_channel_metadata(&relay, &community, &channel.id, "lobby").await.unwrap();
6674 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6675 assert_eq!(after.channels[0].name, "lobby", "the producer renamed the channel locally");
6676 assert_eq!(crate::db::community::get_edition_head(&cid, &ch_hex).unwrap().unwrap().0, 2, "channel head advanced");
6677 }
6678
6679 #[tokio::test]
6680 async fn revoking_the_last_link_privatizes_and_rotates_the_base() {
6681 let (_tmp, _guard) = init_test_db();
6686 let relay = MemoryRelay::new();
6687 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6688 assert!(!is_public(&community).unwrap(), "a fresh community is Private");
6689 assert_eq!(community.server_root_epoch, crate::community::Epoch(0));
6690
6691 let (t1, _) = create_public_invite(&relay, &community, None, None).await.unwrap();
6693 let (t2, _) = create_public_invite(&relay, &community, None, None).await.unwrap();
6694 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6695 assert!(is_public(&c).unwrap(), "minting a link flips the mode to Public");
6696 assert_eq!(c.server_root_epoch, crate::community::Epoch(0), "minting links does NOT rotate the base");
6697
6698 revoke_public_invite(&relay, &c, &crate::simd::hex::hex_to_bytes_32(&t1)).await.unwrap();
6700 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6701 assert!(is_public(&c).unwrap(), "one link remains → still Public");
6702 assert_eq!(c.server_root_epoch, crate::community::Epoch(0), "revoking a non-last link does NOT rotate");
6703
6704 revoke_public_invite(&relay, &c, &crate::simd::hex::hex_to_bytes_32(&t2)).await.unwrap();
6706 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6707 assert!(!is_public(&c).unwrap(), "revoking the last link flips to Private");
6708 assert_eq!(c.server_root_epoch, crate::community::Epoch(1), "privatize re-founded: the base key rotated");
6709
6710 revoke_public_invite(&relay, &c, &crate::simd::hex::hex_to_bytes_32(&t2)).await.unwrap();
6713 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6714 assert_eq!(c.server_root_epoch, crate::community::Epoch(1), "a no-op re-revoke does not double-rotate");
6715 }
6716
6717 #[tokio::test]
6718 async fn private_ban_reseals_base_public_ban_does_not() {
6719 let (_tmp, _guard) = init_test_db();
6722 let relay = MemoryRelay::new();
6723 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6724 let victim = "cc".repeat(32);
6725
6726 assert!(!is_public(&community).unwrap(), "fresh community is Private");
6728 publish_banlist(&relay, &community, &[victim.clone()]).await.unwrap();
6729 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6730 assert_eq!(c.server_root_epoch, crate::community::Epoch(1), "a private-community ban re-seals the base");
6731
6732 create_public_invite(&relay, &c, None, None).await.unwrap();
6734 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6735 assert!(is_public(&c).unwrap(), "minted a link → Public");
6736 publish_banlist(&relay, &c, &[victim.clone(), "dd".repeat(32)]).await.unwrap();
6737 let c2 = crate::db::community::load_community(&community.id).unwrap().unwrap();
6738 assert_eq!(c2.server_root_epoch, crate::community::Epoch(1), "a public-community ban does NOT rotate the base");
6739 }
6740
6741 #[tokio::test]
6742 async fn private_ban_seals_the_banned_member_out_of_the_new_root() {
6743 use crate::community::derive::{base_rekey_pseudonym, recipient_pseudonym};
6749 use crate::community::rekey::{open_rekey_event, rekey_pairwise_secret};
6750 use crate::types::Message;
6751 use nostr_sdk::prelude::ToBech32;
6752 let (_tmp, _guard) = init_test_db();
6753 let relay = MemoryRelay::new();
6754 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6755 let cid = community.id.to_hex();
6756 let genesis_root = *community.server_root_key.as_bytes();
6757 let channel_hex = community.channels[0].id.to_hex();
6758
6759 let victim = Keys::generate();
6761 let victim_b32 = victim.public_key().to_bech32().unwrap();
6762 let mut m = Message::default();
6763 m.id = "aa".repeat(32);
6764 m.npub = Some(victim_b32.clone());
6765 m.at = 1000;
6766 crate::db::events::save_message(&channel_hex, &m).await.unwrap();
6767 assert!(
6768 crate::db::community::community_member_activity(&cid).unwrap().iter().any(|(np, _)| np == &victim_b32),
6769 "victim is observed before the ban"
6770 );
6771
6772 publish_banlist(&relay, &community, &[victim.public_key().to_hex()]).await.unwrap();
6774 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
6775 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(1), "private ban re-seals the base");
6776 assert!(
6777 !crate::db::community::community_member_activity(&cid).unwrap().iter().any(|(np, _)| np == &victim_b32),
6778 "the banned victim is no longer observed (banlist hex → bech32 reconciliation worked)"
6779 );
6780
6781 let addr = base_rekey_pseudonym(&crate::community::ServerRootKey(genesis_root), &community.id, crate::community::Epoch(1)).to_hex();
6783 let found = relay
6784 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() }, &community.relays)
6785 .await
6786 .unwrap();
6787 assert_eq!(found.len(), 1, "the base rekey is published");
6788 let parsed = open_rekey_event(&found[0], &genesis_root).unwrap();
6789 let secret = rekey_pairwise_secret(victim.secret_key(), &parsed.rotator).unwrap();
6790 let loc = recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
6791 assert!(
6792 parsed.blobs.iter().all(|b| b.locator != loc),
6793 "the BANNED victim has NO blob — sealed OUT of the new root (read access is actually cut)"
6794 );
6795 }
6796
6797 struct SwapDuringPublishRelay {
6804 inner: MemoryRelay,
6805 to: String,
6806 armed: std::sync::atomic::AtomicBool,
6807 }
6808
6809 impl SwapDuringPublishRelay {
6810 fn new() -> Self {
6811 let to = make_test_npub(TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed));
6812 std::fs::create_dir_all(crate::db::shared_test_data_dir().join(&to)).unwrap();
6813 assert_ne!(to, crate::db::get_current_account().unwrap(), "the fixture must swap to a DIFFERENT account");
6814 Self { inner: MemoryRelay::new(), to, armed: std::sync::atomic::AtomicBool::new(false) }
6815 }
6816 fn arm(&self) {
6821 self.armed.store(true, std::sync::atomic::Ordering::SeqCst);
6822 }
6823 fn swap_if_armed(&self) {
6824 if self.armed.swap(false, std::sync::atomic::Ordering::SeqCst) {
6825 crate::db::set_current_account(self.to.clone()).unwrap();
6826 crate::db::init_database(&self.to).unwrap();
6827 }
6828 }
6829 }
6830 #[async_trait::async_trait]
6831 impl Transport for SwapDuringPublishRelay {
6832 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> { Ok(crate::community::transport::PlaneFetch::whole(self.fetch(query, relays).await?)) }
6833 async fn publish(&self, event: &Event, relays: &[String]) -> Result<(), String> {
6834 self.swap_if_armed();
6835 self.inner.publish(event, relays).await
6836 }
6837 async fn publish_durable(&self, event: &Event, relays: &[String]) -> Result<(), String> {
6838 self.swap_if_armed();
6839 self.inner.publish_durable(event, relays).await
6840 }
6841 async fn fetch(&self, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
6842 self.inner.fetch(query, relays).await
6843 }
6844 }
6845
6846 #[tokio::test]
6855 async fn account_swap_during_grant_publish_lands_in_the_issuing_account() {
6856 let (_tmp, _guard) = init_test_db();
6857 let issuer = crate::db::current_session();
6858 let swap = SwapDuringPublishRelay::new();
6859 let community = create_community(&swap, "HQ", "general", vec!["r1".into()]).await.unwrap();
6860 let cid = community.id.to_hex();
6861 let member = "cc".repeat(32);
6862 let entity_hex = crate::simd::hex::bytes_to_hex_32(
6863 &crate::community::derive::grant_locator(&community.id, &crate::simd::hex::hex_to_bytes_32(&member)));
6864 assert!(crate::db::community::get_edition_head(&cid, &entity_hex).unwrap().is_none(), "no grant head yet");
6865
6866 swap.arm();
6867 set_member_grant(&swap, &community, &member, vec!["a".repeat(64)]).await.unwrap();
6868
6869 assert!(
6870 crate::db::with_session(issuer, async {
6871 crate::db::community::get_edition_head(&cid, &entity_hex).unwrap().is_some()
6872 }).await,
6873 "the grant recorded against the account that issued it"
6874 );
6875 assert_eq!(crate::db::get_current_account().unwrap(), swap.to, "the swap really happened");
6876 assert!(
6877 crate::db::community::get_edition_head(&cid, &entity_hex).unwrap_or(None).is_none(),
6878 "and the account swapped in has no trace of it"
6879 );
6880 }
6881
6882 #[tokio::test]
6886 async fn account_swap_during_ban_publish_applies_to_the_banning_account() {
6887 let (_tmp, _guard) = init_test_db();
6888 let banner = crate::db::current_session();
6889 let swap = SwapDuringPublishRelay::new();
6890 let community = create_community(&swap, "HQ", "general", vec!["r1".into()]).await.unwrap();
6891 let cid = community.id.to_hex();
6892 assert!(!is_public(&community).unwrap(), "fresh community is Private (a ban re-seals)");
6893
6894 swap.arm();
6895 publish_banlist(&swap, &community, &["cc".repeat(32)]).await.unwrap();
6896
6897 assert!(
6898 crate::db::with_session(banner, async {
6899 !crate::db::community::get_community_banlist(&cid).unwrap().is_empty()
6900 }).await,
6901 "the ban applied to the account that issued it — losing it would leave the \
6902 relays holding a ban the owner cannot see"
6903 );
6904 assert!(
6905 crate::db::community::get_community_banlist(&cid).unwrap_or_default().is_empty(),
6906 "and the account swapped in inherits no banlist"
6907 );
6908 }
6909
6910 #[tokio::test]
6913 async fn swap_session_clears_per_account_state_and_keys() {
6914 let (_tmp, _guard) = init_test_db();
6915 {
6916 let mut st = crate::state::STATE.lock().await;
6917 st.db_loaded = true;
6918 st.is_syncing = true;
6919 }
6920 assert!(crate::state::MY_SECRET_KEY.has_key(), "account A holds a live key");
6921
6922 crate::VectorCore.swap_session().await;
6923
6924 let st = crate::state::STATE.lock().await;
6925 assert!(st.chats.is_empty() && st.profiles.is_empty(), "STATE chats/profiles cleared on swap");
6926 assert!(!st.db_loaded && !st.is_syncing, "db_loaded / is_syncing reset");
6927 assert!(!crate::state::MY_SECRET_KEY.has_key(), "key vault cleared — no leak into account B");
6928 }
6929
6930 #[tokio::test]
6934 async fn join_finalization_persists_and_registers_the_channel() {
6935 let (_tmp, _guard) = init_test_db();
6936 crate::state::STATE.lock().await.chats.clear(); let relay = MemoryRelay::new();
6938 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6939 become_local(&Keys::generate());
6941
6942 crate::VectorCore.finalize_member_join(community.clone(), &relay, None).await.unwrap();
6943
6944 assert!(crate::db::community::load_community(&community.id).unwrap().is_some(), "community persisted on join");
6945 assert!(!crate::state::STATE.lock().await.chats.is_empty(), "the channel is registered as a chat");
6946 }
6947
6948 #[tokio::test]
6952 async fn join_finalization_tears_down_a_banned_joiner() {
6953 let (_tmp, _guard) = init_test_db();
6954 let relay = MemoryRelay::new();
6955 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6956 create_public_invite(&relay, &community, None, None).await.unwrap();
6958 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
6959
6960 let joiner = Keys::generate();
6962 publish_banlist(&relay, &community, &[joiner.public_key().to_hex()]).await.unwrap();
6963 become_local(&joiner);
6964 assert!(crate::db::community::load_community(&community.id).unwrap().is_some(), "community present pre-join");
6965
6966 let result = crate::VectorCore.finalize_member_join(community.clone(), &relay, None).await;
6967 assert!(result.is_err(), "a banned joiner's finalize must fail");
6968 assert!(result.unwrap_err().to_string().contains("banned"), "the error names the ban");
6969 assert!(
6970 crate::db::community::load_community(&community.id).unwrap().is_none(),
6971 "the just-saved community is torn back down — no orphaned row for a banned joiner"
6972 );
6973 }
6974
6975 #[tokio::test]
6981 async fn delete_community_wipes_every_community_scoped_table() {
6982 let (_tmp, _guard) = init_test_db();
6983 let relay = MemoryRelay::new();
6984 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6985 let cid = community.id.to_hex();
6986
6987 crate::db::community::store_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 1, &[0x11u8; 32]).unwrap();
6989 crate::db::community::save_public_invite("tok", &cid, "https://x/invite#y", None, None).unwrap();
6990 crate::db::community::save_pending_invite(&cid, "{}", "npub1inviter", 0).unwrap();
6991 crate::db::community::set_edition_head(&cid, &cid, 1, &[0x22u8; 32]).unwrap();
6992 crate::db::community::set_community_banlist(&cid, &["cc".repeat(32)], 100).unwrap();
6993
6994 assert!(crate::db::community::community_exists(&community.id).unwrap());
6996 assert!(!crate::db::community::held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty());
6997 assert!(!crate::db::community::list_public_invites(&cid).unwrap().is_empty());
6998 assert!(crate::db::community::list_pending_invites().unwrap().iter().any(|p| p.community_id == cid));
6999 assert!(!crate::db::community::get_all_edition_heads(&cid).unwrap().is_empty());
7000 assert!(!crate::db::community::get_community_banlist(&cid).unwrap().is_empty());
7001
7002 crate::db::community::delete_community(&cid).unwrap();
7003
7004 assert!(!crate::db::community::community_exists(&community.id).unwrap(), "communities row gone");
7006 assert!(crate::db::community::load_community(&community.id).unwrap().is_none(), "community not loadable");
7007 assert!(crate::db::community::held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty(), "epoch keys wiped");
7008 assert!(crate::db::community::list_public_invites(&cid).unwrap().is_empty(), "public invites wiped");
7009 assert!(!crate::db::community::list_pending_invites().unwrap().iter().any(|p| p.community_id == cid), "pending invites wiped");
7010 assert!(crate::db::community::get_all_edition_heads(&cid).unwrap().is_empty(), "edition heads wiped");
7011 assert!(crate::db::community::get_community_banlist(&cid).unwrap().is_empty(), "banlist wiped with the channels");
7012 }
7013
7014 #[tokio::test]
7018 async fn fetch_control_folded_skips_junk_injected_at_the_coordinate() {
7019 let (_tmp, _guard) = init_test_db();
7020 let relay = MemoryRelay::new();
7021 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7022 let owner_hex = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key().to_hex();
7023
7024 let z = crate::community::roster::control_pseudonym(&community.server_root_key, &community.id, community.server_root_epoch);
7026 let junk = nostr_sdk::prelude::EventBuilder::new(nostr_sdk::prelude::Kind::Custom(event_kind::COMMUNITY_CONTROL), "not a sealed edition")
7027 .tags([nostr_sdk::prelude::Tag::custom("z", [z])])
7028 .finalize(&Keys::generate())
7029 .unwrap();
7030 relay.publish(&junk, &community.relays).await.unwrap();
7031
7032 let folded = fetch_control_folded(&relay, &community).await.unwrap();
7033 assert!(
7034 !crate::community::roster::authorize_delegation(&folded, Some(&owner_hex)).roles.is_empty(),
7035 "the genuine Admin role still folds; the un-openable junk is silently dropped"
7036 );
7037 }
7038
7039 #[tokio::test]
7042 async fn fetch_control_folded_on_dead_relays_is_empty_not_a_panic() {
7043 let (_tmp, _guard) = init_test_db();
7044 let community = saved_community_owned_by(&Keys::generate());
7045 let folded = fetch_control_folded(&FailingRelay, &community).await.unwrap();
7046 assert!(folded.roles.roles.is_empty() && folded.root_meta.is_none(), "dead relays → empty fold, no panic");
7047 }
7048
7049 #[tokio::test]
7050 async fn successful_private_ban_leaves_no_read_cut_pending() {
7051 let (_tmp, _guard) = init_test_db();
7053 let relay = MemoryRelay::new();
7054 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7055 let cid = community.id.to_hex();
7056 publish_banlist(&relay, &community, &["cc".repeat(32)]).await.unwrap();
7057 assert!(!crate::db::community::get_read_cut_pending(&cid).unwrap(), "a successful re-seal leaves no pending read-cut");
7058 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
7059 assert_eq!(c.server_root_epoch, crate::community::Epoch(1));
7060 }
7061
7062 #[tokio::test]
7063 async fn failed_reseal_sets_pending_then_sync_retry_recovers() {
7064 let (_tmp, _guard) = init_test_db();
7069 let relay = RekeyFailingRelay::new(); let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7071 let cid = community.id.to_hex();
7072 let victim = "cc".repeat(32);
7073
7074 assert!(publish_banlist(&relay, &community, &[victim.clone()]).await.is_err(), "the re-seal's base rekey fails");
7076 assert!(crate::db::community::get_read_cut_pending(&cid).unwrap(), "a failed re-seal leaves read_cut_pending set");
7077 assert_eq!(crate::db::community::get_community_banlist(&cid).unwrap(), vec![victim.clone()], "the ban itself still applied");
7078 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
7079 assert_eq!(c.server_root_epoch, crate::community::Epoch(0), "base NOT rotated while the re-seal is pending");
7080
7081 relay.allow_rekey();
7083 retry_pending_read_cut(&relay, &c).await.unwrap();
7084 assert!(!crate::db::community::get_read_cut_pending(&cid).unwrap(), "pending cleared after the retry succeeds");
7085 let c2 = crate::db::community::load_community(&community.id).unwrap().unwrap();
7086 assert_eq!(c2.server_root_epoch, crate::community::Epoch(1), "the read-cut finally rotated the base");
7087 }
7088
7089 #[tokio::test]
7090 async fn privatize_reseals_to_observed_participants_not_just_owner() {
7091 use crate::community::derive::{base_rekey_pseudonym, recipient_pseudonym};
7095 use crate::community::rekey::{open_rekey_blob, open_rekey_event, rekey_pairwise_secret};
7096 use crate::types::Message;
7097 use nostr_sdk::prelude::ToBech32;
7098 let (_tmp, _guard) = init_test_db();
7099 let relay = MemoryRelay::new();
7100 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7101 let cid = community.id.to_hex();
7102 let genesis_root = *community.server_root_key.as_bytes();
7103 let channel_hex = community.channels[0].id.to_hex();
7104
7105 let alice = Keys::generate();
7107 let alice_b32 = alice.public_key().to_bech32().unwrap();
7108 let mut m = Message::default();
7109 m.id = "aa".repeat(32);
7110 m.npub = Some(alice_b32.clone());
7111 m.at = 1000;
7112 crate::db::events::save_message(&channel_hex, &m).await.unwrap();
7113 assert!(
7114 crate::db::community::community_member_activity(&cid).unwrap().iter().any(|(np, _)| np == &alice_b32),
7115 "alice is an observed participant"
7116 );
7117
7118 let (token_hex, _) = create_public_invite(&relay, &community, None, None).await.unwrap();
7120 revoke_public_invite(&relay, &community, &crate::simd::hex::hex_to_bytes_32(&token_hex)).await.unwrap();
7121 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
7122 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(1), "privatize rotated the base");
7123
7124 let addr = base_rekey_pseudonym(&crate::community::ServerRootKey(genesis_root), &community.id, crate::community::Epoch(1)).to_hex();
7127 let found = relay
7128 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() }, &community.relays)
7129 .await
7130 .unwrap();
7131 assert_eq!(found.len(), 1, "the base rekey is published");
7132 let parsed = open_rekey_event(&found[0], &genesis_root).unwrap();
7133 let secret = rekey_pairwise_secret(alice.secret_key(), &parsed.rotator).unwrap();
7134 let loc = recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
7135 let alice_blob = parsed.blobs.iter().find(|b| b.locator == loc).expect("alice's blob present (NOT sealed out)");
7136 let recovered = open_rekey_blob(alice.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, alice_blob).unwrap();
7137 assert_eq!(reloaded.server_root_key.as_bytes(), &recovered, "alice recovers the new root = owner's advanced base");
7138 }
7139
7140 #[tokio::test]
7141 async fn unpermissioned_invite_links_edition_is_rejected() {
7142 let (_tmp, _guard) = init_test_db();
7146 let relay = MemoryRelay::new();
7147 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7148
7149 let mallory = Keys::generate();
7150 let loc = "2b".repeat(32);
7151 let inner = crate::community::roster::build_invite_links_edition(&mallory, &community.id, &[loc], 1, None, 1000, None).unwrap();
7152 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
7153 relay.inject(&outer, &community.relays);
7154
7155 let applied = fetch_and_apply_invite_links(&relay, &community).await.unwrap();
7156 assert!(applied.is_empty(), "an unpermissioned member's link edition is rejected");
7157 assert!(!is_public(&community).unwrap(), "mode stays Private despite the forged edition");
7158 }
7159
7160 #[tokio::test]
7161 async fn invite_links_union_across_authorized_creators() {
7162 let (_tmp, _guard) = init_test_db();
7166 let relay = MemoryRelay::new();
7167 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7168 let cid = community.id.to_hex();
7169
7170 create_public_invite(&relay, &community, None, None).await.unwrap();
7172 let owner_loc = public_invite::locator_hex(&crate::simd::hex::hex_to_bytes_32(
7173 &crate::db::community::list_public_invites(&cid).unwrap()[0].token));
7174
7175 let admin = Keys::generate();
7177 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
7178 set_member_grant(&relay, &community, &admin.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
7179 let admin_loc = "ab".repeat(32);
7180 let inner = crate::community::roster::build_invite_links_edition(&admin, &community.id, &[admin_loc.clone()], 1, None, 2000, None).unwrap();
7181 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
7182 relay.inject(&outer, &community.relays);
7183
7184 let agg = fetch_and_apply_invite_links(&relay, &community).await.unwrap();
7185 assert!(agg.contains(&owner_loc), "owner's link in the aggregate");
7186 assert!(agg.contains(&admin_loc), "the granted admin's link unions in too");
7187 assert!(is_public(&community).unwrap());
7188
7189 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
7193 let owner_token = crate::db::community::list_public_invites(&cid).unwrap()[0].token.clone();
7194 revoke_public_invite(&relay, &c, &crate::simd::hex::hex_to_bytes_32(&owner_token)).await.unwrap();
7195 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
7196 assert_eq!(c.server_root_epoch, crate::community::Epoch(0), "another creator's link remains → no privatize rekey");
7197 assert!(is_public(&c).unwrap(), "still Public (admin's link is live)");
7198 }
7199
7200 #[tokio::test]
7201 async fn invite_registry_retains_a_persisted_creator_on_a_partial_fold() {
7202 let (_tmp, _guard) = init_test_db();
7207 let relay = MemoryRelay::new();
7208 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7209 let cid = community.id.to_hex();
7210
7211 create_public_invite(&relay, &community, None, None).await.unwrap();
7212 let owner_loc = public_invite::locator_hex(&crate::simd::hex::hex_to_bytes_32(
7213 &crate::db::community::list_public_invites(&cid).unwrap()[0].token));
7214 let agg = fetch_and_apply_invite_links(&relay, &community).await.unwrap();
7215 assert!(agg.contains(&owner_loc), "the mint folds + persists normally");
7216
7217 let partial = MemoryRelay::new();
7219 let agg = fetch_and_apply_invite_links(&partial, &community).await.unwrap();
7220 assert!(agg.contains(&owner_loc), "an absent edition retains the persisted locators");
7221 assert!(is_public(&community).unwrap(), "mode survives the partial view");
7222 }
7223
7224 #[tokio::test]
7225 async fn invite_registry_drops_a_demoted_creator_whose_edition_is_present() {
7226 let (_tmp, _guard) = init_test_db();
7231 let relay = MemoryRelay::new();
7232 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7233 let cid = community.id.to_hex();
7234
7235 let admin = Keys::generate();
7236 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
7237 set_member_grant(&relay, &community, &admin.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
7238 let admin_loc = "ab".repeat(32);
7239 let inner = crate::community::roster::build_invite_links_edition(&admin, &community.id, &[admin_loc.clone()], 1, None, 2000, None).unwrap();
7240 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
7241 relay.inject(&outer, &community.relays);
7242 let agg = fetch_and_apply_invite_links(&relay, &community).await.unwrap();
7243 assert!(agg.contains(&admin_loc), "the granted admin's link folds + persists");
7244
7245 set_member_grant(&relay, &community, &admin.public_key().to_hex(), vec![]).await.unwrap();
7248 let agg = fetch_and_apply_invite_links(&relay, &community).await.unwrap();
7249 assert!(!agg.contains(&admin_loc), "a present-but-unauthorized edition drops the persisted row");
7250 assert!(!is_public(&community).unwrap(), "no live authorized link → Private");
7251 }
7252
7253 #[tokio::test]
7254 async fn failed_banlist_publish_does_not_persist_locally() {
7255 let (_tmp, _guard) = init_test_db();
7258 let relay = MemoryRelay::new();
7259 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7260 let id_hex = community.id.to_hex();
7261 assert!(crate::db::community::get_community_banlist(&id_hex).unwrap().is_empty());
7262
7263 let victim = "cc".repeat(32);
7264 let err = publish_banlist(&FailingRelay, &community, &[victim]).await;
7265 assert!(err.is_err(), "a failed publish must propagate");
7266 assert!(
7267 crate::db::community::get_community_banlist(&id_hex).unwrap().is_empty(),
7268 "local banlist must be untouched when the publish failed"
7269 );
7270 }
7271
7272 #[tokio::test]
7273 async fn metadata_failed_publish_does_not_persist_locally() {
7274 let (_tmp, _guard) = init_test_db();
7278 let relay = MemoryRelay::new();
7279 let mut community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7280 community.name = "Renamed HQ".to_string();
7281 assert!(republish_community_metadata(&FailingRelay, &community).await.is_err());
7282 let loaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
7283 assert_eq!(loaded.name, "HQ", "a failed metadata publish leaves the local name unchanged");
7284 }
7285
7286 #[tokio::test]
7287 async fn send_persists_key_then_delete_round_trip() {
7288 let (_tmp, _guard) = init_test_db();
7289 let relay = MemoryRelay::new();
7290 let community = Community::create("HQ", "general", vec!["r1".into()]);
7291 let channel = community.channels[0].clone();
7292 let alice = Keys::generate();
7293
7294 let _outer = send_message(&relay, &community, &channel, &alice, "deletable", 1).await.unwrap();
7296 let before = fetch_channel_messages(&relay, &community, &channel).await.unwrap();
7297 assert_eq!(before.len(), 1);
7298 let message_id = before[0].message_id.to_hex();
7299
7300 delete_message(&relay, &message_id).await.unwrap();
7302 let after = fetch_channel_messages(&relay, &community, &channel).await.unwrap();
7303 assert!(after.is_empty(), "message should be deleted after delete_message");
7304
7305 assert!(delete_message(&relay, &message_id).await.is_err());
7307 }
7308
7309 #[tokio::test]
7310 async fn failed_delete_publish_preserves_key() {
7311 let (_tmp, _guard) = init_test_db();
7314 let relay = MemoryRelay::new();
7315 let community = Community::create("HQ", "general", vec!["r1".into()]);
7316 let channel = community.channels[0].clone();
7317 let alice = Keys::generate();
7318 send_message(&relay, &community, &channel, &alice, "delete me", 1).await.unwrap();
7319 let message_id = fetch_channel_messages(&relay, &community, &channel).await.unwrap()[0]
7320 .message_id
7321 .to_hex();
7322
7323 assert!(delete_message(&FailingRelay, &message_id).await.is_err());
7325
7326 delete_message(&relay, &message_id).await.unwrap();
7328 assert!(fetch_channel_messages(&relay, &community, &channel).await.unwrap().is_empty());
7329 }
7330
7331 #[tokio::test]
7332 async fn delete_unknown_message_errors() {
7333 let (_tmp, _guard) = init_test_db();
7334 let relay = MemoryRelay::new();
7335 let fake = Keys::generate();
7337 let bogus = EventBuilder::new(Kind::Custom(1), "x").finalize(&fake).unwrap().id;
7338 assert!(delete_message(&relay, &bogus.to_hex()).await.is_err());
7339 }
7340
7341 #[tokio::test]
7342 async fn accept_invite_persists_member_view() {
7343 let (_tmp, _guard) = init_test_db();
7344 let owner = Community::create("HQ", "general", vec!["r1".into()]);
7345 let invite = crate::community::invite::build_invite(&owner);
7346
7347 let joined = accept_invite(&invite).expect("accept");
7348 assert!(!is_proven_owner(&joined), "joined as member, not owner");
7349 let loaded = crate::db::community::load_community(&owner.id).unwrap().expect("saved");
7351 assert_eq!(loaded.channels[0].key.as_bytes(), owner.channels[0].key.as_bytes());
7352 }
7353
7354 #[tokio::test]
7355 async fn accept_invite_does_not_downgrade_owned_community() {
7356 let (_tmp, _guard) = init_test_db();
7359 let relay = MemoryRelay::new();
7360 let owner = create_community(&relay, "HQ", "general", vec![]).await.unwrap();
7361 assert!(is_proven_owner(&owner), "we are the proven owner");
7362
7363 let invite = crate::community::invite::build_invite(&owner);
7364 let err = accept_invite(&invite).unwrap_err();
7365 assert!(err.contains("already own"), "must refuse to downgrade an owned community, got: {err}");
7366
7367 let reloaded = crate::db::community::load_community(&owner.id).unwrap().unwrap();
7369 assert_eq!(reloaded.server_root_key.as_bytes(), owner.server_root_key.as_bytes());
7370 }
7371
7372 #[tokio::test]
7377 async fn stale_v1_invite_cannot_reparent_a_migrated_communitys_channels() {
7378 let (_tmp, _guard) = init_test_db();
7379 let v1 = Community::create("Guild", "general", vec!["wss://r1".into()]);
7381 let stale_invite = crate::community::invite::build_invite(&v1);
7382 accept_invite(&stale_invite).expect("initial join");
7383 let v1_cid = v1.id.to_hex();
7384 let channel_hex = v1.channels[0].id.to_hex();
7385 assert_eq!(
7386 crate::db::community::community_id_for_channel(&channel_hex).unwrap().as_deref(),
7387 Some(v1_cid.as_str()),
7388 "precondition: the channel row starts parented to v1"
7389 );
7390
7391 let v2_cid = "9f".repeat(32);
7393 crate::db::community::reparent_channels_and_fence(&v1_cid, &v2_cid).unwrap();
7394 assert_eq!(
7395 crate::db::community::community_id_for_channel(&channel_hex).unwrap().as_deref(),
7396 Some(v2_cid.as_str()),
7397 "precondition: the flip moved the channel to the twin"
7398 );
7399
7400 let err = accept_invite(&stale_invite).unwrap_err();
7402 assert!(
7403 err.contains("upgraded to Concord v2"),
7404 "a migrated community must refuse a v1 re-accept, got: {err}"
7405 );
7406
7407 assert_eq!(
7409 crate::db::community::community_id_for_channel(&channel_hex).unwrap().as_deref(),
7410 Some(v2_cid.as_str()),
7411 "the refused accept must not have re-parented the channel back to v1"
7412 );
7413 assert_eq!(
7415 crate::db::community::get_migrated_to(&v1_cid).unwrap().as_deref(),
7416 Some(v2_cid.as_str())
7417 );
7418 }
7419
7420 #[tokio::test]
7424 async fn accept_invite_still_works_for_a_live_v1_community() {
7425 let (_tmp, _guard) = init_test_db();
7426 let v1 = Community::create("Guild", "general", vec!["wss://r1".into()]);
7427 let invite = crate::community::invite::build_invite(&v1);
7428 accept_invite(&invite).expect("initial join");
7429 accept_invite(&invite).expect("re-accept on a live v1 community must still work");
7431 assert!(crate::db::community::get_migrated_to(&v1.id.to_hex()).unwrap().is_none());
7432 }
7433
7434 #[tokio::test]
7439 async fn a_fresh_join_is_never_gated_by_another_communitys_fence() {
7440 let (_tmp, _guard) = init_test_db();
7441 let migrated = Community::create("Old", "general", vec!["wss://r1".into()]);
7443 accept_invite(&crate::community::invite::build_invite(&migrated)).unwrap();
7444 crate::db::community::reparent_channels_and_fence(&migrated.id.to_hex(), &"9f".repeat(32)).unwrap();
7445
7446 let fresh = Community::create("New", "general", vec!["wss://r2".into()]);
7448 accept_invite(&crate::community::invite::build_invite(&fresh)).expect("fresh join must not be gated");
7449 assert!(crate::db::community::load_community(&fresh.id).unwrap().is_some());
7450 }
7451
7452 #[tokio::test]
7457 async fn a_fresh_v1_join_past_the_timelock_needs_a_migration_carrier() {
7458 let (_tmp, _guard) = init_test_db();
7459 let relay = MemoryRelay::new();
7460 let unlock = crate::community::migration::MIGRATION_UNLOCK_AT;
7461
7462 let owner_keys = Keys::generate();
7463 become_local(&owner_keys);
7464 let owned = attested_community("Legacy", "general", vec!["wss://r1".into()]);
7465 let invite = crate::community::invite::build_invite(&owned);
7466 let member_view = crate::community::invite::accept_invite(&invite).expect("decode");
7467
7468 crate::community::migration::gate_fresh_v1_join(&relay, &member_view, unlock - 1)
7469 .await
7470 .expect("pre-unlock fresh join passes without a probe");
7471
7472 let err = crate::community::migration::gate_fresh_v1_join(&relay, &member_view, unlock)
7473 .await
7474 .unwrap_err();
7475 assert!(err.contains("legacy protocol"), "a live v1 community refuses post-unlock, got: {err}");
7476
7477 let sp = crate::community::migration::MigrationSignpost {
7479 v2_community_id: "ab".repeat(32),
7480 owner_xonly: owner_keys.public_key().to_hex(),
7481 owner_salt: "cd".repeat(32),
7482 relays: vec!["wss://r1".into()],
7483 name: "Legacy".into(),
7484 primary_channel: owned.channels[0].id.to_hex(),
7485 root_epoch: 0,
7486 };
7487 let content = crate::community::migration::build_migration_content(&sp, None).unwrap();
7488 publish_migration_carrier(&relay, &owned, &content).await.expect("carrier lands");
7489 crate::community::migration::gate_fresh_v1_join(&relay, &member_view, unlock)
7490 .await
7491 .expect("a carrier-bearing community stays joinable (v2 on-ramp)");
7492
7493 crate::db::community::save_community(&member_view).unwrap();
7495 crate::community::migration::gate_fresh_v1_join(&relay, &member_view, unlock)
7496 .await
7497 .expect("a held community passes post-unlock");
7498 }
7499
7500 #[tokio::test]
7503 async fn metadata_republish_refuses_after_migration() {
7504 let (_tmp, _guard) = init_test_db();
7505 let owner = Keys::generate();
7506 become_local(&owner);
7507 let community = saved_community_owned_by(&owner);
7508 let cid = community.id.to_hex();
7509 let channel_id = community.channels[0].id;
7510 let relay = MemoryRelay::new();
7511
7512 crate::db::community::reparent_channels_and_fence(&cid, &"9f".repeat(32)).unwrap();
7513
7514 let err = republish_community_metadata(&relay, &community).await.unwrap_err();
7515 assert!(err.contains("upgraded to Concord v2"), "community metadata edit gated, got: {err}");
7516 let err = republish_channel_metadata(&relay, &community, &channel_id, "renamed").await.unwrap_err();
7517 assert!(err.contains("upgraded to Concord v2"), "channel rename gated, got: {err}");
7518 assert!(
7521 crate::db::community::get_edition_head(&cid, &cid).unwrap().is_none(),
7522 "no community edition was published"
7523 );
7524 assert!(
7525 crate::db::community::get_edition_head(&cid, &channel_id.to_hex()).unwrap().is_none(),
7526 "no channel edition was published"
7527 );
7528 }
7529
7530 #[tokio::test]
7531 async fn accept_invite_rejects_id_collision_under_different_authority() {
7532 let (_tmp, _guard) = init_test_db();
7537 let legit = Community::create("X", "general", vec!["wss://legit".into()]);
7538 let member_x = accept_invite(&crate::community::invite::build_invite(&legit)).unwrap();
7539 let original_key = member_x.channels[0].key.as_bytes().to_vec();
7540
7541 let attacker = Community::create("evil", "general", vec!["wss://evil".into()]);
7543 let mut hostile = crate::community::invite::build_invite(&attacker);
7544 hostile.community_id = legit.id.to_hex();
7545 assert_ne!(hostile.server_root_key, crate::simd::hex::bytes_to_hex_32(member_x.server_root_key.as_bytes()));
7548
7549 assert!(accept_invite(&hostile).is_err(), "id-collision under new authority must be rejected");
7550
7551 let reloaded = crate::db::community::load_community(&legit.id).unwrap().unwrap();
7553 assert_eq!(reloaded.channels[0].key.as_bytes().to_vec(), original_key);
7554 assert_eq!(reloaded.relays, vec!["wss://legit".to_string()]);
7555 }
7556
7557 #[tokio::test]
7558 async fn rejected_accept_leaves_pending_invite_intact() {
7559 let (_tmp, _guard) = init_test_db();
7562
7563 let owner = attested_community("HQ", "general", vec![]);
7565 crate::db::community::save_community(&owner).unwrap();
7566 let bundle = crate::community::invite::build_invite(&owner).to_json().unwrap();
7567 let cid = owner.id.to_hex();
7568 crate::db::community::save_pending_invite(&cid, &bundle, "npub1inviter", 0).unwrap();
7569
7570 let peeked = crate::db::community::get_pending_invite(&cid).unwrap().expect("parked");
7572 let invite = crate::community::invite::CommunityInvite::from_json(&peeked).unwrap();
7573 assert!(accept_invite(&invite).is_err(), "owning the id → reject");
7574 assert!(
7575 crate::db::community::pending_invite_exists(&cid).unwrap(),
7576 "rejected accept must leave the invite parked"
7577 );
7578
7579 let other = Community::create("Other", "general", vec![]);
7581 let ob = crate::community::invite::build_invite(&other).to_json().unwrap();
7582 let ocid = other.id.to_hex();
7583 crate::db::community::save_pending_invite(&ocid, &ob, "npub1inviter", 0).unwrap();
7584 let op = crate::db::community::get_pending_invite(&ocid).unwrap().unwrap();
7585 let oinvite = crate::community::invite::CommunityInvite::from_json(&op).unwrap();
7586 accept_invite(&oinvite).expect("accept ok");
7587 crate::db::community::delete_pending_invite(&ocid).unwrap();
7588 assert!(!crate::db::community::pending_invite_exists(&ocid).unwrap(), "cleared on success");
7589 }
7590
7591 #[tokio::test]
7592 async fn public_invite_create_fetch_accept_revoke_round_trip() {
7593 let (_tmp, _guard) = init_test_db();
7594 let relay = MemoryRelay::new();
7595 let mut owner = Community::create("Public HQ", "general", vec!["r1".into(), "r2".into()]);
7596 owner.description = Some("everyone welcome".into());
7597 let owner_keys = crate::state::MY_SECRET_KEY.to_keys().unwrap();
7601 owner.owner_attestation = Some(
7602 crate::community::owner::build_owner_attestation_unsigned(owner_keys.public_key(), &owner.id.to_hex())
7603 .finalize(&owner_keys).unwrap().as_json(),
7604 );
7605 let (token_hex, url) = create_public_invite(&relay, &owner, None, None).await.expect("mint");
7607 assert!(url.contains('#'));
7608 assert_eq!(crate::db::community::list_public_invites(&owner.id.to_hex()).unwrap().len(), 1);
7609
7610 let (relays, token) = public_invite::parse_invite_url(&url).unwrap();
7612 assert_eq!(crate::simd::hex::bytes_to_hex_32(&token), token_hex);
7613 let bundle = fetch_public_invite(&relay, &relays, &token).await.expect("fetch");
7614 assert_eq!(bundle.preview.name, "Public HQ");
7615 assert_eq!(bundle.preview.description.as_deref(), Some("everyone welcome"));
7616
7617 let joined = accept_public_invite(&bundle, 0).expect("accept");
7618 assert_eq!(joined.id, owner.id);
7619 assert_eq!(joined.description.as_deref(), Some("everyone welcome"), "preview patched in");
7620
7621 revoke_public_invite(&relay, &owner, &token).await.expect("revoke");
7623 assert!(fetch_public_invite(&relay, &relays, &token).await.is_err(), "revoked link is dead");
7624 assert!(crate::db::community::list_public_invites(&owner.id.to_hex()).unwrap().is_empty());
7625 }
7626
7627 #[tokio::test]
7628 async fn revoked_invite_dies_even_if_one_relay_kept_the_bundle() {
7629 let (_tmp, _guard) = init_test_db();
7633 let relay = MemoryRelay::new();
7634 let owner = attested_community("HQ", "general", vec!["r1".into(), "r2".into()]);
7635 let (_token_hex, url) = create_public_invite(&relay, &owner, None, None).await.unwrap();
7636 let (relays, token) = public_invite::parse_invite_url(&url).unwrap();
7637 assert!(fetch_public_invite(&relay, &relays, &token).await.is_ok(), "live on both relays");
7638
7639 let tombstone = public_invite::build_public_invite_tombstone(&token).unwrap();
7641 relay.inject(&tombstone, &["r1".to_string()]);
7642
7643 assert!(
7644 fetch_public_invite(&relay, &relays, &token).await.is_err(),
7645 "a tombstone on any one relay kills the link, even with a stale live bundle elsewhere",
7646 );
7647 }
7648
7649 #[tokio::test]
7650 async fn fetch_skips_relay_shadow_junk_to_genuine_bundle() {
7651 use nostr_sdk::prelude::{EventBuilder, Keys, Kind, Tag, Timestamp};
7655
7656 let (_tmp, _guard) = init_test_db();
7657 let relay = MemoryRelay::new();
7658 let owner = attested_community("HQ", "general", vec!["r1".into()]);
7659 let (_t, url) = create_public_invite(&relay, &owner, None, None).await.unwrap();
7660 let (relays, token) = public_invite::parse_invite_url(&url).unwrap();
7661
7662 let attacker = Keys::generate();
7665 let junk = EventBuilder::new(Kind::Custom(event_kind::APPLICATION_SPECIFIC), "garbage")
7666 .tags([
7667 Tag::identifier(public_invite::locator_hex(&token)),
7668 Tag::custom("vsk", ["6".to_string()]),
7669 Tag::custom("v", ["1".to_string()]),
7670 ])
7671 .custom_created_at(Timestamp::from_secs(9_000_000_000))
7672 .finalize(&attacker)
7673 .unwrap();
7674 relay.publish(&junk, &relays).await.unwrap();
7675
7676 let bundle = fetch_public_invite(&relay, &relays, &token).await.expect("genuine survives shadow");
7678 assert_eq!(bundle.preview.name, "HQ");
7679 }
7680
7681 #[tokio::test]
7682 async fn expired_public_invite_is_refused() {
7683 let (_tmp, _guard) = init_test_db();
7684 let relay = MemoryRelay::new();
7685 let owner = attested_community("HQ", "general", vec!["r1".into()]);
7686 let (_t, url) = create_public_invite(&relay, &owner, Some(1000), None).await.unwrap();
7687 let (relays, token) = public_invite::parse_invite_url(&url).unwrap();
7688 let bundle = fetch_public_invite(&relay, &relays, &token).await.unwrap();
7689 assert!(accept_public_invite(&bundle, 2000).is_err());
7691 assert!(crate::db::community::load_community(&owner.id).unwrap().is_none());
7692 }
7693
7694 #[tokio::test]
7695 async fn republish_metadata_saves_and_publishes() {
7696 use crate::community::CommunityImage;
7697 let (_tmp, _guard) = init_test_db();
7698 let relay = MemoryRelay::new();
7699 let mut owner = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7702 let cid = owner.id.to_hex();
7703
7704 owner.name = "HQ Renamed".into();
7706 owner.description = Some("now with topic".into());
7707 owner.icon = Some(CommunityImage {
7708 url: "https://b/x".into(), key: "aa".repeat(32), nonce: "bb".repeat(12),
7709 hash: "cc".repeat(32), ext: "png".into(),
7710 });
7711 republish_community_metadata(&relay, &owner).await.expect("republish");
7712
7713 let loaded = crate::db::community::load_community(&owner.id).unwrap().unwrap();
7715 assert_eq!(loaded.name, "HQ Renamed");
7716 assert_eq!(loaded.description.as_deref(), Some("now with topic"));
7717 assert_eq!(loaded.icon.unwrap().url, "https://b/x");
7718
7719 let (head_v, _) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
7722 assert_eq!(head_v, 2, "GroupRoot edition advanced v1 (create) → v2 (republish)");
7723 let z = crate::community::roster::control_pseudonym(&owner.server_root_key, &owner.id, crate::community::Epoch(0));
7724 let control = relay
7725 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], ..Default::default() }, &owner.relays)
7726 .await
7727 .unwrap();
7728 let newest = control
7729 .iter()
7730 .filter_map(|o| crate::community::roster::open_control_edition(o, &owner.server_root_key).ok())
7731 .filter_map(|i| crate::community::edition::parse_edition_inner(&i).ok())
7732 .filter(|p| p.entity_id == owner.id.0)
7733 .max_by_key(|p| p.version)
7734 .expect("GroupRoot edition on the relay");
7735 let meta: crate::community::metadata::CommunityMetadata = serde_json::from_str(&newest.content).unwrap();
7736 assert_eq!(meta.name, "HQ Renamed");
7737 assert_eq!(meta.icon.unwrap().ext, "png");
7738 }
7739
7740 #[tokio::test]
7741 async fn member_cannot_republish_metadata() {
7742 let (_tmp, _guard) = init_test_db();
7743 let relay = MemoryRelay::new();
7744 let owner = Community::create("HQ", "general", vec!["r1".into()]);
7745 let member = crate::community::invite::accept_invite(&crate::community::invite::build_invite(&owner)).unwrap();
7746 assert!(republish_community_metadata(&relay, &member).await.is_err());
7747 }
7748
7749 #[tokio::test]
7750 async fn member_cannot_mint_public_invite() {
7751 let (_tmp, _guard) = init_test_db();
7752 let relay = MemoryRelay::new();
7753 let owner = Community::create("HQ", "general", vec!["r1".into()]);
7754 let member = crate::community::invite::accept_invite(&crate::community::invite::build_invite(&owner)).unwrap();
7755 assert!(create_public_invite(&relay, &member, None, None).await.is_err(), "members can't mint links");
7756 }
7757
7758 #[tokio::test]
7759 async fn accept_oversized_bundle_rejected() {
7760 let (_tmp, _guard) = init_test_db();
7761 let owner = Community::create("HQ", "general", vec![]);
7762 let mut invite = crate::community::invite::build_invite(&owner);
7763 let template = invite.channels[0].clone();
7765 for _ in 0..300 {
7766 invite.channels.push(template.clone());
7767 }
7768 assert!(accept_invite(&invite).is_err(), "oversized bundle must be rejected");
7769 assert!(crate::db::community::load_community(&owner.id).unwrap().is_none(), "nothing persisted");
7770 }
7771
7772 async fn publish_tombstone<T: Transport + ?Sized>(transport: &T, community: &Community, author: &Keys, created_at: u64) {
7778 let inner = crate::community::roster::build_group_dissolved_edition_unsigned(author.public_key(), &community.id, created_at)
7779 .finalize(author).unwrap();
7780 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch).unwrap();
7781 transport.publish_durable(&outer, &community.relays).await.unwrap();
7782 }
7783
7784 struct RekeyCountingRelay {
7787 inner: MemoryRelay,
7788 rekeys: std::sync::atomic::AtomicUsize,
7789 }
7790 impl RekeyCountingRelay {
7791 fn new() -> Self { Self { inner: MemoryRelay::new(), rekeys: std::sync::atomic::AtomicUsize::new(0) } }
7792 fn count(&self, e: &Event) {
7793 if e.kind.as_u16() == event_kind::COMMUNITY_REKEY {
7794 self.rekeys.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
7795 }
7796 }
7797 }
7798 #[async_trait::async_trait]
7799 impl Transport for RekeyCountingRelay {
7800 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<crate::community::transport::PlaneFetch, String> { Ok(crate::community::transport::PlaneFetch::whole(self.fetch(query, relays).await?)) }
7801 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> { self.count(e); self.inner.publish(e, r).await }
7802 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> { self.count(e); self.inner.publish_durable(e, r).await }
7803 async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> { self.inner.fetch(q, r).await }
7804 }
7805
7806 #[tokio::test]
7807 async fn owner_tombstone_folds_to_dissolved() {
7808 let (_tmp, _guard) = init_test_db();
7809 let relay = MemoryRelay::new();
7810 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7812 let cid = community.id.to_hex();
7813 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
7814 publish_tombstone(&relay, &community, &owner, 1000).await;
7815
7816 assert!(!crate::db::community::get_community_dissolved(&cid).unwrap(), "alive before the fold");
7817 fetch_and_apply_control(&relay, &community).await.unwrap();
7818 assert!(crate::db::community::get_community_dissolved(&cid).unwrap(), "owner tombstone seals the community");
7819 }
7820
7821 #[tokio::test]
7822 async fn non_owner_tombstone_is_ignored() {
7823 let (_tmp, _guard) = init_test_db();
7824 let relay = MemoryRelay::new();
7825 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7826 let cid = community.id.to_hex();
7827 let mallory = Keys::generate();
7830 publish_tombstone(&relay, &community, &mallory, 1000).await;
7831
7832 fetch_and_apply_control(&relay, &community).await.unwrap();
7833 assert!(!crate::db::community::get_community_dissolved(&cid).unwrap(), "a non-owner tombstone is ignored");
7834 }
7835
7836 #[tokio::test]
7837 async fn unreadable_deed_rejects_the_tombstone() {
7838 let (_tmp, _guard) = init_test_db();
7839 let relay = MemoryRelay::new();
7840 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
7841 let mut community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7842 let cid = community.id.to_hex();
7843 publish_tombstone(&relay, &community, &owner, 1000).await;
7844 community.owner_attestation = None;
7846 crate::db::community::save_community(&community).unwrap();
7847 let stripped = crate::db::community::load_community(&community.id).unwrap().unwrap();
7848
7849 fetch_and_apply_control(&relay, &stripped).await.unwrap();
7850 assert!(!crate::db::community::get_community_dissolved(&cid).unwrap(), "unverifiable tombstone is rejected, not death-by-default");
7851 }
7852
7853 #[tokio::test]
7854 async fn binary_seal_drops_every_subsequent_event_with_no_timestamp_test() {
7855 let (_tmp, _guard) = init_test_db();
7856 let relay = MemoryRelay::new();
7857 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
7858 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7859 let cid = community.id.to_hex();
7860 publish_tombstone(&relay, &community, &owner, 1000).await;
7861 fetch_and_apply_control(&relay, &community).await.unwrap();
7862 assert!(crate::db::community::get_community_dissolved(&cid).unwrap());
7863
7864 let sealed = crate::db::community::load_community(&community.id).unwrap().unwrap();
7866 let channel = sealed.channels[0].clone();
7867 let me = owner.public_key();
7868
7869 let backdated = super::super::envelope::seal_message(
7871 &Keys::generate(), &channel.key, &channel.id, channel.epoch, "ghost", 1,
7872 ).unwrap();
7873 let mut state = crate::state::ChatState::new();
7874 assert!(super::super::inbound::process_incoming(&mut state, &backdated, &channel, &me).is_none(),
7875 "a backdated message after the seal is dropped (binary seal, no timestamp test)");
7876
7877 publish_tombstone(&relay, &sealed, &owner, 2000).await;
7879 assert_eq!(fetch_and_apply_control(&relay, &sealed).await.unwrap(), 0,
7880 "control fold stops advancing once sealed");
7881 }
7882
7883 #[tokio::test]
7884 async fn dissolve_community_emits_no_rekey_and_no_epoch_bump() {
7885 let (_tmp, _guard) = init_test_db();
7886 let relay = RekeyCountingRelay::new();
7887 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7888 let cid = community.id.to_hex();
7889 create_public_invite(&relay, &community, None, None).await.unwrap();
7891 let before_epoch = crate::db::community::load_community(&community.id).unwrap().unwrap().server_root_epoch;
7892
7893 dissolve_community(&relay, &community).await.unwrap();
7894
7895 assert!(crate::db::community::get_community_dissolved(&cid).unwrap(), "sealed locally");
7896 assert_eq!(relay.rekeys.load(std::sync::atomic::Ordering::Relaxed), 0,
7897 "dissolution publishes NO 3303 rekey (no last-link privatize re-founding)");
7898 assert_eq!(crate::db::community::load_community(&community.id).unwrap().unwrap().server_root_epoch, before_epoch,
7899 "base epoch unchanged — dissolution rotates nothing");
7900 }
7901
7902 #[tokio::test]
7906 async fn migration_carrier_tombstone_seals_and_persists_the_pointer() {
7907 let (_tmp, _guard) = init_test_db();
7908 let relay = MemoryRelay::new();
7909 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
7910 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7911 let cid = community.id.to_hex();
7912
7913 let signpost = crate::community::migration::MigrationSignpost {
7915 v2_community_id: "ab".repeat(32),
7916 owner_xonly: owner.public_key().to_hex(),
7917 owner_salt: "cd".repeat(32),
7918 relays: vec!["r1".into()],
7919 name: "HQ".into(),
7920 primary_channel: community.channels[0].id.to_hex(),
7921 root_epoch: 0,
7922 };
7923 let m = crate::community::migration::seal_m(community.server_root_key.as_bytes(), b"jm").unwrap();
7924 let content = crate::community::migration::build_migration_content(&signpost, Some(m)).unwrap();
7925 let inner = crate::community::roster::build_group_dissolved_edition_with_content(&owner, &community.id, 1000, &content).unwrap();
7926 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch).unwrap();
7927 relay.publish_durable(&outer, &community.relays).await.unwrap();
7928
7929 fetch_and_apply_control(&relay, &community).await.unwrap();
7930
7931 assert!(crate::db::community::get_community_dissolved(&cid).unwrap(), "carrier still seals");
7932 let stored = crate::db::community::get_migration_pointer(&cid).unwrap().expect("pointer persisted");
7933 let parsed = crate::community::migration::parse_migration_payload(&stored).unwrap();
7934 assert_eq!(parsed.signpost.v2_community_id, "ab".repeat(32));
7935 assert!(parsed.m.is_some(), "the sealed key material rode along");
7936 }
7937
7938 #[test]
7942 fn migration_exemption_gates_the_dissolved_base_rekey() {
7943 let (_tmp, _guard) = init_test_db();
7944 let owner = Keys::generate();
7945 let me = Keys::generate();
7946 become_local(&me);
7947 let community = saved_community_owned_by(&owner);
7948 let cid = community.id.to_hex();
7949 crate::db::community::set_community_dissolved(&cid).unwrap();
7950
7951 let parsed = owner_base_rekey(&owner, &community, &me.public_key(), 1, &[0xCDu8; 32]);
7952 assert!(apply_server_root_rekey(&community, &parsed).is_err());
7954
7955 let signpost = crate::community::migration::MigrationSignpost {
7957 v2_community_id: "ab".repeat(32), owner_xonly: owner.public_key().to_hex(),
7958 owner_salt: "cd".repeat(32), relays: vec![], name: "x".into(),
7959 primary_channel: "ef".repeat(32), root_epoch: 5,
7960 };
7961 let content = crate::community::migration::build_migration_content(&signpost, Some("bTE=".into())).unwrap();
7962 crate::db::community::set_migration_pointer(&cid, &content).unwrap();
7963 assert!(crate::community::migration::catchup_exempt(&cid, 1), "epoch 1 <= publish epoch 5 → exempt");
7964 assert!(!crate::community::migration::catchup_exempt(&cid, 6), "beyond the publish epoch → not exempt");
7965
7966 crate::db::community::set_migrated_to(&cid, &"ab".repeat(32)).unwrap();
7968 assert!(!crate::community::migration::catchup_exempt(&cid, 1), "flipped → fence stands");
7969 }
7970
7971 #[tokio::test]
7972 async fn duplicate_owner_tombstones_are_idempotent() {
7973 let (_tmp, _guard) = init_test_db();
7974 let relay = MemoryRelay::new();
7975 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
7976 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7977 let cid = community.id.to_hex();
7978 publish_tombstone(&relay, &community, &owner, 1000).await;
7980 publish_tombstone(&relay, &community, &owner, 2000).await;
7981
7982 fetch_and_apply_control(&relay, &community).await.unwrap();
7983 assert!(crate::db::community::get_community_dissolved(&cid).unwrap(), "duplicates still just dissolve, no error");
7984 assert_eq!(fetch_and_apply_control(&relay, &community).await.unwrap(), 0);
7986 }
7987
7988 #[test]
7989 fn apply_server_root_rekey_refuses_once_dissolved() {
7990 let (_tmp, _guard) = init_test_db();
7991 let owner = Keys::generate();
7992 let me = Keys::generate();
7993 become_local(&me);
7994 let community = saved_community_owned_by(&owner);
7995 let cid = community.id.to_hex();
7996 crate::db::community::set_community_dissolved(&cid).unwrap();
7997
7998 let parsed = owner_base_rekey(&owner, &community, &me.public_key(), 1, &[0xCDu8; 32]);
7999 assert!(apply_server_root_rekey(&community, &parsed).is_err(),
8000 "a base rekey cannot cross a tombstone");
8001 assert_eq!(crate::db::community::load_community(&community.id).unwrap().unwrap().server_root_epoch,
8002 crate::community::Epoch(0), "base epoch did not advance");
8003 }
8004
8005 #[tokio::test]
8006 async fn tombstone_detected_after_a_base_rotation() {
8007 let (_tmp, _guard) = init_test_db();
8008 let relay = MemoryRelay::new();
8009 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
8010 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
8011 let cid = community.id.to_hex();
8012 rotate_server_root(&relay, &community, &[owner.public_key()]).await.unwrap();
8015 let rotated = crate::db::community::load_community(&community.id).unwrap().unwrap();
8016 assert_eq!(rotated.server_root_epoch, crate::community::Epoch(1));
8017 publish_tombstone(&relay, &rotated, &owner, 1000).await;
8018
8019 fetch_and_apply_control(&relay, &rotated).await.unwrap();
8020 assert!(crate::db::community::get_community_dissolved(&cid).unwrap(),
8021 "tombstone at the rotation-stable locator is detected post-rotation");
8022 }
8023
8024 #[tokio::test]
8025 async fn stable_coordinate_tombstone_survives_a_concurrent_rotation() {
8026 let (_tmp, _guard) = init_test_db();
8031 let relay = MemoryRelay::new();
8032 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
8033 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
8034 let cid = community.id.to_hex();
8035 let inner = crate::community::roster::build_group_dissolved_edition_unsigned(owner.public_key(), &community.id, 1000)
8037 .finalize(&owner).unwrap();
8038 let stable = crate::community::roster::seal_dissolved_edition(&Keys::generate(), &inner, &community.id).unwrap();
8039 relay.inject(&stable, &community.relays);
8040 rotate_server_root(&relay, &community, &[owner.public_key()]).await.unwrap();
8043 let rotated = crate::db::community::load_community(&community.id).unwrap().unwrap();
8044 assert_eq!(rotated.server_root_epoch, crate::community::Epoch(1));
8045 assert!(!crate::db::community::get_community_dissolved(&cid).unwrap(), "not folded yet");
8046 fetch_and_apply_control(&relay, &rotated).await.unwrap();
8049 assert!(crate::db::community::get_community_dissolved(&cid).unwrap(),
8050 "stable-coordinate probe discovers the tombstone cross-epoch (C3 closed)");
8051 }
8052}