tangle


git clone https://radroots.dev/git/tangle.git
Log | Files | Refs | README | LICENSE

commit 56a205f58b197198f14f6a08c02068f37b5d12c6
parent 8a80897a4cfb314a045487b8d9180edc6aed0976
Author: triesap <tyson@radroots.org>
Date:   Sat, 13 Jun 2026 17:11:04 -0700

feat: add group domain core

- define NIP-29 group kind and id primitives
- add h and d group tag extraction and classification
- add metadata, role, error, and structure validation types
- prove relay-generated group state rejection in tests

Diffstat:
Acrates/tangle_groups/src/classification.rs | 203+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/tangle_groups/src/errors.rs | 150+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/tangle_groups/src/ids.rs | 122+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/tangle_groups/src/kinds.rs | 129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/tangle_groups/src/lib.rs | 33+++++++++++++++++++++++++++++++++
Acrates/tangle_groups/src/metadata.rs | 262+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/tangle_groups/src/outbox.rs | 2++
Acrates/tangle_groups/src/policy.rs | 2++
Acrates/tangle_groups/src/projection.rs | 2++
Acrates/tangle_groups/src/read_gate.rs | 2++
Acrates/tangle_groups/src/roles.rs | 251+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/tangle_groups/src/signing.rs | 2++
Acrates/tangle_groups/src/tags.rs | 189+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/tangle_groups/src/write_gate.rs | 187+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/tangle_runtime/src/base_relay.rs | 49++++++++++++++++++++++++++++++++++---------------
15 files changed, 1570 insertions(+), 15 deletions(-)

diff --git a/crates/tangle_groups/src/classification.rs b/crates/tangle_groups/src/classification.rs @@ -0,0 +1,203 @@ +use crate::{ + GroupLimitsConfig, + errors::GroupError, + ids::GroupId, + kinds::{is_moderation_kind, is_relay_generated_kind, is_user_request_kind}, + tags::{GroupTagName, extract_group_tag, has_group_identity_tag, require_group_tag}, +}; +use tangle_protocol::{Event, Kind}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GroupEventClass { + NonGroup, + Normal { group_id: GroupId }, + Moderation { kind: Kind, group_id: GroupId }, + RelayGeneratedSnapshot { kind: Kind, group_id: GroupId }, +} + +impl GroupEventClass { + pub fn group_id(&self) -> Option<&GroupId> { + match self { + Self::NonGroup => None, + Self::Normal { group_id } + | Self::Moderation { group_id, .. } + | Self::RelayGeneratedSnapshot { group_id, .. } => Some(group_id), + } + } + + pub fn is_group(&self) -> bool { + !matches!(self, Self::NonGroup) + } +} + +pub fn classify_group_event( + event: &Event, + limits: GroupLimitsConfig, +) -> Result<GroupEventClass, GroupError> { + let kind = event.unsigned().kind(); + if is_relay_generated_kind(kind) { + let group_id = require_group_tag(event.unsigned().tags(), GroupTagName::D, limits)? + .group_id() + .clone(); + return Ok(GroupEventClass::RelayGeneratedSnapshot { kind, group_id }); + } + if is_moderation_kind(kind) { + let group_id = require_group_tag(event.unsigned().tags(), GroupTagName::H, limits)? + .group_id() + .clone(); + return Ok(GroupEventClass::Moderation { kind, group_id }); + } + if is_user_request_kind(kind) { + let group_id = require_group_tag(event.unsigned().tags(), GroupTagName::H, limits)? + .group_id() + .clone(); + return Ok(GroupEventClass::Normal { group_id }); + } + if has_group_identity_tag(event.unsigned().tags()) { + if let Some(group_tag) = + extract_group_tag(event.unsigned().tags(), GroupTagName::H, limits)? + { + return Ok(GroupEventClass::Normal { + group_id: group_tag.group_id().clone(), + }); + } + } + Ok(GroupEventClass::NonGroup) +} + +#[cfg(test)] +mod tests { + use super::{GroupEventClass, classify_group_event}; + use crate::{ + GroupErrorKind, GroupLimitsConfig, KIND_GROUP_CREATE_GROUP, KIND_GROUP_JOIN_REQUEST, + KIND_GROUP_METADATA, KIND_GROUP_PUT_USER, + }; + use tangle_protocol::{ + Event, EventId, Kind, PublicKeyHex, SignatureHex, Tag, UnixTimestamp, UnsignedEvent, + }; + + #[test] + fn classifies_non_group_normal_moderation_and_relay_generated_events() { + assert_eq!( + classify_group_event(&event(1, Vec::new()), GroupLimitsConfig::default()) + .expect("non-group"), + GroupEventClass::NonGroup + ); + assert_eq!( + classify_group_event( + &event(1, vec![Tag::from_parts("h", &["Farm"]).expect("h")]), + GroupLimitsConfig::default() + ) + .expect("normal"), + GroupEventClass::Normal { + group_id: crate::GroupId::new("Farm").expect("group") + } + ); + assert!(matches!( + classify_group_event( + &event( + KIND_GROUP_PUT_USER, + vec![Tag::from_parts("h", &["Farm"]).expect("h")] + ), + GroupLimitsConfig::default() + ) + .expect("moderation"), + GroupEventClass::Moderation { kind, group_id } + if kind.as_u32() == KIND_GROUP_PUT_USER && group_id.as_str() == "Farm" + )); + assert!(matches!( + classify_group_event( + &event( + KIND_GROUP_METADATA, + vec![Tag::from_parts("d", &["Farm"]).expect("d")] + ), + GroupLimitsConfig::default() + ) + .expect("relay generated"), + GroupEventClass::RelayGeneratedSnapshot { kind, group_id } + if kind.as_u32() == KIND_GROUP_METADATA && group_id.as_str() == "Farm" + )); + assert!(matches!( + classify_group_event( + &event( + KIND_GROUP_CREATE_GROUP, + vec![Tag::from_parts("h", &["Farm"]).expect("h")] + ), + GroupLimitsConfig::default() + ) + .expect("create"), + GroupEventClass::Moderation { kind, .. } if kind.as_u32() == KIND_GROUP_CREATE_GROUP + )); + assert!(matches!( + classify_group_event( + &event( + KIND_GROUP_JOIN_REQUEST, + vec![Tag::from_parts("h", &["Farm"]).expect("h")] + ), + GroupLimitsConfig::default() + ) + .expect("join"), + GroupEventClass::Normal { group_id } if group_id.as_str() == "Farm" + )); + } + + #[test] + fn d_tags_do_not_make_regular_addressable_events_group_events() { + assert_eq!( + classify_group_event( + &event(30_001, vec![Tag::from_parts("d", &["note"]).expect("d")]), + GroupLimitsConfig::default() + ) + .expect("event"), + GroupEventClass::NonGroup + ); + } + + #[test] + fn required_h_and_d_tag_rules_are_strict() { + assert_eq!( + classify_group_event( + &event(KIND_GROUP_PUT_USER, Vec::new()), + GroupLimitsConfig::default() + ) + .expect_err("missing h") + .kind(), + GroupErrorKind::MissingGroupTag + ); + assert_eq!( + classify_group_event( + &event(KIND_GROUP_JOIN_REQUEST, Vec::new()), + GroupLimitsConfig::default() + ) + .expect_err("missing h") + .kind(), + GroupErrorKind::MissingGroupTag + ); + assert_eq!( + classify_group_event( + &event( + KIND_GROUP_METADATA, + vec![Tag::from_parts("h", &["Farm"]).expect("h")] + ), + GroupLimitsConfig::default() + ) + .expect_err("missing d") + .kind(), + GroupErrorKind::MissingGroupTag + ); + } + + fn event(kind: u32, tags: Vec<Tag>) -> Event { + Event::new( + EventId::new(&"0".repeat(64)).expect("id"), + UnsignedEvent::new( + PublicKeyHex::new(&"1".repeat(64)).expect("pubkey"), + UnixTimestamp::new(1), + Kind::new(kind.into()).expect("kind"), + tags, + "", + ), + SignatureHex::new(&"2".repeat(128)).expect("sig"), + ) + } +} diff --git a/crates/tangle_groups/src/errors.rs b/crates/tangle_groups/src/errors.rs @@ -0,0 +1,150 @@ +use core::fmt; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GroupReplyPrefix { + Duplicate, + Blocked, + RateLimited, + Invalid, + Restricted, + AuthRequired, + Error, +} + +impl GroupReplyPrefix { + pub fn as_str(self) -> &'static str { + match self { + Self::Duplicate => "duplicate", + Self::Blocked => "blocked", + Self::RateLimited => "rate-limited", + Self::Invalid => "invalid", + Self::Restricted => "restricted", + Self::AuthRequired => "auth-required", + Self::Error => "error", + } + } +} + +impl fmt::Display for GroupReplyPrefix { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GroupErrorKind { + InvalidGroupId, + MalformedGroupTag, + MissingGroupTag, + ConflictingGroupTag, + TooManyGroupTags, + UnsupportedGroupKind, + DirectRelayGeneratedSubmission, + MissingTargetTag, + MalformedTargetTag, + MetadataTooLarge, + TooManySupportedKinds, + InvalidRole, + MissingCapability, + AuthenticationRequired, + Internal, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GroupError { + kind: GroupErrorKind, + prefix: GroupReplyPrefix, + message: String, +} + +impl GroupError { + pub fn new(kind: GroupErrorKind, prefix: GroupReplyPrefix, message: impl Into<String>) -> Self { + Self { + kind, + prefix, + message: message.into(), + } + } + + pub fn invalid(kind: GroupErrorKind, message: impl Into<String>) -> Self { + Self::new(kind, GroupReplyPrefix::Invalid, message) + } + + pub fn blocked(kind: GroupErrorKind, message: impl Into<String>) -> Self { + Self::new(kind, GroupReplyPrefix::Blocked, message) + } + + pub fn restricted(kind: GroupErrorKind, message: impl Into<String>) -> Self { + Self::new(kind, GroupReplyPrefix::Restricted, message) + } + + pub fn auth_required(message: impl Into<String>) -> Self { + Self::new( + GroupErrorKind::AuthenticationRequired, + GroupReplyPrefix::AuthRequired, + message, + ) + } + + pub fn internal(message: impl Into<String>) -> Self { + Self::new(GroupErrorKind::Internal, GroupReplyPrefix::Error, message) + } + + pub fn kind(&self) -> GroupErrorKind { + self.kind + } + + pub fn reply_prefix(&self) -> GroupReplyPrefix { + self.prefix + } + + pub fn message(&self) -> &str { + &self.message + } + + pub fn prefixed_message(&self) -> String { + format!("{}: {}", self.prefix.as_str(), self.message) + } +} + +impl fmt::Display for GroupError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.prefixed_message()) + } +} + +impl std::error::Error for GroupError {} + +#[cfg(test)] +mod tests { + use super::{GroupError, GroupErrorKind, GroupReplyPrefix}; + + #[test] + fn group_errors_map_to_nostr_reply_prefixes() { + let cases = [ + (GroupReplyPrefix::Duplicate, "duplicate"), + (GroupReplyPrefix::Blocked, "blocked"), + (GroupReplyPrefix::RateLimited, "rate-limited"), + (GroupReplyPrefix::Invalid, "invalid"), + (GroupReplyPrefix::Restricted, "restricted"), + (GroupReplyPrefix::AuthRequired, "auth-required"), + (GroupReplyPrefix::Error, "error"), + ]; + + for (prefix, value) in cases { + assert_eq!(prefix.as_str(), value); + assert_eq!(prefix.to_string(), value); + } + + let error = GroupError::restricted( + GroupErrorKind::MissingCapability, + "missing group capability manage_members", + ); + + assert_eq!(error.reply_prefix(), GroupReplyPrefix::Restricted); + assert_eq!( + error.prefixed_message(), + "restricted: missing group capability manage_members" + ); + } +} diff --git a/crates/tangle_groups/src/ids.rs b/crates/tangle_groups/src/ids.rs @@ -0,0 +1,122 @@ +use core::fmt; + +use crate::errors::{GroupError, GroupErrorKind}; + +pub const MIN_GROUP_ID_BYTES: usize = 1; +pub const MAX_GROUP_ID_BYTES: usize = 128; + +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct GroupId(String); + +impl GroupId { + pub fn new(value: &str) -> Result<Self, GroupError> { + Self::new_with_max_bytes(value, MAX_GROUP_ID_BYTES) + } + + pub fn new_with_max_bytes(value: &str, max_bytes: usize) -> Result<Self, GroupError> { + validate_group_id(value, max_bytes)?; + Ok(Self(value.to_owned())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for GroupId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.debug_tuple("GroupId").field(&self.0).finish() + } +} + +impl fmt::Display for GroupId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +pub fn validate_group_id(value: &str, max_bytes: usize) -> Result<(), GroupError> { + let byte_len = value.len(); + if byte_len < MIN_GROUP_ID_BYTES { + return Err(invalid_group_id("group id must not be empty")); + } + if byte_len > max_bytes { + return Err(invalid_group_id(format!( + "group id must be at most {max_bytes} bytes" + ))); + } + if value.trim() != value { + return Err(invalid_group_id( + "group id must not contain leading or trailing whitespace", + )); + } + for character in value.chars() { + if character == '\0' { + return Err(invalid_group_id("group id must not contain NUL")); + } + if character.is_control() { + return Err(invalid_group_id( + "group id must not contain control characters", + )); + } + if matches!(character, '/' | '\\' | '?' | '#' | ':' | '&' | '=') { + return Err(invalid_group_id( + "group id must not contain slashes or URL separators", + )); + } + } + Ok(()) +} + +fn invalid_group_id(message: impl Into<String>) -> GroupError { + GroupError::invalid(GroupErrorKind::InvalidGroupId, message) +} + +#[cfg(test)] +mod tests { + use super::GroupId; + + #[test] + fn group_id_validation_rejects_forbidden_forms() { + assert_eq!( + GroupId::new("").expect_err("empty").message(), + "group id must not be empty" + ); + assert_eq!( + GroupId::new(&"a".repeat(129)) + .expect_err("too long") + .message(), + "group id must be at most 128 bytes" + ); + assert_eq!( + GroupId::new(" group").expect_err("trim").message(), + "group id must not contain leading or trailing whitespace" + ); + assert_eq!( + GroupId::new("group\u{0}id").expect_err("nul").message(), + "group id must not contain NUL" + ); + assert_eq!( + GroupId::new("group\nid").expect_err("control").message(), + "group id must not contain control characters" + ); + assert_eq!( + GroupId::new("group/id").expect_err("slash").message(), + "group id must not contain slashes or URL separators" + ); + assert_eq!( + GroupId::new("group?id").expect_err("url").message(), + "group id must not contain slashes or URL separators" + ); + } + + #[test] + fn group_id_is_case_sensitive() { + let lower = GroupId::new("farm").expect("lower"); + let upper = GroupId::new("Farm").expect("upper"); + + assert_ne!(lower, upper); + assert_eq!(lower.as_str(), "farm"); + assert_eq!(upper.as_str(), "Farm"); + } +} diff --git a/crates/tangle_groups/src/kinds.rs b/crates/tangle_groups/src/kinds.rs @@ -0,0 +1,129 @@ +use tangle_protocol::Kind; + +pub const KIND_GROUP_PUT_USER: u32 = 9_000; +pub const KIND_GROUP_REMOVE_USER: u32 = 9_001; +pub const KIND_GROUP_EDIT_METADATA: u32 = 9_002; +pub const KIND_GROUP_DELETE_EVENT: u32 = 9_005; +pub const KIND_GROUP_CREATE_GROUP: u32 = 9_007; +pub const KIND_GROUP_DELETE_GROUP: u32 = 9_008; +pub const KIND_GROUP_CREATE_INVITE: u32 = 9_009; +pub const KIND_GROUP_JOIN_REQUEST: u32 = 9_021; +pub const KIND_GROUP_LEAVE_REQUEST: u32 = 9_022; +pub const KIND_GROUP_METADATA: u32 = 39_000; +pub const KIND_GROUP_ADMINS: u32 = 39_001; +pub const KIND_GROUP_MEMBERS: u32 = 39_002; +pub const KIND_GROUP_ROLES: u32 = 39_003; +pub const KIND_GROUP_STATE_39004: u32 = 39_004; + +pub const NIP29_MODERATION_KIND_VALUES: [u32; 7] = [ + KIND_GROUP_PUT_USER, + KIND_GROUP_REMOVE_USER, + KIND_GROUP_EDIT_METADATA, + KIND_GROUP_DELETE_EVENT, + KIND_GROUP_CREATE_GROUP, + KIND_GROUP_DELETE_GROUP, + KIND_GROUP_CREATE_INVITE, +]; + +pub const NIP29_USER_REQUEST_KIND_VALUES: [u32; 2] = + [KIND_GROUP_JOIN_REQUEST, KIND_GROUP_LEAVE_REQUEST]; + +pub const NIP29_RELAY_GENERATED_KIND_VALUES: [u32; 5] = [ + KIND_GROUP_METADATA, + KIND_GROUP_ADMINS, + KIND_GROUP_MEMBERS, + KIND_GROUP_ROLES, + KIND_GROUP_STATE_39004, +]; + +pub const NIP29_GROUP_KIND_VALUES: [u32; 14] = [ + KIND_GROUP_PUT_USER, + KIND_GROUP_REMOVE_USER, + KIND_GROUP_EDIT_METADATA, + KIND_GROUP_DELETE_EVENT, + KIND_GROUP_CREATE_GROUP, + KIND_GROUP_DELETE_GROUP, + KIND_GROUP_CREATE_INVITE, + KIND_GROUP_JOIN_REQUEST, + KIND_GROUP_LEAVE_REQUEST, + KIND_GROUP_METADATA, + KIND_GROUP_ADMINS, + KIND_GROUP_MEMBERS, + KIND_GROUP_ROLES, + KIND_GROUP_STATE_39004, +]; + +pub fn is_moderation_kind(kind: Kind) -> bool { + NIP29_MODERATION_KIND_VALUES.contains(&kind.as_u32()) +} + +pub fn is_user_request_kind(kind: Kind) -> bool { + NIP29_USER_REQUEST_KIND_VALUES.contains(&kind.as_u32()) +} + +pub fn is_relay_generated_kind(kind: Kind) -> bool { + NIP29_RELAY_GENERATED_KIND_VALUES.contains(&kind.as_u32()) +} + +pub fn is_group_specific_kind(kind: Kind) -> bool { + NIP29_GROUP_KIND_VALUES.contains(&kind.as_u32()) +} + +#[cfg(test)] +mod tests { + use super::{ + KIND_GROUP_ADMINS, KIND_GROUP_CREATE_GROUP, KIND_GROUP_CREATE_INVITE, + KIND_GROUP_DELETE_EVENT, KIND_GROUP_DELETE_GROUP, KIND_GROUP_EDIT_METADATA, + KIND_GROUP_JOIN_REQUEST, KIND_GROUP_LEAVE_REQUEST, KIND_GROUP_MEMBERS, KIND_GROUP_METADATA, + KIND_GROUP_PUT_USER, KIND_GROUP_REMOVE_USER, KIND_GROUP_ROLES, KIND_GROUP_STATE_39004, + NIP29_GROUP_KIND_VALUES, is_group_specific_kind, is_moderation_kind, + is_relay_generated_kind, is_user_request_kind, + }; + use tangle_protocol::Kind; + + #[test] + fn nip29_kind_constants_cover_moderation_and_relay_generated_ranges() { + assert_eq!( + NIP29_GROUP_KIND_VALUES, + [ + 9_000, 9_001, 9_002, 9_005, 9_007, 9_008, 9_009, 9_021, 9_022, 39_000, 39_001, + 39_002, 39_003, 39_004 + ] + ); + for value in [ + KIND_GROUP_PUT_USER, + KIND_GROUP_REMOVE_USER, + KIND_GROUP_EDIT_METADATA, + KIND_GROUP_DELETE_EVENT, + KIND_GROUP_CREATE_GROUP, + KIND_GROUP_DELETE_GROUP, + KIND_GROUP_CREATE_INVITE, + ] { + let kind = Kind::new(value.into()).expect("kind"); + assert!(is_moderation_kind(kind)); + assert!(is_group_specific_kind(kind)); + assert!(!is_relay_generated_kind(kind)); + assert!(!is_user_request_kind(kind)); + } + for value in [KIND_GROUP_JOIN_REQUEST, KIND_GROUP_LEAVE_REQUEST] { + let kind = Kind::new(value.into()).expect("kind"); + assert!(is_user_request_kind(kind)); + assert!(is_group_specific_kind(kind)); + assert!(!is_moderation_kind(kind)); + assert!(!is_relay_generated_kind(kind)); + } + for value in [ + KIND_GROUP_METADATA, + KIND_GROUP_ADMINS, + KIND_GROUP_MEMBERS, + KIND_GROUP_ROLES, + KIND_GROUP_STATE_39004, + ] { + let kind = Kind::new(value.into()).expect("kind"); + assert!(is_relay_generated_kind(kind)); + assert!(is_group_specific_kind(kind)); + assert!(!is_moderation_kind(kind)); + } + assert!(!is_group_specific_kind(Kind::new(1).expect("kind"))); + } +} diff --git a/crates/tangle_groups/src/lib.rs b/crates/tangle_groups/src/lib.rs @@ -1,9 +1,42 @@ #![forbid(unsafe_code)] +pub mod classification; +pub mod errors; +pub mod ids; +pub mod kinds; +pub mod metadata; +pub mod outbox; +pub mod policy; +pub mod projection; +pub mod read_gate; +pub mod roles; +pub mod signing; +pub mod tags; +pub mod write_gate; + use core::fmt; use serde::Deserialize; use tangle_protocol::PublicKeyHex; +pub use classification::{GroupEventClass, classify_group_event}; +pub use errors::{GroupError, GroupErrorKind, GroupReplyPrefix}; +pub use ids::GroupId; +pub use kinds::{ + KIND_GROUP_ADMINS, KIND_GROUP_CREATE_GROUP, KIND_GROUP_CREATE_INVITE, KIND_GROUP_DELETE_EVENT, + KIND_GROUP_DELETE_GROUP, KIND_GROUP_EDIT_METADATA, KIND_GROUP_JOIN_REQUEST, + KIND_GROUP_LEAVE_REQUEST, KIND_GROUP_MEMBERS, KIND_GROUP_METADATA, KIND_GROUP_PUT_USER, + KIND_GROUP_REMOVE_USER, KIND_GROUP_ROLES, KIND_GROUP_STATE_39004, NIP29_GROUP_KIND_VALUES, + NIP29_MODERATION_KIND_VALUES, NIP29_RELAY_GENERATED_KIND_VALUES, + NIP29_USER_REQUEST_KIND_VALUES, +}; +pub use metadata::{GroupMetadata, SupportedKinds, parse_group_metadata}; +pub use roles::{ + Capability, CapabilitySet, PERMANENT_RELAY_OVERRIDE_ROLE, RoleDefinition, RoleName, + resolve_capabilities, +}; +pub use tags::{GroupTag, GroupTagName, extract_group_tag, has_group_identity_tag}; +pub use write_gate::validate_client_group_event_structure; + #[derive(Clone, PartialEq, Eq)] pub struct RelaySecret(String); diff --git a/crates/tangle_groups/src/metadata.rs b/crates/tangle_groups/src/metadata.rs @@ -0,0 +1,262 @@ +use std::collections::BTreeSet; + +use crate::{ + GroupLimitsConfig, + errors::{GroupError, GroupErrorKind}, +}; +use tangle_protocol::{Kind, Tag}; + +pub const MAX_METADATA_NAME_BYTES: usize = 128; +pub const MAX_METADATA_PICTURE_BYTES: usize = 2_048; +pub const MAX_METADATA_ABOUT_BYTES: usize = 4_096; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GroupMetadata { + name: Option<String>, + picture: Option<String>, + about: Option<String>, + private: bool, + restricted: bool, + hidden: bool, + closed: bool, + supported_kinds: SupportedKinds, +} + +impl GroupMetadata { + pub fn name(&self) -> Option<&str> { + self.name.as_deref() + } + + pub fn picture(&self) -> Option<&str> { + self.picture.as_deref() + } + + pub fn about(&self) -> Option<&str> { + self.about.as_deref() + } + + pub fn private(&self) -> bool { + self.private + } + + pub fn restricted(&self) -> bool { + self.restricted + } + + pub fn hidden(&self) -> bool { + self.hidden + } + + pub fn closed(&self) -> bool { + self.closed + } + + pub fn supported_kinds(&self) -> &SupportedKinds { + &self.supported_kinds + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SupportedKinds { + UnspecifiedAll, + None, + Only(BTreeSet<Kind>), +} + +pub fn parse_group_metadata( + tags: &[Tag], + limits: GroupLimitsConfig, +) -> Result<GroupMetadata, GroupError> { + let mut builder = MetadataBuilder::default(); + for tag in tags { + let Some(name) = tag.values().first().map(String::as_str) else { + continue; + }; + match name { + "name" => builder.name = parse_text_tag(tag, "name", MAX_METADATA_NAME_BYTES)?, + "picture" => { + builder.picture = parse_text_tag(tag, "picture", MAX_METADATA_PICTURE_BYTES)? + } + "about" => builder.about = parse_text_tag(tag, "about", MAX_METADATA_ABOUT_BYTES)?, + "private" => builder.private = true, + "restricted" => builder.restricted = true, + "hidden" => builder.hidden = true, + "closed" => builder.closed = true, + "supported_kinds" => { + if builder.supported_kinds.is_some() { + return Err(GroupError::invalid( + GroupErrorKind::TooManySupportedKinds, + "metadata must contain at most one supported_kinds tag", + )); + } + builder.supported_kinds = Some(parse_supported_kinds_tag(tag, limits)?); + } + _ => {} + } + } + Ok(GroupMetadata { + name: builder.name, + picture: builder.picture, + about: builder.about, + private: builder.private, + restricted: builder.restricted, + hidden: builder.hidden, + closed: builder.closed, + supported_kinds: builder + .supported_kinds + .unwrap_or(SupportedKinds::UnspecifiedAll), + }) +} + +#[derive(Default)] +struct MetadataBuilder { + name: Option<String>, + picture: Option<String>, + about: Option<String>, + private: bool, + restricted: bool, + hidden: bool, + closed: bool, + supported_kinds: Option<SupportedKinds>, +} + +fn parse_text_tag( + tag: &Tag, + field: &'static str, + max_bytes: usize, +) -> Result<Option<String>, GroupError> { + let value = tag.values().get(1).cloned(); + if let Some(value) = &value + && value.len() > max_bytes + { + return Err(GroupError::invalid( + GroupErrorKind::MetadataTooLarge, + format!("metadata {field} must be at most {max_bytes} bytes"), + )); + } + Ok(value) +} + +fn parse_supported_kinds_tag( + tag: &Tag, + limits: GroupLimitsConfig, +) -> Result<SupportedKinds, GroupError> { + let values = tag.values().iter().skip(1).collect::<Vec<_>>(); + if values.is_empty() { + return Ok(SupportedKinds::None); + } + let max = usize::from(limits.max_supported_kinds()); + if values.len() > max { + return Err(GroupError::invalid( + GroupErrorKind::TooManySupportedKinds, + format!( + "supported_kinds has {} values, maximum is {max}", + values.len() + ), + )); + } + let mut kinds = BTreeSet::new(); + for value in values { + let raw = value.parse::<u64>().map_err(|_| { + GroupError::invalid( + GroupErrorKind::UnsupportedGroupKind, + "supported_kinds values must be unsigned integers", + ) + })?; + kinds.insert(Kind::new(raw).map_err(|reason| { + GroupError::invalid( + GroupErrorKind::UnsupportedGroupKind, + format!("supported_kinds value is invalid: {reason}"), + ) + })?); + } + Ok(SupportedKinds::Only(kinds)) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use super::{SupportedKinds, parse_group_metadata}; + use crate::{GroupErrorKind, GroupLimitsConfig}; + use tangle_protocol::{Kind, Tag}; + + #[test] + fn parses_group_metadata_flags_and_fields() { + let metadata = parse_group_metadata( + &[ + Tag::from_parts("name", &["Farmers"]).expect("name"), + Tag::from_parts("picture", &["https://radroots.test/group.png"]).expect("picture"), + Tag::from_parts("about", &["Local harvest coordination"]).expect("about"), + Tag::from_parts("private", &[]).expect("private"), + Tag::from_parts("restricted", &[]).expect("restricted"), + Tag::from_parts("hidden", &[]).expect("hidden"), + Tag::from_parts("closed", &[]).expect("closed"), + Tag::from_parts("supported_kinds", &["1", "7"]).expect("supported"), + ], + GroupLimitsConfig::default(), + ) + .expect("metadata"); + + assert_eq!(metadata.name(), Some("Farmers")); + assert_eq!(metadata.picture(), Some("https://radroots.test/group.png")); + assert_eq!(metadata.about(), Some("Local harvest coordination")); + assert!(metadata.private()); + assert!(metadata.restricted()); + assert!(metadata.hidden()); + assert!(metadata.closed()); + assert_eq!( + metadata.supported_kinds(), + &SupportedKinds::Only(BTreeSet::from([ + Kind::new(1).expect("kind"), + Kind::new(7).expect("kind") + ])) + ); + } + + #[test] + fn supported_kinds_absent_empty_and_list_forms_are_distinct() { + assert_eq!( + parse_group_metadata(&[], GroupLimitsConfig::default()) + .expect("absent") + .supported_kinds(), + &SupportedKinds::UnspecifiedAll + ); + assert_eq!( + parse_group_metadata( + &[Tag::from_parts("supported_kinds", &[]).expect("supported")], + GroupLimitsConfig::default() + ) + .expect("empty") + .supported_kinds(), + &SupportedKinds::None + ); + assert!(matches!( + parse_group_metadata( + &[Tag::from_parts("supported_kinds", &["1"]).expect("supported")], + GroupLimitsConfig::default() + ) + .expect("list") + .supported_kinds(), + SupportedKinds::Only(kinds) if kinds.contains(&Kind::new(1).expect("kind")) + )); + } + + #[test] + fn metadata_parser_rejects_oversize_fields_and_kind_limits() { + let error = parse_group_metadata( + &[Tag::from_parts("name", &[&"a".repeat(129)]).expect("name")], + GroupLimitsConfig::default(), + ) + .expect_err("name"); + assert_eq!(error.kind(), GroupErrorKind::MetadataTooLarge); + + let limits = GroupLimitsConfig::new(128, 8, 1, 1, 1).expect("limits"); + let error = parse_group_metadata( + &[Tag::from_parts("supported_kinds", &["1", "2"]).expect("supported")], + limits, + ) + .expect_err("supported kinds"); + assert_eq!(error.kind(), GroupErrorKind::TooManySupportedKinds); + } +} diff --git a/crates/tangle_groups/src/outbox.rs b/crates/tangle_groups/src/outbox.rs @@ -0,0 +1,2 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct GroupOutboxBoundary; diff --git a/crates/tangle_groups/src/policy.rs b/crates/tangle_groups/src/policy.rs @@ -0,0 +1,2 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct GroupPolicyBoundary; diff --git a/crates/tangle_groups/src/projection.rs b/crates/tangle_groups/src/projection.rs @@ -0,0 +1,2 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct GroupProjectionBoundary; diff --git a/crates/tangle_groups/src/read_gate.rs b/crates/tangle_groups/src/read_gate.rs @@ -0,0 +1,2 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct GroupReadGateBoundary; diff --git a/crates/tangle_groups/src/roles.rs b/crates/tangle_groups/src/roles.rs @@ -0,0 +1,251 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use crate::errors::{GroupError, GroupErrorKind}; + +pub const MAX_ROLE_NAME_BYTES: usize = 64; +pub const PERMANENT_RELAY_OVERRIDE_ROLE: &str = "relay_owner"; + +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RoleName(String); + +impl RoleName { + pub fn new(value: &str) -> Result<Self, GroupError> { + if value.is_empty() { + return Err(invalid_role("role name must not be empty")); + } + if value.len() > MAX_ROLE_NAME_BYTES { + return Err(invalid_role(format!( + "role name must be at most {MAX_ROLE_NAME_BYTES} bytes" + ))); + } + if value.trim() != value { + return Err(invalid_role( + "role name must not contain leading or trailing whitespace", + )); + } + if value.chars().any(char::is_control) { + return Err(invalid_role( + "role name must not contain control characters", + )); + } + if value.chars().any(char::is_whitespace) { + return Err(invalid_role("role name must not contain whitespace")); + } + Ok(Self(value.to_owned())) + } + + pub fn permanent_relay_override() -> Self { + Self(PERMANENT_RELAY_OVERRIDE_ROLE.to_owned()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn is_permanent_relay_override(&self) -> bool { + self.as_str() == PERMANENT_RELAY_OVERRIDE_ROLE + } +} + +impl core::fmt::Debug for RoleName { + fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + formatter.debug_tuple("RoleName").field(&self.0).finish() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Capability { + ManageMembers, + ManageRoles, + ManageMetadata, + DeleteEvents, + DeleteGroup, + CreateInvites, + ManageInvites, + RelayOverride, +} + +impl Capability { + pub fn all() -> [Self; 8] { + [ + Self::ManageMembers, + Self::ManageRoles, + Self::ManageMetadata, + Self::DeleteEvents, + Self::DeleteGroup, + Self::CreateInvites, + Self::ManageInvites, + Self::RelayOverride, + ] + } + + pub fn as_str(self) -> &'static str { + match self { + Self::ManageMembers => "manage_members", + Self::ManageRoles => "manage_roles", + Self::ManageMetadata => "manage_metadata", + Self::DeleteEvents => "delete_events", + Self::DeleteGroup => "delete_group", + Self::CreateInvites => "create_invites", + Self::ManageInvites => "manage_invites", + Self::RelayOverride => "relay_override", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct CapabilitySet { + capabilities: BTreeSet<Capability>, +} + +impl CapabilitySet { + pub fn new(capabilities: impl IntoIterator<Item = Capability>) -> Self { + Self { + capabilities: capabilities.into_iter().collect(), + } + } + + pub fn empty() -> Self { + Self::default() + } + + pub fn permanent_relay_override() -> Self { + Self::new(Capability::all()) + } + + pub fn insert(&mut self, capability: Capability) { + self.capabilities.insert(capability); + } + + pub fn contains(&self, capability: Capability) -> bool { + self.capabilities.contains(&capability) + } + + pub fn is_empty(&self) -> bool { + self.capabilities.is_empty() + } + + pub fn iter(&self) -> impl Iterator<Item = Capability> + '_ { + self.capabilities.iter().copied() + } + + fn extend_from(&mut self, other: &CapabilitySet) { + self.capabilities.extend(other.iter()); + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RoleDefinition { + name: RoleName, + capabilities: CapabilitySet, + description: Option<String>, +} + +impl RoleDefinition { + pub fn new(name: RoleName, capabilities: CapabilitySet, description: Option<String>) -> Self { + Self { + name, + capabilities, + description, + } + } + + pub fn name(&self) -> &RoleName { + &self.name + } + + pub fn capabilities(&self) -> &CapabilitySet { + &self.capabilities + } + + pub fn description(&self) -> Option<&str> { + self.description.as_deref() + } +} + +pub fn resolve_capabilities<'a>( + definitions: impl IntoIterator<Item = &'a RoleDefinition>, + roles: impl IntoIterator<Item = &'a RoleName>, +) -> Result<CapabilitySet, GroupError> { + let definitions = definitions + .into_iter() + .map(|definition| (definition.name().clone(), definition)) + .collect::<BTreeMap<_, _>>(); + let mut resolved = CapabilitySet::empty(); + for role in roles { + if role.is_permanent_relay_override() { + resolved.extend_from(&CapabilitySet::permanent_relay_override()); + continue; + } + let Some(definition) = definitions.get(role) else { + return Err(GroupError::restricted( + GroupErrorKind::MissingCapability, + format!("unknown group role {}", role.as_str()), + )); + }; + resolved.extend_from(definition.capabilities()); + } + Ok(resolved) +} + +fn invalid_role(message: impl Into<String>) -> GroupError { + GroupError::invalid(GroupErrorKind::InvalidRole, message) +} + +#[cfg(test)] +mod tests { + use super::{Capability, CapabilitySet, RoleDefinition, RoleName, resolve_capabilities}; + use crate::GroupErrorKind; + + #[test] + fn role_name_validation_is_strict() { + assert_eq!( + RoleName::new("").expect_err("empty").message(), + "role name must not be empty" + ); + assert_eq!( + RoleName::new("a role").expect_err("space").message(), + "role name must not contain whitespace" + ); + assert_eq!( + RoleName::new(" role").expect_err("trim").message(), + "role name must not contain leading or trailing whitespace" + ); + assert_eq!( + RoleName::new("role\nname").expect_err("control").message(), + "role name must not contain control characters" + ); + } + + #[test] + fn resolves_role_capabilities_and_rejects_unknown_roles() { + let moderator = RoleName::new("moderator").expect("role"); + let definition = RoleDefinition::new( + moderator.clone(), + CapabilitySet::new([Capability::ManageMembers, Capability::DeleteEvents]), + Some("Moderates group members".to_owned()), + ); + let resolved = resolve_capabilities([&definition], [&moderator]).expect("capabilities"); + + assert!(resolved.contains(Capability::ManageMembers)); + assert!(resolved.contains(Capability::DeleteEvents)); + assert!(!resolved.contains(Capability::DeleteGroup)); + assert_eq!(definition.description(), Some("Moderates group members")); + + let unknown = RoleName::new("unknown").expect("unknown"); + let error = resolve_capabilities([&definition], [&unknown]).expect_err("unknown"); + assert_eq!(error.kind(), GroupErrorKind::MissingCapability); + assert_eq!(error.message(), "unknown group role unknown"); + } + + #[test] + fn permanent_relay_override_grants_every_capability() { + let role = RoleName::permanent_relay_override(); + let resolved = resolve_capabilities([], [&role]).expect("capabilities"); + + assert!(role.is_permanent_relay_override()); + for capability in Capability::all() { + assert!(resolved.contains(capability), "{}", capability.as_str()); + } + } +} diff --git a/crates/tangle_groups/src/signing.rs b/crates/tangle_groups/src/signing.rs @@ -0,0 +1,2 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct GroupSigningBoundary; diff --git a/crates/tangle_groups/src/tags.rs b/crates/tangle_groups/src/tags.rs @@ -0,0 +1,189 @@ +use crate::{ + GroupLimitsConfig, + errors::{GroupError, GroupErrorKind}, + ids::GroupId, +}; +use tangle_protocol::Tag; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GroupTagName { + H, + D, +} + +impl GroupTagName { + pub fn as_str(self) -> &'static str { + match self { + Self::H => "h", + Self::D => "d", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GroupTag { + name: GroupTagName, + group_id: GroupId, +} + +impl GroupTag { + pub fn new(name: GroupTagName, group_id: GroupId) -> Self { + Self { name, group_id } + } + + pub fn name(&self) -> GroupTagName { + self.name + } + + pub fn group_id(&self) -> &GroupId { + &self.group_id + } +} + +pub fn has_group_identity_tag(tags: &[Tag]) -> bool { + tags.iter().any(|tag| { + tag.values().first().is_some_and(|name| { + name == GroupTagName::H.as_str() || name == GroupTagName::D.as_str() + }) + }) +} + +pub fn group_identity_tag_count(tags: &[Tag]) -> usize { + tags.iter() + .filter(|tag| { + tag.values().first().is_some_and(|name| { + name == GroupTagName::H.as_str() || name == GroupTagName::D.as_str() + }) + }) + .count() +} + +pub fn ensure_group_tag_limit(tags: &[Tag], limits: GroupLimitsConfig) -> Result<(), GroupError> { + let count = group_identity_tag_count(tags); + let max = usize::from(limits.max_group_tags_per_event()); + if count > max { + return Err(GroupError::invalid( + GroupErrorKind::TooManyGroupTags, + format!("group event has {count} group tags, maximum is {max}"), + )); + } + Ok(()) +} + +pub fn extract_group_tag( + tags: &[Tag], + name: GroupTagName, + limits: GroupLimitsConfig, +) -> Result<Option<GroupTag>, GroupError> { + let mut found: Option<GroupId> = None; + for tag in tags { + if !tag + .values() + .first() + .is_some_and(|tag_name| tag_name == name.as_str()) + { + continue; + } + let Some((indexed_name, value)) = tag.indexed_pair() else { + return Err(GroupError::invalid( + GroupErrorKind::MalformedGroupTag, + format!("malformed {} group tag", name.as_str()), + )); + }; + if indexed_name != name.as_str() { + continue; + } + let group_id = + GroupId::new_with_max_bytes(value, usize::from(limits.max_group_id_bytes()))?; + if let Some(first) = &found { + if first != &group_id { + return Err(GroupError::invalid( + GroupErrorKind::ConflictingGroupTag, + format!("conflicting {} group tags", name.as_str()), + )); + } + } else { + found = Some(group_id); + } + } + Ok(found.map(|group_id| GroupTag::new(name, group_id))) +} + +pub fn require_group_tag( + tags: &[Tag], + name: GroupTagName, + limits: GroupLimitsConfig, +) -> Result<GroupTag, GroupError> { + extract_group_tag(tags, name, limits)?.ok_or_else(|| { + GroupError::invalid( + GroupErrorKind::MissingGroupTag, + format!("missing {} group tag", name.as_str()), + ) + }) +} + +#[cfg(test)] +mod tests { + use super::{ + GroupTagName, extract_group_tag, group_identity_tag_count, has_group_identity_tag, + }; + use crate::{GroupErrorKind, GroupLimitsConfig}; + use tangle_protocol::Tag; + + #[test] + fn extracts_first_indexed_group_tag_and_allows_exact_duplicates() { + let tags = vec![ + Tag::from_parts("h", &["Farm"]).expect("h"), + Tag::from_parts("p", &["a"]).expect("p"), + Tag::from_parts("h", &["Farm"]).expect("h"), + ]; + + let group_tag = extract_group_tag(&tags, GroupTagName::H, GroupLimitsConfig::default()) + .expect("tag") + .expect("present"); + + assert_eq!(group_tag.name(), GroupTagName::H); + assert_eq!(group_tag.group_id().as_str(), "Farm"); + assert!(has_group_identity_tag(&tags)); + assert_eq!(group_identity_tag_count(&tags), 2); + } + + #[test] + fn conflicting_duplicate_group_tags_are_rejected() { + let tags = vec![ + Tag::from_parts("h", &["Farm"]).expect("h"), + Tag::from_parts("h", &["farm"]).expect("h"), + ]; + let error = extract_group_tag(&tags, GroupTagName::H, GroupLimitsConfig::default()) + .expect_err("error"); + + assert_eq!(error.kind(), GroupErrorKind::ConflictingGroupTag); + assert_eq!(error.message(), "conflicting h group tags"); + } + + #[test] + fn malformed_group_tags_are_rejected() { + let tags = vec![Tag::new(vec!["h".to_owned()]).expect("tag")]; + let error = extract_group_tag(&tags, GroupTagName::H, GroupLimitsConfig::default()) + .expect_err("error"); + + assert_eq!(error.kind(), GroupErrorKind::MalformedGroupTag); + assert_eq!(error.message(), "malformed h group tag"); + } + + #[test] + fn group_tag_limit_counts_h_and_d_tags() { + let tags = vec![ + Tag::from_parts("h", &["a"]).expect("h"), + Tag::from_parts("d", &["a"]).expect("d"), + ]; + let limits = GroupLimitsConfig::new(128, 1, 512, 1, 1).expect("limits"); + + assert_eq!( + super::ensure_group_tag_limit(&tags, limits) + .expect_err("limit") + .message(), + "group event has 2 group tags, maximum is 1" + ); + } +} diff --git a/crates/tangle_groups/src/write_gate.rs b/crates/tangle_groups/src/write_gate.rs @@ -0,0 +1,187 @@ +use crate::{ + GroupEventClass, GroupLimitsConfig, + classification::classify_group_event, + errors::{GroupError, GroupErrorKind}, + kinds::{KIND_GROUP_DELETE_EVENT, KIND_GROUP_PUT_USER, KIND_GROUP_REMOVE_USER}, + tags::ensure_group_tag_limit, +}; +use tangle_protocol::{Event, PublicKeyHex}; + +pub fn validate_client_group_event_structure( + event: &Event, + limits: GroupLimitsConfig, +) -> Result<GroupEventClass, GroupError> { + ensure_group_tag_limit(event.unsigned().tags(), limits)?; + let class = classify_group_event(event, limits)?; + match &class { + GroupEventClass::RelayGeneratedSnapshot { .. } => Err(GroupError::blocked( + GroupErrorKind::DirectRelayGeneratedSubmission, + "relay-generated group state events cannot be submitted by clients", + )), + GroupEventClass::Moderation { kind, .. } => { + validate_moderation_targets(event, kind.as_u32())?; + Ok(class) + } + GroupEventClass::Normal { .. } | GroupEventClass::NonGroup => Ok(class), + } +} + +fn validate_moderation_targets(event: &Event, kind: u32) -> Result<(), GroupError> { + match kind { + KIND_GROUP_PUT_USER | KIND_GROUP_REMOVE_USER => require_valid_p_tag(event), + KIND_GROUP_DELETE_EVENT => require_indexed_tag_value(event, "e").map(|_| ()), + _ => Ok(()), + } +} + +fn require_valid_p_tag(event: &Event) -> Result<(), GroupError> { + let value = require_indexed_tag_value(event, "p")?; + PublicKeyHex::new(value).map_err(|reason| { + GroupError::invalid( + GroupErrorKind::MalformedTargetTag, + format!("malformed p target tag: {reason}"), + ) + })?; + Ok(()) +} + +fn require_indexed_tag_value<'a>(event: &'a Event, name: &str) -> Result<&'a str, GroupError> { + for tag in event.unsigned().tags() { + if !tag + .values() + .first() + .is_some_and(|tag_name| tag_name == name) + { + continue; + } + let Some((_, value)) = tag.indexed_pair() else { + return Err(GroupError::invalid( + GroupErrorKind::MalformedTargetTag, + format!("malformed {name} target tag"), + )); + }; + return Ok(value); + } + Err(GroupError::invalid( + GroupErrorKind::MissingTargetTag, + format!("missing {name} target tag"), + )) +} + +#[cfg(test)] +mod tests { + use super::validate_client_group_event_structure; + use crate::{ + GroupErrorKind, GroupEventClass, GroupLimitsConfig, KIND_GROUP_DELETE_EVENT, + KIND_GROUP_JOIN_REQUEST, KIND_GROUP_METADATA, KIND_GROUP_PUT_USER, + }; + use tangle_protocol::{ + Event, EventId, Kind, PublicKeyHex, SignatureHex, Tag, UnixTimestamp, UnsignedEvent, + }; + + #[test] + fn client_submitted_relay_generated_events_are_rejected() { + let error = validate_client_group_event_structure( + &event( + KIND_GROUP_METADATA, + vec![Tag::from_parts("d", &["Farm"]).expect("d")], + ), + GroupLimitsConfig::default(), + ) + .expect_err("relay generated"); + + assert_eq!(error.kind(), GroupErrorKind::DirectRelayGeneratedSubmission); + assert_eq!( + error.prefixed_message(), + "blocked: relay-generated group state events cannot be submitted by clients" + ); + } + + #[test] + fn validates_moderation_target_tags() { + assert_eq!( + validate_client_group_event_structure( + &event( + KIND_GROUP_PUT_USER, + vec![Tag::from_parts("h", &["Farm"]).expect("h")] + ), + GroupLimitsConfig::default() + ) + .expect_err("missing p") + .kind(), + GroupErrorKind::MissingTargetTag + ); + assert_eq!( + validate_client_group_event_structure( + &event( + KIND_GROUP_PUT_USER, + vec![ + Tag::from_parts("h", &["Farm"]).expect("h"), + Tag::from_parts("p", &["bad"]).expect("p") + ] + ), + GroupLimitsConfig::default() + ) + .expect_err("bad p") + .kind(), + GroupErrorKind::MalformedTargetTag + ); + assert_eq!( + validate_client_group_event_structure( + &event( + KIND_GROUP_DELETE_EVENT, + vec![Tag::from_parts("h", &["Farm"]).expect("h")] + ), + GroupLimitsConfig::default() + ) + .expect_err("missing e") + .kind(), + GroupErrorKind::MissingTargetTag + ); + } + + #[test] + fn validates_non_group_and_normal_group_structure() { + assert_eq!( + validate_client_group_event_structure( + &event(1, Vec::new()), + GroupLimitsConfig::default() + ) + .expect("non-group"), + GroupEventClass::NonGroup + ); + assert!(matches!( + validate_client_group_event_structure( + &event(1, vec![Tag::from_parts("h", &["Farm"]).expect("h")]), + GroupLimitsConfig::default() + ) + .expect("normal"), + GroupEventClass::Normal { group_id } if group_id.as_str() == "Farm" + )); + assert!(matches!( + validate_client_group_event_structure( + &event( + KIND_GROUP_JOIN_REQUEST, + vec![Tag::from_parts("h", &["Farm"]).expect("h")] + ), + GroupLimitsConfig::default() + ) + .expect("join"), + GroupEventClass::Normal { group_id } if group_id.as_str() == "Farm" + )); + } + + fn event(kind: u32, tags: Vec<Tag>) -> Event { + Event::new( + EventId::new(&"0".repeat(64)).expect("id"), + UnsignedEvent::new( + PublicKeyHex::new(&"1".repeat(64)).expect("pubkey"), + UnixTimestamp::new(1), + Kind::new(kind.into()).expect("kind"), + tags, + "", + ), + SignatureHex::new(&"2".repeat(128)).expect("sig"), + ) + } +} diff --git a/crates/tangle_runtime/src/base_relay.rs b/crates/tangle_runtime/src/base_relay.rs @@ -9,7 +9,9 @@ use http::{HeaderMap, HeaderValue, StatusCode, header}; use serde::{Deserialize, Serialize}; use std::{collections::BTreeMap, collections::BTreeSet, str}; use tangle_crypto::{RelaySigner, verify_event_signature}; -use tangle_groups::GroupRuntimeConfig; +use tangle_groups::{ + GroupEventClass, GroupLimitsConfig, GroupRuntimeConfig, validate_client_group_event_structure, +}; use tangle_nips::parse_relay_auth_event; use tangle_protocol::{ ClientMessage, Event, EventId, Filter, PublicKeyHex, RelayMessage, SubscriptionId, @@ -313,11 +315,15 @@ impl BaseRelay { if let Err(error) = verify_event_signature(&event) { return Ok(ok_rejected(event_id, format!("invalid: {error}"))); } - if is_nip29_group_event(&event) { - return Ok(ok_rejected( - event_id, - "blocked: NIP-29 group events are not accepted before group service".to_owned(), - )); + match validate_client_group_event_structure(&event, GroupLimitsConfig::default()) { + Ok(GroupEventClass::NonGroup) => {} + Ok(_) => { + return Ok(ok_rejected( + event_id, + "blocked: NIP-29 group events are not accepted before group service".to_owned(), + )); + } + Err(error) => return Ok(ok_rejected(event_id, error.prefixed_message())), } if event.unsigned().kind().is_ephemeral() { return Ok(ok_accepted(event_id, String::new())); @@ -582,15 +588,6 @@ fn ok_rejected(event_id: EventId, message: String) -> RelayMessage { } } -fn is_nip29_group_event(event: &Event) -> bool { - matches!(event.unsigned().kind().as_u32(), 39_000..=39_004) - || event - .unsigned() - .tags() - .iter() - .any(|tag| tag.indexed_pair().is_some_and(|(name, _)| name == "h")) -} - fn tangle_event_to_pocket(event: &Event) -> Result<PocketOwnedEvent, BaseRelayError> { let raw = event_to_value(event).to_string(); parse_pocket_event_json(raw.as_bytes()).map_err(BaseRelayError::from) @@ -800,6 +797,28 @@ mod tests { } #[test] + fn base_relay_rejects_client_submitted_relay_generated_group_state() { + let relay = test_relay("base-relay-generated-group-reject", 4); + let event = signed_public_event( + 7, + 39_000, + vec![Tag::from_parts("d", &["public-group"]).expect("group")], + "", + ); + + assert_eq!( + relay.handle_event(event.clone()).expect("event"), + RelayMessage::Ok { + event_id: event.id().clone(), + accepted: false, + message: + "blocked: relay-generated group state events cannot be submitted by clients" + .to_owned() + } + ); + } + + #[test] fn live_subscription_lag_closes_subscription_for_resync() { let mut relay = test_relay("base-relay-lag", 1); let subscription_id = SubscriptionId::new("sub-lag").expect("sub");