892 lines
26 KiB
Rust
892 lines
26 KiB
Rust
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<String>,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub relays: Vec<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub icon: Option<ImageRef>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub banner: Option<ImageRef>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub custom: Option<Extra>,
|
|
#[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<bool>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub deleted: Option<bool>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub custom: Option<Extra>,
|
|
#[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<Event>,
|
|
}
|
|
|
|
pub fn genesis(
|
|
owner: &Keys,
|
|
metadata: &CommunityMetadata,
|
|
at_secs: u64,
|
|
) -> Result<CommunityGenesis> {
|
|
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<ParsedEdition> {
|
|
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<AuthorityCitation>,
|
|
}
|
|
|
|
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<AuthorityCitation>,
|
|
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<AuthorityCitation>,
|
|
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<AuthorityCitation>,
|
|
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<AuthorityCitation>,
|
|
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<PublicKey>,
|
|
head: Option<&EntityHead>,
|
|
citation: Option<AuthorityCitation>,
|
|
at_secs: u64,
|
|
) -> Result<(Event, EntityHead)> {
|
|
let entries: Vec<String> = 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<String> {
|
|
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<PublicKey>,
|
|
pub community: Option<CommunityMetadata>,
|
|
pub channels: BTreeMap<ChannelId, ChannelMetadata>,
|
|
pub floors: Floors,
|
|
pub gapped: bool,
|
|
}
|
|
|
|
pub fn fold_control(
|
|
owner: &PublicKey,
|
|
community_id: &CommunityId,
|
|
editions: &[ParsedEdition],
|
|
floors: &Floors,
|
|
held_bans: &BTreeSet<PublicKey>,
|
|
) -> ControlFold {
|
|
let authority: Vec<AuthorityEdition> = 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<CommunityMetadata>,
|
|
channels: BTreeMap<ChannelId, ChannelMetadata>,
|
|
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::<ChannelMetadata>(&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<EditionMeta> = 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<Event> {
|
|
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<ParsedEdition> {
|
|
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::<CommunityMetadata>(&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")
|
|
);
|
|
}
|
|
}
|