add guestbook and moderation

This commit is contained in:
2026-09-17 07:30:38 +07:00
parent d1b83fdc33
commit ea9abae554
7 changed files with 1196 additions and 58 deletions
+106 -13
View File
@@ -6,7 +6,9 @@ use anyhow::Result;
use nostr_sdk::prelude::*;
use crate::derive::channel_group_key;
use crate::edition::canonical_decimal;
use crate::edition::{
AuthorityCitation, TAG_CITATION, canonical_decimal, citation_from, citation_tag,
};
use crate::stream::{
KIND_WRAP, KIND_WRAP_EPHEMERAL, OpenedStream, SealForm, StreamError, build_rumor_ms,
build_seal, channel_binding_tags, check_channel_binding, open_wrap, resolve_ms_strict,
@@ -96,6 +98,7 @@ pub enum ChatAction {
Delete {
target: EventId,
target_kind: Option<u16>,
citation: Option<AuthorityCitation>,
},
Typing,
Opaque,
@@ -217,6 +220,7 @@ pub fn build_delete(
epoch: Epoch,
target: EventId,
target_kind: Option<u16>,
citation: Option<&AuthorityCitation>,
at_ms: u64,
) -> UnsignedEvent {
let mut tags = channel_binding_tags(channel, epoch);
@@ -226,6 +230,10 @@ pub fn build_delete(
tags.push(Tag::custom(TAG_TARGET_KIND, [target_kind.to_string()]));
}
if let Some(citation) = citation {
tags.push(citation_tag(citation));
}
build_rumor_ms(KIND_DELETE, author, "", tags, at_ms)
}
@@ -317,10 +325,10 @@ pub fn plane_keys(
.collect()
}
/// Folds the chat plane into timeline rows, newest first. A delete is honored
/// only from the message's own author, and a deletion is terminal: an edit or a
/// reaction arriving later never revives it.
pub fn fold(rumors: &[ChatRumor]) -> Vec<ChatMessage> {
pub fn fold(
rumors: &[ChatRumor],
can_delete: impl Fn(&PublicKey, Option<&AuthorityCitation>, &PublicKey) -> bool,
) -> Vec<ChatMessage> {
let mut order: Vec<usize> = (0..rumors.len()).collect();
order.sort_by_key(|&index| (rumors[index].at_ms, rumors[index].id));
@@ -356,8 +364,6 @@ pub fn fold(rumors: &[ChatRumor]) -> Vec<ChatMessage> {
});
}
// Mutations replay so the last one applied is the winner: the highest
// `at_ms` and, between equal ones, the lower inner rumor id.
let mut mutations: Vec<usize> = (0..rumors.len()).collect();
mutations.sort_by_key(|&index| (rumors[index].at_ms, Reverse(rumors[index].id)));
@@ -378,12 +384,16 @@ pub fn fold(rumors: &[ChatRumor]) -> Vec<ChatMessage> {
message.content = content.clone();
message.edited_at = Some(rumor.at_ms);
}
ChatAction::Delete { target, .. } => {
ChatAction::Delete {
target, citation, ..
} => {
let Some(&slot) = slot.get(target) else {
continue;
};
if messages[slot].author == rumor.author {
let author = messages[slot].author;
if author == rumor.author || can_delete(&rumor.author, citation.as_ref(), &author) {
messages[slot].deleted = true;
}
}
@@ -454,6 +464,7 @@ fn action_of(rumor: &UnsignedEvent) -> Result<ChatAction, ChatError> {
KIND_DELETE => Ok(ChatAction::Delete {
target: required_id(rumor, TAG_TARGET)?,
target_kind: optional_kind(rumor, TAG_TARGET_KIND)?,
citation: optional_citation(rumor)?,
}),
KIND_TYPING => Ok(ChatAction::Typing),
KIND_WEBXDC => Ok(ChatAction::Opaque),
@@ -469,8 +480,7 @@ fn optional_reply(
return Ok(None);
};
// NIP-C7 `q` and NIP-22 `E`/`e` put a relay hint at index 2 and the
// referenced author at index 3, which is a SHOULD, so absent reads as unknown.
// NIP-C7 `q` and NIP-22 `E`/`e` put a relay hint at index 2 and the referenced author at index 3.
let author = match fields.get(3).map(String::as_str) {
Some(hex) if !hex.is_empty() => Some(pubkey(hex, name)?),
_ => None,
@@ -500,6 +510,16 @@ fn optional_kind(rumor: &UnsignedEvent, name: &'static str) -> Result<Option<u16
.map_err(|_| ChatError::BadTag(name))
}
fn optional_citation(rumor: &UnsignedEvent) -> Result<Option<AuthorityCitation>, ChatError> {
let Some(fields) = tag(rumor, TAG_CITATION)? else {
return Ok(None);
};
citation_from(fields)
.map(Some)
.ok_or(ChatError::BadTag(TAG_CITATION))
}
fn expiration_of(rumor: &UnsignedEvent) -> Result<Option<Timestamp>, ChatError> {
let Some(fields) = tag(rumor, TAG_EXPIRATION)? else {
return Ok(None);
@@ -646,6 +666,7 @@ mod tests {
Epoch(0),
id,
Some(KIND_MESSAGE),
None,
AT + 3_000,
),
&group,
@@ -654,7 +675,7 @@ mod tests {
),
];
let folded = fold(&rumors);
let folded = fold(&rumors, |_, _, _| false);
assert_eq!(folded.len(), 1);
assert_eq!(folded[0].id, id);
@@ -698,6 +719,7 @@ mod tests {
Epoch(0),
id,
Some(KIND_MESSAGE),
None,
AT + 2_000,
),
&group,
@@ -706,7 +728,7 @@ mod tests {
),
];
let folded = fold(&rumors);
let folded = fold(&rumors, |_, _, _| false);
assert_eq!(folded.len(), 1);
assert_eq!(folded[0].content, "hello");
@@ -851,4 +873,75 @@ mod tests {
Err(ChatError::DuplicateTag(TAG_TARGET))
));
}
#[test]
fn a_moderator_delete_needs_the_roster_and_a_citation() {
let alice = Keys::generate();
let moderator = Keys::generate();
let peer = Keys::generate();
let group = group();
let message = read(
&build_message(alice.public_key(), &channel(), Epoch(0), "hello", None, AT),
&group,
&alice,
Epoch(0),
);
let id = message.id;
let citation = AuthorityCitation {
entity: [0x33; 32],
version: 1,
hash: [0x44; 32],
};
let delete = |author: &Keys, citation: Option<&AuthorityCitation>| {
read(
&build_delete(
author.public_key(),
&channel(),
Epoch(0),
id,
Some(KIND_MESSAGE),
citation,
AT + 1_000,
),
&group,
author,
Epoch(0),
)
};
let can_delete =
|actor: &PublicKey, citation: Option<&AuthorityCitation>, author: &PublicKey| {
actor != author && citation.is_some() && actor == &moderator.public_key()
};
let cited = vec![message.clone(), delete(&moderator, Some(&citation))];
assert!(matches!(
&cited[1].action,
ChatAction::Delete { citation: Some(parsed), .. } if *parsed == citation
));
assert!(
fold(&cited, can_delete)[0].deleted,
"a cited moderator delete lands"
);
let uncited = vec![message.clone(), delete(&moderator, None)];
assert!(
!fold(&uncited, can_delete)[0].deleted,
"an uncited delete names no rank"
);
let peer_delete = vec![message.clone(), delete(&peer, Some(&citation))];
assert!(
!fold(&peer_delete, can_delete)[0].deleted,
"a peer's delete is not authority"
);
let own = vec![message.clone(), delete(&alice, None)];
assert!(
fold(&own, |_, _, _| false)[0].deleted,
"a self-delete never consults the predicate"
);
}
}
+81 -4
View File
@@ -5,14 +5,15 @@ use nostr_sdk::prelude::{Event, Keys, PublicKey, Timestamp, UnsignedEvent};
use serde::{Deserialize, Serialize};
use crate::derive::{
community_id_of, control_group_key, control_signer_group_key, verify_community_id,
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, Permissions, Roster, citation_ok, fold_roster,
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};
@@ -220,6 +221,7 @@ impl ControlWriter {
community_id: &CommunityId,
metadata: &CommunityMetadata,
head: Option<&EntityHead>,
citation: Option<AuthorityCitation>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
let content = encode_metadata(metadata)?;
@@ -231,7 +233,7 @@ impl ControlWriter {
entity: *community_id.as_bytes(),
content: &content,
head,
citation: None,
citation,
},
at_secs,
)
@@ -243,6 +245,7 @@ impl ControlWriter {
channel: &ChannelId,
metadata: &ChannelMetadata,
head: Option<&EntityHead>,
citation: Option<AuthorityCitation>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
let content = serde_json::to_string(metadata)?;
@@ -254,7 +257,79 @@ impl ControlWriter {
entity: *channel.as_bytes(),
content: &content,
head,
citation: None,
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,
)
@@ -600,6 +675,7 @@ mod tests {
..metadata("coop two")
},
Some(community_head),
None,
AT + 1,
)
.expect("publishes");
@@ -613,6 +689,7 @@ mod tests {
..ChannelMetadata::default()
},
Some(channel_head),
None,
AT + 2,
)
.expect("publishes");
+26 -15
View File
@@ -31,7 +31,7 @@ const TAG_SUBKIND: &str = "vsk";
const TAG_ENTITY: &str = "eid";
const TAG_VERSION: &str = "ev";
const TAG_PREV: &str = "ep";
const TAG_CITATION: &str = "vac";
pub const TAG_CITATION: &str = "vac";
#[derive(Debug)]
pub enum EditionError {
@@ -126,6 +126,29 @@ pub fn edition_hash(
Sha256::digest(signing_bytes(entity, version, prev, content)).into()
}
pub fn citation_tag(citation: &AuthorityCitation) -> Tag {
Tag::custom(
TAG_CITATION,
[
HEXLOWER.encode(&citation.entity),
citation.version.to_string(),
HEXLOWER.encode(&citation.hash),
],
)
}
pub fn citation_from(fields: &[String]) -> Option<AuthorityCitation> {
if fields.len() != 4 {
return None;
}
Some(AuthorityCitation {
entity: hex32(&fields[1], TAG_CITATION).ok()?,
version: canonical_decimal(&fields[2])?,
hash: hex32(&fields[3], TAG_CITATION).ok()?,
})
}
pub fn build_edition(fields: EditionFields<'_>) -> UnsignedEvent {
let mut tags = vec![
Tag::custom(TAG_SUBKIND, [fields.subkind]),
@@ -138,14 +161,7 @@ pub fn build_edition(fields: EditionFields<'_>) -> UnsignedEvent {
}
if let Some(citation) = fields.citation {
tags.push(Tag::custom(
TAG_CITATION,
[
HEXLOWER.encode(&citation.entity),
citation.version.to_string(),
HEXLOWER.encode(&citation.hash),
],
));
tags.push(citation_tag(&citation));
}
build_rumor_secs(
@@ -187,12 +203,7 @@ pub fn parse_edition(rumor: &UnsignedEvent) -> Result<ParsedEdition, EditionErro
};
let citation = match fields(rumor, TAG_CITATION)? {
Some(fields) if fields.len() == 4 => Some(AuthorityCitation {
entity: hex32(&fields[1], TAG_CITATION)?,
version: canonical_decimal(&fields[2]).ok_or(EditionError::BadField(TAG_CITATION))?,
hash: hex32(&fields[3], TAG_CITATION)?,
}),
Some(_) => return Err(EditionError::BadField(TAG_CITATION)),
Some(fields) => Some(citation_from(fields).ok_or(EditionError::BadField(TAG_CITATION))?),
None => None,
};
+890
View File
@@ -0,0 +1,890 @@
use std::cmp::Reverse;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use anyhow::Result;
use data_encoding::HEXLOWER;
use nostr_sdk::prelude::*;
use crate::edition::{
AuthorityCitation, TAG_CITATION, canonical_decimal, citation_from, citation_tag,
};
use crate::stream::{
KIND_WRAP, OpenedStream, SealForm, StreamError, build_rumor_ms, build_seal, open_wrap,
wrap_seal,
};
use crate::{GroupKey, decode_hex_32};
pub const KIND_JOIN_LEAVE: u16 = 3306;
pub const KIND_KICK: u16 = 3309;
pub const KIND_SNAPSHOT: u16 = 3312;
pub const MAX_SNAPSHOT_CHUNK: usize = 400;
pub const MAX_FUTURE_SKEW_MS: u64 = 60 * 60 * 1000;
const TAG_INVITE: &str = "invite";
const TAG_TARGET: &str = "p";
const TAG_SNAP: &str = "snap";
const TAG_CONTENT: &str = "content";
const CONTENT_JOIN: &str = "join";
const CONTENT_LEAVE: &str = "leave";
#[derive(Debug)]
pub enum GuestbookError {
Stream(StreamError),
NotEncryptedSealed,
UnknownKind(u16),
MissingTag(&'static str),
DuplicateTag(&'static str),
BadTag(&'static str),
}
impl fmt::Display for GuestbookError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
GuestbookError::Stream(error) => write!(f, "stream: {error}"),
GuestbookError::NotEncryptedSealed => {
write!(f, "guestbook rumor must ride an encrypted seal")
}
GuestbookError::UnknownKind(kind) => {
write!(f, "not a guestbook rumor kind: {kind}")
}
GuestbookError::MissingTag(name) => write!(f, "missing guestbook tag: {name}"),
GuestbookError::DuplicateTag(name) => write!(f, "duplicate guestbook tag: {name}"),
GuestbookError::BadTag(name) => write!(f, "malformed guestbook tag: {name}"),
}
}
}
impl std::error::Error for GuestbookError {}
impl From<StreamError> for GuestbookError {
fn from(error: StreamError) -> Self {
GuestbookError::Stream(error)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GuestbookEntry {
Join {
member: PublicKey,
at_ms: u64,
/// The `(creator, label)` an invite attributed the join to.
invited_by: Option<(String, String)>,
},
Leave {
member: PublicKey,
at_ms: u64,
},
Kick {
actor: PublicKey,
target: PublicKey,
at_ms: u64,
citation: Option<AuthorityCitation>,
},
Snapshot {
refounder: PublicKey,
members: Vec<PublicKey>,
snapshot_id: [u8; 32],
chunk: (u32, u32),
at_ms: u64,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GuestbookRumor {
pub id: EventId,
pub author: PublicKey,
pub kind: Kind,
pub at_ms: u64,
pub entry: GuestbookEntry,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MemberState {
Joined {
at_ms: u64,
invited_by: Option<(String, String)>,
},
Left {
at_ms: u64,
},
Kicked {
at_ms: u64,
actor: PublicKey,
},
}
pub fn build_join(
member: PublicKey,
invited_by: Option<(&str, &str)>,
at_ms: u64,
) -> UnsignedEvent {
let mut tags = Vec::new();
if let Some((creator, label)) = invited_by {
tags.push(Tag::custom(TAG_INVITE, [creator, label]));
}
build_rumor_ms(KIND_JOIN_LEAVE, member, CONTENT_JOIN, tags, at_ms)
}
pub fn build_leave(member: PublicKey, at_ms: u64) -> UnsignedEvent {
build_rumor_ms(KIND_JOIN_LEAVE, member, CONTENT_LEAVE, Vec::new(), at_ms)
}
pub fn build_kick(
actor: PublicKey,
target: &PublicKey,
citation: Option<&AuthorityCitation>,
at_ms: u64,
) -> UnsignedEvent {
let mut tags = vec![Tag::custom(TAG_TARGET, [target.to_hex()])];
if let Some(citation) = citation {
tags.push(citation_tag(citation));
}
build_rumor_ms(KIND_KICK, actor, "", tags, at_ms)
}
pub fn build_snapshot_chunks(
refounder: PublicKey,
members: &[PublicKey],
snapshot_id: [u8; 32],
at_ms: u64,
) -> Vec<UnsignedEvent> {
let chunks: Vec<&[PublicKey]> = members.chunks(MAX_SNAPSHOT_CHUNK).collect();
let total = chunks.len() as u32;
chunks
.iter()
.enumerate()
.map(|(index, chunk)| {
let hex: Vec<String> = chunk.iter().map(PublicKey::to_hex).collect();
let content = format!(
"[{}]",
hex.iter()
.map(|member| format!("\"{member}\""))
.collect::<Vec<_>>()
.join(",")
);
let tags = vec![Tag::custom(
TAG_SNAP,
[
HEXLOWER.encode(&snapshot_id),
(index as u32 + 1).to_string(),
total.to_string(),
],
)];
build_rumor_ms(KIND_SNAPSHOT, refounder, &content, tags, at_ms)
})
.collect()
}
pub fn seal_rumor(
rumor: &UnsignedEvent,
group: &GroupKey,
author: &Keys,
) -> Result<(Event, Keys), GuestbookError> {
let kind = rumor.kind.as_u16();
if !is_guestbook_kind(kind) {
return Err(GuestbookError::UnknownKind(kind));
}
let seal = build_seal(rumor, SealForm::Encrypted, group, author)?;
Ok(wrap_seal(&seal, group, KIND_WRAP, rumor.created_at, &[])?)
}
pub fn open(
wrap: &Event,
group: &GroupKey,
) -> Result<(OpenedStream, GuestbookRumor), GuestbookError> {
let opened = open_wrap(wrap, group)?;
if opened.seal_form != SealForm::Encrypted {
return Err(GuestbookError::NotEncryptedSealed);
}
let entry = entry_of(&opened)?;
let rumor = GuestbookRumor {
id: opened.rumor_id,
author: opened.author,
kind: opened.rumor.kind,
at_ms: opened.at_ms,
entry,
};
Ok((opened, rumor))
}
pub fn coalesce(
rumors: &[GuestbookRumor],
now_ms: u64,
snapshot_authority: Option<&PublicKey>,
can_kick: impl Fn(&PublicKey, &PublicKey, Option<&AuthorityCitation>) -> bool,
) -> BTreeMap<PublicKey, MemberState> {
let mut states: BTreeMap<PublicKey, (u64, Reverse<EventId>, MemberState)> = BTreeMap::new();
let horizon = now_ms.saturating_add(MAX_FUTURE_SKEW_MS);
for rumor in rumors {
if rumor.at_ms > horizon {
continue;
}
match &rumor.entry {
GuestbookEntry::Join {
member,
at_ms,
invited_by,
} => offer(
&mut states,
*member,
*at_ms,
rumor.id,
MemberState::Joined {
at_ms: *at_ms,
invited_by: invited_by.clone(),
},
),
GuestbookEntry::Leave { member, at_ms } => offer(
&mut states,
*member,
*at_ms,
rumor.id,
MemberState::Left { at_ms: *at_ms },
),
GuestbookEntry::Kick {
actor,
target,
at_ms,
citation,
} => {
if !can_kick(actor, target, citation.as_ref()) {
continue;
}
offer(
&mut states,
*target,
*at_ms,
rumor.id,
MemberState::Kicked {
at_ms: *at_ms,
actor: *actor,
},
);
}
GuestbookEntry::Snapshot {
refounder,
members,
at_ms,
..
} => {
if snapshot_authority != Some(refounder) {
continue;
}
for member in members {
offer(
&mut states,
*member,
*at_ms,
rumor.id,
MemberState::Joined {
at_ms: *at_ms,
invited_by: None,
},
);
}
}
}
}
states
.into_iter()
.map(|(member, (_, _, state))| (member, state))
.collect()
}
pub fn complete_memberlist(
coalesced: &BTreeMap<PublicKey, MemberState>,
observed: &BTreeMap<PublicKey, u64>,
granted: &BTreeSet<PublicKey>,
banned: &BTreeSet<PublicKey>,
banned_at: &BTreeMap<PublicKey, u64>,
) -> BTreeSet<PublicKey> {
let mut candidates: BTreeSet<&PublicKey> = coalesced.keys().collect();
candidates.extend(observed.keys());
candidates.extend(granted.iter());
let mut members = BTreeSet::new();
for member in candidates {
let mut inclusion = observed.get(member).copied();
if let Some(state) = coalesced.get(member) {
match state {
MemberState::Joined { at_ms, .. } => {
inclusion = Some(inclusion.map_or(*at_ms, |seen| seen.max(*at_ms)));
}
MemberState::Left { .. } | MemberState::Kicked { .. } => {}
}
}
if inclusion.is_none() && granted.contains(member) {
inclusion = Some(0);
}
let mut exclusion = match coalesced.get(member) {
Some(MemberState::Left { at_ms }) | Some(MemberState::Kicked { at_ms, .. }) => {
Some(*at_ms)
}
_ => None,
};
if banned.contains(member) {
exclusion = Some(match banned_at.get(member) {
Some(at_ms) => exclusion.map_or(*at_ms, |seen| seen.max(*at_ms)),
None => u64::MAX,
});
}
if let Some(inclusion) = inclusion
&& exclusion.is_none_or(|exclusion| inclusion > exclusion)
{
members.insert(*member);
}
}
members
}
fn offer(
states: &mut BTreeMap<PublicKey, (u64, Reverse<EventId>, MemberState)>,
member: PublicKey,
at_ms: u64,
id: EventId,
state: MemberState,
) {
let candidate = (at_ms, Reverse(id));
if let Some(existing) = states.get(&member)
&& (existing.0, existing.1) >= candidate
{
return;
}
states.insert(member, (at_ms, Reverse(id), state));
}
fn is_guestbook_kind(kind: u16) -> bool {
matches!(kind, KIND_JOIN_LEAVE | KIND_KICK | KIND_SNAPSHOT)
}
fn entry_of(opened: &OpenedStream) -> Result<GuestbookEntry, GuestbookError> {
let rumor = &opened.rumor;
let author = opened.author;
let at_ms = opened.at_ms;
match rumor.kind.as_u16() {
KIND_JOIN_LEAVE => match rumor.content.as_str() {
CONTENT_JOIN => Ok(GuestbookEntry::Join {
member: author,
at_ms,
invited_by: invite_of(rumor),
}),
CONTENT_LEAVE => Ok(GuestbookEntry::Leave {
member: author,
at_ms,
}),
_ => Err(GuestbookError::BadTag(TAG_CONTENT)),
},
KIND_KICK => Ok(GuestbookEntry::Kick {
actor: author,
target: tagged_pubkey(rumor, TAG_TARGET)?,
at_ms,
citation: optional_citation(rumor)?,
}),
KIND_SNAPSHOT => {
let (snapshot_id, chunk) = snapshot_of(rumor)?;
let members = members_of(&rumor.content)?;
Ok(GuestbookEntry::Snapshot {
refounder: author,
members,
snapshot_id,
chunk,
at_ms,
})
}
other => Err(GuestbookError::UnknownKind(other)),
}
}
fn invite_of(rumor: &UnsignedEvent) -> Option<(String, String)> {
rumor.tags.iter().find_map(|candidate| {
let fields = candidate.as_slice();
(fields.len() >= 3 && fields[0] == TAG_INVITE)
.then(|| (fields[1].clone(), fields[2].clone()))
})
}
fn members_of(content: &str) -> Result<Vec<PublicKey>, GuestbookError> {
let entries: Vec<String> =
serde_json::from_str(content).map_err(|_| GuestbookError::BadTag(TAG_CONTENT))?;
if entries.len() > MAX_SNAPSHOT_CHUNK {
return Err(GuestbookError::BadTag(TAG_SNAP));
}
entries
.iter()
.map(|entry| pubkey(entry, TAG_CONTENT))
.collect()
}
fn snapshot_of(rumor: &UnsignedEvent) -> Result<([u8; 32], (u32, u32)), GuestbookError> {
let fields = required(rumor, TAG_SNAP)?;
if fields.len() != 4 {
return Err(GuestbookError::BadTag(TAG_SNAP));
}
let snapshot_id = decode_hex_32(&fields[1]).map_err(|_| GuestbookError::BadTag(TAG_SNAP))?;
let index = decimal(&fields[2])?;
let total = decimal(&fields[3])?;
if index == 0 || index > total {
return Err(GuestbookError::BadTag(TAG_SNAP));
}
Ok((snapshot_id, (index, total)))
}
fn optional_citation(rumor: &UnsignedEvent) -> Result<Option<AuthorityCitation>, GuestbookError> {
let Some(fields) = tag(rumor, TAG_CITATION)? else {
return Ok(None);
};
citation_from(fields)
.map(Some)
.ok_or(GuestbookError::BadTag(TAG_CITATION))
}
fn decimal(raw: &str) -> Result<u32, GuestbookError> {
canonical_decimal(raw)
.and_then(|value| u32::try_from(value).ok())
.ok_or(GuestbookError::BadTag(TAG_SNAP))
}
fn required<'a>(
rumor: &'a UnsignedEvent,
name: &'static str,
) -> Result<&'a [String], GuestbookError> {
tag(rumor, name)?.ok_or(GuestbookError::MissingTag(name))
}
fn tagged_pubkey(rumor: &UnsignedEvent, name: &'static str) -> Result<PublicKey, GuestbookError> {
pubkey(value(required(rumor, name)?, name)?, name)
}
fn tag<'a>(
rumor: &'a UnsignedEvent,
name: &'static str,
) -> Result<Option<&'a [String]>, GuestbookError> {
let mut found: Option<&[String]> = None;
for candidate in rumor.tags.iter() {
let fields = candidate.as_slice();
if fields.first().map(String::as_str) != Some(name) {
continue;
}
if found.is_some() {
return Err(GuestbookError::DuplicateTag(name));
}
found = Some(fields);
}
Ok(found)
}
fn value<'a>(fields: &'a [String], name: &'static str) -> Result<&'a str, GuestbookError> {
fields
.get(1)
.map(String::as_str)
.ok_or(GuestbookError::BadTag(name))
}
fn pubkey(hex: &str, name: &'static str) -> Result<PublicKey, GuestbookError> {
let bytes = decode_hex_32(hex).map_err(|_| GuestbookError::BadTag(name))?;
PublicKey::from_slice(&bytes).map_err(|_| GuestbookError::BadTag(name))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::derive::guestbook_group_key;
use crate::stream::build_rumor_secs;
use crate::{CommunityId, Epoch};
const ROOT: [u8; 32] = [0x5au8; 32];
const AT: u64 = 1_700_000_000_000;
fn community() -> CommunityId {
CommunityId::from_bytes([0x11u8; 32])
}
fn group() -> GroupKey {
guestbook_group_key(&ROOT, &community(), Epoch(0)).expect("derives")
}
fn citation() -> AuthorityCitation {
AuthorityCitation {
entity: [0x33u8; 32],
version: 1,
hash: [0x44u8; 32],
}
}
fn publish(rumor: &UnsignedEvent, author: &Keys) -> GuestbookRumor {
let wrap = seal_rumor(rumor, &group(), author).expect("seals").0;
open(&wrap, &group()).expect("opens").1
}
#[test]
fn join_leave_kick_and_snapshot_converge_to_one_memberlist() {
let alice = Keys::generate();
let bob = Keys::generate();
let carol = Keys::generate();
let dave = Keys::generate();
let frank = Keys::generate();
let grace = Keys::generate();
let owner = Keys::generate();
let survivors: Vec<PublicKey> = (0..401).map(|_| Keys::generate().public_key()).collect();
let mut rumors = vec![
publish(
&build_join(
alice.public_key(),
Some((&"ab".repeat(32), "Reddit")),
AT + 1_000,
),
&alice,
),
publish(&build_join(bob.public_key(), None, AT + 2_000), &bob),
publish(&build_leave(bob.public_key(), AT + 3_000), &bob),
publish(&build_join(dave.public_key(), None, AT + 4_000), &dave),
publish(
&build_kick(
carol.public_key(),
&dave.public_key(),
Some(&citation()),
AT + 5_000,
),
&carol,
),
publish(&build_join(frank.public_key(), None, AT + 7_000), &frank),
];
let snapshot_id = "77".repeat(32);
let chunks =
build_snapshot_chunks(carol.public_key(), &survivors, [0x77u8; 32], AT + 6_000);
assert_eq!(chunks.len(), 2, "401 survivors chunk into two events");
for (index, chunk) in chunks.iter().enumerate() {
assert!(chunk.tags.iter().any(|tag| tag.as_slice()
== [
TAG_SNAP,
snapshot_id.as_str(),
&(index + 1).to_string(),
"2"
]));
rumors.push(publish(chunk, &carol));
}
let can_kick =
|actor: &PublicKey, target: &PublicKey, citation: Option<&AuthorityCitation>| {
citation.is_some() && actor == &carol.public_key() && target != &owner.public_key()
};
let states = coalesce(&rumors, AT + 8_000, Some(&carol.public_key()), can_kick);
assert_eq!(
states.get(&alice.public_key()),
Some(&MemberState::Joined {
at_ms: AT + 1_000,
invited_by: Some(("ab".repeat(32), "Reddit".to_owned())),
})
);
assert_eq!(
states.get(&bob.public_key()),
Some(&MemberState::Left { at_ms: AT + 3_000 })
);
assert_eq!(
states.get(&dave.public_key()),
Some(&MemberState::Kicked {
at_ms: AT + 5_000,
actor: carol.public_key(),
})
);
assert!(
survivors
.iter()
.all(|member| matches!(states.get(member), Some(MemberState::Joined { .. }))),
"every chunk seeds its own members"
);
let reversed: Vec<GuestbookRumor> = rumors.iter().rev().cloned().collect();
assert_eq!(
coalesce(&reversed, AT + 8_000, Some(&carol.public_key()), can_kick),
states,
"arrival order cannot change the fold"
);
let observed = BTreeMap::from([
(bob.public_key(), AT + 9_000),
(carol.public_key(), AT + 5_000),
]);
let granted = BTreeSet::from([grace.public_key()]);
let banned = BTreeSet::from([frank.public_key()]);
let banned_at = BTreeMap::from([(frank.public_key(), AT + 8_000)]);
let members = complete_memberlist(&states, &observed, &granted, &banned, &banned_at);
let mut expected = BTreeSet::from([
alice.public_key(),
bob.public_key(),
carol.public_key(),
grace.public_key(),
]);
expected.extend(survivors.iter().copied());
assert_eq!(members, expected);
assert!(
!members.contains(&dave.public_key()),
"a kicked member is out"
);
assert!(
!members.contains(&frank.public_key()),
"a ban wins over a later join"
);
}
#[test]
fn a_kick_or_snapshot_without_authority_is_dropped() {
let moderator = Keys::generate();
let outsider = Keys::generate();
let owner = Keys::generate();
let kicked = Keys::generate();
let uncited = Keys::generate();
let unranked = Keys::generate();
let refounder = Keys::generate();
let impostor = Keys::generate();
let seeded = Keys::generate();
let smuggled = Keys::generate();
let can_kick = |actor: &PublicKey,
target: &PublicKey,
citation: Option<&AuthorityCitation>| {
citation.is_some() && actor == &moderator.public_key() && target != &owner.public_key()
};
let rumors = vec![
publish(
&build_kick(
moderator.public_key(),
&kicked.public_key(),
Some(&citation()),
AT,
),
&moderator,
),
publish(
&build_kick(moderator.public_key(), &uncited.public_key(), None, AT),
&moderator,
),
publish(
&build_kick(
outsider.public_key(),
&unranked.public_key(),
Some(&citation()),
AT,
),
&outsider,
),
publish(
&build_kick(
moderator.public_key(),
&owner.public_key(),
Some(&citation()),
AT,
),
&moderator,
),
];
let states = coalesce(&rumors, AT + 1_000, None, can_kick);
assert_eq!(
states.get(&kicked.public_key()),
Some(&MemberState::Kicked {
at_ms: AT,
actor: moderator.public_key(),
})
);
assert!(
!states.contains_key(&uncited.public_key()),
"a kick cites the Grant it acts under"
);
assert!(
!states.contains_key(&unranked.public_key()),
"a kick needs KICK"
);
assert!(
!states.contains_key(&owner.public_key()),
"nobody kicks the owner"
);
let by_refounder = build_snapshot_chunks(
refounder.public_key(),
&[seeded.public_key()],
[0x77u8; 32],
AT,
)
.remove(0);
let by_impostor = build_snapshot_chunks(
impostor.public_key(),
&[smuggled.public_key()],
[0x88u8; 32],
AT,
)
.remove(0);
for authority in [None, Some(refounder.public_key())] {
let states = coalesce(
&[
publish(&by_refounder, &refounder),
publish(&by_impostor, &impostor),
],
AT + 1_000,
authority.as_ref(),
|_, _, _| true,
);
assert_eq!(
states.contains_key(&seeded.public_key()),
authority.is_some(),
"only the epoch's refounder seeds, and there is no owner fallback"
);
assert!(
!states.contains_key(&smuggled.public_key()),
"a foreign snapshot never seeds"
);
}
}
#[test]
fn a_future_entry_a_bad_ms_and_a_malformed_snapshot_are_dropped() {
let member = Keys::generate();
let moderator = Keys::generate();
let target = Keys::generate();
let future = publish(
&build_join(member.public_key(), None, AT + MAX_FUTURE_SKEW_MS + 1),
&member,
);
let horizon = publish(
&build_join(member.public_key(), None, AT + MAX_FUTURE_SKEW_MS),
&member,
);
assert!(
coalesce(&[future], AT, None, |_, _, _| true).is_empty(),
"an entry more than an hour ahead is dropped"
);
assert_eq!(
coalesce(&[horizon], AT, None, |_, _, _| true).len(),
1,
"the horizon itself is skew, not forgery"
);
let bad_ms = build_rumor_secs(
KIND_JOIN_LEAVE,
member.public_key(),
CONTENT_JOIN,
vec![Tag::custom("ms", ["1000"])],
AT / 1000,
);
assert!(matches!(
open(
&seal_rumor(&bad_ms, &group(), &member).expect("seals").0,
&group()
),
Err(GuestbookError::Stream(StreamError::BadMs))
));
let bad_verb = build_rumor_ms(KIND_JOIN_LEAVE, member.public_key(), "maybe", vec![], AT);
assert!(matches!(
open(
&seal_rumor(&bad_verb, &group(), &member).expect("seals").0,
&group()
),
Err(GuestbookError::BadTag(TAG_CONTENT))
));
let ambiguous = build_rumor_ms(
KIND_KICK,
moderator.public_key(),
"",
vec![
Tag::custom(TAG_TARGET, [target.public_key().to_hex()]),
citation_tag(&citation()),
citation_tag(&citation()),
],
AT,
);
assert!(matches!(
open(
&seal_rumor(&ambiguous, &group(), &moderator)
.expect("seals")
.0,
&group()
),
Err(GuestbookError::DuplicateTag(TAG_CITATION))
));
for fields in [
vec![snapshot_id(), "0".to_owned(), "2".to_owned()],
vec![snapshot_id(), "3".to_owned(), "2".to_owned()],
vec![snapshot_id(), "1".to_owned()],
] {
let rumor = build_rumor_ms(
KIND_SNAPSHOT,
moderator.public_key(),
"[]",
vec![Tag::custom(TAG_SNAP, fields)],
AT,
);
assert!(matches!(
open(
&seal_rumor(&rumor, &group(), &moderator).expect("seals").0,
&group()
),
Err(GuestbookError::BadTag(TAG_SNAP))
));
}
}
fn snapshot_id() -> String {
"ab".repeat(32)
}
}
+1
View File
@@ -2,6 +2,7 @@ pub mod chat;
pub mod control;
pub mod derive;
pub mod edition;
pub mod guestbook;
pub mod roles;
pub mod store;
pub mod stream;
+4
View File
@@ -118,6 +118,8 @@ pub struct CommunityState {
pub relays: Vec<RelayUrl>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub heads: Vec<EntityHead>,
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub banned: BTreeSet<PublicKey>,
pub added_at_ms: u64,
}
@@ -183,6 +185,7 @@ impl CommunityState {
channels,
relays,
heads,
banned: BTreeSet::new(),
added_at_ms,
})
}
@@ -200,6 +203,7 @@ impl CommunityState {
pub fn apply_fold(&mut self, fold: &ControlFold) {
self.heads = fold.floors.values().cloned().collect();
self.banned = fold.banned.clone();
if let Some(community) = &fold.community {
self.relays = community