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