1use nostr_sdk::prelude::{Event, EventId, JsonUtil, Keys, Tag, ToBech32};
11
12use super::invite::CommunityInvite;
13use super::public_invite::{
14 self, build_public_invite_event, locator_hex, parse_public_invite_event, PublicInviteBundle,
15};
16use super::send::{delete_own_message, publish_signed_message};
17use super::transport::{Evidence, Query, Transport};
18use super::{Channel, Community};
19use crate::state::SessionGuard;
20use crate::stored_event::event_kind;
21
22async fn active_signer() -> Result<std::sync::Arc<dyn nostr_sdk::prelude::NostrSigner>, String> {
29 if let Some(client) = crate::state::nostr_client() {
30 if let Ok(s) = client.signer().await {
31 return Ok(s);
32 }
33 }
34 let keys = crate::state::MY_SECRET_KEY.to_keys().ok_or("no signer available (no client and no local key)")?;
35 Ok(std::sync::Arc::new(keys))
36}
37
38pub const MAX_COMMUNITIES: usize = 50;
42
43fn enforce_community_cap() -> Result<(), String> {
47 let held = super::list::load_local_list().entries.len();
48 if held >= MAX_COMMUNITIES {
49 return Err(format!(
50 "You've reached the limit of {} communities. Leave one to join another.",
51 MAX_COMMUNITIES
52 ));
53 }
54 Ok(())
55}
56
57pub async fn create_community<T: Transport + ?Sized>(
62 transport: &T,
63 name: &str,
64 default_channel_name: &str,
65 relays: Vec<String>,
66) -> Result<Community, String> {
67 let session = SessionGuard::capture();
68 enforce_community_cap()?;
69 let mut community = Community::create(name, default_channel_name, relays);
70 let owner_pk = crate::state::my_public_key().ok_or("cannot create a community without an identity")?;
76 let unsigned = super::owner::build_owner_attestation_unsigned(owner_pk, &community.id.to_hex());
77 let attestation = if let Some(keys) = crate::state::MY_SECRET_KEY.to_keys().filter(|k| k.public_key() == owner_pk) {
81 unsigned.sign_with_keys(&keys).map_err(|e| format!("sign owner attestation: {e}"))?
82 } else if let Some(client) = crate::state::nostr_client() {
83 let signer = client.signer().await.map_err(|e| format!("no signer for owner attestation: {e}"))?;
84 unsigned.sign(&signer).await.map_err(|e| format!("sign owner attestation: {e}"))?
85 } else {
86 return Err("cannot create a community without an identity signer (the owner attestation is mandatory)".to_string());
87 };
88 community.owner_attestation = Some(attestation.as_json());
89 if !session.is_valid() {
91 return Err("account changed during community creation".to_string());
92 }
93 crate::db::community::save_community(&community)?;
99
100 let signer = active_signer().await?;
103 let cid = community.id.to_hex();
104 let created = std::time::SystemTime::now()
105 .duration_since(std::time::UNIX_EPOCH)
106 .map(|d| d.as_secs())
107 .unwrap_or(0);
108
109 let admin = super::roles::Role::admin(crate::simd::hex::bytes_to_hex_32(&super::random_32()));
117 let root_meta = super::metadata::CommunityMetadata::of(&community);
118 let root_inner = super::roster::build_community_root_edition_unsigned(owner_pk, &community.id, &root_meta, 1, None, created, None)?
119 .sign(&signer).await.map_err(|e| format!("sign genesis group-root: {e}"))?;
120 let role_inner = super::roster::build_role_edition_unsigned(owner_pk, &admin, 1, None, created, None)?
121 .sign(&signer).await.map_err(|e| format!("sign genesis admin-role: {e}"))?;
122 let mut heads: Vec<(String, [u8; 32], Option<[u8; 32]>)> = vec![
126 (cid.clone(), super::version::edition_hash(&community.id.0, 1, None, root_inner.content.as_bytes()), Some(root_inner.id.to_bytes())),
127 (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),
128 ];
129 let mut to_publish: Vec<Event> = vec![
130 super::roster::seal_control_edition(&Keys::generate(), &root_inner, &community.server_root_key, &community.id, community.server_root_epoch)?,
131 super::roster::seal_control_edition(&Keys::generate(), &role_inner, &community.server_root_key, &community.id, community.server_root_epoch)?,
132 ];
133 for channel in &community.channels {
134 let meta = super::metadata::ChannelMetadata { name: channel.name.clone() };
135 let inner = super::roster::build_channel_metadata_edition_unsigned(owner_pk, &channel.id, &meta, 1, None, created, None)?
136 .sign(&signer).await.map_err(|e| format!("sign genesis channel-metadata: {e}"))?;
137 heads.push((channel.id.to_hex(), super::version::edition_hash(&channel.id.0, 1, None, inner.content.as_bytes()), Some(inner.id.to_bytes())));
138 to_publish.push(super::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch)?);
139 }
140 for outer in &to_publish {
144 transport.publish_durable(outer, &community.relays).await?;
145 }
146 if session.is_valid() {
149 for (entity_hex, hash, inner_id) in &heads {
150 let _ = match inner_id {
151 Some(id) => crate::db::community::set_edition_head_with_id(&cid, entity_hex, 1, hash, id),
152 None => crate::db::community::set_edition_head(&cid, entity_hex, 1, hash),
153 };
154 }
155 let roster = super::roles::CommunityRoles { roles: vec![admin], grants: Vec::new() };
156 let _ = crate::db::community::set_community_roles(&cid, &roster, created as i64);
157 }
158 Ok(community)
159}
160
161pub async fn send_message<T: Transport + ?Sized>(
164 transport: &T,
165 community: &Community,
166 channel: &Channel,
167 author: &Keys,
168 content: &str,
169 ms: u64,
170) -> Result<Event, String> {
171 let session = SessionGuard::capture();
172 let inner = super::envelope::build_inner_event(author.public_key(), &channel.id, channel.epoch, content, ms, None)
176 .sign_with_keys(author)
177 .map_err(|e| e.to_string())?;
178 let (outer, ephemeral) = publish_signed_message(transport, community, channel, &inner, false).await?;
179 if !session.is_valid() {
182 return Err("account changed during send; not persisting message key".to_string());
183 }
184 crate::db::community::store_message_key(&inner.id.to_hex(), &outer.id.to_hex(), &ephemeral, &community.relays)?;
185 Ok(outer)
186}
187
188pub async fn send_signed_message<T: Transport + ?Sized>(
193 transport: &T,
194 community: &Community,
195 channel: &Channel,
196 inner: &Event,
197) -> Result<Event, String> {
198 let session = SessionGuard::capture();
199 let (outer, ephemeral) = publish_signed_message(transport, community, channel, inner, false).await?;
200 if !session.is_valid() {
201 return Err("account changed during send; not persisting message key".to_string());
202 }
203 crate::db::community::store_message_key(&inner.id.to_hex(), &outer.id.to_hex(), &ephemeral, &community.relays)?;
204 Ok(outer)
205}
206
207pub async fn build_presence(
217 channel: &Channel,
218 joined: bool,
219 attribution: Option<(String, Option<String>)>,
220) -> Result<nostr_sdk::Event, String> {
221 let author_pk = crate::state::my_public_key().ok_or("not logged in")?;
222 let ms = std::time::SystemTime::now()
223 .duration_since(std::time::UNIX_EPOCH)
224 .map(|d| d.as_millis() as u64)
225 .unwrap_or(0);
226 let content = match (joined, attribution) {
227 (false, _) => "leave".to_string(),
228 (true, Some((by, label))) => serde_json::json!({ "by": by, "l": label }).to_string(),
229 (true, None) => "join".to_string(),
230 };
231 let unsigned = super::envelope::build_inner_typed(
232 author_pk, &channel.id, channel.epoch, event_kind::COMMUNITY_PRESENCE, &content, ms, None, &[],
233 );
234 let signer = active_signer().await?;
235 unsigned.sign(&signer).await.map_err(|e| format!("Failed to sign presence: {e}"))
236}
237
238pub async fn publish_presence_event<T: Transport + ?Sized>(
240 transport: &T,
241 community: &Community,
242 channel: &Channel,
243 inner: &nostr_sdk::Event,
244) -> Result<(), String> {
245 let _ = publish_signed_message(transport, community, channel, inner, true).await?;
246 Ok(())
247}
248
249pub async fn publish_presence<T: Transport + ?Sized>(
250 transport: &T,
251 community: &Community,
252 channel: &Channel,
253 joined: bool,
254 attribution: Option<(String, Option<String>)>,
255) -> Result<(), String> {
256 let inner = build_presence(channel, joined, attribution).await?;
257 publish_presence_event(transport, community, channel, &inner).await
258}
259
260pub async fn publish_webxdc_signal<T: Transport + ?Sized>(
267 transport: &T,
268 community: &Community,
269 channel: &Channel,
270 topic_id: &str,
271 node_addr: Option<&str>,
272) -> Result<(), String> {
273 let author_pk = crate::state::my_public_key().ok_or("not logged in")?;
274 let ms = std::time::SystemTime::now()
275 .duration_since(std::time::UNIX_EPOCH)
276 .map(|d| d.as_millis() as u64)
277 .unwrap_or(0);
278 let content = crate::webxdc::peer_signal_content(topic_id, node_addr);
279 let unsigned = super::envelope::build_inner_typed(
280 author_pk, &channel.id, channel.epoch, event_kind::COMMUNITY_WEBXDC, &content, ms, None, &[],
281 );
282 let signer = active_signer().await?;
283 let inner = unsigned.sign(&signer).await.map_err(|e| format!("Failed to sign webxdc signal: {e}"))?;
284 let _ = publish_signed_message(transport, community, channel, &inner, true).await?;
285 Ok(())
286}
287
288pub async fn publish_typing_signal<T: Transport + ?Sized>(
294 transport: &T,
295 community: &Community,
296 channel: &Channel,
297) -> Result<(), String> {
298 let author_pk = crate::state::my_public_key().ok_or("not logged in")?;
299 let ms = std::time::SystemTime::now()
300 .duration_since(std::time::UNIX_EPOCH)
301 .map(|d| d.as_millis() as u64)
302 .unwrap_or(0);
303 let unsigned = super::envelope::build_inner_typed(
304 author_pk, &channel.id, channel.epoch, event_kind::COMMUNITY_TYPING, "typing", ms, None, &[],
305 );
306 let signer = active_signer().await?;
307 let inner = unsigned.sign(&signer).await.map_err(|e| format!("Failed to sign typing signal: {e}"))?;
308 let _ = publish_signed_message(transport, community, channel, &inner, false).await?;
309 Ok(())
310}
311
312pub async fn persist_webxdc_signal(
319 channel_hex: &str,
320 npub: &str,
321 topic_id: &str,
322 node_addr: Option<&str>,
323 event_id: &str,
324 created_at: u64,
325) {
326 if crate::db::events::event_exists(event_id).unwrap_or(true) {
327 return;
328 }
329 let now_secs = std::time::SystemTime::now()
332 .duration_since(std::time::UNIX_EPOCH)
333 .unwrap_or_default()
334 .as_secs();
335 let created_at = created_at.min(now_secs + 300);
336 let Ok(chat_id) = crate::db::id_cache::get_or_create_chat_id(channel_hex) else { return };
337 let mut tags = vec![
338 vec!["webxdc-topic".to_string(), topic_id.to_string()],
339 vec!["d".to_string(), "vector-webxdc-peer".to_string()],
340 ];
341 if let Some(addr) = node_addr {
342 tags.push(vec!["webxdc-node-addr".to_string(), addr.to_string()]);
343 }
344 let event = crate::stored_event::StoredEvent {
345 id: event_id.to_string(),
346 kind: crate::stored_event::event_kind::APPLICATION_SPECIFIC,
347 chat_id,
348 user_id: None,
349 content: if node_addr.is_some() { "peer-advertisement" } else { "peer-left" }.to_string(),
350 tags,
351 reference_id: Some(topic_id.to_string()),
352 created_at,
353 received_at: std::time::SystemTime::now()
354 .duration_since(std::time::UNIX_EPOCH)
355 .unwrap_or_default()
356 .as_millis() as u64,
357 mine: false,
358 pending: false,
359 failed: false,
360 wrapper_event_id: None,
361 npub: Some(npub.to_string()),
362 preview_metadata: None,
363 };
364 if let Err(e) = crate::db::events::save_event(&event).await {
365 crate::log_warn!("[community] failed to persist webxdc peer signal: {e}");
366 }
367}
368
369async fn strip_member_roles_on_removal<T: Transport + ?Sized>(
383 transport: &T,
384 community: &Community,
385 member_hex: &str,
386) {
387 let cid = community.id.to_hex();
388 let roster = match crate::db::community::get_community_roles(&cid) {
389 Ok(r) => r,
390 Err(_) => return,
391 };
392 let held: Vec<String> = roster
393 .grants
394 .iter()
395 .find(|g| g.member == member_hex)
396 .map(|g| g.role_ids.clone())
397 .unwrap_or_default();
398 if held.is_empty() {
399 return; }
401 for role_id in &held {
402 if caller_can_manage_role(community, &roster, role_id, member_hex).is_err() {
403 crate::log_warn!(
404 "removal: not authorized to revoke role {role_id} of {member_hex}; leaving the grant (kick/ban still neutralizes)"
405 );
406 return;
407 }
408 }
409 if let Err(e) = set_member_grant(transport, community, member_hex, Vec::new()).await {
410 crate::log_warn!("removal: role-strip publish failed for {member_hex}: {e}");
411 }
412}
413
414pub async fn publish_kick<T: Transport + ?Sized>(
415 transport: &T,
416 community: &Community,
417 channel: &Channel,
418 target_hex: &str,
419) -> Result<String, String> {
420 let author_pk = crate::state::my_public_key().ok_or("not logged in")?;
421 let me = author_pk.to_hex();
422 let cid = community.id.to_hex();
423 {
426 let owner = proven_owner_hex(community);
427 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
428 if !roster.can_act_on_member(&me, owner.as_deref(), target_hex, super::roles::Permissions::KICK) {
429 return Err("you can't kick a member who outranks you (or the owner)".to_string());
430 }
431 }
432 let ms = std::time::SystemTime::now()
433 .duration_since(std::time::UNIX_EPOCH)
434 .map(|d| d.as_millis() as u64)
435 .unwrap_or(0);
436 let citation = authority_citation(community, &me);
438 let extra: Vec<nostr_sdk::prelude::Tag> = citation.iter().map(|c| c.to_tag()).collect();
439 let unsigned = super::envelope::build_inner_full(
440 author_pk, &channel.id, channel.epoch, event_kind::COMMUNITY_KICK, target_hex, ms, None, &[], &extra,
441 );
442 let signer = active_signer().await?;
443 let inner = unsigned.sign(&signer).await.map_err(|e| format!("Failed to sign kick: {e}"))?;
444 publish_signed_message(transport, community, channel, &inner, true).await?;
445 strip_member_roles_on_removal(transport, community, target_hex).await;
448 Ok(inner.id.to_hex())
450}
451
452
453pub async fn publish_banlist<T: Transport + ?Sized>(
459 transport: &T,
460 community: &Community,
461 banned_hex: &[String],
462) -> Result<(), String> {
463 let session = SessionGuard::capture();
464 let cid = community.id.to_hex();
465 let signer = active_signer().await?;
468 let actor_pk = crate::state::my_public_key().ok_or("no local identity to sign the banlist edition")?;
469 {
474 let me = actor_pk.to_hex();
475 let owner = proven_owner_hex(community);
476 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
477 let current: std::collections::HashSet<String> =
478 crate::db::community::get_community_banlist(&cid).unwrap_or_default().into_iter().collect();
479 let next: std::collections::HashSet<&str> = banned_hex.iter().map(|s| s.as_str()).collect();
480 let added = banned_hex.iter().filter(|n| !current.contains(n.as_str()));
481 let removed = current.iter().filter(|n| !next.contains(n.as_str()));
482 for target in added.chain(removed) {
483 if !roster.can_act_on_member(&me, owner.as_deref(), target, super::roles::Permissions::BAN) {
484 return Err("you can't ban or unban a member who outranks you (or the owner)".to_string());
485 }
486 }
487 }
488 {
494 let prev: std::collections::HashSet<String> =
495 crate::db::community::get_community_banlist(&cid).unwrap_or_default().into_iter().collect();
496 let adds = banned_hex.iter().any(|n| !prev.contains(n.as_str()));
497 let cut_needed = (adds || crate::db::community::get_read_cut_pending(&cid)?) && !is_public(community)?;
498 if cut_needed && crate::state::MY_SECRET_KEY.to_keys().is_none() {
499 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());
500 }
501 }
502 let entity_id = super::derive::banlist_locator(&community.id);
504 let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
505 let (version, prev_hash) = match crate::db::community::get_edition_head(&cid, &entity_hex)? {
506 Some((v, h)) => (v + 1, Some(h)),
507 None => (1, None),
508 };
509 let created_at = std::time::SystemTime::now()
510 .duration_since(std::time::UNIX_EPOCH)
511 .map(|d| d.as_secs())
512 .unwrap_or(0);
513 let citation = authority_citation(community, &actor_pk.to_hex());
516 let unsigned = super::roster::build_banlist_edition_unsigned(actor_pk, &community.id, banned_hex, version, prev_hash.as_ref(), created_at, citation.as_ref())?;
517 let inner = unsigned.sign(&signer).await.map_err(|e| format!("sign banlist edition: {e}"))?;
518 let outer = super::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch)?;
519 let self_hash = super::version::edition_hash(&entity_id, version, prev_hash.as_ref(), inner.content.as_bytes());
520
521 let newly_added: Vec<String> = {
524 let prev: std::collections::HashSet<String> =
525 crate::db::community::get_community_banlist(&cid).unwrap_or_default().into_iter().collect();
526 banned_hex.iter().filter(|n| !prev.contains(n.as_str())).cloned().collect()
527 };
528 let newly_banned = !newly_added.is_empty();
529
530 transport.publish_durable(&outer, &community.relays).await?;
534 if session.is_valid() {
535 crate::db::community::set_community_banlist(&cid, banned_hex, created_at as i64)?;
536 crate::db::community::set_edition_head(&cid, &entity_hex, version, &self_hash)?;
537 }
538
539 if session.is_valid() {
544 for member_hex in &newly_added {
545 strip_member_roles_on_removal(transport, community, member_hex).await;
546 }
547 }
548
549 let need_cut = (newly_banned || crate::db::community::get_read_cut_pending(&cid)?)
564 && session.is_valid()
565 && !is_public(community)?;
566 if need_cut {
567 run_read_cut(transport, community, newly_banned).await?;
570 }
571 Ok(())
572}
573
574pub fn am_i_banned(community: &Community) -> bool {
580 let me = match crate::state::my_public_key() {
581 Some(p) => p.to_hex(),
582 None => return false,
583 };
584 crate::db::community::get_community_banlist(&community.id.to_hex())
585 .unwrap_or_default()
586 .iter()
587 .any(|b| b == &me)
588}
589
590pub async fn retry_pending_read_cut<T: Transport + ?Sized>(
596 transport: &T,
597 community: &Community,
598) -> Result<(), String> {
599 let cid = community.id.to_hex();
600 if !crate::db::community::get_read_cut_pending(&cid)? {
601 return Ok(());
602 }
603 if is_public(community)? {
604 crate::db::community::set_read_cut_pending(&cid, false)?; return Ok(());
606 }
607 let fresh = crate::db::community::load_community(&community.id)?.ok_or("community no longer present")?;
612 run_read_cut(transport, &fresh, false).await
613}
614
615async fn fetch_control_folded<T: Transport + ?Sized>(
625 transport: &T,
626 community: &Community,
627) -> Result<super::roster::FoldedRoster, String> {
628 fetch_control_folded_with(transport, community, Evidence::Quorum).await
629}
630
631async fn fetch_control_folded_with<T: Transport + ?Sized>(
632 transport: &T,
633 community: &Community,
634 evidence: Evidence,
635) -> Result<super::roster::FoldedRoster, String> {
636 let z_tags = vec![super::roster::control_pseudonym(&community.server_root_key, &community.id, community.server_root_epoch)];
641 let query = Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags, evidence, ..Default::default() };
646 let raw = transport.fetch(&query, &community.relays).await?;
647 let inner_editions: Vec<Event> = raw
650 .iter()
651 .take(super::roster::MAX_CONTROL_EDITIONS)
652 .filter_map(|ev| super::roster::open_control_edition(ev, &community.server_root_key).ok())
653 .collect();
654 let fetched = inner_editions.len();
659 let current_epoch = community.server_root_epoch.0;
665 let floors: std::collections::HashMap<String, (u64, [u8; 32])> =
666 crate::db::community::get_all_edition_heads_epoched(&community.id.to_hex())?
667 .into_iter()
668 .filter(|(_, (epoch, _, _))| *epoch == current_epoch)
669 .map(|(entity, (_epoch, version, hash))| (entity, (version, hash)))
670 .collect();
671 let mut folded = super::roster::fold_roster(&inner_editions, &community.id, &floors);
672 folded.fetched = fetched; Ok(folded)
674}
675
676pub async fn fetch_and_apply_control<T: Transport + ?Sized>(
682 transport: &T,
683 community: &Community,
684) -> Result<usize, String> {
685 fetch_and_apply_control_with(transport, community, Evidence::Quorum).await
686}
687
688pub async fn fetch_and_apply_control_full<T: Transport + ?Sized>(
693 transport: &T,
694 community: &Community,
695) -> Result<usize, String> {
696 fetch_and_apply_control_with(transport, community, Evidence::Full).await
697}
698
699async fn fetch_and_apply_control_with<T: Transport + ?Sized>(
700 transport: &T,
701 community: &Community,
702 evidence: Evidence,
703) -> Result<usize, String> {
704 let session = SessionGuard::capture();
705 let cid = community.id.to_hex();
706 if crate::db::community::get_community_dissolved(&cid)? {
709 return Ok(0);
710 }
711 let folded = fetch_control_folded_with(transport, community, evidence).await?;
712 if !session.is_valid() {
713 return Err("account changed during control fetch".to_string());
714 }
715 if let Some(owner) = proven_owner_hex(community) {
725 let by_fold = folded.dissolved_by.iter().any(|s| s.to_hex() == owner);
726 let by_probe = !by_fold && dissolved_tombstone_present(transport, community, &owner).await;
727 if by_fold || by_probe {
728 let _ = fetch_and_apply_banlist_inner(transport, community, Some(folded.clone())).await;
731 let _ = fetch_and_apply_roles_inner(transport, community, Some(folded.clone())).await;
732 let _ = fetch_and_apply_invite_links_inner(transport, community, Some(folded.clone())).await;
733 let _ = fetch_and_apply_metadata_inner(transport, community, Some(folded.clone())).await;
734 if session.is_valid() {
735 crate::db::community::set_community_dissolved(&cid)?;
736 crate::emit_event("community_refreshed", &serde_json::json!({ "community_id": cid }));
740 }
741 return Ok(folded.fetched);
742 }
743 }
744 let fetched = folded.fetched;
747 let _ = fetch_and_apply_banlist_inner(transport, community, Some(folded.clone())).await;
748 let _ = fetch_and_apply_roles_inner(transport, community, Some(folded.clone())).await;
749 let _ = fetch_and_apply_invite_links_inner(transport, community, Some(folded.clone())).await;
750 let _ = fetch_and_apply_metadata_inner(transport, community, Some(folded)).await;
751 Ok(fetched)
752}
753
754pub async fn fetch_and_apply_banlist<T: Transport + ?Sized>(
755 transport: &T,
756 community: &Community,
757) -> Result<Vec<String>, String> {
758 fetch_and_apply_banlist_inner(transport, community, None).await
759}
760
761async fn fetch_and_apply_banlist_inner<T: Transport + ?Sized>(
762 transport: &T,
763 community: &Community,
764 prefolded: Option<super::roster::FoldedRoster>,
765) -> Result<Vec<String>, String> {
766 let session = SessionGuard::capture();
767 let cid = community.id.to_hex();
768 let folded = match prefolded {
769 Some(f) => f,
770 None => fetch_control_folded(transport, community).await?,
771 };
772 let owner = proven_owner_hex(community);
775 let authorized = super::roster::authorize_delegation(&folded, owner.as_deref());
776 if !session.is_valid() {
777 return Err("account changed during banlist fetch".to_string());
778 }
779 if let (Some(author), Some(head)) = (folded.banlist_author, &folded.banlist_head) {
780 let author_hex = author.to_hex();
785 let held: std::collections::HashSet<String> =
786 crate::db::community::get_community_banlist(&cid)?.into_iter().collect();
787 let next: std::collections::HashSet<&str> = folded.banned.iter().map(|s| s.as_str()).collect();
788 let added = folded.banned.iter().filter(|n| !held.contains(n.as_str()));
789 let removed = held.iter().filter(|n| !next.contains(n.as_str()));
790 let citation = folded.banlist_head.as_ref().and_then(|h| h.citation.as_ref());
796 let banner_grant_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(&community.id, &author.to_bytes()));
797 let pinned = super::roster::authority_citation_satisfied(&folded.heads, owner.as_deref(), &author_hex, &banner_grant_hex, citation);
798 let authed = pinned
799 && added.chain(removed).all(|target| {
800 authorized.can_act_on_member(&author_hex, owner.as_deref(), target, super::roles::Permissions::BAN)
801 });
802 let held_version = crate::db::community::get_edition_head(&cid, &head.entity_hex)?.map(|(v, _)| v).unwrap_or(0);
803 if authed && head.version > held_version {
804 crate::db::community::set_community_banlist(&cid, &folded.banned, head.version as i64)?;
805 crate::db::community::set_edition_head(&cid, &head.entity_hex, head.version, &head.self_hash)?;
806 return Ok(folded.banned);
807 }
808 }
809 crate::db::community::get_community_banlist(&cid)
811}
812
813pub async fn set_member_grant<T: Transport + ?Sized>(
818 transport: &T,
819 community: &Community,
820 member_hex: &str,
821 role_ids: Vec<String>,
822) -> Result<(), String> {
823 let session = SessionGuard::capture();
824 let signer = active_signer().await?;
827 let actor_pk = crate::state::my_public_key().ok_or("no local identity to sign the grant edition")?;
828 let cid = community.id.to_hex();
829 let grant = super::roles::MemberGrant { member: member_hex.to_string(), role_ids };
830
831 let member_bytes = crate::simd::hex::hex_to_bytes_32(member_hex);
834 let entity_id = super::derive::grant_locator(&community.id, &member_bytes);
835 let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
836 let (version, prev_hash) = match crate::db::community::get_edition_head(&cid, &entity_hex)? {
837 Some((v, h)) => (v + 1, Some(h)),
838 None => (1, None),
839 };
840 let created_at = std::time::SystemTime::now()
841 .duration_since(std::time::UNIX_EPOCH)
842 .map(|d| d.as_secs())
843 .unwrap_or(0);
844
845 let citation = authority_citation(community, &actor_pk.to_hex());
852 let unsigned = super::roster::build_grant_edition_unsigned(actor_pk, &community.id, &grant, version, prev_hash.as_ref(), created_at, citation.as_ref())?;
853 let inner = unsigned.sign(&signer).await.map_err(|e| format!("sign grant edition: {e}"))?;
854 let outer = super::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch)?;
855 let self_hash = super::version::edition_hash(&entity_id, version, prev_hash.as_ref(), inner.content.as_bytes());
859
860 let is_full_revoke = grant.role_ids.is_empty();
861 let mut roster = crate::db::community::get_community_roles(&cid)?;
863 roster.grants.retain(|g| g.member != member_hex);
864 if !grant.role_ids.is_empty() {
865 roster.grants.push(grant);
866 }
867
868 transport.publish_durable(&outer, &community.relays).await?;
874 if session.is_valid() {
875 crate::db::community::set_community_roles(&cid, &roster, created_at as i64)?;
876 crate::db::community::set_edition_head(&cid, &entity_hex, version, &self_hash)?;
877 }
878
879 if is_full_revoke && session.is_valid() {
887 if let Ok(folded) = fetch_control_folded(transport, community).await {
888 if session.is_valid() {
889 let current = crate::db::community::load_community(&community.id)?.unwrap_or_else(|| community.clone());
890 if folded.root_author.map(|a| a.to_hex()).as_deref() == Some(member_hex) {
891 if let Some(meta) = &folded.root_meta {
892 let mut c = current.clone();
893 c.name = meta.name.clone();
894 c.description = meta.description.clone();
895 c.icon = meta.icon.clone();
896 c.banner = meta.banner.clone();
897 let _ = republish_community_metadata(transport, &c).await;
898 }
899 }
900 for cm in &folded.channel_meta {
901 if cm.author.to_hex() == member_hex
902 && current.channels.iter().any(|ch| ch.id.0 == cm.channel_id)
903 {
904 let _ = republish_channel_metadata(
905 transport, ¤t, &crate::community::ChannelId(cm.channel_id), &cm.meta.name,
906 ).await;
907 }
908 }
909 }
910 }
911 }
912 Ok(())
913}
914
915pub fn is_proven_owner(community: &Community) -> bool {
920 match crate::state::my_public_key() {
921 Some(me) => proven_owner_hex(community).as_deref() == Some(me.to_hex().as_str()),
922 None => false,
923 }
924}
925
926pub fn caller_can_manage_roles(community: &Community) -> bool {
930 let me = match crate::state::my_public_key() {
931 Some(p) => p,
932 None => return false,
933 };
934 let cid = community.id.to_hex();
935 let is_owner = community
936 .owner_attestation
937 .as_ref()
938 .and_then(|a| super::owner::verify_owner_attestation(a, &cid))
939 .map(|pk| pk == me)
940 .unwrap_or(false);
941 if is_owner {
942 return true; }
944 crate::db::community::get_community_roles(&cid)
945 .unwrap_or_default()
946 .has_permission(&me.to_hex(), super::roles::Permissions::MANAGE_ROLES)
947}
948
949pub fn caller_has_permission(community: &Community, permission: u64) -> bool {
953 let me = match crate::state::my_public_key() {
954 Some(p) => p,
955 None => return false,
956 };
957 crate::db::community::get_community_roles(&community.id.to_hex())
958 .unwrap_or_default()
959 .is_authorized(&me.to_hex(), proven_owner_hex(community).as_deref(), permission)
960}
961
962pub fn caller_can_manage_role_id(community: &Community, role_id: &str) -> bool {
967 let me = match crate::state::my_public_key() {
968 Some(p) => p.to_hex(),
969 None => return false,
970 };
971 let roster = crate::db::community::get_community_roles(&community.id.to_hex()).unwrap_or_default();
972 let position = match roster.role(role_id) {
973 Some(r) => r.position,
974 None => return false,
975 };
976 roster.can_manage_position(&me, proven_owner_hex(community).as_deref(), position)
977}
978
979#[derive(Debug, Clone, Default, serde::Serialize)]
984pub struct CommunityCapabilities {
985 pub manage_metadata: bool,
986 pub manage_channels: bool,
987 pub create_invite: bool,
988 pub kick: bool,
989 pub ban: bool,
990 pub manage_messages: bool,
991 pub manage_roles: bool,
992}
993
994pub fn caller_capabilities(community: &Community) -> CommunityCapabilities {
995 use super::roles::Permissions as P;
996 let me_hex = match crate::state::my_public_key() {
997 Some(p) => p.to_hex(),
998 None => return CommunityCapabilities::default(),
999 };
1000 let owner = proven_owner_hex(community);
1001 let roster = crate::db::community::get_community_roles(&community.id.to_hex()).unwrap_or_default();
1002 let has = |bit: u64| roster.is_authorized(&me_hex, owner.as_deref(), bit);
1003 CommunityCapabilities {
1004 manage_metadata: has(P::MANAGE_METADATA),
1005 manage_channels: has(P::MANAGE_CHANNELS),
1006 create_invite: has(P::CREATE_INVITE),
1007 kick: has(P::KICK),
1008 ban: has(P::BAN),
1009 manage_messages: has(P::MANAGE_MESSAGES),
1010 manage_roles: has(P::MANAGE_ROLES),
1011 }
1012}
1013
1014fn authority_citation(community: &Community, actor_hex: &str) -> Option<super::edition::AuthorityCitation> {
1022 if proven_owner_hex(community).as_deref() == Some(actor_hex) {
1023 return None;
1024 }
1025 let cid = community.id.to_hex();
1026 let actor_bytes = crate::simd::hex::hex_to_bytes_32(actor_hex);
1027 let entity_id = super::derive::grant_locator(&community.id, &actor_bytes);
1028 let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
1029 crate::db::community::get_edition_head(&cid, &entity_hex)
1030 .ok()
1031 .flatten()
1032 .map(|(version, edition_hash)| super::edition::AuthorityCitation { entity_id, version, edition_hash })
1033}
1034
1035fn proven_owner_hex(community: &Community) -> Option<String> {
1038 let cid = community.id.to_hex();
1039 community
1040 .owner_attestation
1041 .as_ref()
1042 .and_then(|a| super::owner::verify_owner_attestation(a, &cid))
1043 .map(|pk| pk.to_hex())
1044}
1045
1046pub fn can_moderation_hide(community: &Community, actor_hex: &str, author_hex: &str) -> bool {
1052 let to_hex = |s: &str| nostr_sdk::PublicKey::parse(s).map(|pk| pk.to_hex()).unwrap_or_else(|_| s.to_string());
1059 let actor = to_hex(actor_hex);
1060 let author = to_hex(author_hex);
1061 let owner = proven_owner_hex(community);
1062 let roster = crate::db::community::get_community_roles(&community.id.to_hex()).unwrap_or_default();
1063 roster.can_act_on_member(&actor, owner.as_deref(), &author, super::roles::Permissions::MANAGE_MESSAGES)
1064}
1065
1066fn rotator_is_authorized(
1074 cid: &str,
1075 roster: &super::roles::CommunityRoles,
1076 owner_hex: Option<&str>,
1077 rotator_hex: &str,
1078 permission: u64,
1079) -> bool {
1080 if owner_hex != Some(rotator_hex)
1081 && crate::db::community::get_community_banlist(cid)
1082 .unwrap_or_default()
1083 .iter()
1084 .any(|b| b == rotator_hex)
1085 {
1086 return false;
1087 }
1088 roster.is_authorized(rotator_hex, owner_hex, permission)
1089}
1090
1091fn caller_can_manage_role(
1097 community: &Community,
1098 roster: &super::roles::CommunityRoles,
1099 role_id: &str,
1100 member_hex: &str,
1101) -> Result<(), String> {
1102 let me = crate::state::my_public_key().ok_or("no active identity")?.to_hex();
1103 let owner = proven_owner_hex(community);
1104 let owner_ref = owner.as_deref();
1105 let role = roster.role(role_id).ok_or("no such role")?;
1106 if !roster.can_manage_position(&me, owner_ref, role.position) {
1107 return Err("you can only manage roles below your own".to_string());
1108 }
1109 if !roster.can_manage_member(&me, owner_ref, member_hex) {
1110 return Err("you can't manage a member who outranks you".to_string());
1111 }
1112 Ok(())
1113}
1114
1115pub async fn grant_role<T: Transport + ?Sized>(
1119 transport: &T,
1120 community: &Community,
1121 member: nostr_sdk::prelude::PublicKey,
1122 role_id: &str,
1123) -> Result<(), String> {
1124 let cid = community.id.to_hex();
1125 let member_hex = member.to_hex();
1126 let roster = crate::db::community::get_community_roles(&cid)?;
1127 caller_can_manage_role(community, &roster, role_id, &member_hex)?;
1128 let mut role_ids: Vec<String> = roster
1130 .grants
1131 .iter()
1132 .find(|g| g.member == member_hex)
1133 .map(|g| g.role_ids.clone())
1134 .unwrap_or_default();
1135 if !role_ids.iter().any(|r| r == role_id) {
1136 role_ids.push(role_id.to_string());
1137 }
1138
1139 set_member_grant(transport, community, &member_hex, role_ids).await
1143}
1144
1145pub async fn revoke_role<T: Transport + ?Sized>(
1152 transport: &T,
1153 community: &Community,
1154 member: nostr_sdk::prelude::PublicKey,
1155 role_id: &str,
1156) -> Result<(), String> {
1157 let cid = community.id.to_hex();
1158 let member_hex = member.to_hex();
1159 let roster = crate::db::community::get_community_roles(&cid)?;
1160 caller_can_manage_role(community, &roster, role_id, &member_hex)?;
1161 let role_ids: Vec<String> = roster
1162 .grants
1163 .iter()
1164 .find(|g| g.member == member_hex)
1165 .map(|g| g.role_ids.iter().filter(|r| r.as_str() != role_id).cloned().collect())
1166 .unwrap_or_default();
1167 set_member_grant(transport, community, &member_hex, role_ids).await
1168}
1169
1170pub async fn fetch_and_apply_roles<T: Transport + ?Sized>(
1176 transport: &T,
1177 community: &Community,
1178) -> Result<super::roles::CommunityRoles, String> {
1179 fetch_and_apply_roles_inner(transport, community, None).await
1180}
1181
1182async fn fetch_and_apply_roles_inner<T: Transport + ?Sized>(
1183 transport: &T,
1184 community: &Community,
1185 prefolded: Option<super::roster::FoldedRoster>,
1186) -> Result<super::roles::CommunityRoles, String> {
1187 let session = SessionGuard::capture();
1188 let cid = community.id.to_hex();
1189 let folded = match prefolded {
1190 Some(f) => f,
1191 None => fetch_control_folded(transport, community).await?,
1192 };
1193
1194 if !session.is_valid() {
1195 return Err("account changed during roles fetch".to_string());
1196 }
1197 for head in &folded.heads {
1206 crate::db::community::set_edition_head(&cid, &head.entity_hex, head.version, &head.self_hash)?;
1207 }
1208 if folded.heads.is_empty() {
1213 return crate::db::community::get_community_roles(&cid);
1214 }
1215 let authorized = super::roster::authorize_delegation(&folded, proven_owner_hex(community).as_deref());
1219 crate::db::community::set_community_roles(&cid, &authorized, 0)?;
1220 Ok(authorized)
1221}
1222
1223pub async fn publish_owner_hide<T: Transport + ?Sized>(
1228 transport: &T,
1229 community: &Community,
1230 channel: &Channel,
1231 target_message_id: &str,
1232) -> Result<(), String> {
1233 let signer = active_signer().await?;
1238 let me_pk = crate::state::my_public_key().ok_or("no local identity to sign the hide")?;
1239 let me = me_pk.to_hex();
1240 {
1241 let target_author = {
1242 let st = crate::state::STATE.lock().await;
1243 st.find_message(target_message_id).and_then(|(_, m)| m.npub)
1244 };
1245 let author = target_author
1246 .ok_or("can't resolve the target message's author to authorize the hide")?;
1247 if !can_moderation_hide(community, &me, &author) {
1248 return Err("you can't hide a message from a member who outranks you (or the owner)".to_string());
1249 }
1250 }
1251 let ms = std::time::SystemTime::now()
1252 .duration_since(std::time::UNIX_EPOCH)
1253 .map(|d| d.as_millis() as u64)
1254 .unwrap_or(0);
1255 let citation = authority_citation(community, &me);
1261 let extra: Vec<Tag> = citation.iter().map(|c| c.to_tag()).collect();
1262 let inner = super::envelope::build_inner_full(
1263 me_pk, &channel.id, channel.epoch,
1264 event_kind::COMMUNITY_DELETE, "", ms, Some(target_message_id), &[], &extra,
1265 )
1266 .sign(&signer)
1267 .await
1268 .map_err(|e| format!("sign hide: {e}"))?;
1269 let _ = publish_signed_message(transport, community, channel, &inner, true).await?;
1270 Ok(())
1271}
1272
1273pub async fn delete_message<T: Transport + ?Sized>(
1278 transport: &T,
1279 message_id: &str,
1280) -> Result<(), String> {
1281 let session = SessionGuard::capture();
1282 if !session.is_valid() {
1283 return Err("account changed; aborting delete".to_string());
1284 }
1285 let (ephemeral, outer_event_id_hex, relays) = match crate::db::community::get_message_key(message_id)? {
1289 Some(v) => v,
1290 None => {
1291 return Err("no retained key for this message (not yours, or already deleted)".to_string())
1292 }
1293 };
1294 let id = EventId::from_hex(&outer_event_id_hex).map_err(|e| e.to_string())?;
1295 delete_own_message(transport, &relays, &ephemeral, id).await?;
1296 crate::db::community::delete_message_key(message_id)?;
1298 Ok(())
1299}
1300
1301pub fn accept_invite(invite: &CommunityInvite) -> Result<Community, String> {
1314 let session = SessionGuard::capture();
1315 let community = super::invite::accept_invite(invite)?; match crate::db::community::load_community(&community.id)? {
1318 Some(existing) => {
1320 if is_proven_owner(&existing) {
1321 return Err("you already own this Community".to_string());
1322 }
1323 if existing.server_root_key.as_bytes() != community.server_root_key.as_bytes() {
1327 return Err(
1328 "invite reuses a known Community id under a different authority — rejected"
1329 .to_string(),
1330 );
1331 }
1332 }
1333 None => enforce_community_cap()?,
1335 }
1336
1337 if !session.is_valid() {
1338 return Err("account changed during invite accept".to_string());
1339 }
1340 crate::db::community::save_community(&community)?;
1341 Ok(community)
1342}
1343
1344pub async fn preload_community(invite: &super::invite::CommunityInvite) {
1351 let Ok(community) = super::invite::accept_invite(invite) else { return };
1352 let Some(channel) = community.channels.first() else { return };
1353 let cid = community.id.to_hex();
1354 crate::community::cache::begin_preload(&cid);
1356 let transport = super::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1357 match super::send::fetch_channel_page(&transport, &community, channel, None, None, 50).await {
1359 Ok(page) if !page.is_empty() => crate::community::cache::finish_preload(&cid, page),
1360 _ => crate::community::cache::abort_preload(&cid),
1362 }
1363
1364 let prune_relays = community.relays.clone();
1368 let prune_id = community.id;
1369 let guard = crate::state::SessionGuard::capture();
1370 tokio::spawn(async move {
1371 tokio::time::sleep(crate::community::cache::PRELOAD_TTL).await;
1372 if !guard.is_valid() {
1373 return;
1374 }
1375 if matches!(crate::db::community::load_community(&prune_id), Ok(Some(_))) {
1378 return;
1379 }
1380 crate::community::cache::abort_preload(&prune_id.to_hex());
1382 super::transport::prune_unneeded_community_relays(&prune_relays).await;
1383 });
1384}
1385
1386pub async fn republish_community_metadata<T: Transport + ?Sized>(
1391 transport: &T,
1392 community: &Community,
1393) -> Result<(), String> {
1394 let session = SessionGuard::capture();
1395 let cid = community.id.to_hex();
1396 let signer = active_signer().await?;
1397 let actor_pk = crate::state::my_public_key().ok_or("no local identity to sign the metadata edition")?;
1398 let owner = proven_owner_hex(community);
1399 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
1400 if !roster.is_authorized(&actor_pk.to_hex(), owner.as_deref(), super::roles::Permissions::MANAGE_METADATA) {
1401 return Err("only a member with manage-metadata authority can edit the community".to_string());
1402 }
1403 let (version, prev_hash) = match crate::db::community::get_edition_head(&cid, &cid)? {
1408 Some((v, h)) => (v + 1, Some(h)),
1409 None => (1, None),
1410 };
1411 let created = std::time::SystemTime::now()
1412 .duration_since(std::time::UNIX_EPOCH)
1413 .map(|d| d.as_secs())
1414 .unwrap_or(0);
1415 let meta = super::metadata::CommunityMetadata::of(community);
1416 let citation = authority_citation(community, &actor_pk.to_hex());
1421 let unsigned = super::roster::build_community_root_edition_unsigned(actor_pk, &community.id, &meta, version, prev_hash.as_ref(), created, citation.as_ref())?;
1422 let inner = unsigned.sign(&signer).await.map_err(|e| format!("sign community-root edition: {e}"))?;
1423 let outer = super::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch)?;
1424 transport.publish_durable(&outer, &community.relays).await?;
1425 if session.is_valid() {
1426 crate::db::community::save_community(community)?;
1427 let h = super::version::edition_hash(&community.id.0, version, prev_hash.as_ref(), inner.content.as_bytes());
1428 crate::db::community::set_edition_head_with_id(&cid, &cid, version, &h, &inner.id.to_bytes())?;
1431 }
1432 Ok(())
1433}
1434
1435pub async fn republish_channel_metadata<T: Transport + ?Sized>(
1441 transport: &T,
1442 community: &Community,
1443 channel_id: &crate::community::ChannelId,
1444 new_name: &str,
1445) -> Result<(), String> {
1446 let session = SessionGuard::capture();
1447 let cid = community.id.to_hex();
1448 let ch_hex = channel_id.to_hex();
1449 if !community.channels.iter().any(|c| &c.id == channel_id) {
1450 return Err("no such channel in this community".to_string());
1451 }
1452 let signer = active_signer().await?;
1453 let actor_pk = crate::state::my_public_key().ok_or("no local identity to sign the channel metadata edition")?;
1454 let owner = proven_owner_hex(community);
1455 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
1456 if !roster.is_authorized(&actor_pk.to_hex(), owner.as_deref(), super::roles::Permissions::MANAGE_CHANNELS) {
1457 return Err("only a member with manage-channels authority can rename a channel".to_string());
1458 }
1459 let (version, prev_hash) = match crate::db::community::get_edition_head(&cid, &ch_hex)? {
1460 Some((v, h)) => (v + 1, Some(h)),
1461 None => (1, None),
1462 };
1463 let created = std::time::SystemTime::now()
1464 .duration_since(std::time::UNIX_EPOCH)
1465 .map(|d| d.as_secs())
1466 .unwrap_or(0);
1467 let meta = super::metadata::ChannelMetadata { name: new_name.to_string() };
1468 let citation = authority_citation(community, &actor_pk.to_hex());
1471 let unsigned = super::roster::build_channel_metadata_edition_unsigned(actor_pk, channel_id, &meta, version, prev_hash.as_ref(), created, citation.as_ref())?;
1472 let inner = unsigned.sign(&signer).await.map_err(|e| format!("sign channel-metadata edition: {e}"))?;
1473 let outer = super::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch)?;
1474 transport.publish_durable(&outer, &community.relays).await?;
1475 if session.is_valid() {
1476 let mut current = crate::db::community::load_community(&community.id)?.ok_or("community no longer present")?;
1477 if let Some(ch) = current.channels.iter_mut().find(|c| &c.id == channel_id) {
1478 ch.name = new_name.to_string();
1479 }
1480 crate::db::community::save_community(¤t)?;
1481 let h = super::version::edition_hash(&channel_id.0, version, prev_hash.as_ref(), inner.content.as_bytes());
1482 crate::db::community::set_edition_head_with_id(&cid, &ch_hex, version, &h, &inner.id.to_bytes())?;
1483 }
1484 Ok(())
1485}
1486
1487fn generate_invite_label() -> String {
1500 use rand::Rng;
1501 const ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
1502 let mut rng = rand::thread_rng();
1503 (0..6).map(|_| ALPHABET[rng.gen_range(0..ALPHABET.len())] as char).collect()
1504}
1505
1506pub async fn create_public_invite<T: Transport + ?Sized>(
1507 transport: &T,
1508 community: &Community,
1509 expires_at: Option<u64>,
1510 label: Option<String>,
1511) -> Result<(String, String), String> {
1512 if !caller_has_permission(community, super::roles::Permissions::CREATE_INVITE) {
1513 return Err("you need the create-invite permission to mint a public invite".to_string());
1514 }
1515 let session = SessionGuard::capture();
1516
1517 let existing = crate::db::community::list_public_invites(&community.id.to_hex()).unwrap_or_default();
1521 let label_taken = |cand: &str| {
1522 existing.iter().any(|r| r.label.as_deref().map(|e| e.eq_ignore_ascii_case(cand)).unwrap_or(false))
1523 };
1524 let label = match label {
1525 Some(l) if !l.trim().is_empty() => {
1526 let l = l.trim().to_string();
1527 if label_taken(&l) {
1528 return Err(format!("You already have an invite link labeled \u{201c}{l}\u{201d}. Pick a different label."));
1529 }
1530 Some(l)
1531 }
1532 _ => {
1534 let mut l = generate_invite_label();
1535 while label_taken(&l) {
1536 l = generate_invite_label();
1537 }
1538 Some(l)
1539 }
1540 };
1541
1542 let creator_npub = crate::state::my_public_key().and_then(|pk| pk.to_bech32().ok());
1545 let token = public_invite::new_token();
1546 let event = build_public_invite_event(community, &token, expires_at, creator_npub, label.clone()).map_err(|e| e.to_string())?;
1547 transport.publish_durable(&event, &community.relays).await?;
1548
1549 if !session.is_valid() {
1552 return Err("account changed during public invite creation".to_string());
1553 }
1554 let token_hex = crate::simd::hex::bytes_to_hex_32(&token);
1555 let url = public_invite::encode_invite_url(&community.relays, &token);
1556 crate::db::community::save_public_invite(
1557 &token_hex,
1558 &community.id.to_hex(),
1559 &url,
1560 expires_at.map(|e| e as i64),
1561 label.as_deref(),
1562 )?;
1563 super::invite_list::add_invite(super::invite_list::InviteEntry {
1566 token: token_hex.clone(),
1567 community_id: community.id.to_hex(),
1568 url: url.clone(),
1569 label: label.clone(),
1570 created_at: std::time::SystemTime::now()
1571 .duration_since(std::time::UNIX_EPOCH)
1572 .map(|d| d.as_secs())
1573 .unwrap_or(0),
1574 expires_at,
1575 });
1576 republish_my_invite_links(transport, community).await?;
1579 Ok((token_hex, url))
1580}
1581
1582pub async fn latest_invite_preview<T: Transport + ?Sized>(
1588 transport: &T,
1589 bundle: &public_invite::PublicInviteBundle,
1590) -> public_invite::PublicInvitePreview {
1591 let snapshot = bundle.preview.clone();
1592 let Ok(community) = super::invite::accept_invite(&bundle.join) else {
1593 return snapshot;
1594 };
1595 let Ok(folded) = fetch_control_folded(transport, &community).await else {
1596 return snapshot;
1597 };
1598 let owner = proven_owner_hex(&community);
1599 let authorized = super::roster::authorize_delegation(&folded, owner.as_deref());
1600 match folded.root_candidates.iter().find(|c| {
1601 authorized.is_authorized(&c.author.to_hex(), owner.as_deref(), super::roles::Permissions::MANAGE_METADATA)
1602 }) {
1603 Some(c) => public_invite::PublicInvitePreview {
1604 name: c.meta.name.clone(),
1605 description: c.meta.description.clone(),
1606 icon: c.meta.icon.clone(),
1607 },
1608 None => snapshot,
1609 }
1610}
1611
1612pub async fn fetch_public_invite<T: Transport + ?Sized>(
1616 transport: &T,
1617 relays: &[String],
1618 token: &[u8; 32],
1619) -> Result<PublicInviteBundle, String> {
1620 let query = Query {
1624 kinds: vec![event_kind::APPLICATION_SPECIFIC],
1625 d_tags: vec![locator_hex(token)],
1626 ..Default::default()
1627 };
1628 let events = transport.fetch(&query, relays).await?;
1629 let (mut bundle_at, mut bundle, mut revoked_at) = (0u64, None, None::<u64>);
1635 for ev in &events {
1636 match parse_public_invite_event(ev, token) {
1637 Ok(b) => if bundle.is_none() || ev.created_at.as_secs() > bundle_at {
1638 bundle_at = ev.created_at.as_secs();
1639 bundle = Some(b);
1640 },
1641 Err(super::public_invite::PublicInviteError::Revoked) => {
1642 let at = ev.created_at.as_secs();
1643 if revoked_at.map_or(true, |r| at > r) { revoked_at = Some(at); }
1644 }
1645 Err(_) => {} }
1647 }
1648 match (bundle, revoked_at) {
1649 (Some(b), Some(r)) if bundle_at > r => Ok(b), (_, Some(_)) => Err("this invite was revoked".to_string()),
1651 (Some(b), None) => Ok(b),
1652 (None, None) => Err("no public invite found at that link (revoked, never posted, or shadowed)".to_string()),
1653 }
1654}
1655
1656pub fn accept_public_invite(bundle: &PublicInviteBundle, now_secs: u64) -> Result<Community, String> {
1660 if bundle.is_expired(now_secs) {
1661 return Err("this invite link has expired".to_string());
1662 }
1663 let mut community = accept_invite(&bundle.join)?;
1664 if bundle.preview.description.is_some() || bundle.preview.icon.is_some() {
1667 community.description = bundle.preview.description.clone();
1668 community.icon = bundle.preview.icon.clone();
1669 crate::db::community::save_community(&community)?;
1670 }
1671 Ok(community)
1672}
1673
1674pub async fn revoke_public_invite<T: Transport + ?Sized>(
1681 transport: &T,
1682 community: &Community,
1683 token: &[u8; 32],
1684) -> Result<(), String> {
1685 let session = SessionGuard::capture();
1686 let cid = community.id.to_hex();
1687 let token_hex = crate::simd::hex::bytes_to_hex_32(token);
1688 let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
1689 if !crate::db::community::list_public_invites(&cid)?.iter().any(|r| r.token == token_hex) {
1692 return Ok(());
1693 }
1694 let my_locators_before: Vec<String> = crate::db::community::list_public_invites(&cid)?
1695 .iter()
1696 .filter(|r| r.expires_at.map_or(true, |e| (e as u64) > now))
1697 .map(|r| public_invite::locator_hex(&crate::simd::hex::hex_to_bytes_32(&r.token)))
1698 .collect();
1699 let _ = fetch_and_apply_invite_links(transport, community).await;
1703 if !session.is_valid() {
1704 return Err("account changed during invite revoke".to_string());
1705 }
1706 let this_locator = public_invite::locator_hex(token);
1712 let cached_aggregate: std::collections::BTreeSet<String> =
1713 crate::db::community::get_community_invite_registry(&cid)?.into_iter().collect();
1714 let my_before: std::collections::BTreeSet<String> = my_locators_before.iter().cloned().collect();
1715 let others: std::collections::BTreeSet<String> = cached_aggregate.difference(&my_before).cloned().collect();
1716 let my_after: std::collections::BTreeSet<String> =
1717 my_before.iter().filter(|l| **l != this_locator).cloned().collect();
1718 let would_empty_aggregate = others.is_empty() && my_after.is_empty();
1719 if would_empty_aggregate && crate::state::MY_SECRET_KEY.to_keys().is_none() {
1720 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());
1721 }
1722 if let Ok(tombstone) = public_invite::build_public_invite_tombstone(token) {
1732 let _ = transport.publish_durable(&tombstone, &community.relays).await;
1733 }
1734 if !session.is_valid() {
1736 return Err("account changed during invite revoke".to_string());
1737 }
1738 crate::db::community::delete_public_invite(&token_hex)?;
1739 super::invite_list::revoke_invite(&token_hex, &cid);
1742 republish_my_invite_links(transport, community).await?;
1744 if session.is_valid() {
1745 let aggregate_after: Vec<String> = others.union(&my_after).cloned().collect();
1746 crate::db::community::set_community_invite_registry(&cid, &aggregate_after)?;
1747 }
1748 if would_empty_aggregate {
1749 run_read_cut(transport, community, true).await?;
1753 }
1754 Ok(())
1755}
1756
1757async fn dissolved_tombstone_present<T: Transport + ?Sized>(transport: &T, community: &Community, owner_hex: &str) -> bool {
1770 let z = super::derive::dissolved_pseudonym(&community.id);
1771 let q = Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], ..Default::default() };
1772 for ev in transport.fetch(&q, &community.relays).await.unwrap_or_default() {
1773 if super::roster::dissolved_tombstone_signer(&ev, &community.id).map(|s| s.to_hex()) == Some(owner_hex.to_string()) {
1774 return true;
1775 }
1776 }
1777 false
1778}
1779
1780pub async fn dissolve_community<T: Transport + ?Sized>(
1781 transport: &T,
1782 community: &Community,
1783) -> Result<(), String> {
1784 let session = SessionGuard::capture();
1785 let cid = community.id.to_hex();
1786
1787 if !is_proven_owner(community) {
1789 return Err("only the community owner can dissolve (delete) the community".to_string());
1790 }
1791 let signer = active_signer().await?;
1792 let actor_pk = crate::state::my_public_key().ok_or("no local identity to sign the dissolution")?;
1793
1794 let created_at = std::time::SystemTime::now()
1798 .duration_since(std::time::UNIX_EPOCH)
1799 .map(|d| d.as_secs())
1800 .unwrap_or(0);
1801 let unsigned = super::roster::build_group_dissolved_edition_unsigned(actor_pk, &community.id, created_at);
1802 let inner = unsigned.sign(&signer).await.map_err(|e| format!("sign dissolution tombstone: {e}"))?;
1803 let stable = super::roster::seal_dissolved_edition(&Keys::generate(), &inner, &community.id)?;
1807 transport.publish_durable(&stable, &community.relays).await?;
1808 if let Ok(outer) = super::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch) {
1811 let _ = transport.publish_durable(&outer, &community.relays).await;
1812 }
1813 if !session.is_valid() {
1814 return Err("account changed during dissolution".to_string());
1815 }
1816
1817 if caller_has_permission(community, super::roles::Permissions::CREATE_INVITE) {
1823 let _ = publish_my_invite_links(transport, community, &[]).await;
1824 if let Ok(records) = crate::db::community::list_public_invites(&cid) {
1825 for r in records {
1826 let token = crate::simd::hex::hex_to_bytes_32(&r.token);
1827 if let Ok(tombstone) = public_invite::build_public_invite_tombstone(&token) {
1828 let _ = transport.publish_durable(&tombstone, &community.relays).await;
1829 }
1830 let _ = crate::db::community::delete_public_invite(&r.token);
1831 }
1832 }
1833 }
1834
1835 if !session.is_valid() {
1837 return Err("account changed during dissolution".to_string());
1838 }
1839 crate::db::community::set_community_dissolved(&cid)?;
1840 Ok(())
1841}
1842
1843pub async fn publish_my_invite_links<T: Transport + ?Sized>(
1850 transport: &T,
1851 community: &Community,
1852 my_locators: &[String],
1853) -> Result<(), String> {
1854 let session = SessionGuard::capture();
1855 if !caller_has_permission(community, super::roles::Permissions::CREATE_INVITE) {
1856 return Err("you need the create-invite permission to publish invite links".to_string());
1857 }
1858 let cid = community.id.to_hex();
1859 let signer = active_signer().await?;
1860 let actor_pk = crate::state::my_public_key().ok_or("no local identity to sign the invite links")?;
1861 let entity_id = super::derive::invite_links_locator(&community.id, &actor_pk.to_bytes());
1862 let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
1863 let (version, prev_hash) = match crate::db::community::get_edition_head(&cid, &entity_hex)? {
1864 Some((v, h)) => (v + 1, Some(h)),
1865 None => (1, None),
1866 };
1867 let created_at = std::time::SystemTime::now()
1868 .duration_since(std::time::UNIX_EPOCH)
1869 .map(|d| d.as_secs())
1870 .unwrap_or(0);
1871 let citation = authority_citation(community, &actor_pk.to_hex());
1873 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())?;
1874 let inner = unsigned.sign(&signer).await.map_err(|e| format!("sign invite-links edition: {e}"))?;
1875 let outer = super::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch)?;
1876 let self_hash = super::version::edition_hash(&entity_id, version, prev_hash.as_ref(), inner.content.as_bytes());
1877 transport.publish_durable(&outer, &community.relays).await?;
1878 if session.is_valid() {
1879 crate::db::community::set_edition_head(&cid, &entity_hex, version, &self_hash)?;
1880 let mut agg: std::collections::BTreeSet<String> =
1883 crate::db::community::get_community_invite_registry(&cid)?.into_iter().collect();
1884 agg.extend(my_locators.iter().cloned());
1885 crate::db::community::set_community_invite_registry(&cid, &agg.into_iter().collect::<Vec<_>>())?;
1886 crate::db::community::upsert_invite_link_set(&cid, &actor_pk.to_hex(), my_locators)?;
1887 }
1888 Ok(())
1889}
1890
1891pub async fn fetch_and_apply_invite_links<T: Transport + ?Sized>(
1897 transport: &T,
1898 community: &Community,
1899) -> Result<Vec<String>, String> {
1900 fetch_and_apply_invite_links_inner(transport, community, None).await
1901}
1902
1903async fn fetch_and_apply_invite_links_inner<T: Transport + ?Sized>(
1904 transport: &T,
1905 community: &Community,
1906 prefolded: Option<super::roster::FoldedRoster>,
1907) -> Result<Vec<String>, String> {
1908 let session = SessionGuard::capture();
1909 let cid = community.id.to_hex();
1910 let folded = match prefolded {
1911 Some(f) => f,
1912 None => fetch_control_folded(transport, community).await?,
1913 };
1914 if !session.is_valid() {
1915 return Err("account changed during invite-links fetch".to_string());
1916 }
1917 let owner = proven_owner_hex(community);
1918 let authorized = super::roster::authorize_delegation(&folded, owner.as_deref());
1919 let mut aggregate: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
1920 let mut per_creator: Vec<crate::db::community::InviteLinkSetRow> = Vec::new();
1922 for set in &folded.invite_link_sets {
1923 if !authorized.is_authorized(&set.creator.to_hex(), owner.as_deref(), super::roles::Permissions::CREATE_INVITE) {
1926 continue;
1927 }
1928 let held = crate::db::community::get_edition_head(&cid, &set.head.entity_hex)?.map(|(v, _)| v).unwrap_or(0);
1929 if set.head.version > held {
1930 crate::db::community::set_edition_head(&cid, &set.head.entity_hex, set.head.version, &set.head.self_hash)?;
1931 }
1932 aggregate.extend(set.locators.iter().cloned());
1933 per_creator.push(crate::db::community::InviteLinkSetRow {
1934 creator_hex: set.creator.to_hex(),
1935 locators: set.locators.clone(),
1936 });
1937 }
1938 {
1954 let present_creators: std::collections::HashSet<String> =
1955 folded.invite_link_sets.iter().map(|s| s.creator.to_hex()).collect();
1956 for row in crate::db::community::get_invite_link_sets(&cid)? {
1957 if present_creators.contains(&row.creator_hex) {
1958 continue;
1959 }
1960 aggregate.extend(row.locators.iter().cloned());
1961 per_creator.push(row);
1962 }
1963 }
1964 let aggregate: Vec<String> = aggregate.into_iter().collect();
1965 if !session.is_valid() {
1966 return Err("account changed during invite-links fold".to_string());
1967 }
1968 crate::db::community::set_community_invite_registry(&cid, &aggregate)?;
1969 crate::db::community::replace_invite_link_sets(&cid, &per_creator)?;
1970 Ok(aggregate)
1971}
1972
1973pub async fn fetch_and_apply_metadata<T: Transport + ?Sized>(
1981 transport: &T,
1982 community: &Community,
1983) -> Result<(), String> {
1984 fetch_and_apply_metadata_inner(transport, community, None).await
1985}
1986
1987async fn fetch_and_apply_metadata_inner<T: Transport + ?Sized>(
1988 transport: &T,
1989 community: &Community,
1990 prefolded: Option<super::roster::FoldedRoster>,
1991) -> Result<(), String> {
1992 let session = SessionGuard::capture();
1993 let cid = community.id.to_hex();
1994 let folded = match prefolded {
1995 Some(f) => f,
1996 None => fetch_control_folded(transport, community).await?,
1997 };
1998 if !session.is_valid() {
1999 return Err("account changed during metadata fetch".to_string());
2000 }
2001 let owner = proven_owner_hex(community);
2002 let authorized = super::roster::authorize_delegation(&folded, owner.as_deref());
2003 let manage = super::roles::Permissions::MANAGE_METADATA;
2006 let manage_channels = super::roles::Permissions::MANAGE_CHANNELS;
2007
2008 let mut current = match crate::db::community::load_community(&community.id)? {
2011 Some(c) => c,
2012 None => return Ok(()),
2013 };
2014 let mut dirty = false;
2015 let mut head_updates: Vec<(String, u64, [u8; 32], [u8; 32], bool)> = Vec::new();
2019
2020 let decide = |entity_hex: &str, head: &super::roster::EntityHead| -> Result<Option<bool>, String> {
2027 let held = crate::db::community::get_edition_head(&cid, entity_hex)?;
2028 let held_v = held.map(|(v, _)| v).unwrap_or(0);
2029 if head.version > held_v {
2030 return Ok(Some(false)); }
2032 if head.version == held_v && held.map(|(_, h)| h) != Some(head.self_hash) {
2033 let held_id = crate::db::community::get_edition_head_inner_id(&cid, entity_hex)?;
2034 if held_id.is_none() || Some(head.inner_id) < held_id {
2035 return Ok(Some(true)); }
2037 }
2038 Ok(None)
2039 };
2040
2041 if let Some(c) = folded.root_candidates.iter()
2046 .find(|c| authorized.is_authorized(&c.author.to_hex(), owner.as_deref(), manage))
2047 {
2048 let head = &c.head;
2049 if let Some(is_converge) = decide(&head.entity_hex, head)? {
2050 let meta = &c.meta;
2051 current.name = meta.name.clone();
2060 current.description = meta.description.clone();
2061 current.icon = meta.icon.clone();
2062 current.banner = meta.banner.clone();
2063 dirty = true;
2064 head_updates.push((head.entity_hex.clone(), head.version, head.self_hash, head.inner_id, is_converge));
2065 }
2066 }
2067 let mut resolved_channels: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
2073 for cm in &folded.channel_candidates {
2074 if resolved_channels.contains(&cm.channel_id) {
2075 continue; }
2077 if !authorized.is_authorized(&cm.author.to_hex(), owner.as_deref(), manage_channels) {
2078 continue; }
2080 resolved_channels.insert(cm.channel_id);
2081 let Some(is_converge) = decide(&cm.head.entity_hex, &cm.head)? else { continue };
2082 if let Some(ch) = current.channels.iter_mut().find(|c| c.id.0 == cm.channel_id) {
2083 ch.name = cm.meta.name.clone();
2084 dirty = true;
2085 head_updates.push((cm.head.entity_hex.clone(), cm.head.version, cm.head.self_hash, cm.head.inner_id, is_converge));
2086 }
2087 }
2088
2089 if dirty && session.is_valid() {
2090 crate::db::community::save_community(¤t)?;
2091 for (entity_hex, version, self_hash, inner_id, is_converge) in &head_updates {
2094 if *is_converge {
2095 crate::db::community::converge_edition_head(&cid, entity_hex, *version, self_hash, inner_id)?;
2096 } else {
2097 crate::db::community::set_edition_head_with_id(&cid, entity_hex, *version, self_hash, inner_id)?;
2098 }
2099 }
2100 }
2101 Ok(())
2102}
2103
2104pub fn is_public(community: &Community) -> Result<bool, String> {
2112 Ok(!crate::db::community::get_community_invite_registry(&community.id.to_hex())?.is_empty())
2113}
2114
2115async fn republish_my_invite_links<T: Transport + ?Sized>(
2120 transport: &T,
2121 community: &Community,
2122) -> Result<Vec<String>, String> {
2123 let cid = community.id.to_hex();
2124 let now = std::time::SystemTime::now()
2125 .duration_since(std::time::UNIX_EPOCH)
2126 .map(|d| d.as_secs())
2127 .unwrap_or(0);
2128 let locators: Vec<String> = crate::db::community::list_public_invites(&cid)?
2129 .iter()
2130 .filter(|r| r.expires_at.map_or(true, |e| (e as u64) > now))
2131 .map(|r| public_invite::locator_hex(&crate::simd::hex::hex_to_bytes_32(&r.token)))
2132 .collect();
2133 publish_my_invite_links(transport, community, &locators).await?;
2134 Ok(locators)
2135}
2136
2137async fn observe_channel_activity<T: Transport + ?Sized>(
2143 transport: &T,
2144 community: &Community,
2145) -> Result<(), String> {
2146 let session = SessionGuard::capture();
2147 let my_pk = crate::state::my_public_key().ok_or("no local identity to observe channel activity")?;
2148 for channel in &community.channels {
2149 let events = super::send::fetch_channel_events(transport, community, channel)
2150 .await
2151 .unwrap_or_default();
2152 if !session.is_valid() {
2153 return Err("account changed during activity observation".to_string());
2154 }
2155 let outcomes = {
2156 let mut st = crate::state::STATE.lock().await;
2157 super::inbound::process_channel_batch(&mut st, &events, channel, &my_pk)
2158 };
2159 let ch_hex = channel.id.to_hex();
2160 for o in &outcomes {
2161 match o {
2162 super::inbound::IncomingEvent::NewMessage(m)
2163 | super::inbound::IncomingEvent::Updated { message: m, .. } => {
2164 let _ = crate::db::events::save_message(&ch_hex, m).await;
2165 }
2166 super::inbound::IncomingEvent::Presence { npub, joined, event_id, created_at, invited_by, invited_label } => {
2167 let et = if *joined {
2168 crate::stored_event::SystemEventType::MemberJoined
2169 } else {
2170 crate::stored_event::SystemEventType::MemberLeft
2171 };
2172 let note = invited_by.as_ref().map(|by| match invited_label {
2173 Some(l) if !l.is_empty() => format!("{by}|{l}"),
2174 _ => by.clone(),
2175 });
2176 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;
2177 }
2178 super::inbound::IncomingEvent::WebxdcPeer { npub, topic_id, node_addr, event_id, created_at } => {
2179 persist_webxdc_signal(&ch_hex, npub, topic_id, node_addr.as_deref(), event_id, *created_at).await;
2180 }
2181 _ => {}
2182 }
2183 }
2184 }
2185 Ok(())
2186}
2187
2188pub async fn sync_before_admin_write<T: Transport + ?Sized>(
2199 transport: &T,
2200 community: &Community,
2201 observe_activity: bool,
2202) -> Result<Community, String> {
2203 if catch_up_server_root(transport, community).await?.removed {
2205 return Err("you have been removed from this community".to_string());
2206 }
2207 let community = crate::db::community::load_community(&community.id)?
2208 .ok_or("community gone during admin sync")?;
2209 let cid = community.id.to_hex();
2210 let responded = fetch_and_apply_control_full(transport, &community).await.map(|n| n > 0).unwrap_or(false);
2219 let hold_local_heads = !crate::db::community::get_all_edition_heads_epoched(&cid)?.is_empty();
2220 if hold_local_heads && !responded {
2221 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());
2222 }
2223 let community = crate::db::community::load_community(&community.id)?
2224 .ok_or("community gone during admin sync")?;
2225 if observe_activity {
2228 let _ = observe_channel_activity(transport, &community).await;
2229 }
2230 crate::db::community::load_community(&community.id)?.ok_or("community gone during admin sync".to_string())
2231}
2232
2233async fn run_read_cut<T: Transport + ?Sized>(
2243 transport: &T,
2244 community: &Community,
2245 fresh: bool,
2246) -> Result<(), String> {
2247 let cid = community.id.to_hex();
2248 let session = SessionGuard::capture();
2249 if fresh {
2250 let base = crate::db::community::load_community(&community.id)?
2253 .map(|c| c.server_root_epoch.0)
2254 .unwrap_or(community.server_root_epoch.0);
2255 crate::db::community::set_read_cut_target_epoch(&cid, base.saturating_add(1))?;
2256 }
2257 crate::db::community::set_read_cut_pending(&cid, true)?;
2258 reseal_base_to_observed(transport, community).await?;
2259 if session.is_valid() {
2260 crate::db::community::set_read_cut_pending(&cid, false)?;
2261 }
2262 Ok(())
2263}
2264
2265async fn reseal_base_to_observed<T: Transport + ?Sized>(
2275 transport: &T,
2276 community: &Community,
2277) -> Result<(), String> {
2278 let session = SessionGuard::capture();
2279 let cid = community.id.to_hex();
2280 let community = &sync_before_admin_write(transport, community, true).await?;
2285 let participants: Vec<nostr_sdk::PublicKey> = crate::db::community::community_member_activity(&cid)?
2289 .into_iter()
2290 .filter_map(|(npub, _)| nostr_sdk::PublicKey::parse(&npub).ok())
2291 .collect();
2292 let target = crate::db::community::get_read_cut_target_epoch(&cid)?;
2302 if community.server_root_epoch.0 < target {
2303 rotate_server_root(transport, community, &participants).await?;
2304 if !session.is_valid() {
2305 return Err("account changed during re-founding".to_string());
2306 }
2307 }
2308 let community = crate::db::community::load_community(&community.id)?
2314 .ok_or("community gone after base rotation")?;
2315 let cut_epoch = community.server_root_epoch.0;
2316 let prior_root = crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, cut_epoch.saturating_sub(1))?
2322 .unwrap_or(*community.server_root_key.as_bytes()); for channel in &community.channels {
2324 let ch_hex = channel.id.to_hex();
2325 if crate::db::community::channel_rekeyed_at_server_epoch(&cid, &ch_hex)? >= cut_epoch {
2328 continue;
2329 }
2330 rotate_channel(transport, &community, &channel.id, &participants, &prior_root).await?;
2331 if !session.is_valid() {
2332 return Err("account changed during re-founding".to_string());
2333 }
2334 crate::db::community::mark_channel_rekeyed_at_server_epoch(&cid, &ch_hex, cut_epoch)?;
2335 }
2336 Ok(())
2337}
2338
2339#[derive(Debug, PartialEq, Eq)]
2341pub enum RekeyOutcome {
2342 Applied { head_advanced: bool },
2345 NotARecipient,
2348}
2349
2350pub fn apply_channel_rekey(
2361 community: &Community,
2362 parsed: &super::rekey::ParsedRekey,
2363) -> Result<RekeyOutcome, String> {
2364 let session = SessionGuard::capture();
2368
2369 let channel_id = match parsed.scope {
2371 super::derive::RekeyScope::Channel(c) => c,
2372 super::derive::RekeyScope::ServerRoot => {
2373 return Err("server-root rotation uses apply_server_root_rekey, not the channel path".to_string())
2374 }
2375 };
2376 if !community.channels.iter().any(|c| c.id == channel_id) {
2377 return Err("rekey targets a channel not in this community".to_string());
2378 }
2379 let cid = community.id.to_hex();
2380 let channel_hex = channel_id.to_hex();
2381
2382 let owner = proven_owner_hex(community);
2389 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_else(|e| {
2390 crate::log_warn!("rekey apply: roster read failed ({e}); authorizing owner only");
2393 Default::default()
2394 });
2395 if !roster.is_authorized(
2396 &parsed.rotator.to_hex(),
2397 owner.as_deref(),
2398 super::roles::Permissions::MANAGE_CHANNELS,
2399 ) {
2400 return Err("rekey rotator lacks MANAGE_CHANNELS authority".to_string());
2401 }
2402
2403 if let Some(prev_key) = crate::db::community::held_epoch_key(&cid, &channel_hex, parsed.prev_epoch.0)? {
2413 if super::rekey::epoch_key_commitment(parsed.prev_epoch, &prev_key) != parsed.prev_key_commitment {
2414 crate::log_warn!(
2415 "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",
2416 parsed.new_epoch.0, parsed.prev_epoch.0
2417 );
2418 }
2419 }
2420
2421 let my_keys = crate::state::MY_SECRET_KEY
2423 .to_keys()
2424 .ok_or("no local identity to open the rekey blob")?;
2425 let secret = super::rekey::rekey_pairwise_secret(my_keys.secret_key(), &parsed.rotator)?;
2426 let my_locator = super::derive::recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
2427 let mine = match parsed.blobs.iter().find(|b| b.locator == my_locator) {
2428 Some(b) => b,
2429 None => return Ok(RekeyOutcome::NotARecipient),
2430 };
2431 let new_key =
2432 super::rekey::open_rekey_blob(my_keys.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine)?;
2433
2434 if !session.is_valid() {
2436 return Err("session changed during rekey apply".to_string());
2437 }
2438 let head_advanced =
2439 crate::db::community::advance_channel_epoch(&cid, &channel_hex, parsed.new_epoch.0, &new_key)?;
2440 Ok(RekeyOutcome::Applied { head_advanced })
2441}
2442
2443fn mint_or_reuse_rotation_key(cid: &str, scope_id: &str, epoch: u64) -> Result<zeroize::Zeroizing<[u8; 32]>, String> {
2449 if let Some(k) = crate::db::community::held_epoch_key(cid, scope_id, epoch)? {
2450 return Ok(zeroize::Zeroizing::new(k));
2451 }
2452 let k = zeroize::Zeroizing::new(super::random_32());
2453 crate::db::community::store_epoch_key(cid, scope_id, epoch, &k)?;
2454 Ok(k)
2455}
2456
2457async fn publish_rekey_chunked<T, F>(
2465 transport: &T,
2466 relays: &[String],
2467 blobs: &[super::rekey::RekeyBlob],
2468 build: F,
2469) -> Result<(), String>
2470where
2471 T: Transport + ?Sized,
2472 F: Fn(&[super::rekey::RekeyBlob]) -> Result<Event, String>,
2473{
2474 if blobs.is_empty() {
2475 return Err("rekey has no recipients".to_string());
2476 }
2477 for chunk in blobs.chunks(super::rekey::MAX_REKEY_BLOBS) {
2478 let event = build(chunk)?;
2479 transport.publish_durable(&event, relays).await?;
2480 }
2481 Ok(())
2482}
2483
2484pub async fn rotate_channel<T: Transport + ?Sized>(
2496 transport: &T,
2497 community: &Community,
2498 channel_id: &super::ChannelId,
2499 recipients: &[nostr_sdk::PublicKey],
2500 envelope_root: &[u8; 32],
2506) -> Result<u64, String> {
2507 let session = SessionGuard::capture();
2508 let cid = community.id.to_hex();
2509
2510 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)")?;
2514 let owner = proven_owner_hex(community);
2515 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
2516 if !roster.is_authorized(&my_keys.public_key().to_hex(), owner.as_deref(), super::roles::Permissions::MANAGE_CHANNELS) {
2517 return Err("not authorized to rotate this channel (no MANAGE_CHANNELS)".to_string());
2518 }
2519
2520 let channel = community
2524 .channels
2525 .iter()
2526 .find(|c| &c.id == channel_id)
2527 .ok_or("channel not found in community")?;
2528 let prev_epoch = channel.epoch;
2529 let new_epoch = super::Epoch(prev_epoch.0.checked_add(1).ok_or("channel epoch overflow")?);
2530 let prev_commit = super::rekey::epoch_key_commitment(prev_epoch, channel.key.as_bytes());
2531 let new_key = mint_or_reuse_rotation_key(&cid, &channel_id.to_hex(), new_epoch.0)?;
2534
2535 let mut seen = std::collections::HashSet::new();
2539 let mut blobs = Vec::new();
2540 for pk in recipients.iter().chain(std::iter::once(&my_keys.public_key())) {
2541 if !seen.insert(pk.to_hex()) {
2542 continue;
2543 }
2544 blobs.push(super::rekey::build_rekey_blob(
2545 my_keys.secret_key(), pk, super::derive::RekeyScope::Channel(*channel_id), new_epoch, &new_key,
2546 )?);
2547 }
2548
2549 publish_rekey_chunked(transport, &community.relays, &blobs, |chunk| {
2551 super::rekey::build_channel_rekey_event(
2552 &Keys::generate(), &my_keys, envelope_root, channel_id,
2553 new_epoch, prev_epoch, &prev_commit, chunk,
2554 )
2555 })
2556 .await?;
2557 if !session.is_valid() {
2558 return Err("session changed during channel rotation".to_string());
2559 }
2560 crate::db::community::advance_channel_epoch(&cid, &channel_id.to_hex(), new_epoch.0, &new_key)?;
2561 Ok(new_epoch.0)
2562}
2563
2564fn emit_rekey_progress(label: &str, pct: u8) {
2568 crate::emit_event("community_rekey_progress", &serde_json::json!({ "label": label, "pct": pct }));
2569}
2570
2571pub(crate) async fn rotate_server_root<T: Transport + ?Sized>(
2587 transport: &T,
2588 community: &Community,
2589 recipients: &[nostr_sdk::PublicKey],
2590) -> Result<u64, String> {
2591 let session = SessionGuard::capture();
2592 let cid = community.id.to_hex();
2593
2594 if crate::db::community::get_community_dissolved(&cid)? {
2596 return Err("community is dissolved; it cannot be re-founded".to_string());
2597 }
2598
2599 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)")?;
2610 let owner = proven_owner_hex(community);
2611 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
2612 if !roster.is_authorized(&my_keys.public_key().to_hex(), owner.as_deref(), super::roles::Permissions::BAN) {
2613 return Err("not authorized to rotate the server root (no BAN)".to_string());
2614 }
2615
2616 let fresh = crate::db::community::load_community(&community.id)?
2622 .ok_or("community gone before base rotation")?;
2623 let community = &fresh;
2624 let prev_epoch = community.server_root_epoch;
2625 let new_epoch = super::Epoch(prev_epoch.0.checked_add(1).ok_or("server-root epoch overflow")?);
2626 let prev_commit = super::rekey::epoch_key_commitment(prev_epoch, community.server_root_key.as_bytes());
2628 let new_root = mint_or_reuse_rotation_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch.0)?;
2630 emit_rekey_progress("Rerolling community keys...", 5);
2631
2632 let sealed = prepare_reanchor_control_plane(transport, community, &new_root, new_epoch).await?;
2638 if !session.is_valid() {
2639 return Err("session changed during re-founding acquire".to_string());
2640 }
2641
2642 let total_recipients = (recipients.len() + 1).max(1); let mut seen = std::collections::HashSet::new();
2644 let mut blobs = Vec::new();
2645 for pk in recipients.iter().chain(std::iter::once(&my_keys.public_key())) {
2646 if !seen.insert(pk.to_hex()) {
2647 continue;
2648 }
2649 blobs.push(super::rekey::build_rekey_blob(
2650 my_keys.secret_key(), pk, super::derive::RekeyScope::ServerRoot, new_epoch, &new_root,
2651 )?);
2652 emit_rekey_progress(
2653 &format!("Preparing keys for members ({}/{})...", blobs.len(), total_recipients),
2654 (5 + 35 * blobs.len() / total_recipients) as u8,
2655 );
2656 }
2657
2658 emit_rekey_progress("Sending keys to members...", 42);
2662 publish_rekey_chunked(transport, &community.relays, &blobs, |chunk| {
2663 super::rekey::build_server_root_rekey_event(
2664 &Keys::generate(), &my_keys, community.server_root_key.as_bytes(), &community.id,
2665 new_epoch, prev_epoch, &prev_commit, chunk,
2666 )
2667 })
2668 .await?;
2669
2670 let snapshot = publish_reanchor_snapshot(transport, &community.relays, sealed).await?;
2678 if snapshot.iter().any(|e| !e.published) {
2679 return Err(
2680 "re-founding aborted: a snapshot edition did not land (rate-limited / unreachable relay?); base head NOT advanced".to_string()
2681 );
2682 }
2683 if !session.is_valid() {
2684 return Err("session changed during server-root rotation".to_string());
2685 }
2686 emit_rekey_progress("Finalizing...", 98);
2687 crate::db::community::advance_server_root_epoch(&cid, new_epoch.0, &new_root)?;
2689 for e in &snapshot {
2693 crate::db::community::set_edition_head_with_id(&cid, &e.entity_hex, e.version, &e.self_hash, &e.inner_id)?;
2694 }
2695 Ok(new_epoch.0)
2696}
2697
2698pub(crate) struct SnapshotEntry {
2733 pub entity_hex: String,
2734 pub version: u64,
2735 pub self_hash: [u8; 32],
2736 pub inner_id: [u8; 32],
2737 pub published: bool,
2738}
2739
2740pub(crate) async fn prepare_reanchor_control_plane<T: Transport + ?Sized>(
2746 transport: &T,
2747 community: &Community,
2748 new_root: &[u8; 32],
2749 new_epoch: super::Epoch,
2750) -> Result<Vec<(Event, SnapshotEntry)>, String> {
2751 let session = SessionGuard::capture();
2752 let cid = community.id.to_hex();
2753
2754 let z = super::roster::control_pseudonym(&community.server_root_key, &community.id, community.server_root_epoch);
2762 let query = Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], evidence: Evidence::Full, ..Default::default() };
2766 let outers = transport.fetch(&query, &community.relays).await?;
2767 if !session.is_valid() {
2768 return Err("session changed during re-founding fetch".to_string());
2769 }
2770 let mut by_hash: std::collections::HashMap<[u8; 32], (Event, [u8; 32])> = std::collections::HashMap::new();
2772 for outer in &outers {
2773 if let Ok(inner) = super::roster::open_control_edition(outer, &community.server_root_key) {
2774 if let Ok(parsed) = super::edition::parse_edition_inner(&inner) {
2775 by_hash.insert(parsed.self_hash, (inner, parsed.inner_id));
2776 }
2777 }
2778 }
2779
2780 let new_root_key = super::ServerRootKey(*new_root);
2784 let mut sealed: Vec<(Event, SnapshotEntry)> = Vec::new();
2785 for (entity_hex, (epoch, version, self_hash)) in crate::db::community::get_all_edition_heads_epoched(&cid)? {
2786 if epoch != community.server_root_epoch.0 {
2787 continue; }
2789 let (inner, inner_id) = by_hash.get(&self_hash).ok_or_else(|| {
2790 format!("re-founding aborted: head edition for entity {entity_hex} (v{version}) not fetchable — aborting so no member is stranded")
2791 })?;
2792 let outer = super::roster::seal_control_edition(&Keys::generate(), inner, &new_root_key, &community.id, new_epoch)?;
2793 sealed.push((outer, SnapshotEntry { entity_hex, version, self_hash, inner_id: *inner_id, published: false }));
2794 }
2795 Ok(sealed)
2796}
2797
2798pub(crate) async fn publish_reanchor_snapshot<T: Transport + ?Sized>(
2802 transport: &T,
2803 relays: &[String],
2804 sealed: Vec<(Event, SnapshotEntry)>,
2805) -> Result<Vec<SnapshotEntry>, String> {
2806 use futures_util::stream::StreamExt;
2809 let total = sealed.len().max(1);
2810 let done = std::sync::atomic::AtomicUsize::new(0);
2811 let done_ref = &done;
2812 emit_rekey_progress(&format!("Re-founding community (0/{total})..."), 50);
2813 let out: Vec<SnapshotEntry> = futures_util::stream::iter(sealed.into_iter().map(|(ev, mut entry)| async move {
2814 entry.published = transport.publish_durable(&ev, relays).await.is_ok();
2815 let n = done_ref.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
2816 emit_rekey_progress(&format!("Re-founding community ({n}/{total})..."), (50 + 45 * n / total) as u8);
2817 entry
2818 }))
2819 .buffer_unordered(4)
2820 .collect()
2821 .await;
2822 Ok(out)
2823}
2824
2825#[cfg(test)]
2828pub(crate) async fn reanchor_control_plane<T: Transport + ?Sized>(
2829 transport: &T,
2830 community: &Community,
2831 new_root: &[u8; 32],
2832 new_epoch: super::Epoch,
2833) -> Result<Vec<SnapshotEntry>, String> {
2834 let sealed = prepare_reanchor_control_plane(transport, community, new_root, new_epoch).await?;
2835 publish_reanchor_snapshot(transport, &community.relays, sealed).await
2836}
2837
2838pub fn apply_server_root_rekey(
2846 community: &Community,
2847 parsed: &super::rekey::ParsedRekey,
2848) -> Result<RekeyOutcome, String> {
2849 let session = SessionGuard::capture();
2850
2851 if !matches!(parsed.scope, super::derive::RekeyScope::ServerRoot) {
2853 return Err("not a server-root rekey (channel rekeys use apply_channel_rekey)".to_string());
2854 }
2855 let cid = community.id.to_hex();
2856
2857 if crate::db::community::get_community_dissolved(&cid)? {
2860 return Err("community is dissolved; base epoch cannot advance".to_string());
2861 }
2862
2863 let owner = proven_owner_hex(community);
2870 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_else(|e| {
2871 crate::log_warn!("base rekey apply: roster read failed ({e}); authorizing owner only");
2872 Default::default()
2873 });
2874 if !rotator_is_authorized(&cid, &roster, owner.as_deref(), &parsed.rotator.to_hex(), super::roles::Permissions::BAN) {
2875 return Err("base rekey rotator lacks server-wide rotation authority (BAN)".to_string());
2876 }
2877
2878 if let Some(prev_root) =
2885 crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, parsed.prev_epoch.0)?
2886 {
2887 if super::rekey::epoch_key_commitment(parsed.prev_epoch, &prev_root) != parsed.prev_key_commitment {
2888 crate::log_warn!(
2889 "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",
2890 parsed.new_epoch.0, parsed.prev_epoch.0
2891 );
2892 }
2893 }
2894
2895 let my_keys = crate::state::MY_SECRET_KEY.to_keys().ok_or("no local identity to open the base rekey blob")?;
2897 let secret = super::rekey::rekey_pairwise_secret(my_keys.secret_key(), &parsed.rotator)?;
2898 let my_locator = super::derive::recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
2899 let mine = match parsed.blobs.iter().find(|b| b.locator == my_locator) {
2900 Some(b) => b,
2901 None => return Ok(RekeyOutcome::NotARecipient),
2902 };
2903 let new_root =
2904 super::rekey::open_rekey_blob(my_keys.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine)?;
2905
2906 if !session.is_valid() {
2907 return Err("session changed during base rekey apply".to_string());
2908 }
2909 let head_advanced = crate::db::community::advance_server_root_epoch(&cid, parsed.new_epoch.0, &new_root)?;
2910 Ok(RekeyOutcome::Applied { head_advanced })
2911}
2912
2913const REKEY_CATCHUP_WINDOW: u64 = 64;
2917const MAX_REKEY_CATCHUP_ROUNDS: usize = 64;
2920
2921async fn heal_channel_fork_epochs<T: Transport + ?Sized>(
2933 transport: &T,
2934 community: &Community,
2935 channel_id: &super::ChannelId,
2936 cid: &str,
2937 channel_hex: &str,
2938 epochs: &std::collections::BTreeSet<u64>,
2939 server_roots: &[[u8; 32]],
2940 session: &SessionGuard,
2941) -> Result<(), String> {
2942 if epochs.is_empty() {
2943 return Ok(());
2944 }
2945 let owner_hex = proven_owner_hex(community);
2946 let roster = crate::db::community::get_community_roles(cid).unwrap_or_default();
2947 let mut winner: std::collections::BTreeMap<u64, [u8; 32]> = std::collections::BTreeMap::new();
2950 for sr in server_roots {
2951 let z_tags: Vec<String> = epochs
2952 .iter()
2953 .map(|e| super::derive::rekey_pseudonym(&super::ServerRootKey(*sr), channel_id, super::Epoch(*e)).to_hex())
2954 .collect();
2955 let q = Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags, ..Default::default() };
2956 for ev in transport.fetch(&q, &community.relays).await.unwrap_or_default() {
2957 let Ok(p) = super::rekey::open_rekey_event(&ev, sr) else { continue };
2958 if !matches!(p.scope, super::derive::RekeyScope::Channel(c) if &c == channel_id) || !epochs.contains(&p.new_epoch.0) {
2959 continue;
2960 }
2961 if !rotator_is_authorized(cid, &roster, owner_hex.as_deref(), &p.rotator.to_hex(), super::roles::Permissions::MANAGE_CHANNELS) {
2962 continue;
2963 }
2964 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);
2966 }
2967 }
2968 for (epoch, win_key) in winner {
2969 if !session.is_valid() {
2970 return Err("session changed during channel convergence".to_string());
2971 }
2972 if let Ok(Some(cur)) = crate::db::community::held_epoch_key(cid, channel_hex, epoch) {
2977 if win_key < cur {
2978 match crate::db::community::converge_channel_epoch(cid, channel_hex, epoch, &win_key) {
2981 Ok(false) => crate::log_trace!("channel heal: converge of epoch {epoch} did not apply (head moved)"),
2982 Err(e) => crate::log_trace!("channel heal: converge of epoch {epoch} errored: {e}"),
2983 Ok(true) => {}
2984 }
2985 }
2986 }
2987 }
2988 Ok(())
2989}
2990
2991pub async fn catch_up_channel_rekeys<T: Transport + ?Sized>(
3006 transport: &T,
3007 community: &Community,
3008 channel_id: &super::ChannelId,
3009) -> Result<u64, String> {
3010 let session = SessionGuard::capture();
3011 let server_root = community.server_root_key.as_bytes();
3012 let cid = community.id.to_hex();
3013 let channel_hex = channel_id.to_hex();
3014 let mut server_roots: Vec<[u8; 32]> = crate::db::community::held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX)
3020 .unwrap_or_default()
3021 .into_iter()
3022 .map(|(_, k)| k)
3023 .collect();
3024 if !server_roots.iter().any(|r| r == server_root) {
3025 server_roots.push(*server_root); }
3027 let mut head = community
3028 .channels
3029 .iter()
3030 .find(|c| &c.id == channel_id)
3031 .ok_or("channel not found in community")?
3032 .epoch
3033 .0;
3034
3035 let mut forked_epochs: std::collections::BTreeSet<u64> = std::collections::BTreeSet::new();
3039
3040 for _round in 0..MAX_REKEY_CATCHUP_ROUNDS {
3041 let window_top = head.saturating_add(REKEY_CATCHUP_WINDOW);
3042 let mut parsed: Vec<super::rekey::ParsedRekey> = Vec::new();
3046 for sr in &server_roots {
3047 let z_tags: Vec<String> = (head.saturating_add(1)..=window_top)
3048 .map(|e| super::derive::rekey_pseudonym(&super::ServerRootKey(*sr), channel_id, super::Epoch(e)).to_hex())
3049 .collect();
3050 let query = Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags, ..Default::default() };
3051 for ev in transport.fetch(&query, &community.relays).await.unwrap_or_default() {
3056 if let Ok(p) = super::rekey::open_rekey_event(&ev, sr) {
3057 if matches!(p.scope, super::derive::RekeyScope::Channel(c) if &c == channel_id) {
3058 parsed.push(p);
3059 }
3060 }
3061 }
3062 }
3063 if parsed.is_empty() {
3064 break; }
3066 parsed.sort_by_key(|p| p.new_epoch.0);
3068 let max_found = parsed.last().map(|p| p.new_epoch.0).unwrap_or(head);
3069
3070 let head_before = head;
3071 let mut removed = false;
3072 let mut by_epoch: std::collections::BTreeMap<u64, Vec<&super::rekey::ParsedRekey>> = std::collections::BTreeMap::new();
3077 for p in &parsed {
3078 by_epoch.entry(p.new_epoch.0).or_default().push(p);
3079 }
3080 for (e, chunks) in by_epoch {
3081 if !session.is_valid() {
3082 return Err("session changed during rekey catch-up".to_string());
3083 }
3084 let mut applied = false;
3085 let mut saw_not_recipient = false;
3086 for p in &chunks {
3087 match apply_channel_rekey(community, p) {
3088 Ok(RekeyOutcome::Applied { .. }) => {
3089 applied = true;
3090 break;
3091 }
3092 Ok(RekeyOutcome::NotARecipient) => saw_not_recipient = true,
3093 Err(err) => crate::log_warn!("rekey catch-up: skipping epoch {e} chunk: {err}"),
3094 }
3095 }
3096 if applied {
3097 if let Some(p) = chunks.first() {
3103 let pe = p.prev_epoch.0;
3104 if let Ok(Some(prev_key)) = crate::db::community::held_epoch_key(&cid, &channel_hex, pe) {
3105 if super::rekey::epoch_key_commitment(p.prev_epoch, &prev_key) != p.prev_key_commitment {
3106 forked_epochs.insert(pe);
3107 }
3108 }
3109 }
3110 if e > head + 1 {
3113 crate::log_warn!(
3114 "rekey catch-up: channel epochs {}..={} not recovered (key gap; history unreadable until re-fetched)",
3115 head + 1, e - 1
3116 );
3117 }
3118 head = head.max(e);
3119 } else if saw_not_recipient {
3120 removed = true;
3123 break;
3124 }
3125 }
3127
3128 if removed || head == head_before || max_found < window_top {
3131 break;
3132 }
3133 }
3134
3135 let held: std::collections::HashSet<u64> = crate::db::community::held_epoch_keys(&cid, &channel_hex)
3142 .unwrap_or_default()
3143 .into_iter()
3144 .map(|(e, _)| e.0)
3145 .collect();
3146 let missing: Vec<u64> = (0..head).filter(|e| !held.contains(e)).collect();
3147 if !missing.is_empty() {
3148 for sr in &server_roots {
3149 if !session.is_valid() {
3150 return Err("session changed during rekey gap-fill".to_string());
3151 }
3152 let z_tags: Vec<String> = missing
3153 .iter()
3154 .map(|e| super::derive::rekey_pseudonym(&super::ServerRootKey(*sr), channel_id, super::Epoch(*e)).to_hex())
3155 .collect();
3156 let query = Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags, ..Default::default() };
3157 for ev in transport.fetch(&query, &community.relays).await.unwrap_or_default() {
3160 if let Ok(p) = super::rekey::open_rekey_event(&ev, sr) {
3161 if matches!(p.scope, super::derive::RekeyScope::Channel(c) if &c == channel_id) {
3162 let _ = apply_channel_rekey(community, &p); }
3164 }
3165 }
3166 }
3167 }
3168
3169 if head > 0 && session.is_valid() {
3176 let lo = head.saturating_sub(REKEY_CATCHUP_WINDOW).max(1);
3177 let mut epochs: std::collections::BTreeSet<u64> = (lo..=head).collect();
3178 epochs.append(&mut forked_epochs);
3179 let _ = heal_channel_fork_epochs(transport, community, channel_id, &cid, &channel_hex, &epochs, &server_roots, &session).await;
3180 }
3181 Ok(head)
3182}
3183
3184const MAX_BASE_CATCHUP_STEPS: usize = 256;
3187
3188fn peek_my_server_root(parsed: &super::rekey::ParsedRekey) -> Result<Option<[u8; 32]>, String> {
3202 if !matches!(parsed.scope, super::derive::RekeyScope::ServerRoot) {
3203 return Ok(None);
3204 }
3205 let my_keys = crate::state::MY_SECRET_KEY.to_keys().ok_or("no local key to open a base rekey blob")?;
3206 let secret = super::rekey::rekey_pairwise_secret(my_keys.secret_key(), &parsed.rotator)?;
3207 let my_locator = super::derive::recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
3208 let mine = match parsed.blobs.iter().find(|b| b.locator == my_locator) {
3209 Some(b) => b,
3210 None => return Ok(None),
3211 };
3212 super::rekey::open_rekey_blob(my_keys.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine).map(Some)
3213}
3214
3215fn peek_my_channel_key(parsed: &super::rekey::ParsedRekey) -> Option<[u8; 32]> {
3219 let my_keys = crate::state::MY_SECRET_KEY.to_keys()?;
3220 let secret = super::rekey::rekey_pairwise_secret(my_keys.secret_key(), &parsed.rotator).ok()?;
3221 let my_locator = super::derive::recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
3222 let mine = parsed.blobs.iter().find(|b| b.locator == my_locator)?;
3223 super::rekey::open_rekey_blob(my_keys.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine).ok()
3224}
3225
3226pub async fn catch_up_server_root<T: Transport + ?Sized>(
3227 transport: &T,
3228 community: &Community,
3229) -> Result<BaseCatchup, String> {
3230 let session = SessionGuard::capture();
3231 let cid = community.id.to_hex();
3232 let mut head = community.server_root_epoch.0;
3233 let mut removed = false;
3238 let mut current_root: [u8; 32] = *community.server_root_key.as_bytes();
3240
3241 for _step in 0..MAX_BASE_CATCHUP_STEPS {
3242 let next = match head.checked_add(1) {
3243 Some(n) => n,
3244 None => break,
3245 };
3246 let addr = super::derive::base_rekey_pseudonym(&super::ServerRootKey(current_root), &community.id, super::Epoch(next)).to_hex();
3247 let query = Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() };
3248 let events = transport.fetch(&query, &community.relays).await?;
3249 if events.is_empty() {
3250 break; }
3252
3253 let chunks: Vec<super::rekey::ParsedRekey> = events
3256 .iter()
3257 .filter_map(|ev| super::rekey::open_rekey_event(ev, ¤t_root).ok())
3258 .filter(|p| matches!(p.scope, super::derive::RekeyScope::ServerRoot) && p.new_epoch.0 == next)
3259 .collect();
3260 if chunks.is_empty() {
3261 break; }
3263
3264 if !session.is_valid() {
3265 return Err("session changed during base rekey catch-up".to_string());
3266 }
3267
3268 let owner_hex = proven_owner_hex(community);
3282 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
3283 let mut candidates: Vec<(&super::rekey::ParsedRekey, [u8; 32])> = Vec::new();
3284 for parsed in &chunks {
3285 if !rotator_is_authorized(&cid, &roster, owner_hex.as_deref(), &parsed.rotator.to_hex(), super::roles::Permissions::BAN) {
3286 continue;
3287 }
3288 match peek_my_server_root(parsed) {
3289 Ok(Some(root)) => candidates.push((parsed, root)),
3290 Ok(None) => {}
3291 Err(err) => crate::log_warn!("base rekey catch-up: epoch {next} peek: {err}"),
3292 }
3293 }
3294 let applied = match candidates.into_iter().min_by(|a, b| a.1.cmp(&b.1)) {
3295 Some((parsed, _)) => match apply_server_root_rekey(community, parsed) {
3296 Ok(RekeyOutcome::Applied { .. }) => true,
3297 Ok(RekeyOutcome::NotARecipient) => false, Err(err) => { crate::log_warn!("base rekey catch-up: epoch {next} apply: {err}"); false }
3299 },
3300 None => {
3301 let owner = proven_owner_hex(community);
3306 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
3307 if chunks.iter().any(|p| rotator_is_authorized(&cid, &roster, owner.as_deref(), &p.rotator.to_hex(), super::roles::Permissions::BAN)) {
3308 removed = true;
3309 }
3310 false }
3312 };
3313 if !applied {
3314 break;
3315 }
3316 match crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, next)? {
3318 Some(root) => {
3319 current_root = root;
3320 head = next;
3321 }
3322 None => {
3323 crate::log_warn!("base rekey catch-up: epoch {next} applied but its root is not archived; halting walk");
3326 break;
3327 }
3328 }
3329 }
3330
3331 if head > 0 && !removed {
3338 if let Ok(Some(prior_root)) = crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, head - 1) {
3339 let addr = super::derive::base_rekey_pseudonym(&super::ServerRootKey(prior_root), &community.id, super::Epoch(head)).to_hex();
3340 let query = Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() };
3341 let events = transport.fetch(&query, &community.relays).await.unwrap_or_default();
3342 let chunks: Vec<super::rekey::ParsedRekey> = events
3343 .iter()
3344 .filter_map(|ev| super::rekey::open_rekey_event(ev, &prior_root).ok())
3345 .filter(|p| matches!(p.scope, super::derive::RekeyScope::ServerRoot) && p.new_epoch.0 == head)
3346 .collect();
3347 let owner_hex = proven_owner_hex(community);
3348 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
3349 let mut best: Option<(&super::rekey::ParsedRekey, [u8; 32])> = None;
3350 for p in &chunks {
3351 if !rotator_is_authorized(&cid, &roster, owner_hex.as_deref(), &p.rotator.to_hex(), super::roles::Permissions::BAN) {
3355 continue;
3356 }
3357 if let Ok(Some(root)) = peek_my_server_root(p) {
3358 if best.as_ref().map_or(true, |(_, br)| root < *br) {
3359 best = Some((p, root));
3360 }
3361 }
3362 }
3363 let current_deauthorized = chunks.iter().any(|p| {
3373 matches!(peek_my_server_root(p), Ok(Some(r)) if r == current_root)
3374 && !rotator_is_authorized(&cid, &roster, owner_hex.as_deref(), &p.rotator.to_hex(), super::roles::Permissions::BAN)
3375 });
3376 if let Some((winner, win_root)) = best {
3377 let adopt = if current_deauthorized {
3378 win_root != current_root
3379 } else {
3380 win_root < current_root
3381 };
3382 if adopt {
3383 if !session.is_valid() {
3384 return Err("session changed during base convergence".to_string());
3385 }
3386 if apply_server_root_rekey(community, winner).is_ok() {
3389 match crate::db::community::converge_server_root_epoch(&cid, head, &win_root) {
3390 Ok(false) => crate::log_trace!("base heal: converge of epoch {head} did not apply (head moved)"),
3391 Err(e) => crate::log_trace!("base heal: converge of epoch {head} errored: {e}"),
3392 Ok(true) => {}
3393 }
3394 current_root = win_root;
3395 if let Ok(Some(fresh)) = crate::db::community::load_community(&community.id) {
3396 let _ = fetch_and_apply_control(transport, &fresh).await;
3397 }
3398 }
3399 }
3400 }
3401 }
3402 }
3403 let _ = current_root; Ok(BaseCatchup { epoch: head, removed })
3405}
3406
3407#[derive(Debug, Clone, Copy)]
3411pub struct BaseCatchup {
3412 pub epoch: u64,
3413 pub removed: bool,
3414}
3415
3416#[cfg(test)]
3417mod tests {
3418 use super::*;
3419 use crate::community::send::fetch_channel_messages;
3420 use crate::community::transport::{memory::MemoryRelay, Query, Transport};
3421 use nostr_sdk::prelude::{EventBuilder, Kind};
3422
3423 struct FailingRelay;
3426 #[async_trait::async_trait]
3427 impl Transport for FailingRelay {
3428 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
3429 async fn publish(&self, _event: &Event, _relays: &[String]) -> Result<(), String> {
3430 Err("relay unreachable".to_string())
3431 }
3432 async fn publish_durable(&self, _event: &Event, _relays: &[String]) -> Result<(), String> {
3433 Err("relay unreachable".to_string())
3434 }
3435 async fn fetch(&self, _query: &Query, _relays: &[String]) -> Result<Vec<Event>, String> {
3436 Ok(Vec::new())
3437 }
3438 }
3439
3440 struct RekeyFailingRelay {
3444 inner: MemoryRelay,
3445 fail_rekey: std::sync::atomic::AtomicBool,
3446 }
3447 impl RekeyFailingRelay {
3448 fn new() -> Self {
3449 Self { inner: MemoryRelay::new(), fail_rekey: std::sync::atomic::AtomicBool::new(true) }
3450 }
3451 fn allow_rekey(&self) {
3452 self.fail_rekey.store(false, std::sync::atomic::Ordering::Relaxed);
3453 }
3454 fn blocks(&self, event: &Event) -> bool {
3455 self.fail_rekey.load(std::sync::atomic::Ordering::Relaxed)
3456 && event.kind.as_u16() == crate::stored_event::event_kind::COMMUNITY_REKEY
3457 }
3458 }
3459 #[async_trait::async_trait]
3460 impl Transport for RekeyFailingRelay {
3461 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
3462 async fn publish(&self, event: &Event, relays: &[String]) -> Result<(), String> {
3463 if self.blocks(event) { return Err("rekey relay down".to_string()); }
3464 self.inner.publish(event, relays).await
3465 }
3466 async fn publish_durable(&self, event: &Event, relays: &[String]) -> Result<(), String> {
3467 if self.blocks(event) { return Err("rekey relay down".to_string()); }
3468 self.inner.publish_durable(event, relays).await
3469 }
3470 async fn fetch(&self, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
3471 self.inner.fetch(query, relays).await
3472 }
3473 }
3474
3475 static TEST_COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(5000);
3476
3477 fn make_test_npub(n: u32) -> String {
3478 const BECH32: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
3479 let mut payload = vec![b'q'; 58];
3480 let mut x = n as u64;
3481 let mut i = 58;
3482 while x > 0 && i > 0 {
3483 i -= 1;
3484 payload[i] = BECH32[(x as usize) % 32];
3485 x /= 32;
3486 }
3487 format!("npub1{}", std::str::from_utf8(&payload).unwrap())
3488 }
3489
3490 fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
3491 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
3492 crate::db::close_database();
3493 crate::db::clear_id_caches();
3496 let tmp = tempfile::tempdir().unwrap();
3497 let n = TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3498 let account = make_test_npub(n);
3499 std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
3500 crate::db::set_app_data_dir(tmp.path().to_path_buf());
3501 crate::db::set_current_account(account.clone()).unwrap();
3502 crate::db::init_database(&account).unwrap();
3503 let _ = crate::state::take_nostr_client();
3506 let owner = Keys::generate();
3508 crate::state::MY_SECRET_KEY.store_from_keys(&owner, &[]);
3509 crate::state::set_my_public_key(owner.public_key());
3510 (tmp, guard)
3511 }
3512
3513 #[test]
3514 fn community_cap_rejects_a_new_membership_at_the_limit() {
3515 let (_tmp, _guard) = init_test_db();
3516 let mk = |i: usize| {
3517 let id = format!("{:064x}", i);
3518 crate::community::list::CommunityListEntry {
3519 community_id: id.clone(),
3520 seed: crate::community::invite::CommunityInvite {
3521 community_id: id,
3522 name: String::new(),
3523 server_root_key: String::new(),
3524 server_root_epoch: 0,
3525 relays: vec![],
3526 channels: vec![],
3527 owner_attestation: None,
3528 icon: None,
3529 },
3530 current: None,
3531 added_at: 0,
3532 }
3533 };
3534 let mut list = crate::community::list::CommunityList::default();
3535 for i in 0..(MAX_COMMUNITIES - 1) {
3536 list.entries.push(mk(i));
3537 }
3538 crate::db::settings::set_sql_setting("community_list_json".to_string(), list.to_json()).unwrap();
3539 assert!(enforce_community_cap().is_ok(), "under the cap a new join is allowed");
3540
3541 list.entries.push(mk(MAX_COMMUNITIES - 1)); crate::db::settings::set_sql_setting("community_list_json".to_string(), list.to_json()).unwrap();
3543 assert!(enforce_community_cap().is_err(), "at the cap a new join is rejected");
3544 }
3545
3546 fn saved_community_owned_by(owner: &Keys) -> Community {
3551 use nostr_sdk::JsonUtil;
3552 let mut community = Community::create("HQ", "general", vec!["r".into()]);
3553 let cid = community.id.to_hex();
3554 community.owner_attestation = Some(
3555 crate::community::owner::build_owner_attestation_unsigned(owner.public_key(), &cid)
3556 .sign_with_keys(owner)
3557 .unwrap()
3558 .as_json(),
3559 );
3560 crate::db::community::save_community(&community).unwrap();
3561 community
3562 }
3563
3564 fn attested_community(name: &str, channel: &str, relays: Vec<String>) -> Community {
3568 use nostr_sdk::JsonUtil;
3569 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
3570 let mut community = Community::create(name, channel, relays);
3571 community.owner_attestation = Some(
3572 crate::community::owner::build_owner_attestation_unsigned(owner.public_key(), &community.id.to_hex())
3573 .sign_with_keys(&owner).unwrap().as_json(),
3574 );
3575 community
3576 }
3577
3578 fn become_local(me: &Keys) {
3580 crate::state::MY_SECRET_KEY.store_from_keys(me, &[]);
3581 crate::state::set_my_public_key(me.public_key());
3582 }
3583
3584 fn owner_channel_rekey(
3587 owner: &Keys,
3588 community: &Community,
3589 recipient_pk: &nostr_sdk::PublicKey,
3590 new_epoch: u64,
3591 new_key: &[u8; 32],
3592 ) -> super::super::rekey::ParsedRekey {
3593 let chan = &community.channels[0];
3594 let scope = super::super::derive::RekeyScope::Channel(chan.id);
3595 let blob = super::super::rekey::build_rekey_blob(
3596 owner.secret_key(), recipient_pk, scope, crate::community::Epoch(new_epoch), new_key,
3597 )
3598 .unwrap();
3599 let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), chan.key.as_bytes());
3600 let outer = super::super::rekey::build_channel_rekey_event(
3601 &Keys::generate(), owner, community.server_root_key.as_bytes(), &chan.id,
3602 crate::community::Epoch(new_epoch), crate::community::Epoch(0), &commit, &[blob],
3603 )
3604 .unwrap();
3605 super::super::rekey::open_rekey_event(&outer, community.server_root_key.as_bytes()).unwrap()
3606 }
3607
3608 #[tokio::test]
3613 async fn outer_event_dedup_skips_an_already_persisted_wire_event() {
3614 let (_tmp, _guard) = init_test_db();
3615 let owner = Keys::generate();
3616 let me = Keys::generate();
3617 become_local(&me);
3618 let community = saved_community_owned_by(&owner);
3619 let channel = community.channels[0].clone();
3620 let chan_hex = channel.id.to_hex();
3621
3622 let author = Keys::generate();
3624 let outer = crate::community::envelope::seal_message(
3625 &author, &channel.key, &channel.id, channel.epoch, "gm", 1000,
3626 ).unwrap();
3627 let outer_hex = outer.id.to_hex();
3628
3629 let mut state = crate::state::ChatState::new();
3631 let msg = match crate::community::inbound::process_incoming(&mut state, &outer, &channel, &me.public_key()) {
3632 Some(crate::community::inbound::IncomingEvent::NewMessage(m)) => m,
3633 _ => panic!("expected NewMessage from a fresh wire event"),
3634 };
3635 assert_eq!(msg.wrapper_event_id.as_deref(), Some(outer_hex.as_str()),
3636 "the inner must carry its outer wire id as wrapper_event_id");
3637
3638 crate::db::events::save_message(&chan_hex, &msg).await.unwrap();
3640
3641 let mut state2 = crate::state::ChatState::new();
3643 let second = crate::community::inbound::process_incoming(&mut state2, &outer, &channel, &me.public_key());
3644 assert!(second.is_none(), "an already-processed wire event must dedup before decryption");
3645 }
3646
3647 #[tokio::test]
3650 async fn ledger_is_shared_but_negentropy_stays_nip17_only() {
3651 let (_tmp, _guard) = init_test_db();
3652 let dm = [0xA1u8; 32];
3653 let concord = [0xC0u8; 32];
3654 crate::db::wrappers::save_processed_wrapper(&dm, 100, crate::db::wrappers::TRANSPORT_NIP17).unwrap();
3655 crate::db::wrappers::save_processed_wrapper(&concord, 200, crate::db::wrappers::TRANSPORT_CONCORD).unwrap();
3656
3657 assert!(crate::db::wrappers::processed_wrapper_exists(&dm));
3659 assert!(crate::db::wrappers::processed_wrapper_exists(&concord));
3660
3661 let items = crate::db::wrappers::load_negentropy_items().unwrap();
3663 assert_eq!(items.len(), 1, "negentropy must exclude concord wrappers");
3664 assert_eq!(items[0].0.to_bytes(), dm);
3665 }
3666
3667 #[tokio::test]
3671 async fn non_message_subkind_dedups_via_the_shared_ledger() {
3672 let (_tmp, _guard) = init_test_db();
3673 let owner = Keys::generate();
3674 let me = Keys::generate();
3675 become_local(&me);
3676 let community = saved_community_owned_by(&owner);
3677 let channel = community.channels[0].clone();
3678
3679 let author = Keys::generate();
3681 let inner = super::super::envelope::build_inner_typed(
3682 author.public_key(), &channel.id, channel.epoch,
3683 crate::stored_event::event_kind::COMMUNITY_PRESENCE, "join", 5, None, &[],
3684 ).sign_with_keys(&author).unwrap();
3685 let outer = super::super::envelope::seal_with_signed_inner(
3686 &Keys::generate(), &inner, &channel.key, &channel.id, channel.epoch,
3687 ).unwrap();
3688
3689 let mut state = crate::state::ChatState::new();
3691 let first = crate::community::inbound::process_incoming(&mut state, &outer, &channel, &me.public_key());
3692 assert!(matches!(first, Some(crate::community::inbound::IncomingEvent::Presence { .. })),
3693 "expected a Presence outcome");
3694 assert!(crate::db::wrappers::processed_wrapper_exists(&outer.id.to_bytes()),
3695 "a non-message sub-kind must record its outer id in the shared ledger");
3696
3697 let second = crate::community::inbound::process_incoming(&mut crate::state::ChatState::new(), &outer, &channel, &me.public_key());
3699 assert!(second.is_none(), "a re-fetched presence must dedup via the shared ledger");
3700 }
3701
3702 #[test]
3703 fn apply_channel_rekey_recovers_and_advances_head() {
3704 let (_tmp, _guard) = init_test_db();
3705 let owner = Keys::generate(); let me = Keys::generate();
3707 become_local(&me);
3708 let community = saved_community_owned_by(&owner);
3709 let cid = community.id.to_hex();
3710 let chan_hex = community.channels[0].id.to_hex();
3711 let new_key = [0xCDu8; 32];
3712
3713 let parsed = owner_channel_rekey(&owner, &community, &me.public_key(), 1, &new_key);
3714 let outcome = apply_channel_rekey(&community, &parsed).unwrap();
3715 assert_eq!(outcome, RekeyOutcome::Applied { head_advanced: true });
3716
3717 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(new_key));
3719 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
3720 assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(1));
3721 assert_eq!(reloaded.channels[0].key.as_bytes(), &new_key);
3722 assert!(crate::db::community::held_epoch_key(&cid, &chan_hex, 0).unwrap().is_some());
3724 }
3725
3726 #[test]
3727 fn apply_channel_rekey_accepts_matching_continuity() {
3728 let (_tmp, _guard) = init_test_db();
3731 let owner = Keys::generate();
3732 let me = Keys::generate();
3733 become_local(&me);
3734 let community = saved_community_owned_by(&owner);
3735 let parsed = owner_channel_rekey(&owner, &community, &me.public_key(), 1, &[0x44u8; 32]);
3737 assert_eq!(
3738 apply_channel_rekey(&community, &parsed).unwrap(),
3739 RekeyOutcome::Applied { head_advanced: true },
3740 "a rekey whose prior-key commitment matches the held genesis key applies"
3741 );
3742 }
3743
3744 #[test]
3745 fn advance_channel_epoch_archives_when_no_head_row() {
3746 let (_tmp, _guard) = init_test_db();
3749 let cid = "f".repeat(64);
3750 let orphan_channel = "a".repeat(64);
3751 let advanced = crate::db::community::advance_channel_epoch(&cid, &orphan_channel, 2, &[0x77u8; 32]).unwrap();
3752 assert!(!advanced, "no head row → head not advanced");
3753 assert_eq!(crate::db::community::held_epoch_key(&cid, &orphan_channel, 2).unwrap(), Some([0x77u8; 32]), "key still archived");
3754 }
3755
3756 #[tokio::test]
3757 async fn rotate_channel_publishes_recoverable_rekey_and_advances_own_head() {
3758 use crate::community::derive::{recipient_pseudonym, rekey_pseudonym};
3759 use crate::community::rekey::{open_rekey_blob, open_rekey_event, rekey_pairwise_secret};
3760 let (_tmp, _guard) = init_test_db();
3761 let owner = Keys::generate();
3762 become_local(&owner); let community = saved_community_owned_by(&owner);
3764 let channel_id = community.channels[0].id;
3765 let member = Keys::generate(); let relay = MemoryRelay::new();
3767
3768 let new_epoch = rotate_channel(&relay, &community, &channel_id, &[member.public_key()], community.server_root_key.as_bytes())
3769 .await
3770 .expect("rotate");
3771 assert_eq!(new_epoch, 1);
3772
3773 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
3775 assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(1));
3776
3777 let addr = rekey_pseudonym(
3780 &crate::community::ServerRootKey(*community.server_root_key.as_bytes()),
3781 &channel_id, crate::community::Epoch(1),
3782 )
3783 .to_hex();
3784 let found = relay
3785 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() }, &community.relays)
3786 .await
3787 .unwrap();
3788 assert_eq!(found.len(), 1, "rekey addressable by its server-root pseudonym");
3789 let parsed = open_rekey_event(&found[0], community.server_root_key.as_bytes()).unwrap();
3790 assert_eq!(parsed.rotator, owner.public_key());
3791 assert_eq!(parsed.new_epoch, crate::community::Epoch(1));
3792 assert_eq!(parsed.prev_epoch, crate::community::Epoch(0));
3793 assert_eq!(parsed.blobs.len(), 2, "the member + me (multi-device) each get a blob");
3794
3795 let secret = rekey_pairwise_secret(member.secret_key(), &parsed.rotator).unwrap();
3797 let loc = recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
3798 let mine = parsed.blobs.iter().find(|b| b.locator == loc).expect("member's blob present");
3799 let recovered = open_rekey_blob(member.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine).unwrap();
3800 assert_eq!(reloaded.channels[0].key.as_bytes(), &recovered, "member's recovered key == my advanced head key");
3801 }
3802
3803 #[tokio::test]
3804 async fn rotate_channel_failed_publish_leaves_head_unadvanced() {
3805 let (_tmp, _guard) = init_test_db();
3808 let owner = Keys::generate();
3809 become_local(&owner);
3810 let community = saved_community_owned_by(&owner);
3811 let member = Keys::generate();
3812 let err = rotate_channel(&FailingRelay, &community, &community.channels[0].id, &[member.public_key()], community.server_root_key.as_bytes()).await;
3813 assert!(err.is_err(), "a failed publish must propagate, not silently advance");
3814 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
3815 assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(0), "head stays put on publish failure");
3816 }
3817
3818 fn build_rekey_chain(
3822 owner: &Keys, community: &Community, recipient_pk: &nostr_sdk::PublicKey, n: u64,
3823 ) -> (Vec<Event>, Vec<[u8; 32]>) {
3824 let chan = &community.channels[0];
3825 let scope = super::super::derive::RekeyScope::Channel(chan.id);
3826 let mut prev_key = *chan.key.as_bytes();
3827 let mut events = Vec::new();
3828 let mut keys = Vec::new();
3829 for e in 1..=n {
3830 let new_key = [e as u8; 32];
3831 let blob = super::super::rekey::build_rekey_blob(owner.secret_key(), recipient_pk, scope, crate::community::Epoch(e), &new_key).unwrap();
3832 let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(e - 1), &prev_key);
3833 let ev = super::super::rekey::build_channel_rekey_event(
3834 &Keys::generate(), owner, community.server_root_key.as_bytes(), &chan.id,
3835 crate::community::Epoch(e), crate::community::Epoch(e - 1), &commit, &[blob],
3836 ).unwrap();
3837 events.push(ev);
3838 keys.push(new_key);
3839 prev_key = new_key;
3840 }
3841 (events, keys)
3842 }
3843
3844 #[tokio::test]
3845 async fn catch_up_steps_over_a_missing_epoch() {
3846 let (_tmp, _guard) = init_test_db();
3849 let owner = Keys::generate();
3850 let me = Keys::generate();
3851 become_local(&me);
3852 let community = saved_community_owned_by(&owner);
3853 let channel_id = community.channels[0].id;
3854 let cid = community.id.to_hex();
3855 let chan_hex = channel_id.to_hex();
3856
3857 let (events, keys) = build_rekey_chain(&owner, &community, &me.public_key(), 3);
3858 let relay = MemoryRelay::new();
3859 relay.inject(&events[0], &community.relays); relay.inject(&events[2], &community.relays); let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
3862
3863 assert_eq!(reached, 3, "head reaches the latest present epoch, stepping over the gap");
3864 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(keys[0]));
3865 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 2).unwrap(), None, "missing epoch is a hole");
3866 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 3).unwrap(), Some(keys[2]));
3867 }
3868
3869 #[tokio::test]
3870 async fn catch_up_recovers_a_rekey_under_a_prior_server_root() {
3871 let (_tmp, _guard) = init_test_db();
3876 let owner = Keys::generate();
3877 let me = Keys::generate();
3878 become_local(&me);
3879 let root0_community = saved_community_owned_by(&owner);
3880 let cid = root0_community.id.to_hex();
3881 let channel_id = root0_community.channels[0].id;
3882 let chan_hex = channel_id.to_hex();
3883 let scope = super::super::derive::RekeyScope::Channel(channel_id);
3884 let genesis_key = *root0_community.channels[0].key.as_bytes();
3885
3886 let root1 = [0x99u8; 32];
3888 crate::db::community::advance_server_root_epoch(&cid, 1, &root1).unwrap();
3889 let community = crate::db::community::load_community(&root0_community.id).unwrap().unwrap();
3890 assert_eq!(community.server_root_epoch, crate::community::Epoch(1));
3891
3892 let (k1, k2) = ([0x11u8; 32], [0x22u8; 32]);
3894 let blob1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &k1).unwrap();
3895 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
3896 let ev1 = super::super::rekey::build_channel_rekey_event(
3897 &Keys::generate(), &owner, root0_community.server_root_key.as_bytes(), &channel_id,
3898 crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob1]).unwrap();
3899 let blob2 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(2), &k2).unwrap();
3900 let commit1 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(1), &k1);
3901 let ev2 = super::super::rekey::build_channel_rekey_event(
3902 &Keys::generate(), &owner, &root1, &channel_id,
3903 crate::community::Epoch(2), crate::community::Epoch(1), &commit1, &[blob2]).unwrap();
3904
3905 let relay = MemoryRelay::new();
3906 relay.inject(&ev1, &community.relays);
3907 relay.inject(&ev2, &community.relays);
3908
3909 let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
3910 assert_eq!(reached, 2, "reached the latest channel epoch across the server-root rotation");
3911 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(k1),
3912 "epoch-1 key recovered from a rekey under the PRIOR server root");
3913 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 2).unwrap(), Some(k2));
3914 }
3915
3916 #[tokio::test]
3917 async fn catch_up_backfills_a_sub_head_gap() {
3918 let (_tmp, _guard) = init_test_db();
3921 let owner = Keys::generate();
3922 let me = Keys::generate();
3923 become_local(&me);
3924 let community = saved_community_owned_by(&owner);
3925 let cid = community.id.to_hex();
3926 let channel_id = community.channels[0].id;
3927 let chan_hex = channel_id.to_hex();
3928 let scope = super::super::derive::RekeyScope::Channel(channel_id);
3929 let genesis_key = *community.channels[0].key.as_bytes();
3930
3931 let k2 = [0x22u8; 32];
3933 crate::db::community::advance_channel_epoch(&cid, &chan_hex, 2, &k2).unwrap();
3934 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), None, "epoch 1 starts as a hole");
3935
3936 let k1 = [0x11u8; 32];
3938 let blob1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &k1).unwrap();
3939 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
3940 let ev1 = super::super::rekey::build_channel_rekey_event(
3941 &Keys::generate(), &owner, community.server_root_key.as_bytes(), &channel_id,
3942 crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob1]).unwrap();
3943 let relay = MemoryRelay::new();
3944 relay.inject(&ev1, &community.relays);
3945
3946 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
3947 let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
3948 assert_eq!(reached, 2, "head unchanged (gap-fill never regresses it)");
3949 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(k1),
3950 "the sub-head hole was backfilled");
3951 }
3952
3953 #[tokio::test]
3954 async fn catch_up_walks_a_chain_of_rotations_to_the_latest() {
3955 let (_tmp, _guard) = init_test_db();
3956 let owner = Keys::generate();
3957 let me = Keys::generate();
3958 become_local(&me); let community = saved_community_owned_by(&owner);
3960 let channel_id = community.channels[0].id;
3961 let cid = community.id.to_hex();
3962 let chan_hex = channel_id.to_hex();
3963
3964 let (events, keys) = build_rekey_chain(&owner, &community, &me.public_key(), 3);
3966 let relay = MemoryRelay::new();
3967 for ev in events.iter().rev() {
3968 relay.inject(ev, &community.relays);
3969 }
3970
3971 let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
3972 assert_eq!(reached, 3, "caught up to the latest epoch");
3973 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
3975 assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(3));
3976 assert_eq!(reloaded.channels[0].key.as_bytes(), &keys[2]);
3977 for (i, k) in keys.iter().enumerate() {
3978 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, (i + 1) as u64).unwrap(), Some(*k));
3979 }
3980 }
3981
3982 #[tokio::test]
3983 async fn catch_up_slides_across_the_window_boundary() {
3984 let (_tmp, _guard) = init_test_db();
3988 let owner = Keys::generate();
3989 let me = Keys::generate();
3990 become_local(&me);
3991 let community = saved_community_owned_by(&owner);
3992 let channel_id = community.channels[0].id;
3993 let cid = community.id.to_hex();
3994 let chan_hex = channel_id.to_hex();
3995
3996 let (events, keys) = build_rekey_chain(&owner, &community, &me.public_key(), 70);
3997 let relay = MemoryRelay::new();
3998 for ev in &events {
3999 relay.inject(ev, &community.relays);
4000 }
4001 let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4002 assert_eq!(reached, 70, "slid past the 64-epoch window boundary to the latest");
4003 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 70).unwrap(), Some(keys[69]));
4004 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 64).unwrap(), Some(keys[63]), "window-1 keys retained too");
4005 }
4006
4007 fn build_base_rekey_chain(
4013 owner: &Keys, community: &Community, recipient_pk: &nostr_sdk::PublicKey, n: u64,
4014 ) -> (Vec<Event>, Vec<[u8; 32]>) {
4015 let mut prior_root = *community.server_root_key.as_bytes();
4016 let mut events = Vec::new();
4017 let mut roots = Vec::new();
4018 for e in 1..=n {
4019 let new_root = [(e % 256) as u8; 32];
4020 let blob = super::super::rekey::build_rekey_blob(
4021 owner.secret_key(), recipient_pk, super::super::derive::RekeyScope::ServerRoot, crate::community::Epoch(e), &new_root,
4022 )
4023 .unwrap();
4024 let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(e - 1), &prior_root);
4025 events.push(super::super::rekey::build_server_root_rekey_event(
4026 &Keys::generate(), owner, &prior_root, &community.id,
4027 crate::community::Epoch(e), crate::community::Epoch(e - 1), &commit, &[blob],
4028 ).unwrap());
4029 roots.push(new_root);
4030 prior_root = new_root;
4031 }
4032 (events, roots)
4033 }
4034
4035 #[tokio::test]
4036 async fn catch_up_server_root_walks_a_chain_of_base_rotations() {
4037 let (_tmp, _guard) = init_test_db();
4038 let owner = Keys::generate();
4039 let me = Keys::generate();
4040 become_local(&me);
4041 let community = saved_community_owned_by(&owner);
4042 let cid = community.id.to_hex();
4043
4044 let (events, roots) = build_base_rekey_chain(&owner, &community, &me.public_key(), 3);
4045 let relay = MemoryRelay::new();
4046 for ev in events.iter().rev() {
4047 relay.inject(ev, &community.relays);
4048 }
4049 let reached = catch_up_server_root(&relay, &community).await.unwrap();
4050 assert_eq!(reached.epoch, 3, "walked the base chain to the latest epoch");
4051 assert!(!reached.removed, "a normal catch-up is not a removal");
4052 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4053 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(3));
4054 assert_eq!(reloaded.server_root_key.as_bytes(), &roots[2], "base head is the latest root");
4055 for (i, r) in roots.iter().enumerate() {
4057 assert_eq!(crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, (i + 1) as u64).unwrap(), Some(*r));
4058 }
4059 }
4060
4061 #[tokio::test]
4062 async fn catch_up_recovers_from_a_split_base_rotation_second_chunk() {
4063 let (_tmp, _guard) = init_test_db();
4067 let owner = Keys::generate();
4068 let me = Keys::generate();
4069 become_local(&me);
4070 let community = saved_community_owned_by(&owner);
4071 let genesis = *community.server_root_key.as_bytes();
4072 let new_root = [0x5Au8; 32];
4073 let scope = super::super::derive::RekeyScope::ServerRoot;
4074 let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis);
4075 let mk = |recipient: &nostr_sdk::PublicKey| {
4076 let blob = super::super::rekey::build_rekey_blob(owner.secret_key(), recipient, scope, crate::community::Epoch(1), &new_root).unwrap();
4077 super::super::rekey::build_server_root_rekey_event(
4078 &Keys::generate(), &owner, &genesis, &community.id,
4079 crate::community::Epoch(1), crate::community::Epoch(0), &commit, &[blob],
4080 ).unwrap()
4081 };
4082 let relay = MemoryRelay::new();
4083 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();
4087 assert_eq!(reached.epoch, 1, "recovered the split rotation via the second chunk");
4088 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4089 assert_eq!(reloaded.server_root_key.as_bytes(), &new_root, "recovered the new root from chunk 2");
4090 }
4091
4092 #[tokio::test]
4093 async fn catch_up_converges_concurrent_refoundings_on_the_lowest_root() {
4094 let (_tmp, _guard) = init_test_db();
4098 let owner = Keys::generate();
4099 let me = Keys::generate();
4100 become_local(&me);
4101 let community = saved_community_owned_by(&owner);
4102 let genesis = *community.server_root_key.as_bytes();
4103 let scope = super::super::derive::RekeyScope::ServerRoot;
4104 let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis);
4105 let root_lo = [0x10u8; 32];
4106 let root_hi = [0xF0u8; 32]; let mk = |root: &[u8; 32]| {
4108 let blob = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), root).unwrap();
4109 super::super::rekey::build_server_root_rekey_event(
4110 &Keys::generate(), &owner, &genesis, &community.id,
4111 crate::community::Epoch(1), crate::community::Epoch(0), &commit, &[blob],
4112 ).unwrap()
4113 };
4114 let relay = MemoryRelay::new();
4115 relay.inject(&mk(&root_hi), &community.relays);
4117 relay.inject(&mk(&root_lo), &community.relays);
4118
4119 let reached = catch_up_server_root(&relay, &community).await.unwrap();
4120 assert_eq!(reached.epoch, 1, "advanced one epoch");
4121 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4122 assert_eq!(reloaded.server_root_key.as_bytes(), &root_lo, "converged on the LOWEST root, not the first-arrived");
4123 }
4124
4125 #[tokio::test]
4126 async fn rotate_retry_reuses_the_archived_root_no_same_epoch_fork() {
4127 let (_tmp, _guard) = init_test_db();
4132 let owner = Keys::generate();
4133 become_local(&owner);
4134 let community = saved_community_owned_by(&owner);
4135 let cid = community.id.to_hex();
4136 let relay = RekeyFailingRelay::new(); let member = Keys::generate();
4138
4139 assert!(rotate_server_root(&relay, &community, &[member.public_key()]).await.is_err(), "the rekey publish fails");
4140 let k1 = crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap()
4141 .expect("the new root is archived before publishing (fork-safety)");
4142 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4143 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(0), "head not advanced on a failed publish");
4144
4145 relay.allow_rekey();
4146 rotate_server_root(&relay, &reloaded, &[member.public_key()]).await.unwrap();
4147 let k2 = crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap().unwrap();
4148 assert_eq!(k1, k2, "the retry REUSES the archived root — no second root for epoch 1, no fork");
4149 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
4150 assert_eq!(after.server_root_epoch, crate::community::Epoch(1), "the retry completed the rotation");
4151 assert_eq!(after.server_root_key.as_bytes(), &k1, "the committed root is the one minted on attempt 1");
4152 }
4153
4154 #[tokio::test]
4155 async fn rotate_server_root_splits_a_large_recipient_set_into_multiple_events() {
4156 let (_tmp, _guard) = init_test_db();
4158 let owner = Keys::generate();
4159 become_local(&owner);
4160 let community = saved_community_owned_by(&owner);
4161 let genesis = *community.server_root_key.as_bytes();
4162 let relay = MemoryRelay::new();
4163 let recipients: Vec<_> = (0..super::super::rekey::MAX_REKEY_BLOBS).map(|_| Keys::generate().public_key()).collect();
4165 rotate_server_root(&relay, &community, &recipients).await.unwrap();
4166 let addr = super::super::derive::base_rekey_pseudonym(&super::super::ServerRootKey(genesis), &community.id, crate::community::Epoch(1)).to_hex();
4167 let evs = relay
4168 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() }, &community.relays)
4169 .await
4170 .unwrap();
4171 assert_eq!(evs.len(), 2, "a >MAX_REKEY_BLOBS rotation splits into 2 events at one address");
4172 }
4173
4174 #[tokio::test]
4175 async fn catch_up_server_root_is_a_noop_with_no_rotations() {
4176 let (_tmp, _guard) = init_test_db();
4177 let owner = Keys::generate();
4178 let me = Keys::generate();
4179 become_local(&me);
4180 let community = saved_community_owned_by(&owner);
4181 let relay = MemoryRelay::new();
4182 assert_eq!(catch_up_server_root(&relay, &community).await.unwrap().epoch, 0, "no base rotations → stays at 0");
4183 }
4184
4185 #[tokio::test]
4186 async fn concurrent_refounders_converge_to_the_lowest_root() {
4187 let (_tmp, _guard) = init_test_db();
4192 let owner = Keys::generate();
4193 let me = Keys::generate();
4194 become_local(&me); let community = saved_community_owned_by(&owner);
4196 let cid = community.id.to_hex();
4197 let genesis_root = *community.server_root_key.as_bytes();
4198 let scope = super::super::derive::RekeyScope::ServerRoot;
4199
4200 let root_lo = [0x10u8; 32];
4203 let root_hi = [0x99u8; 32]; let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_root);
4205 let blob_lo = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &root_lo).unwrap();
4206 let ev_lo = super::super::rekey::build_server_root_rekey_event(
4207 &Keys::generate(), &owner, &genesis_root, &community.id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_lo]).unwrap();
4208
4209 let relay = MemoryRelay::new();
4210 relay.inject(&ev_lo, &community.relays);
4211
4212 crate::db::community::advance_server_root_epoch(&cid, 1, &root_hi).unwrap();
4214 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4215 assert_eq!(community.server_root_key.as_bytes(), &root_hi, "start on the higher root");
4216
4217 let out = catch_up_server_root(&relay, &community).await.unwrap();
4218 assert_eq!(out.epoch, 1, "converged in place at the same epoch (not advanced)");
4219 assert!(!out.removed);
4220 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
4221 assert_eq!(after.server_root_key.as_bytes(), &root_lo, "originator converged to the lowest authorized root");
4222
4223 let _ = catch_up_server_root(&relay, &after).await.unwrap();
4225 assert_eq!(crate::db::community::load_community(&community.id).unwrap().unwrap().server_root_key.as_bytes(), &root_lo, "no flip-flop");
4226 }
4227
4228 #[tokio::test]
4229 async fn banned_rotators_rekey_is_not_a_convergence_candidate() {
4230 let (_tmp, _guard) = init_test_db();
4235 let owner = Keys::generate();
4236 let me = Keys::generate();
4237 let banned_admin = Keys::generate();
4238 become_local(&me);
4239 let community = saved_community_owned_by(&owner);
4240 let cid = community.id.to_hex();
4241 let genesis_root = *community.server_root_key.as_bytes();
4242 let scope = super::super::derive::RekeyScope::ServerRoot;
4243
4244 let role_id = "e".repeat(64);
4246 let roster = crate::community::roles::CommunityRoles {
4247 roles: vec![crate::community::roles::Role::admin(role_id.clone())],
4248 grants: vec![crate::community::roles::MemberGrant { member: banned_admin.public_key().to_hex(), role_ids: vec![role_id] }],
4249 };
4250 crate::db::community::set_community_roles(&cid, &roster, 1).unwrap();
4251 crate::db::community::set_community_banlist(&cid, &[banned_admin.public_key().to_hex()], 2).unwrap();
4253
4254 let root_evil = [0x01u8; 32];
4256 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_root);
4257 let blob = super::super::rekey::build_rekey_blob(banned_admin.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &root_evil).unwrap();
4258 let ev = super::super::rekey::build_server_root_rekey_event(
4259 &Keys::generate(), &banned_admin, &genesis_root, &community.id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob]).unwrap();
4260 let relay = MemoryRelay::new();
4261 relay.inject(&ev, &community.relays);
4262
4263 let out = catch_up_server_root(&relay, &community).await.unwrap();
4266 assert_eq!(out.epoch, 0, "banned rotator's re-founding must not advance the base");
4267 assert!(!out.removed, "banned rotator's exclusion must not read as an authorized removal");
4268 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
4269 assert_eq!(after.server_root_key.as_bytes(), &genesis_root, "root unchanged");
4270
4271 let parsed = super::super::rekey::open_rekey_event(&ev, &genesis_root).unwrap();
4273 assert!(apply_server_root_rekey(&community, &parsed).is_err(), "apply must refuse a banned rotator");
4274 }
4275
4276 #[tokio::test]
4277 async fn heal_abandons_a_deauthorized_root_for_the_authorized_higher_sibling() {
4278 let (_tmp, _guard) = init_test_db();
4282 let owner = Keys::generate();
4283 let me = Keys::generate();
4284 let banned_admin = Keys::generate();
4285 become_local(&me);
4286 let community = saved_community_owned_by(&owner);
4287 let cid = community.id.to_hex();
4288 let genesis_root = *community.server_root_key.as_bytes();
4289 let scope = super::super::derive::RekeyScope::ServerRoot;
4290 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_root);
4291
4292 let root_evil = [0x01u8; 32];
4295 let root_owner = [0x77u8; 32];
4296 let blob_evil = super::super::rekey::build_rekey_blob(banned_admin.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &root_evil).unwrap();
4297 let ev_evil = super::super::rekey::build_server_root_rekey_event(
4298 &Keys::generate(), &banned_admin, &genesis_root, &community.id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_evil]).unwrap();
4299 let blob_owner = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &root_owner).unwrap();
4300 let ev_owner = super::super::rekey::build_server_root_rekey_event(
4301 &Keys::generate(), &owner, &genesis_root, &community.id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_owner]).unwrap();
4302 let relay = MemoryRelay::new();
4303 relay.inject(&ev_evil, &community.relays);
4304 relay.inject(&ev_owner, &community.relays);
4305
4306 crate::db::community::advance_server_root_epoch(&cid, 1, &root_evil).unwrap();
4308 crate::db::community::set_community_banlist(&cid, &[banned_admin.public_key().to_hex()], 2).unwrap();
4309 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4310 assert_eq!(community.server_root_key.as_bytes(), &root_evil, "start partitioned on the attacker's root");
4311
4312 let out = catch_up_server_root(&relay, &community).await.unwrap();
4313 assert_eq!(out.epoch, 1);
4314 assert!(!out.removed);
4315 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
4316 assert_eq!(after.server_root_key.as_bytes(), &root_owner,
4317 "heal must abandon the deauthorized root and adopt the owner's higher sibling");
4318
4319 let _ = catch_up_server_root(&relay, &after).await.unwrap();
4321 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");
4322 }
4323
4324 #[tokio::test]
4325 async fn concurrent_channel_rekeyers_converge_to_the_lowest_key() {
4326 let (_tmp, _guard) = init_test_db();
4331 let owner = Keys::generate();
4332 let me = Keys::generate();
4333 become_local(&me); let community = saved_community_owned_by(&owner);
4335 let cid = community.id.to_hex();
4336 let channel_id = community.channels[0].id;
4337 let chan_hex = channel_id.to_hex();
4338 let scope = super::super::derive::RekeyScope::Channel(channel_id);
4339 let genesis_key = *community.channels[0].key.as_bytes();
4340 let root = *community.server_root_key.as_bytes();
4341 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
4342
4343 let key_lo = [0x10u8; 32];
4345 let key_hi = [0x99u8; 32];
4346 let blob_lo = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_lo).unwrap();
4347 let blob_hi = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_hi).unwrap();
4348 let ev_lo = super::super::rekey::build_channel_rekey_event(
4349 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_lo]).unwrap();
4350 let ev_hi = super::super::rekey::build_channel_rekey_event(
4351 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_hi]).unwrap();
4352
4353 let relay = MemoryRelay::new();
4354 relay.inject(&ev_hi, &community.relays); relay.inject(&ev_lo, &community.relays);
4356
4357 crate::db::community::advance_server_root_epoch(&cid, 1, &[0x42u8; 32]).unwrap();
4362 crate::db::community::advance_channel_epoch(&cid, &chan_hex, 1, &key_hi).unwrap();
4364 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4365
4366 let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4367 assert_eq!(reached, 1, "converged in place at the same channel epoch");
4368 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(key_lo),
4369 "adopted the lowest delivered key regardless of relay order");
4370
4371 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
4373 let _ = catch_up_channel_rekeys(&relay, &after, &channel_id).await.unwrap();
4374 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(key_lo), "no flip-flop");
4375 }
4376
4377 #[tokio::test]
4378 async fn concurrent_channel_rekeyers_converge_when_i_authored_the_losing_fork() {
4379 let (_tmp, _guard) = init_test_db();
4384 let owner = Keys::generate();
4385 let me = Keys::generate(); become_local(&me);
4387 let community = saved_community_owned_by(&owner);
4388 let cid = community.id.to_hex();
4389 let channel_id = community.channels[0].id;
4390 let chan_hex = channel_id.to_hex();
4391 let scope = super::super::derive::RekeyScope::Channel(channel_id);
4392 let genesis_key = *community.channels[0].key.as_bytes();
4393 let root = *community.server_root_key.as_bytes();
4394 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
4395
4396 let role_id = "d".repeat(64);
4400 let roster = crate::community::roles::CommunityRoles {
4401 roles: vec![crate::community::roles::Role::admin(role_id.clone())],
4402 grants: vec![crate::community::roles::MemberGrant { member: me.public_key().to_hex(), role_ids: vec![role_id] }],
4403 };
4404 crate::db::community::set_community_roles(&cid, &roster, 1).unwrap();
4405
4406 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();
4410 let ev_lo = super::super::rekey::build_channel_rekey_event(
4411 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_lo]).unwrap();
4412 let blob_hi = super::super::rekey::build_rekey_blob(me.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_hi).unwrap();
4414 let ev_hi = super::super::rekey::build_channel_rekey_event(
4415 &Keys::generate(), &me, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_hi]).unwrap();
4416
4417 let relay = MemoryRelay::new();
4418 relay.inject(&ev_hi, &community.relays);
4419 relay.inject(&ev_lo, &community.relays);
4420
4421 crate::db::community::advance_server_root_epoch(&cid, 1, &[0x42u8; 32]).unwrap();
4423 crate::db::community::advance_channel_epoch(&cid, &chan_hex, 1, &key_hi).unwrap();
4425 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4426
4427 let _ = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4428 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(key_lo),
4429 "I authored the losing fork but must converge DOWN to the owner's lower key");
4430 }
4431
4432 #[tokio::test]
4433 async fn reorg_through_a_fork_heals_the_forked_past_epoch() {
4434 let (_tmp, _guard) = init_test_db();
4439 let owner = Keys::generate();
4440 let me = Keys::generate();
4441 become_local(&me);
4442 let community = saved_community_owned_by(&owner);
4443 let cid = community.id.to_hex();
4444 let channel_id = community.channels[0].id;
4445 let chan_hex = channel_id.to_hex();
4446 let scope = super::super::derive::RekeyScope::Channel(channel_id);
4447 let genesis_key = *community.channels[0].key.as_bytes();
4448 let root = *community.server_root_key.as_bytes();
4449 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
4450
4451 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();
4456 let blob_hi1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_hi1).unwrap();
4457 let ev_lo1 = super::super::rekey::build_channel_rekey_event(
4458 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_lo1]).unwrap();
4459 let ev_hi1 = super::super::rekey::build_channel_rekey_event(
4460 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_hi1]).unwrap();
4461 let commit1_win = super::super::rekey::epoch_key_commitment(crate::community::Epoch(1), &key_lo1);
4463 let blob_e2 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(2), &key_e2).unwrap();
4464 let ev_e2 = super::super::rekey::build_channel_rekey_event(
4465 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(2), crate::community::Epoch(1), &commit1_win, &[blob_e2]).unwrap();
4466
4467 let relay = MemoryRelay::new();
4468 relay.inject(&ev_lo1, &community.relays);
4469 relay.inject(&ev_hi1, &community.relays);
4470 relay.inject(&ev_e2, &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_hi1).unwrap();
4476 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4477
4478 let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4479 assert_eq!(reached, 2, "reorged forward to the head epoch");
4480 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 2).unwrap(), Some(key_e2), "head epoch adopted");
4481 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(key_lo1),
4482 "the FORKED past epoch re-converged to the lowest sibling (its messages become readable)");
4483 }
4484
4485 #[tokio::test]
4486 async fn window_heal_converges_an_already_reorged_past_fork() {
4487 let (_tmp, _guard) = init_test_db();
4492 let owner = Keys::generate();
4493 let me = Keys::generate();
4494 become_local(&me);
4495 let community = saved_community_owned_by(&owner);
4496 let cid = community.id.to_hex();
4497 let channel_id = community.channels[0].id;
4498 let chan_hex = channel_id.to_hex();
4499 let scope = super::super::derive::RekeyScope::Channel(channel_id);
4500 let genesis_key = *community.channels[0].key.as_bytes();
4501 let root = *community.server_root_key.as_bytes();
4502 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
4503
4504 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();
4508 let blob_hi1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_hi1).unwrap();
4509 let ev_lo1 = super::super::rekey::build_channel_rekey_event(
4510 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_lo1]).unwrap();
4511 let ev_hi1 = super::super::rekey::build_channel_rekey_event(
4512 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_hi1]).unwrap();
4513
4514 let relay = MemoryRelay::new();
4515 relay.inject(&ev_lo1, &community.relays);
4516 relay.inject(&ev_hi1, &community.relays);
4517 crate::db::community::advance_server_root_epoch(&cid, 1, &[0x42u8; 32]).unwrap();
4521 crate::db::community::advance_channel_epoch(&cid, &chan_hex, 1, &key_hi1).unwrap();
4523 crate::db::community::advance_channel_epoch(&cid, &chan_hex, 2, &key_e2).unwrap();
4524 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4525
4526 let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4527 assert_eq!(reached, 2, "head unchanged (no new rekey to apply)");
4528 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 2).unwrap(), Some(key_e2), "head epoch untouched");
4529 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(key_lo1),
4530 "the already-forked past epoch re-converged to the lowest sibling via the window heal (no in-sync reorg)");
4531 }
4532
4533 #[tokio::test]
4534 async fn channel_heal_cannot_converge_to_a_key_i_was_not_given() {
4535 let (_tmp, _guard) = init_test_db();
4542 let owner = Keys::generate();
4543 let me = Keys::generate();
4544 become_local(&me);
4545 let community = saved_community_owned_by(&owner);
4546 let cid = community.id.to_hex();
4547 let channel_id = community.channels[0].id;
4548 let chan_hex = channel_id.to_hex();
4549 let scope = super::super::derive::RekeyScope::Channel(channel_id);
4550 let genesis_key = *community.channels[0].key.as_bytes();
4551 let root = *community.server_root_key.as_bytes();
4552 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
4553
4554 let key_lo = [0x10u8; 32]; let key_hi = [0x99u8; 32]; let other = Keys::generate();
4558 let blob_lo = super::super::rekey::build_rekey_blob(owner.secret_key(), &other.public_key(), scope, crate::community::Epoch(1), &key_lo).unwrap();
4559 let ev_lo = super::super::rekey::build_channel_rekey_event(
4560 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_lo]).unwrap();
4561 let blob_hi = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_hi).unwrap();
4563 let ev_hi = super::super::rekey::build_channel_rekey_event(
4564 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_hi]).unwrap();
4565
4566 let relay = MemoryRelay::new();
4567 relay.inject(&ev_lo, &community.relays);
4568 relay.inject(&ev_hi, &community.relays);
4569 crate::db::community::advance_server_root_epoch(&cid, 1, &[0x42u8; 32]).unwrap();
4570 crate::db::community::advance_channel_epoch(&cid, &chan_hex, 1, &key_hi).unwrap();
4571 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4572
4573 let _ = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4574 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(key_hi),
4576 "excluded from the winning rekey ⇒ cannot converge");
4577 }
4578
4579 #[tokio::test]
4580 async fn refounding_channel_rekey_is_sealed_under_the_prior_root() {
4581 let (_tmp, _guard) = init_test_db();
4586 let owner = Keys::generate();
4587 become_local(&owner); let community = saved_community_owned_by(&owner);
4589 let channel_id = community.channels[0].id;
4590 let prior_root = [0x11u8; 32]; let relay = MemoryRelay::new();
4593 rotate_channel(&relay, &community, &channel_id, &[owner.public_key()], &prior_root).await.unwrap();
4594
4595 let z = super::super::derive::rekey_pseudonym(&crate::community::ServerRootKey(prior_root), &channel_id, crate::community::Epoch(1)).to_hex();
4597 let q = Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![z], ..Default::default() };
4598 let evs = relay.fetch(&q, &community.relays).await.unwrap();
4599 assert_eq!(evs.len(), 1, "channel rekey is addressed at the PRIOR-root pseudonym");
4600 assert!(super::super::rekey::open_rekey_event(&evs[0], &prior_root).is_ok(),
4602 "opens under the prior (shared) root every retained member still holds");
4603 assert!(super::super::rekey::open_rekey_event(&evs[0], community.server_root_key.as_bytes()).is_err(),
4604 "does NOT open under the current/new root (which a base-fork loser would have dropped)");
4605 }
4606
4607 #[tokio::test]
4608 async fn apply_channel_rekey_converges_past_a_divergent_prior_epoch() {
4609 let (_tmp, _guard) = init_test_db();
4614 let owner = Keys::generate();
4615 let me = Keys::generate();
4616 become_local(&me);
4617 let community = saved_community_owned_by(&owner);
4618 let cid = community.id.to_hex();
4619 let channel_id = community.channels[0].id;
4620 let chan_hex = channel_id.to_hex();
4621 let scope = super::super::derive::RekeyScope::Channel(channel_id);
4622 let root = *community.server_root_key.as_bytes();
4623
4624 let my_fork_key = [0xAAu8; 32];
4626 crate::db::community::advance_channel_epoch(&cid, &chan_hex, 1, &my_fork_key).unwrap();
4627
4628 let winner_epoch1 = [0xBBu8; 32];
4630 let new_key = [0x22u8; 32];
4631 let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(1), &winner_epoch1);
4632 let blob = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(2), &new_key).unwrap();
4633 let ev = super::super::rekey::build_channel_rekey_event(
4634 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(2), crate::community::Epoch(1), &commit, &[blob]).unwrap();
4635 let parsed = super::super::rekey::open_rekey_event(&ev, &root).unwrap();
4636
4637 let outcome = apply_channel_rekey(&community, &parsed).unwrap();
4638 assert!(matches!(outcome, RekeyOutcome::Applied { head_advanced: true }),
4639 "must converge forward past the divergent prior epoch, got {outcome:?}");
4640 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 2).unwrap(), Some(new_key),
4641 "adopted the winner's epoch-2 key");
4642 }
4643
4644 #[tokio::test]
4645 async fn catch_up_server_root_stops_when_removed_from_base() {
4646 let (_tmp, _guard) = init_test_db();
4649 let owner = Keys::generate();
4650 let me = Keys::generate();
4651 become_local(&me);
4652 let community = saved_community_owned_by(&owner);
4653 let scope = super::super::derive::RekeyScope::ServerRoot;
4654 let relay = MemoryRelay::new();
4655
4656 let root1 = [0x11u8; 32];
4658 let b1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &root1).unwrap();
4659 let e1 = super::super::rekey::build_server_root_rekey_event(
4660 &Keys::generate(), &owner, community.server_root_key.as_bytes(), &community.id,
4661 crate::community::Epoch(1), crate::community::Epoch(0),
4662 &super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), community.server_root_key.as_bytes()), &[b1],
4663 ).unwrap();
4664 let other = Keys::generate();
4666 let b2 = super::super::rekey::build_rekey_blob(owner.secret_key(), &other.public_key(), scope, crate::community::Epoch(2), &[0x22u8; 32]).unwrap();
4667 let e2 = super::super::rekey::build_server_root_rekey_event(
4668 &Keys::generate(), &owner, &root1, &community.id,
4669 crate::community::Epoch(2), crate::community::Epoch(1),
4670 &super::super::rekey::epoch_key_commitment(crate::community::Epoch(1), &root1), &[b2],
4671 ).unwrap();
4672 relay.inject(&e1, &community.relays);
4673 relay.inject(&e2, &community.relays);
4674
4675 let reached = catch_up_server_root(&relay, &community).await.unwrap();
4676 assert_eq!(reached.epoch, 1, "stops at the last base epoch I was a recipient of");
4677 assert!(reached.removed, "excluded by an AUTHORIZED (owner) base rotation → flagged removed so the caller erases");
4678 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4679 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(1));
4680 }
4681
4682 #[tokio::test]
4683 async fn catch_up_is_a_noop_with_no_rotations() {
4684 let (_tmp, _guard) = init_test_db();
4685 let owner = Keys::generate();
4686 let me = Keys::generate();
4687 become_local(&me);
4688 let community = saved_community_owned_by(&owner);
4689 let relay = MemoryRelay::new(); let reached = catch_up_channel_rekeys(&relay, &community, &community.channels[0].id).await.unwrap();
4691 assert_eq!(reached, 0, "no rotations → stays at the held epoch");
4692 }
4693
4694 #[tokio::test]
4695 async fn catch_up_stops_when_removed_midway() {
4696 let (_tmp, _guard) = init_test_db();
4699 let owner = Keys::generate();
4700 let me = Keys::generate();
4701 become_local(&me);
4702 let community = saved_community_owned_by(&owner);
4703 let channel_id = community.channels[0].id;
4704 let chan = &community.channels[0];
4705 let scope = super::super::derive::RekeyScope::Channel(channel_id);
4706 let relay = MemoryRelay::new();
4707
4708 let k1 = [0x11u8; 32];
4710 let b1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &k1).unwrap();
4711 let e1 = super::super::rekey::build_channel_rekey_event(
4712 &Keys::generate(), &owner, community.server_root_key.as_bytes(), &channel_id,
4713 crate::community::Epoch(1), crate::community::Epoch(0),
4714 &super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), chan.key.as_bytes()), &[b1],
4715 ).unwrap();
4716 let other = Keys::generate();
4718 let b2 = super::super::rekey::build_rekey_blob(owner.secret_key(), &other.public_key(), scope, crate::community::Epoch(2), &[0x22u8; 32]).unwrap();
4719 let e2 = super::super::rekey::build_channel_rekey_event(
4720 &Keys::generate(), &owner, community.server_root_key.as_bytes(), &channel_id,
4721 crate::community::Epoch(2), crate::community::Epoch(1),
4722 &super::super::rekey::epoch_key_commitment(crate::community::Epoch(1), &k1), &[b2],
4723 ).unwrap();
4724 relay.inject(&e1, &community.relays);
4725 relay.inject(&e2, &community.relays);
4726
4727 let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4728 assert_eq!(reached, 1, "stops at the last epoch I was a recipient of");
4729 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4730 assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(1));
4731 }
4732
4733 #[tokio::test]
4734 async fn rotate_channel_rejects_unauthorized() {
4735 let (_tmp, _guard) = init_test_db();
4736 let owner = Keys::generate();
4737 let rogue = Keys::generate();
4738 become_local(&rogue); let community = saved_community_owned_by(&owner);
4740 let relay = MemoryRelay::new();
4741 assert!(
4742 rotate_channel(&relay, &community, &community.channels[0].id, &[], community.server_root_key.as_bytes()).await.is_err(),
4743 "a non-authorized member cannot rotate"
4744 );
4745 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4747 assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(0));
4748 }
4749
4750 #[tokio::test]
4753 async fn rotate_server_root_publishes_recoverable_rekey_and_advances_base() {
4754 use crate::community::derive::{base_rekey_pseudonym, recipient_pseudonym};
4755 use crate::community::rekey::{open_rekey_blob, open_rekey_event, rekey_pairwise_secret};
4756 let (_tmp, _guard) = init_test_db();
4757 let owner = Keys::generate();
4758 become_local(&owner); let community = saved_community_owned_by(&owner);
4760 let genesis_root = *community.server_root_key.as_bytes();
4761 let member = Keys::generate();
4762 let relay = MemoryRelay::new();
4763
4764 let new_epoch = rotate_server_root(&relay, &community, &[member.public_key()]).await.expect("rotate base");
4765 assert_eq!(new_epoch, 1);
4766
4767 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4769 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(1));
4770 assert_ne!(reloaded.server_root_key.as_bytes(), &genesis_root, "base root is fresh-random, not the genesis");
4771
4772 let addr = base_rekey_pseudonym(&crate::community::ServerRootKey(genesis_root), &community.id, crate::community::Epoch(1)).to_hex();
4774 let found = relay
4775 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() }, &community.relays)
4776 .await
4777 .unwrap();
4778 assert_eq!(found.len(), 1, "base rekey addressable by its prior-root pseudonym");
4779 let parsed = open_rekey_event(&found[0], &genesis_root).unwrap();
4780 assert!(matches!(parsed.scope, crate::community::derive::RekeyScope::ServerRoot));
4781 assert_eq!(parsed.rotator, owner.public_key());
4782 assert_eq!(parsed.blobs.len(), 2, "member + me (multi-device)");
4783
4784 let secret = rekey_pairwise_secret(member.secret_key(), &parsed.rotator).unwrap();
4786 let loc = recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
4787 let mine = parsed.blobs.iter().find(|b| b.locator == loc).expect("member's blob present");
4788 let recovered = open_rekey_blob(member.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine).unwrap();
4789 assert_eq!(reloaded.server_root_key.as_bytes(), &recovered, "member's recovered root == owner's advanced base head");
4790 }
4791
4792 #[tokio::test]
4793 async fn rotate_server_root_failed_publish_leaves_base_unadvanced() {
4794 let (_tmp, _guard) = init_test_db();
4795 let owner = Keys::generate();
4796 become_local(&owner);
4797 let community = saved_community_owned_by(&owner);
4798 let member = Keys::generate();
4799 assert!(rotate_server_root(&FailingRelay, &community, &[member.public_key()]).await.is_err());
4800 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4801 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(0), "base head stays put on publish failure");
4802 }
4803
4804 #[tokio::test]
4805 async fn rotate_server_root_dedups_self_in_recipients() {
4806 use crate::community::rekey::open_rekey_event;
4808 let (_tmp, _guard) = init_test_db();
4809 let owner = Keys::generate();
4810 become_local(&owner);
4811 let community = saved_community_owned_by(&owner);
4812 let relay = MemoryRelay::new();
4813 rotate_server_root(&relay, &community, &[owner.public_key()]).await.unwrap();
4814 let addr = crate::community::derive::base_rekey_pseudonym(
4815 &crate::community::ServerRootKey(*community.server_root_key.as_bytes()), &community.id, crate::community::Epoch(1),
4816 )
4817 .to_hex();
4818 let found = relay
4819 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() }, &community.relays)
4820 .await
4821 .unwrap();
4822 let parsed = open_rekey_event(&found[0], community.server_root_key.as_bytes()).unwrap();
4823 assert_eq!(parsed.blobs.len(), 1, "self listed in recipients yields exactly one blob, not two");
4824 }
4825
4826 #[tokio::test]
4827 async fn rotate_server_root_rejects_unauthorized() {
4828 let (_tmp, _guard) = init_test_db();
4829 let owner = Keys::generate();
4830 let rogue = Keys::generate();
4831 become_local(&rogue); let community = saved_community_owned_by(&owner);
4833 let relay = MemoryRelay::new();
4834 assert!(rotate_server_root(&relay, &community, &[]).await.is_err(), "a non-BAN member cannot rotate the base");
4835 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4836 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(0));
4837 }
4838
4839 #[tokio::test]
4840 async fn rotate_server_root_reanchors_the_control_plane_to_the_new_epoch() {
4841 let (_tmp, _guard) = init_test_db();
4844 let relay = MemoryRelay::new();
4845 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
4848 let cid = community.id.to_hex();
4849 assert_eq!(crate::db::community::edition_head_entity_ids(&cid).unwrap().len(), 3);
4850
4851 let member = Keys::generate();
4852 assert_eq!(rotate_server_root(&relay, &community, &[member.public_key()]).await.unwrap(), 1);
4853 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4854 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(1), "base head advanced");
4855
4856 let z = crate::community::roster::control_pseudonym(&reloaded.server_root_key, &community.id, crate::community::Epoch(1));
4858 let evs = relay
4859 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], ..Default::default() }, &community.relays)
4860 .await
4861 .unwrap();
4862 let inners: Vec<_> = evs
4863 .iter()
4864 .filter_map(|o| crate::community::roster::open_control_edition(o, &reloaded.server_root_key).ok())
4865 .collect();
4866 let folded = crate::community::roster::fold_roster(&inners, &community.id, &Default::default());
4867 assert!(!folded.roles.roles.is_empty(), "control plane re-anchored at the new epoch as part of the rotation");
4868 }
4869
4870 #[tokio::test]
4871 async fn admin_refounding_carries_heads_verbatim_preserving_owner_and_peer_roles() {
4872 use crate::community::roles::Permissions;
4876 let (_tmp, _guard) = init_test_db();
4877 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
4878 let owner_hex = owner.public_key().to_hex();
4879 let relay = MemoryRelay::new();
4880 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
4881 let cid = community.id.to_hex();
4882 let admin_role = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
4883
4884 let alice = Keys::generate();
4886 let bob = Keys::generate();
4887 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role.clone()]).await.unwrap();
4888 set_member_grant(&relay, &community, &bob.public_key().to_hex(), vec![admin_role.clone()]).await.unwrap();
4889 let _ = fetch_and_apply_control(&relay, &community).await;
4890 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4891
4892 let mut edited = community.clone();
4895 edited.name = "HQ renamed".into();
4896 republish_community_metadata(&relay, &edited).await.unwrap();
4897 let _ = fetch_and_apply_control(&relay, &community).await;
4898 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4899 assert!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0 >= 2, "GroupRoot now above v1");
4900
4901 become_local(&alice);
4903 let new_epoch = rotate_server_root(&relay, &community, &[owner.public_key(), bob.public_key()]).await.unwrap();
4904 assert_eq!(new_epoch, 1);
4905 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4906 assert_eq!(community.server_root_epoch, crate::community::Epoch(1));
4907
4908 let z = crate::community::roster::control_pseudonym(&community.server_root_key, &community.id, crate::community::Epoch(1));
4910 let evs = relay.fetch(&Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], ..Default::default() }, &community.relays).await.unwrap();
4911 let inners: Vec<_> = evs.iter().filter_map(|o| crate::community::roster::open_control_edition(o, &community.server_root_key).ok()).collect();
4912 let folded = crate::community::roster::fold_roster(&inners, &community.id, &Default::default());
4913 let authed = crate::community::roster::authorize_delegation(&folded, Some(&owner_hex));
4914 assert!(authed.is_authorized(&alice.public_key().to_hex(), Some(&owner_hex), Permissions::BAN), "alice (re-founder) still admin");
4915 assert!(authed.is_authorized(&bob.public_key().to_hex(), Some(&owner_hex), Permissions::BAN), "bob (peer admin) NOT demoted by alice's re-founding");
4916 let new_owner = folded.root_meta.as_ref().and_then(|m| m.owner_attestation.as_ref())
4917 .and_then(|j| Event::from_json(j).ok()).map(|e| e.pubkey.to_hex());
4918 assert_eq!(new_owner.as_deref(), Some(owner_hex.as_str()), "owner deed carried verbatim — ownership intact after an admin re-founding");
4919 assert_eq!(folded.root_meta.as_ref().map(|m| m.name.as_str()), Some("HQ renamed"),
4920 "the >v1 GroupRoot head carried verbatim (content preserved across the re-founding)");
4921 let mut per_entity: std::collections::HashMap<[u8; 32], usize> = std::collections::HashMap::new();
4923 for i in &inners {
4924 if let Ok(p) = crate::community::edition::parse_edition_inner(i) { *per_entity.entry(p.entity_id).or_default() += 1; }
4925 }
4926 assert!(per_entity.values().all(|&c| c == 1), "one edition per entity at the new epoch (compacted)");
4927 }
4928
4929 #[tokio::test]
4933 async fn admin_write_blocked_when_isolated() {
4934 let (_tmp, _guard) = init_test_db();
4935 let me = Keys::generate();
4936 become_local(&me);
4937 let community = saved_community_owned_by(&me);
4938 let cid = community.id.to_hex();
4939 crate::db::community::set_edition_head_with_id(&cid, &cid, 1, &[1u8; 32], &[1u8; 32]).unwrap();
4941 crate::db::community::set_read_cut_target_epoch(&cid, 1).unwrap();
4942 let err = reseal_base_to_observed(&FailingRelay, &community).await.unwrap_err();
4944 assert!(err.contains("offline") || err.contains("can't reach any relay"),
4945 "isolated admin write must fail closed, got: {err}");
4946 assert_eq!(crate::db::community::load_community(&community.id).unwrap().unwrap().server_root_epoch,
4948 crate::community::Epoch(0), "no rotation while isolated");
4949 }
4950
4951 #[tokio::test]
4954 async fn refounding_rotates_channel_keys_too() {
4955 let (_tmp, _guard) = init_test_db();
4956 let relay = MemoryRelay::new();
4957 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
4958 let channel_id = community.channels[0].id;
4959 assert_eq!(community.channels[0].epoch, crate::community::Epoch(0));
4960 assert_eq!(community.server_root_epoch, crate::community::Epoch(0));
4961
4962 run_read_cut(&relay, &community, true).await.unwrap();
4963
4964 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
4965 assert_eq!(after.server_root_epoch, crate::community::Epoch(1), "base rotated");
4966 let ch = after.channels.iter().find(|c| c.id == channel_id).unwrap();
4967 assert_eq!(ch.epoch, crate::community::Epoch(1), "channel key rotated too (O2)");
4968 assert_eq!(crate::db::community::channel_rekeyed_at_server_epoch(&community.id.to_hex(), &channel_id.to_hex()).unwrap(),
4969 1, "channel marked rekeyed for the new base epoch");
4970 assert!(!crate::db::community::get_read_cut_pending(&community.id.to_hex()).unwrap(),
4971 "a complete read-cut clears the pending flag");
4972 }
4973
4974 #[tokio::test]
4979 async fn read_cut_resumes_without_double_base_rotation_after_channel_failure() {
4980 struct ChannelRekeyFails {
4984 inner: MemoryRelay,
4985 rekeys: std::sync::atomic::AtomicUsize,
4986 fail_channel: std::sync::atomic::AtomicBool,
4987 }
4988 #[async_trait::async_trait]
4989 impl Transport for ChannelRekeyFails {
4990 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
4991 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> { self.inner.publish(e, r).await }
4992 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
4993 if e.kind.as_u16() == event_kind::COMMUNITY_REKEY {
4994 let n = self.rekeys.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
4995 if n >= 1 && self.fail_channel.load(std::sync::atomic::Ordering::Relaxed) {
4996 return Err("channel rekey relay down".into());
4997 }
4998 }
4999 self.inner.publish_durable(e, r).await
5000 }
5001 async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> { self.inner.fetch(q, r).await }
5002 }
5003 let (_tmp, _guard) = init_test_db();
5004 let relay = ChannelRekeyFails {
5005 inner: MemoryRelay::new(),
5006 rekeys: std::sync::atomic::AtomicUsize::new(0),
5007 fail_channel: std::sync::atomic::AtomicBool::new(true),
5008 };
5009 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5010 let channel_id = community.channels[0].id;
5011 let cid = community.id.to_hex();
5012 let ch_hex = channel_id.to_hex();
5013
5014 assert!(run_read_cut(&relay, &community, true).await.is_err(), "the channel failure surfaces an error");
5016 let mid = crate::db::community::load_community(&community.id).unwrap().unwrap();
5017 assert_eq!(mid.server_root_epoch, crate::community::Epoch(1), "base advanced exactly once");
5018 assert_eq!(mid.channels.iter().find(|c| c.id == channel_id).unwrap().epoch, crate::community::Epoch(0),
5019 "channel NOT rotated (its rekey failed)");
5020 assert!(crate::db::community::get_read_cut_pending(&cid).unwrap(), "cut left pending after the failure");
5021 assert_eq!(crate::db::community::get_read_cut_target_epoch(&cid).unwrap(), 1, "target recorded durably");
5022 assert_eq!(crate::db::community::channel_rekeyed_at_server_epoch(&cid, &ch_hex).unwrap(), 0,
5023 "channel not yet marked for this cut");
5024
5025 relay.fail_channel.store(false, std::sync::atomic::Ordering::Relaxed);
5027 retry_pending_read_cut(&relay, &mid).await.unwrap();
5028 let done = crate::db::community::load_community(&community.id).unwrap().unwrap();
5029 assert_eq!(done.server_root_epoch, crate::community::Epoch(1),
5030 "base NOT rotated again — resumed at the same epoch (no double base rotation)");
5031 assert_eq!(done.channels.iter().find(|c| c.id == channel_id).unwrap().epoch, crate::community::Epoch(1),
5032 "the un-rotated channel finished on resume");
5033 assert_eq!(crate::db::community::channel_rekeyed_at_server_epoch(&cid, &ch_hex).unwrap(), 1,
5034 "channel marked rekeyed for the cut epoch");
5035 assert!(!crate::db::community::get_read_cut_pending(&cid).unwrap(), "pending cleared after the resume completes");
5036 }
5037
5038 #[tokio::test]
5039 async fn rotate_server_root_aborts_when_the_snapshot_does_not_land() {
5040 struct ControlPublishFails { inner: MemoryRelay, fail: std::sync::atomic::AtomicBool }
5045 #[async_trait::async_trait]
5046 impl Transport for ControlPublishFails {
5047 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
5048 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> { self.inner.publish(e, r).await }
5049 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
5050 if self.fail.load(std::sync::atomic::Ordering::Relaxed) && e.kind.as_u16() == event_kind::COMMUNITY_CONTROL {
5051 return Err("control relay down".into());
5052 }
5053 self.inner.publish_durable(e, r).await
5054 }
5055 async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> { self.inner.fetch(q, r).await }
5056 }
5057 let (_tmp, _guard) = init_test_db();
5058 let relay = ControlPublishFails { inner: MemoryRelay::new(), fail: std::sync::atomic::AtomicBool::new(false) };
5059 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5061 relay.fail.store(true, std::sync::atomic::Ordering::Relaxed);
5062
5063 assert!(
5064 rotate_server_root(&relay, &community, &[]).await.is_err(),
5065 "a snapshot whose editions can't be re-published must abort the rotation"
5066 );
5067 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5068 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(0), "base head NOT advanced when the snapshot doesn't land");
5069 }
5070
5071 #[tokio::test]
5072 async fn acquire_before_commit_a_reanchor_fetch_miss_publishes_no_base_rekey() {
5073 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
5078 struct ReanchorFetchEmpty { inner: MemoryRelay, drop_control: AtomicBool, base_rekeys: AtomicUsize }
5079 #[async_trait::async_trait]
5080 impl Transport for ReanchorFetchEmpty {
5081 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
5082 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> { self.inner.publish(e, r).await }
5083 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
5084 if e.kind.as_u16() == event_kind::COMMUNITY_REKEY {
5085 self.base_rekeys.fetch_add(1, Ordering::Relaxed);
5086 }
5087 self.inner.publish_durable(e, r).await
5088 }
5089 async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> {
5090 if self.drop_control.load(Ordering::Relaxed) && q.kinds.iter().any(|k| *k == event_kind::COMMUNITY_CONTROL) {
5091 return Ok(vec![]); }
5093 self.inner.fetch(q, r).await
5094 }
5095 }
5096 let (_tmp, _guard) = init_test_db();
5097 let relay = ReanchorFetchEmpty { inner: MemoryRelay::new(), drop_control: AtomicBool::new(false), base_rekeys: AtomicUsize::new(0) };
5098 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5099 relay.drop_control.store(true, Ordering::Relaxed);
5100
5101 assert!(rotate_server_root(&relay, &community, &[]).await.is_err(),
5102 "a re-anchor fetch miss must abort the rotation");
5103 assert_eq!(relay.base_rekeys.load(Ordering::Relaxed), 0,
5104 "the base rekey must NOT be published when the pre-publish fetch gate trips (acquire-before-commit)");
5105 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5106 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(0), "base head NOT advanced");
5107 }
5108
5109 #[tokio::test]
5112 async fn reanchor_carries_role_and_grant_to_the_new_epoch_under_the_new_root() {
5113 let (_tmp, _guard) = init_test_db();
5114 let relay = MemoryRelay::new();
5115 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5117 let cid = community.id.to_hex();
5118 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5119 let member = Keys::generate();
5120 set_member_grant(&relay, &community, &member.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
5122 let _ = fetch_and_apply_control(&relay, &community).await;
5123 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5124
5125 let new_root = [0x99u8; 32];
5127 let snap = reanchor_control_plane(&relay, &community, &new_root, crate::community::Epoch(1)).await.unwrap();
5128 assert!(snap.iter().all(|e| e.published), "every snapshot edition published");
5129 assert_eq!(snap.len(), 4, "GroupRoot + channel + Admin role + grant compacted to v1");
5130
5131 let new_z = crate::community::roster::control_pseudonym(
5133 &crate::community::ServerRootKey(new_root), &community.id, crate::community::Epoch(1),
5134 );
5135 let after = relay
5136 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![new_z], ..Default::default() }, &community.relays)
5137 .await
5138 .unwrap();
5139 let inners: Vec<_> = after
5140 .iter()
5141 .filter_map(|o| crate::community::roster::open_control_edition(o, &crate::community::ServerRootKey(new_root)).ok())
5142 .collect();
5143 let folded = crate::community::roster::fold_roster(&inners, &community.id, &Default::default());
5144 assert!(!folded.roles.roles.is_empty(), "Admin role reachable at the new epoch");
5145 assert!(
5146 folded.roles.grants.iter().any(|g| g.member == member.public_key().to_hex()),
5147 "grant carried to the new epoch under the new root"
5148 );
5149 }
5150
5151 #[tokio::test]
5152 async fn grant_after_a_rekey_survives_the_fold_at_the_new_epoch() {
5153 use crate::community::roles::Permissions;
5159 let (_tmp, _guard) = init_test_db();
5160 let relay = MemoryRelay::new();
5161 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5162 let cid = community.id.to_hex();
5163 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
5164 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5165
5166 rotate_server_root(&relay, &community, &[owner.public_key()]).await.expect("rotate base");
5168 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5169 assert_eq!(community.server_root_epoch, crate::community::Epoch(1), "advanced to the new epoch");
5170
5171 let alice = "aa".repeat(32);
5173 set_member_grant(&relay, &community, &alice, vec![admin_role_id]).await.unwrap();
5174
5175 let roster = fetch_and_apply_roles(&relay, &community).await.unwrap();
5177 assert!(
5178 roster.has_permission(&alice, Permissions::BAN),
5179 "post-rekey grant survives — Alice is Admin at the new epoch (pre-fix: dropped, role unreachable)"
5180 );
5181 assert_eq!(roster.highest_position(&alice), Some(1));
5182 }
5183
5184 #[tokio::test]
5188 async fn demote_re_asserts_the_demoted_members_metadata_head() {
5189 let (_tmp, _guard) = init_test_db();
5190 let relay = MemoryRelay::new();
5191 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5192 let cid = community.id.to_hex();
5193 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
5194 let admin_role = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5195 let alice = Keys::generate();
5196 let alice_hex = alice.public_key().to_hex();
5197
5198 set_member_grant(&relay, &community, &alice_hex, vec![admin_role]).await.unwrap();
5199 become_local(&alice);
5201 let mut as_alice = crate::db::community::load_community(&community.id).unwrap().unwrap();
5202 as_alice.name = "Alice's HQ".into();
5203 republish_community_metadata(&relay, &as_alice).await.unwrap();
5204 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5205 assert_eq!(
5206 fetch_control_folded(&relay, &community).await.unwrap().root_author.map(|a| a.to_hex()),
5207 Some(alice_hex.clone()), "alice heads the GroupRoot after her edit",
5208 );
5209
5210 become_local(&owner);
5212 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5213 set_member_grant(&relay, &community, &alice_hex, vec![]).await.unwrap();
5214
5215 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5216 let folded = fetch_control_folded(&relay, &community).await.unwrap();
5217 assert_eq!(folded.root_author.map(|a| a.to_hex()), Some(owner.public_key().to_hex()),
5218 "the demote re-asserted the GroupRoot under the owner");
5219 assert_eq!(folded.root_meta.as_ref().unwrap().name, "Alice's HQ",
5220 "the re-assert preserves the demoted member's content");
5221 }
5222
5223 #[tokio::test]
5226 async fn demote_skips_reassert_when_member_does_not_head() {
5227 let (_tmp, _guard) = init_test_db();
5228 let relay = MemoryRelay::new();
5229 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5230 let cid = community.id.to_hex();
5231 let admin_role = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5232 let alice = Keys::generate();
5233 let alice_hex = alice.public_key().to_hex();
5234
5235 set_member_grant(&relay, &community, &alice_hex, vec![admin_role]).await.unwrap();
5236 let mut c = crate::db::community::load_community(&community.id).unwrap().unwrap();
5238 c.name = "Owner's HQ".into();
5239 republish_community_metadata(&relay, &c).await.unwrap();
5240 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5241 let before = fetch_control_folded(&relay, &community).await.unwrap().root_head.unwrap().version;
5242
5243 set_member_grant(&relay, &community, &alice_hex, vec![]).await.unwrap();
5244 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5245 let after = fetch_control_folded(&relay, &community).await.unwrap().root_head.unwrap().version;
5246 assert_eq!(after, before, "no re-assert published — the demoted member didn't head the GroupRoot");
5247 }
5248
5249 #[tokio::test]
5250 async fn reanchor_carries_the_banlist_edition_to_the_new_epoch() {
5251 let (_tmp, _guard) = init_test_db();
5254 let relay = MemoryRelay::new();
5255 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5256 let carol = "cc".repeat(32);
5257 publish_banlist(&relay, &community, &[carol.clone()]).await.unwrap();
5259 let _ = fetch_and_apply_control(&relay, &community).await;
5260 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5261
5262 let new_root = [0x99u8; 32];
5264 let n = reanchor_control_plane(&relay, &community, &new_root, crate::community::Epoch(1)).await.unwrap();
5265 assert!(n.iter().all(|e| e.published), "every snapshot edition published");
5266 assert_eq!(n.len(), 4, "GroupRoot + channel + Admin role + banlist compacted to v1");
5267
5268 let new_z = crate::community::roster::control_pseudonym(
5270 &crate::community::ServerRootKey(new_root), &community.id, crate::community::Epoch(1),
5271 );
5272 let after = relay
5273 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![new_z], ..Default::default() }, &community.relays)
5274 .await
5275 .unwrap();
5276 let inners: Vec<_> = after
5277 .iter()
5278 .filter_map(|o| crate::community::roster::open_control_edition(o, &crate::community::ServerRootKey(new_root)).ok())
5279 .collect();
5280 let folded = crate::community::roster::fold_roster(&inners, &community.id, &Default::default());
5281 assert_eq!(folded.banned, vec![carol], "banlist reachable at the new epoch under the new root");
5282 }
5283
5284 fn owner_base_rekey(
5289 owner: &Keys, community: &Community, recipient_pk: &nostr_sdk::PublicKey, new_epoch: u64, new_root: &[u8; 32],
5290 ) -> super::super::rekey::ParsedRekey {
5291 let prev = community.server_root_epoch.0;
5292 let blob = super::super::rekey::build_rekey_blob(
5293 owner.secret_key(), recipient_pk, super::super::derive::RekeyScope::ServerRoot, crate::community::Epoch(new_epoch), new_root,
5294 )
5295 .unwrap();
5296 let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(prev), community.server_root_key.as_bytes());
5297 let outer = super::super::rekey::build_server_root_rekey_event(
5298 &Keys::generate(), owner, community.server_root_key.as_bytes(), &community.id,
5299 crate::community::Epoch(new_epoch), crate::community::Epoch(prev), &commit, &[blob],
5300 )
5301 .unwrap();
5302 super::super::rekey::open_rekey_event(&outer, community.server_root_key.as_bytes()).unwrap()
5303 }
5304
5305 #[test]
5306 fn apply_server_root_rekey_recovers_new_root_and_advances_base() {
5307 let (_tmp, _guard) = init_test_db();
5308 let owner = Keys::generate();
5309 let me = Keys::generate();
5310 become_local(&me);
5311 let community = saved_community_owned_by(&owner);
5312 let cid = community.id.to_hex();
5313 let new_root = [0xCDu8; 32];
5314
5315 let parsed = owner_base_rekey(&owner, &community, &me.public_key(), 1, &new_root);
5316 assert_eq!(apply_server_root_rekey(&community, &parsed).unwrap(), RekeyOutcome::Applied { head_advanced: true });
5317
5318 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5319 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(1));
5320 assert_eq!(reloaded.server_root_key.as_bytes(), &new_root, "base head advanced to the new root");
5321 assert!(crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 0).unwrap().is_some());
5323 assert_eq!(crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap(), Some(new_root));
5324 }
5325
5326 #[test]
5327 fn apply_server_root_rekey_not_a_recipient_leaves_base_unchanged() {
5328 let (_tmp, _guard) = init_test_db();
5329 let owner = Keys::generate();
5330 let me = Keys::generate();
5331 become_local(&me);
5332 let community = saved_community_owned_by(&owner);
5333 let other = Keys::generate(); let parsed = owner_base_rekey(&owner, &community, &other.public_key(), 1, &[0x11u8; 32]);
5335 assert_eq!(apply_server_root_rekey(&community, &parsed).unwrap(), RekeyOutcome::NotARecipient);
5336 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5337 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(0), "removed-from-base member's head unchanged");
5338 }
5339
5340 #[test]
5341 fn apply_server_root_rekey_rejects_rotator_without_ban() {
5342 let (_tmp, _guard) = init_test_db();
5343 let owner = Keys::generate();
5344 let me = Keys::generate();
5345 become_local(&me);
5346 let community = saved_community_owned_by(&owner);
5347 let rogue = Keys::generate();
5349 let parsed = owner_base_rekey(&rogue, &community, &me.public_key(), 1, &[0x22u8; 32]);
5350 assert!(apply_server_root_rekey(&community, &parsed).is_err(), "unauthorized base rotation rejected");
5351 }
5352
5353 #[test]
5354 fn apply_server_root_rekey_reorgs_onto_authorized_chain_despite_prior_mismatch() {
5355 let (_tmp, _guard) = init_test_db();
5360 let owner = Keys::generate();
5361 let me = Keys::generate();
5362 become_local(&me);
5363 let community = saved_community_owned_by(&owner);
5364 let blob = super::super::rekey::build_rekey_blob(
5365 owner.secret_key(), &me.public_key(), super::super::derive::RekeyScope::ServerRoot, crate::community::Epoch(1), &[0x33u8; 32],
5366 )
5367 .unwrap();
5368 let bad = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &[0xFFu8; 32]);
5370 let outer = super::super::rekey::build_server_root_rekey_event(
5371 &Keys::generate(), &owner, community.server_root_key.as_bytes(), &community.id,
5372 crate::community::Epoch(1), crate::community::Epoch(0), &bad, &[blob],
5373 )
5374 .unwrap();
5375 let parsed = super::super::rekey::open_rekey_event(&outer, community.server_root_key.as_bytes()).unwrap();
5376 let outcome = apply_server_root_rekey(&community, &parsed);
5377 assert!(
5378 matches!(outcome, Ok(RekeyOutcome::Applied { .. })),
5379 "an authorized base chain must be adopted (reorg), not rejected as foreign; got {outcome:?}"
5380 );
5381 }
5382
5383 #[test]
5384 fn apply_server_root_rekey_catchup_archives_without_regressing_base_head() {
5385 let (_tmp, _guard) = init_test_db();
5388 let owner = Keys::generate();
5389 let me = Keys::generate();
5390 become_local(&me);
5391 let community = saved_community_owned_by(&owner);
5392 let cid = community.id.to_hex();
5393
5394 let r5 = [0x55u8; 32];
5395 let p5 = owner_base_rekey(&owner, &community, &me.public_key(), 5, &r5);
5396 assert_eq!(apply_server_root_rekey(&community, &p5).unwrap(), RekeyOutcome::Applied { head_advanced: true });
5397 let r3 = [0x33u8; 32];
5398 let p3 = owner_base_rekey(&owner, &community, &me.public_key(), 3, &r3);
5399 assert_eq!(apply_server_root_rekey(&community, &p3).unwrap(), RekeyOutcome::Applied { head_advanced: false });
5400
5401 assert_eq!(crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 3).unwrap(), Some(r3));
5402 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5403 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(5), "base head stayed at newest");
5404 assert_eq!(reloaded.server_root_key.as_bytes(), &r5);
5405 }
5406
5407 #[test]
5408 fn apply_server_root_rekey_authorizes_a_granted_ban_admin() {
5409 let (_tmp, _guard) = init_test_db();
5413 let owner = Keys::generate();
5414 let me = Keys::generate();
5415 become_local(&me);
5416 let community = saved_community_owned_by(&owner);
5417 let cid = community.id.to_hex();
5418
5419 let admin = Keys::generate();
5420 let role_id = "d".repeat(64);
5421 let roster = crate::community::roles::CommunityRoles {
5422 roles: vec![crate::community::roles::Role::admin(role_id.clone())],
5423 grants: vec![crate::community::roles::MemberGrant { member: admin.public_key().to_hex(), role_ids: vec![role_id] }],
5424 };
5425 crate::db::community::set_community_roles(&cid, &roster, 1).unwrap();
5426
5427 let parsed = owner_base_rekey(&admin, &community, &me.public_key(), 1, &[0x77u8; 32]);
5428 assert_eq!(
5429 apply_server_root_rekey(&community, &parsed).unwrap(),
5430 RekeyOutcome::Applied { head_advanced: true },
5431 "a BAN-granted admin (not the owner) can rotate the base"
5432 );
5433 }
5434
5435 #[test]
5436 fn apply_server_root_rekey_accepts_when_prior_root_not_held() {
5437 let (_tmp, _guard) = init_test_db();
5440 let owner = Keys::generate();
5441 let me = Keys::generate();
5442 become_local(&me);
5443 let community = saved_community_owned_by(&owner);
5444
5445 let new_root = [0x99u8; 32];
5446 let blob = super::super::rekey::build_rekey_blob(
5447 owner.secret_key(), &me.public_key(), super::super::derive::RekeyScope::ServerRoot, crate::community::Epoch(5), &new_root,
5448 )
5449 .unwrap();
5450 let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(4), &[0xEEu8; 32]);
5452 let outer = super::super::rekey::build_server_root_rekey_event(
5453 &Keys::generate(), &owner, community.server_root_key.as_bytes(), &community.id,
5454 crate::community::Epoch(5), crate::community::Epoch(4), &commit, &[blob],
5455 )
5456 .unwrap();
5457 let parsed = super::super::rekey::open_rekey_event(&outer, community.server_root_key.as_bytes()).unwrap();
5458 assert_eq!(apply_server_root_rekey(&community, &parsed).unwrap(), RekeyOutcome::Applied { head_advanced: true });
5459 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5460 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(5));
5461 }
5462
5463 #[test]
5464 fn apply_server_root_rekey_rejects_channel_scope() {
5465 let (_tmp, _guard) = init_test_db();
5467 let owner = Keys::generate();
5468 let me = Keys::generate();
5469 become_local(&me);
5470 let community = saved_community_owned_by(&owner);
5471 let channel_parsed = owner_channel_rekey(&owner, &community, &me.public_key(), 1, &[0x44u8; 32]);
5472 assert!(apply_server_root_rekey(&community, &channel_parsed).is_err(), "channel scope rejected by base apply");
5473 }
5474
5475 #[test]
5476 fn apply_channel_rekey_not_a_recipient() {
5477 let (_tmp, _guard) = init_test_db();
5478 let owner = Keys::generate();
5479 let me = Keys::generate();
5480 become_local(&me);
5481 let community = saved_community_owned_by(&owner);
5482 let other = Keys::generate();
5484 let parsed = owner_channel_rekey(&owner, &community, &other.public_key(), 1, &[0x11u8; 32]);
5485 assert_eq!(apply_channel_rekey(&community, &parsed).unwrap(), RekeyOutcome::NotARecipient);
5486 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5488 assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(0));
5489 }
5490
5491 #[test]
5492 fn apply_channel_rekey_rejects_unauthorized_rotator() {
5493 let (_tmp, _guard) = init_test_db();
5494 let owner = Keys::generate();
5495 let me = Keys::generate();
5496 become_local(&me);
5497 let community = saved_community_owned_by(&owner);
5498 let rogue = Keys::generate();
5500 let parsed = owner_channel_rekey(&rogue, &community, &me.public_key(), 1, &[0x22u8; 32]);
5501 assert!(apply_channel_rekey(&community, &parsed).is_err(), "unauthorized rotation must be rejected");
5502 }
5503
5504 #[test]
5505 fn apply_channel_rekey_reorgs_onto_authorized_chain_despite_prior_mismatch() {
5506 let (_tmp, _guard) = init_test_db();
5513 let owner = Keys::generate();
5514 let me = Keys::generate();
5515 become_local(&me);
5516 let community = saved_community_owned_by(&owner);
5517 let chan = &community.channels[0];
5518 let scope = super::super::derive::RekeyScope::Channel(chan.id);
5519 let new_key = [0x33u8; 32];
5520 let blob = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &new_key).unwrap();
5521 let other_commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &[0xFFu8; 32]);
5523 let outer = super::super::rekey::build_channel_rekey_event(
5524 &Keys::generate(), &owner, community.server_root_key.as_bytes(), &chan.id,
5525 crate::community::Epoch(1), crate::community::Epoch(0), &other_commit, &[blob],
5526 )
5527 .unwrap();
5528 let parsed = super::super::rekey::open_rekey_event(&outer, community.server_root_key.as_bytes()).unwrap();
5529 let outcome = apply_channel_rekey(&community, &parsed).unwrap();
5530 assert!(matches!(outcome, RekeyOutcome::Applied { .. }),
5531 "an authorized chain must be adopted (reorg), not rejected as foreign; got {outcome:?}");
5532 assert_eq!(crate::db::community::held_epoch_key(&community.id.to_hex(), &chan.id.to_hex(), 1).unwrap(), Some(new_key));
5533 }
5534
5535 #[test]
5536 fn apply_channel_rekey_catchup_archives_without_regressing_head() {
5537 let (_tmp, _guard) = init_test_db();
5538 let owner = Keys::generate();
5539 let me = Keys::generate();
5540 become_local(&me);
5541 let community = saved_community_owned_by(&owner);
5542 let cid = community.id.to_hex();
5543 let chan_hex = community.channels[0].id.to_hex();
5544
5545 let k5 = [0x55u8; 32];
5547 let p5 = owner_channel_rekey(&owner, &community, &me.public_key(), 5, &k5);
5548 assert_eq!(apply_channel_rekey(&community, &p5).unwrap(), RekeyOutcome::Applied { head_advanced: true });
5549 let k3 = [0x33u8; 32];
5551 let p3 = owner_channel_rekey(&owner, &community, &me.public_key(), 3, &k3);
5552 assert_eq!(apply_channel_rekey(&community, &p3).unwrap(), RekeyOutcome::Applied { head_advanced: false });
5553
5554 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 3).unwrap(), Some(k3), "old epoch archived");
5555 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 5).unwrap(), Some(k5));
5556 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5557 assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(5), "head stayed at the newest epoch");
5558 assert_eq!(reloaded.channels[0].key.as_bytes(), &k5);
5559 }
5560
5561 #[tokio::test]
5562 async fn create_community_persists_and_publishes_metadata() {
5563 use crate::community::transport::Query;
5564 use crate::stored_event::event_kind;
5565
5566 let (_tmp, _guard) = init_test_db();
5567 let relay = MemoryRelay::new();
5568 let community = create_community(&relay, "Vector HQ", "general", vec!["r1".into()])
5569 .await
5570 .expect("create");
5571
5572 assert_eq!(community.name, "Vector HQ");
5574 assert_eq!(community.channels.len(), 1);
5575 assert_eq!(community.channels[0].name, "general");
5576
5577 let loaded = crate::db::community::load_community(&community.id).unwrap().expect("persisted");
5579 assert_eq!(loaded.channels[0].name, "general");
5580 assert_eq!(loaded.server_root_key.as_bytes(), community.server_root_key.as_bytes());
5581
5582 let meta_events = relay
5585 .fetch(
5586 &Query { kinds: vec![event_kind::APPLICATION_SPECIFIC], ..Default::default() },
5587 &community.relays,
5588 )
5589 .await
5590 .unwrap();
5591 assert!(meta_events.is_empty(), "no legacy 30078 metadata events");
5592
5593 let z = crate::community::roster::control_pseudonym(&community.server_root_key, &community.id, crate::community::Epoch(0));
5596 let control = relay
5597 .fetch(
5598 &Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], ..Default::default() },
5599 &community.relays,
5600 )
5601 .await
5602 .unwrap();
5603 assert_eq!(control.len(), 3, "GroupRoot + ChannelMetadata + Admin role editions");
5604 let owner_pk = crate::state::my_public_key().unwrap();
5605 let parsed: Vec<_> = control
5606 .iter()
5607 .filter_map(|o| crate::community::roster::open_control_edition(o, &community.server_root_key).ok())
5608 .filter_map(|i| crate::community::edition::parse_edition_inner(&i).ok())
5609 .collect();
5610 assert!(parsed.iter().all(|p| p.author == owner_pk), "every genesis edition authored by the owner");
5611 let root = parsed.iter().find(|p| p.entity_id == community.id.0).expect("GroupRoot edition");
5613 let root_meta: crate::community::metadata::CommunityMetadata = serde_json::from_str(&root.content).unwrap();
5614 assert_eq!(root_meta.name, "Vector HQ");
5615 assert!(root_meta.owner_attestation.is_some());
5616 let role: crate::community::roles::Role = parsed
5618 .iter()
5619 .find_map(|p| serde_json::from_str::<crate::community::roles::Role>(&p.content).ok().filter(|r| r.name == "Admin"))
5620 .expect("Admin role edition");
5621 assert_eq!(role.position, 1);
5622 assert!(role.permissions.contains(crate::community::roles::Permissions::ADMIN_ALL));
5623
5624 let cached = crate::db::community::get_community_roles(&community.id.to_hex()).unwrap();
5626 assert_eq!(cached.roles.len(), 1);
5627 assert!(cached.grants.is_empty(), "owner is implicit position 0, takes no grant");
5628 }
5629
5630 #[tokio::test]
5631 async fn role_grant_round_trips_through_relays_and_revokes() {
5632 use crate::community::roles::Permissions;
5633 let (_tmp, _guard) = init_test_db();
5634 let relay = MemoryRelay::new();
5635 let community = create_community(&relay, "HQ", "general", vec!["r1".into()])
5636 .await
5637 .expect("create");
5638 let cid = community.id.to_hex();
5639 let alice = "aa".repeat(32);
5640 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0]
5641 .role_id
5642 .clone();
5643
5644 set_member_grant(&relay, &community, &alice, vec![admin_role_id.clone()])
5646 .await
5647 .unwrap();
5648 assert!(
5649 crate::db::community::get_community_roles(&cid).unwrap().is_privileged(&alice),
5650 "local cache reflects the grant immediately"
5651 );
5652
5653 let roster = fetch_and_apply_roles(&relay, &community).await.unwrap();
5656 assert!(roster.has_permission(&alice, Permissions::BAN));
5657 assert!(roster.has_permission(&alice, Permissions::MANAGE_ROLES));
5658 assert_eq!(roster.roles.len(), 1);
5659 assert_eq!(roster.highest_position(&alice), Some(1));
5660
5661 set_member_grant(&relay, &community, &alice, vec![]).await.unwrap();
5663 let after = crate::db::community::get_community_roles(&cid).unwrap();
5664 assert!(!after.is_privileged(&alice), "revoked member holds no role");
5665 assert!(after.grants.is_empty(), "empty grant pruned");
5666 }
5667
5668 #[tokio::test]
5669 async fn admin_cannot_grant_a_peer_rank_role() {
5670 let (_tmp, _guard) = init_test_db();
5674 let relay = MemoryRelay::new();
5675 let community = create_community(&relay, "HQ", "general", vec!["r1".into()])
5676 .await
5677 .expect("create");
5678 let cid = community.id.to_hex();
5679 let admin_role_id =
5680 crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5681 let alice = Keys::generate();
5682 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id.clone()])
5684 .await
5685 .unwrap();
5686
5687 crate::state::set_my_public_key(alice.public_key());
5689 let bob = Keys::generate().public_key();
5690 let err = grant_role(&relay, &community, bob, &admin_role_id).await.unwrap_err();
5691 assert!(err.contains("below your own"), "peer-rank grant refused, got: {err}");
5692 }
5693
5694 #[tokio::test]
5695 async fn create_community_mints_a_verifiable_owner_attestation() {
5696 let (_tmp, _guard) = init_test_db();
5699 let me = crate::state::my_public_key().unwrap();
5700 let relay = MemoryRelay::new();
5701 let community = create_community(&relay, "HQ", "general", vec!["r1".into()])
5702 .await
5703 .expect("create");
5704 let att = community.owner_attestation.as_ref().expect("attestation is mandatory");
5705 let proven = super::super::owner::verify_owner_attestation(att, &community.id.to_hex());
5706 assert_eq!(proven, Some(me), "the creator is the proven owner");
5707 assert_eq!(
5709 super::super::owner::verify_owner_attestation(att, &"f".repeat(64)),
5710 None,
5711 );
5712 }
5713
5714 #[tokio::test]
5715 async fn admin_cannot_ban_a_peer_admin() {
5716 let (_tmp, _guard) = init_test_db();
5720 let relay = MemoryRelay::new();
5721 let community = create_community(&relay, "HQ", "general", vec!["r1".into()])
5722 .await
5723 .expect("create");
5724 let cid = community.id.to_hex();
5725 let admin_role_id =
5726 crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5727 let alice = Keys::generate();
5728 let bob = Keys::generate();
5729 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id.clone()])
5731 .await
5732 .unwrap();
5733 set_member_grant(&relay, &community, &bob.public_key().to_hex(), vec![admin_role_id.clone()])
5734 .await
5735 .unwrap();
5736
5737 become_local(&alice);
5740 let err = publish_banlist(&relay, &community, &[bob.public_key().to_hex()])
5741 .await
5742 .unwrap_err();
5743 assert!(err.contains("outranks you"), "peer-admin ban refused, got: {err}");
5744 }
5745
5746 #[tokio::test]
5747 async fn roster_reconstructs_purely_from_relay() {
5748 let (_tmp, _guard) = init_test_db();
5752 let relay = MemoryRelay::new();
5753 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5754 let cid = community.id.to_hex();
5755 let admin_role_id =
5756 crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5757 let alice = "aa".repeat(32);
5758 set_member_grant(&relay, &community, &alice, vec![admin_role_id.clone()]).await.unwrap();
5759
5760 crate::db::community::set_community_roles(&cid, &crate::community::roles::CommunityRoles::default(), 0).unwrap();
5762 assert!(crate::db::community::get_community_roles(&cid).unwrap().roles.is_empty(), "cache wiped");
5763
5764 let roster = fetch_and_apply_roles(&relay, &community).await.unwrap();
5765 assert!(roster.is_admin(&alice), "roster reconstructed from relay editions, not the cache");
5766 assert_eq!(roster.roles.len(), 1, "the Admin role edition folded back");
5767 }
5768
5769 #[tokio::test]
5770 async fn admin_cannot_unban_a_peer_admin() {
5771 let (_tmp, _guard) = init_test_db();
5774 let relay = MemoryRelay::new();
5775 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5776 let cid = community.id.to_hex();
5777 let admin_role_id =
5778 crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5779 let alice = Keys::generate();
5780 let bob = Keys::generate();
5781 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 crate::db::community::set_community_banlist(&cid, &[bob.public_key().to_hex()], 1000).unwrap();
5789
5790 become_local(&alice);
5792 let err = publish_banlist(&relay, &community, &[]).await.unwrap_err();
5793 assert!(err.contains("unban"), "unbanning a peer admin refused, got: {err}");
5794 }
5795
5796 #[tokio::test]
5797 async fn create_community_rejects_signer_identity_mismatch() {
5798 let (_tmp, _guard) = init_test_db(); let other = Keys::generate();
5803 crate::state::set_my_public_key(other.public_key()); let relay = MemoryRelay::new();
5805 let err = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap_err();
5806 assert!(err.contains("identity signer"), "signer mismatch refused, got: {err}");
5807 }
5808
5809 #[tokio::test]
5810 async fn banlist_newer_edition_applies_older_is_refused() {
5811 let (_tmp, _guard) = init_test_db();
5812 let relay = MemoryRelay::new();
5813 let community = create_community(&relay, "HQ", "general", vec!["r1".into()])
5814 .await
5815 .expect("create");
5816 let id_hex = community.id.to_hex();
5817 let banlist_entity = crate::simd::hex::bytes_to_hex_32(&crate::community::derive::banlist_locator(&community.id));
5818 let mallory = "aa".repeat(32);
5819 let bob = "bb".repeat(32);
5820
5821 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
5824 let inner = crate::community::roster::build_banlist_edition(&owner, &community.id, &[mallory.clone()], 1, None, 1000, None).unwrap();
5825 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
5826 relay.inject(&outer, &community.relays);
5827
5828 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
5830 assert_eq!(applied, vec![mallory.clone()]);
5831 let (head_v, _) = crate::db::community::get_edition_head(&id_hex, &banlist_entity).unwrap().unwrap();
5832 assert_eq!(head_v, 1, "banlist edition head advanced to v1");
5833
5834 crate::db::community::set_community_banlist(&id_hex, &[mallory.clone(), bob.clone()], 2).unwrap();
5837 crate::db::community::set_edition_head(&id_hex, &banlist_entity, 2, &[0x22u8; 32]).unwrap();
5838 let after = fetch_and_apply_banlist(&relay, &community).await.unwrap();
5839 assert_eq!(after, vec![mallory, bob], "older relay edition refused, local banlist preserved");
5840 }
5841
5842 #[tokio::test]
5843 async fn unauthorized_banlist_edition_is_rejected() {
5844 let (_tmp, _guard) = init_test_db();
5848 let relay = MemoryRelay::new();
5849 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5850 let bob = "bb".repeat(32);
5851
5852 let mallory = Keys::generate();
5854 let inner = crate::community::roster::build_banlist_edition(&mallory, &community.id, &[bob], 1, None, 1000, None).unwrap();
5855 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
5856 relay.inject(&outer, &community.relays);
5857
5858 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
5860 assert!(applied.is_empty(), "an unauthorized signer's banlist edition is rejected");
5861 }
5862
5863 #[tokio::test]
5864 async fn banlist_receiver_enforces_per_target_outrank() {
5865 let (_tmp, _guard) = init_test_db();
5868 let relay = MemoryRelay::new();
5869 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5870 let cid = community.id.to_hex();
5871 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5872 let alice = Keys::generate();
5873 let bob = Keys::generate();
5874 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id.clone()]).await.unwrap();
5876 set_member_grant(&relay, &community, &bob.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
5877
5878 let cite = authority_citation(&community, &alice.public_key().to_hex());
5881 let inner = crate::community::roster::build_banlist_edition(&alice, &community.id, &[bob.public_key().to_hex()], 1, None, 1000, cite.as_ref()).unwrap();
5882 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
5883 relay.inject(&outer, &community.relays);
5884
5885 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
5887 assert!(applied.is_empty(), "an admin can't ban a peer admin (receiver-side outrank)");
5888 }
5889
5890 #[tokio::test]
5891 async fn banlist_admin_bans_regular_member_applies() {
5892 let (_tmp, _guard) = init_test_db();
5895 let relay = MemoryRelay::new();
5896 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5897 let cid = community.id.to_hex();
5898 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5899 let alice = Keys::generate();
5900 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
5901
5902 let carol = "cc".repeat(32);
5903 let cite = authority_citation(&community, &alice.public_key().to_hex());
5905 let inner = crate::community::roster::build_banlist_edition(&alice, &community.id, &[carol.clone()], 1, None, 1000, cite.as_ref()).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();
5910 assert_eq!(applied, vec![carol], "an admin's ban of a regular member applies");
5911 }
5912
5913 #[tokio::test]
5914 async fn owner_banlist_needs_no_citation() {
5915 let (_tmp, _guard) = init_test_db();
5918 let relay = MemoryRelay::new();
5919 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5920 let victim = "cc".repeat(32);
5921
5922 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
5924 let inner = crate::community::roster::build_banlist_edition(&owner, &community.id, &[victim.clone()], 1, None, 1000, None).unwrap();
5925 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
5926 relay.inject(&outer, &community.relays);
5927
5928 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
5929 assert_eq!(applied, vec![victim], "an owner's uncited ban applies");
5930 }
5931
5932 #[tokio::test]
5933 async fn banlist_with_forged_citation_hash_is_rejected() {
5934 let (_tmp, _guard) = init_test_db();
5937 let relay = MemoryRelay::new();
5938 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5939 let cid = community.id.to_hex();
5940 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5941 let alice = Keys::generate();
5942 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
5943
5944 let carol = "cc".repeat(32);
5945 let mut cite = authority_citation(&community, &alice.public_key().to_hex()).unwrap();
5947 cite.edition_hash = [0xEE; 32];
5948 let inner = crate::community::roster::build_banlist_edition(&alice, &community.id, &[carol], 1, None, 1000, Some(&cite)).unwrap();
5949 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
5950 relay.inject(&outer, &community.relays);
5951
5952 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
5953 assert!(applied.is_empty(), "a forged-hash citation is rejected");
5954 }
5955
5956 #[tokio::test]
5957 async fn banlist_citing_unsynced_future_version_is_rejected() {
5958 let (_tmp, _guard) = init_test_db();
5962 let relay = MemoryRelay::new();
5963 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5964 let cid = community.id.to_hex();
5965 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5966 let alice = Keys::generate();
5967 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
5968
5969 let carol = "cc".repeat(32);
5970 let mut cite = authority_citation(&community, &alice.public_key().to_hex()).unwrap();
5971 cite.version += 5; let inner = crate::community::roster::build_banlist_edition(&alice, &community.id, &[carol], 1, None, 1000, Some(&cite)).unwrap();
5973 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
5974 relay.inject(&outer, &community.relays);
5975
5976 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
5977 assert!(applied.is_empty(), "citing an unsynced future grant version fails closed");
5978 }
5979
5980 #[tokio::test]
5981 async fn demoted_banner_superseded_ban_is_rejected() {
5982 let (_tmp, _guard) = init_test_db();
5986 let relay = MemoryRelay::new();
5987 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5988 let cid = community.id.to_hex();
5989 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5990 let alice = Keys::generate();
5991 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
5992
5993 let carol = "cc".repeat(32);
5994 let cite = authority_citation(&community, &alice.public_key().to_hex());
5996 let inner = crate::community::roster::build_banlist_edition(&alice, &community.id, &[carol], 1, None, 1000, cite.as_ref()).unwrap();
5997 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
5998 relay.inject(&outer, &community.relays);
5999
6000 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![]).await.unwrap();
6002
6003 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
6004 assert!(applied.is_empty(), "a since-demoted banner's stale ban is rejected (refuse-superseded)");
6005 }
6006
6007 #[tokio::test]
6008 async fn withheld_revocation_cannot_resurrect_a_demoted_banners_grant() {
6009 let (_tmp, _guard) = init_test_db();
6015 let relay = MemoryRelay::new();
6016 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6017 let cid = community.id.to_hex();
6018 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
6019 let alice = Keys::generate();
6020 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
6021
6022 let carol = "cc".repeat(32);
6024 let cite = authority_citation(&community, &alice.public_key().to_hex());
6025 let inner = crate::community::roster::build_banlist_edition(&alice, &community.id, &[carol], 1, None, 1000, cite.as_ref()).unwrap();
6026 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6027 relay.inject(&outer, &community.relays);
6028
6029 let alice_bytes = alice.public_key().to_bytes();
6032 let grant_entity = crate::simd::hex::bytes_to_hex_32(&crate::community::derive::grant_locator(&community.id, &alice_bytes));
6033 crate::db::community::set_edition_head(&cid, &grant_entity, 2, &[0xAB; 32]).unwrap();
6034
6035 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
6036 assert!(applied.is_empty(), "a withheld revocation can't roll the banner's grant back to re-authorize them");
6037 }
6038
6039 #[tokio::test]
6040 async fn invite_registry_round_trips_and_drives_is_public() {
6041 let (_tmp, _guard) = init_test_db();
6045 let relay = MemoryRelay::new();
6046 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6047 assert!(!is_public(&community).unwrap(), "a fresh community is Private");
6048
6049 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6052 let loc = "1a".repeat(32);
6053 let inner = crate::community::roster::build_invite_links_edition(&owner, &community.id, &[loc.clone()], 1, None, 1000, None).unwrap();
6054 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6055 relay.inject(&outer, &community.relays);
6056
6057 let applied = fetch_and_apply_invite_links(&relay, &community).await.unwrap();
6058 assert_eq!(applied, vec![loc], "the owner's link edition folds + unions from the relay");
6059 assert!(is_public(&community).unwrap(), "mode recomputed Public from the folded aggregate");
6060
6061 publish_my_invite_links(&relay, &community, &[]).await.unwrap();
6063 let applied = fetch_and_apply_invite_links(&relay, &community).await.unwrap();
6064 assert!(applied.is_empty() && !is_public(&community).unwrap(), "an empty aggregate is Private");
6065 }
6066
6067 #[tokio::test]
6068 async fn metadata_edit_round_trips_to_a_lagging_member() {
6069 let (_tmp, _guard) = init_test_db();
6073 let relay = MemoryRelay::new();
6074 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6075 let cid = community.id.to_hex();
6076 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6077 let (genesis_v, genesis_hash) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
6078 assert_eq!(genesis_v, 1);
6079
6080 let mut edited = crate::community::metadata::CommunityMetadata::of(&community);
6081 edited.name = "Renamed HQ".into();
6082 edited.description = Some("now with a topic".into());
6083 let inner = crate::community::roster::build_community_root_edition(&owner, &community.id, &edited, 2, Some(&genesis_hash), 4000, None).unwrap();
6084 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6085 relay.inject(&outer, &community.relays);
6086
6087 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6088 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6089 assert_eq!(after.name, "Renamed HQ", "the owner's GroupRoot edit folded from the relay");
6090 assert_eq!(after.description.as_deref(), Some("now with a topic"));
6091 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0, 2, "head advanced to v2");
6092 }
6093
6094 #[tokio::test]
6095 async fn unauthorized_metadata_edit_is_ignored() {
6096 let (_tmp, _guard) = init_test_db();
6099 let relay = MemoryRelay::new();
6100 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6101 let cid = community.id.to_hex();
6102 let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
6103
6104 let mallory = Keys::generate();
6105 let mut hacked = crate::community::metadata::CommunityMetadata::of(&community);
6106 hacked.name = "Pwned".into();
6107 let inner = crate::community::roster::build_community_root_edition(&mallory, &community.id, &hacked, 2, Some(&genesis_hash), 5000, None).unwrap();
6108 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6109 relay.inject(&outer, &community.relays);
6110
6111 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6112 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6113 assert_eq!(after.name, "HQ", "a non-manage-metadata signer's GroupRoot edit is rejected");
6114 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0, 1, "an unauthorized edit never advances the head");
6115 }
6116
6117 #[tokio::test]
6118 async fn channel_rename_round_trips_from_owner_edition() {
6119 let (_tmp, _guard) = init_test_db();
6122 let relay = MemoryRelay::new();
6123 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6124 let cid = community.id.to_hex();
6125 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6126 let channel = community.channels[0].clone();
6127 let ch_hex = channel.id.to_hex();
6128 let (_, genesis_ch_hash) = crate::db::community::get_edition_head(&cid, &ch_hex).unwrap().unwrap();
6129
6130 let meta = crate::community::metadata::ChannelMetadata { name: "announcements".into() };
6131 let inner = crate::community::roster::build_channel_metadata_edition(&owner, &channel.id, &meta, 2, Some(&genesis_ch_hash), 6000, None).unwrap();
6132 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6133 relay.inject(&outer, &community.relays);
6134
6135 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6136 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6137 assert_eq!(after.channels[0].name, "announcements", "the owner's channel rename folded + applied");
6138 assert_eq!(crate::db::community::get_edition_head(&cid, &ch_hex).unwrap().unwrap().0, 2, "channel head advanced to v2");
6139 }
6140
6141 fn root_fork_v2(author: &Keys, community: &Community, name: &str, created: u64, genesis_hash: &[u8; 32]) -> (Event, [u8; 32], [u8; 32]) {
6144 let mut meta = crate::community::metadata::CommunityMetadata::of(community);
6145 meta.name = name.into();
6146 let inner = crate::community::roster::build_community_root_edition(author, &community.id, &meta, 2, Some(genesis_hash), created, None).unwrap();
6147 let self_hash = crate::community::version::edition_hash(&community.id.0, 2, Some(genesis_hash), inner.content.as_bytes());
6148 let inner_id = inner.id.to_bytes();
6149 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6150 (outer, self_hash, inner_id)
6151 }
6152
6153 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]) {
6156 let meta = crate::community::metadata::ChannelMetadata { name: name.into() };
6157 let inner = crate::community::roster::build_channel_metadata_edition(author, channel_id, &meta, 2, Some(genesis_hash), created, None).unwrap();
6158 let self_hash = crate::community::version::edition_hash(&channel_id.0, 2, Some(genesis_hash), inner.content.as_bytes());
6159 let inner_id = inner.id.to_bytes();
6160 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6161 (outer, self_hash, inner_id)
6162 }
6163
6164 #[tokio::test]
6168 async fn channel_same_version_fork_converges_to_the_lower_inner_id() {
6169 let (_tmp, _guard) = init_test_db();
6170 let relay = MemoryRelay::new();
6171 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6172 let cid = community.id.to_hex();
6173 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6174 let channel_id = community.channels[0].id;
6175 let ch_hex = channel_id.to_hex();
6176 let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &ch_hex).unwrap().unwrap();
6177
6178 let (out_a, ha, ida) = channel_fork_v2(&owner, &community, &channel_id, "alpha", 1000, &genesis_hash);
6179 let (out_b, hb, idb) = channel_fork_v2(&owner, &community, &channel_id, "bravo", 2000, &genesis_hash);
6180 let (win_name, win_h, win_id, lose_name, lose_h, lose_id) = if ida < idb {
6181 ("alpha", ha, ida, "bravo", hb, idb)
6182 } else {
6183 ("bravo", hb, idb, "alpha", ha, ida)
6184 };
6185 crate::db::community::set_edition_head_with_id(&cid, &ch_hex, 2, &lose_h, &lose_id).unwrap();
6187 {
6188 let mut c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6189 c.channels.iter_mut().find(|ch| ch.id == channel_id).unwrap().name = lose_name.into();
6190 crate::db::community::save_community(&c).unwrap();
6191 }
6192 relay.inject(&out_a, &community.relays);
6193 relay.inject(&out_b, &community.relays);
6194
6195 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6196 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6197 let ch_name = &after.channels.iter().find(|c| c.id == channel_id).unwrap().name;
6198 assert_eq!(ch_name, win_name, "channel converged on the lower-inner-id winner, not our held fork");
6199 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");
6200 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");
6201
6202 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6204 let after2 = crate::db::community::load_community(&community.id).unwrap().unwrap();
6205 assert_eq!(&after2.channels.iter().find(|c| c.id == channel_id).unwrap().name, win_name, "no flip back to the higher-id fork");
6206 }
6207
6208 #[tokio::test]
6211 async fn channel_same_version_fork_excludes_an_unauthorized_lower_id_edition() {
6212 let (_tmp, _guard) = init_test_db();
6213 let relay = MemoryRelay::new();
6214 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6215 let cid = community.id.to_hex();
6216 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6217 let channel_id = community.channels[0].id;
6218 let ch_hex = channel_id.to_hex();
6219 let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &ch_hex).unwrap().unwrap();
6220
6221 let (owner_out, owner_h, owner_id) = channel_fork_v2(&owner, &community, &channel_id, "legit", 1000, &genesis_hash);
6222 let mallory = Keys::generate();
6224 let mal_out = {
6225 let mut chosen = None;
6226 for t in 1..=10_000u64 {
6227 let cand = channel_fork_v2(&mallory, &community, &channel_id, "forged", t, &genesis_hash);
6228 if cand.2 < owner_id { chosen = Some(cand.0); break; }
6229 }
6230 chosen.expect("a mallory channel edition with a lower inner id")
6231 };
6232 relay.inject(&owner_out, &community.relays);
6233 relay.inject(&mal_out, &community.relays);
6234
6235 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6236 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6237 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");
6238 assert_eq!(crate::db::community::get_edition_head(&cid, &ch_hex).unwrap().unwrap(), (2, owner_h), "the authorized channel edition is the head");
6239 }
6240
6241 #[tokio::test]
6245 async fn epoch_primary_floor_lets_a_refounding_v1_supersede_a_held_high_version() {
6246 let (_tmp, _guard) = init_test_db();
6247 let relay = MemoryRelay::new();
6248 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6249 let cid = community.id.to_hex();
6250 for v in 2..=5u64 {
6252 crate::db::community::set_edition_head_with_id(&cid, &cid, v, &[v as u8; 32], &[v as u8; 32]).unwrap();
6253 }
6254 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0, 5);
6255 crate::db::community::set_edition_head_with_id(&cid, &cid, 3, &[0x33; 32], &[0x33; 32]).unwrap();
6257 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0, 5, "in-epoch downgrade refused");
6258
6259 crate::db::community::advance_server_root_epoch(&cid, 1, &[0xEE; 32]).unwrap();
6261 crate::db::community::set_edition_head_with_id(&cid, &cid, 1, &[0x01; 32], &[0x01; 32]).unwrap();
6262 let (v, h) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
6263 assert_eq!((v, h), (1, [0x01; 32]), "epoch-1 v1 supersedes epoch-0 v5 (epoch-primary)");
6264 assert_eq!(
6265 crate::db::community::get_all_edition_heads_epoched(&cid).unwrap().get(&cid).map(|(e, v, _)| (*e, *v)),
6266 Some((1, 1)),
6267 "head now recorded at epoch 1",
6268 );
6269 crate::db::community::set_edition_head_with_id(&cid, &cid, 1, &[0xAA; 32], &[0x02; 32]).unwrap();
6271 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().1, [0x01; 32], "same-epoch same-version is not an advance");
6272 }
6273
6274 #[tokio::test]
6278 async fn same_version_fork_converges_to_the_lower_inner_id() {
6279 let (_tmp, _guard) = init_test_db();
6280 let relay = MemoryRelay::new();
6281 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6282 let cid = community.id.to_hex();
6283 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6284 let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
6285
6286 let (out_a, ha, ida) = root_fork_v2(&owner, &community, "Alpha", 1000, &genesis_hash);
6287 let (out_b, hb, idb) = root_fork_v2(&owner, &community, "Bravo", 2000, &genesis_hash);
6288 let (win_name, win_h, win_id, lose_name, lose_h, lose_id) = if ida < idb {
6290 ("Alpha", ha, ida, "Bravo", hb, idb)
6291 } else {
6292 ("Bravo", hb, idb, "Alpha", ha, ida)
6293 };
6294 crate::db::community::set_edition_head_with_id(&cid, &cid, 2, &lose_h, &lose_id).unwrap();
6295 {
6296 let mut c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6297 c.name = lose_name.into();
6298 crate::db::community::save_community(&c).unwrap();
6299 }
6300 relay.inject(&out_a, &community.relays);
6301 relay.inject(&out_b, &community.relays);
6302
6303 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6304 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6305 assert_eq!(after.name, win_name, "converged on the lower-inner-id winner, not our own held fork");
6306 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap(), (2, win_h), "head self_hash converged at the SAME version");
6307 assert_eq!(crate::db::community::get_edition_head_inner_id(&cid, &cid).unwrap(), Some(win_id), "head inner_id moved to the winner");
6308
6309 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6311 assert_eq!(crate::db::community::load_community(&community.id).unwrap().unwrap().name, win_name, "no flip back to the higher-id fork");
6312 }
6313
6314 #[tokio::test]
6317 async fn converged_head_chains_the_next_edit_without_reforking() {
6318 let (_tmp, _guard) = init_test_db();
6319 let relay = MemoryRelay::new();
6320 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6321 let cid = community.id.to_hex();
6322 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6323 let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
6324
6325 let (out_a, ha, ida) = root_fork_v2(&owner, &community, "Alpha", 1000, &genesis_hash);
6326 let (out_b, hb, idb) = root_fork_v2(&owner, &community, "Bravo", 2000, &genesis_hash);
6327 let (lose_h, lose_id) = if ida < idb { (hb, idb) } else { (ha, ida) };
6328 crate::db::community::set_edition_head_with_id(&cid, &cid, 2, &lose_h, &lose_id).unwrap();
6329 relay.inject(&out_a, &community.relays);
6330 relay.inject(&out_b, &community.relays);
6331 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6332 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0, 2, "converged at v2");
6333
6334 let mut c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6335 c.name = "Third".into();
6336 republish_community_metadata(&relay, &c).await.unwrap();
6337 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0, 3, "advanced to v3 off the converged head");
6338
6339 let empty: std::collections::HashMap<String, (u64, [u8; 32])> = std::collections::HashMap::new();
6340 let folded = crate::community::roster::fold_roster(&fetch_control_inners(&relay, &community).await, &community.id, &empty);
6341 assert_eq!(folded.root_head.as_ref().map(|h| h.version), Some(3), "a fresh fold reaches v3");
6342 assert!(!folded.gapped_entities.contains(&community.id.0), "the chain is contiguous genesis -> winner -> v3");
6343 }
6344
6345 #[tokio::test]
6348 async fn same_version_fork_excludes_an_unauthorized_lower_id_edition() {
6349 let (_tmp, _guard) = init_test_db();
6350 let relay = MemoryRelay::new();
6351 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6352 let cid = community.id.to_hex();
6353 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6354 let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
6355
6356 let (owner_out, owner_h, owner_id) = root_fork_v2(&owner, &community, "Legit", 1000, &genesis_hash);
6357 let mallory = Keys::generate();
6359 let (mal_out, mal_id) = {
6360 let mut chosen = None;
6361 for t in 1..=10_000u64 {
6362 let cand = root_fork_v2(&mallory, &community, "Forged", t, &genesis_hash);
6363 if cand.2 < owner_id { chosen = Some((cand.0, cand.2)); break; }
6364 }
6365 chosen.expect("a mallory edition with a lower inner id")
6366 };
6367 assert!(mal_id < owner_id, "premise: the forgery sorts first author-blind");
6368 relay.inject(&owner_out, &community.relays);
6369 relay.inject(&mal_out, &community.relays);
6370
6371 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6373 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6374 assert_eq!(after.name, "Legit", "the forgery never wins despite a lower inner id");
6375 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap(), (2, owner_h), "the authorized edition is the head");
6376 }
6377
6378 #[tokio::test]
6382 async fn same_version_fork_on_an_authority_record_fails_closed() {
6383 let (_tmp, _guard) = init_test_db();
6384 let relay = MemoryRelay::new();
6385 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6386 let cid = community.id.to_hex();
6387 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6388 let bl_eid = crate::community::derive::banlist_locator(&community.id);
6389 let bl_hex = crate::simd::hex::bytes_to_hex_32(&bl_eid);
6390
6391 let prev = [0x99u8; 32]; let build_ban = |list: &[String], created: u64| {
6393 let inner = crate::community::roster::build_banlist_edition(&owner, &community.id, list, 2, Some(&prev), created, None).unwrap();
6394 let self_hash = crate::community::version::edition_hash(&bl_eid, 2, Some(&prev), inner.content.as_bytes());
6395 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6396 (outer, self_hash, inner.id.to_bytes())
6397 };
6398 let (out_a, ha, ida) = build_ban(&["aa".repeat(32)], 1000);
6399 let (out_b, hb, idb) = build_ban(&["bb".repeat(32)], 2000);
6400 let (lose_h, lose_id) = if ida < idb { (hb, idb) } else { (ha, ida) };
6403 crate::db::community::set_edition_head_with_id(&cid, &bl_hex, 2, &lose_h, &lose_id).unwrap();
6404 relay.inject(&out_a, &community.relays);
6405 relay.inject(&out_b, &community.relays);
6406
6407 let floors = crate::db::community::get_all_edition_heads(&cid).unwrap();
6408 let folded = crate::community::roster::fold_roster(&fetch_control_inners(&relay, &community).await, &community.id, &floors);
6409 assert!(folded.gapped_entities.contains(&bl_eid), "the authority-record fork is quarantined");
6410 assert!(folded.banlist_head.is_none() && folded.banlist_author.is_none(), "no banlist folded off the withheld view");
6411 }
6412
6413 #[tokio::test]
6414 async fn editions_sign_through_the_active_client_signer() {
6415 let (_tmp, _guard) = init_test_db();
6420 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6421 crate::state::set_nostr_client(nostr_sdk::Client::builder().signer(owner.clone()).build());
6422
6423 let relay = MemoryRelay::new();
6424 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6425 let cid = community.id.to_hex();
6426
6427 publish_banlist(&relay, &community, &["dd".repeat(32)]).await.unwrap();
6429 let floors = crate::db::community::get_all_edition_heads(&cid).unwrap();
6430 let folded = crate::community::roster::fold_roster(
6431 &fetch_control_inners(&relay, &community).await, &community.id, &floors);
6432 assert_eq!(folded.banlist_author, Some(owner.public_key()), "banlist signed by the client signer");
6433 assert!(folded.root_author.is_some(), "genesis GroupRoot folded");
6434 let _ = crate::state::take_nostr_client();
6435 }
6436
6437 async fn fetch_control_inners(relay: &MemoryRelay, community: &Community) -> Vec<Event> {
6439 let z = crate::community::roster::control_pseudonym(&community.server_root_key, &community.id, crate::community::Epoch(0));
6440 let query = Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], ..Default::default() };
6441 let mut out = Vec::new();
6442 for ev in relay.fetch(&query, &community.relays).await.unwrap() {
6443 if let Ok(inner) = crate::community::roster::open_control_edition(&ev, &community.server_root_key) {
6444 out.push(inner);
6445 }
6446 }
6447 out
6448 }
6449
6450 fn simulate_bunker(owner: &Keys) {
6453 crate::state::set_nostr_client(nostr_sdk::Client::builder().signer(owner.clone()).build());
6454 crate::state::MY_SECRET_KEY.clear(&[]);
6455 assert!(crate::state::MY_SECRET_KEY.to_keys().is_none(), "bunker sim: no local key");
6456 }
6457
6458 #[tokio::test]
6459 async fn am_i_banned_detects_own_npub_in_banlist() {
6460 let (_tmp, _guard) = init_test_db();
6462 let relay = MemoryRelay::new();
6463 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6464 let me = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key().to_hex();
6465 let cid = community.id.to_hex();
6466 assert!(!am_i_banned(&community), "not banned on a fresh community");
6467 crate::db::community::set_community_banlist(&cid, &[me], 1).unwrap();
6469 assert!(am_i_banned(&community), "own npub in the banlist → banned → self-remove");
6470 crate::db::community::set_community_banlist(&cid, &[], 2).unwrap();
6471 assert!(!am_i_banned(&community), "cleared banlist → not banned");
6472 }
6473
6474 #[tokio::test]
6475 async fn bunker_owner_cannot_ban_in_private_community() {
6476 let (_tmp, _guard) = init_test_db();
6479 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6480 let relay = MemoryRelay::new();
6481 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6482 simulate_bunker(&owner);
6483
6484 let victim = "cc".repeat(32);
6485 let err = publish_banlist(&relay, &community, &[victim]).await.unwrap_err();
6486 assert!(err.contains("private community") && err.contains("bunker"), "clear bunker explanation: {err}");
6487 assert!(
6488 crate::db::community::get_community_banlist(&community.id.to_hex()).unwrap().is_empty(),
6489 "the ban must NOT half-apply (nothing published or persisted)"
6490 );
6491 let _ = crate::state::take_nostr_client();
6492 }
6493
6494 #[tokio::test]
6495 async fn bunker_owner_can_ban_in_public_community() {
6496 let (_tmp, _guard) = init_test_db();
6499 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6500 let relay = MemoryRelay::new();
6501 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6502 create_public_invite(&relay, &community, None, None).await.unwrap();
6503 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
6504 assert!(is_public(&community).unwrap(), "minting a link made it Public");
6505 simulate_bunker(&owner);
6506
6507 let victim = "cc".repeat(32);
6508 publish_banlist(&relay, &community, &[victim.clone()]).await.unwrap();
6509 assert_eq!(
6510 crate::db::community::get_community_banlist(&community.id.to_hex()).unwrap(),
6511 vec![victim],
6512 "a public ban from a bunker account succeeds (no rekey needed)"
6513 );
6514 let _ = crate::state::take_nostr_client();
6515 }
6516
6517 #[tokio::test]
6518 async fn bunker_owner_cannot_privatize() {
6519 let (_tmp, _guard) = init_test_db();
6522 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6523 let relay = MemoryRelay::new();
6524 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6525 let (token, _) = create_public_invite(&relay, &community, None, None).await.unwrap();
6526 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
6527 simulate_bunker(&owner);
6528
6529 let err = revoke_public_invite(&relay, &community, &crate::simd::hex::hex_to_bytes_32(&token)).await.unwrap_err();
6530 assert!(err.contains("private") && err.contains("bunker"), "clear bunker explanation: {err}");
6531 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6532 assert!(is_public(&after).unwrap(), "the revoke must NOT half-apply — community stays Public");
6533 let _ = crate::state::take_nostr_client();
6534 }
6535
6536 #[tokio::test]
6537 async fn non_owner_admin_can_edit_community_metadata() {
6538 let (_tmp, _guard) = init_test_db();
6542 let relay = MemoryRelay::new();
6543 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6544 let cid = community.id.to_hex();
6545 let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
6546
6547 let admin = Keys::generate();
6549 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
6550 set_member_grant(&relay, &community, &admin.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
6551
6552 let mut edited = crate::community::metadata::CommunityMetadata::of(&community);
6554 edited.name = "Admin Renamed".into();
6555 let inner = crate::community::roster::build_community_root_edition(&admin, &community.id, &edited, 2, Some(&genesis_hash), 7000, None).unwrap();
6556 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6557 relay.inject(&outer, &community.relays);
6558
6559 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6560 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6561 assert_eq!(after.name, "Admin Renamed", "a MANAGE_METADATA admin (not the owner) can edit metadata");
6562 }
6563
6564 #[tokio::test]
6565 async fn banning_an_admin_revokes_their_role() {
6566 let (_tmp, _guard) = init_test_db();
6570 let relay = MemoryRelay::new();
6571 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6572 let cid = community.id.to_hex();
6573 create_public_invite(&relay, &community, None, None).await.unwrap();
6574 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
6575
6576 let alice = Keys::generate();
6577 let alice_hex = alice.public_key().to_hex();
6578 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
6579 set_member_grant(&relay, &community, &alice_hex, vec![admin_role_id]).await.unwrap();
6580 let holds_role = |hex: &str| crate::db::community::get_community_roles(&cid).unwrap()
6581 .grants.iter().any(|g| g.member == hex && !g.role_ids.is_empty());
6582 assert!(holds_role(&alice_hex), "alice is admin pre-ban");
6583
6584 publish_banlist(&relay, &community, &[alice_hex.clone()]).await.unwrap();
6585 assert!(!holds_role(&alice_hex), "banning an admin revokes their role — no dangling grant");
6586 }
6587
6588 #[tokio::test]
6589 async fn kicking_an_admin_revokes_their_role() {
6590 let (_tmp, _guard) = init_test_db();
6593 let relay = MemoryRelay::new();
6594 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6595 let cid = community.id.to_hex();
6596 let alice = Keys::generate();
6597 let alice_hex = alice.public_key().to_hex();
6598 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
6599 set_member_grant(&relay, &community, &alice_hex, vec![admin_role_id]).await.unwrap();
6600 let holds_role = |hex: &str| crate::db::community::get_community_roles(&cid).unwrap()
6601 .grants.iter().any(|g| g.member == hex && !g.role_ids.is_empty());
6602 assert!(holds_role(&alice_hex), "alice is admin pre-kick");
6603
6604 publish_kick(&relay, &community, &community.channels[0], &alice_hex).await.unwrap();
6605 assert!(!holds_role(&alice_hex), "kicking an admin revokes their role");
6606 }
6607
6608 #[tokio::test]
6609 async fn republish_channel_metadata_renames_and_publishes() {
6610 let (_tmp, _guard) = init_test_db();
6613 let relay = MemoryRelay::new();
6614 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6615 let cid = community.id.to_hex();
6616 let channel = community.channels[0].clone();
6617 let ch_hex = channel.id.to_hex();
6618
6619 republish_channel_metadata(&relay, &community, &channel.id, "lobby").await.unwrap();
6620 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6621 assert_eq!(after.channels[0].name, "lobby", "the producer renamed the channel locally");
6622 assert_eq!(crate::db::community::get_edition_head(&cid, &ch_hex).unwrap().unwrap().0, 2, "channel head advanced");
6623 }
6624
6625 #[tokio::test]
6626 async fn revoking_the_last_link_privatizes_and_rotates_the_base() {
6627 let (_tmp, _guard) = init_test_db();
6632 let relay = MemoryRelay::new();
6633 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6634 assert!(!is_public(&community).unwrap(), "a fresh community is Private");
6635 assert_eq!(community.server_root_epoch, crate::community::Epoch(0));
6636
6637 let (t1, _) = create_public_invite(&relay, &community, None, None).await.unwrap();
6639 let (t2, _) = create_public_invite(&relay, &community, None, None).await.unwrap();
6640 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6641 assert!(is_public(&c).unwrap(), "minting a link flips the mode to Public");
6642 assert_eq!(c.server_root_epoch, crate::community::Epoch(0), "minting links does NOT rotate the base");
6643
6644 revoke_public_invite(&relay, &c, &crate::simd::hex::hex_to_bytes_32(&t1)).await.unwrap();
6646 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6647 assert!(is_public(&c).unwrap(), "one link remains → still Public");
6648 assert_eq!(c.server_root_epoch, crate::community::Epoch(0), "revoking a non-last link does NOT rotate");
6649
6650 revoke_public_invite(&relay, &c, &crate::simd::hex::hex_to_bytes_32(&t2)).await.unwrap();
6652 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6653 assert!(!is_public(&c).unwrap(), "revoking the last link flips to Private");
6654 assert_eq!(c.server_root_epoch, crate::community::Epoch(1), "privatize re-founded: the base key rotated");
6655
6656 revoke_public_invite(&relay, &c, &crate::simd::hex::hex_to_bytes_32(&t2)).await.unwrap();
6659 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6660 assert_eq!(c.server_root_epoch, crate::community::Epoch(1), "a no-op re-revoke does not double-rotate");
6661 }
6662
6663 #[tokio::test]
6664 async fn private_ban_reseals_base_public_ban_does_not() {
6665 let (_tmp, _guard) = init_test_db();
6668 let relay = MemoryRelay::new();
6669 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6670 let victim = "cc".repeat(32);
6671
6672 assert!(!is_public(&community).unwrap(), "fresh community is Private");
6674 publish_banlist(&relay, &community, &[victim.clone()]).await.unwrap();
6675 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6676 assert_eq!(c.server_root_epoch, crate::community::Epoch(1), "a private-community ban re-seals the base");
6677
6678 create_public_invite(&relay, &c, None, None).await.unwrap();
6680 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6681 assert!(is_public(&c).unwrap(), "minted a link → Public");
6682 publish_banlist(&relay, &c, &[victim.clone(), "dd".repeat(32)]).await.unwrap();
6683 let c2 = crate::db::community::load_community(&community.id).unwrap().unwrap();
6684 assert_eq!(c2.server_root_epoch, crate::community::Epoch(1), "a public-community ban does NOT rotate the base");
6685 }
6686
6687 #[tokio::test]
6688 async fn private_ban_seals_the_banned_member_out_of_the_new_root() {
6689 use crate::community::derive::{base_rekey_pseudonym, recipient_pseudonym};
6695 use crate::community::rekey::{open_rekey_event, rekey_pairwise_secret};
6696 use crate::types::Message;
6697 use nostr_sdk::ToBech32;
6698 let (_tmp, _guard) = init_test_db();
6699 let relay = MemoryRelay::new();
6700 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6701 let cid = community.id.to_hex();
6702 let genesis_root = *community.server_root_key.as_bytes();
6703 let channel_hex = community.channels[0].id.to_hex();
6704
6705 let victim = Keys::generate();
6707 let victim_b32 = victim.public_key().to_bech32().unwrap();
6708 let mut m = Message::default();
6709 m.id = "aa".repeat(32);
6710 m.npub = Some(victim_b32.clone());
6711 m.at = 1000;
6712 crate::db::events::save_message(&channel_hex, &m).await.unwrap();
6713 assert!(
6714 crate::db::community::community_member_activity(&cid).unwrap().iter().any(|(np, _)| np == &victim_b32),
6715 "victim is observed before the ban"
6716 );
6717
6718 publish_banlist(&relay, &community, &[victim.public_key().to_hex()]).await.unwrap();
6720 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
6721 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(1), "private ban re-seals the base");
6722 assert!(
6723 !crate::db::community::community_member_activity(&cid).unwrap().iter().any(|(np, _)| np == &victim_b32),
6724 "the banned victim is no longer observed (banlist hex → bech32 reconciliation worked)"
6725 );
6726
6727 let addr = base_rekey_pseudonym(&crate::community::ServerRootKey(genesis_root), &community.id, crate::community::Epoch(1)).to_hex();
6729 let found = relay
6730 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() }, &community.relays)
6731 .await
6732 .unwrap();
6733 assert_eq!(found.len(), 1, "the base rekey is published");
6734 let parsed = open_rekey_event(&found[0], &genesis_root).unwrap();
6735 let secret = rekey_pairwise_secret(victim.secret_key(), &parsed.rotator).unwrap();
6736 let loc = recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
6737 assert!(
6738 parsed.blobs.iter().all(|b| b.locator != loc),
6739 "the BANNED victim has NO blob — sealed OUT of the new root (read access is actually cut)"
6740 );
6741 }
6742
6743 struct SwapDuringPublishRelay {
6747 inner: MemoryRelay,
6748 }
6749 #[async_trait::async_trait]
6750 impl Transport for SwapDuringPublishRelay {
6751 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
6752 async fn publish(&self, event: &Event, relays: &[String]) -> Result<(), String> {
6753 crate::state::bump_session_generation();
6754 self.inner.publish(event, relays).await
6755 }
6756 async fn publish_durable(&self, event: &Event, relays: &[String]) -> Result<(), String> {
6757 crate::state::bump_session_generation();
6758 self.inner.publish_durable(event, relays).await
6759 }
6760 async fn fetch(&self, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
6761 self.inner.fetch(query, relays).await
6762 }
6763 }
6764
6765 #[tokio::test]
6769 async fn account_swap_during_grant_publish_skips_the_local_persist() {
6770 let (_tmp, _guard) = init_test_db();
6771 let setup = MemoryRelay::new();
6772 let community = create_community(&setup, "HQ", "general", vec!["r1".into()]).await.unwrap();
6773 let cid = community.id.to_hex();
6774 let member = "cc".repeat(32);
6775 let entity_hex = crate::simd::hex::bytes_to_hex_32(
6776 &crate::community::derive::grant_locator(&community.id, &crate::simd::hex::hex_to_bytes_32(&member)));
6777 assert!(crate::db::community::get_edition_head(&cid, &entity_hex).unwrap().is_none(), "no grant head yet");
6778
6779 let swap = SwapDuringPublishRelay { inner: MemoryRelay::new() };
6780 set_member_grant(&swap, &community, &member, vec!["a".repeat(64)]).await.unwrap();
6781
6782 assert!(
6783 crate::db::community::get_edition_head(&cid, &entity_hex).unwrap().is_none(),
6784 "session straddled a swap → persist skipped → no local grant head (account B uncorrupted)"
6785 );
6786 }
6787
6788 #[tokio::test]
6792 async fn account_swap_during_ban_publish_applies_nothing_locally() {
6793 let (_tmp, _guard) = init_test_db();
6794 let setup = MemoryRelay::new();
6795 let community = create_community(&setup, "HQ", "general", vec!["r1".into()]).await.unwrap();
6796 let cid = community.id.to_hex();
6797 assert!(!is_public(&community).unwrap(), "fresh community is Private (a ban would normally re-seal)");
6798
6799 let swap = SwapDuringPublishRelay { inner: MemoryRelay::new() };
6800 publish_banlist(&swap, &community, &["cc".repeat(32)]).await.unwrap();
6801
6802 assert!(crate::db::community::get_community_banlist(&cid).unwrap().is_empty(),
6803 "banlist persist skipped on the stale session");
6804 assert_eq!(crate::db::community::load_community(&community.id).unwrap().unwrap().server_root_epoch,
6805 crate::community::Epoch(0), "no read-cut re-seal → base NOT rotated into the wrong account");
6806 assert!(!crate::db::community::get_read_cut_pending(&cid).unwrap(),
6807 "read_cut_pending untouched (need_cut requires is_valid())");
6808 }
6809
6810 #[tokio::test]
6813 async fn swap_session_clears_per_account_state_and_keys() {
6814 let (_tmp, _guard) = init_test_db();
6815 {
6816 let mut st = crate::state::STATE.lock().await;
6817 st.db_loaded = true;
6818 st.is_syncing = true;
6819 }
6820 assert!(crate::state::MY_SECRET_KEY.has_key(), "account A holds a live key");
6821
6822 crate::VectorCore.swap_session().await;
6823
6824 let st = crate::state::STATE.lock().await;
6825 assert!(st.chats.is_empty() && st.profiles.is_empty(), "STATE chats/profiles cleared on swap");
6826 assert!(!st.db_loaded && !st.is_syncing, "db_loaded / is_syncing reset");
6827 assert!(!crate::state::MY_SECRET_KEY.has_key(), "key vault cleared — no leak into account B");
6828 }
6829
6830 #[tokio::test]
6834 async fn join_finalization_persists_and_registers_the_channel() {
6835 let (_tmp, _guard) = init_test_db();
6836 crate::state::STATE.lock().await.chats.clear(); let relay = MemoryRelay::new();
6838 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6839 become_local(&Keys::generate());
6841
6842 crate::VectorCore.finalize_member_join(community.clone(), &relay, None).await.unwrap();
6843
6844 assert!(crate::db::community::load_community(&community.id).unwrap().is_some(), "community persisted on join");
6845 assert!(!crate::state::STATE.lock().await.chats.is_empty(), "the channel is registered as a chat");
6846 }
6847
6848 #[tokio::test]
6852 async fn join_finalization_tears_down_a_banned_joiner() {
6853 let (_tmp, _guard) = init_test_db();
6854 let relay = MemoryRelay::new();
6855 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6856 create_public_invite(&relay, &community, None, None).await.unwrap();
6858 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
6859
6860 let joiner = Keys::generate();
6862 publish_banlist(&relay, &community, &[joiner.public_key().to_hex()]).await.unwrap();
6863 become_local(&joiner);
6864 assert!(crate::db::community::load_community(&community.id).unwrap().is_some(), "community present pre-join");
6865
6866 let result = crate::VectorCore.finalize_member_join(community.clone(), &relay, None).await;
6867 assert!(result.is_err(), "a banned joiner's finalize must fail");
6868 assert!(result.unwrap_err().to_string().contains("banned"), "the error names the ban");
6869 assert!(
6870 crate::db::community::load_community(&community.id).unwrap().is_none(),
6871 "the just-saved community is torn back down — no orphaned row for a banned joiner"
6872 );
6873 }
6874
6875 #[tokio::test]
6881 async fn delete_community_wipes_every_community_scoped_table() {
6882 let (_tmp, _guard) = init_test_db();
6883 let relay = MemoryRelay::new();
6884 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6885 let cid = community.id.to_hex();
6886
6887 crate::db::community::store_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 1, &[0x11u8; 32]).unwrap();
6889 crate::db::community::save_public_invite("tok", &cid, "https://x/invite#y", None, None).unwrap();
6890 crate::db::community::save_pending_invite(&cid, "{}", "npub1inviter").unwrap();
6891 crate::db::community::set_edition_head(&cid, &cid, 1, &[0x22u8; 32]).unwrap();
6892 crate::db::community::set_community_banlist(&cid, &["cc".repeat(32)], 100).unwrap();
6893
6894 assert!(crate::db::community::community_exists(&community.id).unwrap());
6896 assert!(!crate::db::community::held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty());
6897 assert!(!crate::db::community::list_public_invites(&cid).unwrap().is_empty());
6898 assert!(crate::db::community::list_pending_invites().unwrap().iter().any(|p| p.community_id == cid));
6899 assert!(!crate::db::community::get_all_edition_heads(&cid).unwrap().is_empty());
6900 assert!(!crate::db::community::get_community_banlist(&cid).unwrap().is_empty());
6901
6902 crate::db::community::delete_community(&cid).unwrap();
6903
6904 assert!(!crate::db::community::community_exists(&community.id).unwrap(), "communities row gone");
6906 assert!(crate::db::community::load_community(&community.id).unwrap().is_none(), "community not loadable");
6907 assert!(crate::db::community::held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty(), "epoch keys wiped");
6908 assert!(crate::db::community::list_public_invites(&cid).unwrap().is_empty(), "public invites wiped");
6909 assert!(!crate::db::community::list_pending_invites().unwrap().iter().any(|p| p.community_id == cid), "pending invites wiped");
6910 assert!(crate::db::community::get_all_edition_heads(&cid).unwrap().is_empty(), "edition heads wiped");
6911 assert!(crate::db::community::get_community_banlist(&cid).unwrap().is_empty(), "banlist wiped with the channels");
6912 }
6913
6914 #[tokio::test]
6918 async fn fetch_control_folded_skips_junk_injected_at_the_coordinate() {
6919 let (_tmp, _guard) = init_test_db();
6920 let relay = MemoryRelay::new();
6921 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6922 let owner_hex = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key().to_hex();
6923
6924 let z = crate::community::roster::control_pseudonym(&community.server_root_key, &community.id, community.server_root_epoch);
6926 let junk = nostr_sdk::EventBuilder::new(nostr_sdk::Kind::Custom(event_kind::COMMUNITY_CONTROL), "not a sealed edition")
6927 .tags([nostr_sdk::Tag::custom(nostr_sdk::TagKind::Custom("z".into()), [z])])
6928 .sign_with_keys(&Keys::generate())
6929 .unwrap();
6930 relay.publish(&junk, &community.relays).await.unwrap();
6931
6932 let folded = fetch_control_folded(&relay, &community).await.unwrap();
6933 assert!(
6934 !crate::community::roster::authorize_delegation(&folded, Some(&owner_hex)).roles.is_empty(),
6935 "the genuine Admin role still folds; the un-openable junk is silently dropped"
6936 );
6937 }
6938
6939 #[tokio::test]
6942 async fn fetch_control_folded_on_dead_relays_is_empty_not_a_panic() {
6943 let (_tmp, _guard) = init_test_db();
6944 let community = saved_community_owned_by(&Keys::generate());
6945 let folded = fetch_control_folded(&FailingRelay, &community).await.unwrap();
6946 assert!(folded.roles.roles.is_empty() && folded.root_meta.is_none(), "dead relays → empty fold, no panic");
6947 }
6948
6949 #[tokio::test]
6950 async fn successful_private_ban_leaves_no_read_cut_pending() {
6951 let (_tmp, _guard) = init_test_db();
6953 let relay = MemoryRelay::new();
6954 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6955 let cid = community.id.to_hex();
6956 publish_banlist(&relay, &community, &["cc".repeat(32)]).await.unwrap();
6957 assert!(!crate::db::community::get_read_cut_pending(&cid).unwrap(), "a successful re-seal leaves no pending read-cut");
6958 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6959 assert_eq!(c.server_root_epoch, crate::community::Epoch(1));
6960 }
6961
6962 #[tokio::test]
6963 async fn failed_reseal_sets_pending_then_sync_retry_recovers() {
6964 let (_tmp, _guard) = init_test_db();
6969 let relay = RekeyFailingRelay::new(); let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6971 let cid = community.id.to_hex();
6972 let victim = "cc".repeat(32);
6973
6974 assert!(publish_banlist(&relay, &community, &[victim.clone()]).await.is_err(), "the re-seal's base rekey fails");
6976 assert!(crate::db::community::get_read_cut_pending(&cid).unwrap(), "a failed re-seal leaves read_cut_pending set");
6977 assert_eq!(crate::db::community::get_community_banlist(&cid).unwrap(), vec![victim.clone()], "the ban itself still applied");
6978 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6979 assert_eq!(c.server_root_epoch, crate::community::Epoch(0), "base NOT rotated while the re-seal is pending");
6980
6981 relay.allow_rekey();
6983 retry_pending_read_cut(&relay, &c).await.unwrap();
6984 assert!(!crate::db::community::get_read_cut_pending(&cid).unwrap(), "pending cleared after the retry succeeds");
6985 let c2 = crate::db::community::load_community(&community.id).unwrap().unwrap();
6986 assert_eq!(c2.server_root_epoch, crate::community::Epoch(1), "the read-cut finally rotated the base");
6987 }
6988
6989 #[tokio::test]
6990 async fn privatize_reseals_to_observed_participants_not_just_owner() {
6991 use crate::community::derive::{base_rekey_pseudonym, recipient_pseudonym};
6995 use crate::community::rekey::{open_rekey_blob, open_rekey_event, rekey_pairwise_secret};
6996 use crate::types::Message;
6997 use nostr_sdk::ToBech32;
6998 let (_tmp, _guard) = init_test_db();
6999 let relay = MemoryRelay::new();
7000 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7001 let cid = community.id.to_hex();
7002 let genesis_root = *community.server_root_key.as_bytes();
7003 let channel_hex = community.channels[0].id.to_hex();
7004
7005 let alice = Keys::generate();
7007 let alice_b32 = alice.public_key().to_bech32().unwrap();
7008 let mut m = Message::default();
7009 m.id = "aa".repeat(32);
7010 m.npub = Some(alice_b32.clone());
7011 m.at = 1000;
7012 crate::db::events::save_message(&channel_hex, &m).await.unwrap();
7013 assert!(
7014 crate::db::community::community_member_activity(&cid).unwrap().iter().any(|(np, _)| np == &alice_b32),
7015 "alice is an observed participant"
7016 );
7017
7018 let (token_hex, _) = create_public_invite(&relay, &community, None, None).await.unwrap();
7020 revoke_public_invite(&relay, &community, &crate::simd::hex::hex_to_bytes_32(&token_hex)).await.unwrap();
7021 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
7022 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(1), "privatize rotated the base");
7023
7024 let addr = base_rekey_pseudonym(&crate::community::ServerRootKey(genesis_root), &community.id, crate::community::Epoch(1)).to_hex();
7027 let found = relay
7028 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() }, &community.relays)
7029 .await
7030 .unwrap();
7031 assert_eq!(found.len(), 1, "the base rekey is published");
7032 let parsed = open_rekey_event(&found[0], &genesis_root).unwrap();
7033 let secret = rekey_pairwise_secret(alice.secret_key(), &parsed.rotator).unwrap();
7034 let loc = recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
7035 let alice_blob = parsed.blobs.iter().find(|b| b.locator == loc).expect("alice's blob present (NOT sealed out)");
7036 let recovered = open_rekey_blob(alice.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, alice_blob).unwrap();
7037 assert_eq!(reloaded.server_root_key.as_bytes(), &recovered, "alice recovers the new root = owner's advanced base");
7038 }
7039
7040 #[tokio::test]
7041 async fn unpermissioned_invite_links_edition_is_rejected() {
7042 let (_tmp, _guard) = init_test_db();
7046 let relay = MemoryRelay::new();
7047 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7048
7049 let mallory = Keys::generate();
7050 let loc = "2b".repeat(32);
7051 let inner = crate::community::roster::build_invite_links_edition(&mallory, &community.id, &[loc], 1, None, 1000, None).unwrap();
7052 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
7053 relay.inject(&outer, &community.relays);
7054
7055 let applied = fetch_and_apply_invite_links(&relay, &community).await.unwrap();
7056 assert!(applied.is_empty(), "an unpermissioned member's link edition is rejected");
7057 assert!(!is_public(&community).unwrap(), "mode stays Private despite the forged edition");
7058 }
7059
7060 #[tokio::test]
7061 async fn invite_links_union_across_authorized_creators() {
7062 let (_tmp, _guard) = init_test_db();
7066 let relay = MemoryRelay::new();
7067 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7068 let cid = community.id.to_hex();
7069
7070 create_public_invite(&relay, &community, None, None).await.unwrap();
7072 let owner_loc = public_invite::locator_hex(&crate::simd::hex::hex_to_bytes_32(
7073 &crate::db::community::list_public_invites(&cid).unwrap()[0].token));
7074
7075 let admin = Keys::generate();
7077 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
7078 set_member_grant(&relay, &community, &admin.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
7079 let admin_loc = "ab".repeat(32);
7080 let inner = crate::community::roster::build_invite_links_edition(&admin, &community.id, &[admin_loc.clone()], 1, None, 2000, None).unwrap();
7081 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
7082 relay.inject(&outer, &community.relays);
7083
7084 let agg = fetch_and_apply_invite_links(&relay, &community).await.unwrap();
7085 assert!(agg.contains(&owner_loc), "owner's link in the aggregate");
7086 assert!(agg.contains(&admin_loc), "the granted admin's link unions in too");
7087 assert!(is_public(&community).unwrap());
7088
7089 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
7093 let owner_token = crate::db::community::list_public_invites(&cid).unwrap()[0].token.clone();
7094 revoke_public_invite(&relay, &c, &crate::simd::hex::hex_to_bytes_32(&owner_token)).await.unwrap();
7095 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
7096 assert_eq!(c.server_root_epoch, crate::community::Epoch(0), "another creator's link remains → no privatize rekey");
7097 assert!(is_public(&c).unwrap(), "still Public (admin's link is live)");
7098 }
7099
7100 #[tokio::test]
7101 async fn invite_registry_retains_a_persisted_creator_on_a_partial_fold() {
7102 let (_tmp, _guard) = init_test_db();
7107 let relay = MemoryRelay::new();
7108 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7109 let cid = community.id.to_hex();
7110
7111 create_public_invite(&relay, &community, None, None).await.unwrap();
7112 let owner_loc = public_invite::locator_hex(&crate::simd::hex::hex_to_bytes_32(
7113 &crate::db::community::list_public_invites(&cid).unwrap()[0].token));
7114 let agg = fetch_and_apply_invite_links(&relay, &community).await.unwrap();
7115 assert!(agg.contains(&owner_loc), "the mint folds + persists normally");
7116
7117 let partial = MemoryRelay::new();
7119 let agg = fetch_and_apply_invite_links(&partial, &community).await.unwrap();
7120 assert!(agg.contains(&owner_loc), "an absent edition retains the persisted locators");
7121 assert!(is_public(&community).unwrap(), "mode survives the partial view");
7122 }
7123
7124 #[tokio::test]
7125 async fn invite_registry_drops_a_demoted_creator_whose_edition_is_present() {
7126 let (_tmp, _guard) = init_test_db();
7131 let relay = MemoryRelay::new();
7132 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7133 let cid = community.id.to_hex();
7134
7135 let admin = Keys::generate();
7136 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
7137 set_member_grant(&relay, &community, &admin.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
7138 let admin_loc = "ab".repeat(32);
7139 let inner = crate::community::roster::build_invite_links_edition(&admin, &community.id, &[admin_loc.clone()], 1, None, 2000, None).unwrap();
7140 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
7141 relay.inject(&outer, &community.relays);
7142 let agg = fetch_and_apply_invite_links(&relay, &community).await.unwrap();
7143 assert!(agg.contains(&admin_loc), "the granted admin's link folds + persists");
7144
7145 set_member_grant(&relay, &community, &admin.public_key().to_hex(), vec![]).await.unwrap();
7148 let agg = fetch_and_apply_invite_links(&relay, &community).await.unwrap();
7149 assert!(!agg.contains(&admin_loc), "a present-but-unauthorized edition drops the persisted row");
7150 assert!(!is_public(&community).unwrap(), "no live authorized link → Private");
7151 }
7152
7153 #[tokio::test]
7154 async fn failed_banlist_publish_does_not_persist_locally() {
7155 let (_tmp, _guard) = init_test_db();
7158 let relay = MemoryRelay::new();
7159 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7160 let id_hex = community.id.to_hex();
7161 assert!(crate::db::community::get_community_banlist(&id_hex).unwrap().is_empty());
7162
7163 let victim = "cc".repeat(32);
7164 let err = publish_banlist(&FailingRelay, &community, &[victim]).await;
7165 assert!(err.is_err(), "a failed publish must propagate");
7166 assert!(
7167 crate::db::community::get_community_banlist(&id_hex).unwrap().is_empty(),
7168 "local banlist must be untouched when the publish failed"
7169 );
7170 }
7171
7172 #[tokio::test]
7173 async fn metadata_failed_publish_does_not_persist_locally() {
7174 let (_tmp, _guard) = init_test_db();
7178 let relay = MemoryRelay::new();
7179 let mut community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7180 community.name = "Renamed HQ".to_string();
7181 assert!(republish_community_metadata(&FailingRelay, &community).await.is_err());
7182 let loaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
7183 assert_eq!(loaded.name, "HQ", "a failed metadata publish leaves the local name unchanged");
7184 }
7185
7186 #[tokio::test]
7187 async fn send_persists_key_then_delete_round_trip() {
7188 let (_tmp, _guard) = init_test_db();
7189 let relay = MemoryRelay::new();
7190 let community = Community::create("HQ", "general", vec!["r1".into()]);
7191 let channel = community.channels[0].clone();
7192 let alice = Keys::generate();
7193
7194 let _outer = send_message(&relay, &community, &channel, &alice, "deletable", 1).await.unwrap();
7196 let before = fetch_channel_messages(&relay, &community, &channel).await.unwrap();
7197 assert_eq!(before.len(), 1);
7198 let message_id = before[0].message_id.to_hex();
7199
7200 delete_message(&relay, &message_id).await.unwrap();
7202 let after = fetch_channel_messages(&relay, &community, &channel).await.unwrap();
7203 assert!(after.is_empty(), "message should be deleted after delete_message");
7204
7205 assert!(delete_message(&relay, &message_id).await.is_err());
7207 }
7208
7209 #[tokio::test]
7210 async fn failed_delete_publish_preserves_key() {
7211 let (_tmp, _guard) = init_test_db();
7214 let relay = MemoryRelay::new();
7215 let community = Community::create("HQ", "general", vec!["r1".into()]);
7216 let channel = community.channels[0].clone();
7217 let alice = Keys::generate();
7218 send_message(&relay, &community, &channel, &alice, "delete me", 1).await.unwrap();
7219 let message_id = fetch_channel_messages(&relay, &community, &channel).await.unwrap()[0]
7220 .message_id
7221 .to_hex();
7222
7223 assert!(delete_message(&FailingRelay, &message_id).await.is_err());
7225
7226 delete_message(&relay, &message_id).await.unwrap();
7228 assert!(fetch_channel_messages(&relay, &community, &channel).await.unwrap().is_empty());
7229 }
7230
7231 #[tokio::test]
7232 async fn delete_unknown_message_errors() {
7233 let (_tmp, _guard) = init_test_db();
7234 let relay = MemoryRelay::new();
7235 let fake = Keys::generate();
7237 let bogus = EventBuilder::new(Kind::Custom(1), "x").sign_with_keys(&fake).unwrap().id;
7238 assert!(delete_message(&relay, &bogus.to_hex()).await.is_err());
7239 }
7240
7241 #[tokio::test]
7242 async fn accept_invite_persists_member_view() {
7243 let (_tmp, _guard) = init_test_db();
7244 let owner = Community::create("HQ", "general", vec!["r1".into()]);
7245 let invite = crate::community::invite::build_invite(&owner);
7246
7247 let joined = accept_invite(&invite).expect("accept");
7248 assert!(!is_proven_owner(&joined), "joined as member, not owner");
7249 let loaded = crate::db::community::load_community(&owner.id).unwrap().expect("saved");
7251 assert_eq!(loaded.channels[0].key.as_bytes(), owner.channels[0].key.as_bytes());
7252 }
7253
7254 #[tokio::test]
7255 async fn accept_invite_does_not_downgrade_owned_community() {
7256 let (_tmp, _guard) = init_test_db();
7259 let relay = MemoryRelay::new();
7260 let owner = create_community(&relay, "HQ", "general", vec![]).await.unwrap();
7261 assert!(is_proven_owner(&owner), "we are the proven owner");
7262
7263 let invite = crate::community::invite::build_invite(&owner);
7264 let err = accept_invite(&invite).unwrap_err();
7265 assert!(err.contains("already own"), "must refuse to downgrade an owned community, got: {err}");
7266
7267 let reloaded = crate::db::community::load_community(&owner.id).unwrap().unwrap();
7269 assert_eq!(reloaded.server_root_key.as_bytes(), owner.server_root_key.as_bytes());
7270 }
7271
7272 #[tokio::test]
7273 async fn accept_invite_rejects_id_collision_under_different_authority() {
7274 let (_tmp, _guard) = init_test_db();
7279 let legit = Community::create("X", "general", vec!["wss://legit".into()]);
7280 let member_x = accept_invite(&crate::community::invite::build_invite(&legit)).unwrap();
7281 let original_key = member_x.channels[0].key.as_bytes().to_vec();
7282
7283 let attacker = Community::create("evil", "general", vec!["wss://evil".into()]);
7285 let mut hostile = crate::community::invite::build_invite(&attacker);
7286 hostile.community_id = legit.id.to_hex();
7287 assert_ne!(hostile.server_root_key, crate::simd::hex::bytes_to_hex_32(member_x.server_root_key.as_bytes()));
7290
7291 assert!(accept_invite(&hostile).is_err(), "id-collision under new authority must be rejected");
7292
7293 let reloaded = crate::db::community::load_community(&legit.id).unwrap().unwrap();
7295 assert_eq!(reloaded.channels[0].key.as_bytes().to_vec(), original_key);
7296 assert_eq!(reloaded.relays, vec!["wss://legit".to_string()]);
7297 }
7298
7299 #[tokio::test]
7300 async fn rejected_accept_leaves_pending_invite_intact() {
7301 let (_tmp, _guard) = init_test_db();
7304
7305 let owner = attested_community("HQ", "general", vec![]);
7307 crate::db::community::save_community(&owner).unwrap();
7308 let bundle = crate::community::invite::build_invite(&owner).to_json().unwrap();
7309 let cid = owner.id.to_hex();
7310 crate::db::community::save_pending_invite(&cid, &bundle, "npub1inviter").unwrap();
7311
7312 let peeked = crate::db::community::get_pending_invite(&cid).unwrap().expect("parked");
7314 let invite = crate::community::invite::CommunityInvite::from_json(&peeked).unwrap();
7315 assert!(accept_invite(&invite).is_err(), "owning the id → reject");
7316 assert!(
7317 crate::db::community::pending_invite_exists(&cid).unwrap(),
7318 "rejected accept must leave the invite parked"
7319 );
7320
7321 let other = Community::create("Other", "general", vec![]);
7323 let ob = crate::community::invite::build_invite(&other).to_json().unwrap();
7324 let ocid = other.id.to_hex();
7325 crate::db::community::save_pending_invite(&ocid, &ob, "npub1inviter").unwrap();
7326 let op = crate::db::community::get_pending_invite(&ocid).unwrap().unwrap();
7327 let oinvite = crate::community::invite::CommunityInvite::from_json(&op).unwrap();
7328 accept_invite(&oinvite).expect("accept ok");
7329 crate::db::community::delete_pending_invite(&ocid).unwrap();
7330 assert!(!crate::db::community::pending_invite_exists(&ocid).unwrap(), "cleared on success");
7331 }
7332
7333 #[tokio::test]
7334 async fn public_invite_create_fetch_accept_revoke_round_trip() {
7335 let (_tmp, _guard) = init_test_db();
7336 let relay = MemoryRelay::new();
7337 let mut owner = Community::create("Public HQ", "general", vec!["r1".into(), "r2".into()]);
7338 owner.description = Some("everyone welcome".into());
7339 let owner_keys = crate::state::MY_SECRET_KEY.to_keys().unwrap();
7343 owner.owner_attestation = Some(
7344 crate::community::owner::build_owner_attestation_unsigned(owner_keys.public_key(), &owner.id.to_hex())
7345 .sign_with_keys(&owner_keys).unwrap().as_json(),
7346 );
7347 let (token_hex, url) = create_public_invite(&relay, &owner, None, None).await.expect("mint");
7349 assert!(url.contains('#'));
7350 assert_eq!(crate::db::community::list_public_invites(&owner.id.to_hex()).unwrap().len(), 1);
7351
7352 let (relays, token) = public_invite::parse_invite_url(&url).unwrap();
7354 assert_eq!(crate::simd::hex::bytes_to_hex_32(&token), token_hex);
7355 let bundle = fetch_public_invite(&relay, &relays, &token).await.expect("fetch");
7356 assert_eq!(bundle.preview.name, "Public HQ");
7357 assert_eq!(bundle.preview.description.as_deref(), Some("everyone welcome"));
7358
7359 let joined = accept_public_invite(&bundle, 0).expect("accept");
7360 assert_eq!(joined.id, owner.id);
7361 assert_eq!(joined.description.as_deref(), Some("everyone welcome"), "preview patched in");
7362
7363 revoke_public_invite(&relay, &owner, &token).await.expect("revoke");
7365 assert!(fetch_public_invite(&relay, &relays, &token).await.is_err(), "revoked link is dead");
7366 assert!(crate::db::community::list_public_invites(&owner.id.to_hex()).unwrap().is_empty());
7367 }
7368
7369 #[tokio::test]
7370 async fn revoked_invite_dies_even_if_one_relay_kept_the_bundle() {
7371 let (_tmp, _guard) = init_test_db();
7375 let relay = MemoryRelay::new();
7376 let owner = attested_community("HQ", "general", vec!["r1".into(), "r2".into()]);
7377 let (_token_hex, url) = create_public_invite(&relay, &owner, None, None).await.unwrap();
7378 let (relays, token) = public_invite::parse_invite_url(&url).unwrap();
7379 assert!(fetch_public_invite(&relay, &relays, &token).await.is_ok(), "live on both relays");
7380
7381 let tombstone = public_invite::build_public_invite_tombstone(&token).unwrap();
7383 relay.inject(&tombstone, &["r1".to_string()]);
7384
7385 assert!(
7386 fetch_public_invite(&relay, &relays, &token).await.is_err(),
7387 "a tombstone on any one relay kills the link, even with a stale live bundle elsewhere",
7388 );
7389 }
7390
7391 #[tokio::test]
7392 async fn fetch_skips_relay_shadow_junk_to_genuine_bundle() {
7393 use nostr_sdk::prelude::{EventBuilder, Keys, Kind, Tag, TagKind, Timestamp};
7397
7398 let (_tmp, _guard) = init_test_db();
7399 let relay = MemoryRelay::new();
7400 let owner = attested_community("HQ", "general", vec!["r1".into()]);
7401 let (_t, url) = create_public_invite(&relay, &owner, None, None).await.unwrap();
7402 let (relays, token) = public_invite::parse_invite_url(&url).unwrap();
7403
7404 let attacker = Keys::generate();
7407 let junk = EventBuilder::new(Kind::Custom(event_kind::APPLICATION_SPECIFIC), "garbage")
7408 .tags([
7409 Tag::identifier(public_invite::locator_hex(&token)),
7410 Tag::custom(TagKind::Custom("vsk".into()), ["6".to_string()]),
7411 Tag::custom(TagKind::Custom("v".into()), ["1".to_string()]),
7412 ])
7413 .custom_created_at(Timestamp::from_secs(9_000_000_000))
7414 .sign_with_keys(&attacker)
7415 .unwrap();
7416 relay.publish(&junk, &relays).await.unwrap();
7417
7418 let bundle = fetch_public_invite(&relay, &relays, &token).await.expect("genuine survives shadow");
7420 assert_eq!(bundle.preview.name, "HQ");
7421 }
7422
7423 #[tokio::test]
7424 async fn expired_public_invite_is_refused() {
7425 let (_tmp, _guard) = init_test_db();
7426 let relay = MemoryRelay::new();
7427 let owner = attested_community("HQ", "general", vec!["r1".into()]);
7428 let (_t, url) = create_public_invite(&relay, &owner, Some(1000), None).await.unwrap();
7429 let (relays, token) = public_invite::parse_invite_url(&url).unwrap();
7430 let bundle = fetch_public_invite(&relay, &relays, &token).await.unwrap();
7431 assert!(accept_public_invite(&bundle, 2000).is_err());
7433 assert!(crate::db::community::load_community(&owner.id).unwrap().is_none());
7434 }
7435
7436 #[tokio::test]
7437 async fn republish_metadata_saves_and_publishes() {
7438 use crate::community::CommunityImage;
7439 let (_tmp, _guard) = init_test_db();
7440 let relay = MemoryRelay::new();
7441 let mut owner = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7444 let cid = owner.id.to_hex();
7445
7446 owner.name = "HQ Renamed".into();
7448 owner.description = Some("now with topic".into());
7449 owner.icon = Some(CommunityImage {
7450 url: "https://b/x".into(), key: "aa".repeat(32), nonce: "bb".repeat(12),
7451 hash: "cc".repeat(32), ext: "png".into(),
7452 });
7453 republish_community_metadata(&relay, &owner).await.expect("republish");
7454
7455 let loaded = crate::db::community::load_community(&owner.id).unwrap().unwrap();
7457 assert_eq!(loaded.name, "HQ Renamed");
7458 assert_eq!(loaded.description.as_deref(), Some("now with topic"));
7459 assert_eq!(loaded.icon.unwrap().url, "https://b/x");
7460
7461 let (head_v, _) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
7464 assert_eq!(head_v, 2, "GroupRoot edition advanced v1 (create) → v2 (republish)");
7465 let z = crate::community::roster::control_pseudonym(&owner.server_root_key, &owner.id, crate::community::Epoch(0));
7466 let control = relay
7467 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], ..Default::default() }, &owner.relays)
7468 .await
7469 .unwrap();
7470 let newest = control
7471 .iter()
7472 .filter_map(|o| crate::community::roster::open_control_edition(o, &owner.server_root_key).ok())
7473 .filter_map(|i| crate::community::edition::parse_edition_inner(&i).ok())
7474 .filter(|p| p.entity_id == owner.id.0)
7475 .max_by_key(|p| p.version)
7476 .expect("GroupRoot edition on the relay");
7477 let meta: crate::community::metadata::CommunityMetadata = serde_json::from_str(&newest.content).unwrap();
7478 assert_eq!(meta.name, "HQ Renamed");
7479 assert_eq!(meta.icon.unwrap().ext, "png");
7480 }
7481
7482 #[tokio::test]
7483 async fn member_cannot_republish_metadata() {
7484 let (_tmp, _guard) = init_test_db();
7485 let relay = MemoryRelay::new();
7486 let owner = Community::create("HQ", "general", vec!["r1".into()]);
7487 let member = crate::community::invite::accept_invite(&crate::community::invite::build_invite(&owner)).unwrap();
7488 assert!(republish_community_metadata(&relay, &member).await.is_err());
7489 }
7490
7491 #[tokio::test]
7492 async fn member_cannot_mint_public_invite() {
7493 let (_tmp, _guard) = init_test_db();
7494 let relay = MemoryRelay::new();
7495 let owner = Community::create("HQ", "general", vec!["r1".into()]);
7496 let member = crate::community::invite::accept_invite(&crate::community::invite::build_invite(&owner)).unwrap();
7497 assert!(create_public_invite(&relay, &member, None, None).await.is_err(), "members can't mint links");
7498 }
7499
7500 #[tokio::test]
7501 async fn accept_oversized_bundle_rejected() {
7502 let (_tmp, _guard) = init_test_db();
7503 let owner = Community::create("HQ", "general", vec![]);
7504 let mut invite = crate::community::invite::build_invite(&owner);
7505 let template = invite.channels[0].clone();
7507 for _ in 0..300 {
7508 invite.channels.push(template.clone());
7509 }
7510 assert!(accept_invite(&invite).is_err(), "oversized bundle must be rejected");
7511 assert!(crate::db::community::load_community(&owner.id).unwrap().is_none(), "nothing persisted");
7512 }
7513
7514 async fn publish_tombstone<T: Transport + ?Sized>(transport: &T, community: &Community, author: &Keys, created_at: u64) {
7520 let inner = crate::community::roster::build_group_dissolved_edition_unsigned(author.public_key(), &community.id, created_at)
7521 .sign_with_keys(author).unwrap();
7522 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch).unwrap();
7523 transport.publish_durable(&outer, &community.relays).await.unwrap();
7524 }
7525
7526 struct RekeyCountingRelay {
7529 inner: MemoryRelay,
7530 rekeys: std::sync::atomic::AtomicUsize,
7531 }
7532 impl RekeyCountingRelay {
7533 fn new() -> Self { Self { inner: MemoryRelay::new(), rekeys: std::sync::atomic::AtomicUsize::new(0) } }
7534 fn count(&self, e: &Event) {
7535 if e.kind.as_u16() == event_kind::COMMUNITY_REKEY {
7536 self.rekeys.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
7537 }
7538 }
7539 }
7540 #[async_trait::async_trait]
7541 impl Transport for RekeyCountingRelay {
7542 async fn fetch_plane(&self, _plane: &Keys, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> { self.fetch(query, relays).await }
7543 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> { self.count(e); self.inner.publish(e, r).await }
7544 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> { self.count(e); self.inner.publish_durable(e, r).await }
7545 async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> { self.inner.fetch(q, r).await }
7546 }
7547
7548 #[tokio::test]
7549 async fn owner_tombstone_folds_to_dissolved() {
7550 let (_tmp, _guard) = init_test_db();
7551 let relay = MemoryRelay::new();
7552 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7554 let cid = community.id.to_hex();
7555 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
7556 publish_tombstone(&relay, &community, &owner, 1000).await;
7557
7558 assert!(!crate::db::community::get_community_dissolved(&cid).unwrap(), "alive before the fold");
7559 fetch_and_apply_control(&relay, &community).await.unwrap();
7560 assert!(crate::db::community::get_community_dissolved(&cid).unwrap(), "owner tombstone seals the community");
7561 }
7562
7563 #[tokio::test]
7564 async fn non_owner_tombstone_is_ignored() {
7565 let (_tmp, _guard) = init_test_db();
7566 let relay = MemoryRelay::new();
7567 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7568 let cid = community.id.to_hex();
7569 let mallory = Keys::generate();
7572 publish_tombstone(&relay, &community, &mallory, 1000).await;
7573
7574 fetch_and_apply_control(&relay, &community).await.unwrap();
7575 assert!(!crate::db::community::get_community_dissolved(&cid).unwrap(), "a non-owner tombstone is ignored");
7576 }
7577
7578 #[tokio::test]
7579 async fn unreadable_deed_rejects_the_tombstone() {
7580 let (_tmp, _guard) = init_test_db();
7581 let relay = MemoryRelay::new();
7582 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
7583 let mut community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7584 let cid = community.id.to_hex();
7585 publish_tombstone(&relay, &community, &owner, 1000).await;
7586 community.owner_attestation = None;
7588 crate::db::community::save_community(&community).unwrap();
7589 let stripped = crate::db::community::load_community(&community.id).unwrap().unwrap();
7590
7591 fetch_and_apply_control(&relay, &stripped).await.unwrap();
7592 assert!(!crate::db::community::get_community_dissolved(&cid).unwrap(), "unverifiable tombstone is rejected, not death-by-default");
7593 }
7594
7595 #[tokio::test]
7596 async fn binary_seal_drops_every_subsequent_event_with_no_timestamp_test() {
7597 let (_tmp, _guard) = init_test_db();
7598 let relay = MemoryRelay::new();
7599 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
7600 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7601 let cid = community.id.to_hex();
7602 publish_tombstone(&relay, &community, &owner, 1000).await;
7603 fetch_and_apply_control(&relay, &community).await.unwrap();
7604 assert!(crate::db::community::get_community_dissolved(&cid).unwrap());
7605
7606 let sealed = crate::db::community::load_community(&community.id).unwrap().unwrap();
7608 let channel = sealed.channels[0].clone();
7609 let me = owner.public_key();
7610
7611 let backdated = super::super::envelope::seal_message(
7613 &Keys::generate(), &channel.key, &channel.id, channel.epoch, "ghost", 1,
7614 ).unwrap();
7615 let mut state = crate::state::ChatState::new();
7616 assert!(super::super::inbound::process_incoming(&mut state, &backdated, &channel, &me).is_none(),
7617 "a backdated message after the seal is dropped (binary seal, no timestamp test)");
7618
7619 publish_tombstone(&relay, &sealed, &owner, 2000).await;
7621 assert_eq!(fetch_and_apply_control(&relay, &sealed).await.unwrap(), 0,
7622 "control fold stops advancing once sealed");
7623 }
7624
7625 #[tokio::test]
7626 async fn dissolve_community_emits_no_rekey_and_no_epoch_bump() {
7627 let (_tmp, _guard) = init_test_db();
7628 let relay = RekeyCountingRelay::new();
7629 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7630 let cid = community.id.to_hex();
7631 create_public_invite(&relay, &community, None, None).await.unwrap();
7633 let before_epoch = crate::db::community::load_community(&community.id).unwrap().unwrap().server_root_epoch;
7634
7635 dissolve_community(&relay, &community).await.unwrap();
7636
7637 assert!(crate::db::community::get_community_dissolved(&cid).unwrap(), "sealed locally");
7638 assert_eq!(relay.rekeys.load(std::sync::atomic::Ordering::Relaxed), 0,
7639 "dissolution publishes NO 3303 rekey (no last-link privatize re-founding)");
7640 assert_eq!(crate::db::community::load_community(&community.id).unwrap().unwrap().server_root_epoch, before_epoch,
7641 "base epoch unchanged — dissolution rotates nothing");
7642 }
7643
7644 #[tokio::test]
7645 async fn duplicate_owner_tombstones_are_idempotent() {
7646 let (_tmp, _guard) = init_test_db();
7647 let relay = MemoryRelay::new();
7648 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
7649 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7650 let cid = community.id.to_hex();
7651 publish_tombstone(&relay, &community, &owner, 1000).await;
7653 publish_tombstone(&relay, &community, &owner, 2000).await;
7654
7655 fetch_and_apply_control(&relay, &community).await.unwrap();
7656 assert!(crate::db::community::get_community_dissolved(&cid).unwrap(), "duplicates still just dissolve, no error");
7657 assert_eq!(fetch_and_apply_control(&relay, &community).await.unwrap(), 0);
7659 }
7660
7661 #[test]
7662 fn apply_server_root_rekey_refuses_once_dissolved() {
7663 let (_tmp, _guard) = init_test_db();
7664 let owner = Keys::generate();
7665 let me = Keys::generate();
7666 become_local(&me);
7667 let community = saved_community_owned_by(&owner);
7668 let cid = community.id.to_hex();
7669 crate::db::community::set_community_dissolved(&cid).unwrap();
7670
7671 let parsed = owner_base_rekey(&owner, &community, &me.public_key(), 1, &[0xCDu8; 32]);
7672 assert!(apply_server_root_rekey(&community, &parsed).is_err(),
7673 "a base rekey cannot cross a tombstone");
7674 assert_eq!(crate::db::community::load_community(&community.id).unwrap().unwrap().server_root_epoch,
7675 crate::community::Epoch(0), "base epoch did not advance");
7676 }
7677
7678 #[tokio::test]
7679 async fn tombstone_detected_after_a_base_rotation() {
7680 let (_tmp, _guard) = init_test_db();
7681 let relay = MemoryRelay::new();
7682 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
7683 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7684 let cid = community.id.to_hex();
7685 rotate_server_root(&relay, &community, &[owner.public_key()]).await.unwrap();
7688 let rotated = crate::db::community::load_community(&community.id).unwrap().unwrap();
7689 assert_eq!(rotated.server_root_epoch, crate::community::Epoch(1));
7690 publish_tombstone(&relay, &rotated, &owner, 1000).await;
7691
7692 fetch_and_apply_control(&relay, &rotated).await.unwrap();
7693 assert!(crate::db::community::get_community_dissolved(&cid).unwrap(),
7694 "tombstone at the rotation-stable locator is detected post-rotation");
7695 }
7696
7697 #[tokio::test]
7698 async fn stable_coordinate_tombstone_survives_a_concurrent_rotation() {
7699 let (_tmp, _guard) = init_test_db();
7704 let relay = MemoryRelay::new();
7705 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
7706 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7707 let cid = community.id.to_hex();
7708 let inner = crate::community::roster::build_group_dissolved_edition_unsigned(owner.public_key(), &community.id, 1000)
7710 .sign_with_keys(&owner).unwrap();
7711 let stable = crate::community::roster::seal_dissolved_edition(&Keys::generate(), &inner, &community.id).unwrap();
7712 relay.inject(&stable, &community.relays);
7713 rotate_server_root(&relay, &community, &[owner.public_key()]).await.unwrap();
7716 let rotated = crate::db::community::load_community(&community.id).unwrap().unwrap();
7717 assert_eq!(rotated.server_root_epoch, crate::community::Epoch(1));
7718 assert!(!crate::db::community::get_community_dissolved(&cid).unwrap(), "not folded yet");
7719 fetch_and_apply_control(&relay, &rotated).await.unwrap();
7722 assert!(crate::db::community::get_community_dissolved(&cid).unwrap(),
7723 "stable-coordinate probe discovers the tombstone cross-epoch (C3 closed)");
7724 }
7725}