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::{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 = match node_addr {
279 Some(addr) => serde_json::json!({ "op": "ad", "topic": topic_id, "addr": addr }).to_string(),
280 None => serde_json::json!({ "op": "left", "topic": topic_id }).to_string(),
281 };
282 let unsigned = super::envelope::build_inner_typed(
283 author_pk, &channel.id, channel.epoch, event_kind::COMMUNITY_WEBXDC, &content, ms, None, &[],
284 );
285 let signer = active_signer().await?;
286 let inner = unsigned.sign(&signer).await.map_err(|e| format!("Failed to sign webxdc signal: {e}"))?;
287 let _ = publish_signed_message(transport, community, channel, &inner, true).await?;
288 Ok(())
289}
290
291pub async fn publish_typing_signal<T: Transport + ?Sized>(
297 transport: &T,
298 community: &Community,
299 channel: &Channel,
300) -> Result<(), String> {
301 let author_pk = crate::state::my_public_key().ok_or("not logged in")?;
302 let ms = std::time::SystemTime::now()
303 .duration_since(std::time::UNIX_EPOCH)
304 .map(|d| d.as_millis() as u64)
305 .unwrap_or(0);
306 let unsigned = super::envelope::build_inner_typed(
307 author_pk, &channel.id, channel.epoch, event_kind::COMMUNITY_TYPING, "typing", ms, None, &[],
308 );
309 let signer = active_signer().await?;
310 let inner = unsigned.sign(&signer).await.map_err(|e| format!("Failed to sign typing signal: {e}"))?;
311 let _ = publish_signed_message(transport, community, channel, &inner, false).await?;
312 Ok(())
313}
314
315pub async fn persist_webxdc_signal(
322 channel_hex: &str,
323 npub: &str,
324 topic_id: &str,
325 node_addr: Option<&str>,
326 event_id: &str,
327 created_at: u64,
328) {
329 if crate::db::events::event_exists(event_id).unwrap_or(true) {
330 return;
331 }
332 let now_secs = std::time::SystemTime::now()
335 .duration_since(std::time::UNIX_EPOCH)
336 .unwrap_or_default()
337 .as_secs();
338 let created_at = created_at.min(now_secs + 300);
339 let Ok(chat_id) = crate::db::id_cache::get_or_create_chat_id(channel_hex) else { return };
340 let mut tags = vec![
341 vec!["webxdc-topic".to_string(), topic_id.to_string()],
342 vec!["d".to_string(), "vector-webxdc-peer".to_string()],
343 ];
344 if let Some(addr) = node_addr {
345 tags.push(vec!["webxdc-node-addr".to_string(), addr.to_string()]);
346 }
347 let event = crate::stored_event::StoredEvent {
348 id: event_id.to_string(),
349 kind: crate::stored_event::event_kind::APPLICATION_SPECIFIC,
350 chat_id,
351 user_id: None,
352 content: if node_addr.is_some() { "peer-advertisement" } else { "peer-left" }.to_string(),
353 tags,
354 reference_id: Some(topic_id.to_string()),
355 created_at,
356 received_at: std::time::SystemTime::now()
357 .duration_since(std::time::UNIX_EPOCH)
358 .unwrap_or_default()
359 .as_millis() as u64,
360 mine: false,
361 pending: false,
362 failed: false,
363 wrapper_event_id: None,
364 npub: Some(npub.to_string()),
365 preview_metadata: None,
366 };
367 if let Err(e) = crate::db::events::save_event(&event).await {
368 crate::log_warn!("[community] failed to persist webxdc peer signal: {e}");
369 }
370}
371
372async fn strip_member_roles_on_removal<T: Transport + ?Sized>(
386 transport: &T,
387 community: &Community,
388 member_hex: &str,
389) {
390 let cid = community.id.to_hex();
391 let roster = match crate::db::community::get_community_roles(&cid) {
392 Ok(r) => r,
393 Err(_) => return,
394 };
395 let held: Vec<String> = roster
396 .grants
397 .iter()
398 .find(|g| g.member == member_hex)
399 .map(|g| g.role_ids.clone())
400 .unwrap_or_default();
401 if held.is_empty() {
402 return; }
404 for role_id in &held {
405 if caller_can_manage_role(community, &roster, role_id, member_hex).is_err() {
406 crate::log_warn!(
407 "removal: not authorized to revoke role {role_id} of {member_hex}; leaving the grant (kick/ban still neutralizes)"
408 );
409 return;
410 }
411 }
412 if let Err(e) = set_member_grant(transport, community, member_hex, Vec::new()).await {
413 crate::log_warn!("removal: role-strip publish failed for {member_hex}: {e}");
414 }
415}
416
417pub async fn publish_kick<T: Transport + ?Sized>(
418 transport: &T,
419 community: &Community,
420 channel: &Channel,
421 target_hex: &str,
422) -> Result<String, String> {
423 let author_pk = crate::state::my_public_key().ok_or("not logged in")?;
424 let me = author_pk.to_hex();
425 let cid = community.id.to_hex();
426 {
429 let owner = proven_owner_hex(community);
430 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
431 if !roster.can_act_on_member(&me, owner.as_deref(), target_hex, super::roles::Permissions::KICK) {
432 return Err("you can't kick a member who outranks you (or the owner)".to_string());
433 }
434 }
435 let ms = std::time::SystemTime::now()
436 .duration_since(std::time::UNIX_EPOCH)
437 .map(|d| d.as_millis() as u64)
438 .unwrap_or(0);
439 let citation = authority_citation(community, &me);
441 let extra: Vec<nostr_sdk::prelude::Tag> = citation.iter().map(|c| c.to_tag()).collect();
442 let unsigned = super::envelope::build_inner_full(
443 author_pk, &channel.id, channel.epoch, event_kind::COMMUNITY_KICK, target_hex, ms, None, &[], &extra,
444 );
445 let signer = active_signer().await?;
446 let inner = unsigned.sign(&signer).await.map_err(|e| format!("Failed to sign kick: {e}"))?;
447 publish_signed_message(transport, community, channel, &inner, true).await?;
448 strip_member_roles_on_removal(transport, community, target_hex).await;
451 Ok(inner.id.to_hex())
453}
454
455
456pub async fn publish_banlist<T: Transport + ?Sized>(
462 transport: &T,
463 community: &Community,
464 banned_hex: &[String],
465) -> Result<(), String> {
466 let session = SessionGuard::capture();
467 let cid = community.id.to_hex();
468 let signer = active_signer().await?;
471 let actor_pk = crate::state::my_public_key().ok_or("no local identity to sign the banlist edition")?;
472 {
477 let me = actor_pk.to_hex();
478 let owner = proven_owner_hex(community);
479 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
480 let current: std::collections::HashSet<String> =
481 crate::db::community::get_community_banlist(&cid).unwrap_or_default().into_iter().collect();
482 let next: std::collections::HashSet<&str> = banned_hex.iter().map(|s| s.as_str()).collect();
483 let added = banned_hex.iter().filter(|n| !current.contains(n.as_str()));
484 let removed = current.iter().filter(|n| !next.contains(n.as_str()));
485 for target in added.chain(removed) {
486 if !roster.can_act_on_member(&me, owner.as_deref(), target, super::roles::Permissions::BAN) {
487 return Err("you can't ban or unban a member who outranks you (or the owner)".to_string());
488 }
489 }
490 }
491 {
497 let prev: std::collections::HashSet<String> =
498 crate::db::community::get_community_banlist(&cid).unwrap_or_default().into_iter().collect();
499 let adds = banned_hex.iter().any(|n| !prev.contains(n.as_str()));
500 let cut_needed = (adds || crate::db::community::get_read_cut_pending(&cid)?) && !is_public(community)?;
501 if cut_needed && crate::state::MY_SECRET_KEY.to_keys().is_none() {
502 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());
503 }
504 }
505 let entity_id = super::derive::banlist_locator(&community.id);
507 let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
508 let (version, prev_hash) = match crate::db::community::get_edition_head(&cid, &entity_hex)? {
509 Some((v, h)) => (v + 1, Some(h)),
510 None => (1, None),
511 };
512 let created_at = std::time::SystemTime::now()
513 .duration_since(std::time::UNIX_EPOCH)
514 .map(|d| d.as_secs())
515 .unwrap_or(0);
516 let citation = authority_citation(community, &actor_pk.to_hex());
519 let unsigned = super::roster::build_banlist_edition_unsigned(actor_pk, &community.id, banned_hex, version, prev_hash.as_ref(), created_at, citation.as_ref())?;
520 let inner = unsigned.sign(&signer).await.map_err(|e| format!("sign banlist edition: {e}"))?;
521 let outer = super::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch)?;
522 let self_hash = super::version::edition_hash(&entity_id, version, prev_hash.as_ref(), inner.content.as_bytes());
523
524 let newly_added: Vec<String> = {
527 let prev: std::collections::HashSet<String> =
528 crate::db::community::get_community_banlist(&cid).unwrap_or_default().into_iter().collect();
529 banned_hex.iter().filter(|n| !prev.contains(n.as_str())).cloned().collect()
530 };
531 let newly_banned = !newly_added.is_empty();
532
533 transport.publish_durable(&outer, &community.relays).await?;
537 if session.is_valid() {
538 crate::db::community::set_community_banlist(&cid, banned_hex, created_at as i64)?;
539 crate::db::community::set_edition_head(&cid, &entity_hex, version, &self_hash)?;
540 }
541
542 if session.is_valid() {
547 for member_hex in &newly_added {
548 strip_member_roles_on_removal(transport, community, member_hex).await;
549 }
550 }
551
552 let need_cut = (newly_banned || crate::db::community::get_read_cut_pending(&cid)?)
567 && session.is_valid()
568 && !is_public(community)?;
569 if need_cut {
570 run_read_cut(transport, community, newly_banned).await?;
573 }
574 Ok(())
575}
576
577pub fn am_i_banned(community: &Community) -> bool {
583 let me = match crate::state::my_public_key() {
584 Some(p) => p.to_hex(),
585 None => return false,
586 };
587 crate::db::community::get_community_banlist(&community.id.to_hex())
588 .unwrap_or_default()
589 .iter()
590 .any(|b| b == &me)
591}
592
593pub async fn retry_pending_read_cut<T: Transport + ?Sized>(
599 transport: &T,
600 community: &Community,
601) -> Result<(), String> {
602 let cid = community.id.to_hex();
603 if !crate::db::community::get_read_cut_pending(&cid)? {
604 return Ok(());
605 }
606 if is_public(community)? {
607 crate::db::community::set_read_cut_pending(&cid, false)?; return Ok(());
609 }
610 let fresh = crate::db::community::load_community(&community.id)?.ok_or("community no longer present")?;
615 run_read_cut(transport, &fresh, false).await
616}
617
618async fn fetch_control_folded<T: Transport + ?Sized>(
628 transport: &T,
629 community: &Community,
630) -> Result<super::roster::FoldedRoster, String> {
631 let z_tags = vec![super::roster::control_pseudonym(&community.server_root_key, &community.id, community.server_root_epoch)];
636 let query = Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags, ..Default::default() };
640 let raw = transport.fetch(&query, &community.relays).await?;
641 let inner_editions: Vec<Event> = raw
644 .iter()
645 .take(super::roster::MAX_CONTROL_EDITIONS)
646 .filter_map(|ev| super::roster::open_control_edition(ev, &community.server_root_key).ok())
647 .collect();
648 let fetched = inner_editions.len();
653 let current_epoch = community.server_root_epoch.0;
659 let floors: std::collections::HashMap<String, (u64, [u8; 32])> =
660 crate::db::community::get_all_edition_heads_epoched(&community.id.to_hex())?
661 .into_iter()
662 .filter(|(_, (epoch, _, _))| *epoch == current_epoch)
663 .map(|(entity, (_epoch, version, hash))| (entity, (version, hash)))
664 .collect();
665 let mut folded = super::roster::fold_roster(&inner_editions, &community.id, &floors);
666 folded.fetched = fetched; Ok(folded)
668}
669
670pub async fn fetch_and_apply_control<T: Transport + ?Sized>(
676 transport: &T,
677 community: &Community,
678) -> Result<usize, String> {
679 let session = SessionGuard::capture();
680 let cid = community.id.to_hex();
681 if crate::db::community::get_community_dissolved(&cid)? {
684 return Ok(0);
685 }
686 let folded = fetch_control_folded(transport, community).await?;
687 if !session.is_valid() {
688 return Err("account changed during control fetch".to_string());
689 }
690 if let Some(owner) = proven_owner_hex(community) {
700 let by_fold = folded.dissolved_by.iter().any(|s| s.to_hex() == owner);
701 let by_probe = !by_fold && dissolved_tombstone_present(transport, community, &owner).await;
702 if by_fold || by_probe {
703 let _ = fetch_and_apply_banlist_inner(transport, community, Some(folded.clone())).await;
706 let _ = fetch_and_apply_roles_inner(transport, community, Some(folded.clone())).await;
707 let _ = fetch_and_apply_invite_links_inner(transport, community, Some(folded.clone())).await;
708 let _ = fetch_and_apply_metadata_inner(transport, community, Some(folded.clone())).await;
709 if session.is_valid() {
710 crate::db::community::set_community_dissolved(&cid)?;
711 crate::emit_event("community_refreshed", &serde_json::json!({ "community_id": cid }));
715 }
716 return Ok(folded.fetched);
717 }
718 }
719 let fetched = folded.fetched;
722 let _ = fetch_and_apply_banlist_inner(transport, community, Some(folded.clone())).await;
723 let _ = fetch_and_apply_roles_inner(transport, community, Some(folded.clone())).await;
724 let _ = fetch_and_apply_invite_links_inner(transport, community, Some(folded.clone())).await;
725 let _ = fetch_and_apply_metadata_inner(transport, community, Some(folded)).await;
726 Ok(fetched)
727}
728
729pub async fn fetch_and_apply_banlist<T: Transport + ?Sized>(
730 transport: &T,
731 community: &Community,
732) -> Result<Vec<String>, String> {
733 fetch_and_apply_banlist_inner(transport, community, None).await
734}
735
736async fn fetch_and_apply_banlist_inner<T: Transport + ?Sized>(
737 transport: &T,
738 community: &Community,
739 prefolded: Option<super::roster::FoldedRoster>,
740) -> Result<Vec<String>, String> {
741 let session = SessionGuard::capture();
742 let cid = community.id.to_hex();
743 let folded = match prefolded {
744 Some(f) => f,
745 None => fetch_control_folded(transport, community).await?,
746 };
747 let owner = proven_owner_hex(community);
750 let authorized = super::roster::authorize_delegation(&folded, owner.as_deref());
751 if !session.is_valid() {
752 return Err("account changed during banlist fetch".to_string());
753 }
754 if let (Some(author), Some(head)) = (folded.banlist_author, &folded.banlist_head) {
755 let author_hex = author.to_hex();
760 let held: std::collections::HashSet<String> =
761 crate::db::community::get_community_banlist(&cid)?.into_iter().collect();
762 let next: std::collections::HashSet<&str> = folded.banned.iter().map(|s| s.as_str()).collect();
763 let added = folded.banned.iter().filter(|n| !held.contains(n.as_str()));
764 let removed = held.iter().filter(|n| !next.contains(n.as_str()));
765 let citation = folded.banlist_head.as_ref().and_then(|h| h.citation.as_ref());
771 let banner_grant_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(&community.id, &author.to_bytes()));
772 let pinned = super::roster::authority_citation_satisfied(&folded.heads, owner.as_deref(), &author_hex, &banner_grant_hex, citation);
773 let authed = pinned
774 && added.chain(removed).all(|target| {
775 authorized.can_act_on_member(&author_hex, owner.as_deref(), target, super::roles::Permissions::BAN)
776 });
777 let held_version = crate::db::community::get_edition_head(&cid, &head.entity_hex)?.map(|(v, _)| v).unwrap_or(0);
778 if authed && head.version > held_version {
779 crate::db::community::set_community_banlist(&cid, &folded.banned, head.version as i64)?;
780 crate::db::community::set_edition_head(&cid, &head.entity_hex, head.version, &head.self_hash)?;
781 return Ok(folded.banned);
782 }
783 }
784 crate::db::community::get_community_banlist(&cid)
786}
787
788pub async fn set_member_grant<T: Transport + ?Sized>(
793 transport: &T,
794 community: &Community,
795 member_hex: &str,
796 role_ids: Vec<String>,
797) -> Result<(), String> {
798 let session = SessionGuard::capture();
799 let signer = active_signer().await?;
802 let actor_pk = crate::state::my_public_key().ok_or("no local identity to sign the grant edition")?;
803 let cid = community.id.to_hex();
804 let grant = super::roles::MemberGrant { member: member_hex.to_string(), role_ids };
805
806 let member_bytes = crate::simd::hex::hex_to_bytes_32(member_hex);
809 let entity_id = super::derive::grant_locator(&community.id, &member_bytes);
810 let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
811 let (version, prev_hash) = match crate::db::community::get_edition_head(&cid, &entity_hex)? {
812 Some((v, h)) => (v + 1, Some(h)),
813 None => (1, None),
814 };
815 let created_at = std::time::SystemTime::now()
816 .duration_since(std::time::UNIX_EPOCH)
817 .map(|d| d.as_secs())
818 .unwrap_or(0);
819
820 let citation = authority_citation(community, &actor_pk.to_hex());
827 let unsigned = super::roster::build_grant_edition_unsigned(actor_pk, &community.id, &grant, version, prev_hash.as_ref(), created_at, citation.as_ref())?;
828 let inner = unsigned.sign(&signer).await.map_err(|e| format!("sign grant edition: {e}"))?;
829 let outer = super::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch)?;
830 let self_hash = super::version::edition_hash(&entity_id, version, prev_hash.as_ref(), inner.content.as_bytes());
834
835 let is_full_revoke = grant.role_ids.is_empty();
836 let mut roster = crate::db::community::get_community_roles(&cid)?;
838 roster.grants.retain(|g| g.member != member_hex);
839 if !grant.role_ids.is_empty() {
840 roster.grants.push(grant);
841 }
842
843 transport.publish_durable(&outer, &community.relays).await?;
849 if session.is_valid() {
850 crate::db::community::set_community_roles(&cid, &roster, created_at as i64)?;
851 crate::db::community::set_edition_head(&cid, &entity_hex, version, &self_hash)?;
852 }
853
854 if is_full_revoke && session.is_valid() {
862 if let Ok(folded) = fetch_control_folded(transport, community).await {
863 if session.is_valid() {
864 let current = crate::db::community::load_community(&community.id)?.unwrap_or_else(|| community.clone());
865 if folded.root_author.map(|a| a.to_hex()).as_deref() == Some(member_hex) {
866 if let Some(meta) = &folded.root_meta {
867 let mut c = current.clone();
868 c.name = meta.name.clone();
869 c.description = meta.description.clone();
870 c.icon = meta.icon.clone();
871 c.banner = meta.banner.clone();
872 let _ = republish_community_metadata(transport, &c).await;
873 }
874 }
875 for cm in &folded.channel_meta {
876 if cm.author.to_hex() == member_hex
877 && current.channels.iter().any(|ch| ch.id.0 == cm.channel_id)
878 {
879 let _ = republish_channel_metadata(
880 transport, ¤t, &crate::community::ChannelId(cm.channel_id), &cm.meta.name,
881 ).await;
882 }
883 }
884 }
885 }
886 }
887 Ok(())
888}
889
890pub fn is_proven_owner(community: &Community) -> bool {
895 match crate::state::my_public_key() {
896 Some(me) => proven_owner_hex(community).as_deref() == Some(me.to_hex().as_str()),
897 None => false,
898 }
899}
900
901pub fn caller_can_manage_roles(community: &Community) -> bool {
905 let me = match crate::state::my_public_key() {
906 Some(p) => p,
907 None => return false,
908 };
909 let cid = community.id.to_hex();
910 let is_owner = community
911 .owner_attestation
912 .as_ref()
913 .and_then(|a| super::owner::verify_owner_attestation(a, &cid))
914 .map(|pk| pk == me)
915 .unwrap_or(false);
916 if is_owner {
917 return true; }
919 crate::db::community::get_community_roles(&cid)
920 .unwrap_or_default()
921 .has_permission(&me.to_hex(), super::roles::Permissions::MANAGE_ROLES)
922}
923
924pub fn caller_has_permission(community: &Community, permission: u64) -> bool {
928 let me = match crate::state::my_public_key() {
929 Some(p) => p,
930 None => return false,
931 };
932 crate::db::community::get_community_roles(&community.id.to_hex())
933 .unwrap_or_default()
934 .is_authorized(&me.to_hex(), proven_owner_hex(community).as_deref(), permission)
935}
936
937pub fn caller_can_manage_role_id(community: &Community, role_id: &str) -> bool {
942 let me = match crate::state::my_public_key() {
943 Some(p) => p.to_hex(),
944 None => return false,
945 };
946 let roster = crate::db::community::get_community_roles(&community.id.to_hex()).unwrap_or_default();
947 let position = match roster.role(role_id) {
948 Some(r) => r.position,
949 None => return false,
950 };
951 roster.can_manage_position(&me, proven_owner_hex(community).as_deref(), position)
952}
953
954#[derive(Debug, Clone, Default, serde::Serialize)]
959pub struct CommunityCapabilities {
960 pub manage_metadata: bool,
961 pub manage_channels: bool,
962 pub create_invite: bool,
963 pub kick: bool,
964 pub ban: bool,
965 pub manage_messages: bool,
966 pub manage_roles: bool,
967}
968
969pub fn caller_capabilities(community: &Community) -> CommunityCapabilities {
970 use super::roles::Permissions as P;
971 let me_hex = match crate::state::my_public_key() {
972 Some(p) => p.to_hex(),
973 None => return CommunityCapabilities::default(),
974 };
975 let owner = proven_owner_hex(community);
976 let roster = crate::db::community::get_community_roles(&community.id.to_hex()).unwrap_or_default();
977 let has = |bit: u64| roster.is_authorized(&me_hex, owner.as_deref(), bit);
978 CommunityCapabilities {
979 manage_metadata: has(P::MANAGE_METADATA),
980 manage_channels: has(P::MANAGE_CHANNELS),
981 create_invite: has(P::CREATE_INVITE),
982 kick: has(P::KICK),
983 ban: has(P::BAN),
984 manage_messages: has(P::MANAGE_MESSAGES),
985 manage_roles: has(P::MANAGE_ROLES),
986 }
987}
988
989fn authority_citation(community: &Community, actor_hex: &str) -> Option<super::edition::AuthorityCitation> {
997 if proven_owner_hex(community).as_deref() == Some(actor_hex) {
998 return None;
999 }
1000 let cid = community.id.to_hex();
1001 let actor_bytes = crate::simd::hex::hex_to_bytes_32(actor_hex);
1002 let entity_id = super::derive::grant_locator(&community.id, &actor_bytes);
1003 let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
1004 crate::db::community::get_edition_head(&cid, &entity_hex)
1005 .ok()
1006 .flatten()
1007 .map(|(version, edition_hash)| super::edition::AuthorityCitation { entity_id, version, edition_hash })
1008}
1009
1010fn proven_owner_hex(community: &Community) -> Option<String> {
1013 let cid = community.id.to_hex();
1014 community
1015 .owner_attestation
1016 .as_ref()
1017 .and_then(|a| super::owner::verify_owner_attestation(a, &cid))
1018 .map(|pk| pk.to_hex())
1019}
1020
1021pub fn can_moderation_hide(community: &Community, actor_hex: &str, author_hex: &str) -> bool {
1027 let to_hex = |s: &str| nostr_sdk::PublicKey::parse(s).map(|pk| pk.to_hex()).unwrap_or_else(|_| s.to_string());
1034 let actor = to_hex(actor_hex);
1035 let author = to_hex(author_hex);
1036 let owner = proven_owner_hex(community);
1037 let roster = crate::db::community::get_community_roles(&community.id.to_hex()).unwrap_or_default();
1038 roster.can_act_on_member(&actor, owner.as_deref(), &author, super::roles::Permissions::MANAGE_MESSAGES)
1039}
1040
1041fn rotator_is_authorized(
1049 cid: &str,
1050 roster: &super::roles::CommunityRoles,
1051 owner_hex: Option<&str>,
1052 rotator_hex: &str,
1053 permission: u64,
1054) -> bool {
1055 if owner_hex != Some(rotator_hex)
1056 && crate::db::community::get_community_banlist(cid)
1057 .unwrap_or_default()
1058 .iter()
1059 .any(|b| b == rotator_hex)
1060 {
1061 return false;
1062 }
1063 roster.is_authorized(rotator_hex, owner_hex, permission)
1064}
1065
1066fn caller_can_manage_role(
1072 community: &Community,
1073 roster: &super::roles::CommunityRoles,
1074 role_id: &str,
1075 member_hex: &str,
1076) -> Result<(), String> {
1077 let me = crate::state::my_public_key().ok_or("no active identity")?.to_hex();
1078 let owner = proven_owner_hex(community);
1079 let owner_ref = owner.as_deref();
1080 let role = roster.role(role_id).ok_or("no such role")?;
1081 if !roster.can_manage_position(&me, owner_ref, role.position) {
1082 return Err("you can only manage roles below your own".to_string());
1083 }
1084 if !roster.can_manage_member(&me, owner_ref, member_hex) {
1085 return Err("you can't manage a member who outranks you".to_string());
1086 }
1087 Ok(())
1088}
1089
1090pub async fn grant_role<T: Transport + ?Sized>(
1094 transport: &T,
1095 community: &Community,
1096 member: nostr_sdk::prelude::PublicKey,
1097 role_id: &str,
1098) -> Result<(), String> {
1099 let cid = community.id.to_hex();
1100 let member_hex = member.to_hex();
1101 let roster = crate::db::community::get_community_roles(&cid)?;
1102 caller_can_manage_role(community, &roster, role_id, &member_hex)?;
1103 let mut role_ids: Vec<String> = roster
1105 .grants
1106 .iter()
1107 .find(|g| g.member == member_hex)
1108 .map(|g| g.role_ids.clone())
1109 .unwrap_or_default();
1110 if !role_ids.iter().any(|r| r == role_id) {
1111 role_ids.push(role_id.to_string());
1112 }
1113
1114 set_member_grant(transport, community, &member_hex, role_ids).await
1118}
1119
1120pub async fn revoke_role<T: Transport + ?Sized>(
1127 transport: &T,
1128 community: &Community,
1129 member: nostr_sdk::prelude::PublicKey,
1130 role_id: &str,
1131) -> Result<(), String> {
1132 let cid = community.id.to_hex();
1133 let member_hex = member.to_hex();
1134 let roster = crate::db::community::get_community_roles(&cid)?;
1135 caller_can_manage_role(community, &roster, role_id, &member_hex)?;
1136 let role_ids: Vec<String> = roster
1137 .grants
1138 .iter()
1139 .find(|g| g.member == member_hex)
1140 .map(|g| g.role_ids.iter().filter(|r| r.as_str() != role_id).cloned().collect())
1141 .unwrap_or_default();
1142 set_member_grant(transport, community, &member_hex, role_ids).await
1143}
1144
1145pub async fn fetch_and_apply_roles<T: Transport + ?Sized>(
1151 transport: &T,
1152 community: &Community,
1153) -> Result<super::roles::CommunityRoles, String> {
1154 fetch_and_apply_roles_inner(transport, community, None).await
1155}
1156
1157async fn fetch_and_apply_roles_inner<T: Transport + ?Sized>(
1158 transport: &T,
1159 community: &Community,
1160 prefolded: Option<super::roster::FoldedRoster>,
1161) -> Result<super::roles::CommunityRoles, String> {
1162 let session = SessionGuard::capture();
1163 let cid = community.id.to_hex();
1164 let folded = match prefolded {
1165 Some(f) => f,
1166 None => fetch_control_folded(transport, community).await?,
1167 };
1168
1169 if !session.is_valid() {
1170 return Err("account changed during roles fetch".to_string());
1171 }
1172 for head in &folded.heads {
1181 crate::db::community::set_edition_head(&cid, &head.entity_hex, head.version, &head.self_hash)?;
1182 }
1183 if folded.heads.is_empty() {
1188 return crate::db::community::get_community_roles(&cid);
1189 }
1190 let authorized = super::roster::authorize_delegation(&folded, proven_owner_hex(community).as_deref());
1194 crate::db::community::set_community_roles(&cid, &authorized, 0)?;
1195 Ok(authorized)
1196}
1197
1198pub async fn publish_owner_hide<T: Transport + ?Sized>(
1203 transport: &T,
1204 community: &Community,
1205 channel: &Channel,
1206 target_message_id: &str,
1207) -> Result<(), String> {
1208 let signer = active_signer().await?;
1213 let me_pk = crate::state::my_public_key().ok_or("no local identity to sign the hide")?;
1214 let me = me_pk.to_hex();
1215 {
1216 let target_author = {
1217 let st = crate::state::STATE.lock().await;
1218 st.find_message(target_message_id).and_then(|(_, m)| m.npub)
1219 };
1220 let author = target_author
1221 .ok_or("can't resolve the target message's author to authorize the hide")?;
1222 if !can_moderation_hide(community, &me, &author) {
1223 return Err("you can't hide a message from a member who outranks you (or the owner)".to_string());
1224 }
1225 }
1226 let ms = std::time::SystemTime::now()
1227 .duration_since(std::time::UNIX_EPOCH)
1228 .map(|d| d.as_millis() as u64)
1229 .unwrap_or(0);
1230 let citation = authority_citation(community, &me);
1236 let extra: Vec<Tag> = citation.iter().map(|c| c.to_tag()).collect();
1237 let inner = super::envelope::build_inner_full(
1238 me_pk, &channel.id, channel.epoch,
1239 event_kind::COMMUNITY_DELETE, "", ms, Some(target_message_id), &[], &extra,
1240 )
1241 .sign(&signer)
1242 .await
1243 .map_err(|e| format!("sign hide: {e}"))?;
1244 let _ = publish_signed_message(transport, community, channel, &inner, true).await?;
1245 Ok(())
1246}
1247
1248pub async fn delete_message<T: Transport + ?Sized>(
1253 transport: &T,
1254 message_id: &str,
1255) -> Result<(), String> {
1256 let session = SessionGuard::capture();
1257 if !session.is_valid() {
1258 return Err("account changed; aborting delete".to_string());
1259 }
1260 let (ephemeral, outer_event_id_hex, relays) = match crate::db::community::get_message_key(message_id)? {
1264 Some(v) => v,
1265 None => {
1266 return Err("no retained key for this message (not yours, or already deleted)".to_string())
1267 }
1268 };
1269 let id = EventId::from_hex(&outer_event_id_hex).map_err(|e| e.to_string())?;
1270 delete_own_message(transport, &relays, &ephemeral, id).await?;
1271 crate::db::community::delete_message_key(message_id)?;
1273 Ok(())
1274}
1275
1276pub fn accept_invite(invite: &CommunityInvite) -> Result<Community, String> {
1289 let session = SessionGuard::capture();
1290 let community = super::invite::accept_invite(invite)?; match crate::db::community::load_community(&community.id)? {
1293 Some(existing) => {
1295 if is_proven_owner(&existing) {
1296 return Err("you already own this Community".to_string());
1297 }
1298 if existing.server_root_key.as_bytes() != community.server_root_key.as_bytes() {
1302 return Err(
1303 "invite reuses a known Community id under a different authority — rejected"
1304 .to_string(),
1305 );
1306 }
1307 }
1308 None => enforce_community_cap()?,
1310 }
1311
1312 if !session.is_valid() {
1313 return Err("account changed during invite accept".to_string());
1314 }
1315 crate::db::community::save_community(&community)?;
1316 Ok(community)
1317}
1318
1319pub async fn preload_community(invite: &super::invite::CommunityInvite) {
1326 let Ok(community) = super::invite::accept_invite(invite) else { return };
1327 let Some(channel) = community.channels.first() else { return };
1328 let cid = community.id.to_hex();
1329 crate::community::cache::begin_preload(&cid);
1331 let transport = super::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1332 match super::send::fetch_channel_page(&transport, &community, channel, None, None, 50).await {
1334 Ok(page) if !page.is_empty() => crate::community::cache::finish_preload(&cid, page),
1335 _ => crate::community::cache::abort_preload(&cid),
1337 }
1338
1339 let prune_relays = community.relays.clone();
1343 let prune_id = community.id;
1344 let guard = crate::state::SessionGuard::capture();
1345 tokio::spawn(async move {
1346 tokio::time::sleep(crate::community::cache::PRELOAD_TTL).await;
1347 if !guard.is_valid() {
1348 return;
1349 }
1350 if matches!(crate::db::community::load_community(&prune_id), Ok(Some(_))) {
1353 return;
1354 }
1355 crate::community::cache::abort_preload(&prune_id.to_hex());
1357 super::transport::prune_unneeded_community_relays(&prune_relays).await;
1358 });
1359}
1360
1361pub async fn republish_community_metadata<T: Transport + ?Sized>(
1366 transport: &T,
1367 community: &Community,
1368) -> Result<(), String> {
1369 let session = SessionGuard::capture();
1370 let cid = community.id.to_hex();
1371 let signer = active_signer().await?;
1372 let actor_pk = crate::state::my_public_key().ok_or("no local identity to sign the metadata edition")?;
1373 let owner = proven_owner_hex(community);
1374 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
1375 if !roster.is_authorized(&actor_pk.to_hex(), owner.as_deref(), super::roles::Permissions::MANAGE_METADATA) {
1376 return Err("only a member with manage-metadata authority can edit the community".to_string());
1377 }
1378 let (version, prev_hash) = match crate::db::community::get_edition_head(&cid, &cid)? {
1383 Some((v, h)) => (v + 1, Some(h)),
1384 None => (1, None),
1385 };
1386 let created = std::time::SystemTime::now()
1387 .duration_since(std::time::UNIX_EPOCH)
1388 .map(|d| d.as_secs())
1389 .unwrap_or(0);
1390 let meta = super::metadata::CommunityMetadata::of(community);
1391 let citation = authority_citation(community, &actor_pk.to_hex());
1396 let unsigned = super::roster::build_community_root_edition_unsigned(actor_pk, &community.id, &meta, version, prev_hash.as_ref(), created, citation.as_ref())?;
1397 let inner = unsigned.sign(&signer).await.map_err(|e| format!("sign community-root edition: {e}"))?;
1398 let outer = super::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch)?;
1399 transport.publish_durable(&outer, &community.relays).await?;
1400 if session.is_valid() {
1401 crate::db::community::save_community(community)?;
1402 let h = super::version::edition_hash(&community.id.0, version, prev_hash.as_ref(), inner.content.as_bytes());
1403 crate::db::community::set_edition_head_with_id(&cid, &cid, version, &h, &inner.id.to_bytes())?;
1406 }
1407 Ok(())
1408}
1409
1410pub async fn republish_channel_metadata<T: Transport + ?Sized>(
1416 transport: &T,
1417 community: &Community,
1418 channel_id: &crate::community::ChannelId,
1419 new_name: &str,
1420) -> Result<(), String> {
1421 let session = SessionGuard::capture();
1422 let cid = community.id.to_hex();
1423 let ch_hex = channel_id.to_hex();
1424 if !community.channels.iter().any(|c| &c.id == channel_id) {
1425 return Err("no such channel in this community".to_string());
1426 }
1427 let signer = active_signer().await?;
1428 let actor_pk = crate::state::my_public_key().ok_or("no local identity to sign the channel metadata edition")?;
1429 let owner = proven_owner_hex(community);
1430 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
1431 if !roster.is_authorized(&actor_pk.to_hex(), owner.as_deref(), super::roles::Permissions::MANAGE_CHANNELS) {
1432 return Err("only a member with manage-channels authority can rename a channel".to_string());
1433 }
1434 let (version, prev_hash) = match crate::db::community::get_edition_head(&cid, &ch_hex)? {
1435 Some((v, h)) => (v + 1, Some(h)),
1436 None => (1, None),
1437 };
1438 let created = std::time::SystemTime::now()
1439 .duration_since(std::time::UNIX_EPOCH)
1440 .map(|d| d.as_secs())
1441 .unwrap_or(0);
1442 let meta = super::metadata::ChannelMetadata { name: new_name.to_string() };
1443 let citation = authority_citation(community, &actor_pk.to_hex());
1446 let unsigned = super::roster::build_channel_metadata_edition_unsigned(actor_pk, channel_id, &meta, version, prev_hash.as_ref(), created, citation.as_ref())?;
1447 let inner = unsigned.sign(&signer).await.map_err(|e| format!("sign channel-metadata edition: {e}"))?;
1448 let outer = super::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch)?;
1449 transport.publish_durable(&outer, &community.relays).await?;
1450 if session.is_valid() {
1451 let mut current = crate::db::community::load_community(&community.id)?.ok_or("community no longer present")?;
1452 if let Some(ch) = current.channels.iter_mut().find(|c| &c.id == channel_id) {
1453 ch.name = new_name.to_string();
1454 }
1455 crate::db::community::save_community(¤t)?;
1456 let h = super::version::edition_hash(&channel_id.0, version, prev_hash.as_ref(), inner.content.as_bytes());
1457 crate::db::community::set_edition_head_with_id(&cid, &ch_hex, version, &h, &inner.id.to_bytes())?;
1458 }
1459 Ok(())
1460}
1461
1462fn generate_invite_label() -> String {
1475 use rand::Rng;
1476 const ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
1477 let mut rng = rand::thread_rng();
1478 (0..6).map(|_| ALPHABET[rng.gen_range(0..ALPHABET.len())] as char).collect()
1479}
1480
1481pub async fn create_public_invite<T: Transport + ?Sized>(
1482 transport: &T,
1483 community: &Community,
1484 expires_at: Option<u64>,
1485 label: Option<String>,
1486) -> Result<(String, String), String> {
1487 if !caller_has_permission(community, super::roles::Permissions::CREATE_INVITE) {
1488 return Err("you need the create-invite permission to mint a public invite".to_string());
1489 }
1490 let session = SessionGuard::capture();
1491
1492 let existing = crate::db::community::list_public_invites(&community.id.to_hex()).unwrap_or_default();
1496 let label_taken = |cand: &str| {
1497 existing.iter().any(|r| r.label.as_deref().map(|e| e.eq_ignore_ascii_case(cand)).unwrap_or(false))
1498 };
1499 let label = match label {
1500 Some(l) if !l.trim().is_empty() => {
1501 let l = l.trim().to_string();
1502 if label_taken(&l) {
1503 return Err(format!("You already have an invite link labeled \u{201c}{l}\u{201d}. Pick a different label."));
1504 }
1505 Some(l)
1506 }
1507 _ => {
1509 let mut l = generate_invite_label();
1510 while label_taken(&l) {
1511 l = generate_invite_label();
1512 }
1513 Some(l)
1514 }
1515 };
1516
1517 let creator_npub = crate::state::my_public_key().and_then(|pk| pk.to_bech32().ok());
1520 let token = public_invite::new_token();
1521 let event = build_public_invite_event(community, &token, expires_at, creator_npub, label.clone()).map_err(|e| e.to_string())?;
1522 transport.publish_durable(&event, &community.relays).await?;
1523
1524 if !session.is_valid() {
1527 return Err("account changed during public invite creation".to_string());
1528 }
1529 let token_hex = crate::simd::hex::bytes_to_hex_32(&token);
1530 let url = public_invite::encode_invite_url(&community.relays, &token);
1531 crate::db::community::save_public_invite(
1532 &token_hex,
1533 &community.id.to_hex(),
1534 &url,
1535 expires_at.map(|e| e as i64),
1536 label.as_deref(),
1537 )?;
1538 super::invite_list::add_invite(super::invite_list::InviteEntry {
1541 token: token_hex.clone(),
1542 community_id: community.id.to_hex(),
1543 url: url.clone(),
1544 label: label.clone(),
1545 created_at: std::time::SystemTime::now()
1546 .duration_since(std::time::UNIX_EPOCH)
1547 .map(|d| d.as_secs())
1548 .unwrap_or(0),
1549 expires_at,
1550 });
1551 republish_my_invite_links(transport, community).await?;
1554 Ok((token_hex, url))
1555}
1556
1557pub async fn latest_invite_preview<T: Transport + ?Sized>(
1563 transport: &T,
1564 bundle: &public_invite::PublicInviteBundle,
1565) -> public_invite::PublicInvitePreview {
1566 let snapshot = bundle.preview.clone();
1567 let Ok(community) = super::invite::accept_invite(&bundle.join) else {
1568 return snapshot;
1569 };
1570 let Ok(folded) = fetch_control_folded(transport, &community).await else {
1571 return snapshot;
1572 };
1573 let owner = proven_owner_hex(&community);
1574 let authorized = super::roster::authorize_delegation(&folded, owner.as_deref());
1575 match folded.root_candidates.iter().find(|c| {
1576 authorized.is_authorized(&c.author.to_hex(), owner.as_deref(), super::roles::Permissions::MANAGE_METADATA)
1577 }) {
1578 Some(c) => public_invite::PublicInvitePreview {
1579 name: c.meta.name.clone(),
1580 description: c.meta.description.clone(),
1581 icon: c.meta.icon.clone(),
1582 },
1583 None => snapshot,
1584 }
1585}
1586
1587pub async fn fetch_public_invite<T: Transport + ?Sized>(
1591 transport: &T,
1592 relays: &[String],
1593 token: &[u8; 32],
1594) -> Result<PublicInviteBundle, String> {
1595 let query = Query {
1599 kinds: vec![event_kind::APPLICATION_SPECIFIC],
1600 d_tags: vec![locator_hex(token)],
1601 ..Default::default()
1602 };
1603 let events = transport.fetch(&query, relays).await?;
1604 let (mut bundle_at, mut bundle, mut revoked_at) = (0u64, None, None::<u64>);
1610 for ev in &events {
1611 match parse_public_invite_event(ev, token) {
1612 Ok(b) => if bundle.is_none() || ev.created_at.as_secs() > bundle_at {
1613 bundle_at = ev.created_at.as_secs();
1614 bundle = Some(b);
1615 },
1616 Err(super::public_invite::PublicInviteError::Revoked) => {
1617 let at = ev.created_at.as_secs();
1618 if revoked_at.map_or(true, |r| at > r) { revoked_at = Some(at); }
1619 }
1620 Err(_) => {} }
1622 }
1623 match (bundle, revoked_at) {
1624 (Some(b), Some(r)) if bundle_at > r => Ok(b), (_, Some(_)) => Err("this invite was revoked".to_string()),
1626 (Some(b), None) => Ok(b),
1627 (None, None) => Err("no public invite found at that link (revoked, never posted, or shadowed)".to_string()),
1628 }
1629}
1630
1631pub fn accept_public_invite(bundle: &PublicInviteBundle, now_secs: u64) -> Result<Community, String> {
1635 if bundle.is_expired(now_secs) {
1636 return Err("this invite link has expired".to_string());
1637 }
1638 let mut community = accept_invite(&bundle.join)?;
1639 if bundle.preview.description.is_some() || bundle.preview.icon.is_some() {
1642 community.description = bundle.preview.description.clone();
1643 community.icon = bundle.preview.icon.clone();
1644 crate::db::community::save_community(&community)?;
1645 }
1646 Ok(community)
1647}
1648
1649pub async fn revoke_public_invite<T: Transport + ?Sized>(
1656 transport: &T,
1657 community: &Community,
1658 token: &[u8; 32],
1659) -> Result<(), String> {
1660 let session = SessionGuard::capture();
1661 let cid = community.id.to_hex();
1662 let token_hex = crate::simd::hex::bytes_to_hex_32(token);
1663 let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
1664 if !crate::db::community::list_public_invites(&cid)?.iter().any(|r| r.token == token_hex) {
1667 return Ok(());
1668 }
1669 let my_locators_before: Vec<String> = crate::db::community::list_public_invites(&cid)?
1670 .iter()
1671 .filter(|r| r.expires_at.map_or(true, |e| (e as u64) > now))
1672 .map(|r| public_invite::locator_hex(&crate::simd::hex::hex_to_bytes_32(&r.token)))
1673 .collect();
1674 let _ = fetch_and_apply_invite_links(transport, community).await;
1678 if !session.is_valid() {
1679 return Err("account changed during invite revoke".to_string());
1680 }
1681 let this_locator = public_invite::locator_hex(token);
1687 let cached_aggregate: std::collections::BTreeSet<String> =
1688 crate::db::community::get_community_invite_registry(&cid)?.into_iter().collect();
1689 let my_before: std::collections::BTreeSet<String> = my_locators_before.iter().cloned().collect();
1690 let others: std::collections::BTreeSet<String> = cached_aggregate.difference(&my_before).cloned().collect();
1691 let my_after: std::collections::BTreeSet<String> =
1692 my_before.iter().filter(|l| **l != this_locator).cloned().collect();
1693 let would_empty_aggregate = others.is_empty() && my_after.is_empty();
1694 if would_empty_aggregate && crate::state::MY_SECRET_KEY.to_keys().is_none() {
1695 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());
1696 }
1697 if let Ok(tombstone) = public_invite::build_public_invite_tombstone(token) {
1707 let _ = transport.publish_durable(&tombstone, &community.relays).await;
1708 }
1709 if !session.is_valid() {
1711 return Err("account changed during invite revoke".to_string());
1712 }
1713 crate::db::community::delete_public_invite(&token_hex)?;
1714 super::invite_list::revoke_invite(&token_hex, &cid);
1717 republish_my_invite_links(transport, community).await?;
1719 if session.is_valid() {
1720 let aggregate_after: Vec<String> = others.union(&my_after).cloned().collect();
1721 crate::db::community::set_community_invite_registry(&cid, &aggregate_after)?;
1722 }
1723 if would_empty_aggregate {
1724 run_read_cut(transport, community, true).await?;
1728 }
1729 Ok(())
1730}
1731
1732async fn dissolved_tombstone_present<T: Transport + ?Sized>(transport: &T, community: &Community, owner_hex: &str) -> bool {
1745 let z = super::derive::dissolved_pseudonym(&community.id);
1746 let q = Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], ..Default::default() };
1747 for ev in transport.fetch(&q, &community.relays).await.unwrap_or_default() {
1748 if super::roster::dissolved_tombstone_signer(&ev, &community.id).map(|s| s.to_hex()) == Some(owner_hex.to_string()) {
1749 return true;
1750 }
1751 }
1752 false
1753}
1754
1755pub async fn dissolve_community<T: Transport + ?Sized>(
1756 transport: &T,
1757 community: &Community,
1758) -> Result<(), String> {
1759 let session = SessionGuard::capture();
1760 let cid = community.id.to_hex();
1761
1762 if !is_proven_owner(community) {
1764 return Err("only the community owner can dissolve (delete) the community".to_string());
1765 }
1766 let signer = active_signer().await?;
1767 let actor_pk = crate::state::my_public_key().ok_or("no local identity to sign the dissolution")?;
1768
1769 let created_at = std::time::SystemTime::now()
1773 .duration_since(std::time::UNIX_EPOCH)
1774 .map(|d| d.as_secs())
1775 .unwrap_or(0);
1776 let unsigned = super::roster::build_group_dissolved_edition_unsigned(actor_pk, &community.id, created_at);
1777 let inner = unsigned.sign(&signer).await.map_err(|e| format!("sign dissolution tombstone: {e}"))?;
1778 let stable = super::roster::seal_dissolved_edition(&Keys::generate(), &inner, &community.id)?;
1782 transport.publish_durable(&stable, &community.relays).await?;
1783 if let Ok(outer) = super::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch) {
1786 let _ = transport.publish_durable(&outer, &community.relays).await;
1787 }
1788 if !session.is_valid() {
1789 return Err("account changed during dissolution".to_string());
1790 }
1791
1792 if caller_has_permission(community, super::roles::Permissions::CREATE_INVITE) {
1798 let _ = publish_my_invite_links(transport, community, &[]).await;
1799 if let Ok(records) = crate::db::community::list_public_invites(&cid) {
1800 for r in records {
1801 let token = crate::simd::hex::hex_to_bytes_32(&r.token);
1802 if let Ok(tombstone) = public_invite::build_public_invite_tombstone(&token) {
1803 let _ = transport.publish_durable(&tombstone, &community.relays).await;
1804 }
1805 let _ = crate::db::community::delete_public_invite(&r.token);
1806 }
1807 }
1808 }
1809
1810 if !session.is_valid() {
1812 return Err("account changed during dissolution".to_string());
1813 }
1814 crate::db::community::set_community_dissolved(&cid)?;
1815 Ok(())
1816}
1817
1818pub async fn publish_my_invite_links<T: Transport + ?Sized>(
1825 transport: &T,
1826 community: &Community,
1827 my_locators: &[String],
1828) -> Result<(), String> {
1829 let session = SessionGuard::capture();
1830 if !caller_has_permission(community, super::roles::Permissions::CREATE_INVITE) {
1831 return Err("you need the create-invite permission to publish invite links".to_string());
1832 }
1833 let cid = community.id.to_hex();
1834 let signer = active_signer().await?;
1835 let actor_pk = crate::state::my_public_key().ok_or("no local identity to sign the invite links")?;
1836 let entity_id = super::derive::invite_links_locator(&community.id, &actor_pk.to_bytes());
1837 let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
1838 let (version, prev_hash) = match crate::db::community::get_edition_head(&cid, &entity_hex)? {
1839 Some((v, h)) => (v + 1, Some(h)),
1840 None => (1, None),
1841 };
1842 let created_at = std::time::SystemTime::now()
1843 .duration_since(std::time::UNIX_EPOCH)
1844 .map(|d| d.as_secs())
1845 .unwrap_or(0);
1846 let citation = authority_citation(community, &actor_pk.to_hex());
1848 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())?;
1849 let inner = unsigned.sign(&signer).await.map_err(|e| format!("sign invite-links edition: {e}"))?;
1850 let outer = super::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch)?;
1851 let self_hash = super::version::edition_hash(&entity_id, version, prev_hash.as_ref(), inner.content.as_bytes());
1852 transport.publish_durable(&outer, &community.relays).await?;
1853 if session.is_valid() {
1854 crate::db::community::set_edition_head(&cid, &entity_hex, version, &self_hash)?;
1855 let mut agg: std::collections::BTreeSet<String> =
1858 crate::db::community::get_community_invite_registry(&cid)?.into_iter().collect();
1859 agg.extend(my_locators.iter().cloned());
1860 crate::db::community::set_community_invite_registry(&cid, &agg.into_iter().collect::<Vec<_>>())?;
1861 crate::db::community::upsert_invite_link_set(&cid, &actor_pk.to_hex(), my_locators)?;
1862 }
1863 Ok(())
1864}
1865
1866pub async fn fetch_and_apply_invite_links<T: Transport + ?Sized>(
1872 transport: &T,
1873 community: &Community,
1874) -> Result<Vec<String>, String> {
1875 fetch_and_apply_invite_links_inner(transport, community, None).await
1876}
1877
1878async fn fetch_and_apply_invite_links_inner<T: Transport + ?Sized>(
1879 transport: &T,
1880 community: &Community,
1881 prefolded: Option<super::roster::FoldedRoster>,
1882) -> Result<Vec<String>, String> {
1883 let session = SessionGuard::capture();
1884 let cid = community.id.to_hex();
1885 let folded = match prefolded {
1886 Some(f) => f,
1887 None => fetch_control_folded(transport, community).await?,
1888 };
1889 if !session.is_valid() {
1890 return Err("account changed during invite-links fetch".to_string());
1891 }
1892 let owner = proven_owner_hex(community);
1893 let authorized = super::roster::authorize_delegation(&folded, owner.as_deref());
1894 let mut aggregate: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
1895 let mut per_creator: Vec<crate::db::community::InviteLinkSetRow> = Vec::new();
1897 for set in &folded.invite_link_sets {
1898 if !authorized.is_authorized(&set.creator.to_hex(), owner.as_deref(), super::roles::Permissions::CREATE_INVITE) {
1901 continue;
1902 }
1903 let held = crate::db::community::get_edition_head(&cid, &set.head.entity_hex)?.map(|(v, _)| v).unwrap_or(0);
1904 if set.head.version > held {
1905 crate::db::community::set_edition_head(&cid, &set.head.entity_hex, set.head.version, &set.head.self_hash)?;
1906 }
1907 aggregate.extend(set.locators.iter().cloned());
1908 per_creator.push(crate::db::community::InviteLinkSetRow {
1909 creator_hex: set.creator.to_hex(),
1910 locators: set.locators.clone(),
1911 });
1912 }
1913 let aggregate: Vec<String> = aggregate.into_iter().collect();
1914 crate::db::community::set_community_invite_registry(&cid, &aggregate)?;
1915 crate::db::community::replace_invite_link_sets(&cid, &per_creator)?;
1916 Ok(aggregate)
1917}
1918
1919pub async fn fetch_and_apply_metadata<T: Transport + ?Sized>(
1927 transport: &T,
1928 community: &Community,
1929) -> Result<(), String> {
1930 fetch_and_apply_metadata_inner(transport, community, None).await
1931}
1932
1933async fn fetch_and_apply_metadata_inner<T: Transport + ?Sized>(
1934 transport: &T,
1935 community: &Community,
1936 prefolded: Option<super::roster::FoldedRoster>,
1937) -> Result<(), String> {
1938 let session = SessionGuard::capture();
1939 let cid = community.id.to_hex();
1940 let folded = match prefolded {
1941 Some(f) => f,
1942 None => fetch_control_folded(transport, community).await?,
1943 };
1944 if !session.is_valid() {
1945 return Err("account changed during metadata fetch".to_string());
1946 }
1947 let owner = proven_owner_hex(community);
1948 let authorized = super::roster::authorize_delegation(&folded, owner.as_deref());
1949 let manage = super::roles::Permissions::MANAGE_METADATA;
1952 let manage_channels = super::roles::Permissions::MANAGE_CHANNELS;
1953
1954 let mut current = match crate::db::community::load_community(&community.id)? {
1957 Some(c) => c,
1958 None => return Ok(()),
1959 };
1960 let mut dirty = false;
1961 let mut head_updates: Vec<(String, u64, [u8; 32], [u8; 32], bool)> = Vec::new();
1965
1966 let decide = |entity_hex: &str, head: &super::roster::EntityHead| -> Result<Option<bool>, String> {
1973 let held = crate::db::community::get_edition_head(&cid, entity_hex)?;
1974 let held_v = held.map(|(v, _)| v).unwrap_or(0);
1975 if head.version > held_v {
1976 return Ok(Some(false)); }
1978 if head.version == held_v && held.map(|(_, h)| h) != Some(head.self_hash) {
1979 let held_id = crate::db::community::get_edition_head_inner_id(&cid, entity_hex)?;
1980 if held_id.is_none() || Some(head.inner_id) < held_id {
1981 return Ok(Some(true)); }
1983 }
1984 Ok(None)
1985 };
1986
1987 if let Some(c) = folded.root_candidates.iter()
1992 .find(|c| authorized.is_authorized(&c.author.to_hex(), owner.as_deref(), manage))
1993 {
1994 let head = &c.head;
1995 if let Some(is_converge) = decide(&head.entity_hex, head)? {
1996 let meta = &c.meta;
1997 current.name = meta.name.clone();
2006 current.description = meta.description.clone();
2007 current.icon = meta.icon.clone();
2008 current.banner = meta.banner.clone();
2009 dirty = true;
2010 head_updates.push((head.entity_hex.clone(), head.version, head.self_hash, head.inner_id, is_converge));
2011 }
2012 }
2013 let mut resolved_channels: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
2019 for cm in &folded.channel_candidates {
2020 if resolved_channels.contains(&cm.channel_id) {
2021 continue; }
2023 if !authorized.is_authorized(&cm.author.to_hex(), owner.as_deref(), manage_channels) {
2024 continue; }
2026 resolved_channels.insert(cm.channel_id);
2027 let Some(is_converge) = decide(&cm.head.entity_hex, &cm.head)? else { continue };
2028 if let Some(ch) = current.channels.iter_mut().find(|c| c.id.0 == cm.channel_id) {
2029 ch.name = cm.meta.name.clone();
2030 dirty = true;
2031 head_updates.push((cm.head.entity_hex.clone(), cm.head.version, cm.head.self_hash, cm.head.inner_id, is_converge));
2032 }
2033 }
2034
2035 if dirty && session.is_valid() {
2036 crate::db::community::save_community(¤t)?;
2037 for (entity_hex, version, self_hash, inner_id, is_converge) in &head_updates {
2040 if *is_converge {
2041 crate::db::community::converge_edition_head(&cid, entity_hex, *version, self_hash, inner_id)?;
2042 } else {
2043 crate::db::community::set_edition_head_with_id(&cid, entity_hex, *version, self_hash, inner_id)?;
2044 }
2045 }
2046 }
2047 Ok(())
2048}
2049
2050pub fn is_public(community: &Community) -> Result<bool, String> {
2058 Ok(!crate::db::community::get_community_invite_registry(&community.id.to_hex())?.is_empty())
2059}
2060
2061async fn republish_my_invite_links<T: Transport + ?Sized>(
2066 transport: &T,
2067 community: &Community,
2068) -> Result<Vec<String>, String> {
2069 let cid = community.id.to_hex();
2070 let now = std::time::SystemTime::now()
2071 .duration_since(std::time::UNIX_EPOCH)
2072 .map(|d| d.as_secs())
2073 .unwrap_or(0);
2074 let locators: Vec<String> = crate::db::community::list_public_invites(&cid)?
2075 .iter()
2076 .filter(|r| r.expires_at.map_or(true, |e| (e as u64) > now))
2077 .map(|r| public_invite::locator_hex(&crate::simd::hex::hex_to_bytes_32(&r.token)))
2078 .collect();
2079 publish_my_invite_links(transport, community, &locators).await?;
2080 Ok(locators)
2081}
2082
2083async fn observe_channel_activity<T: Transport + ?Sized>(
2089 transport: &T,
2090 community: &Community,
2091) -> Result<(), String> {
2092 let session = SessionGuard::capture();
2093 let my_pk = crate::state::my_public_key().ok_or("no local identity to observe channel activity")?;
2094 for channel in &community.channels {
2095 let events = super::send::fetch_channel_events(transport, community, channel)
2096 .await
2097 .unwrap_or_default();
2098 if !session.is_valid() {
2099 return Err("account changed during activity observation".to_string());
2100 }
2101 let outcomes = {
2102 let mut st = crate::state::STATE.lock().await;
2103 super::inbound::process_channel_batch(&mut st, &events, channel, &my_pk)
2104 };
2105 let ch_hex = channel.id.to_hex();
2106 for o in &outcomes {
2107 match o {
2108 super::inbound::IncomingEvent::NewMessage(m)
2109 | super::inbound::IncomingEvent::Updated { message: m, .. } => {
2110 let _ = crate::db::events::save_message(&ch_hex, m).await;
2111 }
2112 super::inbound::IncomingEvent::Presence { npub, joined, event_id, created_at, invited_by, invited_label } => {
2113 let et = if *joined {
2114 crate::stored_event::SystemEventType::MemberJoined
2115 } else {
2116 crate::stored_event::SystemEventType::MemberLeft
2117 };
2118 let note = invited_by.as_ref().map(|by| match invited_label {
2119 Some(l) if !l.is_empty() => format!("{by}|{l}"),
2120 _ => by.clone(),
2121 });
2122 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;
2123 }
2124 super::inbound::IncomingEvent::WebxdcPeer { npub, topic_id, node_addr, event_id, created_at } => {
2125 persist_webxdc_signal(&ch_hex, npub, topic_id, node_addr.as_deref(), event_id, *created_at).await;
2126 }
2127 _ => {}
2128 }
2129 }
2130 }
2131 Ok(())
2132}
2133
2134pub async fn sync_before_admin_write<T: Transport + ?Sized>(
2145 transport: &T,
2146 community: &Community,
2147 observe_activity: bool,
2148) -> Result<Community, String> {
2149 if catch_up_server_root(transport, community).await?.removed {
2151 return Err("you have been removed from this community".to_string());
2152 }
2153 let community = crate::db::community::load_community(&community.id)?
2154 .ok_or("community gone during admin sync")?;
2155 let cid = community.id.to_hex();
2156 let responded = fetch_and_apply_control(transport, &community).await.map(|n| n > 0).unwrap_or(false);
2163 let hold_local_heads = !crate::db::community::get_all_edition_heads_epoched(&cid)?.is_empty();
2164 if hold_local_heads && !responded {
2165 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());
2166 }
2167 let community = crate::db::community::load_community(&community.id)?
2168 .ok_or("community gone during admin sync")?;
2169 if observe_activity {
2172 let _ = observe_channel_activity(transport, &community).await;
2173 }
2174 crate::db::community::load_community(&community.id)?.ok_or("community gone during admin sync".to_string())
2175}
2176
2177async fn run_read_cut<T: Transport + ?Sized>(
2187 transport: &T,
2188 community: &Community,
2189 fresh: bool,
2190) -> Result<(), String> {
2191 let cid = community.id.to_hex();
2192 let session = SessionGuard::capture();
2193 if fresh {
2194 let base = crate::db::community::load_community(&community.id)?
2197 .map(|c| c.server_root_epoch.0)
2198 .unwrap_or(community.server_root_epoch.0);
2199 crate::db::community::set_read_cut_target_epoch(&cid, base.saturating_add(1))?;
2200 }
2201 crate::db::community::set_read_cut_pending(&cid, true)?;
2202 reseal_base_to_observed(transport, community).await?;
2203 if session.is_valid() {
2204 crate::db::community::set_read_cut_pending(&cid, false)?;
2205 }
2206 Ok(())
2207}
2208
2209async fn reseal_base_to_observed<T: Transport + ?Sized>(
2219 transport: &T,
2220 community: &Community,
2221) -> Result<(), String> {
2222 let session = SessionGuard::capture();
2223 let cid = community.id.to_hex();
2224 let community = &sync_before_admin_write(transport, community, true).await?;
2229 let participants: Vec<nostr_sdk::PublicKey> = crate::db::community::community_member_activity(&cid)?
2233 .into_iter()
2234 .filter_map(|(npub, _)| nostr_sdk::PublicKey::parse(&npub).ok())
2235 .collect();
2236 let target = crate::db::community::get_read_cut_target_epoch(&cid)?;
2246 if community.server_root_epoch.0 < target {
2247 rotate_server_root(transport, community, &participants).await?;
2248 if !session.is_valid() {
2249 return Err("account changed during re-founding".to_string());
2250 }
2251 }
2252 let community = crate::db::community::load_community(&community.id)?
2258 .ok_or("community gone after base rotation")?;
2259 let cut_epoch = community.server_root_epoch.0;
2260 let prior_root = crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, cut_epoch.saturating_sub(1))?
2266 .unwrap_or(*community.server_root_key.as_bytes()); for channel in &community.channels {
2268 let ch_hex = channel.id.to_hex();
2269 if crate::db::community::channel_rekeyed_at_server_epoch(&cid, &ch_hex)? >= cut_epoch {
2272 continue;
2273 }
2274 rotate_channel(transport, &community, &channel.id, &participants, &prior_root).await?;
2275 if !session.is_valid() {
2276 return Err("account changed during re-founding".to_string());
2277 }
2278 crate::db::community::mark_channel_rekeyed_at_server_epoch(&cid, &ch_hex, cut_epoch)?;
2279 }
2280 Ok(())
2281}
2282
2283#[derive(Debug, PartialEq, Eq)]
2285pub enum RekeyOutcome {
2286 Applied { head_advanced: bool },
2289 NotARecipient,
2292}
2293
2294pub fn apply_channel_rekey(
2305 community: &Community,
2306 parsed: &super::rekey::ParsedRekey,
2307) -> Result<RekeyOutcome, String> {
2308 let session = SessionGuard::capture();
2312
2313 let channel_id = match parsed.scope {
2315 super::derive::RekeyScope::Channel(c) => c,
2316 super::derive::RekeyScope::ServerRoot => {
2317 return Err("server-root rotation uses apply_server_root_rekey, not the channel path".to_string())
2318 }
2319 };
2320 if !community.channels.iter().any(|c| c.id == channel_id) {
2321 return Err("rekey targets a channel not in this community".to_string());
2322 }
2323 let cid = community.id.to_hex();
2324 let channel_hex = channel_id.to_hex();
2325
2326 let owner = proven_owner_hex(community);
2333 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_else(|e| {
2334 crate::log_warn!("rekey apply: roster read failed ({e}); authorizing owner only");
2337 Default::default()
2338 });
2339 if !roster.is_authorized(
2340 &parsed.rotator.to_hex(),
2341 owner.as_deref(),
2342 super::roles::Permissions::MANAGE_CHANNELS,
2343 ) {
2344 return Err("rekey rotator lacks MANAGE_CHANNELS authority".to_string());
2345 }
2346
2347 if let Some(prev_key) = crate::db::community::held_epoch_key(&cid, &channel_hex, parsed.prev_epoch.0)? {
2357 if super::rekey::epoch_key_commitment(parsed.prev_epoch, &prev_key) != parsed.prev_key_commitment {
2358 crate::log_warn!(
2359 "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",
2360 parsed.new_epoch.0, parsed.prev_epoch.0
2361 );
2362 }
2363 }
2364
2365 let my_keys = crate::state::MY_SECRET_KEY
2367 .to_keys()
2368 .ok_or("no local identity to open the rekey blob")?;
2369 let secret = super::rekey::rekey_pairwise_secret(my_keys.secret_key(), &parsed.rotator)?;
2370 let my_locator = super::derive::recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
2371 let mine = match parsed.blobs.iter().find(|b| b.locator == my_locator) {
2372 Some(b) => b,
2373 None => return Ok(RekeyOutcome::NotARecipient),
2374 };
2375 let new_key =
2376 super::rekey::open_rekey_blob(my_keys.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine)?;
2377
2378 if !session.is_valid() {
2380 return Err("session changed during rekey apply".to_string());
2381 }
2382 let head_advanced =
2383 crate::db::community::advance_channel_epoch(&cid, &channel_hex, parsed.new_epoch.0, &new_key)?;
2384 Ok(RekeyOutcome::Applied { head_advanced })
2385}
2386
2387fn mint_or_reuse_rotation_key(cid: &str, scope_id: &str, epoch: u64) -> Result<zeroize::Zeroizing<[u8; 32]>, String> {
2393 if let Some(k) = crate::db::community::held_epoch_key(cid, scope_id, epoch)? {
2394 return Ok(zeroize::Zeroizing::new(k));
2395 }
2396 let k = zeroize::Zeroizing::new(super::random_32());
2397 crate::db::community::store_epoch_key(cid, scope_id, epoch, &k)?;
2398 Ok(k)
2399}
2400
2401async fn publish_rekey_chunked<T, F>(
2409 transport: &T,
2410 relays: &[String],
2411 blobs: &[super::rekey::RekeyBlob],
2412 build: F,
2413) -> Result<(), String>
2414where
2415 T: Transport + ?Sized,
2416 F: Fn(&[super::rekey::RekeyBlob]) -> Result<Event, String>,
2417{
2418 if blobs.is_empty() {
2419 return Err("rekey has no recipients".to_string());
2420 }
2421 for chunk in blobs.chunks(super::rekey::MAX_REKEY_BLOBS) {
2422 let event = build(chunk)?;
2423 transport.publish_durable(&event, relays).await?;
2424 }
2425 Ok(())
2426}
2427
2428pub async fn rotate_channel<T: Transport + ?Sized>(
2440 transport: &T,
2441 community: &Community,
2442 channel_id: &super::ChannelId,
2443 recipients: &[nostr_sdk::PublicKey],
2444 envelope_root: &[u8; 32],
2450) -> Result<u64, String> {
2451 let session = SessionGuard::capture();
2452 let cid = community.id.to_hex();
2453
2454 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)")?;
2458 let owner = proven_owner_hex(community);
2459 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
2460 if !roster.is_authorized(&my_keys.public_key().to_hex(), owner.as_deref(), super::roles::Permissions::MANAGE_CHANNELS) {
2461 return Err("not authorized to rotate this channel (no MANAGE_CHANNELS)".to_string());
2462 }
2463
2464 let channel = community
2468 .channels
2469 .iter()
2470 .find(|c| &c.id == channel_id)
2471 .ok_or("channel not found in community")?;
2472 let prev_epoch = channel.epoch;
2473 let new_epoch = super::Epoch(prev_epoch.0.checked_add(1).ok_or("channel epoch overflow")?);
2474 let prev_commit = super::rekey::epoch_key_commitment(prev_epoch, channel.key.as_bytes());
2475 let new_key = mint_or_reuse_rotation_key(&cid, &channel_id.to_hex(), new_epoch.0)?;
2478
2479 let mut seen = std::collections::HashSet::new();
2483 let mut blobs = Vec::new();
2484 for pk in recipients.iter().chain(std::iter::once(&my_keys.public_key())) {
2485 if !seen.insert(pk.to_hex()) {
2486 continue;
2487 }
2488 blobs.push(super::rekey::build_rekey_blob(
2489 my_keys.secret_key(), pk, super::derive::RekeyScope::Channel(*channel_id), new_epoch, &new_key,
2490 )?);
2491 }
2492
2493 publish_rekey_chunked(transport, &community.relays, &blobs, |chunk| {
2495 super::rekey::build_channel_rekey_event(
2496 &Keys::generate(), &my_keys, envelope_root, channel_id,
2497 new_epoch, prev_epoch, &prev_commit, chunk,
2498 )
2499 })
2500 .await?;
2501 if !session.is_valid() {
2502 return Err("session changed during channel rotation".to_string());
2503 }
2504 crate::db::community::advance_channel_epoch(&cid, &channel_id.to_hex(), new_epoch.0, &new_key)?;
2505 Ok(new_epoch.0)
2506}
2507
2508fn emit_rekey_progress(label: &str, pct: u8) {
2512 crate::emit_event("community_rekey_progress", &serde_json::json!({ "label": label, "pct": pct }));
2513}
2514
2515pub(crate) async fn rotate_server_root<T: Transport + ?Sized>(
2531 transport: &T,
2532 community: &Community,
2533 recipients: &[nostr_sdk::PublicKey],
2534) -> Result<u64, String> {
2535 let session = SessionGuard::capture();
2536 let cid = community.id.to_hex();
2537
2538 if crate::db::community::get_community_dissolved(&cid)? {
2540 return Err("community is dissolved; it cannot be re-founded".to_string());
2541 }
2542
2543 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)")?;
2554 let owner = proven_owner_hex(community);
2555 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
2556 if !roster.is_authorized(&my_keys.public_key().to_hex(), owner.as_deref(), super::roles::Permissions::BAN) {
2557 return Err("not authorized to rotate the server root (no BAN)".to_string());
2558 }
2559
2560 let fresh = crate::db::community::load_community(&community.id)?
2566 .ok_or("community gone before base rotation")?;
2567 let community = &fresh;
2568 let prev_epoch = community.server_root_epoch;
2569 let new_epoch = super::Epoch(prev_epoch.0.checked_add(1).ok_or("server-root epoch overflow")?);
2570 let prev_commit = super::rekey::epoch_key_commitment(prev_epoch, community.server_root_key.as_bytes());
2572 let new_root = mint_or_reuse_rotation_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, new_epoch.0)?;
2574 emit_rekey_progress("Rerolling community keys...", 5);
2575
2576 let sealed = prepare_reanchor_control_plane(transport, community, &new_root, new_epoch).await?;
2582 if !session.is_valid() {
2583 return Err("session changed during re-founding acquire".to_string());
2584 }
2585
2586 let total_recipients = (recipients.len() + 1).max(1); let mut seen = std::collections::HashSet::new();
2588 let mut blobs = Vec::new();
2589 for pk in recipients.iter().chain(std::iter::once(&my_keys.public_key())) {
2590 if !seen.insert(pk.to_hex()) {
2591 continue;
2592 }
2593 blobs.push(super::rekey::build_rekey_blob(
2594 my_keys.secret_key(), pk, super::derive::RekeyScope::ServerRoot, new_epoch, &new_root,
2595 )?);
2596 emit_rekey_progress(
2597 &format!("Preparing keys for members ({}/{})...", blobs.len(), total_recipients),
2598 (5 + 35 * blobs.len() / total_recipients) as u8,
2599 );
2600 }
2601
2602 emit_rekey_progress("Sending keys to members...", 42);
2606 publish_rekey_chunked(transport, &community.relays, &blobs, |chunk| {
2607 super::rekey::build_server_root_rekey_event(
2608 &Keys::generate(), &my_keys, community.server_root_key.as_bytes(), &community.id,
2609 new_epoch, prev_epoch, &prev_commit, chunk,
2610 )
2611 })
2612 .await?;
2613
2614 let snapshot = publish_reanchor_snapshot(transport, &community.relays, sealed).await?;
2622 if snapshot.iter().any(|e| !e.published) {
2623 return Err(
2624 "re-founding aborted: a snapshot edition did not land (rate-limited / unreachable relay?); base head NOT advanced".to_string()
2625 );
2626 }
2627 if !session.is_valid() {
2628 return Err("session changed during server-root rotation".to_string());
2629 }
2630 emit_rekey_progress("Finalizing...", 98);
2631 crate::db::community::advance_server_root_epoch(&cid, new_epoch.0, &new_root)?;
2633 for e in &snapshot {
2637 crate::db::community::set_edition_head_with_id(&cid, &e.entity_hex, e.version, &e.self_hash, &e.inner_id)?;
2638 }
2639 Ok(new_epoch.0)
2640}
2641
2642pub(crate) struct SnapshotEntry {
2677 pub entity_hex: String,
2678 pub version: u64,
2679 pub self_hash: [u8; 32],
2680 pub inner_id: [u8; 32],
2681 pub published: bool,
2682}
2683
2684pub(crate) async fn prepare_reanchor_control_plane<T: Transport + ?Sized>(
2690 transport: &T,
2691 community: &Community,
2692 new_root: &[u8; 32],
2693 new_epoch: super::Epoch,
2694) -> Result<Vec<(Event, SnapshotEntry)>, String> {
2695 let session = SessionGuard::capture();
2696 let cid = community.id.to_hex();
2697
2698 let z = super::roster::control_pseudonym(&community.server_root_key, &community.id, community.server_root_epoch);
2706 let query = Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], ..Default::default() };
2707 let outers = transport.fetch(&query, &community.relays).await?;
2708 if !session.is_valid() {
2709 return Err("session changed during re-founding fetch".to_string());
2710 }
2711 let mut by_hash: std::collections::HashMap<[u8; 32], (Event, [u8; 32])> = std::collections::HashMap::new();
2713 for outer in &outers {
2714 if let Ok(inner) = super::roster::open_control_edition(outer, &community.server_root_key) {
2715 if let Ok(parsed) = super::edition::parse_edition_inner(&inner) {
2716 by_hash.insert(parsed.self_hash, (inner, parsed.inner_id));
2717 }
2718 }
2719 }
2720
2721 let new_root_key = super::ServerRootKey(*new_root);
2725 let mut sealed: Vec<(Event, SnapshotEntry)> = Vec::new();
2726 for (entity_hex, (epoch, version, self_hash)) in crate::db::community::get_all_edition_heads_epoched(&cid)? {
2727 if epoch != community.server_root_epoch.0 {
2728 continue; }
2730 let (inner, inner_id) = by_hash.get(&self_hash).ok_or_else(|| {
2731 format!("re-founding aborted: head edition for entity {entity_hex} (v{version}) not fetchable — aborting so no member is stranded")
2732 })?;
2733 let outer = super::roster::seal_control_edition(&Keys::generate(), inner, &new_root_key, &community.id, new_epoch)?;
2734 sealed.push((outer, SnapshotEntry { entity_hex, version, self_hash, inner_id: *inner_id, published: false }));
2735 }
2736 Ok(sealed)
2737}
2738
2739pub(crate) async fn publish_reanchor_snapshot<T: Transport + ?Sized>(
2743 transport: &T,
2744 relays: &[String],
2745 sealed: Vec<(Event, SnapshotEntry)>,
2746) -> Result<Vec<SnapshotEntry>, String> {
2747 use futures_util::stream::StreamExt;
2750 let total = sealed.len().max(1);
2751 let done = std::sync::atomic::AtomicUsize::new(0);
2752 let done_ref = &done;
2753 emit_rekey_progress(&format!("Re-founding community (0/{total})..."), 50);
2754 let out: Vec<SnapshotEntry> = futures_util::stream::iter(sealed.into_iter().map(|(ev, mut entry)| async move {
2755 entry.published = transport.publish_durable(&ev, relays).await.is_ok();
2756 let n = done_ref.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
2757 emit_rekey_progress(&format!("Re-founding community ({n}/{total})..."), (50 + 45 * n / total) as u8);
2758 entry
2759 }))
2760 .buffer_unordered(4)
2761 .collect()
2762 .await;
2763 Ok(out)
2764}
2765
2766#[cfg(test)]
2769pub(crate) async fn reanchor_control_plane<T: Transport + ?Sized>(
2770 transport: &T,
2771 community: &Community,
2772 new_root: &[u8; 32],
2773 new_epoch: super::Epoch,
2774) -> Result<Vec<SnapshotEntry>, String> {
2775 let sealed = prepare_reanchor_control_plane(transport, community, new_root, new_epoch).await?;
2776 publish_reanchor_snapshot(transport, &community.relays, sealed).await
2777}
2778
2779pub fn apply_server_root_rekey(
2787 community: &Community,
2788 parsed: &super::rekey::ParsedRekey,
2789) -> Result<RekeyOutcome, String> {
2790 let session = SessionGuard::capture();
2791
2792 if !matches!(parsed.scope, super::derive::RekeyScope::ServerRoot) {
2794 return Err("not a server-root rekey (channel rekeys use apply_channel_rekey)".to_string());
2795 }
2796 let cid = community.id.to_hex();
2797
2798 if crate::db::community::get_community_dissolved(&cid)? {
2801 return Err("community is dissolved; base epoch cannot advance".to_string());
2802 }
2803
2804 let owner = proven_owner_hex(community);
2811 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_else(|e| {
2812 crate::log_warn!("base rekey apply: roster read failed ({e}); authorizing owner only");
2813 Default::default()
2814 });
2815 if !rotator_is_authorized(&cid, &roster, owner.as_deref(), &parsed.rotator.to_hex(), super::roles::Permissions::BAN) {
2816 return Err("base rekey rotator lacks server-wide rotation authority (BAN)".to_string());
2817 }
2818
2819 if let Some(prev_root) =
2826 crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, parsed.prev_epoch.0)?
2827 {
2828 if super::rekey::epoch_key_commitment(parsed.prev_epoch, &prev_root) != parsed.prev_key_commitment {
2829 crate::log_warn!(
2830 "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",
2831 parsed.new_epoch.0, parsed.prev_epoch.0
2832 );
2833 }
2834 }
2835
2836 let my_keys = crate::state::MY_SECRET_KEY.to_keys().ok_or("no local identity to open the base rekey blob")?;
2838 let secret = super::rekey::rekey_pairwise_secret(my_keys.secret_key(), &parsed.rotator)?;
2839 let my_locator = super::derive::recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
2840 let mine = match parsed.blobs.iter().find(|b| b.locator == my_locator) {
2841 Some(b) => b,
2842 None => return Ok(RekeyOutcome::NotARecipient),
2843 };
2844 let new_root =
2845 super::rekey::open_rekey_blob(my_keys.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine)?;
2846
2847 if !session.is_valid() {
2848 return Err("session changed during base rekey apply".to_string());
2849 }
2850 let head_advanced = crate::db::community::advance_server_root_epoch(&cid, parsed.new_epoch.0, &new_root)?;
2851 Ok(RekeyOutcome::Applied { head_advanced })
2852}
2853
2854const REKEY_CATCHUP_WINDOW: u64 = 64;
2858const MAX_REKEY_CATCHUP_ROUNDS: usize = 64;
2861
2862async fn heal_channel_fork_epochs<T: Transport + ?Sized>(
2874 transport: &T,
2875 community: &Community,
2876 channel_id: &super::ChannelId,
2877 cid: &str,
2878 channel_hex: &str,
2879 epochs: &std::collections::BTreeSet<u64>,
2880 server_roots: &[[u8; 32]],
2881 session: &SessionGuard,
2882) -> Result<(), String> {
2883 if epochs.is_empty() {
2884 return Ok(());
2885 }
2886 let owner_hex = proven_owner_hex(community);
2887 let roster = crate::db::community::get_community_roles(cid).unwrap_or_default();
2888 let mut winner: std::collections::BTreeMap<u64, [u8; 32]> = std::collections::BTreeMap::new();
2891 for sr in server_roots {
2892 let z_tags: Vec<String> = epochs
2893 .iter()
2894 .map(|e| super::derive::rekey_pseudonym(&super::ServerRootKey(*sr), channel_id, super::Epoch(*e)).to_hex())
2895 .collect();
2896 let q = Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags, ..Default::default() };
2897 for ev in transport.fetch(&q, &community.relays).await.unwrap_or_default() {
2898 let Ok(p) = super::rekey::open_rekey_event(&ev, sr) else { continue };
2899 if !matches!(p.scope, super::derive::RekeyScope::Channel(c) if &c == channel_id) || !epochs.contains(&p.new_epoch.0) {
2900 continue;
2901 }
2902 if !rotator_is_authorized(cid, &roster, owner_hex.as_deref(), &p.rotator.to_hex(), super::roles::Permissions::MANAGE_CHANNELS) {
2903 continue;
2904 }
2905 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);
2907 }
2908 }
2909 for (epoch, win_key) in winner {
2910 if !session.is_valid() {
2911 return Err("session changed during channel convergence".to_string());
2912 }
2913 if let Ok(Some(cur)) = crate::db::community::held_epoch_key(cid, channel_hex, epoch) {
2918 if win_key < cur {
2919 match crate::db::community::converge_channel_epoch(cid, channel_hex, epoch, &win_key) {
2922 Ok(false) => crate::log_trace!("channel heal: converge of epoch {epoch} did not apply (head moved)"),
2923 Err(e) => crate::log_trace!("channel heal: converge of epoch {epoch} errored: {e}"),
2924 Ok(true) => {}
2925 }
2926 }
2927 }
2928 }
2929 Ok(())
2930}
2931
2932pub async fn catch_up_channel_rekeys<T: Transport + ?Sized>(
2947 transport: &T,
2948 community: &Community,
2949 channel_id: &super::ChannelId,
2950) -> Result<u64, String> {
2951 let session = SessionGuard::capture();
2952 let server_root = community.server_root_key.as_bytes();
2953 let cid = community.id.to_hex();
2954 let channel_hex = channel_id.to_hex();
2955 let mut server_roots: Vec<[u8; 32]> = crate::db::community::held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX)
2961 .unwrap_or_default()
2962 .into_iter()
2963 .map(|(_, k)| k)
2964 .collect();
2965 if !server_roots.iter().any(|r| r == server_root) {
2966 server_roots.push(*server_root); }
2968 let mut head = community
2969 .channels
2970 .iter()
2971 .find(|c| &c.id == channel_id)
2972 .ok_or("channel not found in community")?
2973 .epoch
2974 .0;
2975
2976 let mut forked_epochs: std::collections::BTreeSet<u64> = std::collections::BTreeSet::new();
2980
2981 for _round in 0..MAX_REKEY_CATCHUP_ROUNDS {
2982 let window_top = head.saturating_add(REKEY_CATCHUP_WINDOW);
2983 let mut parsed: Vec<super::rekey::ParsedRekey> = Vec::new();
2987 for sr in &server_roots {
2988 let z_tags: Vec<String> = (head.saturating_add(1)..=window_top)
2989 .map(|e| super::derive::rekey_pseudonym(&super::ServerRootKey(*sr), channel_id, super::Epoch(e)).to_hex())
2990 .collect();
2991 let query = Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags, ..Default::default() };
2992 for ev in transport.fetch(&query, &community.relays).await.unwrap_or_default() {
2997 if let Ok(p) = super::rekey::open_rekey_event(&ev, sr) {
2998 if matches!(p.scope, super::derive::RekeyScope::Channel(c) if &c == channel_id) {
2999 parsed.push(p);
3000 }
3001 }
3002 }
3003 }
3004 if parsed.is_empty() {
3005 break; }
3007 parsed.sort_by_key(|p| p.new_epoch.0);
3009 let max_found = parsed.last().map(|p| p.new_epoch.0).unwrap_or(head);
3010
3011 let head_before = head;
3012 let mut removed = false;
3013 let mut by_epoch: std::collections::BTreeMap<u64, Vec<&super::rekey::ParsedRekey>> = std::collections::BTreeMap::new();
3018 for p in &parsed {
3019 by_epoch.entry(p.new_epoch.0).or_default().push(p);
3020 }
3021 for (e, chunks) in by_epoch {
3022 if !session.is_valid() {
3023 return Err("session changed during rekey catch-up".to_string());
3024 }
3025 let mut applied = false;
3026 let mut saw_not_recipient = false;
3027 for p in &chunks {
3028 match apply_channel_rekey(community, p) {
3029 Ok(RekeyOutcome::Applied { .. }) => {
3030 applied = true;
3031 break;
3032 }
3033 Ok(RekeyOutcome::NotARecipient) => saw_not_recipient = true,
3034 Err(err) => crate::log_warn!("rekey catch-up: skipping epoch {e} chunk: {err}"),
3035 }
3036 }
3037 if applied {
3038 if let Some(p) = chunks.first() {
3044 let pe = p.prev_epoch.0;
3045 if let Ok(Some(prev_key)) = crate::db::community::held_epoch_key(&cid, &channel_hex, pe) {
3046 if super::rekey::epoch_key_commitment(p.prev_epoch, &prev_key) != p.prev_key_commitment {
3047 forked_epochs.insert(pe);
3048 }
3049 }
3050 }
3051 if e > head + 1 {
3054 crate::log_warn!(
3055 "rekey catch-up: channel epochs {}..={} not recovered (key gap; history unreadable until re-fetched)",
3056 head + 1, e - 1
3057 );
3058 }
3059 head = head.max(e);
3060 } else if saw_not_recipient {
3061 removed = true;
3064 break;
3065 }
3066 }
3068
3069 if removed || head == head_before || max_found < window_top {
3072 break;
3073 }
3074 }
3075
3076 let held: std::collections::HashSet<u64> = crate::db::community::held_epoch_keys(&cid, &channel_hex)
3083 .unwrap_or_default()
3084 .into_iter()
3085 .map(|(e, _)| e.0)
3086 .collect();
3087 let missing: Vec<u64> = (0..head).filter(|e| !held.contains(e)).collect();
3088 if !missing.is_empty() {
3089 for sr in &server_roots {
3090 if !session.is_valid() {
3091 return Err("session changed during rekey gap-fill".to_string());
3092 }
3093 let z_tags: Vec<String> = missing
3094 .iter()
3095 .map(|e| super::derive::rekey_pseudonym(&super::ServerRootKey(*sr), channel_id, super::Epoch(*e)).to_hex())
3096 .collect();
3097 let query = Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags, ..Default::default() };
3098 for ev in transport.fetch(&query, &community.relays).await.unwrap_or_default() {
3101 if let Ok(p) = super::rekey::open_rekey_event(&ev, sr) {
3102 if matches!(p.scope, super::derive::RekeyScope::Channel(c) if &c == channel_id) {
3103 let _ = apply_channel_rekey(community, &p); }
3105 }
3106 }
3107 }
3108 }
3109
3110 if head > 0 && session.is_valid() {
3117 let lo = head.saturating_sub(REKEY_CATCHUP_WINDOW).max(1);
3118 let mut epochs: std::collections::BTreeSet<u64> = (lo..=head).collect();
3119 epochs.append(&mut forked_epochs);
3120 let _ = heal_channel_fork_epochs(transport, community, channel_id, &cid, &channel_hex, &epochs, &server_roots, &session).await;
3121 }
3122 Ok(head)
3123}
3124
3125const MAX_BASE_CATCHUP_STEPS: usize = 256;
3128
3129fn peek_my_server_root(parsed: &super::rekey::ParsedRekey) -> Result<Option<[u8; 32]>, String> {
3143 if !matches!(parsed.scope, super::derive::RekeyScope::ServerRoot) {
3144 return Ok(None);
3145 }
3146 let my_keys = crate::state::MY_SECRET_KEY.to_keys().ok_or("no local key to open a base rekey blob")?;
3147 let secret = super::rekey::rekey_pairwise_secret(my_keys.secret_key(), &parsed.rotator)?;
3148 let my_locator = super::derive::recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
3149 let mine = match parsed.blobs.iter().find(|b| b.locator == my_locator) {
3150 Some(b) => b,
3151 None => return Ok(None),
3152 };
3153 super::rekey::open_rekey_blob(my_keys.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine).map(Some)
3154}
3155
3156fn peek_my_channel_key(parsed: &super::rekey::ParsedRekey) -> Option<[u8; 32]> {
3160 let my_keys = crate::state::MY_SECRET_KEY.to_keys()?;
3161 let secret = super::rekey::rekey_pairwise_secret(my_keys.secret_key(), &parsed.rotator).ok()?;
3162 let my_locator = super::derive::recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
3163 let mine = parsed.blobs.iter().find(|b| b.locator == my_locator)?;
3164 super::rekey::open_rekey_blob(my_keys.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine).ok()
3165}
3166
3167pub async fn catch_up_server_root<T: Transport + ?Sized>(
3168 transport: &T,
3169 community: &Community,
3170) -> Result<BaseCatchup, String> {
3171 let session = SessionGuard::capture();
3172 let cid = community.id.to_hex();
3173 let mut head = community.server_root_epoch.0;
3174 let mut removed = false;
3179 let mut current_root: [u8; 32] = *community.server_root_key.as_bytes();
3181
3182 for _step in 0..MAX_BASE_CATCHUP_STEPS {
3183 let next = match head.checked_add(1) {
3184 Some(n) => n,
3185 None => break,
3186 };
3187 let addr = super::derive::base_rekey_pseudonym(&super::ServerRootKey(current_root), &community.id, super::Epoch(next)).to_hex();
3188 let query = Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() };
3189 let events = transport.fetch(&query, &community.relays).await?;
3190 if events.is_empty() {
3191 break; }
3193
3194 let chunks: Vec<super::rekey::ParsedRekey> = events
3197 .iter()
3198 .filter_map(|ev| super::rekey::open_rekey_event(ev, ¤t_root).ok())
3199 .filter(|p| matches!(p.scope, super::derive::RekeyScope::ServerRoot) && p.new_epoch.0 == next)
3200 .collect();
3201 if chunks.is_empty() {
3202 break; }
3204
3205 if !session.is_valid() {
3206 return Err("session changed during base rekey catch-up".to_string());
3207 }
3208
3209 let owner_hex = proven_owner_hex(community);
3223 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
3224 let mut candidates: Vec<(&super::rekey::ParsedRekey, [u8; 32])> = Vec::new();
3225 for parsed in &chunks {
3226 if !rotator_is_authorized(&cid, &roster, owner_hex.as_deref(), &parsed.rotator.to_hex(), super::roles::Permissions::BAN) {
3227 continue;
3228 }
3229 match peek_my_server_root(parsed) {
3230 Ok(Some(root)) => candidates.push((parsed, root)),
3231 Ok(None) => {}
3232 Err(err) => crate::log_warn!("base rekey catch-up: epoch {next} peek: {err}"),
3233 }
3234 }
3235 let applied = match candidates.into_iter().min_by(|a, b| a.1.cmp(&b.1)) {
3236 Some((parsed, _)) => match apply_server_root_rekey(community, parsed) {
3237 Ok(RekeyOutcome::Applied { .. }) => true,
3238 Ok(RekeyOutcome::NotARecipient) => false, Err(err) => { crate::log_warn!("base rekey catch-up: epoch {next} apply: {err}"); false }
3240 },
3241 None => {
3242 let owner = proven_owner_hex(community);
3247 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
3248 if chunks.iter().any(|p| rotator_is_authorized(&cid, &roster, owner.as_deref(), &p.rotator.to_hex(), super::roles::Permissions::BAN)) {
3249 removed = true;
3250 }
3251 false }
3253 };
3254 if !applied {
3255 break;
3256 }
3257 match crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, next)? {
3259 Some(root) => {
3260 current_root = root;
3261 head = next;
3262 }
3263 None => {
3264 crate::log_warn!("base rekey catch-up: epoch {next} applied but its root is not archived; halting walk");
3267 break;
3268 }
3269 }
3270 }
3271
3272 if head > 0 && !removed {
3279 if let Ok(Some(prior_root)) = crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, head - 1) {
3280 let addr = super::derive::base_rekey_pseudonym(&super::ServerRootKey(prior_root), &community.id, super::Epoch(head)).to_hex();
3281 let query = Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() };
3282 let events = transport.fetch(&query, &community.relays).await.unwrap_or_default();
3283 let chunks: Vec<super::rekey::ParsedRekey> = events
3284 .iter()
3285 .filter_map(|ev| super::rekey::open_rekey_event(ev, &prior_root).ok())
3286 .filter(|p| matches!(p.scope, super::derive::RekeyScope::ServerRoot) && p.new_epoch.0 == head)
3287 .collect();
3288 let owner_hex = proven_owner_hex(community);
3289 let roster = crate::db::community::get_community_roles(&cid).unwrap_or_default();
3290 let mut best: Option<(&super::rekey::ParsedRekey, [u8; 32])> = None;
3291 for p in &chunks {
3292 if !rotator_is_authorized(&cid, &roster, owner_hex.as_deref(), &p.rotator.to_hex(), super::roles::Permissions::BAN) {
3296 continue;
3297 }
3298 if let Ok(Some(root)) = peek_my_server_root(p) {
3299 if best.as_ref().map_or(true, |(_, br)| root < *br) {
3300 best = Some((p, root));
3301 }
3302 }
3303 }
3304 let current_deauthorized = chunks.iter().any(|p| {
3314 matches!(peek_my_server_root(p), Ok(Some(r)) if r == current_root)
3315 && !rotator_is_authorized(&cid, &roster, owner_hex.as_deref(), &p.rotator.to_hex(), super::roles::Permissions::BAN)
3316 });
3317 if let Some((winner, win_root)) = best {
3318 let adopt = if current_deauthorized {
3319 win_root != current_root
3320 } else {
3321 win_root < current_root
3322 };
3323 if adopt {
3324 if !session.is_valid() {
3325 return Err("session changed during base convergence".to_string());
3326 }
3327 if apply_server_root_rekey(community, winner).is_ok() {
3330 match crate::db::community::converge_server_root_epoch(&cid, head, &win_root) {
3331 Ok(false) => crate::log_trace!("base heal: converge of epoch {head} did not apply (head moved)"),
3332 Err(e) => crate::log_trace!("base heal: converge of epoch {head} errored: {e}"),
3333 Ok(true) => {}
3334 }
3335 current_root = win_root;
3336 if let Ok(Some(fresh)) = crate::db::community::load_community(&community.id) {
3337 let _ = fetch_and_apply_control(transport, &fresh).await;
3338 }
3339 }
3340 }
3341 }
3342 }
3343 }
3344 let _ = current_root; Ok(BaseCatchup { epoch: head, removed })
3346}
3347
3348#[derive(Debug, Clone, Copy)]
3352pub struct BaseCatchup {
3353 pub epoch: u64,
3354 pub removed: bool,
3355}
3356
3357#[cfg(test)]
3358mod tests {
3359 use super::*;
3360 use crate::community::send::fetch_channel_messages;
3361 use crate::community::transport::{memory::MemoryRelay, Query, Transport};
3362 use nostr_sdk::prelude::{EventBuilder, Kind};
3363
3364 struct FailingRelay;
3367 #[async_trait::async_trait]
3368 impl Transport for FailingRelay {
3369 async fn publish(&self, _event: &Event, _relays: &[String]) -> Result<(), String> {
3370 Err("relay unreachable".to_string())
3371 }
3372 async fn publish_durable(&self, _event: &Event, _relays: &[String]) -> Result<(), String> {
3373 Err("relay unreachable".to_string())
3374 }
3375 async fn fetch(&self, _query: &Query, _relays: &[String]) -> Result<Vec<Event>, String> {
3376 Ok(Vec::new())
3377 }
3378 }
3379
3380 struct RekeyFailingRelay {
3384 inner: MemoryRelay,
3385 fail_rekey: std::sync::atomic::AtomicBool,
3386 }
3387 impl RekeyFailingRelay {
3388 fn new() -> Self {
3389 Self { inner: MemoryRelay::new(), fail_rekey: std::sync::atomic::AtomicBool::new(true) }
3390 }
3391 fn allow_rekey(&self) {
3392 self.fail_rekey.store(false, std::sync::atomic::Ordering::Relaxed);
3393 }
3394 fn blocks(&self, event: &Event) -> bool {
3395 self.fail_rekey.load(std::sync::atomic::Ordering::Relaxed)
3396 && event.kind.as_u16() == crate::stored_event::event_kind::COMMUNITY_REKEY
3397 }
3398 }
3399 #[async_trait::async_trait]
3400 impl Transport for RekeyFailingRelay {
3401 async fn publish(&self, event: &Event, relays: &[String]) -> Result<(), String> {
3402 if self.blocks(event) { return Err("rekey relay down".to_string()); }
3403 self.inner.publish(event, relays).await
3404 }
3405 async fn publish_durable(&self, event: &Event, relays: &[String]) -> Result<(), String> {
3406 if self.blocks(event) { return Err("rekey relay down".to_string()); }
3407 self.inner.publish_durable(event, relays).await
3408 }
3409 async fn fetch(&self, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
3410 self.inner.fetch(query, relays).await
3411 }
3412 }
3413
3414 static TEST_COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(5000);
3415
3416 fn make_test_npub(n: u32) -> String {
3417 const BECH32: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
3418 let mut payload = vec![b'q'; 58];
3419 let mut x = n as u64;
3420 let mut i = 58;
3421 while x > 0 && i > 0 {
3422 i -= 1;
3423 payload[i] = BECH32[(x as usize) % 32];
3424 x /= 32;
3425 }
3426 format!("npub1{}", std::str::from_utf8(&payload).unwrap())
3427 }
3428
3429 fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
3430 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
3431 crate::db::close_database();
3432 crate::db::clear_id_caches();
3435 let tmp = tempfile::tempdir().unwrap();
3436 let n = TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3437 let account = make_test_npub(n);
3438 std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
3439 crate::db::set_app_data_dir(tmp.path().to_path_buf());
3440 crate::db::set_current_account(account.clone()).unwrap();
3441 crate::db::init_database(&account).unwrap();
3442 let _ = crate::state::take_nostr_client();
3445 let owner = Keys::generate();
3447 crate::state::MY_SECRET_KEY.store_from_keys(&owner, &[]);
3448 crate::state::set_my_public_key(owner.public_key());
3449 (tmp, guard)
3450 }
3451
3452 #[test]
3453 fn community_cap_rejects_a_new_membership_at_the_limit() {
3454 let (_tmp, _guard) = init_test_db();
3455 let mk = |i: usize| {
3456 let id = format!("{:064x}", i);
3457 crate::community::list::CommunityListEntry {
3458 community_id: id.clone(),
3459 seed: crate::community::invite::CommunityInvite {
3460 community_id: id,
3461 name: String::new(),
3462 server_root_key: String::new(),
3463 server_root_epoch: 0,
3464 relays: vec![],
3465 channels: vec![],
3466 owner_attestation: None,
3467 icon: None,
3468 },
3469 current: None,
3470 added_at: 0,
3471 }
3472 };
3473 let mut list = crate::community::list::CommunityList::default();
3474 for i in 0..(MAX_COMMUNITIES - 1) {
3475 list.entries.push(mk(i));
3476 }
3477 crate::db::settings::set_sql_setting("community_list_json".to_string(), list.to_json()).unwrap();
3478 assert!(enforce_community_cap().is_ok(), "under the cap a new join is allowed");
3479
3480 list.entries.push(mk(MAX_COMMUNITIES - 1)); crate::db::settings::set_sql_setting("community_list_json".to_string(), list.to_json()).unwrap();
3482 assert!(enforce_community_cap().is_err(), "at the cap a new join is rejected");
3483 }
3484
3485 fn saved_community_owned_by(owner: &Keys) -> Community {
3490 use nostr_sdk::JsonUtil;
3491 let mut community = Community::create("HQ", "general", vec!["r".into()]);
3492 let cid = community.id.to_hex();
3493 community.owner_attestation = Some(
3494 crate::community::owner::build_owner_attestation_unsigned(owner.public_key(), &cid)
3495 .sign_with_keys(owner)
3496 .unwrap()
3497 .as_json(),
3498 );
3499 crate::db::community::save_community(&community).unwrap();
3500 community
3501 }
3502
3503 fn attested_community(name: &str, channel: &str, relays: Vec<String>) -> Community {
3507 use nostr_sdk::JsonUtil;
3508 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
3509 let mut community = Community::create(name, channel, relays);
3510 community.owner_attestation = Some(
3511 crate::community::owner::build_owner_attestation_unsigned(owner.public_key(), &community.id.to_hex())
3512 .sign_with_keys(&owner).unwrap().as_json(),
3513 );
3514 community
3515 }
3516
3517 fn become_local(me: &Keys) {
3519 crate::state::MY_SECRET_KEY.store_from_keys(me, &[]);
3520 crate::state::set_my_public_key(me.public_key());
3521 }
3522
3523 fn owner_channel_rekey(
3526 owner: &Keys,
3527 community: &Community,
3528 recipient_pk: &nostr_sdk::PublicKey,
3529 new_epoch: u64,
3530 new_key: &[u8; 32],
3531 ) -> super::super::rekey::ParsedRekey {
3532 let chan = &community.channels[0];
3533 let scope = super::super::derive::RekeyScope::Channel(chan.id);
3534 let blob = super::super::rekey::build_rekey_blob(
3535 owner.secret_key(), recipient_pk, scope, crate::community::Epoch(new_epoch), new_key,
3536 )
3537 .unwrap();
3538 let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), chan.key.as_bytes());
3539 let outer = super::super::rekey::build_channel_rekey_event(
3540 &Keys::generate(), owner, community.server_root_key.as_bytes(), &chan.id,
3541 crate::community::Epoch(new_epoch), crate::community::Epoch(0), &commit, &[blob],
3542 )
3543 .unwrap();
3544 super::super::rekey::open_rekey_event(&outer, community.server_root_key.as_bytes()).unwrap()
3545 }
3546
3547 #[tokio::test]
3552 async fn outer_event_dedup_skips_an_already_persisted_wire_event() {
3553 let (_tmp, _guard) = init_test_db();
3554 let owner = Keys::generate();
3555 let me = Keys::generate();
3556 become_local(&me);
3557 let community = saved_community_owned_by(&owner);
3558 let channel = community.channels[0].clone();
3559 let chan_hex = channel.id.to_hex();
3560
3561 let author = Keys::generate();
3563 let outer = crate::community::envelope::seal_message(
3564 &author, &channel.key, &channel.id, channel.epoch, "gm", 1000,
3565 ).unwrap();
3566 let outer_hex = outer.id.to_hex();
3567
3568 let mut state = crate::state::ChatState::new();
3570 let msg = match crate::community::inbound::process_incoming(&mut state, &outer, &channel, &me.public_key()) {
3571 Some(crate::community::inbound::IncomingEvent::NewMessage(m)) => m,
3572 _ => panic!("expected NewMessage from a fresh wire event"),
3573 };
3574 assert_eq!(msg.wrapper_event_id.as_deref(), Some(outer_hex.as_str()),
3575 "the inner must carry its outer wire id as wrapper_event_id");
3576
3577 crate::db::events::save_message(&chan_hex, &msg).await.unwrap();
3579
3580 let mut state2 = crate::state::ChatState::new();
3582 let second = crate::community::inbound::process_incoming(&mut state2, &outer, &channel, &me.public_key());
3583 assert!(second.is_none(), "an already-processed wire event must dedup before decryption");
3584 }
3585
3586 #[tokio::test]
3589 async fn ledger_is_shared_but_negentropy_stays_nip17_only() {
3590 let (_tmp, _guard) = init_test_db();
3591 let dm = [0xA1u8; 32];
3592 let concord = [0xC0u8; 32];
3593 crate::db::wrappers::save_processed_wrapper(&dm, 100, crate::db::wrappers::TRANSPORT_NIP17).unwrap();
3594 crate::db::wrappers::save_processed_wrapper(&concord, 200, crate::db::wrappers::TRANSPORT_CONCORD).unwrap();
3595
3596 assert!(crate::db::wrappers::processed_wrapper_exists(&dm));
3598 assert!(crate::db::wrappers::processed_wrapper_exists(&concord));
3599
3600 let items = crate::db::wrappers::load_negentropy_items().unwrap();
3602 assert_eq!(items.len(), 1, "negentropy must exclude concord wrappers");
3603 assert_eq!(items[0].0.to_bytes(), dm);
3604 }
3605
3606 #[tokio::test]
3610 async fn non_message_subkind_dedups_via_the_shared_ledger() {
3611 let (_tmp, _guard) = init_test_db();
3612 let owner = Keys::generate();
3613 let me = Keys::generate();
3614 become_local(&me);
3615 let community = saved_community_owned_by(&owner);
3616 let channel = community.channels[0].clone();
3617
3618 let author = Keys::generate();
3620 let inner = super::super::envelope::build_inner_typed(
3621 author.public_key(), &channel.id, channel.epoch,
3622 crate::stored_event::event_kind::COMMUNITY_PRESENCE, "join", 5, None, &[],
3623 ).sign_with_keys(&author).unwrap();
3624 let outer = super::super::envelope::seal_with_signed_inner(
3625 &Keys::generate(), &inner, &channel.key, &channel.id, channel.epoch,
3626 ).unwrap();
3627
3628 let mut state = crate::state::ChatState::new();
3630 let first = crate::community::inbound::process_incoming(&mut state, &outer, &channel, &me.public_key());
3631 assert!(matches!(first, Some(crate::community::inbound::IncomingEvent::Presence { .. })),
3632 "expected a Presence outcome");
3633 assert!(crate::db::wrappers::processed_wrapper_exists(&outer.id.to_bytes()),
3634 "a non-message sub-kind must record its outer id in the shared ledger");
3635
3636 let second = crate::community::inbound::process_incoming(&mut crate::state::ChatState::new(), &outer, &channel, &me.public_key());
3638 assert!(second.is_none(), "a re-fetched presence must dedup via the shared ledger");
3639 }
3640
3641 #[test]
3642 fn apply_channel_rekey_recovers_and_advances_head() {
3643 let (_tmp, _guard) = init_test_db();
3644 let owner = Keys::generate(); let me = Keys::generate();
3646 become_local(&me);
3647 let community = saved_community_owned_by(&owner);
3648 let cid = community.id.to_hex();
3649 let chan_hex = community.channels[0].id.to_hex();
3650 let new_key = [0xCDu8; 32];
3651
3652 let parsed = owner_channel_rekey(&owner, &community, &me.public_key(), 1, &new_key);
3653 let outcome = apply_channel_rekey(&community, &parsed).unwrap();
3654 assert_eq!(outcome, RekeyOutcome::Applied { head_advanced: true });
3655
3656 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(new_key));
3658 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
3659 assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(1));
3660 assert_eq!(reloaded.channels[0].key.as_bytes(), &new_key);
3661 assert!(crate::db::community::held_epoch_key(&cid, &chan_hex, 0).unwrap().is_some());
3663 }
3664
3665 #[test]
3666 fn apply_channel_rekey_accepts_matching_continuity() {
3667 let (_tmp, _guard) = init_test_db();
3670 let owner = Keys::generate();
3671 let me = Keys::generate();
3672 become_local(&me);
3673 let community = saved_community_owned_by(&owner);
3674 let parsed = owner_channel_rekey(&owner, &community, &me.public_key(), 1, &[0x44u8; 32]);
3676 assert_eq!(
3677 apply_channel_rekey(&community, &parsed).unwrap(),
3678 RekeyOutcome::Applied { head_advanced: true },
3679 "a rekey whose prior-key commitment matches the held genesis key applies"
3680 );
3681 }
3682
3683 #[test]
3684 fn advance_channel_epoch_archives_when_no_head_row() {
3685 let (_tmp, _guard) = init_test_db();
3688 let cid = "f".repeat(64);
3689 let orphan_channel = "a".repeat(64);
3690 let advanced = crate::db::community::advance_channel_epoch(&cid, &orphan_channel, 2, &[0x77u8; 32]).unwrap();
3691 assert!(!advanced, "no head row → head not advanced");
3692 assert_eq!(crate::db::community::held_epoch_key(&cid, &orphan_channel, 2).unwrap(), Some([0x77u8; 32]), "key still archived");
3693 }
3694
3695 #[tokio::test]
3696 async fn rotate_channel_publishes_recoverable_rekey_and_advances_own_head() {
3697 use crate::community::derive::{recipient_pseudonym, rekey_pseudonym};
3698 use crate::community::rekey::{open_rekey_blob, open_rekey_event, rekey_pairwise_secret};
3699 let (_tmp, _guard) = init_test_db();
3700 let owner = Keys::generate();
3701 become_local(&owner); let community = saved_community_owned_by(&owner);
3703 let channel_id = community.channels[0].id;
3704 let member = Keys::generate(); let relay = MemoryRelay::new();
3706
3707 let new_epoch = rotate_channel(&relay, &community, &channel_id, &[member.public_key()], community.server_root_key.as_bytes())
3708 .await
3709 .expect("rotate");
3710 assert_eq!(new_epoch, 1);
3711
3712 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
3714 assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(1));
3715
3716 let addr = rekey_pseudonym(
3719 &crate::community::ServerRootKey(*community.server_root_key.as_bytes()),
3720 &channel_id, crate::community::Epoch(1),
3721 )
3722 .to_hex();
3723 let found = relay
3724 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() }, &community.relays)
3725 .await
3726 .unwrap();
3727 assert_eq!(found.len(), 1, "rekey addressable by its server-root pseudonym");
3728 let parsed = open_rekey_event(&found[0], community.server_root_key.as_bytes()).unwrap();
3729 assert_eq!(parsed.rotator, owner.public_key());
3730 assert_eq!(parsed.new_epoch, crate::community::Epoch(1));
3731 assert_eq!(parsed.prev_epoch, crate::community::Epoch(0));
3732 assert_eq!(parsed.blobs.len(), 2, "the member + me (multi-device) each get a blob");
3733
3734 let secret = rekey_pairwise_secret(member.secret_key(), &parsed.rotator).unwrap();
3736 let loc = recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
3737 let mine = parsed.blobs.iter().find(|b| b.locator == loc).expect("member's blob present");
3738 let recovered = open_rekey_blob(member.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine).unwrap();
3739 assert_eq!(reloaded.channels[0].key.as_bytes(), &recovered, "member's recovered key == my advanced head key");
3740 }
3741
3742 #[tokio::test]
3743 async fn rotate_channel_failed_publish_leaves_head_unadvanced() {
3744 let (_tmp, _guard) = init_test_db();
3747 let owner = Keys::generate();
3748 become_local(&owner);
3749 let community = saved_community_owned_by(&owner);
3750 let member = Keys::generate();
3751 let err = rotate_channel(&FailingRelay, &community, &community.channels[0].id, &[member.public_key()], community.server_root_key.as_bytes()).await;
3752 assert!(err.is_err(), "a failed publish must propagate, not silently advance");
3753 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
3754 assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(0), "head stays put on publish failure");
3755 }
3756
3757 fn build_rekey_chain(
3761 owner: &Keys, community: &Community, recipient_pk: &nostr_sdk::PublicKey, n: u64,
3762 ) -> (Vec<Event>, Vec<[u8; 32]>) {
3763 let chan = &community.channels[0];
3764 let scope = super::super::derive::RekeyScope::Channel(chan.id);
3765 let mut prev_key = *chan.key.as_bytes();
3766 let mut events = Vec::new();
3767 let mut keys = Vec::new();
3768 for e in 1..=n {
3769 let new_key = [e as u8; 32];
3770 let blob = super::super::rekey::build_rekey_blob(owner.secret_key(), recipient_pk, scope, crate::community::Epoch(e), &new_key).unwrap();
3771 let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(e - 1), &prev_key);
3772 let ev = super::super::rekey::build_channel_rekey_event(
3773 &Keys::generate(), owner, community.server_root_key.as_bytes(), &chan.id,
3774 crate::community::Epoch(e), crate::community::Epoch(e - 1), &commit, &[blob],
3775 ).unwrap();
3776 events.push(ev);
3777 keys.push(new_key);
3778 prev_key = new_key;
3779 }
3780 (events, keys)
3781 }
3782
3783 #[tokio::test]
3784 async fn catch_up_steps_over_a_missing_epoch() {
3785 let (_tmp, _guard) = init_test_db();
3788 let owner = Keys::generate();
3789 let me = Keys::generate();
3790 become_local(&me);
3791 let community = saved_community_owned_by(&owner);
3792 let channel_id = community.channels[0].id;
3793 let cid = community.id.to_hex();
3794 let chan_hex = channel_id.to_hex();
3795
3796 let (events, keys) = build_rekey_chain(&owner, &community, &me.public_key(), 3);
3797 let relay = MemoryRelay::new();
3798 relay.inject(&events[0], &community.relays); relay.inject(&events[2], &community.relays); let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
3801
3802 assert_eq!(reached, 3, "head reaches the latest present epoch, stepping over the gap");
3803 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(keys[0]));
3804 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 2).unwrap(), None, "missing epoch is a hole");
3805 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 3).unwrap(), Some(keys[2]));
3806 }
3807
3808 #[tokio::test]
3809 async fn catch_up_recovers_a_rekey_under_a_prior_server_root() {
3810 let (_tmp, _guard) = init_test_db();
3815 let owner = Keys::generate();
3816 let me = Keys::generate();
3817 become_local(&me);
3818 let root0_community = saved_community_owned_by(&owner);
3819 let cid = root0_community.id.to_hex();
3820 let channel_id = root0_community.channels[0].id;
3821 let chan_hex = channel_id.to_hex();
3822 let scope = super::super::derive::RekeyScope::Channel(channel_id);
3823 let genesis_key = *root0_community.channels[0].key.as_bytes();
3824
3825 let root1 = [0x99u8; 32];
3827 crate::db::community::advance_server_root_epoch(&cid, 1, &root1).unwrap();
3828 let community = crate::db::community::load_community(&root0_community.id).unwrap().unwrap();
3829 assert_eq!(community.server_root_epoch, crate::community::Epoch(1));
3830
3831 let (k1, k2) = ([0x11u8; 32], [0x22u8; 32]);
3833 let blob1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &k1).unwrap();
3834 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
3835 let ev1 = super::super::rekey::build_channel_rekey_event(
3836 &Keys::generate(), &owner, root0_community.server_root_key.as_bytes(), &channel_id,
3837 crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob1]).unwrap();
3838 let blob2 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(2), &k2).unwrap();
3839 let commit1 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(1), &k1);
3840 let ev2 = super::super::rekey::build_channel_rekey_event(
3841 &Keys::generate(), &owner, &root1, &channel_id,
3842 crate::community::Epoch(2), crate::community::Epoch(1), &commit1, &[blob2]).unwrap();
3843
3844 let relay = MemoryRelay::new();
3845 relay.inject(&ev1, &community.relays);
3846 relay.inject(&ev2, &community.relays);
3847
3848 let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
3849 assert_eq!(reached, 2, "reached the latest channel epoch across the server-root rotation");
3850 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(k1),
3851 "epoch-1 key recovered from a rekey under the PRIOR server root");
3852 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 2).unwrap(), Some(k2));
3853 }
3854
3855 #[tokio::test]
3856 async fn catch_up_backfills_a_sub_head_gap() {
3857 let (_tmp, _guard) = init_test_db();
3860 let owner = Keys::generate();
3861 let me = Keys::generate();
3862 become_local(&me);
3863 let community = saved_community_owned_by(&owner);
3864 let cid = community.id.to_hex();
3865 let channel_id = community.channels[0].id;
3866 let chan_hex = channel_id.to_hex();
3867 let scope = super::super::derive::RekeyScope::Channel(channel_id);
3868 let genesis_key = *community.channels[0].key.as_bytes();
3869
3870 let k2 = [0x22u8; 32];
3872 crate::db::community::advance_channel_epoch(&cid, &chan_hex, 2, &k2).unwrap();
3873 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), None, "epoch 1 starts as a hole");
3874
3875 let k1 = [0x11u8; 32];
3877 let blob1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &k1).unwrap();
3878 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
3879 let ev1 = super::super::rekey::build_channel_rekey_event(
3880 &Keys::generate(), &owner, community.server_root_key.as_bytes(), &channel_id,
3881 crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob1]).unwrap();
3882 let relay = MemoryRelay::new();
3883 relay.inject(&ev1, &community.relays);
3884
3885 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
3886 let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
3887 assert_eq!(reached, 2, "head unchanged (gap-fill never regresses it)");
3888 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(k1),
3889 "the sub-head hole was backfilled");
3890 }
3891
3892 #[tokio::test]
3893 async fn catch_up_walks_a_chain_of_rotations_to_the_latest() {
3894 let (_tmp, _guard) = init_test_db();
3895 let owner = Keys::generate();
3896 let me = Keys::generate();
3897 become_local(&me); let community = saved_community_owned_by(&owner);
3899 let channel_id = community.channels[0].id;
3900 let cid = community.id.to_hex();
3901 let chan_hex = channel_id.to_hex();
3902
3903 let (events, keys) = build_rekey_chain(&owner, &community, &me.public_key(), 3);
3905 let relay = MemoryRelay::new();
3906 for ev in events.iter().rev() {
3907 relay.inject(ev, &community.relays);
3908 }
3909
3910 let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
3911 assert_eq!(reached, 3, "caught up to the latest epoch");
3912 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
3914 assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(3));
3915 assert_eq!(reloaded.channels[0].key.as_bytes(), &keys[2]);
3916 for (i, k) in keys.iter().enumerate() {
3917 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, (i + 1) as u64).unwrap(), Some(*k));
3918 }
3919 }
3920
3921 #[tokio::test]
3922 async fn catch_up_slides_across_the_window_boundary() {
3923 let (_tmp, _guard) = init_test_db();
3927 let owner = Keys::generate();
3928 let me = Keys::generate();
3929 become_local(&me);
3930 let community = saved_community_owned_by(&owner);
3931 let channel_id = community.channels[0].id;
3932 let cid = community.id.to_hex();
3933 let chan_hex = channel_id.to_hex();
3934
3935 let (events, keys) = build_rekey_chain(&owner, &community, &me.public_key(), 70);
3936 let relay = MemoryRelay::new();
3937 for ev in &events {
3938 relay.inject(ev, &community.relays);
3939 }
3940 let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
3941 assert_eq!(reached, 70, "slid past the 64-epoch window boundary to the latest");
3942 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 70).unwrap(), Some(keys[69]));
3943 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 64).unwrap(), Some(keys[63]), "window-1 keys retained too");
3944 }
3945
3946 fn build_base_rekey_chain(
3952 owner: &Keys, community: &Community, recipient_pk: &nostr_sdk::PublicKey, n: u64,
3953 ) -> (Vec<Event>, Vec<[u8; 32]>) {
3954 let mut prior_root = *community.server_root_key.as_bytes();
3955 let mut events = Vec::new();
3956 let mut roots = Vec::new();
3957 for e in 1..=n {
3958 let new_root = [(e % 256) as u8; 32];
3959 let blob = super::super::rekey::build_rekey_blob(
3960 owner.secret_key(), recipient_pk, super::super::derive::RekeyScope::ServerRoot, crate::community::Epoch(e), &new_root,
3961 )
3962 .unwrap();
3963 let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(e - 1), &prior_root);
3964 events.push(super::super::rekey::build_server_root_rekey_event(
3965 &Keys::generate(), owner, &prior_root, &community.id,
3966 crate::community::Epoch(e), crate::community::Epoch(e - 1), &commit, &[blob],
3967 ).unwrap());
3968 roots.push(new_root);
3969 prior_root = new_root;
3970 }
3971 (events, roots)
3972 }
3973
3974 #[tokio::test]
3975 async fn catch_up_server_root_walks_a_chain_of_base_rotations() {
3976 let (_tmp, _guard) = init_test_db();
3977 let owner = Keys::generate();
3978 let me = Keys::generate();
3979 become_local(&me);
3980 let community = saved_community_owned_by(&owner);
3981 let cid = community.id.to_hex();
3982
3983 let (events, roots) = build_base_rekey_chain(&owner, &community, &me.public_key(), 3);
3984 let relay = MemoryRelay::new();
3985 for ev in events.iter().rev() {
3986 relay.inject(ev, &community.relays);
3987 }
3988 let reached = catch_up_server_root(&relay, &community).await.unwrap();
3989 assert_eq!(reached.epoch, 3, "walked the base chain to the latest epoch");
3990 assert!(!reached.removed, "a normal catch-up is not a removal");
3991 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
3992 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(3));
3993 assert_eq!(reloaded.server_root_key.as_bytes(), &roots[2], "base head is the latest root");
3994 for (i, r) in roots.iter().enumerate() {
3996 assert_eq!(crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, (i + 1) as u64).unwrap(), Some(*r));
3997 }
3998 }
3999
4000 #[tokio::test]
4001 async fn catch_up_recovers_from_a_split_base_rotation_second_chunk() {
4002 let (_tmp, _guard) = init_test_db();
4006 let owner = Keys::generate();
4007 let me = Keys::generate();
4008 become_local(&me);
4009 let community = saved_community_owned_by(&owner);
4010 let genesis = *community.server_root_key.as_bytes();
4011 let new_root = [0x5Au8; 32];
4012 let scope = super::super::derive::RekeyScope::ServerRoot;
4013 let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis);
4014 let mk = |recipient: &nostr_sdk::PublicKey| {
4015 let blob = super::super::rekey::build_rekey_blob(owner.secret_key(), recipient, scope, crate::community::Epoch(1), &new_root).unwrap();
4016 super::super::rekey::build_server_root_rekey_event(
4017 &Keys::generate(), &owner, &genesis, &community.id,
4018 crate::community::Epoch(1), crate::community::Epoch(0), &commit, &[blob],
4019 ).unwrap()
4020 };
4021 let relay = MemoryRelay::new();
4022 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();
4026 assert_eq!(reached.epoch, 1, "recovered the split rotation via the second chunk");
4027 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4028 assert_eq!(reloaded.server_root_key.as_bytes(), &new_root, "recovered the new root from chunk 2");
4029 }
4030
4031 #[tokio::test]
4032 async fn catch_up_converges_concurrent_refoundings_on_the_lowest_root() {
4033 let (_tmp, _guard) = init_test_db();
4037 let owner = Keys::generate();
4038 let me = Keys::generate();
4039 become_local(&me);
4040 let community = saved_community_owned_by(&owner);
4041 let genesis = *community.server_root_key.as_bytes();
4042 let scope = super::super::derive::RekeyScope::ServerRoot;
4043 let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis);
4044 let root_lo = [0x10u8; 32];
4045 let root_hi = [0xF0u8; 32]; let mk = |root: &[u8; 32]| {
4047 let blob = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), root).unwrap();
4048 super::super::rekey::build_server_root_rekey_event(
4049 &Keys::generate(), &owner, &genesis, &community.id,
4050 crate::community::Epoch(1), crate::community::Epoch(0), &commit, &[blob],
4051 ).unwrap()
4052 };
4053 let relay = MemoryRelay::new();
4054 relay.inject(&mk(&root_hi), &community.relays);
4056 relay.inject(&mk(&root_lo), &community.relays);
4057
4058 let reached = catch_up_server_root(&relay, &community).await.unwrap();
4059 assert_eq!(reached.epoch, 1, "advanced one epoch");
4060 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4061 assert_eq!(reloaded.server_root_key.as_bytes(), &root_lo, "converged on the LOWEST root, not the first-arrived");
4062 }
4063
4064 #[tokio::test]
4065 async fn rotate_retry_reuses_the_archived_root_no_same_epoch_fork() {
4066 let (_tmp, _guard) = init_test_db();
4071 let owner = Keys::generate();
4072 become_local(&owner);
4073 let community = saved_community_owned_by(&owner);
4074 let cid = community.id.to_hex();
4075 let relay = RekeyFailingRelay::new(); let member = Keys::generate();
4077
4078 assert!(rotate_server_root(&relay, &community, &[member.public_key()]).await.is_err(), "the rekey publish fails");
4079 let k1 = crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap()
4080 .expect("the new root is archived before publishing (fork-safety)");
4081 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4082 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(0), "head not advanced on a failed publish");
4083
4084 relay.allow_rekey();
4085 rotate_server_root(&relay, &reloaded, &[member.public_key()]).await.unwrap();
4086 let k2 = crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap().unwrap();
4087 assert_eq!(k1, k2, "the retry REUSES the archived root — no second root for epoch 1, no fork");
4088 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
4089 assert_eq!(after.server_root_epoch, crate::community::Epoch(1), "the retry completed the rotation");
4090 assert_eq!(after.server_root_key.as_bytes(), &k1, "the committed root is the one minted on attempt 1");
4091 }
4092
4093 #[tokio::test]
4094 async fn rotate_server_root_splits_a_large_recipient_set_into_multiple_events() {
4095 let (_tmp, _guard) = init_test_db();
4097 let owner = Keys::generate();
4098 become_local(&owner);
4099 let community = saved_community_owned_by(&owner);
4100 let genesis = *community.server_root_key.as_bytes();
4101 let relay = MemoryRelay::new();
4102 let recipients: Vec<_> = (0..super::super::rekey::MAX_REKEY_BLOBS).map(|_| Keys::generate().public_key()).collect();
4104 rotate_server_root(&relay, &community, &recipients).await.unwrap();
4105 let addr = super::super::derive::base_rekey_pseudonym(&super::super::ServerRootKey(genesis), &community.id, crate::community::Epoch(1)).to_hex();
4106 let evs = relay
4107 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() }, &community.relays)
4108 .await
4109 .unwrap();
4110 assert_eq!(evs.len(), 2, "a >MAX_REKEY_BLOBS rotation splits into 2 events at one address");
4111 }
4112
4113 #[tokio::test]
4114 async fn catch_up_server_root_is_a_noop_with_no_rotations() {
4115 let (_tmp, _guard) = init_test_db();
4116 let owner = Keys::generate();
4117 let me = Keys::generate();
4118 become_local(&me);
4119 let community = saved_community_owned_by(&owner);
4120 let relay = MemoryRelay::new();
4121 assert_eq!(catch_up_server_root(&relay, &community).await.unwrap().epoch, 0, "no base rotations → stays at 0");
4122 }
4123
4124 #[tokio::test]
4125 async fn concurrent_refounders_converge_to_the_lowest_root() {
4126 let (_tmp, _guard) = init_test_db();
4131 let owner = Keys::generate();
4132 let me = Keys::generate();
4133 become_local(&me); let community = saved_community_owned_by(&owner);
4135 let cid = community.id.to_hex();
4136 let genesis_root = *community.server_root_key.as_bytes();
4137 let scope = super::super::derive::RekeyScope::ServerRoot;
4138
4139 let root_lo = [0x10u8; 32];
4142 let root_hi = [0x99u8; 32]; let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_root);
4144 let blob_lo = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &root_lo).unwrap();
4145 let ev_lo = super::super::rekey::build_server_root_rekey_event(
4146 &Keys::generate(), &owner, &genesis_root, &community.id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_lo]).unwrap();
4147
4148 let relay = MemoryRelay::new();
4149 relay.inject(&ev_lo, &community.relays);
4150
4151 crate::db::community::advance_server_root_epoch(&cid, 1, &root_hi).unwrap();
4153 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4154 assert_eq!(community.server_root_key.as_bytes(), &root_hi, "start on the higher root");
4155
4156 let out = catch_up_server_root(&relay, &community).await.unwrap();
4157 assert_eq!(out.epoch, 1, "converged in place at the same epoch (not advanced)");
4158 assert!(!out.removed);
4159 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
4160 assert_eq!(after.server_root_key.as_bytes(), &root_lo, "originator converged to the lowest authorized root");
4161
4162 let _ = catch_up_server_root(&relay, &after).await.unwrap();
4164 assert_eq!(crate::db::community::load_community(&community.id).unwrap().unwrap().server_root_key.as_bytes(), &root_lo, "no flip-flop");
4165 }
4166
4167 #[tokio::test]
4168 async fn banned_rotators_rekey_is_not_a_convergence_candidate() {
4169 let (_tmp, _guard) = init_test_db();
4174 let owner = Keys::generate();
4175 let me = Keys::generate();
4176 let banned_admin = Keys::generate();
4177 become_local(&me);
4178 let community = saved_community_owned_by(&owner);
4179 let cid = community.id.to_hex();
4180 let genesis_root = *community.server_root_key.as_bytes();
4181 let scope = super::super::derive::RekeyScope::ServerRoot;
4182
4183 let role_id = "e".repeat(64);
4185 let roster = crate::community::roles::CommunityRoles {
4186 roles: vec![crate::community::roles::Role::admin(role_id.clone())],
4187 grants: vec![crate::community::roles::MemberGrant { member: banned_admin.public_key().to_hex(), role_ids: vec![role_id] }],
4188 };
4189 crate::db::community::set_community_roles(&cid, &roster, 1).unwrap();
4190 crate::db::community::set_community_banlist(&cid, &[banned_admin.public_key().to_hex()], 2).unwrap();
4192
4193 let root_evil = [0x01u8; 32];
4195 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_root);
4196 let blob = super::super::rekey::build_rekey_blob(banned_admin.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &root_evil).unwrap();
4197 let ev = super::super::rekey::build_server_root_rekey_event(
4198 &Keys::generate(), &banned_admin, &genesis_root, &community.id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob]).unwrap();
4199 let relay = MemoryRelay::new();
4200 relay.inject(&ev, &community.relays);
4201
4202 let out = catch_up_server_root(&relay, &community).await.unwrap();
4205 assert_eq!(out.epoch, 0, "banned rotator's re-founding must not advance the base");
4206 assert!(!out.removed, "banned rotator's exclusion must not read as an authorized removal");
4207 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
4208 assert_eq!(after.server_root_key.as_bytes(), &genesis_root, "root unchanged");
4209
4210 let parsed = super::super::rekey::open_rekey_event(&ev, &genesis_root).unwrap();
4212 assert!(apply_server_root_rekey(&community, &parsed).is_err(), "apply must refuse a banned rotator");
4213 }
4214
4215 #[tokio::test]
4216 async fn heal_abandons_a_deauthorized_root_for_the_authorized_higher_sibling() {
4217 let (_tmp, _guard) = init_test_db();
4221 let owner = Keys::generate();
4222 let me = Keys::generate();
4223 let banned_admin = Keys::generate();
4224 become_local(&me);
4225 let community = saved_community_owned_by(&owner);
4226 let cid = community.id.to_hex();
4227 let genesis_root = *community.server_root_key.as_bytes();
4228 let scope = super::super::derive::RekeyScope::ServerRoot;
4229 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_root);
4230
4231 let root_evil = [0x01u8; 32];
4234 let root_owner = [0x77u8; 32];
4235 let blob_evil = super::super::rekey::build_rekey_blob(banned_admin.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &root_evil).unwrap();
4236 let ev_evil = super::super::rekey::build_server_root_rekey_event(
4237 &Keys::generate(), &banned_admin, &genesis_root, &community.id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_evil]).unwrap();
4238 let blob_owner = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &root_owner).unwrap();
4239 let ev_owner = super::super::rekey::build_server_root_rekey_event(
4240 &Keys::generate(), &owner, &genesis_root, &community.id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_owner]).unwrap();
4241 let relay = MemoryRelay::new();
4242 relay.inject(&ev_evil, &community.relays);
4243 relay.inject(&ev_owner, &community.relays);
4244
4245 crate::db::community::advance_server_root_epoch(&cid, 1, &root_evil).unwrap();
4247 crate::db::community::set_community_banlist(&cid, &[banned_admin.public_key().to_hex()], 2).unwrap();
4248 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4249 assert_eq!(community.server_root_key.as_bytes(), &root_evil, "start partitioned on the attacker's root");
4250
4251 let out = catch_up_server_root(&relay, &community).await.unwrap();
4252 assert_eq!(out.epoch, 1);
4253 assert!(!out.removed);
4254 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
4255 assert_eq!(after.server_root_key.as_bytes(), &root_owner,
4256 "heal must abandon the deauthorized root and adopt the owner's higher sibling");
4257
4258 let _ = catch_up_server_root(&relay, &after).await.unwrap();
4260 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");
4261 }
4262
4263 #[tokio::test]
4264 async fn concurrent_channel_rekeyers_converge_to_the_lowest_key() {
4265 let (_tmp, _guard) = init_test_db();
4270 let owner = Keys::generate();
4271 let me = Keys::generate();
4272 become_local(&me); let community = saved_community_owned_by(&owner);
4274 let cid = community.id.to_hex();
4275 let channel_id = community.channels[0].id;
4276 let chan_hex = channel_id.to_hex();
4277 let scope = super::super::derive::RekeyScope::Channel(channel_id);
4278 let genesis_key = *community.channels[0].key.as_bytes();
4279 let root = *community.server_root_key.as_bytes();
4280 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
4281
4282 let key_lo = [0x10u8; 32];
4284 let key_hi = [0x99u8; 32];
4285 let blob_lo = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_lo).unwrap();
4286 let blob_hi = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_hi).unwrap();
4287 let ev_lo = super::super::rekey::build_channel_rekey_event(
4288 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_lo]).unwrap();
4289 let ev_hi = super::super::rekey::build_channel_rekey_event(
4290 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_hi]).unwrap();
4291
4292 let relay = MemoryRelay::new();
4293 relay.inject(&ev_hi, &community.relays); relay.inject(&ev_lo, &community.relays);
4295
4296 crate::db::community::advance_server_root_epoch(&cid, 1, &[0x42u8; 32]).unwrap();
4301 crate::db::community::advance_channel_epoch(&cid, &chan_hex, 1, &key_hi).unwrap();
4303 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4304
4305 let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4306 assert_eq!(reached, 1, "converged in place at the same channel epoch");
4307 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(key_lo),
4308 "adopted the lowest delivered key regardless of relay order");
4309
4310 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
4312 let _ = catch_up_channel_rekeys(&relay, &after, &channel_id).await.unwrap();
4313 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(key_lo), "no flip-flop");
4314 }
4315
4316 #[tokio::test]
4317 async fn concurrent_channel_rekeyers_converge_when_i_authored_the_losing_fork() {
4318 let (_tmp, _guard) = init_test_db();
4323 let owner = Keys::generate();
4324 let me = Keys::generate(); become_local(&me);
4326 let community = saved_community_owned_by(&owner);
4327 let cid = community.id.to_hex();
4328 let channel_id = community.channels[0].id;
4329 let chan_hex = channel_id.to_hex();
4330 let scope = super::super::derive::RekeyScope::Channel(channel_id);
4331 let genesis_key = *community.channels[0].key.as_bytes();
4332 let root = *community.server_root_key.as_bytes();
4333 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
4334
4335 let role_id = "d".repeat(64);
4339 let roster = crate::community::roles::CommunityRoles {
4340 roles: vec![crate::community::roles::Role::admin(role_id.clone())],
4341 grants: vec![crate::community::roles::MemberGrant { member: me.public_key().to_hex(), role_ids: vec![role_id] }],
4342 };
4343 crate::db::community::set_community_roles(&cid, &roster, 1).unwrap();
4344
4345 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();
4349 let ev_lo = super::super::rekey::build_channel_rekey_event(
4350 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_lo]).unwrap();
4351 let blob_hi = super::super::rekey::build_rekey_blob(me.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_hi).unwrap();
4353 let ev_hi = super::super::rekey::build_channel_rekey_event(
4354 &Keys::generate(), &me, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_hi]).unwrap();
4355
4356 let relay = MemoryRelay::new();
4357 relay.inject(&ev_hi, &community.relays);
4358 relay.inject(&ev_lo, &community.relays);
4359
4360 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 _ = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4367 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(key_lo),
4368 "I authored the losing fork but must converge DOWN to the owner's lower key");
4369 }
4370
4371 #[tokio::test]
4372 async fn reorg_through_a_fork_heals_the_forked_past_epoch() {
4373 let (_tmp, _guard) = init_test_db();
4378 let owner = Keys::generate();
4379 let me = Keys::generate();
4380 become_local(&me);
4381 let community = saved_community_owned_by(&owner);
4382 let cid = community.id.to_hex();
4383 let channel_id = community.channels[0].id;
4384 let chan_hex = channel_id.to_hex();
4385 let scope = super::super::derive::RekeyScope::Channel(channel_id);
4386 let genesis_key = *community.channels[0].key.as_bytes();
4387 let root = *community.server_root_key.as_bytes();
4388 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
4389
4390 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();
4395 let blob_hi1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_hi1).unwrap();
4396 let ev_lo1 = super::super::rekey::build_channel_rekey_event(
4397 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_lo1]).unwrap();
4398 let ev_hi1 = super::super::rekey::build_channel_rekey_event(
4399 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_hi1]).unwrap();
4400 let commit1_win = super::super::rekey::epoch_key_commitment(crate::community::Epoch(1), &key_lo1);
4402 let blob_e2 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(2), &key_e2).unwrap();
4403 let ev_e2 = super::super::rekey::build_channel_rekey_event(
4404 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(2), crate::community::Epoch(1), &commit1_win, &[blob_e2]).unwrap();
4405
4406 let relay = MemoryRelay::new();
4407 relay.inject(&ev_lo1, &community.relays);
4408 relay.inject(&ev_hi1, &community.relays);
4409 relay.inject(&ev_e2, &community.relays);
4410
4411 crate::db::community::advance_server_root_epoch(&cid, 1, &[0x42u8; 32]).unwrap();
4413 crate::db::community::advance_channel_epoch(&cid, &chan_hex, 1, &key_hi1).unwrap();
4415 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4416
4417 let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4418 assert_eq!(reached, 2, "reorged forward to the head epoch");
4419 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 2).unwrap(), Some(key_e2), "head epoch adopted");
4420 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(key_lo1),
4421 "the FORKED past epoch re-converged to the lowest sibling (its messages become readable)");
4422 }
4423
4424 #[tokio::test]
4425 async fn window_heal_converges_an_already_reorged_past_fork() {
4426 let (_tmp, _guard) = init_test_db();
4431 let owner = Keys::generate();
4432 let me = Keys::generate();
4433 become_local(&me);
4434 let community = saved_community_owned_by(&owner);
4435 let cid = community.id.to_hex();
4436 let channel_id = community.channels[0].id;
4437 let chan_hex = channel_id.to_hex();
4438 let scope = super::super::derive::RekeyScope::Channel(channel_id);
4439 let genesis_key = *community.channels[0].key.as_bytes();
4440 let root = *community.server_root_key.as_bytes();
4441 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
4442
4443 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();
4447 let blob_hi1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_hi1).unwrap();
4448 let ev_lo1 = super::super::rekey::build_channel_rekey_event(
4449 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_lo1]).unwrap();
4450 let ev_hi1 = super::super::rekey::build_channel_rekey_event(
4451 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_hi1]).unwrap();
4452
4453 let relay = MemoryRelay::new();
4454 relay.inject(&ev_lo1, &community.relays);
4455 relay.inject(&ev_hi1, &community.relays);
4456 crate::db::community::advance_server_root_epoch(&cid, 1, &[0x42u8; 32]).unwrap();
4460 crate::db::community::advance_channel_epoch(&cid, &chan_hex, 1, &key_hi1).unwrap();
4462 crate::db::community::advance_channel_epoch(&cid, &chan_hex, 2, &key_e2).unwrap();
4463 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4464
4465 let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4466 assert_eq!(reached, 2, "head unchanged (no new rekey to apply)");
4467 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 2).unwrap(), Some(key_e2), "head epoch untouched");
4468 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(key_lo1),
4469 "the already-forked past epoch re-converged to the lowest sibling via the window heal (no in-sync reorg)");
4470 }
4471
4472 #[tokio::test]
4473 async fn channel_heal_cannot_converge_to_a_key_i_was_not_given() {
4474 let (_tmp, _guard) = init_test_db();
4481 let owner = Keys::generate();
4482 let me = Keys::generate();
4483 become_local(&me);
4484 let community = saved_community_owned_by(&owner);
4485 let cid = community.id.to_hex();
4486 let channel_id = community.channels[0].id;
4487 let chan_hex = channel_id.to_hex();
4488 let scope = super::super::derive::RekeyScope::Channel(channel_id);
4489 let genesis_key = *community.channels[0].key.as_bytes();
4490 let root = *community.server_root_key.as_bytes();
4491 let commit0 = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &genesis_key);
4492
4493 let key_lo = [0x10u8; 32]; let key_hi = [0x99u8; 32]; let other = Keys::generate();
4497 let blob_lo = super::super::rekey::build_rekey_blob(owner.secret_key(), &other.public_key(), scope, crate::community::Epoch(1), &key_lo).unwrap();
4498 let ev_lo = super::super::rekey::build_channel_rekey_event(
4499 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_lo]).unwrap();
4500 let blob_hi = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &key_hi).unwrap();
4502 let ev_hi = super::super::rekey::build_channel_rekey_event(
4503 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(1), crate::community::Epoch(0), &commit0, &[blob_hi]).unwrap();
4504
4505 let relay = MemoryRelay::new();
4506 relay.inject(&ev_lo, &community.relays);
4507 relay.inject(&ev_hi, &community.relays);
4508 crate::db::community::advance_server_root_epoch(&cid, 1, &[0x42u8; 32]).unwrap();
4509 crate::db::community::advance_channel_epoch(&cid, &chan_hex, 1, &key_hi).unwrap();
4510 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4511
4512 let _ = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4513 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 1).unwrap(), Some(key_hi),
4515 "excluded from the winning rekey ⇒ cannot converge");
4516 }
4517
4518 #[tokio::test]
4519 async fn refounding_channel_rekey_is_sealed_under_the_prior_root() {
4520 let (_tmp, _guard) = init_test_db();
4525 let owner = Keys::generate();
4526 become_local(&owner); let community = saved_community_owned_by(&owner);
4528 let channel_id = community.channels[0].id;
4529 let prior_root = [0x11u8; 32]; let relay = MemoryRelay::new();
4532 rotate_channel(&relay, &community, &channel_id, &[owner.public_key()], &prior_root).await.unwrap();
4533
4534 let z = super::super::derive::rekey_pseudonym(&crate::community::ServerRootKey(prior_root), &channel_id, crate::community::Epoch(1)).to_hex();
4536 let q = Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![z], ..Default::default() };
4537 let evs = relay.fetch(&q, &community.relays).await.unwrap();
4538 assert_eq!(evs.len(), 1, "channel rekey is addressed at the PRIOR-root pseudonym");
4539 assert!(super::super::rekey::open_rekey_event(&evs[0], &prior_root).is_ok(),
4541 "opens under the prior (shared) root every retained member still holds");
4542 assert!(super::super::rekey::open_rekey_event(&evs[0], community.server_root_key.as_bytes()).is_err(),
4543 "does NOT open under the current/new root (which a base-fork loser would have dropped)");
4544 }
4545
4546 #[tokio::test]
4547 async fn apply_channel_rekey_converges_past_a_divergent_prior_epoch() {
4548 let (_tmp, _guard) = init_test_db();
4553 let owner = Keys::generate();
4554 let me = Keys::generate();
4555 become_local(&me);
4556 let community = saved_community_owned_by(&owner);
4557 let cid = community.id.to_hex();
4558 let channel_id = community.channels[0].id;
4559 let chan_hex = channel_id.to_hex();
4560 let scope = super::super::derive::RekeyScope::Channel(channel_id);
4561 let root = *community.server_root_key.as_bytes();
4562
4563 let my_fork_key = [0xAAu8; 32];
4565 crate::db::community::advance_channel_epoch(&cid, &chan_hex, 1, &my_fork_key).unwrap();
4566
4567 let winner_epoch1 = [0xBBu8; 32];
4569 let new_key = [0x22u8; 32];
4570 let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(1), &winner_epoch1);
4571 let blob = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(2), &new_key).unwrap();
4572 let ev = super::super::rekey::build_channel_rekey_event(
4573 &Keys::generate(), &owner, &root, &channel_id, crate::community::Epoch(2), crate::community::Epoch(1), &commit, &[blob]).unwrap();
4574 let parsed = super::super::rekey::open_rekey_event(&ev, &root).unwrap();
4575
4576 let outcome = apply_channel_rekey(&community, &parsed).unwrap();
4577 assert!(matches!(outcome, RekeyOutcome::Applied { head_advanced: true }),
4578 "must converge forward past the divergent prior epoch, got {outcome:?}");
4579 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 2).unwrap(), Some(new_key),
4580 "adopted the winner's epoch-2 key");
4581 }
4582
4583 #[tokio::test]
4584 async fn catch_up_server_root_stops_when_removed_from_base() {
4585 let (_tmp, _guard) = init_test_db();
4588 let owner = Keys::generate();
4589 let me = Keys::generate();
4590 become_local(&me);
4591 let community = saved_community_owned_by(&owner);
4592 let scope = super::super::derive::RekeyScope::ServerRoot;
4593 let relay = MemoryRelay::new();
4594
4595 let root1 = [0x11u8; 32];
4597 let b1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &root1).unwrap();
4598 let e1 = super::super::rekey::build_server_root_rekey_event(
4599 &Keys::generate(), &owner, community.server_root_key.as_bytes(), &community.id,
4600 crate::community::Epoch(1), crate::community::Epoch(0),
4601 &super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), community.server_root_key.as_bytes()), &[b1],
4602 ).unwrap();
4603 let other = Keys::generate();
4605 let b2 = super::super::rekey::build_rekey_blob(owner.secret_key(), &other.public_key(), scope, crate::community::Epoch(2), &[0x22u8; 32]).unwrap();
4606 let e2 = super::super::rekey::build_server_root_rekey_event(
4607 &Keys::generate(), &owner, &root1, &community.id,
4608 crate::community::Epoch(2), crate::community::Epoch(1),
4609 &super::super::rekey::epoch_key_commitment(crate::community::Epoch(1), &root1), &[b2],
4610 ).unwrap();
4611 relay.inject(&e1, &community.relays);
4612 relay.inject(&e2, &community.relays);
4613
4614 let reached = catch_up_server_root(&relay, &community).await.unwrap();
4615 assert_eq!(reached.epoch, 1, "stops at the last base epoch I was a recipient of");
4616 assert!(reached.removed, "excluded by an AUTHORIZED (owner) base rotation → flagged removed so the caller erases");
4617 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4618 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(1));
4619 }
4620
4621 #[tokio::test]
4622 async fn catch_up_is_a_noop_with_no_rotations() {
4623 let (_tmp, _guard) = init_test_db();
4624 let owner = Keys::generate();
4625 let me = Keys::generate();
4626 become_local(&me);
4627 let community = saved_community_owned_by(&owner);
4628 let relay = MemoryRelay::new(); let reached = catch_up_channel_rekeys(&relay, &community, &community.channels[0].id).await.unwrap();
4630 assert_eq!(reached, 0, "no rotations → stays at the held epoch");
4631 }
4632
4633 #[tokio::test]
4634 async fn catch_up_stops_when_removed_midway() {
4635 let (_tmp, _guard) = init_test_db();
4638 let owner = Keys::generate();
4639 let me = Keys::generate();
4640 become_local(&me);
4641 let community = saved_community_owned_by(&owner);
4642 let channel_id = community.channels[0].id;
4643 let chan = &community.channels[0];
4644 let scope = super::super::derive::RekeyScope::Channel(channel_id);
4645 let relay = MemoryRelay::new();
4646
4647 let k1 = [0x11u8; 32];
4649 let b1 = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &k1).unwrap();
4650 let e1 = super::super::rekey::build_channel_rekey_event(
4651 &Keys::generate(), &owner, community.server_root_key.as_bytes(), &channel_id,
4652 crate::community::Epoch(1), crate::community::Epoch(0),
4653 &super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), chan.key.as_bytes()), &[b1],
4654 ).unwrap();
4655 let other = Keys::generate();
4657 let b2 = super::super::rekey::build_rekey_blob(owner.secret_key(), &other.public_key(), scope, crate::community::Epoch(2), &[0x22u8; 32]).unwrap();
4658 let e2 = super::super::rekey::build_channel_rekey_event(
4659 &Keys::generate(), &owner, community.server_root_key.as_bytes(), &channel_id,
4660 crate::community::Epoch(2), crate::community::Epoch(1),
4661 &super::super::rekey::epoch_key_commitment(crate::community::Epoch(1), &k1), &[b2],
4662 ).unwrap();
4663 relay.inject(&e1, &community.relays);
4664 relay.inject(&e2, &community.relays);
4665
4666 let reached = catch_up_channel_rekeys(&relay, &community, &channel_id).await.unwrap();
4667 assert_eq!(reached, 1, "stops at the last epoch I was a recipient of");
4668 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4669 assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(1));
4670 }
4671
4672 #[tokio::test]
4673 async fn rotate_channel_rejects_unauthorized() {
4674 let (_tmp, _guard) = init_test_db();
4675 let owner = Keys::generate();
4676 let rogue = Keys::generate();
4677 become_local(&rogue); let community = saved_community_owned_by(&owner);
4679 let relay = MemoryRelay::new();
4680 assert!(
4681 rotate_channel(&relay, &community, &community.channels[0].id, &[], community.server_root_key.as_bytes()).await.is_err(),
4682 "a non-authorized member cannot rotate"
4683 );
4684 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4686 assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(0));
4687 }
4688
4689 #[tokio::test]
4692 async fn rotate_server_root_publishes_recoverable_rekey_and_advances_base() {
4693 use crate::community::derive::{base_rekey_pseudonym, recipient_pseudonym};
4694 use crate::community::rekey::{open_rekey_blob, open_rekey_event, rekey_pairwise_secret};
4695 let (_tmp, _guard) = init_test_db();
4696 let owner = Keys::generate();
4697 become_local(&owner); let community = saved_community_owned_by(&owner);
4699 let genesis_root = *community.server_root_key.as_bytes();
4700 let member = Keys::generate();
4701 let relay = MemoryRelay::new();
4702
4703 let new_epoch = rotate_server_root(&relay, &community, &[member.public_key()]).await.expect("rotate base");
4704 assert_eq!(new_epoch, 1);
4705
4706 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4708 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(1));
4709 assert_ne!(reloaded.server_root_key.as_bytes(), &genesis_root, "base root is fresh-random, not the genesis");
4710
4711 let addr = base_rekey_pseudonym(&crate::community::ServerRootKey(genesis_root), &community.id, crate::community::Epoch(1)).to_hex();
4713 let found = relay
4714 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() }, &community.relays)
4715 .await
4716 .unwrap();
4717 assert_eq!(found.len(), 1, "base rekey addressable by its prior-root pseudonym");
4718 let parsed = open_rekey_event(&found[0], &genesis_root).unwrap();
4719 assert!(matches!(parsed.scope, crate::community::derive::RekeyScope::ServerRoot));
4720 assert_eq!(parsed.rotator, owner.public_key());
4721 assert_eq!(parsed.blobs.len(), 2, "member + me (multi-device)");
4722
4723 let secret = rekey_pairwise_secret(member.secret_key(), &parsed.rotator).unwrap();
4725 let loc = recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
4726 let mine = parsed.blobs.iter().find(|b| b.locator == loc).expect("member's blob present");
4727 let recovered = open_rekey_blob(member.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, mine).unwrap();
4728 assert_eq!(reloaded.server_root_key.as_bytes(), &recovered, "member's recovered root == owner's advanced base head");
4729 }
4730
4731 #[tokio::test]
4732 async fn rotate_server_root_failed_publish_leaves_base_unadvanced() {
4733 let (_tmp, _guard) = init_test_db();
4734 let owner = Keys::generate();
4735 become_local(&owner);
4736 let community = saved_community_owned_by(&owner);
4737 let member = Keys::generate();
4738 assert!(rotate_server_root(&FailingRelay, &community, &[member.public_key()]).await.is_err());
4739 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4740 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(0), "base head stays put on publish failure");
4741 }
4742
4743 #[tokio::test]
4744 async fn rotate_server_root_dedups_self_in_recipients() {
4745 use crate::community::rekey::open_rekey_event;
4747 let (_tmp, _guard) = init_test_db();
4748 let owner = Keys::generate();
4749 become_local(&owner);
4750 let community = saved_community_owned_by(&owner);
4751 let relay = MemoryRelay::new();
4752 rotate_server_root(&relay, &community, &[owner.public_key()]).await.unwrap();
4753 let addr = crate::community::derive::base_rekey_pseudonym(
4754 &crate::community::ServerRootKey(*community.server_root_key.as_bytes()), &community.id, crate::community::Epoch(1),
4755 )
4756 .to_hex();
4757 let found = relay
4758 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() }, &community.relays)
4759 .await
4760 .unwrap();
4761 let parsed = open_rekey_event(&found[0], community.server_root_key.as_bytes()).unwrap();
4762 assert_eq!(parsed.blobs.len(), 1, "self listed in recipients yields exactly one blob, not two");
4763 }
4764
4765 #[tokio::test]
4766 async fn rotate_server_root_rejects_unauthorized() {
4767 let (_tmp, _guard) = init_test_db();
4768 let owner = Keys::generate();
4769 let rogue = Keys::generate();
4770 become_local(&rogue); let community = saved_community_owned_by(&owner);
4772 let relay = MemoryRelay::new();
4773 assert!(rotate_server_root(&relay, &community, &[]).await.is_err(), "a non-BAN member cannot rotate the base");
4774 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4775 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(0));
4776 }
4777
4778 #[tokio::test]
4779 async fn rotate_server_root_reanchors_the_control_plane_to_the_new_epoch() {
4780 let (_tmp, _guard) = init_test_db();
4783 let relay = MemoryRelay::new();
4784 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
4787 let cid = community.id.to_hex();
4788 assert_eq!(crate::db::community::edition_head_entity_ids(&cid).unwrap().len(), 3);
4789
4790 let member = Keys::generate();
4791 assert_eq!(rotate_server_root(&relay, &community, &[member.public_key()]).await.unwrap(), 1);
4792 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
4793 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(1), "base head advanced");
4794
4795 let z = crate::community::roster::control_pseudonym(&reloaded.server_root_key, &community.id, crate::community::Epoch(1));
4797 let evs = relay
4798 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], ..Default::default() }, &community.relays)
4799 .await
4800 .unwrap();
4801 let inners: Vec<_> = evs
4802 .iter()
4803 .filter_map(|o| crate::community::roster::open_control_edition(o, &reloaded.server_root_key).ok())
4804 .collect();
4805 let folded = crate::community::roster::fold_roster(&inners, &community.id, &Default::default());
4806 assert!(!folded.roles.roles.is_empty(), "control plane re-anchored at the new epoch as part of the rotation");
4807 }
4808
4809 #[tokio::test]
4810 async fn admin_refounding_carries_heads_verbatim_preserving_owner_and_peer_roles() {
4811 use crate::community::roles::Permissions;
4815 let (_tmp, _guard) = init_test_db();
4816 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
4817 let owner_hex = owner.public_key().to_hex();
4818 let relay = MemoryRelay::new();
4819 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
4820 let cid = community.id.to_hex();
4821 let admin_role = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
4822
4823 let alice = Keys::generate();
4825 let bob = Keys::generate();
4826 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role.clone()]).await.unwrap();
4827 set_member_grant(&relay, &community, &bob.public_key().to_hex(), vec![admin_role.clone()]).await.unwrap();
4828 let _ = fetch_and_apply_control(&relay, &community).await;
4829 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4830
4831 let mut edited = community.clone();
4834 edited.name = "HQ renamed".into();
4835 republish_community_metadata(&relay, &edited).await.unwrap();
4836 let _ = fetch_and_apply_control(&relay, &community).await;
4837 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4838 assert!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0 >= 2, "GroupRoot now above v1");
4839
4840 become_local(&alice);
4842 let new_epoch = rotate_server_root(&relay, &community, &[owner.public_key(), bob.public_key()]).await.unwrap();
4843 assert_eq!(new_epoch, 1);
4844 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
4845 assert_eq!(community.server_root_epoch, crate::community::Epoch(1));
4846
4847 let z = crate::community::roster::control_pseudonym(&community.server_root_key, &community.id, crate::community::Epoch(1));
4849 let evs = relay.fetch(&Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], ..Default::default() }, &community.relays).await.unwrap();
4850 let inners: Vec<_> = evs.iter().filter_map(|o| crate::community::roster::open_control_edition(o, &community.server_root_key).ok()).collect();
4851 let folded = crate::community::roster::fold_roster(&inners, &community.id, &Default::default());
4852 let authed = crate::community::roster::authorize_delegation(&folded, Some(&owner_hex));
4853 assert!(authed.is_authorized(&alice.public_key().to_hex(), Some(&owner_hex), Permissions::BAN), "alice (re-founder) still admin");
4854 assert!(authed.is_authorized(&bob.public_key().to_hex(), Some(&owner_hex), Permissions::BAN), "bob (peer admin) NOT demoted by alice's re-founding");
4855 let new_owner = folded.root_meta.as_ref().and_then(|m| m.owner_attestation.as_ref())
4856 .and_then(|j| Event::from_json(j).ok()).map(|e| e.pubkey.to_hex());
4857 assert_eq!(new_owner.as_deref(), Some(owner_hex.as_str()), "owner deed carried verbatim — ownership intact after an admin re-founding");
4858 assert_eq!(folded.root_meta.as_ref().map(|m| m.name.as_str()), Some("HQ renamed"),
4859 "the >v1 GroupRoot head carried verbatim (content preserved across the re-founding)");
4860 let mut per_entity: std::collections::HashMap<[u8; 32], usize> = std::collections::HashMap::new();
4862 for i in &inners {
4863 if let Ok(p) = crate::community::edition::parse_edition_inner(i) { *per_entity.entry(p.entity_id).or_default() += 1; }
4864 }
4865 assert!(per_entity.values().all(|&c| c == 1), "one edition per entity at the new epoch (compacted)");
4866 }
4867
4868 #[tokio::test]
4872 async fn admin_write_blocked_when_isolated() {
4873 let (_tmp, _guard) = init_test_db();
4874 let me = Keys::generate();
4875 become_local(&me);
4876 let community = saved_community_owned_by(&me);
4877 let cid = community.id.to_hex();
4878 crate::db::community::set_edition_head_with_id(&cid, &cid, 1, &[1u8; 32], &[1u8; 32]).unwrap();
4880 crate::db::community::set_read_cut_target_epoch(&cid, 1).unwrap();
4881 let err = reseal_base_to_observed(&FailingRelay, &community).await.unwrap_err();
4883 assert!(err.contains("offline") || err.contains("can't reach any relay"),
4884 "isolated admin write must fail closed, got: {err}");
4885 assert_eq!(crate::db::community::load_community(&community.id).unwrap().unwrap().server_root_epoch,
4887 crate::community::Epoch(0), "no rotation while isolated");
4888 }
4889
4890 #[tokio::test]
4893 async fn refounding_rotates_channel_keys_too() {
4894 let (_tmp, _guard) = init_test_db();
4895 let relay = MemoryRelay::new();
4896 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
4897 let channel_id = community.channels[0].id;
4898 assert_eq!(community.channels[0].epoch, crate::community::Epoch(0));
4899 assert_eq!(community.server_root_epoch, crate::community::Epoch(0));
4900
4901 run_read_cut(&relay, &community, true).await.unwrap();
4902
4903 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
4904 assert_eq!(after.server_root_epoch, crate::community::Epoch(1), "base rotated");
4905 let ch = after.channels.iter().find(|c| c.id == channel_id).unwrap();
4906 assert_eq!(ch.epoch, crate::community::Epoch(1), "channel key rotated too (O2)");
4907 assert_eq!(crate::db::community::channel_rekeyed_at_server_epoch(&community.id.to_hex(), &channel_id.to_hex()).unwrap(),
4908 1, "channel marked rekeyed for the new base epoch");
4909 assert!(!crate::db::community::get_read_cut_pending(&community.id.to_hex()).unwrap(),
4910 "a complete read-cut clears the pending flag");
4911 }
4912
4913 #[tokio::test]
4918 async fn read_cut_resumes_without_double_base_rotation_after_channel_failure() {
4919 struct ChannelRekeyFails {
4923 inner: MemoryRelay,
4924 rekeys: std::sync::atomic::AtomicUsize,
4925 fail_channel: std::sync::atomic::AtomicBool,
4926 }
4927 #[async_trait::async_trait]
4928 impl Transport for ChannelRekeyFails {
4929 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> { self.inner.publish(e, r).await }
4930 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
4931 if e.kind.as_u16() == event_kind::COMMUNITY_REKEY {
4932 let n = self.rekeys.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
4933 if n >= 1 && self.fail_channel.load(std::sync::atomic::Ordering::Relaxed) {
4934 return Err("channel rekey relay down".into());
4935 }
4936 }
4937 self.inner.publish_durable(e, r).await
4938 }
4939 async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> { self.inner.fetch(q, r).await }
4940 }
4941 let (_tmp, _guard) = init_test_db();
4942 let relay = ChannelRekeyFails {
4943 inner: MemoryRelay::new(),
4944 rekeys: std::sync::atomic::AtomicUsize::new(0),
4945 fail_channel: std::sync::atomic::AtomicBool::new(true),
4946 };
4947 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
4948 let channel_id = community.channels[0].id;
4949 let cid = community.id.to_hex();
4950 let ch_hex = channel_id.to_hex();
4951
4952 assert!(run_read_cut(&relay, &community, true).await.is_err(), "the channel failure surfaces an error");
4954 let mid = crate::db::community::load_community(&community.id).unwrap().unwrap();
4955 assert_eq!(mid.server_root_epoch, crate::community::Epoch(1), "base advanced exactly once");
4956 assert_eq!(mid.channels.iter().find(|c| c.id == channel_id).unwrap().epoch, crate::community::Epoch(0),
4957 "channel NOT rotated (its rekey failed)");
4958 assert!(crate::db::community::get_read_cut_pending(&cid).unwrap(), "cut left pending after the failure");
4959 assert_eq!(crate::db::community::get_read_cut_target_epoch(&cid).unwrap(), 1, "target recorded durably");
4960 assert_eq!(crate::db::community::channel_rekeyed_at_server_epoch(&cid, &ch_hex).unwrap(), 0,
4961 "channel not yet marked for this cut");
4962
4963 relay.fail_channel.store(false, std::sync::atomic::Ordering::Relaxed);
4965 retry_pending_read_cut(&relay, &mid).await.unwrap();
4966 let done = crate::db::community::load_community(&community.id).unwrap().unwrap();
4967 assert_eq!(done.server_root_epoch, crate::community::Epoch(1),
4968 "base NOT rotated again — resumed at the same epoch (no double base rotation)");
4969 assert_eq!(done.channels.iter().find(|c| c.id == channel_id).unwrap().epoch, crate::community::Epoch(1),
4970 "the un-rotated channel finished on resume");
4971 assert_eq!(crate::db::community::channel_rekeyed_at_server_epoch(&cid, &ch_hex).unwrap(), 1,
4972 "channel marked rekeyed for the cut epoch");
4973 assert!(!crate::db::community::get_read_cut_pending(&cid).unwrap(), "pending cleared after the resume completes");
4974 }
4975
4976 #[tokio::test]
4977 async fn rotate_server_root_aborts_when_the_snapshot_does_not_land() {
4978 struct ControlPublishFails { inner: MemoryRelay, fail: std::sync::atomic::AtomicBool }
4983 #[async_trait::async_trait]
4984 impl Transport for ControlPublishFails {
4985 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> { self.inner.publish(e, r).await }
4986 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
4987 if self.fail.load(std::sync::atomic::Ordering::Relaxed) && e.kind.as_u16() == event_kind::COMMUNITY_CONTROL {
4988 return Err("control relay down".into());
4989 }
4990 self.inner.publish_durable(e, r).await
4991 }
4992 async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> { self.inner.fetch(q, r).await }
4993 }
4994 let (_tmp, _guard) = init_test_db();
4995 let relay = ControlPublishFails { inner: MemoryRelay::new(), fail: std::sync::atomic::AtomicBool::new(false) };
4996 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
4998 relay.fail.store(true, std::sync::atomic::Ordering::Relaxed);
4999
5000 assert!(
5001 rotate_server_root(&relay, &community, &[]).await.is_err(),
5002 "a snapshot whose editions can't be re-published must abort the rotation"
5003 );
5004 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5005 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(0), "base head NOT advanced when the snapshot doesn't land");
5006 }
5007
5008 #[tokio::test]
5009 async fn acquire_before_commit_a_reanchor_fetch_miss_publishes_no_base_rekey() {
5010 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
5015 struct ReanchorFetchEmpty { inner: MemoryRelay, drop_control: AtomicBool, base_rekeys: AtomicUsize }
5016 #[async_trait::async_trait]
5017 impl Transport for ReanchorFetchEmpty {
5018 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> { self.inner.publish(e, r).await }
5019 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> {
5020 if e.kind.as_u16() == event_kind::COMMUNITY_REKEY {
5021 self.base_rekeys.fetch_add(1, Ordering::Relaxed);
5022 }
5023 self.inner.publish_durable(e, r).await
5024 }
5025 async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> {
5026 if self.drop_control.load(Ordering::Relaxed) && q.kinds.iter().any(|k| *k == event_kind::COMMUNITY_CONTROL) {
5027 return Ok(vec![]); }
5029 self.inner.fetch(q, r).await
5030 }
5031 }
5032 let (_tmp, _guard) = init_test_db();
5033 let relay = ReanchorFetchEmpty { inner: MemoryRelay::new(), drop_control: AtomicBool::new(false), base_rekeys: AtomicUsize::new(0) };
5034 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5035 relay.drop_control.store(true, Ordering::Relaxed);
5036
5037 assert!(rotate_server_root(&relay, &community, &[]).await.is_err(),
5038 "a re-anchor fetch miss must abort the rotation");
5039 assert_eq!(relay.base_rekeys.load(Ordering::Relaxed), 0,
5040 "the base rekey must NOT be published when the pre-publish fetch gate trips (acquire-before-commit)");
5041 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5042 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(0), "base head NOT advanced");
5043 }
5044
5045 #[tokio::test]
5048 async fn reanchor_carries_role_and_grant_to_the_new_epoch_under_the_new_root() {
5049 let (_tmp, _guard) = init_test_db();
5050 let relay = MemoryRelay::new();
5051 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5053 let cid = community.id.to_hex();
5054 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5055 let member = Keys::generate();
5056 set_member_grant(&relay, &community, &member.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
5058 let _ = fetch_and_apply_control(&relay, &community).await;
5059 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5060
5061 let new_root = [0x99u8; 32];
5063 let snap = reanchor_control_plane(&relay, &community, &new_root, crate::community::Epoch(1)).await.unwrap();
5064 assert!(snap.iter().all(|e| e.published), "every snapshot edition published");
5065 assert_eq!(snap.len(), 4, "GroupRoot + channel + Admin role + grant compacted to v1");
5066
5067 let new_z = crate::community::roster::control_pseudonym(
5069 &crate::community::ServerRootKey(new_root), &community.id, crate::community::Epoch(1),
5070 );
5071 let after = relay
5072 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![new_z], ..Default::default() }, &community.relays)
5073 .await
5074 .unwrap();
5075 let inners: Vec<_> = after
5076 .iter()
5077 .filter_map(|o| crate::community::roster::open_control_edition(o, &crate::community::ServerRootKey(new_root)).ok())
5078 .collect();
5079 let folded = crate::community::roster::fold_roster(&inners, &community.id, &Default::default());
5080 assert!(!folded.roles.roles.is_empty(), "Admin role reachable at the new epoch");
5081 assert!(
5082 folded.roles.grants.iter().any(|g| g.member == member.public_key().to_hex()),
5083 "grant carried to the new epoch under the new root"
5084 );
5085 }
5086
5087 #[tokio::test]
5088 async fn grant_after_a_rekey_survives_the_fold_at_the_new_epoch() {
5089 use crate::community::roles::Permissions;
5095 let (_tmp, _guard) = init_test_db();
5096 let relay = MemoryRelay::new();
5097 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5098 let cid = community.id.to_hex();
5099 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
5100 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5101
5102 rotate_server_root(&relay, &community, &[owner.public_key()]).await.expect("rotate base");
5104 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5105 assert_eq!(community.server_root_epoch, crate::community::Epoch(1), "advanced to the new epoch");
5106
5107 let alice = "aa".repeat(32);
5109 set_member_grant(&relay, &community, &alice, vec![admin_role_id]).await.unwrap();
5110
5111 let roster = fetch_and_apply_roles(&relay, &community).await.unwrap();
5113 assert!(
5114 roster.has_permission(&alice, Permissions::BAN),
5115 "post-rekey grant survives — Alice is Admin at the new epoch (pre-fix: dropped, role unreachable)"
5116 );
5117 assert_eq!(roster.highest_position(&alice), Some(1));
5118 }
5119
5120 #[tokio::test]
5124 async fn demote_re_asserts_the_demoted_members_metadata_head() {
5125 let (_tmp, _guard) = init_test_db();
5126 let relay = MemoryRelay::new();
5127 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5128 let cid = community.id.to_hex();
5129 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
5130 let admin_role = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5131 let alice = Keys::generate();
5132 let alice_hex = alice.public_key().to_hex();
5133
5134 set_member_grant(&relay, &community, &alice_hex, vec![admin_role]).await.unwrap();
5135 become_local(&alice);
5137 let mut as_alice = crate::db::community::load_community(&community.id).unwrap().unwrap();
5138 as_alice.name = "Alice's HQ".into();
5139 republish_community_metadata(&relay, &as_alice).await.unwrap();
5140 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5141 assert_eq!(
5142 fetch_control_folded(&relay, &community).await.unwrap().root_author.map(|a| a.to_hex()),
5143 Some(alice_hex.clone()), "alice heads the GroupRoot after her edit",
5144 );
5145
5146 become_local(&owner);
5148 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5149 set_member_grant(&relay, &community, &alice_hex, vec![]).await.unwrap();
5150
5151 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5152 let folded = fetch_control_folded(&relay, &community).await.unwrap();
5153 assert_eq!(folded.root_author.map(|a| a.to_hex()), Some(owner.public_key().to_hex()),
5154 "the demote re-asserted the GroupRoot under the owner");
5155 assert_eq!(folded.root_meta.as_ref().unwrap().name, "Alice's HQ",
5156 "the re-assert preserves the demoted member's content");
5157 }
5158
5159 #[tokio::test]
5162 async fn demote_skips_reassert_when_member_does_not_head() {
5163 let (_tmp, _guard) = init_test_db();
5164 let relay = MemoryRelay::new();
5165 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5166 let cid = community.id.to_hex();
5167 let admin_role = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5168 let alice = Keys::generate();
5169 let alice_hex = alice.public_key().to_hex();
5170
5171 set_member_grant(&relay, &community, &alice_hex, vec![admin_role]).await.unwrap();
5172 let mut c = crate::db::community::load_community(&community.id).unwrap().unwrap();
5174 c.name = "Owner's HQ".into();
5175 republish_community_metadata(&relay, &c).await.unwrap();
5176 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5177 let before = fetch_control_folded(&relay, &community).await.unwrap().root_head.unwrap().version;
5178
5179 set_member_grant(&relay, &community, &alice_hex, vec![]).await.unwrap();
5180 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5181 let after = fetch_control_folded(&relay, &community).await.unwrap().root_head.unwrap().version;
5182 assert_eq!(after, before, "no re-assert published — the demoted member didn't head the GroupRoot");
5183 }
5184
5185 #[tokio::test]
5186 async fn reanchor_carries_the_banlist_edition_to_the_new_epoch() {
5187 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 carol = "cc".repeat(32);
5193 publish_banlist(&relay, &community, &[carol.clone()]).await.unwrap();
5195 let _ = fetch_and_apply_control(&relay, &community).await;
5196 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
5197
5198 let new_root = [0x99u8; 32];
5200 let n = reanchor_control_plane(&relay, &community, &new_root, crate::community::Epoch(1)).await.unwrap();
5201 assert!(n.iter().all(|e| e.published), "every snapshot edition published");
5202 assert_eq!(n.len(), 4, "GroupRoot + channel + Admin role + banlist compacted to v1");
5203
5204 let new_z = crate::community::roster::control_pseudonym(
5206 &crate::community::ServerRootKey(new_root), &community.id, crate::community::Epoch(1),
5207 );
5208 let after = relay
5209 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![new_z], ..Default::default() }, &community.relays)
5210 .await
5211 .unwrap();
5212 let inners: Vec<_> = after
5213 .iter()
5214 .filter_map(|o| crate::community::roster::open_control_edition(o, &crate::community::ServerRootKey(new_root)).ok())
5215 .collect();
5216 let folded = crate::community::roster::fold_roster(&inners, &community.id, &Default::default());
5217 assert_eq!(folded.banned, vec![carol], "banlist reachable at the new epoch under the new root");
5218 }
5219
5220 fn owner_base_rekey(
5225 owner: &Keys, community: &Community, recipient_pk: &nostr_sdk::PublicKey, new_epoch: u64, new_root: &[u8; 32],
5226 ) -> super::super::rekey::ParsedRekey {
5227 let prev = community.server_root_epoch.0;
5228 let blob = super::super::rekey::build_rekey_blob(
5229 owner.secret_key(), recipient_pk, super::super::derive::RekeyScope::ServerRoot, crate::community::Epoch(new_epoch), new_root,
5230 )
5231 .unwrap();
5232 let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(prev), community.server_root_key.as_bytes());
5233 let outer = super::super::rekey::build_server_root_rekey_event(
5234 &Keys::generate(), owner, community.server_root_key.as_bytes(), &community.id,
5235 crate::community::Epoch(new_epoch), crate::community::Epoch(prev), &commit, &[blob],
5236 )
5237 .unwrap();
5238 super::super::rekey::open_rekey_event(&outer, community.server_root_key.as_bytes()).unwrap()
5239 }
5240
5241 #[test]
5242 fn apply_server_root_rekey_recovers_new_root_and_advances_base() {
5243 let (_tmp, _guard) = init_test_db();
5244 let owner = Keys::generate();
5245 let me = Keys::generate();
5246 become_local(&me);
5247 let community = saved_community_owned_by(&owner);
5248 let cid = community.id.to_hex();
5249 let new_root = [0xCDu8; 32];
5250
5251 let parsed = owner_base_rekey(&owner, &community, &me.public_key(), 1, &new_root);
5252 assert_eq!(apply_server_root_rekey(&community, &parsed).unwrap(), RekeyOutcome::Applied { head_advanced: true });
5253
5254 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5255 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(1));
5256 assert_eq!(reloaded.server_root_key.as_bytes(), &new_root, "base head advanced to the new root");
5257 assert!(crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 0).unwrap().is_some());
5259 assert_eq!(crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 1).unwrap(), Some(new_root));
5260 }
5261
5262 #[test]
5263 fn apply_server_root_rekey_not_a_recipient_leaves_base_unchanged() {
5264 let (_tmp, _guard) = init_test_db();
5265 let owner = Keys::generate();
5266 let me = Keys::generate();
5267 become_local(&me);
5268 let community = saved_community_owned_by(&owner);
5269 let other = Keys::generate(); let parsed = owner_base_rekey(&owner, &community, &other.public_key(), 1, &[0x11u8; 32]);
5271 assert_eq!(apply_server_root_rekey(&community, &parsed).unwrap(), RekeyOutcome::NotARecipient);
5272 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5273 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(0), "removed-from-base member's head unchanged");
5274 }
5275
5276 #[test]
5277 fn apply_server_root_rekey_rejects_rotator_without_ban() {
5278 let (_tmp, _guard) = init_test_db();
5279 let owner = Keys::generate();
5280 let me = Keys::generate();
5281 become_local(&me);
5282 let community = saved_community_owned_by(&owner);
5283 let rogue = Keys::generate();
5285 let parsed = owner_base_rekey(&rogue, &community, &me.public_key(), 1, &[0x22u8; 32]);
5286 assert!(apply_server_root_rekey(&community, &parsed).is_err(), "unauthorized base rotation rejected");
5287 }
5288
5289 #[test]
5290 fn apply_server_root_rekey_reorgs_onto_authorized_chain_despite_prior_mismatch() {
5291 let (_tmp, _guard) = init_test_db();
5296 let owner = Keys::generate();
5297 let me = Keys::generate();
5298 become_local(&me);
5299 let community = saved_community_owned_by(&owner);
5300 let blob = super::super::rekey::build_rekey_blob(
5301 owner.secret_key(), &me.public_key(), super::super::derive::RekeyScope::ServerRoot, crate::community::Epoch(1), &[0x33u8; 32],
5302 )
5303 .unwrap();
5304 let bad = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &[0xFFu8; 32]);
5306 let outer = super::super::rekey::build_server_root_rekey_event(
5307 &Keys::generate(), &owner, community.server_root_key.as_bytes(), &community.id,
5308 crate::community::Epoch(1), crate::community::Epoch(0), &bad, &[blob],
5309 )
5310 .unwrap();
5311 let parsed = super::super::rekey::open_rekey_event(&outer, community.server_root_key.as_bytes()).unwrap();
5312 let outcome = apply_server_root_rekey(&community, &parsed);
5313 assert!(
5314 matches!(outcome, Ok(RekeyOutcome::Applied { .. })),
5315 "an authorized base chain must be adopted (reorg), not rejected as foreign; got {outcome:?}"
5316 );
5317 }
5318
5319 #[test]
5320 fn apply_server_root_rekey_catchup_archives_without_regressing_base_head() {
5321 let (_tmp, _guard) = init_test_db();
5324 let owner = Keys::generate();
5325 let me = Keys::generate();
5326 become_local(&me);
5327 let community = saved_community_owned_by(&owner);
5328 let cid = community.id.to_hex();
5329
5330 let r5 = [0x55u8; 32];
5331 let p5 = owner_base_rekey(&owner, &community, &me.public_key(), 5, &r5);
5332 assert_eq!(apply_server_root_rekey(&community, &p5).unwrap(), RekeyOutcome::Applied { head_advanced: true });
5333 let r3 = [0x33u8; 32];
5334 let p3 = owner_base_rekey(&owner, &community, &me.public_key(), 3, &r3);
5335 assert_eq!(apply_server_root_rekey(&community, &p3).unwrap(), RekeyOutcome::Applied { head_advanced: false });
5336
5337 assert_eq!(crate::db::community::held_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 3).unwrap(), Some(r3));
5338 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5339 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(5), "base head stayed at newest");
5340 assert_eq!(reloaded.server_root_key.as_bytes(), &r5);
5341 }
5342
5343 #[test]
5344 fn apply_server_root_rekey_authorizes_a_granted_ban_admin() {
5345 let (_tmp, _guard) = init_test_db();
5349 let owner = Keys::generate();
5350 let me = Keys::generate();
5351 become_local(&me);
5352 let community = saved_community_owned_by(&owner);
5353 let cid = community.id.to_hex();
5354
5355 let admin = Keys::generate();
5356 let role_id = "d".repeat(64);
5357 let roster = crate::community::roles::CommunityRoles {
5358 roles: vec![crate::community::roles::Role::admin(role_id.clone())],
5359 grants: vec![crate::community::roles::MemberGrant { member: admin.public_key().to_hex(), role_ids: vec![role_id] }],
5360 };
5361 crate::db::community::set_community_roles(&cid, &roster, 1).unwrap();
5362
5363 let parsed = owner_base_rekey(&admin, &community, &me.public_key(), 1, &[0x77u8; 32]);
5364 assert_eq!(
5365 apply_server_root_rekey(&community, &parsed).unwrap(),
5366 RekeyOutcome::Applied { head_advanced: true },
5367 "a BAN-granted admin (not the owner) can rotate the base"
5368 );
5369 }
5370
5371 #[test]
5372 fn apply_server_root_rekey_accepts_when_prior_root_not_held() {
5373 let (_tmp, _guard) = init_test_db();
5376 let owner = Keys::generate();
5377 let me = Keys::generate();
5378 become_local(&me);
5379 let community = saved_community_owned_by(&owner);
5380
5381 let new_root = [0x99u8; 32];
5382 let blob = super::super::rekey::build_rekey_blob(
5383 owner.secret_key(), &me.public_key(), super::super::derive::RekeyScope::ServerRoot, crate::community::Epoch(5), &new_root,
5384 )
5385 .unwrap();
5386 let commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(4), &[0xEEu8; 32]);
5388 let outer = super::super::rekey::build_server_root_rekey_event(
5389 &Keys::generate(), &owner, community.server_root_key.as_bytes(), &community.id,
5390 crate::community::Epoch(5), crate::community::Epoch(4), &commit, &[blob],
5391 )
5392 .unwrap();
5393 let parsed = super::super::rekey::open_rekey_event(&outer, community.server_root_key.as_bytes()).unwrap();
5394 assert_eq!(apply_server_root_rekey(&community, &parsed).unwrap(), RekeyOutcome::Applied { head_advanced: true });
5395 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5396 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(5));
5397 }
5398
5399 #[test]
5400 fn apply_server_root_rekey_rejects_channel_scope() {
5401 let (_tmp, _guard) = init_test_db();
5403 let owner = Keys::generate();
5404 let me = Keys::generate();
5405 become_local(&me);
5406 let community = saved_community_owned_by(&owner);
5407 let channel_parsed = owner_channel_rekey(&owner, &community, &me.public_key(), 1, &[0x44u8; 32]);
5408 assert!(apply_server_root_rekey(&community, &channel_parsed).is_err(), "channel scope rejected by base apply");
5409 }
5410
5411 #[test]
5412 fn apply_channel_rekey_not_a_recipient() {
5413 let (_tmp, _guard) = init_test_db();
5414 let owner = Keys::generate();
5415 let me = Keys::generate();
5416 become_local(&me);
5417 let community = saved_community_owned_by(&owner);
5418 let other = Keys::generate();
5420 let parsed = owner_channel_rekey(&owner, &community, &other.public_key(), 1, &[0x11u8; 32]);
5421 assert_eq!(apply_channel_rekey(&community, &parsed).unwrap(), RekeyOutcome::NotARecipient);
5422 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5424 assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(0));
5425 }
5426
5427 #[test]
5428 fn apply_channel_rekey_rejects_unauthorized_rotator() {
5429 let (_tmp, _guard) = init_test_db();
5430 let owner = Keys::generate();
5431 let me = Keys::generate();
5432 become_local(&me);
5433 let community = saved_community_owned_by(&owner);
5434 let rogue = Keys::generate();
5436 let parsed = owner_channel_rekey(&rogue, &community, &me.public_key(), 1, &[0x22u8; 32]);
5437 assert!(apply_channel_rekey(&community, &parsed).is_err(), "unauthorized rotation must be rejected");
5438 }
5439
5440 #[test]
5441 fn apply_channel_rekey_reorgs_onto_authorized_chain_despite_prior_mismatch() {
5442 let (_tmp, _guard) = init_test_db();
5449 let owner = Keys::generate();
5450 let me = Keys::generate();
5451 become_local(&me);
5452 let community = saved_community_owned_by(&owner);
5453 let chan = &community.channels[0];
5454 let scope = super::super::derive::RekeyScope::Channel(chan.id);
5455 let new_key = [0x33u8; 32];
5456 let blob = super::super::rekey::build_rekey_blob(owner.secret_key(), &me.public_key(), scope, crate::community::Epoch(1), &new_key).unwrap();
5457 let other_commit = super::super::rekey::epoch_key_commitment(crate::community::Epoch(0), &[0xFFu8; 32]);
5459 let outer = super::super::rekey::build_channel_rekey_event(
5460 &Keys::generate(), &owner, community.server_root_key.as_bytes(), &chan.id,
5461 crate::community::Epoch(1), crate::community::Epoch(0), &other_commit, &[blob],
5462 )
5463 .unwrap();
5464 let parsed = super::super::rekey::open_rekey_event(&outer, community.server_root_key.as_bytes()).unwrap();
5465 let outcome = apply_channel_rekey(&community, &parsed).unwrap();
5466 assert!(matches!(outcome, RekeyOutcome::Applied { .. }),
5467 "an authorized chain must be adopted (reorg), not rejected as foreign; got {outcome:?}");
5468 assert_eq!(crate::db::community::held_epoch_key(&community.id.to_hex(), &chan.id.to_hex(), 1).unwrap(), Some(new_key));
5469 }
5470
5471 #[test]
5472 fn apply_channel_rekey_catchup_archives_without_regressing_head() {
5473 let (_tmp, _guard) = init_test_db();
5474 let owner = Keys::generate();
5475 let me = Keys::generate();
5476 become_local(&me);
5477 let community = saved_community_owned_by(&owner);
5478 let cid = community.id.to_hex();
5479 let chan_hex = community.channels[0].id.to_hex();
5480
5481 let k5 = [0x55u8; 32];
5483 let p5 = owner_channel_rekey(&owner, &community, &me.public_key(), 5, &k5);
5484 assert_eq!(apply_channel_rekey(&community, &p5).unwrap(), RekeyOutcome::Applied { head_advanced: true });
5485 let k3 = [0x33u8; 32];
5487 let p3 = owner_channel_rekey(&owner, &community, &me.public_key(), 3, &k3);
5488 assert_eq!(apply_channel_rekey(&community, &p3).unwrap(), RekeyOutcome::Applied { head_advanced: false });
5489
5490 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 3).unwrap(), Some(k3), "old epoch archived");
5491 assert_eq!(crate::db::community::held_epoch_key(&cid, &chan_hex, 5).unwrap(), Some(k5));
5492 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
5493 assert_eq!(reloaded.channels[0].epoch, crate::community::Epoch(5), "head stayed at the newest epoch");
5494 assert_eq!(reloaded.channels[0].key.as_bytes(), &k5);
5495 }
5496
5497 #[tokio::test]
5498 async fn create_community_persists_and_publishes_metadata() {
5499 use crate::community::transport::Query;
5500 use crate::stored_event::event_kind;
5501
5502 let (_tmp, _guard) = init_test_db();
5503 let relay = MemoryRelay::new();
5504 let community = create_community(&relay, "Vector HQ", "general", vec!["r1".into()])
5505 .await
5506 .expect("create");
5507
5508 assert_eq!(community.name, "Vector HQ");
5510 assert_eq!(community.channels.len(), 1);
5511 assert_eq!(community.channels[0].name, "general");
5512
5513 let loaded = crate::db::community::load_community(&community.id).unwrap().expect("persisted");
5515 assert_eq!(loaded.channels[0].name, "general");
5516 assert_eq!(loaded.server_root_key.as_bytes(), community.server_root_key.as_bytes());
5517
5518 let meta_events = relay
5521 .fetch(
5522 &Query { kinds: vec![event_kind::APPLICATION_SPECIFIC], ..Default::default() },
5523 &community.relays,
5524 )
5525 .await
5526 .unwrap();
5527 assert!(meta_events.is_empty(), "no legacy 30078 metadata events");
5528
5529 let z = crate::community::roster::control_pseudonym(&community.server_root_key, &community.id, crate::community::Epoch(0));
5532 let control = relay
5533 .fetch(
5534 &Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], ..Default::default() },
5535 &community.relays,
5536 )
5537 .await
5538 .unwrap();
5539 assert_eq!(control.len(), 3, "GroupRoot + ChannelMetadata + Admin role editions");
5540 let owner_pk = crate::state::my_public_key().unwrap();
5541 let parsed: Vec<_> = control
5542 .iter()
5543 .filter_map(|o| crate::community::roster::open_control_edition(o, &community.server_root_key).ok())
5544 .filter_map(|i| crate::community::edition::parse_edition_inner(&i).ok())
5545 .collect();
5546 assert!(parsed.iter().all(|p| p.author == owner_pk), "every genesis edition authored by the owner");
5547 let root = parsed.iter().find(|p| p.entity_id == community.id.0).expect("GroupRoot edition");
5549 let root_meta: crate::community::metadata::CommunityMetadata = serde_json::from_str(&root.content).unwrap();
5550 assert_eq!(root_meta.name, "Vector HQ");
5551 assert!(root_meta.owner_attestation.is_some());
5552 let role: crate::community::roles::Role = parsed
5554 .iter()
5555 .find_map(|p| serde_json::from_str::<crate::community::roles::Role>(&p.content).ok().filter(|r| r.name == "Admin"))
5556 .expect("Admin role edition");
5557 assert_eq!(role.position, 1);
5558 assert!(role.permissions.contains(crate::community::roles::Permissions::ADMIN_ALL));
5559
5560 let cached = crate::db::community::get_community_roles(&community.id.to_hex()).unwrap();
5562 assert_eq!(cached.roles.len(), 1);
5563 assert!(cached.grants.is_empty(), "owner is implicit position 0, takes no grant");
5564 }
5565
5566 #[tokio::test]
5567 async fn role_grant_round_trips_through_relays_and_revokes() {
5568 use crate::community::roles::Permissions;
5569 let (_tmp, _guard) = init_test_db();
5570 let relay = MemoryRelay::new();
5571 let community = create_community(&relay, "HQ", "general", vec!["r1".into()])
5572 .await
5573 .expect("create");
5574 let cid = community.id.to_hex();
5575 let alice = "aa".repeat(32);
5576 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0]
5577 .role_id
5578 .clone();
5579
5580 set_member_grant(&relay, &community, &alice, vec![admin_role_id.clone()])
5582 .await
5583 .unwrap();
5584 assert!(
5585 crate::db::community::get_community_roles(&cid).unwrap().is_privileged(&alice),
5586 "local cache reflects the grant immediately"
5587 );
5588
5589 let roster = fetch_and_apply_roles(&relay, &community).await.unwrap();
5592 assert!(roster.has_permission(&alice, Permissions::BAN));
5593 assert!(roster.has_permission(&alice, Permissions::MANAGE_ROLES));
5594 assert_eq!(roster.roles.len(), 1);
5595 assert_eq!(roster.highest_position(&alice), Some(1));
5596
5597 set_member_grant(&relay, &community, &alice, vec![]).await.unwrap();
5599 let after = crate::db::community::get_community_roles(&cid).unwrap();
5600 assert!(!after.is_privileged(&alice), "revoked member holds no role");
5601 assert!(after.grants.is_empty(), "empty grant pruned");
5602 }
5603
5604 #[tokio::test]
5605 async fn admin_cannot_grant_a_peer_rank_role() {
5606 let (_tmp, _guard) = init_test_db();
5610 let relay = MemoryRelay::new();
5611 let community = create_community(&relay, "HQ", "general", vec!["r1".into()])
5612 .await
5613 .expect("create");
5614 let cid = community.id.to_hex();
5615 let admin_role_id =
5616 crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5617 let alice = Keys::generate();
5618 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id.clone()])
5620 .await
5621 .unwrap();
5622
5623 crate::state::set_my_public_key(alice.public_key());
5625 let bob = Keys::generate().public_key();
5626 let err = grant_role(&relay, &community, bob, &admin_role_id).await.unwrap_err();
5627 assert!(err.contains("below your own"), "peer-rank grant refused, got: {err}");
5628 }
5629
5630 #[tokio::test]
5631 async fn create_community_mints_a_verifiable_owner_attestation() {
5632 let (_tmp, _guard) = init_test_db();
5635 let me = crate::state::my_public_key().unwrap();
5636 let relay = MemoryRelay::new();
5637 let community = create_community(&relay, "HQ", "general", vec!["r1".into()])
5638 .await
5639 .expect("create");
5640 let att = community.owner_attestation.as_ref().expect("attestation is mandatory");
5641 let proven = super::super::owner::verify_owner_attestation(att, &community.id.to_hex());
5642 assert_eq!(proven, Some(me), "the creator is the proven owner");
5643 assert_eq!(
5645 super::super::owner::verify_owner_attestation(att, &"f".repeat(64)),
5646 None,
5647 );
5648 }
5649
5650 #[tokio::test]
5651 async fn admin_cannot_ban_a_peer_admin() {
5652 let (_tmp, _guard) = init_test_db();
5656 let relay = MemoryRelay::new();
5657 let community = create_community(&relay, "HQ", "general", vec!["r1".into()])
5658 .await
5659 .expect("create");
5660 let cid = community.id.to_hex();
5661 let admin_role_id =
5662 crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5663 let alice = Keys::generate();
5664 let bob = Keys::generate();
5665 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id.clone()])
5667 .await
5668 .unwrap();
5669 set_member_grant(&relay, &community, &bob.public_key().to_hex(), vec![admin_role_id.clone()])
5670 .await
5671 .unwrap();
5672
5673 become_local(&alice);
5676 let err = publish_banlist(&relay, &community, &[bob.public_key().to_hex()])
5677 .await
5678 .unwrap_err();
5679 assert!(err.contains("outranks you"), "peer-admin ban refused, got: {err}");
5680 }
5681
5682 #[tokio::test]
5683 async fn roster_reconstructs_purely_from_relay() {
5684 let (_tmp, _guard) = init_test_db();
5688 let relay = MemoryRelay::new();
5689 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5690 let cid = community.id.to_hex();
5691 let admin_role_id =
5692 crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5693 let alice = "aa".repeat(32);
5694 set_member_grant(&relay, &community, &alice, vec![admin_role_id.clone()]).await.unwrap();
5695
5696 crate::db::community::set_community_roles(&cid, &crate::community::roles::CommunityRoles::default(), 0).unwrap();
5698 assert!(crate::db::community::get_community_roles(&cid).unwrap().roles.is_empty(), "cache wiped");
5699
5700 let roster = fetch_and_apply_roles(&relay, &community).await.unwrap();
5701 assert!(roster.is_admin(&alice), "roster reconstructed from relay editions, not the cache");
5702 assert_eq!(roster.roles.len(), 1, "the Admin role edition folded back");
5703 }
5704
5705 #[tokio::test]
5706 async fn admin_cannot_unban_a_peer_admin() {
5707 let (_tmp, _guard) = init_test_db();
5710 let relay = MemoryRelay::new();
5711 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5712 let cid = community.id.to_hex();
5713 let admin_role_id =
5714 crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5715 let alice = Keys::generate();
5716 let bob = Keys::generate();
5717 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id.clone()])
5718 .await
5719 .unwrap();
5720 set_member_grant(&relay, &community, &bob.public_key().to_hex(), vec![admin_role_id.clone()])
5721 .await
5722 .unwrap();
5723 crate::db::community::set_community_banlist(&cid, &[bob.public_key().to_hex()], 1000).unwrap();
5725
5726 become_local(&alice);
5728 let err = publish_banlist(&relay, &community, &[]).await.unwrap_err();
5729 assert!(err.contains("unban"), "unbanning a peer admin refused, got: {err}");
5730 }
5731
5732 #[tokio::test]
5733 async fn create_community_rejects_signer_identity_mismatch() {
5734 let (_tmp, _guard) = init_test_db(); let other = Keys::generate();
5739 crate::state::set_my_public_key(other.public_key()); let relay = MemoryRelay::new();
5741 let err = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap_err();
5742 assert!(err.contains("identity signer"), "signer mismatch refused, got: {err}");
5743 }
5744
5745 #[tokio::test]
5746 async fn banlist_newer_edition_applies_older_is_refused() {
5747 let (_tmp, _guard) = init_test_db();
5748 let relay = MemoryRelay::new();
5749 let community = create_community(&relay, "HQ", "general", vec!["r1".into()])
5750 .await
5751 .expect("create");
5752 let id_hex = community.id.to_hex();
5753 let banlist_entity = crate::simd::hex::bytes_to_hex_32(&crate::community::derive::banlist_locator(&community.id));
5754 let mallory = "aa".repeat(32);
5755 let bob = "bb".repeat(32);
5756
5757 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
5760 let inner = crate::community::roster::build_banlist_edition(&owner, &community.id, &[mallory.clone()], 1, None, 1000, None).unwrap();
5761 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
5762 relay.inject(&outer, &community.relays);
5763
5764 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
5766 assert_eq!(applied, vec![mallory.clone()]);
5767 let (head_v, _) = crate::db::community::get_edition_head(&id_hex, &banlist_entity).unwrap().unwrap();
5768 assert_eq!(head_v, 1, "banlist edition head advanced to v1");
5769
5770 crate::db::community::set_community_banlist(&id_hex, &[mallory.clone(), bob.clone()], 2).unwrap();
5773 crate::db::community::set_edition_head(&id_hex, &banlist_entity, 2, &[0x22u8; 32]).unwrap();
5774 let after = fetch_and_apply_banlist(&relay, &community).await.unwrap();
5775 assert_eq!(after, vec![mallory, bob], "older relay edition refused, local banlist preserved");
5776 }
5777
5778 #[tokio::test]
5779 async fn unauthorized_banlist_edition_is_rejected() {
5780 let (_tmp, _guard) = init_test_db();
5784 let relay = MemoryRelay::new();
5785 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5786 let bob = "bb".repeat(32);
5787
5788 let mallory = Keys::generate();
5790 let inner = crate::community::roster::build_banlist_edition(&mallory, &community.id, &[bob], 1, None, 1000, None).unwrap();
5791 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
5792 relay.inject(&outer, &community.relays);
5793
5794 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
5796 assert!(applied.is_empty(), "an unauthorized signer's banlist edition is rejected");
5797 }
5798
5799 #[tokio::test]
5800 async fn banlist_receiver_enforces_per_target_outrank() {
5801 let (_tmp, _guard) = init_test_db();
5804 let relay = MemoryRelay::new();
5805 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5806 let cid = community.id.to_hex();
5807 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5808 let alice = Keys::generate();
5809 let bob = Keys::generate();
5810 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id.clone()]).await.unwrap();
5812 set_member_grant(&relay, &community, &bob.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
5813
5814 let cite = authority_citation(&community, &alice.public_key().to_hex());
5817 let inner = crate::community::roster::build_banlist_edition(&alice, &community.id, &[bob.public_key().to_hex()], 1, None, 1000, cite.as_ref()).unwrap();
5818 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
5819 relay.inject(&outer, &community.relays);
5820
5821 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
5823 assert!(applied.is_empty(), "an admin can't ban a peer admin (receiver-side outrank)");
5824 }
5825
5826 #[tokio::test]
5827 async fn banlist_admin_bans_regular_member_applies() {
5828 let (_tmp, _guard) = init_test_db();
5831 let relay = MemoryRelay::new();
5832 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5833 let cid = community.id.to_hex();
5834 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5835 let alice = Keys::generate();
5836 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
5837
5838 let carol = "cc".repeat(32);
5839 let cite = authority_citation(&community, &alice.public_key().to_hex());
5841 let inner = crate::community::roster::build_banlist_edition(&alice, &community.id, &[carol.clone()], 1, None, 1000, cite.as_ref()).unwrap();
5842 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
5843 relay.inject(&outer, &community.relays);
5844
5845 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
5846 assert_eq!(applied, vec![carol], "an admin's ban of a regular member applies");
5847 }
5848
5849 #[tokio::test]
5850 async fn owner_banlist_needs_no_citation() {
5851 let (_tmp, _guard) = init_test_db();
5854 let relay = MemoryRelay::new();
5855 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5856 let victim = "cc".repeat(32);
5857
5858 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
5860 let inner = crate::community::roster::build_banlist_edition(&owner, &community.id, &[victim.clone()], 1, None, 1000, None).unwrap();
5861 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
5862 relay.inject(&outer, &community.relays);
5863
5864 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
5865 assert_eq!(applied, vec![victim], "an owner's uncited ban applies");
5866 }
5867
5868 #[tokio::test]
5869 async fn banlist_with_forged_citation_hash_is_rejected() {
5870 let (_tmp, _guard) = init_test_db();
5873 let relay = MemoryRelay::new();
5874 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5875 let cid = community.id.to_hex();
5876 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5877 let alice = Keys::generate();
5878 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
5879
5880 let carol = "cc".repeat(32);
5881 let mut cite = authority_citation(&community, &alice.public_key().to_hex()).unwrap();
5883 cite.edition_hash = [0xEE; 32];
5884 let inner = crate::community::roster::build_banlist_edition(&alice, &community.id, &[carol], 1, None, 1000, Some(&cite)).unwrap();
5885 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
5886 relay.inject(&outer, &community.relays);
5887
5888 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
5889 assert!(applied.is_empty(), "a forged-hash citation is rejected");
5890 }
5891
5892 #[tokio::test]
5893 async fn banlist_citing_unsynced_future_version_is_rejected() {
5894 let (_tmp, _guard) = init_test_db();
5898 let relay = MemoryRelay::new();
5899 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5900 let cid = community.id.to_hex();
5901 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5902 let alice = Keys::generate();
5903 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
5904
5905 let carol = "cc".repeat(32);
5906 let mut cite = authority_citation(&community, &alice.public_key().to_hex()).unwrap();
5907 cite.version += 5; let inner = crate::community::roster::build_banlist_edition(&alice, &community.id, &[carol], 1, None, 1000, Some(&cite)).unwrap();
5909 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
5910 relay.inject(&outer, &community.relays);
5911
5912 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
5913 assert!(applied.is_empty(), "citing an unsynced future grant version fails closed");
5914 }
5915
5916 #[tokio::test]
5917 async fn demoted_banner_superseded_ban_is_rejected() {
5918 let (_tmp, _guard) = init_test_db();
5922 let relay = MemoryRelay::new();
5923 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5924 let cid = community.id.to_hex();
5925 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5926 let alice = Keys::generate();
5927 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
5928
5929 let carol = "cc".repeat(32);
5930 let cite = authority_citation(&community, &alice.public_key().to_hex());
5932 let inner = crate::community::roster::build_banlist_edition(&alice, &community.id, &[carol], 1, None, 1000, cite.as_ref()).unwrap();
5933 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
5934 relay.inject(&outer, &community.relays);
5935
5936 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![]).await.unwrap();
5938
5939 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
5940 assert!(applied.is_empty(), "a since-demoted banner's stale ban is rejected (refuse-superseded)");
5941 }
5942
5943 #[tokio::test]
5944 async fn withheld_revocation_cannot_resurrect_a_demoted_banners_grant() {
5945 let (_tmp, _guard) = init_test_db();
5951 let relay = MemoryRelay::new();
5952 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5953 let cid = community.id.to_hex();
5954 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
5955 let alice = Keys::generate();
5956 set_member_grant(&relay, &community, &alice.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
5957
5958 let carol = "cc".repeat(32);
5960 let cite = authority_citation(&community, &alice.public_key().to_hex());
5961 let inner = crate::community::roster::build_banlist_edition(&alice, &community.id, &[carol], 1, None, 1000, cite.as_ref()).unwrap();
5962 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
5963 relay.inject(&outer, &community.relays);
5964
5965 let alice_bytes = alice.public_key().to_bytes();
5968 let grant_entity = crate::simd::hex::bytes_to_hex_32(&crate::community::derive::grant_locator(&community.id, &alice_bytes));
5969 crate::db::community::set_edition_head(&cid, &grant_entity, 2, &[0xAB; 32]).unwrap();
5970
5971 let applied = fetch_and_apply_banlist(&relay, &community).await.unwrap();
5972 assert!(applied.is_empty(), "a withheld revocation can't roll the banner's grant back to re-authorize them");
5973 }
5974
5975 #[tokio::test]
5976 async fn invite_registry_round_trips_and_drives_is_public() {
5977 let (_tmp, _guard) = init_test_db();
5981 let relay = MemoryRelay::new();
5982 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
5983 assert!(!is_public(&community).unwrap(), "a fresh community is Private");
5984
5985 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
5988 let loc = "1a".repeat(32);
5989 let inner = crate::community::roster::build_invite_links_edition(&owner, &community.id, &[loc.clone()], 1, None, 1000, None).unwrap();
5990 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
5991 relay.inject(&outer, &community.relays);
5992
5993 let applied = fetch_and_apply_invite_links(&relay, &community).await.unwrap();
5994 assert_eq!(applied, vec![loc], "the owner's link edition folds + unions from the relay");
5995 assert!(is_public(&community).unwrap(), "mode recomputed Public from the folded aggregate");
5996
5997 publish_my_invite_links(&relay, &community, &[]).await.unwrap();
5999 let applied = fetch_and_apply_invite_links(&relay, &community).await.unwrap();
6000 assert!(applied.is_empty() && !is_public(&community).unwrap(), "an empty aggregate is Private");
6001 }
6002
6003 #[tokio::test]
6004 async fn metadata_edit_round_trips_to_a_lagging_member() {
6005 let (_tmp, _guard) = init_test_db();
6009 let relay = MemoryRelay::new();
6010 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6011 let cid = community.id.to_hex();
6012 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6013 let (genesis_v, genesis_hash) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
6014 assert_eq!(genesis_v, 1);
6015
6016 let mut edited = crate::community::metadata::CommunityMetadata::of(&community);
6017 edited.name = "Renamed HQ".into();
6018 edited.description = Some("now with a topic".into());
6019 let inner = crate::community::roster::build_community_root_edition(&owner, &community.id, &edited, 2, Some(&genesis_hash), 4000, None).unwrap();
6020 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6021 relay.inject(&outer, &community.relays);
6022
6023 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6024 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6025 assert_eq!(after.name, "Renamed HQ", "the owner's GroupRoot edit folded from the relay");
6026 assert_eq!(after.description.as_deref(), Some("now with a topic"));
6027 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0, 2, "head advanced to v2");
6028 }
6029
6030 #[tokio::test]
6031 async fn unauthorized_metadata_edit_is_ignored() {
6032 let (_tmp, _guard) = init_test_db();
6035 let relay = MemoryRelay::new();
6036 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6037 let cid = community.id.to_hex();
6038 let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
6039
6040 let mallory = Keys::generate();
6041 let mut hacked = crate::community::metadata::CommunityMetadata::of(&community);
6042 hacked.name = "Pwned".into();
6043 let inner = crate::community::roster::build_community_root_edition(&mallory, &community.id, &hacked, 2, Some(&genesis_hash), 5000, None).unwrap();
6044 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6045 relay.inject(&outer, &community.relays);
6046
6047 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6048 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6049 assert_eq!(after.name, "HQ", "a non-manage-metadata signer's GroupRoot edit is rejected");
6050 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0, 1, "an unauthorized edit never advances the head");
6051 }
6052
6053 #[tokio::test]
6054 async fn channel_rename_round_trips_from_owner_edition() {
6055 let (_tmp, _guard) = init_test_db();
6058 let relay = MemoryRelay::new();
6059 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6060 let cid = community.id.to_hex();
6061 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6062 let channel = community.channels[0].clone();
6063 let ch_hex = channel.id.to_hex();
6064 let (_, genesis_ch_hash) = crate::db::community::get_edition_head(&cid, &ch_hex).unwrap().unwrap();
6065
6066 let meta = crate::community::metadata::ChannelMetadata { name: "announcements".into() };
6067 let inner = crate::community::roster::build_channel_metadata_edition(&owner, &channel.id, &meta, 2, Some(&genesis_ch_hash), 6000, None).unwrap();
6068 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6069 relay.inject(&outer, &community.relays);
6070
6071 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6072 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6073 assert_eq!(after.channels[0].name, "announcements", "the owner's channel rename folded + applied");
6074 assert_eq!(crate::db::community::get_edition_head(&cid, &ch_hex).unwrap().unwrap().0, 2, "channel head advanced to v2");
6075 }
6076
6077 fn root_fork_v2(author: &Keys, community: &Community, name: &str, created: u64, genesis_hash: &[u8; 32]) -> (Event, [u8; 32], [u8; 32]) {
6080 let mut meta = crate::community::metadata::CommunityMetadata::of(community);
6081 meta.name = name.into();
6082 let inner = crate::community::roster::build_community_root_edition(author, &community.id, &meta, 2, Some(genesis_hash), created, None).unwrap();
6083 let self_hash = crate::community::version::edition_hash(&community.id.0, 2, Some(genesis_hash), inner.content.as_bytes());
6084 let inner_id = inner.id.to_bytes();
6085 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6086 (outer, self_hash, inner_id)
6087 }
6088
6089 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]) {
6092 let meta = crate::community::metadata::ChannelMetadata { name: name.into() };
6093 let inner = crate::community::roster::build_channel_metadata_edition(author, channel_id, &meta, 2, Some(genesis_hash), created, None).unwrap();
6094 let self_hash = crate::community::version::edition_hash(&channel_id.0, 2, Some(genesis_hash), inner.content.as_bytes());
6095 let inner_id = inner.id.to_bytes();
6096 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6097 (outer, self_hash, inner_id)
6098 }
6099
6100 #[tokio::test]
6104 async fn channel_same_version_fork_converges_to_the_lower_inner_id() {
6105 let (_tmp, _guard) = init_test_db();
6106 let relay = MemoryRelay::new();
6107 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6108 let cid = community.id.to_hex();
6109 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6110 let channel_id = community.channels[0].id;
6111 let ch_hex = channel_id.to_hex();
6112 let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &ch_hex).unwrap().unwrap();
6113
6114 let (out_a, ha, ida) = channel_fork_v2(&owner, &community, &channel_id, "alpha", 1000, &genesis_hash);
6115 let (out_b, hb, idb) = channel_fork_v2(&owner, &community, &channel_id, "bravo", 2000, &genesis_hash);
6116 let (win_name, win_h, win_id, lose_name, lose_h, lose_id) = if ida < idb {
6117 ("alpha", ha, ida, "bravo", hb, idb)
6118 } else {
6119 ("bravo", hb, idb, "alpha", ha, ida)
6120 };
6121 crate::db::community::set_edition_head_with_id(&cid, &ch_hex, 2, &lose_h, &lose_id).unwrap();
6123 {
6124 let mut c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6125 c.channels.iter_mut().find(|ch| ch.id == channel_id).unwrap().name = lose_name.into();
6126 crate::db::community::save_community(&c).unwrap();
6127 }
6128 relay.inject(&out_a, &community.relays);
6129 relay.inject(&out_b, &community.relays);
6130
6131 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6132 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6133 let ch_name = &after.channels.iter().find(|c| c.id == channel_id).unwrap().name;
6134 assert_eq!(ch_name, win_name, "channel converged on the lower-inner-id winner, not our held fork");
6135 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");
6136 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");
6137
6138 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6140 let after2 = crate::db::community::load_community(&community.id).unwrap().unwrap();
6141 assert_eq!(&after2.channels.iter().find(|c| c.id == channel_id).unwrap().name, win_name, "no flip back to the higher-id fork");
6142 }
6143
6144 #[tokio::test]
6147 async fn channel_same_version_fork_excludes_an_unauthorized_lower_id_edition() {
6148 let (_tmp, _guard) = init_test_db();
6149 let relay = MemoryRelay::new();
6150 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6151 let cid = community.id.to_hex();
6152 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6153 let channel_id = community.channels[0].id;
6154 let ch_hex = channel_id.to_hex();
6155 let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &ch_hex).unwrap().unwrap();
6156
6157 let (owner_out, owner_h, owner_id) = channel_fork_v2(&owner, &community, &channel_id, "legit", 1000, &genesis_hash);
6158 let mallory = Keys::generate();
6160 let mal_out = {
6161 let mut chosen = None;
6162 for t in 1..=10_000u64 {
6163 let cand = channel_fork_v2(&mallory, &community, &channel_id, "forged", t, &genesis_hash);
6164 if cand.2 < owner_id { chosen = Some(cand.0); break; }
6165 }
6166 chosen.expect("a mallory channel edition with a lower inner id")
6167 };
6168 relay.inject(&owner_out, &community.relays);
6169 relay.inject(&mal_out, &community.relays);
6170
6171 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6172 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6173 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");
6174 assert_eq!(crate::db::community::get_edition_head(&cid, &ch_hex).unwrap().unwrap(), (2, owner_h), "the authorized channel edition is the head");
6175 }
6176
6177 #[tokio::test]
6181 async fn epoch_primary_floor_lets_a_refounding_v1_supersede_a_held_high_version() {
6182 let (_tmp, _guard) = init_test_db();
6183 let relay = MemoryRelay::new();
6184 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6185 let cid = community.id.to_hex();
6186 for v in 2..=5u64 {
6188 crate::db::community::set_edition_head_with_id(&cid, &cid, v, &[v as u8; 32], &[v as u8; 32]).unwrap();
6189 }
6190 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0, 5);
6191 crate::db::community::set_edition_head_with_id(&cid, &cid, 3, &[0x33; 32], &[0x33; 32]).unwrap();
6193 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0, 5, "in-epoch downgrade refused");
6194
6195 crate::db::community::advance_server_root_epoch(&cid, 1, &[0xEE; 32]).unwrap();
6197 crate::db::community::set_edition_head_with_id(&cid, &cid, 1, &[0x01; 32], &[0x01; 32]).unwrap();
6198 let (v, h) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
6199 assert_eq!((v, h), (1, [0x01; 32]), "epoch-1 v1 supersedes epoch-0 v5 (epoch-primary)");
6200 assert_eq!(
6201 crate::db::community::get_all_edition_heads_epoched(&cid).unwrap().get(&cid).map(|(e, v, _)| (*e, *v)),
6202 Some((1, 1)),
6203 "head now recorded at epoch 1",
6204 );
6205 crate::db::community::set_edition_head_with_id(&cid, &cid, 1, &[0xAA; 32], &[0x02; 32]).unwrap();
6207 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().1, [0x01; 32], "same-epoch same-version is not an advance");
6208 }
6209
6210 #[tokio::test]
6214 async fn same_version_fork_converges_to_the_lower_inner_id() {
6215 let (_tmp, _guard) = init_test_db();
6216 let relay = MemoryRelay::new();
6217 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6218 let cid = community.id.to_hex();
6219 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6220 let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
6221
6222 let (out_a, ha, ida) = root_fork_v2(&owner, &community, "Alpha", 1000, &genesis_hash);
6223 let (out_b, hb, idb) = root_fork_v2(&owner, &community, "Bravo", 2000, &genesis_hash);
6224 let (win_name, win_h, win_id, lose_name, lose_h, lose_id) = if ida < idb {
6226 ("Alpha", ha, ida, "Bravo", hb, idb)
6227 } else {
6228 ("Bravo", hb, idb, "Alpha", ha, ida)
6229 };
6230 crate::db::community::set_edition_head_with_id(&cid, &cid, 2, &lose_h, &lose_id).unwrap();
6231 {
6232 let mut c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6233 c.name = lose_name.into();
6234 crate::db::community::save_community(&c).unwrap();
6235 }
6236 relay.inject(&out_a, &community.relays);
6237 relay.inject(&out_b, &community.relays);
6238
6239 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6240 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6241 assert_eq!(after.name, win_name, "converged on the lower-inner-id winner, not our own held fork");
6242 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap(), (2, win_h), "head self_hash converged at the SAME version");
6243 assert_eq!(crate::db::community::get_edition_head_inner_id(&cid, &cid).unwrap(), Some(win_id), "head inner_id moved to the winner");
6244
6245 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6247 assert_eq!(crate::db::community::load_community(&community.id).unwrap().unwrap().name, win_name, "no flip back to the higher-id fork");
6248 }
6249
6250 #[tokio::test]
6253 async fn converged_head_chains_the_next_edit_without_reforking() {
6254 let (_tmp, _guard) = init_test_db();
6255 let relay = MemoryRelay::new();
6256 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6257 let cid = community.id.to_hex();
6258 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6259 let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
6260
6261 let (out_a, ha, ida) = root_fork_v2(&owner, &community, "Alpha", 1000, &genesis_hash);
6262 let (out_b, hb, idb) = root_fork_v2(&owner, &community, "Bravo", 2000, &genesis_hash);
6263 let (lose_h, lose_id) = if ida < idb { (hb, idb) } else { (ha, ida) };
6264 crate::db::community::set_edition_head_with_id(&cid, &cid, 2, &lose_h, &lose_id).unwrap();
6265 relay.inject(&out_a, &community.relays);
6266 relay.inject(&out_b, &community.relays);
6267 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6268 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0, 2, "converged at v2");
6269
6270 let mut c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6271 c.name = "Third".into();
6272 republish_community_metadata(&relay, &c).await.unwrap();
6273 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap().0, 3, "advanced to v3 off the converged head");
6274
6275 let empty: std::collections::HashMap<String, (u64, [u8; 32])> = std::collections::HashMap::new();
6276 let folded = crate::community::roster::fold_roster(&fetch_control_inners(&relay, &community).await, &community.id, &empty);
6277 assert_eq!(folded.root_head.as_ref().map(|h| h.version), Some(3), "a fresh fold reaches v3");
6278 assert!(!folded.gapped_entities.contains(&community.id.0), "the chain is contiguous genesis -> winner -> v3");
6279 }
6280
6281 #[tokio::test]
6284 async fn same_version_fork_excludes_an_unauthorized_lower_id_edition() {
6285 let (_tmp, _guard) = init_test_db();
6286 let relay = MemoryRelay::new();
6287 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6288 let cid = community.id.to_hex();
6289 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6290 let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
6291
6292 let (owner_out, owner_h, owner_id) = root_fork_v2(&owner, &community, "Legit", 1000, &genesis_hash);
6293 let mallory = Keys::generate();
6295 let (mal_out, mal_id) = {
6296 let mut chosen = None;
6297 for t in 1..=10_000u64 {
6298 let cand = root_fork_v2(&mallory, &community, "Forged", t, &genesis_hash);
6299 if cand.2 < owner_id { chosen = Some((cand.0, cand.2)); break; }
6300 }
6301 chosen.expect("a mallory edition with a lower inner id")
6302 };
6303 assert!(mal_id < owner_id, "premise: the forgery sorts first author-blind");
6304 relay.inject(&owner_out, &community.relays);
6305 relay.inject(&mal_out, &community.relays);
6306
6307 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6309 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6310 assert_eq!(after.name, "Legit", "the forgery never wins despite a lower inner id");
6311 assert_eq!(crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap(), (2, owner_h), "the authorized edition is the head");
6312 }
6313
6314 #[tokio::test]
6318 async fn same_version_fork_on_an_authority_record_fails_closed() {
6319 let (_tmp, _guard) = init_test_db();
6320 let relay = MemoryRelay::new();
6321 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6322 let cid = community.id.to_hex();
6323 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6324 let bl_eid = crate::community::derive::banlist_locator(&community.id);
6325 let bl_hex = crate::simd::hex::bytes_to_hex_32(&bl_eid);
6326
6327 let prev = [0x99u8; 32]; let build_ban = |list: &[String], created: u64| {
6329 let inner = crate::community::roster::build_banlist_edition(&owner, &community.id, list, 2, Some(&prev), created, None).unwrap();
6330 let self_hash = crate::community::version::edition_hash(&bl_eid, 2, Some(&prev), inner.content.as_bytes());
6331 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6332 (outer, self_hash, inner.id.to_bytes())
6333 };
6334 let (out_a, ha, ida) = build_ban(&["aa".repeat(32)], 1000);
6335 let (out_b, hb, idb) = build_ban(&["bb".repeat(32)], 2000);
6336 let (lose_h, lose_id) = if ida < idb { (hb, idb) } else { (ha, ida) };
6339 crate::db::community::set_edition_head_with_id(&cid, &bl_hex, 2, &lose_h, &lose_id).unwrap();
6340 relay.inject(&out_a, &community.relays);
6341 relay.inject(&out_b, &community.relays);
6342
6343 let floors = crate::db::community::get_all_edition_heads(&cid).unwrap();
6344 let folded = crate::community::roster::fold_roster(&fetch_control_inners(&relay, &community).await, &community.id, &floors);
6345 assert!(folded.gapped_entities.contains(&bl_eid), "the authority-record fork is quarantined");
6346 assert!(folded.banlist_head.is_none() && folded.banlist_author.is_none(), "no banlist folded off the withheld view");
6347 }
6348
6349 #[tokio::test]
6350 async fn editions_sign_through_the_active_client_signer() {
6351 let (_tmp, _guard) = init_test_db();
6356 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6357 crate::state::set_nostr_client(nostr_sdk::Client::builder().signer(owner.clone()).build());
6358
6359 let relay = MemoryRelay::new();
6360 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6361 let cid = community.id.to_hex();
6362
6363 publish_banlist(&relay, &community, &["dd".repeat(32)]).await.unwrap();
6365 let floors = crate::db::community::get_all_edition_heads(&cid).unwrap();
6366 let folded = crate::community::roster::fold_roster(
6367 &fetch_control_inners(&relay, &community).await, &community.id, &floors);
6368 assert_eq!(folded.banlist_author, Some(owner.public_key()), "banlist signed by the client signer");
6369 assert!(folded.root_author.is_some(), "genesis GroupRoot folded");
6370 let _ = crate::state::take_nostr_client();
6371 }
6372
6373 async fn fetch_control_inners(relay: &MemoryRelay, community: &Community) -> Vec<Event> {
6375 let z = crate::community::roster::control_pseudonym(&community.server_root_key, &community.id, crate::community::Epoch(0));
6376 let query = Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], ..Default::default() };
6377 let mut out = Vec::new();
6378 for ev in relay.fetch(&query, &community.relays).await.unwrap() {
6379 if let Ok(inner) = crate::community::roster::open_control_edition(&ev, &community.server_root_key) {
6380 out.push(inner);
6381 }
6382 }
6383 out
6384 }
6385
6386 fn simulate_bunker(owner: &Keys) {
6389 crate::state::set_nostr_client(nostr_sdk::Client::builder().signer(owner.clone()).build());
6390 crate::state::MY_SECRET_KEY.clear(&[]);
6391 assert!(crate::state::MY_SECRET_KEY.to_keys().is_none(), "bunker sim: no local key");
6392 }
6393
6394 #[tokio::test]
6395 async fn am_i_banned_detects_own_npub_in_banlist() {
6396 let (_tmp, _guard) = init_test_db();
6398 let relay = MemoryRelay::new();
6399 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6400 let me = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key().to_hex();
6401 let cid = community.id.to_hex();
6402 assert!(!am_i_banned(&community), "not banned on a fresh community");
6403 crate::db::community::set_community_banlist(&cid, &[me], 1).unwrap();
6405 assert!(am_i_banned(&community), "own npub in the banlist → banned → self-remove");
6406 crate::db::community::set_community_banlist(&cid, &[], 2).unwrap();
6407 assert!(!am_i_banned(&community), "cleared banlist → not banned");
6408 }
6409
6410 #[tokio::test]
6411 async fn bunker_owner_cannot_ban_in_private_community() {
6412 let (_tmp, _guard) = init_test_db();
6415 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6416 let relay = MemoryRelay::new();
6417 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6418 simulate_bunker(&owner);
6419
6420 let victim = "cc".repeat(32);
6421 let err = publish_banlist(&relay, &community, &[victim]).await.unwrap_err();
6422 assert!(err.contains("private community") && err.contains("bunker"), "clear bunker explanation: {err}");
6423 assert!(
6424 crate::db::community::get_community_banlist(&community.id.to_hex()).unwrap().is_empty(),
6425 "the ban must NOT half-apply (nothing published or persisted)"
6426 );
6427 let _ = crate::state::take_nostr_client();
6428 }
6429
6430 #[tokio::test]
6431 async fn bunker_owner_can_ban_in_public_community() {
6432 let (_tmp, _guard) = init_test_db();
6435 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6436 let relay = MemoryRelay::new();
6437 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6438 create_public_invite(&relay, &community, None, None).await.unwrap();
6439 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
6440 assert!(is_public(&community).unwrap(), "minting a link made it Public");
6441 simulate_bunker(&owner);
6442
6443 let victim = "cc".repeat(32);
6444 publish_banlist(&relay, &community, &[victim.clone()]).await.unwrap();
6445 assert_eq!(
6446 crate::db::community::get_community_banlist(&community.id.to_hex()).unwrap(),
6447 vec![victim],
6448 "a public ban from a bunker account succeeds (no rekey needed)"
6449 );
6450 let _ = crate::state::take_nostr_client();
6451 }
6452
6453 #[tokio::test]
6454 async fn bunker_owner_cannot_privatize() {
6455 let (_tmp, _guard) = init_test_db();
6458 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
6459 let relay = MemoryRelay::new();
6460 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6461 let (token, _) = create_public_invite(&relay, &community, None, None).await.unwrap();
6462 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
6463 simulate_bunker(&owner);
6464
6465 let err = revoke_public_invite(&relay, &community, &crate::simd::hex::hex_to_bytes_32(&token)).await.unwrap_err();
6466 assert!(err.contains("private") && err.contains("bunker"), "clear bunker explanation: {err}");
6467 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6468 assert!(is_public(&after).unwrap(), "the revoke must NOT half-apply — community stays Public");
6469 let _ = crate::state::take_nostr_client();
6470 }
6471
6472 #[tokio::test]
6473 async fn non_owner_admin_can_edit_community_metadata() {
6474 let (_tmp, _guard) = init_test_db();
6478 let relay = MemoryRelay::new();
6479 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6480 let cid = community.id.to_hex();
6481 let (_, genesis_hash) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
6482
6483 let admin = Keys::generate();
6485 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
6486 set_member_grant(&relay, &community, &admin.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
6487
6488 let mut edited = crate::community::metadata::CommunityMetadata::of(&community);
6490 edited.name = "Admin Renamed".into();
6491 let inner = crate::community::roster::build_community_root_edition(&admin, &community.id, &edited, 2, Some(&genesis_hash), 7000, None).unwrap();
6492 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6493 relay.inject(&outer, &community.relays);
6494
6495 fetch_and_apply_metadata(&relay, &community).await.unwrap();
6496 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6497 assert_eq!(after.name, "Admin Renamed", "a MANAGE_METADATA admin (not the owner) can edit metadata");
6498 }
6499
6500 #[tokio::test]
6501 async fn banning_an_admin_revokes_their_role() {
6502 let (_tmp, _guard) = init_test_db();
6506 let relay = MemoryRelay::new();
6507 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6508 let cid = community.id.to_hex();
6509 create_public_invite(&relay, &community, None, None).await.unwrap();
6510 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
6511
6512 let alice = Keys::generate();
6513 let alice_hex = alice.public_key().to_hex();
6514 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
6515 set_member_grant(&relay, &community, &alice_hex, vec![admin_role_id]).await.unwrap();
6516 let holds_role = |hex: &str| crate::db::community::get_community_roles(&cid).unwrap()
6517 .grants.iter().any(|g| g.member == hex && !g.role_ids.is_empty());
6518 assert!(holds_role(&alice_hex), "alice is admin pre-ban");
6519
6520 publish_banlist(&relay, &community, &[alice_hex.clone()]).await.unwrap();
6521 assert!(!holds_role(&alice_hex), "banning an admin revokes their role — no dangling grant");
6522 }
6523
6524 #[tokio::test]
6525 async fn kicking_an_admin_revokes_their_role() {
6526 let (_tmp, _guard) = init_test_db();
6529 let relay = MemoryRelay::new();
6530 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6531 let cid = community.id.to_hex();
6532 let alice = Keys::generate();
6533 let alice_hex = alice.public_key().to_hex();
6534 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
6535 set_member_grant(&relay, &community, &alice_hex, vec![admin_role_id]).await.unwrap();
6536 let holds_role = |hex: &str| crate::db::community::get_community_roles(&cid).unwrap()
6537 .grants.iter().any(|g| g.member == hex && !g.role_ids.is_empty());
6538 assert!(holds_role(&alice_hex), "alice is admin pre-kick");
6539
6540 publish_kick(&relay, &community, &community.channels[0], &alice_hex).await.unwrap();
6541 assert!(!holds_role(&alice_hex), "kicking an admin revokes their role");
6542 }
6543
6544 #[tokio::test]
6545 async fn republish_channel_metadata_renames_and_publishes() {
6546 let (_tmp, _guard) = init_test_db();
6549 let relay = MemoryRelay::new();
6550 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6551 let cid = community.id.to_hex();
6552 let channel = community.channels[0].clone();
6553 let ch_hex = channel.id.to_hex();
6554
6555 republish_channel_metadata(&relay, &community, &channel.id, "lobby").await.unwrap();
6556 let after = crate::db::community::load_community(&community.id).unwrap().unwrap();
6557 assert_eq!(after.channels[0].name, "lobby", "the producer renamed the channel locally");
6558 assert_eq!(crate::db::community::get_edition_head(&cid, &ch_hex).unwrap().unwrap().0, 2, "channel head advanced");
6559 }
6560
6561 #[tokio::test]
6562 async fn revoking_the_last_link_privatizes_and_rotates_the_base() {
6563 let (_tmp, _guard) = init_test_db();
6568 let relay = MemoryRelay::new();
6569 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6570 assert!(!is_public(&community).unwrap(), "a fresh community is Private");
6571 assert_eq!(community.server_root_epoch, crate::community::Epoch(0));
6572
6573 let (t1, _) = create_public_invite(&relay, &community, None, None).await.unwrap();
6575 let (t2, _) = create_public_invite(&relay, &community, None, None).await.unwrap();
6576 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6577 assert!(is_public(&c).unwrap(), "minting a link flips the mode to Public");
6578 assert_eq!(c.server_root_epoch, crate::community::Epoch(0), "minting links does NOT rotate the base");
6579
6580 revoke_public_invite(&relay, &c, &crate::simd::hex::hex_to_bytes_32(&t1)).await.unwrap();
6582 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6583 assert!(is_public(&c).unwrap(), "one link remains → still Public");
6584 assert_eq!(c.server_root_epoch, crate::community::Epoch(0), "revoking a non-last link does NOT rotate");
6585
6586 revoke_public_invite(&relay, &c, &crate::simd::hex::hex_to_bytes_32(&t2)).await.unwrap();
6588 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6589 assert!(!is_public(&c).unwrap(), "revoking the last link flips to Private");
6590 assert_eq!(c.server_root_epoch, crate::community::Epoch(1), "privatize re-founded: the base key rotated");
6591
6592 revoke_public_invite(&relay, &c, &crate::simd::hex::hex_to_bytes_32(&t2)).await.unwrap();
6595 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6596 assert_eq!(c.server_root_epoch, crate::community::Epoch(1), "a no-op re-revoke does not double-rotate");
6597 }
6598
6599 #[tokio::test]
6600 async fn private_ban_reseals_base_public_ban_does_not() {
6601 let (_tmp, _guard) = init_test_db();
6604 let relay = MemoryRelay::new();
6605 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6606 let victim = "cc".repeat(32);
6607
6608 assert!(!is_public(&community).unwrap(), "fresh community is Private");
6610 publish_banlist(&relay, &community, &[victim.clone()]).await.unwrap();
6611 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6612 assert_eq!(c.server_root_epoch, crate::community::Epoch(1), "a private-community ban re-seals the base");
6613
6614 create_public_invite(&relay, &c, None, None).await.unwrap();
6616 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6617 assert!(is_public(&c).unwrap(), "minted a link → Public");
6618 publish_banlist(&relay, &c, &[victim.clone(), "dd".repeat(32)]).await.unwrap();
6619 let c2 = crate::db::community::load_community(&community.id).unwrap().unwrap();
6620 assert_eq!(c2.server_root_epoch, crate::community::Epoch(1), "a public-community ban does NOT rotate the base");
6621 }
6622
6623 #[tokio::test]
6624 async fn private_ban_seals_the_banned_member_out_of_the_new_root() {
6625 use crate::community::derive::{base_rekey_pseudonym, recipient_pseudonym};
6631 use crate::community::rekey::{open_rekey_event, rekey_pairwise_secret};
6632 use crate::types::Message;
6633 use nostr_sdk::ToBech32;
6634 let (_tmp, _guard) = init_test_db();
6635 let relay = MemoryRelay::new();
6636 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6637 let cid = community.id.to_hex();
6638 let genesis_root = *community.server_root_key.as_bytes();
6639 let channel_hex = community.channels[0].id.to_hex();
6640
6641 let victim = Keys::generate();
6643 let victim_b32 = victim.public_key().to_bech32().unwrap();
6644 let mut m = Message::default();
6645 m.id = "aa".repeat(32);
6646 m.npub = Some(victim_b32.clone());
6647 m.at = 1000;
6648 crate::db::events::save_message(&channel_hex, &m).await.unwrap();
6649 assert!(
6650 crate::db::community::community_member_activity(&cid).unwrap().iter().any(|(np, _)| np == &victim_b32),
6651 "victim is observed before the ban"
6652 );
6653
6654 publish_banlist(&relay, &community, &[victim.public_key().to_hex()]).await.unwrap();
6656 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
6657 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(1), "private ban re-seals the base");
6658 assert!(
6659 !crate::db::community::community_member_activity(&cid).unwrap().iter().any(|(np, _)| np == &victim_b32),
6660 "the banned victim is no longer observed (banlist hex → bech32 reconciliation worked)"
6661 );
6662
6663 let addr = base_rekey_pseudonym(&crate::community::ServerRootKey(genesis_root), &community.id, crate::community::Epoch(1)).to_hex();
6665 let found = relay
6666 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() }, &community.relays)
6667 .await
6668 .unwrap();
6669 assert_eq!(found.len(), 1, "the base rekey is published");
6670 let parsed = open_rekey_event(&found[0], &genesis_root).unwrap();
6671 let secret = rekey_pairwise_secret(victim.secret_key(), &parsed.rotator).unwrap();
6672 let loc = recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
6673 assert!(
6674 parsed.blobs.iter().all(|b| b.locator != loc),
6675 "the BANNED victim has NO blob — sealed OUT of the new root (read access is actually cut)"
6676 );
6677 }
6678
6679 struct SwapDuringPublishRelay {
6683 inner: MemoryRelay,
6684 }
6685 #[async_trait::async_trait]
6686 impl Transport for SwapDuringPublishRelay {
6687 async fn publish(&self, event: &Event, relays: &[String]) -> Result<(), String> {
6688 crate::state::bump_session_generation();
6689 self.inner.publish(event, relays).await
6690 }
6691 async fn publish_durable(&self, event: &Event, relays: &[String]) -> Result<(), String> {
6692 crate::state::bump_session_generation();
6693 self.inner.publish_durable(event, relays).await
6694 }
6695 async fn fetch(&self, query: &Query, relays: &[String]) -> Result<Vec<Event>, String> {
6696 self.inner.fetch(query, relays).await
6697 }
6698 }
6699
6700 #[tokio::test]
6704 async fn account_swap_during_grant_publish_skips_the_local_persist() {
6705 let (_tmp, _guard) = init_test_db();
6706 let setup = MemoryRelay::new();
6707 let community = create_community(&setup, "HQ", "general", vec!["r1".into()]).await.unwrap();
6708 let cid = community.id.to_hex();
6709 let member = "cc".repeat(32);
6710 let entity_hex = crate::simd::hex::bytes_to_hex_32(
6711 &crate::community::derive::grant_locator(&community.id, &crate::simd::hex::hex_to_bytes_32(&member)));
6712 assert!(crate::db::community::get_edition_head(&cid, &entity_hex).unwrap().is_none(), "no grant head yet");
6713
6714 let swap = SwapDuringPublishRelay { inner: MemoryRelay::new() };
6715 set_member_grant(&swap, &community, &member, vec!["a".repeat(64)]).await.unwrap();
6716
6717 assert!(
6718 crate::db::community::get_edition_head(&cid, &entity_hex).unwrap().is_none(),
6719 "session straddled a swap → persist skipped → no local grant head (account B uncorrupted)"
6720 );
6721 }
6722
6723 #[tokio::test]
6727 async fn account_swap_during_ban_publish_applies_nothing_locally() {
6728 let (_tmp, _guard) = init_test_db();
6729 let setup = MemoryRelay::new();
6730 let community = create_community(&setup, "HQ", "general", vec!["r1".into()]).await.unwrap();
6731 let cid = community.id.to_hex();
6732 assert!(!is_public(&community).unwrap(), "fresh community is Private (a ban would normally re-seal)");
6733
6734 let swap = SwapDuringPublishRelay { inner: MemoryRelay::new() };
6735 publish_banlist(&swap, &community, &["cc".repeat(32)]).await.unwrap();
6736
6737 assert!(crate::db::community::get_community_banlist(&cid).unwrap().is_empty(),
6738 "banlist persist skipped on the stale session");
6739 assert_eq!(crate::db::community::load_community(&community.id).unwrap().unwrap().server_root_epoch,
6740 crate::community::Epoch(0), "no read-cut re-seal → base NOT rotated into the wrong account");
6741 assert!(!crate::db::community::get_read_cut_pending(&cid).unwrap(),
6742 "read_cut_pending untouched (need_cut requires is_valid())");
6743 }
6744
6745 #[tokio::test]
6748 async fn swap_session_clears_per_account_state_and_keys() {
6749 let (_tmp, _guard) = init_test_db();
6750 {
6751 let mut st = crate::state::STATE.lock().await;
6752 st.db_loaded = true;
6753 st.is_syncing = true;
6754 }
6755 assert!(crate::state::MY_SECRET_KEY.has_key(), "account A holds a live key");
6756
6757 crate::VectorCore.swap_session().await;
6758
6759 let st = crate::state::STATE.lock().await;
6760 assert!(st.chats.is_empty() && st.profiles.is_empty(), "STATE chats/profiles cleared on swap");
6761 assert!(!st.db_loaded && !st.is_syncing, "db_loaded / is_syncing reset");
6762 assert!(!crate::state::MY_SECRET_KEY.has_key(), "key vault cleared — no leak into account B");
6763 }
6764
6765 #[tokio::test]
6769 async fn join_finalization_persists_and_registers_the_channel() {
6770 let (_tmp, _guard) = init_test_db();
6771 crate::state::STATE.lock().await.chats.clear(); let relay = MemoryRelay::new();
6773 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6774 become_local(&Keys::generate());
6776
6777 crate::VectorCore.finalize_member_join(community.clone(), &relay, None).await.unwrap();
6778
6779 assert!(crate::db::community::load_community(&community.id).unwrap().is_some(), "community persisted on join");
6780 assert!(!crate::state::STATE.lock().await.chats.is_empty(), "the channel is registered as a chat");
6781 }
6782
6783 #[tokio::test]
6787 async fn join_finalization_tears_down_a_banned_joiner() {
6788 let (_tmp, _guard) = init_test_db();
6789 let relay = MemoryRelay::new();
6790 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6791 create_public_invite(&relay, &community, None, None).await.unwrap();
6793 let community = crate::db::community::load_community(&community.id).unwrap().unwrap();
6794
6795 let joiner = Keys::generate();
6797 publish_banlist(&relay, &community, &[joiner.public_key().to_hex()]).await.unwrap();
6798 become_local(&joiner);
6799 assert!(crate::db::community::load_community(&community.id).unwrap().is_some(), "community present pre-join");
6800
6801 let result = crate::VectorCore.finalize_member_join(community.clone(), &relay, None).await;
6802 assert!(result.is_err(), "a banned joiner's finalize must fail");
6803 assert!(result.unwrap_err().to_string().contains("banned"), "the error names the ban");
6804 assert!(
6805 crate::db::community::load_community(&community.id).unwrap().is_none(),
6806 "the just-saved community is torn back down — no orphaned row for a banned joiner"
6807 );
6808 }
6809
6810 #[tokio::test]
6816 async fn delete_community_wipes_every_community_scoped_table() {
6817 let (_tmp, _guard) = init_test_db();
6818 let relay = MemoryRelay::new();
6819 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6820 let cid = community.id.to_hex();
6821
6822 crate::db::community::store_epoch_key(&cid, crate::community::SERVER_ROOT_SCOPE_HEX, 1, &[0x11u8; 32]).unwrap();
6824 crate::db::community::save_public_invite("tok", &cid, "https://x/invite#y", None, None).unwrap();
6825 crate::db::community::save_pending_invite(&cid, "{}", "npub1inviter").unwrap();
6826 crate::db::community::set_edition_head(&cid, &cid, 1, &[0x22u8; 32]).unwrap();
6827 crate::db::community::set_community_banlist(&cid, &["cc".repeat(32)], 100).unwrap();
6828
6829 assert!(crate::db::community::community_exists(&community.id).unwrap());
6831 assert!(!crate::db::community::held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty());
6832 assert!(!crate::db::community::list_public_invites(&cid).unwrap().is_empty());
6833 assert!(crate::db::community::list_pending_invites().unwrap().iter().any(|p| p.community_id == cid));
6834 assert!(!crate::db::community::get_all_edition_heads(&cid).unwrap().is_empty());
6835 assert!(!crate::db::community::get_community_banlist(&cid).unwrap().is_empty());
6836
6837 crate::db::community::delete_community(&cid).unwrap();
6838
6839 assert!(!crate::db::community::community_exists(&community.id).unwrap(), "communities row gone");
6841 assert!(crate::db::community::load_community(&community.id).unwrap().is_none(), "community not loadable");
6842 assert!(crate::db::community::held_epoch_keys(&cid, crate::community::SERVER_ROOT_SCOPE_HEX).unwrap().is_empty(), "epoch keys wiped");
6843 assert!(crate::db::community::list_public_invites(&cid).unwrap().is_empty(), "public invites wiped");
6844 assert!(!crate::db::community::list_pending_invites().unwrap().iter().any(|p| p.community_id == cid), "pending invites wiped");
6845 assert!(crate::db::community::get_all_edition_heads(&cid).unwrap().is_empty(), "edition heads wiped");
6846 assert!(crate::db::community::get_community_banlist(&cid).unwrap().is_empty(), "banlist wiped with the channels");
6847 }
6848
6849 #[tokio::test]
6853 async fn fetch_control_folded_skips_junk_injected_at_the_coordinate() {
6854 let (_tmp, _guard) = init_test_db();
6855 let relay = MemoryRelay::new();
6856 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6857 let owner_hex = crate::state::MY_SECRET_KEY.to_keys().unwrap().public_key().to_hex();
6858
6859 let z = crate::community::roster::control_pseudonym(&community.server_root_key, &community.id, community.server_root_epoch);
6861 let junk = nostr_sdk::EventBuilder::new(nostr_sdk::Kind::Custom(event_kind::COMMUNITY_CONTROL), "not a sealed edition")
6862 .tags([nostr_sdk::Tag::custom(nostr_sdk::TagKind::Custom("z".into()), [z])])
6863 .sign_with_keys(&Keys::generate())
6864 .unwrap();
6865 relay.publish(&junk, &community.relays).await.unwrap();
6866
6867 let folded = fetch_control_folded(&relay, &community).await.unwrap();
6868 assert!(
6869 !crate::community::roster::authorize_delegation(&folded, Some(&owner_hex)).roles.is_empty(),
6870 "the genuine Admin role still folds; the un-openable junk is silently dropped"
6871 );
6872 }
6873
6874 #[tokio::test]
6877 async fn fetch_control_folded_on_dead_relays_is_empty_not_a_panic() {
6878 let (_tmp, _guard) = init_test_db();
6879 let community = saved_community_owned_by(&Keys::generate());
6880 let folded = fetch_control_folded(&FailingRelay, &community).await.unwrap();
6881 assert!(folded.roles.roles.is_empty() && folded.root_meta.is_none(), "dead relays → empty fold, no panic");
6882 }
6883
6884 #[tokio::test]
6885 async fn successful_private_ban_leaves_no_read_cut_pending() {
6886 let (_tmp, _guard) = init_test_db();
6888 let relay = MemoryRelay::new();
6889 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6890 let cid = community.id.to_hex();
6891 publish_banlist(&relay, &community, &["cc".repeat(32)]).await.unwrap();
6892 assert!(!crate::db::community::get_read_cut_pending(&cid).unwrap(), "a successful re-seal leaves no pending read-cut");
6893 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6894 assert_eq!(c.server_root_epoch, crate::community::Epoch(1));
6895 }
6896
6897 #[tokio::test]
6898 async fn failed_reseal_sets_pending_then_sync_retry_recovers() {
6899 let (_tmp, _guard) = init_test_db();
6904 let relay = RekeyFailingRelay::new(); let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6906 let cid = community.id.to_hex();
6907 let victim = "cc".repeat(32);
6908
6909 assert!(publish_banlist(&relay, &community, &[victim.clone()]).await.is_err(), "the re-seal's base rekey fails");
6911 assert!(crate::db::community::get_read_cut_pending(&cid).unwrap(), "a failed re-seal leaves read_cut_pending set");
6912 assert_eq!(crate::db::community::get_community_banlist(&cid).unwrap(), vec![victim.clone()], "the ban itself still applied");
6913 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
6914 assert_eq!(c.server_root_epoch, crate::community::Epoch(0), "base NOT rotated while the re-seal is pending");
6915
6916 relay.allow_rekey();
6918 retry_pending_read_cut(&relay, &c).await.unwrap();
6919 assert!(!crate::db::community::get_read_cut_pending(&cid).unwrap(), "pending cleared after the retry succeeds");
6920 let c2 = crate::db::community::load_community(&community.id).unwrap().unwrap();
6921 assert_eq!(c2.server_root_epoch, crate::community::Epoch(1), "the read-cut finally rotated the base");
6922 }
6923
6924 #[tokio::test]
6925 async fn privatize_reseals_to_observed_participants_not_just_owner() {
6926 use crate::community::derive::{base_rekey_pseudonym, recipient_pseudonym};
6930 use crate::community::rekey::{open_rekey_blob, open_rekey_event, rekey_pairwise_secret};
6931 use crate::types::Message;
6932 use nostr_sdk::ToBech32;
6933 let (_tmp, _guard) = init_test_db();
6934 let relay = MemoryRelay::new();
6935 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6936 let cid = community.id.to_hex();
6937 let genesis_root = *community.server_root_key.as_bytes();
6938 let channel_hex = community.channels[0].id.to_hex();
6939
6940 let alice = Keys::generate();
6942 let alice_b32 = alice.public_key().to_bech32().unwrap();
6943 let mut m = Message::default();
6944 m.id = "aa".repeat(32);
6945 m.npub = Some(alice_b32.clone());
6946 m.at = 1000;
6947 crate::db::events::save_message(&channel_hex, &m).await.unwrap();
6948 assert!(
6949 crate::db::community::community_member_activity(&cid).unwrap().iter().any(|(np, _)| np == &alice_b32),
6950 "alice is an observed participant"
6951 );
6952
6953 let (token_hex, _) = create_public_invite(&relay, &community, None, None).await.unwrap();
6955 revoke_public_invite(&relay, &community, &crate::simd::hex::hex_to_bytes_32(&token_hex)).await.unwrap();
6956 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
6957 assert_eq!(reloaded.server_root_epoch, crate::community::Epoch(1), "privatize rotated the base");
6958
6959 let addr = base_rekey_pseudonym(&crate::community::ServerRootKey(genesis_root), &community.id, crate::community::Epoch(1)).to_hex();
6962 let found = relay
6963 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_REKEY], z_tags: vec![addr], ..Default::default() }, &community.relays)
6964 .await
6965 .unwrap();
6966 assert_eq!(found.len(), 1, "the base rekey is published");
6967 let parsed = open_rekey_event(&found[0], &genesis_root).unwrap();
6968 let secret = rekey_pairwise_secret(alice.secret_key(), &parsed.rotator).unwrap();
6969 let loc = recipient_pseudonym(&secret, parsed.scope, parsed.new_epoch).to_hex();
6970 let alice_blob = parsed.blobs.iter().find(|b| b.locator == loc).expect("alice's blob present (NOT sealed out)");
6971 let recovered = open_rekey_blob(alice.secret_key(), &parsed.rotator, parsed.scope, parsed.new_epoch, alice_blob).unwrap();
6972 assert_eq!(reloaded.server_root_key.as_bytes(), &recovered, "alice recovers the new root = owner's advanced base");
6973 }
6974
6975 #[tokio::test]
6976 async fn unpermissioned_invite_links_edition_is_rejected() {
6977 let (_tmp, _guard) = init_test_db();
6981 let relay = MemoryRelay::new();
6982 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
6983
6984 let mallory = Keys::generate();
6985 let loc = "2b".repeat(32);
6986 let inner = crate::community::roster::build_invite_links_edition(&mallory, &community.id, &[loc], 1, None, 1000, None).unwrap();
6987 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
6988 relay.inject(&outer, &community.relays);
6989
6990 let applied = fetch_and_apply_invite_links(&relay, &community).await.unwrap();
6991 assert!(applied.is_empty(), "an unpermissioned member's link edition is rejected");
6992 assert!(!is_public(&community).unwrap(), "mode stays Private despite the forged edition");
6993 }
6994
6995 #[tokio::test]
6996 async fn invite_links_union_across_authorized_creators() {
6997 let (_tmp, _guard) = init_test_db();
7001 let relay = MemoryRelay::new();
7002 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7003 let cid = community.id.to_hex();
7004
7005 create_public_invite(&relay, &community, None, None).await.unwrap();
7007 let owner_loc = public_invite::locator_hex(&crate::simd::hex::hex_to_bytes_32(
7008 &crate::db::community::list_public_invites(&cid).unwrap()[0].token));
7009
7010 let admin = Keys::generate();
7012 let admin_role_id = crate::db::community::get_community_roles(&cid).unwrap().roles[0].role_id.clone();
7013 set_member_grant(&relay, &community, &admin.public_key().to_hex(), vec![admin_role_id]).await.unwrap();
7014 let admin_loc = "ab".repeat(32);
7015 let inner = crate::community::roster::build_invite_links_edition(&admin, &community.id, &[admin_loc.clone()], 1, None, 2000, None).unwrap();
7016 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, crate::community::Epoch(0)).unwrap();
7017 relay.inject(&outer, &community.relays);
7018
7019 let agg = fetch_and_apply_invite_links(&relay, &community).await.unwrap();
7020 assert!(agg.contains(&owner_loc), "owner's link in the aggregate");
7021 assert!(agg.contains(&admin_loc), "the granted admin's link unions in too");
7022 assert!(is_public(&community).unwrap());
7023
7024 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
7028 let owner_token = crate::db::community::list_public_invites(&cid).unwrap()[0].token.clone();
7029 revoke_public_invite(&relay, &c, &crate::simd::hex::hex_to_bytes_32(&owner_token)).await.unwrap();
7030 let c = crate::db::community::load_community(&community.id).unwrap().unwrap();
7031 assert_eq!(c.server_root_epoch, crate::community::Epoch(0), "another creator's link remains → no privatize rekey");
7032 assert!(is_public(&c).unwrap(), "still Public (admin's link is live)");
7033 }
7034
7035 #[tokio::test]
7036 async fn failed_banlist_publish_does_not_persist_locally() {
7037 let (_tmp, _guard) = init_test_db();
7040 let relay = MemoryRelay::new();
7041 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7042 let id_hex = community.id.to_hex();
7043 assert!(crate::db::community::get_community_banlist(&id_hex).unwrap().is_empty());
7044
7045 let victim = "cc".repeat(32);
7046 let err = publish_banlist(&FailingRelay, &community, &[victim]).await;
7047 assert!(err.is_err(), "a failed publish must propagate");
7048 assert!(
7049 crate::db::community::get_community_banlist(&id_hex).unwrap().is_empty(),
7050 "local banlist must be untouched when the publish failed"
7051 );
7052 }
7053
7054 #[tokio::test]
7055 async fn metadata_failed_publish_does_not_persist_locally() {
7056 let (_tmp, _guard) = init_test_db();
7060 let relay = MemoryRelay::new();
7061 let mut community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7062 community.name = "Renamed HQ".to_string();
7063 assert!(republish_community_metadata(&FailingRelay, &community).await.is_err());
7064 let loaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
7065 assert_eq!(loaded.name, "HQ", "a failed metadata publish leaves the local name unchanged");
7066 }
7067
7068 #[tokio::test]
7069 async fn send_persists_key_then_delete_round_trip() {
7070 let (_tmp, _guard) = init_test_db();
7071 let relay = MemoryRelay::new();
7072 let community = Community::create("HQ", "general", vec!["r1".into()]);
7073 let channel = community.channels[0].clone();
7074 let alice = Keys::generate();
7075
7076 let _outer = send_message(&relay, &community, &channel, &alice, "deletable", 1).await.unwrap();
7078 let before = fetch_channel_messages(&relay, &community, &channel).await.unwrap();
7079 assert_eq!(before.len(), 1);
7080 let message_id = before[0].message_id.to_hex();
7081
7082 delete_message(&relay, &message_id).await.unwrap();
7084 let after = fetch_channel_messages(&relay, &community, &channel).await.unwrap();
7085 assert!(after.is_empty(), "message should be deleted after delete_message");
7086
7087 assert!(delete_message(&relay, &message_id).await.is_err());
7089 }
7090
7091 #[tokio::test]
7092 async fn failed_delete_publish_preserves_key() {
7093 let (_tmp, _guard) = init_test_db();
7096 let relay = MemoryRelay::new();
7097 let community = Community::create("HQ", "general", vec!["r1".into()]);
7098 let channel = community.channels[0].clone();
7099 let alice = Keys::generate();
7100 send_message(&relay, &community, &channel, &alice, "delete me", 1).await.unwrap();
7101 let message_id = fetch_channel_messages(&relay, &community, &channel).await.unwrap()[0]
7102 .message_id
7103 .to_hex();
7104
7105 assert!(delete_message(&FailingRelay, &message_id).await.is_err());
7107
7108 delete_message(&relay, &message_id).await.unwrap();
7110 assert!(fetch_channel_messages(&relay, &community, &channel).await.unwrap().is_empty());
7111 }
7112
7113 #[tokio::test]
7114 async fn delete_unknown_message_errors() {
7115 let (_tmp, _guard) = init_test_db();
7116 let relay = MemoryRelay::new();
7117 let fake = Keys::generate();
7119 let bogus = EventBuilder::new(Kind::Custom(1), "x").sign_with_keys(&fake).unwrap().id;
7120 assert!(delete_message(&relay, &bogus.to_hex()).await.is_err());
7121 }
7122
7123 #[tokio::test]
7124 async fn accept_invite_persists_member_view() {
7125 let (_tmp, _guard) = init_test_db();
7126 let owner = Community::create("HQ", "general", vec!["r1".into()]);
7127 let invite = crate::community::invite::build_invite(&owner);
7128
7129 let joined = accept_invite(&invite).expect("accept");
7130 assert!(!is_proven_owner(&joined), "joined as member, not owner");
7131 let loaded = crate::db::community::load_community(&owner.id).unwrap().expect("saved");
7133 assert_eq!(loaded.channels[0].key.as_bytes(), owner.channels[0].key.as_bytes());
7134 }
7135
7136 #[tokio::test]
7137 async fn accept_invite_does_not_downgrade_owned_community() {
7138 let (_tmp, _guard) = init_test_db();
7141 let relay = MemoryRelay::new();
7142 let owner = create_community(&relay, "HQ", "general", vec![]).await.unwrap();
7143 assert!(is_proven_owner(&owner), "we are the proven owner");
7144
7145 let invite = crate::community::invite::build_invite(&owner);
7146 let err = accept_invite(&invite).unwrap_err();
7147 assert!(err.contains("already own"), "must refuse to downgrade an owned community, got: {err}");
7148
7149 let reloaded = crate::db::community::load_community(&owner.id).unwrap().unwrap();
7151 assert_eq!(reloaded.server_root_key.as_bytes(), owner.server_root_key.as_bytes());
7152 }
7153
7154 #[tokio::test]
7155 async fn accept_invite_rejects_id_collision_under_different_authority() {
7156 let (_tmp, _guard) = init_test_db();
7161 let legit = Community::create("X", "general", vec!["wss://legit".into()]);
7162 let member_x = accept_invite(&crate::community::invite::build_invite(&legit)).unwrap();
7163 let original_key = member_x.channels[0].key.as_bytes().to_vec();
7164
7165 let attacker = Community::create("evil", "general", vec!["wss://evil".into()]);
7167 let mut hostile = crate::community::invite::build_invite(&attacker);
7168 hostile.community_id = legit.id.to_hex();
7169 assert_ne!(hostile.server_root_key, crate::simd::hex::bytes_to_hex_32(member_x.server_root_key.as_bytes()));
7172
7173 assert!(accept_invite(&hostile).is_err(), "id-collision under new authority must be rejected");
7174
7175 let reloaded = crate::db::community::load_community(&legit.id).unwrap().unwrap();
7177 assert_eq!(reloaded.channels[0].key.as_bytes().to_vec(), original_key);
7178 assert_eq!(reloaded.relays, vec!["wss://legit".to_string()]);
7179 }
7180
7181 #[tokio::test]
7182 async fn rejected_accept_leaves_pending_invite_intact() {
7183 let (_tmp, _guard) = init_test_db();
7186
7187 let owner = attested_community("HQ", "general", vec![]);
7189 crate::db::community::save_community(&owner).unwrap();
7190 let bundle = crate::community::invite::build_invite(&owner).to_json().unwrap();
7191 let cid = owner.id.to_hex();
7192 crate::db::community::save_pending_invite(&cid, &bundle, "npub1inviter").unwrap();
7193
7194 let peeked = crate::db::community::get_pending_invite(&cid).unwrap().expect("parked");
7196 let invite = crate::community::invite::CommunityInvite::from_json(&peeked).unwrap();
7197 assert!(accept_invite(&invite).is_err(), "owning the id → reject");
7198 assert!(
7199 crate::db::community::pending_invite_exists(&cid).unwrap(),
7200 "rejected accept must leave the invite parked"
7201 );
7202
7203 let other = Community::create("Other", "general", vec![]);
7205 let ob = crate::community::invite::build_invite(&other).to_json().unwrap();
7206 let ocid = other.id.to_hex();
7207 crate::db::community::save_pending_invite(&ocid, &ob, "npub1inviter").unwrap();
7208 let op = crate::db::community::get_pending_invite(&ocid).unwrap().unwrap();
7209 let oinvite = crate::community::invite::CommunityInvite::from_json(&op).unwrap();
7210 accept_invite(&oinvite).expect("accept ok");
7211 crate::db::community::delete_pending_invite(&ocid).unwrap();
7212 assert!(!crate::db::community::pending_invite_exists(&ocid).unwrap(), "cleared on success");
7213 }
7214
7215 #[tokio::test]
7216 async fn public_invite_create_fetch_accept_revoke_round_trip() {
7217 let (_tmp, _guard) = init_test_db();
7218 let relay = MemoryRelay::new();
7219 let mut owner = Community::create("Public HQ", "general", vec!["r1".into(), "r2".into()]);
7220 owner.description = Some("everyone welcome".into());
7221 let owner_keys = crate::state::MY_SECRET_KEY.to_keys().unwrap();
7225 owner.owner_attestation = Some(
7226 crate::community::owner::build_owner_attestation_unsigned(owner_keys.public_key(), &owner.id.to_hex())
7227 .sign_with_keys(&owner_keys).unwrap().as_json(),
7228 );
7229 let (token_hex, url) = create_public_invite(&relay, &owner, None, None).await.expect("mint");
7231 assert!(url.contains('#'));
7232 assert_eq!(crate::db::community::list_public_invites(&owner.id.to_hex()).unwrap().len(), 1);
7233
7234 let (relays, token) = public_invite::parse_invite_url(&url).unwrap();
7236 assert_eq!(crate::simd::hex::bytes_to_hex_32(&token), token_hex);
7237 let bundle = fetch_public_invite(&relay, &relays, &token).await.expect("fetch");
7238 assert_eq!(bundle.preview.name, "Public HQ");
7239 assert_eq!(bundle.preview.description.as_deref(), Some("everyone welcome"));
7240
7241 let joined = accept_public_invite(&bundle, 0).expect("accept");
7242 assert_eq!(joined.id, owner.id);
7243 assert_eq!(joined.description.as_deref(), Some("everyone welcome"), "preview patched in");
7244
7245 revoke_public_invite(&relay, &owner, &token).await.expect("revoke");
7247 assert!(fetch_public_invite(&relay, &relays, &token).await.is_err(), "revoked link is dead");
7248 assert!(crate::db::community::list_public_invites(&owner.id.to_hex()).unwrap().is_empty());
7249 }
7250
7251 #[tokio::test]
7252 async fn revoked_invite_dies_even_if_one_relay_kept_the_bundle() {
7253 let (_tmp, _guard) = init_test_db();
7257 let relay = MemoryRelay::new();
7258 let owner = attested_community("HQ", "general", vec!["r1".into(), "r2".into()]);
7259 let (_token_hex, url) = create_public_invite(&relay, &owner, None, None).await.unwrap();
7260 let (relays, token) = public_invite::parse_invite_url(&url).unwrap();
7261 assert!(fetch_public_invite(&relay, &relays, &token).await.is_ok(), "live on both relays");
7262
7263 let tombstone = public_invite::build_public_invite_tombstone(&token).unwrap();
7265 relay.inject(&tombstone, &["r1".to_string()]);
7266
7267 assert!(
7268 fetch_public_invite(&relay, &relays, &token).await.is_err(),
7269 "a tombstone on any one relay kills the link, even with a stale live bundle elsewhere",
7270 );
7271 }
7272
7273 #[tokio::test]
7274 async fn fetch_skips_relay_shadow_junk_to_genuine_bundle() {
7275 use nostr_sdk::prelude::{EventBuilder, Keys, Kind, Tag, TagKind, Timestamp};
7279
7280 let (_tmp, _guard) = init_test_db();
7281 let relay = MemoryRelay::new();
7282 let owner = attested_community("HQ", "general", vec!["r1".into()]);
7283 let (_t, url) = create_public_invite(&relay, &owner, None, None).await.unwrap();
7284 let (relays, token) = public_invite::parse_invite_url(&url).unwrap();
7285
7286 let attacker = Keys::generate();
7289 let junk = EventBuilder::new(Kind::Custom(event_kind::APPLICATION_SPECIFIC), "garbage")
7290 .tags([
7291 Tag::identifier(public_invite::locator_hex(&token)),
7292 Tag::custom(TagKind::Custom("vsk".into()), ["6".to_string()]),
7293 Tag::custom(TagKind::Custom("v".into()), ["1".to_string()]),
7294 ])
7295 .custom_created_at(Timestamp::from_secs(9_000_000_000))
7296 .sign_with_keys(&attacker)
7297 .unwrap();
7298 relay.publish(&junk, &relays).await.unwrap();
7299
7300 let bundle = fetch_public_invite(&relay, &relays, &token).await.expect("genuine survives shadow");
7302 assert_eq!(bundle.preview.name, "HQ");
7303 }
7304
7305 #[tokio::test]
7306 async fn expired_public_invite_is_refused() {
7307 let (_tmp, _guard) = init_test_db();
7308 let relay = MemoryRelay::new();
7309 let owner = attested_community("HQ", "general", vec!["r1".into()]);
7310 let (_t, url) = create_public_invite(&relay, &owner, Some(1000), None).await.unwrap();
7311 let (relays, token) = public_invite::parse_invite_url(&url).unwrap();
7312 let bundle = fetch_public_invite(&relay, &relays, &token).await.unwrap();
7313 assert!(accept_public_invite(&bundle, 2000).is_err());
7315 assert!(crate::db::community::load_community(&owner.id).unwrap().is_none());
7316 }
7317
7318 #[tokio::test]
7319 async fn republish_metadata_saves_and_publishes() {
7320 use crate::community::CommunityImage;
7321 let (_tmp, _guard) = init_test_db();
7322 let relay = MemoryRelay::new();
7323 let mut owner = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7326 let cid = owner.id.to_hex();
7327
7328 owner.name = "HQ Renamed".into();
7330 owner.description = Some("now with topic".into());
7331 owner.icon = Some(CommunityImage {
7332 url: "https://b/x".into(), key: "aa".repeat(32), nonce: "bb".repeat(12),
7333 hash: "cc".repeat(32), ext: "png".into(),
7334 });
7335 republish_community_metadata(&relay, &owner).await.expect("republish");
7336
7337 let loaded = crate::db::community::load_community(&owner.id).unwrap().unwrap();
7339 assert_eq!(loaded.name, "HQ Renamed");
7340 assert_eq!(loaded.description.as_deref(), Some("now with topic"));
7341 assert_eq!(loaded.icon.unwrap().url, "https://b/x");
7342
7343 let (head_v, _) = crate::db::community::get_edition_head(&cid, &cid).unwrap().unwrap();
7346 assert_eq!(head_v, 2, "GroupRoot edition advanced v1 (create) → v2 (republish)");
7347 let z = crate::community::roster::control_pseudonym(&owner.server_root_key, &owner.id, crate::community::Epoch(0));
7348 let control = relay
7349 .fetch(&Query { kinds: vec![event_kind::COMMUNITY_CONTROL], z_tags: vec![z], ..Default::default() }, &owner.relays)
7350 .await
7351 .unwrap();
7352 let newest = control
7353 .iter()
7354 .filter_map(|o| crate::community::roster::open_control_edition(o, &owner.server_root_key).ok())
7355 .filter_map(|i| crate::community::edition::parse_edition_inner(&i).ok())
7356 .filter(|p| p.entity_id == owner.id.0)
7357 .max_by_key(|p| p.version)
7358 .expect("GroupRoot edition on the relay");
7359 let meta: crate::community::metadata::CommunityMetadata = serde_json::from_str(&newest.content).unwrap();
7360 assert_eq!(meta.name, "HQ Renamed");
7361 assert_eq!(meta.icon.unwrap().ext, "png");
7362 }
7363
7364 #[tokio::test]
7365 async fn member_cannot_republish_metadata() {
7366 let (_tmp, _guard) = init_test_db();
7367 let relay = MemoryRelay::new();
7368 let owner = Community::create("HQ", "general", vec!["r1".into()]);
7369 let member = crate::community::invite::accept_invite(&crate::community::invite::build_invite(&owner)).unwrap();
7370 assert!(republish_community_metadata(&relay, &member).await.is_err());
7371 }
7372
7373 #[tokio::test]
7374 async fn member_cannot_mint_public_invite() {
7375 let (_tmp, _guard) = init_test_db();
7376 let relay = MemoryRelay::new();
7377 let owner = Community::create("HQ", "general", vec!["r1".into()]);
7378 let member = crate::community::invite::accept_invite(&crate::community::invite::build_invite(&owner)).unwrap();
7379 assert!(create_public_invite(&relay, &member, None, None).await.is_err(), "members can't mint links");
7380 }
7381
7382 #[tokio::test]
7383 async fn accept_oversized_bundle_rejected() {
7384 let (_tmp, _guard) = init_test_db();
7385 let owner = Community::create("HQ", "general", vec![]);
7386 let mut invite = crate::community::invite::build_invite(&owner);
7387 let template = invite.channels[0].clone();
7389 for _ in 0..300 {
7390 invite.channels.push(template.clone());
7391 }
7392 assert!(accept_invite(&invite).is_err(), "oversized bundle must be rejected");
7393 assert!(crate::db::community::load_community(&owner.id).unwrap().is_none(), "nothing persisted");
7394 }
7395
7396 async fn publish_tombstone<T: Transport + ?Sized>(transport: &T, community: &Community, author: &Keys, created_at: u64) {
7402 let inner = crate::community::roster::build_group_dissolved_edition_unsigned(author.public_key(), &community.id, created_at)
7403 .sign_with_keys(author).unwrap();
7404 let outer = crate::community::roster::seal_control_edition(&Keys::generate(), &inner, &community.server_root_key, &community.id, community.server_root_epoch).unwrap();
7405 transport.publish_durable(&outer, &community.relays).await.unwrap();
7406 }
7407
7408 struct RekeyCountingRelay {
7411 inner: MemoryRelay,
7412 rekeys: std::sync::atomic::AtomicUsize,
7413 }
7414 impl RekeyCountingRelay {
7415 fn new() -> Self { Self { inner: MemoryRelay::new(), rekeys: std::sync::atomic::AtomicUsize::new(0) } }
7416 fn count(&self, e: &Event) {
7417 if e.kind.as_u16() == event_kind::COMMUNITY_REKEY {
7418 self.rekeys.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
7419 }
7420 }
7421 }
7422 #[async_trait::async_trait]
7423 impl Transport for RekeyCountingRelay {
7424 async fn publish(&self, e: &Event, r: &[String]) -> Result<(), String> { self.count(e); self.inner.publish(e, r).await }
7425 async fn publish_durable(&self, e: &Event, r: &[String]) -> Result<(), String> { self.count(e); self.inner.publish_durable(e, r).await }
7426 async fn fetch(&self, q: &Query, r: &[String]) -> Result<Vec<Event>, String> { self.inner.fetch(q, r).await }
7427 }
7428
7429 #[tokio::test]
7430 async fn owner_tombstone_folds_to_dissolved() {
7431 let (_tmp, _guard) = init_test_db();
7432 let relay = MemoryRelay::new();
7433 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7435 let cid = community.id.to_hex();
7436 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
7437 publish_tombstone(&relay, &community, &owner, 1000).await;
7438
7439 assert!(!crate::db::community::get_community_dissolved(&cid).unwrap(), "alive before the fold");
7440 fetch_and_apply_control(&relay, &community).await.unwrap();
7441 assert!(crate::db::community::get_community_dissolved(&cid).unwrap(), "owner tombstone seals the community");
7442 }
7443
7444 #[tokio::test]
7445 async fn non_owner_tombstone_is_ignored() {
7446 let (_tmp, _guard) = init_test_db();
7447 let relay = MemoryRelay::new();
7448 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7449 let cid = community.id.to_hex();
7450 let mallory = Keys::generate();
7453 publish_tombstone(&relay, &community, &mallory, 1000).await;
7454
7455 fetch_and_apply_control(&relay, &community).await.unwrap();
7456 assert!(!crate::db::community::get_community_dissolved(&cid).unwrap(), "a non-owner tombstone is ignored");
7457 }
7458
7459 #[tokio::test]
7460 async fn unreadable_deed_rejects_the_tombstone() {
7461 let (_tmp, _guard) = init_test_db();
7462 let relay = MemoryRelay::new();
7463 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
7464 let mut community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7465 let cid = community.id.to_hex();
7466 publish_tombstone(&relay, &community, &owner, 1000).await;
7467 community.owner_attestation = None;
7469 crate::db::community::save_community(&community).unwrap();
7470 let stripped = crate::db::community::load_community(&community.id).unwrap().unwrap();
7471
7472 fetch_and_apply_control(&relay, &stripped).await.unwrap();
7473 assert!(!crate::db::community::get_community_dissolved(&cid).unwrap(), "unverifiable tombstone is rejected, not death-by-default");
7474 }
7475
7476 #[tokio::test]
7477 async fn binary_seal_drops_every_subsequent_event_with_no_timestamp_test() {
7478 let (_tmp, _guard) = init_test_db();
7479 let relay = MemoryRelay::new();
7480 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
7481 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7482 let cid = community.id.to_hex();
7483 publish_tombstone(&relay, &community, &owner, 1000).await;
7484 fetch_and_apply_control(&relay, &community).await.unwrap();
7485 assert!(crate::db::community::get_community_dissolved(&cid).unwrap());
7486
7487 let sealed = crate::db::community::load_community(&community.id).unwrap().unwrap();
7489 let channel = sealed.channels[0].clone();
7490 let me = owner.public_key();
7491
7492 let backdated = super::super::envelope::seal_message(
7494 &Keys::generate(), &channel.key, &channel.id, channel.epoch, "ghost", 1,
7495 ).unwrap();
7496 let mut state = crate::state::ChatState::new();
7497 assert!(super::super::inbound::process_incoming(&mut state, &backdated, &channel, &me).is_none(),
7498 "a backdated message after the seal is dropped (binary seal, no timestamp test)");
7499
7500 publish_tombstone(&relay, &sealed, &owner, 2000).await;
7502 assert_eq!(fetch_and_apply_control(&relay, &sealed).await.unwrap(), 0,
7503 "control fold stops advancing once sealed");
7504 }
7505
7506 #[tokio::test]
7507 async fn dissolve_community_emits_no_rekey_and_no_epoch_bump() {
7508 let (_tmp, _guard) = init_test_db();
7509 let relay = RekeyCountingRelay::new();
7510 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7511 let cid = community.id.to_hex();
7512 create_public_invite(&relay, &community, None, None).await.unwrap();
7514 let before_epoch = crate::db::community::load_community(&community.id).unwrap().unwrap().server_root_epoch;
7515
7516 dissolve_community(&relay, &community).await.unwrap();
7517
7518 assert!(crate::db::community::get_community_dissolved(&cid).unwrap(), "sealed locally");
7519 assert_eq!(relay.rekeys.load(std::sync::atomic::Ordering::Relaxed), 0,
7520 "dissolution publishes NO 3303 rekey (no last-link privatize re-founding)");
7521 assert_eq!(crate::db::community::load_community(&community.id).unwrap().unwrap().server_root_epoch, before_epoch,
7522 "base epoch unchanged — dissolution rotates nothing");
7523 }
7524
7525 #[tokio::test]
7526 async fn duplicate_owner_tombstones_are_idempotent() {
7527 let (_tmp, _guard) = init_test_db();
7528 let relay = MemoryRelay::new();
7529 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
7530 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7531 let cid = community.id.to_hex();
7532 publish_tombstone(&relay, &community, &owner, 1000).await;
7534 publish_tombstone(&relay, &community, &owner, 2000).await;
7535
7536 fetch_and_apply_control(&relay, &community).await.unwrap();
7537 assert!(crate::db::community::get_community_dissolved(&cid).unwrap(), "duplicates still just dissolve, no error");
7538 assert_eq!(fetch_and_apply_control(&relay, &community).await.unwrap(), 0);
7540 }
7541
7542 #[test]
7543 fn apply_server_root_rekey_refuses_once_dissolved() {
7544 let (_tmp, _guard) = init_test_db();
7545 let owner = Keys::generate();
7546 let me = Keys::generate();
7547 become_local(&me);
7548 let community = saved_community_owned_by(&owner);
7549 let cid = community.id.to_hex();
7550 crate::db::community::set_community_dissolved(&cid).unwrap();
7551
7552 let parsed = owner_base_rekey(&owner, &community, &me.public_key(), 1, &[0xCDu8; 32]);
7553 assert!(apply_server_root_rekey(&community, &parsed).is_err(),
7554 "a base rekey cannot cross a tombstone");
7555 assert_eq!(crate::db::community::load_community(&community.id).unwrap().unwrap().server_root_epoch,
7556 crate::community::Epoch(0), "base epoch did not advance");
7557 }
7558
7559 #[tokio::test]
7560 async fn tombstone_detected_after_a_base_rotation() {
7561 let (_tmp, _guard) = init_test_db();
7562 let relay = MemoryRelay::new();
7563 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
7564 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7565 let cid = community.id.to_hex();
7566 rotate_server_root(&relay, &community, &[owner.public_key()]).await.unwrap();
7569 let rotated = crate::db::community::load_community(&community.id).unwrap().unwrap();
7570 assert_eq!(rotated.server_root_epoch, crate::community::Epoch(1));
7571 publish_tombstone(&relay, &rotated, &owner, 1000).await;
7572
7573 fetch_and_apply_control(&relay, &rotated).await.unwrap();
7574 assert!(crate::db::community::get_community_dissolved(&cid).unwrap(),
7575 "tombstone at the rotation-stable locator is detected post-rotation");
7576 }
7577
7578 #[tokio::test]
7579 async fn stable_coordinate_tombstone_survives_a_concurrent_rotation() {
7580 let (_tmp, _guard) = init_test_db();
7585 let relay = MemoryRelay::new();
7586 let owner = crate::state::MY_SECRET_KEY.to_keys().unwrap();
7587 let community = create_community(&relay, "HQ", "general", vec!["r1".into()]).await.unwrap();
7588 let cid = community.id.to_hex();
7589 let inner = crate::community::roster::build_group_dissolved_edition_unsigned(owner.public_key(), &community.id, 1000)
7591 .sign_with_keys(&owner).unwrap();
7592 let stable = crate::community::roster::seal_dissolved_edition(&Keys::generate(), &inner, &community.id).unwrap();
7593 relay.inject(&stable, &community.relays);
7594 rotate_server_root(&relay, &community, &[owner.public_key()]).await.unwrap();
7597 let rotated = crate::db::community::load_community(&community.id).unwrap().unwrap();
7598 assert_eq!(rotated.server_root_epoch, crate::community::Epoch(1));
7599 assert!(!crate::db::community::get_community_dissolved(&cid).unwrap(), "not folded yet");
7600 fetch_and_apply_control(&relay, &rotated).await.unwrap();
7603 assert!(crate::db::community::get_community_dissolved(&cid).unwrap(),
7604 "stable-coordinate probe discovers the tombstone cross-epoch (C3 closed)");
7605 }
7606}