use std::collections::{BTreeMap, BTreeSet}; use anyhow::{Result, bail}; use nostr_sdk::prelude::{Event, Keys, PublicKey, Timestamp, UnsignedEvent}; use serde::{Deserialize, Serialize}; use crate::derive::{ banlist_locator, community_id_of, control_group_key, control_signer_group_key, grant_locator, verify_community_id, }; use crate::edition::{ AuthorityCitation, EditionFields, EditionMeta, EntityHead, Floors, ParsedEdition, build_edition, fold_head, parse_edition, vsk, }; use crate::roles::{ AuthorityEdition, CommunityRoles, Grant, Permissions, Role, Roster, citation_ok, fold_roster, }; use crate::stream::{KIND_WRAP, SealForm, build_seal, open_wrap_at, wrap_seal_with}; use crate::{ChannelId, CommunityId, Epoch, Extra, GroupKey, random_32}; pub const MAX_NAME_BYTES: usize = 64; pub const MAX_DESCRIPTION_BYTES: usize = 10_000; pub const MAX_RELAYS: usize = 5; pub const GENERAL_CHANNEL: &str = "general"; pub const ROOT_EPOCH: Epoch = Epoch(0); const GENESIS_VERSION: u64 = 1; #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct ImageRef { pub url: String, pub key: String, pub nonce: String, pub hash: String, #[serde(flatten)] pub extra: Extra, } #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct CommunityMetadata { pub name: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub relays: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub icon: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub banner: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub custom: Option, #[serde(flatten)] pub extra: Extra, } #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct ChannelMetadata { pub name: String, pub private: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub voice: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub deleted: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub custom: Option, #[serde(flatten)] pub extra: Extra, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct CommunityIdentity { pub community_id: CommunityId, pub owner: PublicKey, pub owner_salt: [u8; 32], } impl CommunityIdentity { pub fn verify(&self) -> bool { verify_community_id(&self.community_id, &self.owner.to_bytes(), &self.owner_salt) } } #[derive(Debug, Clone)] pub struct CommunityGenesis { pub identity: CommunityIdentity, pub community_root: [u8; 32], pub control_root: [u8; 32], pub channel_id: ChannelId, pub wraps: Vec, } pub fn genesis( owner: &Keys, metadata: &CommunityMetadata, at_secs: u64, ) -> Result { let metadata_content = encode_metadata(metadata)?; let owner_salt = random_32()?; let identity = CommunityIdentity { community_id: community_id_of(&owner.public_key().to_bytes(), &owner_salt), owner: owner.public_key(), owner_salt, }; let community_root = random_32()?; let control_root = random_32()?; let channel_id = ChannelId::from_bytes(random_32()?); let read = control_group_key(&community_root, &identity.community_id, ROOT_EPOCH)?; let signer = control_signer_group_key(&control_root, &identity.community_id, ROOT_EPOCH)?; let channel_content = serde_json::to_string(&ChannelMetadata { name: GENERAL_CHANNEL.to_owned(), private: false, ..ChannelMetadata::default() })?; let editions = [ build_edition(EditionFields { author: identity.owner, subkind: vsk::COMMUNITY_METADATA, entity: *identity.community_id.as_bytes(), version: GENESIS_VERSION, prev: None, citation: None, content: &metadata_content, at_secs, }), build_edition(EditionFields { author: identity.owner, subkind: vsk::CHANNEL_METADATA, entity: *channel_id.as_bytes(), version: GENESIS_VERSION, prev: None, citation: None, content: &channel_content, at_secs, }), ]; let mut wraps = Vec::with_capacity(editions.len()); for edition in &editions { wraps.push(seal_edition(edition, owner, &read, &signer, at_secs)?); } Ok(CommunityGenesis { identity, community_root, control_root, channel_id, wraps, }) } /// Opens a Control Plane wrap from its reading key alone. pub fn open_edition( wrap: &Event, read: &GroupKey, address: &PublicKey, verify_wrap_signature: bool, ) -> Result { let opened = open_wrap_at(wrap, address, read.conversation(), verify_wrap_signature)?; if opened.seal_form != SealForm::Plaintext { bail!("control editions require a plaintext seal"); } Ok(parse_edition(&opened.rumor)?) } /// Appends editions to entity chains. pub struct ControlWriter { pub author: PublicKey, pub read: GroupKey, pub signer: GroupKey, } pub struct Edition<'a> { pub subkind: &'a str, pub entity: [u8; 32], pub content: &'a str, /// The head this edition supersedes. /// /// `None` starts the chain. pub head: Option<&'a EntityHead>, pub citation: Option, } impl ControlWriter { pub fn publish( &self, keys: &Keys, edition: Edition<'_>, at_secs: u64, ) -> Result<(Event, EntityHead)> { let rumor = build_edition(EditionFields { author: self.author, subkind: edition.subkind, entity: edition.entity, version: edition .head .map_or(GENESIS_VERSION, |head| head.version + 1), prev: edition.head.map(|head| head.self_hash), citation: edition.citation, content: edition.content, at_secs, }); let parsed = parse_edition(&rumor)?; let wrap = seal_edition(&rumor, keys, &self.read, &self.signer, at_secs)?; Ok((wrap, EntityHead::from(&parsed))) } pub fn set_community_metadata( &self, keys: &Keys, community_id: &CommunityId, metadata: &CommunityMetadata, head: Option<&EntityHead>, citation: Option, at_secs: u64, ) -> Result<(Event, EntityHead)> { let content = encode_metadata(metadata)?; self.publish( keys, Edition { subkind: vsk::COMMUNITY_METADATA, entity: *community_id.as_bytes(), content: &content, head, citation, }, at_secs, ) } pub fn set_channel_metadata( &self, keys: &Keys, channel: &ChannelId, metadata: &ChannelMetadata, head: Option<&EntityHead>, citation: Option, at_secs: u64, ) -> Result<(Event, EntityHead)> { let content = serde_json::to_string(metadata)?; self.publish( keys, Edition { subkind: vsk::CHANNEL_METADATA, entity: *channel.as_bytes(), content: &content, head, citation, }, at_secs, ) } pub fn set_role( &self, keys: &Keys, role: &Role, head: Option<&EntityHead>, citation: Option, at_secs: u64, ) -> Result<(Event, EntityHead)> { let content = role.to_content()?; self.publish( keys, Edition { subkind: vsk::ROLE, entity: *role.role_id.as_bytes(), content: &content, head, citation, }, at_secs, ) } pub fn set_grant( &self, keys: &Keys, community_id: &CommunityId, grant: &Grant, head: Option<&EntityHead>, citation: Option, at_secs: u64, ) -> Result<(Event, EntityHead)> { let content = grant.to_content()?; self.publish( keys, Edition { subkind: vsk::GRANT, entity: grant_locator(community_id, &grant.member.to_bytes()), content: &content, head, citation, }, at_secs, ) } pub fn set_banlist( &self, keys: &Keys, community_id: &CommunityId, banned: &BTreeSet, head: Option<&EntityHead>, citation: Option, at_secs: u64, ) -> Result<(Event, EntityHead)> { let entries: Vec = banned.iter().map(PublicKey::to_hex).collect(); let content = serde_json::to_string(&entries)?; self.publish( keys, Edition { subkind: vsk::BANLIST, entity: banlist_locator(community_id), content: &content, head, citation, }, at_secs, ) } } fn encode_metadata(metadata: &CommunityMetadata) -> Result { if metadata.name.len() > MAX_NAME_BYTES { bail!("community name exceeds {MAX_NAME_BYTES} bytes"); } if metadata .description .as_ref() .is_some_and(|description| description.len() > MAX_DESCRIPTION_BYTES) { bail!("community description exceeds {MAX_DESCRIPTION_BYTES} bytes"); } let mut metadata = metadata.clone(); metadata.relays.truncate(MAX_RELAYS); Ok(serde_json::to_string(&metadata)?) } #[derive(Debug, Clone, Default)] pub struct ControlFold { pub roles: CommunityRoles, pub banned: BTreeSet, pub community: Option, pub channels: BTreeMap, pub floors: Floors, pub gapped: bool, } pub fn fold_control( owner: &PublicKey, community_id: &CommunityId, editions: &[ParsedEdition], floors: &Floors, held_bans: &BTreeSet, ) -> ControlFold { let authority: Vec = editions .iter() .filter_map(|edition| AuthorityEdition::parse(edition, community_id)) .collect(); let roster = fold_roster(owner, community_id, &authority, floors, held_bans); let metadata = fold_metadata(owner, community_id, editions, &roster, floors); let mut floors = roster.floors; floors.extend(metadata.floors); ControlFold { roles: roster.roles, banned: roster.banned, community: metadata.community, channels: metadata.channels, floors, gapped: roster.gapped || metadata.gapped, } } #[derive(Debug, Default)] struct MetadataFold { community: Option, channels: BTreeMap, floors: Floors, gapped: bool, } fn fold_metadata( owner: &PublicKey, community_id: &CommunityId, editions: &[ParsedEdition], roster: &Roster, floors: &Floors, ) -> MetadataFold { let judge = Judge { owner, community_id, roster, floors, }; let community_entity = *community_id.as_bytes(); let mut community: Vec<&ParsedEdition> = Vec::new(); let mut channels: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new(); for edition in editions { match edition.subkind.as_str() { // A channel addressed at the community's own coordinate would share, and // corrupt, the metadata chain's floor. vsk::COMMUNITY_METADATA if edition.entity == community_entity => { community.push(edition) } vsk::CHANNEL_METADATA if edition.entity != community_entity => { channels.entry(edition.entity).or_default().push(edition); } _ => {} } } let mut fold = MetadataFold::default(); if let Some(head) = authorized_head( &judge, community_entity, &community, Permissions::MANAGE_METADATA, &mut fold.gapped, ) { fold.community = serde_json::from_str(&head.content).ok(); fold.floors.insert(head.entity, EntityHead::from(head)); } for (entity, candidates) in &channels { let Some(head) = authorized_head( &judge, *entity, candidates, Permissions::MANAGE_CHANNELS, &mut fold.gapped, ) else { continue; }; fold.floors.insert(*entity, EntityHead::from(head)); if let Ok(metadata) = serde_json::from_str::(&head.content) { fold.channels .insert(ChannelId::from_bytes(*entity), metadata); } } fold } struct Judge<'a> { owner: &'a PublicKey, community_id: &'a CommunityId, roster: &'a Roster, floors: &'a Floors, } fn authorized_head<'a>( judge: &Judge<'_>, entity: [u8; 32], candidates: &[&'a ParsedEdition], permission: u64, gapped: &mut bool, ) -> Option<&'a ParsedEdition> { let authorized: Vec<&ParsedEdition> = candidates .iter() .copied() .filter(|edition| { // A banned npub's edits are dropped even while a grant naming them still carries the bit. !judge.roster.banned.contains(&edition.author) && judge .roster .roles .is_authorized(&edition.author, judge.owner, permission) && citation_ok( judge.owner, judge.community_id, &edition.author, edition.citation.as_ref(), &judge.roster.floors, ) }) .collect(); if authorized.is_empty() { return None; } let metas: Vec = authorized .iter() .map(|edition| EditionMeta::from(*edition)) .collect(); let selection = fold_head(&metas, judge.floors.get(&entity)); *gapped |= selection.gap; selection.head.map(|index| authorized[index]) } fn seal_edition( edition: &UnsignedEvent, owner: &Keys, read: &GroupKey, signer: &GroupKey, at_secs: u64, ) -> Result { let seal = build_seal(edition, SealForm::Plaintext, read, owner)?; let (wrap, _) = wrap_seal_with( &seal, read.conversation(), signer.keys(), KIND_WRAP, Timestamp::from_secs(at_secs), &[], )?; Ok(wrap) } #[cfg(test)] mod tests { use nostr_memory::MemoryDatabase; use super::*; use crate::derive::grant_locator; use crate::edition::fold; use crate::roles::{Grant, Role, RoleScope}; use crate::store::{CommunityState, load_state, save_state}; use crate::{Extra, RoleId}; const AT: u64 = 1_700_000_000; fn holder(minted: &CommunityGenesis) -> (GroupKey, GroupKey) { let community_id = minted.identity.community_id; ( control_group_key(&minted.community_root, &community_id, ROOT_EPOCH).expect("derives"), control_signer_group_key(&minted.control_root, &community_id, ROOT_EPOCH) .expect("derives"), ) } fn open_all(wraps: &[Event], read: &GroupKey, address: &PublicKey) -> Vec { wraps .iter() .map(|wrap| open_edition(wrap, read, address, true).expect("opens")) .collect() } fn metadata(name: &str) -> CommunityMetadata { CommunityMetadata { name: name.to_owned(), ..CommunityMetadata::default() } } #[test] fn genesis_reopens_for_a_second_holder() { let owner = Keys::generate(); let community_metadata = CommunityMetadata { name: "coop".to_owned(), relays: vec!["wss://relay.example".to_owned()], ..CommunityMetadata::default() }; let minted = genesis(&owner, &community_metadata, AT).expect("mints"); assert!(minted.identity.verify(), "identity is self-certifying"); // Only what an invite hands over: the roots, the community id and the owner salt. let (read, signer) = holder(&minted); let editions = open_all(&minted.wraps, &read, &signer.pk()); assert_eq!(editions.len(), 2); let community = &editions[0]; assert_eq!(community.subkind, vsk::COMMUNITY_METADATA); assert_eq!(community.entity, *minted.identity.community_id.as_bytes()); assert_eq!(community.author, owner.public_key()); assert_eq!((community.version, community.prev), (1, None)); assert_eq!( serde_json::from_str::(&community.content) .expect("parses") .name, "coop" ); let channel = &editions[1]; assert_eq!(channel.subkind, vsk::CHANNEL_METADATA); assert_eq!(channel.entity, *minted.channel_id.as_bytes()); for edition in &editions { let folded = fold(&[EditionMeta::from(edition)], 0, None); assert_eq!(folded.head, Some(0)); assert!( folded.anchored && !folded.gap, "genesis anchors at its floor" ); } let state = CommunityState::from_genesis(&minted, &editions, AT * 1_000).expect("projects"); smol::block_on(async { let database = MemoryDatabase::unbounded(); save_state(&database, &state).await.expect("saves"); let loaded = load_state(&database, &minted.identity.community_id) .await .expect("loads") .expect("present"); assert_eq!(loaded.community_root, minted.community_root); assert_eq!(loaded.control_root, Some(minted.control_root)); assert_eq!(loaded.channels.len(), 1); assert_eq!(loaded.heads.len(), 2); }); } #[test] fn metadata_and_channel_edits_reach_a_second_client() { let owner = Keys::generate(); let minted = genesis(&owner, &metadata("coop"), AT).expect("mints"); let community_id = minted.identity.community_id; let owner_pk = owner.public_key(); let (read, signer) = holder(&minted); let genesis_editions = open_all(&minted.wraps, &read, &signer.pk()); let roster = fold_control( &owner_pk, &community_id, &genesis_editions, &Floors::new(), &BTreeSet::new(), ); assert_eq!( roster.community.as_ref().map(|meta| meta.name.as_str()), Some("coop") ); let writer = ControlWriter { author: owner_pk, read: read.clone(), signer: signer.clone(), }; let community_head = roster.floors.get(community_id.as_bytes()).expect("head"); let channel_head = roster .floors .get(minted.channel_id.as_bytes()) .expect("head"); let (community_wrap, _) = writer .set_community_metadata( &owner, &community_id, &CommunityMetadata { relays: vec!["wss://relay.example".to_owned()], ..metadata("coop two") }, Some(community_head), None, AT + 1, ) .expect("publishes"); let (channel_wrap, _) = writer .set_channel_metadata( &owner, &minted.channel_id, &ChannelMetadata { name: "lobby".to_owned(), private: false, ..ChannelMetadata::default() }, Some(channel_head), None, AT + 2, ) .expect("publishes"); let mut edited = genesis_editions.clone(); edited.extend(open_all( &[community_wrap, channel_wrap], &read, &signer.pk(), )); let folded = fold_control( &owner_pk, &community_id, &edited, &Floors::new(), &BTreeSet::new(), ); assert_eq!( folded.community.as_ref().map(|meta| meta.name.as_str()), Some("coop two") ); assert_eq!( folded .channels .get(&minted.channel_id) .map(|channel| channel.name.as_str()), Some("lobby") ); // A relay serving only the editions a client already folded past must not walk // the community backwards. let stale = fold_control( &owner_pk, &community_id, &genesis_editions, &folded.floors, &BTreeSet::new(), ); assert!(stale.community.is_none()); assert!(stale.channels.is_empty()); let mut state = CommunityState::from_genesis(&minted, &genesis_editions, AT * 1_000).expect("projects"); state.apply_fold(&folded); assert_eq!(state.channels.len(), 1); assert_eq!(state.channels[0].name, "lobby"); assert_eq!(state.relays.len(), 1); } #[test] fn a_delegated_member_edits_metadata_only_under_its_own_grant() { let owner = Keys::generate(); let member = Keys::generate(); let minted = genesis(&owner, &metadata("coop"), AT).expect("mints"); let community_id = minted.identity.community_id; let owner_pk = owner.public_key(); let (read, signer) = holder(&minted); let writer = ControlWriter { author: owner_pk, read: read.clone(), signer: signer.clone(), }; let role_id = RoleId::from_bytes([0x07; 32]); let role = Role { role_id, name: "Mod".to_owned(), position: 1, permissions: Permissions(Permissions::MANAGE_METADATA), scope: RoleScope::Server, color: 0, extra: Extra::default(), }; let (role_wrap, _) = writer .publish( &owner, Edition { subkind: vsk::ROLE, entity: *role_id.as_bytes(), content: &role.to_content().expect("serializes"), head: None, citation: None, }, AT + 1, ) .expect("publishes"); let (grant_wrap, _) = writer .publish( &owner, Edition { subkind: vsk::GRANT, entity: grant_locator(&community_id, &member.public_key().to_bytes()), content: &Grant { member: member.public_key(), role_ids: vec![role_id], control_wrap: None, extra: Extra::default(), } .to_content() .expect("serializes"), head: None, citation: None, }, AT + 2, ) .expect("publishes"); let mut base = open_all(&minted.wraps, &read, &signer.pk()); base.extend(open_all(&[role_wrap, grant_wrap], &read, &signer.pk())); let roster = fold_control( &owner_pk, &community_id, &base, &Floors::new(), &BTreeSet::new(), ); assert!(roster.roles.is_staff(&member.public_key(), &owner_pk)); let grant = roster .floors .get(&grant_locator( &community_id, &member.public_key().to_bytes(), )) .expect("the member's grant folded"); let head = roster.floors.get(community_id.as_bytes()).expect("head"); // The member seals with their own keys and wraps with the staff write key. let member_writer = ControlWriter { author: member.public_key(), read, signer: signer.clone(), }; let content = serde_json::to_string(&metadata("coop by mod")).expect("serializes"); let (uncited, _) = member_writer .publish( &member, Edition { subkind: vsk::COMMUNITY_METADATA, entity: *community_id.as_bytes(), content: &content, head: Some(head), citation: None, }, AT + 3, ) .expect("publishes"); let (cited, _) = member_writer .publish( &member, Edition { subkind: vsk::COMMUNITY_METADATA, entity: *community_id.as_bytes(), content: &content, head: Some(head), citation: Some(AuthorityCitation { entity: grant.entity, version: grant.version, hash: grant.self_hash, }), }, AT + 4, ) .expect("publishes"); // Uncited, the edit claims an authority the member never showed. let mut forged = base.clone(); forged.extend(open_all(&[uncited], &member_writer.read, &signer.pk())); let folded = fold_control( &owner_pk, &community_id, &forged, &Floors::new(), &BTreeSet::new(), ); assert_eq!( folded.community.as_ref().map(|meta| meta.name.as_str()), Some("coop") ); let mut edited_editions = base; edited_editions.extend(open_all(&[cited], &member_writer.read, &signer.pk())); let folded = fold_control( &owner_pk, &community_id, &edited_editions, &Floors::new(), &BTreeSet::new(), ); assert_eq!( folded.community.as_ref().map(|meta| meta.name.as_str()), Some("coop by mod") ); } }