add control fold and roster

This commit is contained in:
2026-09-16 20:05:51 +07:00
parent d926c1e3ea
commit 4329385abe
8 changed files with 2016 additions and 111 deletions
+572 -58
View File
@@ -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")
);
}
}