add control fold and roster
This commit is contained in:
+572
-58
@@ -1,14 +1,21 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use nostr_sdk::prelude::{Event, Keys, PublicKey, Timestamp, UnsignedEvent};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::derive::{
|
||||
community_id_of, control_group_key, control_signer_group_key, verify_community_id,
|
||||
};
|
||||
use crate::edition::{EditionFields, ParsedEdition, build_edition, parse_edition, vsk};
|
||||
use crate::edition::{
|
||||
AuthorityCitation, EditionFields, EditionMeta, EntityHead, Floors, ParsedEdition,
|
||||
build_edition, fold_head, parse_edition, vsk,
|
||||
};
|
||||
use crate::roles::{
|
||||
AuthorityEdition, CommunityRoles, Permissions, Roster, citation_ok, fold_roster,
|
||||
};
|
||||
use crate::stream::{KIND_WRAP, SealForm, build_seal, open_wrap_at, wrap_seal_with};
|
||||
use crate::{ChannelId, CommunityId, Epoch, GroupKey, random_32};
|
||||
use crate::{ChannelId, CommunityId, Epoch, Extra, GroupKey, random_32};
|
||||
|
||||
pub const MAX_NAME_BYTES: usize = 64;
|
||||
pub const MAX_DESCRIPTION_BYTES: usize = 10_000;
|
||||
@@ -17,12 +24,8 @@ pub const MAX_RELAYS: usize = 5;
|
||||
pub const GENERAL_CHANNEL: &str = "general";
|
||||
pub const ROOT_EPOCH: Epoch = Epoch(0);
|
||||
|
||||
/// The first edition every entity starts at. Genuinely 1, not 0: a version of
|
||||
/// 0 is what the fold treats as a gap.
|
||||
const GENESIS_VERSION: u64 = 1;
|
||||
|
||||
type Extra = Map<String, Value>;
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ImageRef {
|
||||
pub url: String,
|
||||
@@ -64,7 +67,6 @@ pub struct ChannelMetadata {
|
||||
pub extra: Extra,
|
||||
}
|
||||
|
||||
/// A community's permanent identity.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CommunityIdentity {
|
||||
pub community_id: CommunityId,
|
||||
@@ -78,12 +80,6 @@ impl CommunityIdentity {
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything a creation mints:
|
||||
///
|
||||
/// - Identity
|
||||
/// - Roots an invite will carry
|
||||
/// - First channel
|
||||
/// - Two genesis wraps to publish
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CommunityGenesis {
|
||||
pub identity: CommunityIdentity,
|
||||
@@ -93,29 +89,14 @@ pub struct CommunityGenesis {
|
||||
pub wraps: Vec<Event>,
|
||||
}
|
||||
|
||||
/// Mints a community and signs its two genesis editions.
|
||||
pub fn genesis(
|
||||
owner: &Keys,
|
||||
metadata: &CommunityMetadata,
|
||||
at_secs: u64,
|
||||
) -> Result<CommunityGenesis> {
|
||||
let mut metadata = metadata.clone();
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
metadata.relays.truncate(MAX_RELAYS);
|
||||
|
||||
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(),
|
||||
@@ -129,7 +110,6 @@ pub fn genesis(
|
||||
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 metadata_content = serde_json::to_string(&metadata)?;
|
||||
let channel_content = serde_json::to_string(&ChannelMetadata {
|
||||
name: GENERAL_CHANNEL.to_owned(),
|
||||
private: false,
|
||||
@@ -190,6 +170,277 @@ pub fn open_edition(
|
||||
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>,
|
||||
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: None,
|
||||
},
|
||||
at_secs,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn set_channel_metadata(
|
||||
&self,
|
||||
keys: &Keys,
|
||||
channel: &ChannelId,
|
||||
metadata: &ChannelMetadata,
|
||||
head: Option<&EntityHead>,
|
||||
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: None,
|
||||
},
|
||||
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,
|
||||
@@ -216,42 +467,53 @@ mod tests {
|
||||
use nostr_memory::MemoryDatabase;
|
||||
|
||||
use super::*;
|
||||
use crate::edition::{EditionMeta, fold};
|
||||
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 metadata = CommunityMetadata {
|
||||
let community_metadata = CommunityMetadata {
|
||||
name: "coop".to_owned(),
|
||||
relays: vec!["wss://relay.example".to_owned()],
|
||||
..CommunityMetadata::default()
|
||||
};
|
||||
let at_secs = 1_700_000_000;
|
||||
|
||||
let minted = genesis(&owner, &metadata, at_secs).expect("mints");
|
||||
let minted = genesis(&owner, &community_metadata, AT).expect("mints");
|
||||
assert!(minted.identity.verify(), "identity is self-certifying");
|
||||
|
||||
// The second client holds only what an invite hands over: the roots,
|
||||
// the community id and the owner salt.
|
||||
let read = control_group_key(
|
||||
&minted.community_root,
|
||||
&minted.identity.community_id,
|
||||
ROOT_EPOCH,
|
||||
)
|
||||
.expect("derives");
|
||||
let address = control_signer_group_key(
|
||||
&minted.control_root,
|
||||
&minted.identity.community_id,
|
||||
ROOT_EPOCH,
|
||||
)
|
||||
.expect("derives")
|
||||
.pk();
|
||||
|
||||
let mut editions = Vec::new();
|
||||
for wrap in &minted.wraps {
|
||||
editions.push(open_edition(wrap, &read, &address, true).expect("opens"));
|
||||
}
|
||||
// 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);
|
||||
|
||||
@@ -280,8 +542,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
let state =
|
||||
CommunityState::from_genesis(&minted, &editions, at_secs * 1_000).expect("projects");
|
||||
let state = CommunityState::from_genesis(&minted, &editions, AT * 1_000).expect("projects");
|
||||
|
||||
smol::block_on(async {
|
||||
let database = MemoryDatabase::unbounded();
|
||||
@@ -297,4 +558,257 @@ mod tests {
|
||||
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),
|
||||
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),
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,7 +181,8 @@ pub fn control_signer_group_key(
|
||||
}
|
||||
|
||||
/// Member-writable, unlike the Control Plane:
|
||||
/// a join or a leave is each member's own word.
|
||||
///
|
||||
/// - A join or a leave is each member's own word.
|
||||
pub fn guestbook_group_key(
|
||||
community_root: &[u8; 32],
|
||||
community_id: &CommunityId,
|
||||
@@ -210,7 +211,6 @@ pub fn channel_rekey_group_key(
|
||||
)
|
||||
}
|
||||
|
||||
/// Keyed by the prior `community_root`: the base has no stable key above it
|
||||
pub fn base_rekey_group_key(
|
||||
prior_root: &[u8; 32],
|
||||
community_id: &CommunityId,
|
||||
@@ -224,13 +224,10 @@ pub fn base_rekey_group_key(
|
||||
)
|
||||
}
|
||||
|
||||
/// Keyed by the `community_id` alone, so every member past or present resolves
|
||||
/// the same address and a Refounding cannot strand the grave.
|
||||
pub fn dissolved_group_key(community_id: &CommunityId) -> Result<GroupKey> {
|
||||
GroupKey::derive(LABEL_DISSOLVED, community_id.as_bytes(), &ZERO32, None)
|
||||
}
|
||||
|
||||
/// A plain SHA-256 commitment
|
||||
pub fn community_id_of(owner_xonly: &[u8; 32], owner_salt: &[u8; 32]) -> CommunityId {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(LABEL_COMMUNITY.as_bytes());
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::fmt;
|
||||
|
||||
use data_encoding::HEXLOWER;
|
||||
use nostr_sdk::prelude::{EventId, PublicKey, Tag, UnsignedEvent};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::decode_hex_32;
|
||||
@@ -312,6 +313,92 @@ pub fn bootstrap_head(editions: &[EditionMeta]) -> Option<usize> {
|
||||
.map(|(index, _)| index)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct HeadSelection {
|
||||
pub head: Option<usize>,
|
||||
pub gap: bool,
|
||||
}
|
||||
|
||||
/// The head to prefer for one entity, given what this client already committed to.
|
||||
pub fn fold_head(editions: &[EditionMeta], floor: Option<&EntityHead>) -> HeadSelection {
|
||||
let Some(floor) = floor else {
|
||||
return HeadSelection {
|
||||
head: bootstrap_head(editions),
|
||||
gap: false,
|
||||
};
|
||||
};
|
||||
|
||||
let anchored = fold(editions, floor.version, Some(&floor.self_hash));
|
||||
|
||||
if anchored.anchored {
|
||||
return HeadSelection {
|
||||
head: anchored.head,
|
||||
gap: anchored.gap,
|
||||
};
|
||||
}
|
||||
|
||||
if anchored.head.is_none() && !anchored.gap {
|
||||
return HeadSelection::default();
|
||||
}
|
||||
|
||||
let fork = editions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, edition)| edition.version == floor.version)
|
||||
.min_by_key(|(_, edition)| edition.tiebreak_id);
|
||||
|
||||
let winner = match fork {
|
||||
Some((_, edition))
|
||||
if edition.self_hash != floor.self_hash && edition.tiebreak_id < floor.rumor_id =>
|
||||
{
|
||||
edition.self_hash
|
||||
}
|
||||
_ => {
|
||||
return HeadSelection {
|
||||
head: None,
|
||||
gap: true,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let refolded = fold(editions, floor.version, Some(&winner));
|
||||
|
||||
if refolded.anchored {
|
||||
HeadSelection {
|
||||
head: refolded.head,
|
||||
gap: refolded.gap,
|
||||
}
|
||||
} else {
|
||||
HeadSelection {
|
||||
head: None,
|
||||
gap: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A committed head, and the refuse-downgrade floor a later fold is judged against.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct EntityHead {
|
||||
pub entity: [u8; 32],
|
||||
pub version: u64,
|
||||
pub self_hash: [u8; 32],
|
||||
pub rumor_id: EventId,
|
||||
}
|
||||
|
||||
impl From<&ParsedEdition> for EntityHead {
|
||||
fn from(edition: &ParsedEdition) -> Self {
|
||||
Self {
|
||||
entity: edition.entity,
|
||||
version: edition.version,
|
||||
self_hash: edition.self_hash,
|
||||
rumor_id: edition.rumor_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every entity's committed head, keyed by coordinate.
|
||||
pub type Floors = BTreeMap<[u8; 32], EntityHead>;
|
||||
|
||||
fn canonical_decimal(raw: &str) -> Option<u64> {
|
||||
if raw.is_empty() || !raw.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return None;
|
||||
@@ -366,6 +453,86 @@ fn value<'a>(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn meta(version: u64, prev: Option<[u8; 32]>, hash: u8, tiebreak: u8) -> EditionMeta {
|
||||
EditionMeta {
|
||||
version,
|
||||
self_hash: [hash; 32],
|
||||
prev,
|
||||
tiebreak_id: EventId::from_byte_array([tiebreak; 32]),
|
||||
}
|
||||
}
|
||||
|
||||
fn head(version: u64, hash: u8, rumor: u8) -> EntityHead {
|
||||
EntityHead {
|
||||
entity: [0x11; 32],
|
||||
version,
|
||||
self_hash: [hash; 32],
|
||||
rumor_id: EventId::from_byte_array([rumor; 32]),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fold_picks_the_head_from_the_chain_and_the_floor() {
|
||||
let chain = [
|
||||
meta(1, None, 0xa1, 1),
|
||||
meta(2, Some([0xa1; 32]), 0xa2, 2),
|
||||
meta(3, Some([0xa2; 32]), 0xa3, 3),
|
||||
];
|
||||
|
||||
let folded = fold(&chain, 0, None);
|
||||
assert_eq!(folded.head, Some(2));
|
||||
assert!(!folded.gap && folded.anchored);
|
||||
|
||||
// A missing link stops the walk at the last contiguous edition.
|
||||
let gapped = fold(&[chain[0], chain[2]], 0, None);
|
||||
assert_eq!(gapped.head, Some(0));
|
||||
assert!(gapped.gap && gapped.anchored);
|
||||
|
||||
// Everything below the held floor is a stale relay, not a gap.
|
||||
let stale = fold(&chain[..2], 3, Some(&[0xa3; 32]));
|
||||
assert_eq!(stale.head, None);
|
||||
assert!(!stale.gap && !stale.anchored);
|
||||
|
||||
// A fork at a version breaks on the lower inner rumor id, and the chain resumes.
|
||||
let fork = [meta(1, None, 0xb1, 9), meta(1, None, 0xa1, 1)];
|
||||
assert_eq!(
|
||||
fold(&fork, 0, None).head,
|
||||
Some(1),
|
||||
"the lower rumor id wins"
|
||||
);
|
||||
let forked = [fork[0], fork[1], chain[1], chain[2]];
|
||||
assert_eq!(fold(&forked, 0, None).head, Some(3));
|
||||
|
||||
// A re-wrap onto the head we hold is the legitimate case; one whose `prev` no
|
||||
// longer resolves is a withholding.
|
||||
let rewrapped = meta(5, Some([0x99; 32]), 0xc5, 5);
|
||||
assert_eq!(
|
||||
fold_head(&[rewrapped], Some(&head(4, 0x99, 4))).head,
|
||||
Some(0)
|
||||
);
|
||||
let dangling = meta(5, Some([0x88; 32]), 0xc5, 5);
|
||||
let refused = fold_head(&[dangling], Some(&head(4, 0x99, 4)));
|
||||
assert_eq!(refused.head, None);
|
||||
assert!(refused.gap);
|
||||
|
||||
// A bootstrap takes it anyway: a compaction would leave a joiner with nothing.
|
||||
assert_eq!(bootstrap_head(&[dangling]), Some(0));
|
||||
assert_eq!(fold_head(&[dangling], None).head, Some(0));
|
||||
|
||||
// A fork at the floor's own version converges to the lower rumor id when that is
|
||||
// genuinely earlier than what we hold, and the chain above it re-anchors.
|
||||
let forked = [
|
||||
meta(2, Some([0xa1; 32]), 0xb2, 3),
|
||||
meta(3, Some([0xb2; 32]), 0xb3, 4),
|
||||
];
|
||||
let converged = fold_head(&forked, Some(&head(2, 0xaa, 9)));
|
||||
assert_eq!(converged.head, Some(1));
|
||||
assert!(!converged.gap);
|
||||
|
||||
// A fork that is not earlier than the held head is refused.
|
||||
assert_eq!(fold_head(&forked, Some(&head(2, 0xaa, 2))).head, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edition_hash_matches_the_cross_client_vector() {
|
||||
let entity = [0x11u8; 32];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod control;
|
||||
pub mod derive;
|
||||
pub mod edition;
|
||||
pub mod roles;
|
||||
pub mod store;
|
||||
pub mod stream;
|
||||
|
||||
@@ -14,6 +15,9 @@ use rand::TryRng as _;
|
||||
use rand::rngs::SysRng;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
/// Unknown fields a content struct does not model, so a republish cannot wipe them.
|
||||
pub(crate) type Extra = serde_json::Map<String, serde_json::Value>;
|
||||
|
||||
macro_rules! hex_id {
|
||||
($(#[$meta:meta])* $name:ident) => {
|
||||
$(#[$meta])*
|
||||
@@ -76,20 +80,21 @@ macro_rules! hex_id {
|
||||
}
|
||||
|
||||
hex_id! {
|
||||
/// A Community's permanent identity: a self-certifying commitment to its
|
||||
/// owner's key. It travels inside invites and is itself never on the wire
|
||||
/// (CORD-02 §1).
|
||||
/// A self-certifying commitment to the owner's key, carried inside invites and
|
||||
/// never on the wire.
|
||||
CommunityId
|
||||
}
|
||||
|
||||
hex_id! {
|
||||
/// A Channel's identity within its Community (CORD-03).
|
||||
ChannelId
|
||||
}
|
||||
|
||||
/// A key-rotation counter attached to each Community key.
|
||||
///
|
||||
/// It bumps only on a Rekey, a membership change where somebody is removed.
|
||||
hex_id! {
|
||||
/// Both a Role's entity coordinate and the field it repeats in its own content.
|
||||
RoleId
|
||||
}
|
||||
|
||||
/// A key-rotation counter; it bumps only on a Rekey that removes somebody.
|
||||
#[derive(
|
||||
Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Default, Serialize, Deserialize,
|
||||
)]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+47
-11
@@ -5,9 +5,11 @@ use anyhow::{Result, anyhow};
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::control::{ChannelMetadata, CommunityGenesis, CommunityMetadata, ROOT_EPOCH};
|
||||
use crate::control::{
|
||||
ChannelMetadata, CommunityGenesis, CommunityMetadata, ControlFold, ROOT_EPOCH,
|
||||
};
|
||||
use crate::derive::control_signer_group_key;
|
||||
use crate::edition::{ParsedEdition, vsk};
|
||||
use crate::edition::{EntityHead, Floors, ParsedEdition, vsk};
|
||||
use crate::stream::OpenedStream;
|
||||
use crate::{ChannelId, CommunityId, Epoch};
|
||||
|
||||
@@ -45,7 +47,6 @@ pub async fn cache_rumor(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read a channel's cached rumors.
|
||||
pub async fn query_rumors(
|
||||
database: &dyn NostrDatabase,
|
||||
channel: &ChannelId,
|
||||
@@ -89,14 +90,6 @@ pub async fn query_rumors(
|
||||
Ok(rumors)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct EntityHead {
|
||||
pub entity: [u8; 32],
|
||||
pub version: u64,
|
||||
pub self_hash: [u8; 32],
|
||||
pub rumor_id: EventId,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ChannelKeyRef {
|
||||
pub id: ChannelId,
|
||||
@@ -194,6 +187,49 @@ impl CommunityState {
|
||||
pub fn identifier(&self) -> String {
|
||||
state_identifier(&self.id)
|
||||
}
|
||||
|
||||
pub fn floors(&self) -> Floors {
|
||||
self.heads
|
||||
.iter()
|
||||
.map(|head| (head.entity, head.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn apply_fold(&mut self, fold: &ControlFold) {
|
||||
self.heads = fold.floors.values().cloned().collect();
|
||||
|
||||
if let Some(community) = &fold.community {
|
||||
self.relays = community
|
||||
.relays
|
||||
.iter()
|
||||
.filter_map(|relay| RelayUrl::parse(relay).ok())
|
||||
.collect();
|
||||
}
|
||||
|
||||
for (id, metadata) in &fold.channels {
|
||||
if metadata.deleted.unwrap_or(false) {
|
||||
self.channels.retain(|channel| channel.id != *id);
|
||||
continue;
|
||||
}
|
||||
|
||||
match self.channels.iter_mut().find(|channel| channel.id == *id) {
|
||||
Some(channel) => {
|
||||
channel.name = metadata.name.clone();
|
||||
|
||||
if !metadata.private {
|
||||
channel.private = false;
|
||||
}
|
||||
}
|
||||
None if !metadata.private => self.channels.push(ChannelKeyRef {
|
||||
id: *id,
|
||||
name: metadata.name.clone(),
|
||||
private: false,
|
||||
epoch: self.root_epoch,
|
||||
}),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn state_identifier(id: &CommunityId) -> String {
|
||||
|
||||
@@ -140,7 +140,6 @@ pub fn build_rumor_secs(
|
||||
rumor
|
||||
}
|
||||
|
||||
/// Resolve a rumor's true millisecond time.
|
||||
pub fn resolve_ms_strict(rumor: &UnsignedEvent) -> Result<u64, StreamError> {
|
||||
let seconds = rumor.created_at.as_secs().saturating_mul(1000);
|
||||
let mut tag: Option<Option<String>> = None;
|
||||
@@ -215,7 +214,6 @@ pub fn wrap_seal(
|
||||
)
|
||||
}
|
||||
|
||||
/// Signs with `signer` while encrypting under `conversation`.
|
||||
pub fn wrap_seal_with(
|
||||
seal: &Event,
|
||||
conversation: &ConversationKey,
|
||||
|
||||
Reference in New Issue
Block a user