lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

commit 2d1d4815678ec232b4bfcced6a95b334c52a99d4
parent 12020a0d56e3570e47a51d85fdec716ce8f29ff8
Author: triesap <tyson@radroots.org>
Date:   Thu, 11 Jun 2026 17:29:54 -0700

events_codec: add group codecs

- add NIP-29 group tag encoding and decoding helpers
- separate h-routed operations from d-routed group state events
- cover user operations, invites, metadata, admins, members, and roles
- reject routing mismatches without adding Field authorization semantics

Diffstat:
Acrates/events_codec/src/group/decode.rs | 327+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/events_codec/src/group/encode.rs | 352+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/events_codec/src/group/mod.rs | 234+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/events_codec/src/lib.rs | 1+
4 files changed, 914 insertions(+), 0 deletions(-)

diff --git a/crates/events_codec/src/group/decode.rs b/crates/events_codec/src/group/decode.rs @@ -0,0 +1,327 @@ +#[cfg(not(feature = "std"))] +use alloc::{ + string::{String, ToString}, + vec::Vec, +}; + +use radroots_events::{ + group::{ + 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, RadrootsGroupAdmins, + RadrootsGroupCreateGroup, RadrootsGroupCreateInvite, RadrootsGroupDeleteEvent, + RadrootsGroupDeleteGroup, RadrootsGroupEditMetadata, RadrootsGroupEditableMetadata, + RadrootsGroupJoinRequest, RadrootsGroupLeaveRequest, RadrootsGroupMembers, + RadrootsGroupMetadata, RadrootsGroupPutUser, RadrootsGroupRemoveUser, RadrootsGroupRole, + RadrootsGroupRoles, RadrootsGroupUserRef, + }, + tags::{TAG_D, TAG_E, TAG_H, TAG_P}, +}; + +use crate::error::EventParseError; +use crate::field_helpers::{ + optional_tag_value, require_empty_content, required_tag_value, tag_values, + validate_non_empty_tag_value, +}; + +const TAG_ABOUT: &str = "about"; +const TAG_CLOSED: &str = "closed"; +const TAG_CLAIM: &str = "claim"; +const TAG_EXPIRATION: &str = "expiration"; +const TAG_HIDDEN: &str = "hidden"; +const TAG_NAME: &str = "name"; +const TAG_PICTURE: &str = "picture"; +const TAG_PRIVATE: &str = "private"; +const TAG_ROLE: &str = "role"; + +pub fn group_put_user_from_event( + kind: u32, + tags: &[Vec<String>], + content: &str, +) -> Result<RadrootsGroupPutUser, EventParseError> { + require_kind(kind, KIND_GROUP_PUT_USER, "9000")?; + require_empty_content(content, "content")?; + let (pubkey, roles) = required_user_tag(tags)?; + Ok(RadrootsGroupPutUser { + group_id: required_tag_value(tags, TAG_H)?, + pubkey, + roles, + }) +} + +pub fn group_remove_user_from_event( + kind: u32, + tags: &[Vec<String>], + content: &str, +) -> Result<RadrootsGroupRemoveUser, EventParseError> { + require_kind(kind, KIND_GROUP_REMOVE_USER, "9001")?; + require_empty_content(content, "content")?; + let (pubkey, _) = required_user_tag(tags)?; + Ok(RadrootsGroupRemoveUser { + group_id: required_tag_value(tags, TAG_H)?, + pubkey, + }) +} + +pub fn group_create_group_from_event( + kind: u32, + tags: &[Vec<String>], + content: &str, +) -> Result<RadrootsGroupCreateGroup, EventParseError> { + require_kind(kind, KIND_GROUP_CREATE_GROUP, "9007")?; + require_empty_content(content, "content")?; + Ok(RadrootsGroupCreateGroup { + group_id: required_tag_value(tags, TAG_H)?, + metadata: metadata_from_tags(tags)?, + }) +} + +pub fn group_edit_metadata_from_event( + kind: u32, + tags: &[Vec<String>], + content: &str, +) -> Result<RadrootsGroupEditMetadata, EventParseError> { + require_kind(kind, KIND_GROUP_EDIT_METADATA, "9002")?; + require_empty_content(content, "content")?; + Ok(RadrootsGroupEditMetadata { + group_id: required_tag_value(tags, TAG_H)?, + metadata: metadata_from_tags(tags)?, + }) +} + +pub fn group_delete_group_from_event( + kind: u32, + tags: &[Vec<String>], + content: &str, +) -> Result<RadrootsGroupDeleteGroup, EventParseError> { + require_kind(kind, KIND_GROUP_DELETE_GROUP, "9008")?; + require_empty_content(content, "content")?; + Ok(RadrootsGroupDeleteGroup { + group_id: required_tag_value(tags, TAG_H)?, + }) +} + +pub fn group_delete_event_from_event( + kind: u32, + tags: &[Vec<String>], + content: &str, +) -> Result<RadrootsGroupDeleteEvent, EventParseError> { + require_kind(kind, KIND_GROUP_DELETE_EVENT, "9005")?; + require_empty_content(content, "content")?; + Ok(RadrootsGroupDeleteEvent { + group_id: required_tag_value(tags, TAG_H)?, + event_id: required_tag_value(tags, TAG_E)?, + }) +} + +pub fn group_create_invite_from_event( + kind: u32, + tags: &[Vec<String>], + content: &str, +) -> Result<RadrootsGroupCreateInvite, EventParseError> { + require_kind(kind, KIND_GROUP_CREATE_INVITE, "9009")?; + require_empty_content(content, "content")?; + Ok(RadrootsGroupCreateInvite { + group_id: required_tag_value(tags, TAG_H)?, + invitee_pubkey: optional_tag_value(tags, TAG_P)?, + roles: tag_values(tags, TAG_ROLE)?, + expires_at: parse_u64_optional(tags, TAG_EXPIRATION)?, + claim: optional_tag_value(tags, TAG_CLAIM)?, + }) +} + +pub fn group_join_request_from_event( + kind: u32, + tags: &[Vec<String>], + content: &str, +) -> Result<RadrootsGroupJoinRequest, EventParseError> { + require_kind(kind, KIND_GROUP_JOIN_REQUEST, "9021")?; + Ok(RadrootsGroupJoinRequest { + group_id: required_tag_value(tags, TAG_H)?, + message: optional_content(content), + }) +} + +pub fn group_leave_request_from_event( + kind: u32, + tags: &[Vec<String>], + content: &str, +) -> Result<RadrootsGroupLeaveRequest, EventParseError> { + require_kind(kind, KIND_GROUP_LEAVE_REQUEST, "9022")?; + Ok(RadrootsGroupLeaveRequest { + group_id: required_tag_value(tags, TAG_H)?, + message: optional_content(content), + }) +} + +pub fn group_metadata_from_event( + kind: u32, + tags: &[Vec<String>], + content: &str, +) -> Result<RadrootsGroupMetadata, EventParseError> { + require_kind(kind, KIND_GROUP_METADATA, "39000")?; + require_empty_content(content, "content")?; + Ok(RadrootsGroupMetadata { + d_tag: required_tag_value(tags, TAG_D)?, + metadata: metadata_from_tags(tags)?, + }) +} + +pub fn group_admins_from_event( + kind: u32, + tags: &[Vec<String>], + content: &str, +) -> Result<RadrootsGroupAdmins, EventParseError> { + require_kind(kind, KIND_GROUP_ADMINS, "39001")?; + require_empty_content(content, "content")?; + Ok(RadrootsGroupAdmins { + d_tag: required_tag_value(tags, TAG_D)?, + admins: user_refs_from_tags(tags)?, + }) +} + +pub fn group_members_from_event( + kind: u32, + tags: &[Vec<String>], + content: &str, +) -> Result<RadrootsGroupMembers, EventParseError> { + require_kind(kind, KIND_GROUP_MEMBERS, "39002")?; + require_empty_content(content, "content")?; + Ok(RadrootsGroupMembers { + d_tag: required_tag_value(tags, TAG_D)?, + members: user_refs_from_tags(tags)?, + }) +} + +pub fn group_roles_from_event( + kind: u32, + tags: &[Vec<String>], + content: &str, +) -> Result<RadrootsGroupRoles, EventParseError> { + require_kind(kind, KIND_GROUP_ROLES, "39003")?; + require_empty_content(content, "content")?; + Ok(RadrootsGroupRoles { + d_tag: required_tag_value(tags, TAG_D)?, + roles: roles_from_tags(tags)?, + }) +} + +fn require_kind( + kind: u32, + expected_kind: u32, + expected: &'static str, +) -> Result<(), EventParseError> { + if kind == expected_kind { + Ok(()) + } else { + Err(EventParseError::InvalidKind { + expected, + got: kind, + }) + } +} + +fn metadata_from_tags( + tags: &[Vec<String>], +) -> Result<RadrootsGroupEditableMetadata, EventParseError> { + Ok(RadrootsGroupEditableMetadata { + name: optional_tag_value(tags, TAG_NAME)?, + about: optional_tag_value(tags, TAG_ABOUT)?, + picture: optional_tag_value(tags, TAG_PICTURE)?, + is_private: bool_tag(tags, TAG_PRIVATE)?, + is_closed: bool_tag(tags, TAG_CLOSED)?, + is_hidden: bool_tag(tags, TAG_HIDDEN)?, + }) +} + +fn bool_tag(tags: &[Vec<String>], key: &'static str) -> Result<bool, EventParseError> { + let Some(value) = optional_tag_value(tags, key)? else { + return Ok(false); + }; + match value.as_str() { + "true" => Ok(true), + "false" => Ok(false), + _ => Err(EventParseError::InvalidTag(key)), + } +} + +fn required_user_tag(tags: &[Vec<String>]) -> Result<(String, Vec<String>), EventParseError> { + let tag = tags + .iter() + .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_P)) + .ok_or(EventParseError::MissingTag(TAG_P))?; + user_from_tag(tag) +} + +fn user_refs_from_tags(tags: &[Vec<String>]) -> Result<Vec<RadrootsGroupUserRef>, EventParseError> { + tags.iter() + .filter(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_P)) + .map(|tag| { + let (pubkey, roles) = user_from_tag(tag)?; + Ok(RadrootsGroupUserRef { pubkey, roles }) + }) + .collect() +} + +fn user_from_tag(tag: &[String]) -> Result<(String, Vec<String>), EventParseError> { + let pubkey = tag + .get(1) + .cloned() + .ok_or(EventParseError::InvalidTag(TAG_P))?; + validate_non_empty_tag_value(&pubkey, TAG_P)?; + let mut roles = Vec::new(); + for role in tag.iter().skip(2) { + validate_non_empty_tag_value(role, TAG_P)?; + roles.push(role.clone()); + } + Ok((pubkey, roles)) +} + +fn roles_from_tags(tags: &[Vec<String>]) -> Result<Vec<RadrootsGroupRole>, EventParseError> { + tags.iter() + .filter(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_ROLE)) + .map(|tag| { + let name = tag + .get(1) + .cloned() + .ok_or(EventParseError::InvalidTag(TAG_ROLE))?; + validate_non_empty_tag_value(&name, TAG_ROLE)?; + let description = tag.get(2).cloned(); + if let Some(description) = description.as_deref() { + validate_non_empty_tag_value(description, TAG_ROLE)?; + } + let mut permissions = Vec::new(); + for permission in tag.iter().skip(3) { + validate_non_empty_tag_value(permission, TAG_ROLE)?; + permissions.push(permission.clone()); + } + Ok(RadrootsGroupRole { + name, + description, + permissions, + }) + }) + .collect() +} + +fn parse_u64_optional( + tags: &[Vec<String>], + key: &'static str, +) -> Result<Option<u64>, EventParseError> { + let Some(value) = optional_tag_value(tags, key)? else { + return Ok(None); + }; + value + .parse::<u64>() + .map(Some) + .map_err(|err| EventParseError::InvalidNumber(key, err)) +} + +fn optional_content(content: &str) -> Option<String> { + if content.is_empty() { + None + } else { + Some(content.to_string()) + } +} diff --git a/crates/events_codec/src/group/encode.rs b/crates/events_codec/src/group/encode.rs @@ -0,0 +1,352 @@ +#[cfg(not(feature = "std"))] +use alloc::{ + string::{String, ToString}, + vec, + vec::Vec, +}; + +use radroots_events::{ + group::{ + 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, RadrootsGroupAdmins, + RadrootsGroupCreateGroup, RadrootsGroupCreateInvite, RadrootsGroupDeleteEvent, + RadrootsGroupDeleteGroup, RadrootsGroupEditMetadata, RadrootsGroupEditableMetadata, + RadrootsGroupJoinRequest, RadrootsGroupLeaveRequest, RadrootsGroupMembers, + RadrootsGroupMetadata, RadrootsGroupPutUser, RadrootsGroupRemoveUser, RadrootsGroupRole, + RadrootsGroupRoles, RadrootsGroupUserRef, + }, + tags::{TAG_D, TAG_E, TAG_H, TAG_P}, +}; + +use crate::error::EventEncodeError; +use crate::field_helpers::{ + push_optional_tag, push_tag, push_tag_values, validate_non_empty_field, +}; +use crate::wire::WireEventParts; + +const TAG_ABOUT: &str = "about"; +const TAG_CLOSED: &str = "closed"; +const TAG_CLAIM: &str = "claim"; +const TAG_EXPIRATION: &str = "expiration"; +const TAG_HIDDEN: &str = "hidden"; +const TAG_NAME: &str = "name"; +const TAG_PICTURE: &str = "picture"; +const TAG_PRIVATE: &str = "private"; +const TAG_ROLE: &str = "role"; + +pub fn group_put_user_build_tags( + event: &RadrootsGroupPutUser, +) -> Result<Vec<Vec<String>>, EventEncodeError> { + let mut tags = h_tags(&event.group_id)?; + push_user_tag(&mut tags, &event.pubkey, &event.roles)?; + Ok(tags) +} + +pub fn group_remove_user_build_tags( + event: &RadrootsGroupRemoveUser, +) -> Result<Vec<Vec<String>>, EventEncodeError> { + let mut tags = h_tags(&event.group_id)?; + push_tag(&mut tags, TAG_P, event.pubkey.as_str()); + validate_non_empty_field(&event.pubkey, "pubkey")?; + Ok(tags) +} + +pub fn group_create_group_build_tags( + event: &RadrootsGroupCreateGroup, +) -> Result<Vec<Vec<String>>, EventEncodeError> { + let mut tags = h_tags(&event.group_id)?; + push_metadata_tags(&mut tags, &event.metadata)?; + Ok(tags) +} + +pub fn group_edit_metadata_build_tags( + event: &RadrootsGroupEditMetadata, +) -> Result<Vec<Vec<String>>, EventEncodeError> { + let mut tags = h_tags(&event.group_id)?; + push_metadata_tags(&mut tags, &event.metadata)?; + Ok(tags) +} + +pub fn group_delete_group_build_tags( + event: &RadrootsGroupDeleteGroup, +) -> Result<Vec<Vec<String>>, EventEncodeError> { + h_tags(&event.group_id) +} + +pub fn group_delete_event_build_tags( + event: &RadrootsGroupDeleteEvent, +) -> Result<Vec<Vec<String>>, EventEncodeError> { + let mut tags = h_tags(&event.group_id)?; + validate_non_empty_field(&event.event_id, "event_id")?; + push_tag(&mut tags, TAG_E, event.event_id.as_str()); + Ok(tags) +} + +pub fn group_create_invite_build_tags( + event: &RadrootsGroupCreateInvite, +) -> Result<Vec<Vec<String>>, EventEncodeError> { + let mut tags = h_tags(&event.group_id)?; + push_optional_tag(&mut tags, TAG_P, event.invitee_pubkey.as_deref()); + for role in &event.roles { + validate_non_empty_field(role, "roles")?; + push_tag(&mut tags, TAG_ROLE, role.as_str()); + } + if let Some(expires_at) = event.expires_at { + push_tag(&mut tags, TAG_EXPIRATION, expires_at.to_string()); + } + push_optional_tag(&mut tags, TAG_CLAIM, event.claim.as_deref()); + Ok(tags) +} + +pub fn group_join_request_build_tags( + event: &RadrootsGroupJoinRequest, +) -> Result<Vec<Vec<String>>, EventEncodeError> { + h_tags(&event.group_id) +} + +pub fn group_leave_request_build_tags( + event: &RadrootsGroupLeaveRequest, +) -> Result<Vec<Vec<String>>, EventEncodeError> { + h_tags(&event.group_id) +} + +pub fn group_metadata_build_tags( + event: &RadrootsGroupMetadata, +) -> Result<Vec<Vec<String>>, EventEncodeError> { + let mut tags = d_tags(&event.d_tag)?; + push_metadata_tags(&mut tags, &event.metadata)?; + Ok(tags) +} + +pub fn group_admins_build_tags( + event: &RadrootsGroupAdmins, +) -> Result<Vec<Vec<String>>, EventEncodeError> { + let mut tags = d_tags(&event.d_tag)?; + push_user_refs(&mut tags, &event.admins)?; + Ok(tags) +} + +pub fn group_members_build_tags( + event: &RadrootsGroupMembers, +) -> Result<Vec<Vec<String>>, EventEncodeError> { + let mut tags = d_tags(&event.d_tag)?; + push_user_refs(&mut tags, &event.members)?; + Ok(tags) +} + +pub fn group_roles_build_tags( + event: &RadrootsGroupRoles, +) -> Result<Vec<Vec<String>>, EventEncodeError> { + let mut tags = d_tags(&event.d_tag)?; + for role in &event.roles { + validate_role(role)?; + let mut values = vec![role.name.clone()]; + if let Some(description) = role.description.as_deref() { + values.push(description.to_string()); + } + values.extend(role.permissions.iter().cloned()); + push_tag_values(&mut tags, TAG_ROLE, values); + } + Ok(tags) +} + +pub fn group_put_user_to_wire_parts( + event: &RadrootsGroupPutUser, +) -> Result<WireEventParts, EventEncodeError> { + empty_wire(KIND_GROUP_PUT_USER, group_put_user_build_tags(event)?) +} + +pub fn group_remove_user_to_wire_parts( + event: &RadrootsGroupRemoveUser, +) -> Result<WireEventParts, EventEncodeError> { + empty_wire(KIND_GROUP_REMOVE_USER, group_remove_user_build_tags(event)?) +} + +pub fn group_create_group_to_wire_parts( + event: &RadrootsGroupCreateGroup, +) -> Result<WireEventParts, EventEncodeError> { + empty_wire( + KIND_GROUP_CREATE_GROUP, + group_create_group_build_tags(event)?, + ) +} + +pub fn group_edit_metadata_to_wire_parts( + event: &RadrootsGroupEditMetadata, +) -> Result<WireEventParts, EventEncodeError> { + empty_wire( + KIND_GROUP_EDIT_METADATA, + group_edit_metadata_build_tags(event)?, + ) +} + +pub fn group_delete_group_to_wire_parts( + event: &RadrootsGroupDeleteGroup, +) -> Result<WireEventParts, EventEncodeError> { + empty_wire( + KIND_GROUP_DELETE_GROUP, + group_delete_group_build_tags(event)?, + ) +} + +pub fn group_delete_event_to_wire_parts( + event: &RadrootsGroupDeleteEvent, +) -> Result<WireEventParts, EventEncodeError> { + empty_wire( + KIND_GROUP_DELETE_EVENT, + group_delete_event_build_tags(event)?, + ) +} + +pub fn group_create_invite_to_wire_parts( + event: &RadrootsGroupCreateInvite, +) -> Result<WireEventParts, EventEncodeError> { + empty_wire( + KIND_GROUP_CREATE_INVITE, + group_create_invite_build_tags(event)?, + ) +} + +pub fn group_join_request_to_wire_parts( + event: &RadrootsGroupJoinRequest, +) -> Result<WireEventParts, EventEncodeError> { + message_wire( + KIND_GROUP_JOIN_REQUEST, + group_join_request_build_tags(event)?, + event.message.as_deref(), + ) +} + +pub fn group_leave_request_to_wire_parts( + event: &RadrootsGroupLeaveRequest, +) -> Result<WireEventParts, EventEncodeError> { + message_wire( + KIND_GROUP_LEAVE_REQUEST, + group_leave_request_build_tags(event)?, + event.message.as_deref(), + ) +} + +pub fn group_metadata_to_wire_parts( + event: &RadrootsGroupMetadata, +) -> Result<WireEventParts, EventEncodeError> { + empty_wire(KIND_GROUP_METADATA, group_metadata_build_tags(event)?) +} + +pub fn group_admins_to_wire_parts( + event: &RadrootsGroupAdmins, +) -> Result<WireEventParts, EventEncodeError> { + empty_wire(KIND_GROUP_ADMINS, group_admins_build_tags(event)?) +} + +pub fn group_members_to_wire_parts( + event: &RadrootsGroupMembers, +) -> Result<WireEventParts, EventEncodeError> { + empty_wire(KIND_GROUP_MEMBERS, group_members_build_tags(event)?) +} + +pub fn group_roles_to_wire_parts( + event: &RadrootsGroupRoles, +) -> Result<WireEventParts, EventEncodeError> { + empty_wire(KIND_GROUP_ROLES, group_roles_build_tags(event)?) +} + +fn h_tags(group_id: &str) -> Result<Vec<Vec<String>>, EventEncodeError> { + validate_non_empty_field(group_id, "group_id")?; + let mut tags = Vec::new(); + push_tag(&mut tags, TAG_H, group_id); + Ok(tags) +} + +fn d_tags(d_tag: &str) -> Result<Vec<Vec<String>>, EventEncodeError> { + validate_non_empty_field(d_tag, "d_tag")?; + let mut tags = Vec::new(); + push_tag(&mut tags, TAG_D, d_tag); + Ok(tags) +} + +fn push_metadata_tags( + tags: &mut Vec<Vec<String>>, + metadata: &RadrootsGroupEditableMetadata, +) -> Result<(), EventEncodeError> { + push_optional_tag(tags, TAG_NAME, metadata.name.as_deref()); + push_optional_tag(tags, TAG_ABOUT, metadata.about.as_deref()); + push_optional_tag(tags, TAG_PICTURE, metadata.picture.as_deref()); + if metadata.is_private { + push_tag(tags, TAG_PRIVATE, "true"); + } + if metadata.is_closed { + push_tag(tags, TAG_CLOSED, "true"); + } + if metadata.is_hidden { + push_tag(tags, TAG_HIDDEN, "true"); + } + validate_optional(metadata.name.as_deref(), "name")?; + validate_optional(metadata.about.as_deref(), "about")?; + validate_optional(metadata.picture.as_deref(), "picture")?; + Ok(()) +} + +fn push_user_refs( + tags: &mut Vec<Vec<String>>, + users: &[RadrootsGroupUserRef], +) -> Result<(), EventEncodeError> { + for user in users { + push_user_tag(tags, &user.pubkey, &user.roles)?; + } + Ok(()) +} + +fn push_user_tag( + tags: &mut Vec<Vec<String>>, + pubkey: &str, + roles: &[String], +) -> Result<(), EventEncodeError> { + validate_non_empty_field(pubkey, "pubkey")?; + for role in roles { + validate_non_empty_field(role, "roles")?; + } + let mut values = vec![pubkey.to_string()]; + values.extend(roles.iter().cloned()); + push_tag_values(tags, TAG_P, values); + Ok(()) +} + +fn validate_role(role: &RadrootsGroupRole) -> Result<(), EventEncodeError> { + validate_non_empty_field(&role.name, "role.name")?; + validate_optional(role.description.as_deref(), "role.description")?; + for permission in &role.permissions { + validate_non_empty_field(permission, "role.permissions")?; + } + Ok(()) +} + +fn validate_optional(value: Option<&str>, field: &'static str) -> Result<(), EventEncodeError> { + if let Some(value) = value { + validate_non_empty_field(value, field)?; + } + Ok(()) +} + +fn empty_wire(kind: u32, tags: Vec<Vec<String>>) -> Result<WireEventParts, EventEncodeError> { + Ok(WireEventParts { + kind, + content: String::new(), + tags, + }) +} + +fn message_wire( + kind: u32, + tags: Vec<Vec<String>>, + message: Option<&str>, +) -> Result<WireEventParts, EventEncodeError> { + validate_optional(message, "message")?; + Ok(WireEventParts { + kind, + content: message.unwrap_or_default().to_string(), + tags, + }) +} diff --git a/crates/events_codec/src/group/mod.rs b/crates/events_codec/src/group/mod.rs @@ -0,0 +1,234 @@ +pub mod decode; +pub mod encode; + +#[cfg(test)] +mod tests { + use radroots_events::group::{ + KIND_GROUP_ADMINS, KIND_GROUP_CREATE_INVITE, KIND_GROUP_JOIN_REQUEST, KIND_GROUP_MEMBERS, + KIND_GROUP_METADATA, KIND_GROUP_PUT_USER, KIND_GROUP_REMOVE_USER, KIND_GROUP_ROLES, + RadrootsGroupAdmins, RadrootsGroupCreateInvite, RadrootsGroupEditableMetadata, + RadrootsGroupJoinRequest, RadrootsGroupMembers, RadrootsGroupMetadata, + RadrootsGroupPutUser, RadrootsGroupRemoveUser, RadrootsGroupRole, RadrootsGroupRoles, + RadrootsGroupUserRef, + }; + + use crate::error::EventParseError; + use crate::group::decode::{ + group_admins_from_event, group_create_invite_from_event, group_join_request_from_event, + group_members_from_event, group_metadata_from_event, group_put_user_from_event, + group_remove_user_from_event, group_roles_from_event, + }; + use crate::group::encode::{ + group_admins_to_wire_parts, group_create_invite_to_wire_parts, + group_join_request_to_wire_parts, group_members_to_wire_parts, + group_metadata_to_wire_parts, group_put_user_to_wire_parts, + group_remove_user_to_wire_parts, group_roles_to_wire_parts, + }; + + #[test] + fn group_user_operations_use_h_group_id_routing() { + let put = RadrootsGroupPutUser { + group_id: "field-group".to_string(), + pubkey: "member_pubkey".to_string(), + roles: vec!["member".to_string()], + }; + let remove = RadrootsGroupRemoveUser { + group_id: "field-group".to_string(), + pubkey: "member_pubkey".to_string(), + }; + + let put_parts = group_put_user_to_wire_parts(&put).expect("put user"); + let remove_parts = group_remove_user_to_wire_parts(&remove).expect("remove user"); + + assert_eq!(put_parts.kind, KIND_GROUP_PUT_USER); + assert_eq!(remove_parts.kind, KIND_GROUP_REMOVE_USER); + assert!(put_parts.tags.contains(&tag("h", "field-group"))); + assert!( + !put_parts + .tags + .iter() + .any(|tag| tag.first().map(|v| v.as_str()) == Some("d")) + ); + assert_eq!( + group_put_user_from_event(put_parts.kind, &put_parts.tags, &put_parts.content) + .expect("decode put"), + put + ); + assert_eq!( + group_remove_user_from_event( + remove_parts.kind, + &remove_parts.tags, + &remove_parts.content + ) + .expect("decode remove"), + remove + ); + } + + #[test] + fn group_metadata_and_lists_use_d_tag_routing() { + let metadata = RadrootsGroupMetadata { + d_tag: "field-group".to_string(), + metadata: sample_metadata(), + }; + let admins = RadrootsGroupAdmins { + d_tag: "field-group".to_string(), + admins: vec![sample_user("admin_pubkey", "admin")], + }; + let members = RadrootsGroupMembers { + d_tag: "field-group".to_string(), + members: vec![sample_user("member_pubkey", "member")], + }; + let roles = RadrootsGroupRoles { + d_tag: "field-group".to_string(), + roles: vec![sample_role()], + }; + + let metadata_parts = group_metadata_to_wire_parts(&metadata).expect("metadata"); + let admins_parts = group_admins_to_wire_parts(&admins).expect("admins"); + let members_parts = group_members_to_wire_parts(&members).expect("members"); + let roles_parts = group_roles_to_wire_parts(&roles).expect("roles"); + + assert_eq!(metadata_parts.kind, KIND_GROUP_METADATA); + assert!(metadata_parts.tags.contains(&tag("d", "field-group"))); + assert!( + !metadata_parts + .tags + .iter() + .any(|tag| tag.first().map(|v| v.as_str()) == Some("h")) + ); + assert_eq!( + group_metadata_from_event( + metadata_parts.kind, + &metadata_parts.tags, + &metadata_parts.content + ) + .expect("decode metadata"), + metadata + ); + assert_eq!( + group_admins_from_event(admins_parts.kind, &admins_parts.tags, &admins_parts.content) + .expect("decode admins"), + admins + ); + assert_eq!( + group_members_from_event( + members_parts.kind, + &members_parts.tags, + &members_parts.content + ) + .expect("decode members"), + members + ); + assert_eq!( + group_roles_from_event(roles_parts.kind, &roles_parts.tags, &roles_parts.content) + .expect("decode roles"), + roles + ); + assert_eq!(admins_parts.kind, KIND_GROUP_ADMINS); + assert_eq!(members_parts.kind, KIND_GROUP_MEMBERS); + assert_eq!(roles_parts.kind, KIND_GROUP_ROLES); + } + + #[test] + fn group_invites_and_join_requests_roundtrip_without_field_authorization() { + let invite = RadrootsGroupCreateInvite { + group_id: "field-group".to_string(), + invitee_pubkey: Some("member_pubkey".to_string()), + roles: vec!["member".to_string()], + expires_at: Some(1_780_000_000), + claim: Some("claim-token".to_string()), + }; + let join = RadrootsGroupJoinRequest { + group_id: "field-group".to_string(), + message: Some("requesting access".to_string()), + }; + + let invite_parts = group_create_invite_to_wire_parts(&invite).expect("invite"); + let join_parts = group_join_request_to_wire_parts(&join).expect("join"); + + assert_eq!(invite_parts.kind, KIND_GROUP_CREATE_INVITE); + assert_eq!(join_parts.kind, KIND_GROUP_JOIN_REQUEST); + assert!(invite_parts.tags.contains(&tag("h", "field-group"))); + assert_eq!(join_parts.content, "requesting access"); + assert_eq!( + group_create_invite_from_event( + invite_parts.kind, + &invite_parts.tags, + &invite_parts.content + ) + .expect("decode invite"), + invite + ); + assert_eq!( + group_join_request_from_event(join_parts.kind, &join_parts.tags, &join_parts.content) + .expect("decode join"), + join + ); + } + + #[test] + fn group_codecs_reject_wrong_routing_tags() { + let metadata = RadrootsGroupMetadata { + d_tag: "field-group".to_string(), + metadata: sample_metadata(), + }; + let mut metadata_parts = group_metadata_to_wire_parts(&metadata).expect("metadata"); + metadata_parts + .tags + .retain(|tag| tag.first().map(|value| value.as_str()) != Some("d")); + metadata_parts.tags.push(tag("h", "field-group")); + let metadata_err = group_metadata_from_event( + metadata_parts.kind, + &metadata_parts.tags, + &metadata_parts.content, + ) + .unwrap_err(); + assert!(matches!(metadata_err, EventParseError::MissingTag("d"))); + + let put = RadrootsGroupPutUser { + group_id: "field-group".to_string(), + pubkey: "member_pubkey".to_string(), + roles: vec!["member".to_string()], + }; + let mut put_parts = group_put_user_to_wire_parts(&put).expect("put"); + put_parts + .tags + .retain(|tag| tag.first().map(|value| value.as_str()) != Some("h")); + put_parts.tags.push(tag("d", "field-group")); + let put_err = + group_put_user_from_event(put_parts.kind, &put_parts.tags, &put_parts.content) + .unwrap_err(); + assert!(matches!(put_err, EventParseError::MissingTag("h"))); + } + + fn sample_metadata() -> RadrootsGroupEditableMetadata { + RadrootsGroupEditableMetadata { + name: Some("Small Regen Farm".to_string()), + about: Some("Field app group".to_string()), + picture: Some("https://media.example.invalid/group.png".to_string()), + is_private: false, + is_closed: false, + is_hidden: false, + } + } + + fn sample_user(pubkey: &str, role: &str) -> RadrootsGroupUserRef { + RadrootsGroupUserRef { + pubkey: pubkey.to_string(), + roles: vec![role.to_string()], + } + } + + fn sample_role() -> RadrootsGroupRole { + RadrootsGroupRole { + name: "member".to_string(), + description: Some("can read and write group events".to_string()), + permissions: vec!["read".to_string(), "write".to_string()], + } + } + + fn tag(key: &str, value: &str) -> Vec<String> { + vec![key.to_string(), value.to_string()] + } +} diff --git a/crates/events_codec/src/lib.rs b/crates/events_codec/src/lib.rs @@ -24,6 +24,7 @@ pub mod farm_workspace; pub mod follow; pub mod geochat; pub mod gift_wrap; +pub mod group; pub mod http_auth; pub mod message; pub mod message_file;