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:
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;