lib

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

commit f470d1fe3e305fa66b4ecb5dbac5474fbd434e66
parent 6e48c1a0408d140ef0d6242cbcc9765738648eed
Author: triesap <tyson@radroots.org>
Date:   Thu, 11 Jun 2026 18:32:23 -0700

events_codec: align NIP-29 group shapes

- replace first-pass invite and metadata marker shapes with code, bare marker, and supported_kinds tags
- preserve optional moderation and relay-state content in group event models and codecs
- update native and wasm tests for valid protocol cases and rejected first-pass tags
- document the supported NIP-29 subset and deferred LiveKit state

Diffstat:
Mcrates/events/README | 9++++++++-
Mcrates/events/src/group.rs | 37+++++++++++++++++++++++++++----------
Mcrates/events_codec/README | 12+++++++++---
Mcrates/events_codec/src/group/decode.rs | 94++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Mcrates/events_codec/src/group/encode.rs | 86++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Mcrates/events_codec/src/group/mod.rs | 62+++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/events_codec/tests/field_events.rs | 36++++++++++++++++++++++++++++++------
Mcrates/events_codec_wasm/src/lib.rs | 37+++++++++++++++++++++++++++++--------
Mspec/README.md | 9+++++++--
9 files changed, 281 insertions(+), 101 deletions(-)

diff --git a/crates/events/README b/crates/events/README @@ -26,7 +26,14 @@ farming operations: * farm file metadata events for media attached to farm documents; * NIP-42 relay auth and NIP-98 HTTP auth payload models; * NIP-29 group metadata, member lists, roles, invites, joins, leaves, and user - operations. + operations for the supported `9000`, `9001`, `9002`, `9005`, `9007`, `9008`, + `9009`, `9021`, `9022`, `39000`, `39001`, `39002`, and `39003` subset. + +The NIP-29 group surface uses bare metadata marker tags such as `private`, +`restricted`, `hidden`, and `closed`, `supported_kinds` declarations, and +`code` tags for invite and join flows. User management and moderation events +preserve optional reason content. LiveKit room metadata and live participant +state are not part of this crate's current group event subset. Task records, work sessions, harvest records, approvals, and similar Field business objects are CRDT document semantics carried by diff --git a/crates/events/src/group.rs b/crates/events/src/group.rs @@ -37,6 +37,7 @@ pub const KIND_GROUP_ROLES: u32 = KIND_GROUP_ROLES_EVENT; #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsGroupPutUser { pub group_id: String, + pub message: Option<String>, pub pubkey: String, pub roles: Vec<String>, } @@ -45,6 +46,7 @@ pub struct RadrootsGroupPutUser { #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsGroupRemoveUser { pub group_id: String, + pub message: Option<String>, pub pubkey: String, } @@ -52,6 +54,7 @@ pub struct RadrootsGroupRemoveUser { #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsGroupCreateGroup { pub group_id: String, + pub message: Option<String>, pub metadata: RadrootsGroupEditableMetadata, } @@ -59,6 +62,7 @@ pub struct RadrootsGroupCreateGroup { #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsGroupEditMetadata { pub group_id: String, + pub message: Option<String>, pub metadata: RadrootsGroupEditableMetadata, } @@ -66,12 +70,14 @@ pub struct RadrootsGroupEditMetadata { #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsGroupDeleteGroup { pub group_id: String, + pub message: Option<String>, } #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsGroupDeleteEvent { pub group_id: String, + pub message: Option<String>, pub event_id: String, } @@ -79,10 +85,8 @@ pub struct RadrootsGroupDeleteEvent { #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsGroupCreateInvite { pub group_id: String, - pub invitee_pubkey: Option<String>, - pub roles: Vec<String>, - pub expires_at: Option<u64>, - pub claim: Option<String>, + pub message: Option<String>, + pub code: String, } #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] @@ -90,6 +94,7 @@ pub struct RadrootsGroupCreateInvite { pub struct RadrootsGroupJoinRequest { pub group_id: String, pub message: Option<String>, + pub code: Option<String>, } #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] @@ -110,6 +115,7 @@ pub struct RadrootsGroupMetadata { #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsGroupAdmins { pub d_tag: String, + pub description: Option<String>, pub admins: Vec<RadrootsGroupUserRef>, } @@ -117,6 +123,7 @@ pub struct RadrootsGroupAdmins { #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsGroupMembers { pub d_tag: String, + pub description: Option<String>, pub members: Vec<RadrootsGroupUserRef>, } @@ -124,6 +131,7 @@ pub struct RadrootsGroupMembers { #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsGroupRoles { pub d_tag: String, + pub description: Option<String>, pub roles: Vec<RadrootsGroupRole>, } @@ -134,8 +142,10 @@ pub struct RadrootsGroupEditableMetadata { pub about: Option<String>, pub picture: Option<String>, pub is_private: bool, + pub is_restricted: bool, pub is_closed: bool, pub is_hidden: bool, + pub supported_kinds: Option<Vec<u32>>, } #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] @@ -161,16 +171,19 @@ mod tests { fn group_user_and_moderation_models_use_h_group_id_semantics() { let put = RadrootsGroupPutUser { group_id: "field-group".to_string(), + message: Some("add member".to_string()), pubkey: "member_pubkey".to_string(), roles: vec!["member".to_string()], }; let delete = RadrootsGroupDeleteEvent { group_id: "field-group".to_string(), + message: Some("remove duplicate event".to_string()), event_id: "event_id".to_string(), }; let join = RadrootsGroupJoinRequest { group_id: "field-group".to_string(), message: Some("requesting access".to_string()), + code: Some("invite-code".to_string()), }; assert_eq!(put.group_id, "field-group"); @@ -189,10 +202,12 @@ mod tests { }; let members = RadrootsGroupMembers { d_tag: "field-group".to_string(), + description: Some("group members".to_string()), members: vec![sample_user_ref()], }; let roles = RadrootsGroupRoles { d_tag: "field-group".to_string(), + description: Some("group roles".to_string()), roles: vec![RadrootsGroupRole { name: "member".to_string(), description: Some("can read and write group events".to_string()), @@ -212,6 +227,7 @@ mod tests { fn group_models_are_infrastructure_not_field_business_authorization() { let admins = RadrootsGroupAdmins { d_tag: "field-group".to_string(), + description: Some("group admins".to_string()), admins: vec![sample_user_ref()], }; @@ -223,14 +239,13 @@ mod tests { fn group_models_serialize_stable_shapes() { let create = RadrootsGroupCreateGroup { group_id: "field-group".to_string(), + message: None, metadata: sample_metadata(), }; 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()), + message: Some("join the field group".to_string()), + code: "invite-code".to_string(), }; let create_value = serde_json::to_value(create).unwrap(); @@ -238,8 +253,8 @@ mod tests { assert_eq!(create_value["group_id"], "field-group"); assert_eq!(create_value["metadata"]["name"], "Small Regen Farm"); - assert_eq!(invite_value["roles"][0], "member"); - assert_eq!(invite_value["claim"], "claim-token"); + assert_eq!(invite_value["code"], "invite-code"); + assert_eq!(invite_value["message"], "join the field group"); assert_eq!(KIND_GROUP_CREATE_GROUP, 9007); assert_eq!(KIND_GROUP_CREATE_INVITE, 9009); } @@ -250,8 +265,10 @@ mod tests { about: Some("Field app group".to_string()), picture: Some("https://media.example.invalid/group.png".to_string()), is_private: false, + is_restricted: true, is_closed: false, is_hidden: false, + supported_kinds: Some(vec![78, 30078]), } } diff --git a/crates/events_codec/README b/crates/events_codec/README @@ -29,13 +29,19 @@ The Field codec surface validates the public Nostr event substrate exposed by * NIP-42 relay auth and NIP-98 HTTP auth events require empty content and the auth tags required by their protocols; * NIP-29 group codecs preserve the protocol distinction between `h`-routed - group operations and `d`-routed addressable group state. + group operations and `d`-routed addressable group state for the supported + `9000`, `9001`, `9002`, `9005`, `9007`, `9008`, `9009`, `9021`, `9022`, + `39000`, `39001`, `39002`, and `39003` subset. These codecs validate event shape, routing tags, hashes, and payload encoding. They do not validate private Field task, work-session, harvest, approval, or authorization semantics; those remain application and CRDT document concerns. -The companion `radroots_events_codec_wasm` crate exposes deterministic JSON tag -builders for the same Field and NIP-29 families. +The group codecs use bare metadata marker tags such as `private`, `restricted`, +`hidden`, and `closed`, `supported_kinds` declarations, and `code` tags for +invite and join flows. They preserve optional reason content on user management +and moderation events. LiveKit room metadata and live participant state are +deferred. The companion `radroots_events_codec_wasm` crate exposes +deterministic JSON tag builders for the same Field and NIP-29 families. ## Copyright diff --git a/crates/events_codec/src/group/decode.rs b/crates/events_codec/src/group/decode.rs @@ -21,19 +21,19 @@ use radroots_events::{ use crate::error::EventParseError; use crate::field_helpers::{ - optional_tag_value, require_empty_content, required_tag_value, tag_values, - validate_non_empty_tag_value, + optional_tag_value, require_empty_content, required_tag_value, 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_CODE: &str = "code"; const TAG_HIDDEN: &str = "hidden"; const TAG_NAME: &str = "name"; const TAG_PICTURE: &str = "picture"; const TAG_PRIVATE: &str = "private"; +const TAG_RESTRICTED: &str = "restricted"; const TAG_ROLE: &str = "role"; +const TAG_SUPPORTED_KINDS: &str = "supported_kinds"; pub fn group_put_user_from_event( kind: u32, @@ -41,10 +41,10 @@ pub fn group_put_user_from_event( 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)?, + message: optional_content(content), pubkey, roles, }) @@ -56,10 +56,10 @@ pub fn group_remove_user_from_event( 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)?, + message: optional_content(content), pubkey, }) } @@ -70,9 +70,9 @@ pub fn group_create_group_from_event( 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)?, + message: optional_content(content), metadata: metadata_from_tags(tags)?, }) } @@ -83,9 +83,9 @@ pub fn group_edit_metadata_from_event( 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)?, + message: optional_content(content), metadata: metadata_from_tags(tags)?, }) } @@ -96,9 +96,9 @@ pub fn group_delete_group_from_event( 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)?, + message: optional_content(content), }) } @@ -108,9 +108,9 @@ pub fn group_delete_event_from_event( 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)?, + message: optional_content(content), event_id: required_tag_value(tags, TAG_E)?, }) } @@ -121,13 +121,10 @@ pub fn group_create_invite_from_event( 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)?, + message: optional_content(content), + code: required_tag_value(tags, TAG_CODE)?, }) } @@ -140,6 +137,7 @@ pub fn group_join_request_from_event( Ok(RadrootsGroupJoinRequest { group_id: required_tag_value(tags, TAG_H)?, message: optional_content(content), + code: optional_tag_value(tags, TAG_CODE)?, }) } @@ -174,9 +172,9 @@ pub fn group_admins_from_event( 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)?, + description: optional_content(content), admins: user_refs_from_tags(tags)?, }) } @@ -187,9 +185,9 @@ pub fn group_members_from_event( 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)?, + description: optional_content(content), members: user_refs_from_tags(tags)?, }) } @@ -200,9 +198,9 @@ pub fn group_roles_from_event( 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)?, + description: optional_content(content), roles: roles_from_tags(tags)?, }) } @@ -229,21 +227,48 @@ fn metadata_from_tags( 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)?, + is_private: marker_tag(tags, TAG_PRIVATE)?, + is_restricted: marker_tag(tags, TAG_RESTRICTED)?, + is_closed: marker_tag(tags, TAG_CLOSED)?, + is_hidden: marker_tag(tags, TAG_HIDDEN)?, + supported_kinds: supported_kinds_from_tags(tags)?, }) } -fn bool_tag(tags: &[Vec<String>], key: &'static str) -> Result<bool, EventParseError> { - let Some(value) = optional_tag_value(tags, key)? else { - return Ok(false); +fn marker_tag(tags: &[Vec<String>], key: &'static str) -> Result<bool, EventParseError> { + let mut found = false; + for tag in tags + .iter() + .filter(|tag| tag.first().map(|value| value.as_str()) == Some(key)) + { + if found || tag.len() != 1 { + return Err(EventParseError::InvalidTag(key)); + } + found = true; + } + Ok(found) +} + +fn supported_kinds_from_tags(tags: &[Vec<String>]) -> Result<Option<Vec<u32>>, EventParseError> { + let mut matches = tags + .iter() + .filter(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_SUPPORTED_KINDS)); + let Some(tag) = matches.next() else { + return Ok(None); }; - match value.as_str() { - "true" => Ok(true), - "false" => Ok(false), - _ => Err(EventParseError::InvalidTag(key)), + if matches.next().is_some() { + return Err(EventParseError::InvalidTag(TAG_SUPPORTED_KINDS)); + } + let mut supported_kinds = Vec::new(); + for value in tag.iter().skip(1) { + validate_non_empty_tag_value(value, TAG_SUPPORTED_KINDS)?; + supported_kinds.push( + value + .parse::<u32>() + .map_err(|err| EventParseError::InvalidNumber(TAG_SUPPORTED_KINDS, err))?, + ); } + Ok(Some(supported_kinds)) } fn required_user_tag(tags: &[Vec<String>]) -> Result<(String, Vec<String>), EventParseError> { @@ -305,19 +330,6 @@ fn roles_from_tags(tags: &[Vec<String>]) -> Result<Vec<RadrootsGroupRole>, Event .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 diff --git a/crates/events_codec/src/group/encode.rs b/crates/events_codec/src/group/encode.rs @@ -28,13 +28,14 @@ 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_CODE: &str = "code"; const TAG_HIDDEN: &str = "hidden"; const TAG_NAME: &str = "name"; const TAG_PICTURE: &str = "picture"; const TAG_PRIVATE: &str = "private"; +const TAG_RESTRICTED: &str = "restricted"; const TAG_ROLE: &str = "role"; +const TAG_SUPPORTED_KINDS: &str = "supported_kinds"; pub fn group_put_user_build_tags( event: &RadrootsGroupPutUser, @@ -88,22 +89,18 @@ 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()); + validate_non_empty_field(&event.code, "code")?; + push_tag(&mut tags, TAG_CODE, event.code.as_str()); Ok(tags) } pub fn group_join_request_build_tags( event: &RadrootsGroupJoinRequest, ) -> Result<Vec<Vec<String>>, EventEncodeError> { - h_tags(&event.group_id) + let mut tags = h_tags(&event.group_id)?; + push_optional_tag(&mut tags, TAG_CODE, event.code.as_deref()); + validate_optional(event.code.as_deref(), "code")?; + Ok(tags) } pub fn group_leave_request_build_tags( @@ -155,57 +152,70 @@ pub fn group_roles_build_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)?) + message_wire( + KIND_GROUP_PUT_USER, + group_put_user_build_tags(event)?, + event.message.as_deref(), + ) } 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)?) + message_wire( + KIND_GROUP_REMOVE_USER, + group_remove_user_build_tags(event)?, + event.message.as_deref(), + ) } pub fn group_create_group_to_wire_parts( event: &RadrootsGroupCreateGroup, ) -> Result<WireEventParts, EventEncodeError> { - empty_wire( + message_wire( KIND_GROUP_CREATE_GROUP, group_create_group_build_tags(event)?, + event.message.as_deref(), ) } pub fn group_edit_metadata_to_wire_parts( event: &RadrootsGroupEditMetadata, ) -> Result<WireEventParts, EventEncodeError> { - empty_wire( + message_wire( KIND_GROUP_EDIT_METADATA, group_edit_metadata_build_tags(event)?, + event.message.as_deref(), ) } pub fn group_delete_group_to_wire_parts( event: &RadrootsGroupDeleteGroup, ) -> Result<WireEventParts, EventEncodeError> { - empty_wire( + message_wire( KIND_GROUP_DELETE_GROUP, group_delete_group_build_tags(event)?, + event.message.as_deref(), ) } pub fn group_delete_event_to_wire_parts( event: &RadrootsGroupDeleteEvent, ) -> Result<WireEventParts, EventEncodeError> { - empty_wire( + message_wire( KIND_GROUP_DELETE_EVENT, group_delete_event_build_tags(event)?, + event.message.as_deref(), ) } pub fn group_create_invite_to_wire_parts( event: &RadrootsGroupCreateInvite, ) -> Result<WireEventParts, EventEncodeError> { - empty_wire( + message_wire( KIND_GROUP_CREATE_INVITE, group_create_invite_build_tags(event)?, + event.message.as_deref(), ) } @@ -238,19 +248,31 @@ pub fn group_metadata_to_wire_parts( pub fn group_admins_to_wire_parts( event: &RadrootsGroupAdmins, ) -> Result<WireEventParts, EventEncodeError> { - empty_wire(KIND_GROUP_ADMINS, group_admins_build_tags(event)?) + message_wire( + KIND_GROUP_ADMINS, + group_admins_build_tags(event)?, + event.description.as_deref(), + ) } pub fn group_members_to_wire_parts( event: &RadrootsGroupMembers, ) -> Result<WireEventParts, EventEncodeError> { - empty_wire(KIND_GROUP_MEMBERS, group_members_build_tags(event)?) + message_wire( + KIND_GROUP_MEMBERS, + group_members_build_tags(event)?, + event.description.as_deref(), + ) } pub fn group_roles_to_wire_parts( event: &RadrootsGroupRoles, ) -> Result<WireEventParts, EventEncodeError> { - empty_wire(KIND_GROUP_ROLES, group_roles_build_tags(event)?) + message_wire( + KIND_GROUP_ROLES, + group_roles_build_tags(event)?, + event.description.as_deref(), + ) } fn h_tags(group_id: &str) -> Result<Vec<Vec<String>>, EventEncodeError> { @@ -275,13 +297,23 @@ fn push_metadata_tags( 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"); + push_marker_tag(tags, TAG_PRIVATE); + } + if metadata.is_restricted { + push_marker_tag(tags, TAG_RESTRICTED); } if metadata.is_closed { - push_tag(tags, TAG_CLOSED, "true"); + push_marker_tag(tags, TAG_CLOSED); } if metadata.is_hidden { - push_tag(tags, TAG_HIDDEN, "true"); + push_marker_tag(tags, TAG_HIDDEN); + } + if let Some(supported_kinds) = metadata.supported_kinds.as_deref() { + push_tag_values( + tags, + TAG_SUPPORTED_KINDS, + supported_kinds.iter().map(ToString::to_string), + ); } validate_optional(metadata.name.as_deref(), "name")?; validate_optional(metadata.about.as_deref(), "about")?; @@ -289,6 +321,10 @@ fn push_metadata_tags( Ok(()) } +fn push_marker_tag(tags: &mut Vec<Vec<String>>, key: &str) { + tags.push(vec![key.to_string()]); +} + fn push_user_refs( tags: &mut Vec<Vec<String>>, users: &[RadrootsGroupUserRef], diff --git a/crates/events_codec/src/group/mod.rs b/crates/events_codec/src/group/mod.rs @@ -29,11 +29,13 @@ mod tests { fn group_user_operations_use_h_group_id_routing() { let put = RadrootsGroupPutUser { group_id: "field-group".to_string(), + message: Some("add member".to_string()), pubkey: "member_pubkey".to_string(), roles: vec!["member".to_string()], }; let remove = RadrootsGroupRemoveUser { group_id: "field-group".to_string(), + message: Some("remove member".to_string()), pubkey: "member_pubkey".to_string(), }; @@ -42,6 +44,8 @@ mod tests { assert_eq!(put_parts.kind, KIND_GROUP_PUT_USER); assert_eq!(remove_parts.kind, KIND_GROUP_REMOVE_USER); + assert_eq!(put_parts.content, "add member"); + assert_eq!(remove_parts.content, "remove member"); assert!(put_parts.tags.contains(&tag("h", "field-group"))); assert!( !put_parts @@ -73,14 +77,17 @@ mod tests { }; let admins = RadrootsGroupAdmins { d_tag: "field-group".to_string(), + description: Some("group admins".to_string()), admins: vec![sample_user("admin_pubkey", "admin")], }; let members = RadrootsGroupMembers { d_tag: "field-group".to_string(), + description: Some("group members".to_string()), members: vec![sample_user("member_pubkey", "member")], }; let roles = RadrootsGroupRoles { d_tag: "field-group".to_string(), + description: Some("group roles".to_string()), roles: vec![sample_role()], }; @@ -91,12 +98,22 @@ mod tests { assert_eq!(metadata_parts.kind, KIND_GROUP_METADATA); assert!(metadata_parts.tags.contains(&tag("d", "field-group"))); + assert!(metadata_parts.tags.contains(&marker("restricted"))); + assert!(metadata_parts.tags.contains(&marker("closed"))); + assert!(metadata_parts.tags.contains(&vec![ + "supported_kinds".to_string(), + "78".to_string(), + "30078".to_string() + ])); assert!( !metadata_parts .tags .iter() .any(|tag| tag.first().map(|v| v.as_str()) == Some("h")) ); + assert_eq!(admins_parts.content, "group admins"); + assert_eq!(members_parts.content, "group members"); + assert_eq!(roles_parts.content, "group roles"); assert_eq!( group_metadata_from_event( metadata_parts.kind, @@ -134,14 +151,13 @@ mod tests { 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()), + message: Some("join the field group".to_string()), + code: "invite-code".to_string(), }; let join = RadrootsGroupJoinRequest { group_id: "field-group".to_string(), message: Some("requesting access".to_string()), + code: Some("invite-code".to_string()), }; let invite_parts = group_create_invite_to_wire_parts(&invite).expect("invite"); @@ -150,6 +166,9 @@ mod tests { 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!(invite_parts.tags.contains(&tag("code", "invite-code"))); + assert!(join_parts.tags.contains(&tag("code", "invite-code"))); + assert_eq!(invite_parts.content, "join the field group"); assert_eq!(join_parts.content, "requesting access"); assert_eq!( group_create_invite_from_event( @@ -188,6 +207,7 @@ mod tests { let put = RadrootsGroupPutUser { group_id: "field-group".to_string(), + message: None, pubkey: "member_pubkey".to_string(), roles: vec!["member".to_string()], }; @@ -202,14 +222,42 @@ mod tests { assert!(matches!(put_err, EventParseError::MissingTag("h"))); } + #[test] + fn group_codecs_reject_nonstandard_first_pass_group_shapes() { + let valued_marker_tags = vec![ + tag("d", "field-group"), + tag("private", "true"), + tag("supported_kinds", "78"), + ]; + let metadata_err = + group_metadata_from_event(KIND_GROUP_METADATA, &valued_marker_tags, "").unwrap_err(); + assert!(matches!( + metadata_err, + EventParseError::InvalidTag("private") + )); + + let first_pass_invite_tags = vec![ + tag("h", "field-group"), + tag("p", "member_pubkey"), + tag("role", "member"), + tag("claim", "claim-token"), + ]; + let invite_err = + group_create_invite_from_event(KIND_GROUP_CREATE_INVITE, &first_pass_invite_tags, "") + .unwrap_err(); + assert!(matches!(invite_err, EventParseError::MissingTag("code"))); + } + 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_restricted: true, + is_closed: true, is_hidden: false, + supported_kinds: Some(vec![78, 30078]), } } @@ -231,4 +279,8 @@ mod tests { fn tag(key: &str, value: &str) -> Vec<String> { vec![key.to_string(), value.to_string()] } + + fn marker(key: &str) -> Vec<String> { + vec![key.to_string()] + } } diff --git a/crates/events_codec/tests/field_events.rs b/crates/events_codec/tests/field_events.rs @@ -13,8 +13,9 @@ use radroots_events::{ RadrootsFarmWorkspaceRelay, RadrootsFarmWorkspaceRelayMode, }, group::{ - RadrootsGroupAdmins, RadrootsGroupCreateInvite, RadrootsGroupEditableMetadata, - RadrootsGroupMetadata, RadrootsGroupPutUser, RadrootsGroupUserRef, + KIND_GROUP_CREATE_INVITE, KIND_GROUP_METADATA, RadrootsGroupAdmins, + RadrootsGroupCreateInvite, RadrootsGroupEditableMetadata, RadrootsGroupMetadata, + RadrootsGroupPutUser, RadrootsGroupUserRef, }, http_auth::RadrootsHttpAuth, kinds::KIND_POST, @@ -132,6 +133,7 @@ fn field_codec_matrix_roundtrips_all_new_event_families() { let admins = RadrootsGroupAdmins { d_tag: GROUP_ID.to_string(), + description: Some("field group admins".to_string()), admins: vec![RadrootsGroupUserRef { pubkey: "admin_pubkey".to_string(), roles: vec!["admin".to_string()], @@ -146,6 +148,7 @@ fn field_codec_matrix_roundtrips_all_new_event_families() { let put = RadrootsGroupPutUser { group_id: GROUP_ID.to_string(), + message: Some("add field member".to_string()), pubkey: "member_pubkey".to_string(), roles: vec!["member".to_string()], }; @@ -158,10 +161,8 @@ fn field_codec_matrix_roundtrips_all_new_event_families() { let invite = RadrootsGroupCreateInvite { group_id: GROUP_ID.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()), + message: Some("join the field group".to_string()), + code: "invite-code".to_string(), }; let invite_parts = group_create_invite_to_wire_parts(&invite).expect("invite parts"); assert_eq!( @@ -204,6 +205,7 @@ fn field_codec_matrix_rejects_missing_required_tags_and_mismatches() { let put_parts = group_put_user_to_wire_parts(&RadrootsGroupPutUser { group_id: GROUP_ID.to_string(), + message: None, pubkey: "member_pubkey".to_string(), roles: vec!["member".to_string()], }) @@ -212,6 +214,26 @@ fn field_codec_matrix_rejects_missing_required_tags_and_mismatches() { group_put_user_from_event(put_parts.kind, &without_tag(&put_parts.tags, "h"), ""), Err(EventParseError::MissingTag("h")) )); + + let valued_marker_tags = vec![ + vec!["d".to_string(), GROUP_ID.to_string()], + vec!["private".to_string(), "true".to_string()], + ]; + assert!(matches!( + group_metadata_from_event(KIND_GROUP_METADATA, &valued_marker_tags, ""), + Err(EventParseError::InvalidTag("private")) + )); + + let first_pass_invite_tags = vec![ + vec!["h".to_string(), GROUP_ID.to_string()], + vec!["p".to_string(), "member_pubkey".to_string()], + vec!["role".to_string(), "member".to_string()], + vec!["claim".to_string(), "claim-token".to_string()], + ]; + assert!(matches!( + group_create_invite_from_event(KIND_GROUP_CREATE_INVITE, &first_pass_invite_tags, ""), + Err(EventParseError::MissingTag("code")) + )); } #[test] @@ -352,8 +374,10 @@ fn sample_group_metadata() -> RadrootsGroupEditableMetadata { about: Some("Field app group".to_string()), picture: Some("https://media.example.invalid/group.png".to_string()), is_private: false, + is_restricted: true, is_closed: false, is_hidden: false, + supported_kinds: Some(vec![78, 30078]), } } diff --git a/crates/events_codec_wasm/src/lib.rs b/crates/events_codec_wasm/src/lib.rs @@ -493,8 +493,10 @@ mod tests { about: Some("Field app group".to_string()), picture: Some("https://media.example.invalid/group.png".to_string()), is_private: false, + is_restricted: true, is_closed: false, is_hidden: false, + supported_kinds: Some(vec![78, 30078]), } } @@ -514,11 +516,15 @@ mod tests { } fn assert_tags_json(value: Result<String, RadrootsJsValue>) { - let json = value.expect("tags json"); - let tags: Vec<Vec<String>> = serde_json::from_str(&json).expect("tags"); + let tags = tags_json(value); assert!(!tags.is_empty()); } + fn tags_json(value: Result<String, RadrootsJsValue>) -> Vec<Vec<String>> { + let json = value.expect("tags json"); + serde_json::from_str(&json).expect("tags") + } + #[test] fn bindings_reject_invalid_json() { let bindings: [fn(&str) -> Result<String, RadrootsJsValue>; 36] = [ @@ -621,6 +627,7 @@ mod tests { assert_tags_json(group_put_user_tags( &serde_json::to_string(&RadrootsGroupPutUser { group_id: "field-group".to_string(), + message: Some("add member".to_string()), pubkey: "member_pubkey".to_string(), roles: vec!["member".to_string()], }) @@ -629,6 +636,7 @@ mod tests { assert_tags_json(group_remove_user_tags( &serde_json::to_string(&RadrootsGroupRemoveUser { group_id: "field-group".to_string(), + message: Some("remove member".to_string()), pubkey: "member_pubkey".to_string(), }) .expect("remove user json"), @@ -636,6 +644,7 @@ mod tests { assert_tags_json(group_create_group_tags( &serde_json::to_string(&RadrootsGroupCreateGroup { group_id: "field-group".to_string(), + message: Some("create group".to_string()), metadata: metadata.clone(), }) .expect("create group json"), @@ -643,6 +652,7 @@ mod tests { assert_tags_json(group_edit_metadata_tags( &serde_json::to_string(&RadrootsGroupEditMetadata { group_id: "field-group".to_string(), + message: Some("edit metadata".to_string()), metadata: metadata.clone(), }) .expect("edit metadata json"), @@ -650,30 +660,32 @@ mod tests { assert_tags_json(group_delete_group_tags( &serde_json::to_string(&RadrootsGroupDeleteGroup { group_id: "field-group".to_string(), + message: Some("delete group".to_string()), }) .expect("delete group json"), )); assert_tags_json(group_delete_event_tags( &serde_json::to_string(&RadrootsGroupDeleteEvent { group_id: "field-group".to_string(), + message: Some("delete event".to_string()), event_id: "event_id".to_string(), }) .expect("delete event json"), )); - assert_tags_json(group_create_invite_tags( + let invite_tags = tags_json(group_create_invite_tags( &serde_json::to_string(&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()), + message: Some("join the field group".to_string()), + code: "invite-code".to_string(), }) .expect("invite json"), )); + assert!(invite_tags.contains(&vec!["code".to_string(), "invite-code".to_string()])); assert_tags_json(group_join_request_tags( &serde_json::to_string(&RadrootsGroupJoinRequest { group_id: "field-group".to_string(), message: Some("requesting access".to_string()), + code: Some("invite-code".to_string()), }) .expect("join json"), )); @@ -684,16 +696,23 @@ mod tests { }) .expect("leave json"), )); - assert_tags_json(group_metadata_tags( + let metadata_tags = tags_json(group_metadata_tags( &serde_json::to_string(&RadrootsGroupMetadata { d_tag: "field-group".to_string(), metadata, }) .expect("metadata json"), )); + assert!(metadata_tags.contains(&vec!["restricted".to_string()])); + assert!(metadata_tags.contains(&vec![ + "supported_kinds".to_string(), + "78".to_string(), + "30078".to_string() + ])); assert_tags_json(group_admins_tags( &serde_json::to_string(&RadrootsGroupAdmins { d_tag: "field-group".to_string(), + description: Some("group admins".to_string()), admins: vec![sample_group_user("admin")], }) .expect("admins json"), @@ -701,6 +720,7 @@ mod tests { assert_tags_json(group_members_tags( &serde_json::to_string(&RadrootsGroupMembers { d_tag: "field-group".to_string(), + description: Some("group members".to_string()), members: vec![sample_group_user("member")], }) .expect("members json"), @@ -708,6 +728,7 @@ mod tests { assert_tags_json(group_roles_tags( &serde_json::to_string(&RadrootsGroupRoles { d_tag: "field-group".to_string(), + description: Some("group roles".to_string()), roles: vec![sample_group_role()], }) .expect("roles json"), diff --git a/spec/README.md b/spec/README.md @@ -32,8 +32,13 @@ through `radroots_events`, `radroots_events_codec`, and `radroots_events_codec_wasm`. The substrate includes workspace manifests, CRDT change envelopes, farm file -metadata, NIP-42 relay auth, NIP-98 HTTP auth, and NIP-29 group events. These are -event and codec APIs, not curated SDK operations by default. +metadata, NIP-42 relay auth, NIP-98 HTTP auth, and the supported NIP-29 group +event subset covering `9000`, `9001`, `9002`, `9005`, `9007`, `9008`, `9009`, +`9021`, `9022`, `39000`, `39001`, `39002`, and `39003`. These are event and +codec APIs, not curated SDK operations by default. The active NIP-29 subset uses +bare metadata markers, `supported_kinds`, and `code` tags for invite and join +flows, and preserves optional user management and moderation reason content; +LiveKit room metadata and live participant state are deferred. Task records, work sessions, harvest records, approvals, and similar Field business objects are CRDT document semantics carried inside the CRDT change