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