commit e914e45a41f670f020218534a480f8c5957fef51
parent 3f659008241fb347dad36de3e98b90b6ff56caf6
Author: triesap <tyson@radroots.org>
Date: Thu, 11 Jun 2026 16:59:39 -0700
events: add nip29 group models
- add NIP-29 group management, metadata, member, admin, and role models
- encode h-tag versus d-tag group identity semantics in the model tests
- keep group events scoped to relay infrastructure rather than Field authorization
Diffstat:
2 files changed, 265 insertions(+), 0 deletions(-)
diff --git a/crates/events/src/group.rs b/crates/events/src/group.rs
@@ -0,0 +1,264 @@
+#![forbid(unsafe_code)]
+
+use crate::kinds::{
+ KIND_GROUP_ADMINS as KIND_GROUP_ADMINS_EVENT,
+ KIND_GROUP_CREATE_GROUP as KIND_GROUP_CREATE_GROUP_EVENT,
+ KIND_GROUP_CREATE_INVITE as KIND_GROUP_CREATE_INVITE_EVENT,
+ KIND_GROUP_DELETE_EVENT as KIND_GROUP_DELETE_EVENT_EVENT,
+ KIND_GROUP_DELETE_GROUP as KIND_GROUP_DELETE_GROUP_EVENT,
+ KIND_GROUP_EDIT_METADATA as KIND_GROUP_EDIT_METADATA_EVENT,
+ KIND_GROUP_JOIN_REQUEST as KIND_GROUP_JOIN_REQUEST_EVENT,
+ KIND_GROUP_LEAVE_REQUEST as KIND_GROUP_LEAVE_REQUEST_EVENT,
+ KIND_GROUP_MEMBERS as KIND_GROUP_MEMBERS_EVENT,
+ KIND_GROUP_METADATA as KIND_GROUP_METADATA_EVENT,
+ KIND_GROUP_PUT_USER as KIND_GROUP_PUT_USER_EVENT,
+ KIND_GROUP_REMOVE_USER as KIND_GROUP_REMOVE_USER_EVENT,
+ KIND_GROUP_ROLES as KIND_GROUP_ROLES_EVENT,
+};
+
+#[cfg(not(feature = "std"))]
+use alloc::{string::String, vec::Vec};
+
+pub const KIND_GROUP_PUT_USER: u32 = KIND_GROUP_PUT_USER_EVENT;
+pub const KIND_GROUP_REMOVE_USER: u32 = KIND_GROUP_REMOVE_USER_EVENT;
+pub const KIND_GROUP_EDIT_METADATA: u32 = KIND_GROUP_EDIT_METADATA_EVENT;
+pub const KIND_GROUP_DELETE_EVENT: u32 = KIND_GROUP_DELETE_EVENT_EVENT;
+pub const KIND_GROUP_CREATE_GROUP: u32 = KIND_GROUP_CREATE_GROUP_EVENT;
+pub const KIND_GROUP_DELETE_GROUP: u32 = KIND_GROUP_DELETE_GROUP_EVENT;
+pub const KIND_GROUP_CREATE_INVITE: u32 = KIND_GROUP_CREATE_INVITE_EVENT;
+pub const KIND_GROUP_JOIN_REQUEST: u32 = KIND_GROUP_JOIN_REQUEST_EVENT;
+pub const KIND_GROUP_LEAVE_REQUEST: u32 = KIND_GROUP_LEAVE_REQUEST_EVENT;
+pub const KIND_GROUP_METADATA: u32 = KIND_GROUP_METADATA_EVENT;
+pub const KIND_GROUP_ADMINS: u32 = KIND_GROUP_ADMINS_EVENT;
+pub const KIND_GROUP_MEMBERS: u32 = KIND_GROUP_MEMBERS_EVENT;
+pub const KIND_GROUP_ROLES: u32 = KIND_GROUP_ROLES_EVENT;
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsGroupPutUser {
+ pub group_id: String,
+ pub pubkey: String,
+ pub roles: Vec<String>,
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsGroupRemoveUser {
+ pub group_id: String,
+ pub pubkey: String,
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsGroupCreateGroup {
+ pub group_id: String,
+ pub metadata: RadrootsGroupEditableMetadata,
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsGroupEditMetadata {
+ pub group_id: String,
+ pub metadata: RadrootsGroupEditableMetadata,
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsGroupDeleteGroup {
+ pub group_id: String,
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsGroupDeleteEvent {
+ pub group_id: String,
+ pub event_id: String,
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[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>,
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsGroupJoinRequest {
+ pub group_id: String,
+ pub message: Option<String>,
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsGroupLeaveRequest {
+ pub group_id: String,
+ pub message: Option<String>,
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsGroupMetadata {
+ pub d_tag: String,
+ pub metadata: RadrootsGroupEditableMetadata,
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsGroupAdmins {
+ pub d_tag: String,
+ pub admins: Vec<RadrootsGroupUserRef>,
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsGroupMembers {
+ pub d_tag: String,
+ pub members: Vec<RadrootsGroupUserRef>,
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsGroupRoles {
+ pub d_tag: String,
+ pub roles: Vec<RadrootsGroupRole>,
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+pub struct RadrootsGroupEditableMetadata {
+ pub name: Option<String>,
+ pub about: Option<String>,
+ pub picture: Option<String>,
+ pub is_private: bool,
+ pub is_closed: bool,
+ pub is_hidden: bool,
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsGroupUserRef {
+ pub pubkey: String,
+ pub roles: Vec<String>,
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsGroupRole {
+ pub name: String,
+ pub description: Option<String>,
+ pub permissions: Vec<String>,
+}
+
+#[cfg(all(test, feature = "serde"))]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn group_user_and_moderation_models_use_h_group_id_semantics() {
+ let put = RadrootsGroupPutUser {
+ group_id: "field-group".to_string(),
+ pubkey: "member_pubkey".to_string(),
+ roles: vec!["member".to_string()],
+ };
+ let delete = RadrootsGroupDeleteEvent {
+ group_id: "field-group".to_string(),
+ event_id: "event_id".to_string(),
+ };
+ let join = RadrootsGroupJoinRequest {
+ group_id: "field-group".to_string(),
+ message: Some("requesting access".to_string()),
+ };
+
+ assert_eq!(put.group_id, "field-group");
+ assert_eq!(delete.group_id, "field-group");
+ assert_eq!(join.group_id, "field-group");
+ assert_eq!(KIND_GROUP_PUT_USER, 9000);
+ assert_eq!(KIND_GROUP_DELETE_EVENT, 9005);
+ assert_eq!(KIND_GROUP_JOIN_REQUEST, 9021);
+ }
+
+ #[test]
+ fn group_metadata_and_lists_use_d_tag_semantics() {
+ let metadata = RadrootsGroupMetadata {
+ d_tag: "field-group".to_string(),
+ metadata: sample_metadata(),
+ };
+ let members = RadrootsGroupMembers {
+ d_tag: "field-group".to_string(),
+ members: vec![sample_user_ref()],
+ };
+ let roles = RadrootsGroupRoles {
+ d_tag: "field-group".to_string(),
+ roles: vec![RadrootsGroupRole {
+ name: "member".to_string(),
+ description: Some("can read and write group events".to_string()),
+ permissions: vec!["read".to_string(), "write".to_string()],
+ }],
+ };
+
+ assert_eq!(metadata.d_tag, "field-group");
+ assert_eq!(members.d_tag, "field-group");
+ assert_eq!(roles.d_tag, "field-group");
+ assert_eq!(KIND_GROUP_METADATA, 39000);
+ assert_eq!(KIND_GROUP_MEMBERS, 39002);
+ assert_eq!(KIND_GROUP_ROLES, 39003);
+ }
+
+ #[test]
+ fn group_models_are_infrastructure_not_field_business_authorization() {
+ let admins = RadrootsGroupAdmins {
+ d_tag: "field-group".to_string(),
+ admins: vec![sample_user_ref()],
+ };
+
+ assert_eq!(admins.admins[0].roles, vec!["admin".to_string()]);
+ assert_eq!(KIND_GROUP_ADMINS, 39001);
+ }
+
+ #[test]
+ fn group_models_serialize_stable_shapes() {
+ let create = RadrootsGroupCreateGroup {
+ group_id: "field-group".to_string(),
+ 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()),
+ };
+
+ let create_value = serde_json::to_value(create).unwrap();
+ let invite_value = serde_json::to_value(invite).unwrap();
+
+ 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!(KIND_GROUP_CREATE_GROUP, 9007);
+ assert_eq!(KIND_GROUP_CREATE_INVITE, 9009);
+ }
+
+ fn sample_metadata() -> RadrootsGroupEditableMetadata {
+ RadrootsGroupEditableMetadata {
+ name: Some("Small Regen Farm".to_string()),
+ about: Some("Field app group".to_string()),
+ picture: Some("https://media.example.invalid/group.png".to_string()),
+ is_private: false,
+ is_closed: false,
+ is_hidden: false,
+ }
+ }
+
+ fn sample_user_ref() -> RadrootsGroupUserRef {
+ RadrootsGroupUserRef {
+ pubkey: "admin_pubkey".to_string(),
+ roles: vec!["admin".to_string()],
+ }
+ }
+}
diff --git a/crates/events/src/lib.rs b/crates/events/src/lib.rs
@@ -17,6 +17,7 @@ pub mod farm_workspace;
pub mod follow;
pub mod geochat;
pub mod gift_wrap;
+pub mod group;
pub mod http_auth;
pub mod job;
pub mod job_feedback;