commit b3c2bafbb8b209954d613dc03dd3700049e41243
parent f263315cb394ad3e113b46505129304937a1d618
Author: triesap <tyson@radroots.org>
Date: Wed, 31 Dec 2025 11:28:43 +0000
events: add coop and document event support
- Add coop/document kinds and profile type mapping
- Implement coop encode/decode and list-set helpers
- Implement document encode/decode with subject tag validation
- Expose coop/document tag builders via wasm bindings
Diffstat:
19 files changed, 985 insertions(+), 1 deletion(-)
diff --git a/events-codec-wasm/src/lib.rs b/events-codec-wasm/src/lib.rs
@@ -3,6 +3,8 @@
use radroots_events::comment::RadrootsComment;
use radroots_events::follow::RadrootsFollow;
+use radroots_events::document::RadrootsDocument;
+use radroots_events::coop::RadrootsCoop;
use radroots_events::farm::RadrootsFarm;
use radroots_events::job_feedback::RadrootsJobFeedback;
use radroots_events::job_request::RadrootsJobRequest;
@@ -18,6 +20,8 @@ use radroots_events::gift_wrap::RadrootsGiftWrap;
use radroots_events::seal::RadrootsSeal;
use radroots_events_codec::comment::encode::comment_build_tags;
use radroots_events_codec::follow::encode::follow_build_tags;
+use radroots_events_codec::document::encode::document_build_tags;
+use radroots_events_codec::coop::encode::coop_build_tags;
use radroots_events_codec::farm::encode::farm_build_tags;
use radroots_events_codec::gift_wrap::encode::gift_wrap_build_tags;
use radroots_events_codec::job::feedback::encode::job_feedback_build_tags;
@@ -52,6 +56,14 @@ fn parse_follow(follow_json: &str) -> Result<RadrootsFollow, JsValue> {
serde_json::from_str(follow_json).map_err(err_js)
}
+fn parse_document(document_json: &str) -> Result<RadrootsDocument, JsValue> {
+ serde_json::from_str(document_json).map_err(err_js)
+}
+
+fn parse_coop(coop_json: &str) -> Result<RadrootsCoop, JsValue> {
+ serde_json::from_str(coop_json).map_err(err_js)
+}
+
fn parse_farm(farm_json: &str) -> Result<RadrootsFarm, JsValue> {
serde_json::from_str(farm_json).map_err(err_js)
}
@@ -132,6 +144,20 @@ pub fn follow_tags(follow_json: &str) -> Result<String, JsValue> {
tags_to_json(tags)
}
+#[wasm_bindgen(js_name = document_tags)]
+pub fn document_tags(document_json: &str) -> Result<String, JsValue> {
+ let document = parse_document(document_json)?;
+ let tags = document_build_tags(&document).map_err(err_js)?;
+ tags_to_json(tags)
+}
+
+#[wasm_bindgen(js_name = coop_tags)]
+pub fn coop_tags(coop_json: &str) -> Result<String, JsValue> {
+ let coop = parse_coop(coop_json)?;
+ let tags = coop_build_tags(&coop).map_err(err_js)?;
+ tags_to_json(tags)
+}
+
#[wasm_bindgen(js_name = farm_tags)]
pub fn farm_tags(farm_json: &str) -> Result<String, JsValue> {
let farm = parse_farm(farm_json)?;
diff --git a/events-codec/src/coop/decode.rs b/events-codec/src/coop/decode.rs
@@ -0,0 +1,107 @@
+#![cfg(feature = "serde_json")]
+#![forbid(unsafe_code)]
+
+#[cfg(not(feature = "std"))]
+use alloc::{string::{String, ToString}, vec::Vec};
+
+use radroots_events::{
+ RadrootsNostrEvent,
+ coop::{RadrootsCoop, RadrootsCoopEventIndex, RadrootsCoopEventMetadata},
+ kinds::KIND_COOP,
+ tags::TAG_D,
+};
+
+use crate::error::EventParseError;
+
+const DEFAULT_KIND: u32 = KIND_COOP;
+
+fn parse_d_tag(tags: &[Vec<String>]) -> Result<String, EventParseError> {
+ let tag = tags
+ .iter()
+ .find(|t| t.get(0).map(|s| s.as_str()) == Some(TAG_D))
+ .ok_or(EventParseError::MissingTag(TAG_D))?;
+ let value = tag
+ .get(1)
+ .map(|s| s.to_string())
+ .ok_or(EventParseError::InvalidTag(TAG_D))?;
+ if value.trim().is_empty() {
+ return Err(EventParseError::InvalidTag(TAG_D));
+ }
+ Ok(value)
+}
+
+pub fn coop_from_event(
+ kind: u32,
+ tags: &[Vec<String>],
+ content: &str,
+) -> Result<RadrootsCoop, EventParseError> {
+ if kind != DEFAULT_KIND {
+ return Err(EventParseError::InvalidKind {
+ expected: "30360",
+ got: kind,
+ });
+ }
+ if content.trim().is_empty() {
+ return Err(EventParseError::InvalidJson("content"));
+ }
+ let d_tag = parse_d_tag(tags)?;
+ let mut coop: RadrootsCoop =
+ serde_json::from_str(content).map_err(|_| EventParseError::InvalidJson("content"))?;
+
+ if coop.d_tag.trim().is_empty() {
+ coop.d_tag = d_tag;
+ } else if coop.d_tag != d_tag {
+ return Err(EventParseError::InvalidTag(TAG_D));
+ }
+
+ Ok(coop)
+}
+
+pub fn metadata_from_event(
+ id: String,
+ author: String,
+ published_at: u32,
+ kind: u32,
+ content: String,
+ tags: Vec<Vec<String>>,
+) -> Result<RadrootsCoopEventMetadata, EventParseError> {
+ let coop = coop_from_event(kind, &tags, &content)?;
+ Ok(RadrootsCoopEventMetadata {
+ id,
+ author,
+ published_at,
+ kind,
+ coop,
+ })
+}
+
+pub fn index_from_event(
+ id: String,
+ author: String,
+ published_at: u32,
+ kind: u32,
+ content: String,
+ tags: Vec<Vec<String>>,
+ sig: String,
+) -> Result<RadrootsCoopEventIndex, EventParseError> {
+ let metadata = metadata_from_event(
+ id.clone(),
+ author.clone(),
+ published_at,
+ kind,
+ content.clone(),
+ tags.clone(),
+ )?;
+ Ok(RadrootsCoopEventIndex {
+ event: RadrootsNostrEvent {
+ id,
+ author,
+ created_at: published_at,
+ kind,
+ content,
+ tags,
+ sig,
+ },
+ metadata,
+ })
+}
diff --git a/events-codec/src/coop/encode.rs b/events-codec/src/coop/encode.rs
@@ -0,0 +1,86 @@
+#![forbid(unsafe_code)]
+
+#[cfg(not(feature = "std"))]
+use alloc::{string::{String, ToString}, vec::Vec};
+
+use radroots_events::{
+ coop::{RadrootsCoop, RadrootsCoopRef},
+ kinds::KIND_COOP,
+ tags::TAG_D,
+};
+
+use crate::error::EventEncodeError;
+
+#[cfg(feature = "serde_json")]
+use crate::wire::WireEventParts;
+
+const TAG_T: &str = "t";
+const TAG_G: &str = "g";
+
+fn push_tag(tags: &mut Vec<Vec<String>>, key: &str, value: &str) {
+ let mut tag = Vec::with_capacity(2);
+ tag.push(key.to_string());
+ tag.push(value.to_string());
+ tags.push(tag);
+}
+
+pub fn coop_build_tags(coop: &RadrootsCoop) -> Result<Vec<Vec<String>>, EventEncodeError> {
+ if coop.d_tag.trim().is_empty() {
+ return Err(EventEncodeError::EmptyRequiredField("d_tag"));
+ }
+ if coop.name.trim().is_empty() {
+ return Err(EventEncodeError::EmptyRequiredField("name"));
+ }
+ let mut tags = Vec::new();
+ push_tag(&mut tags, TAG_D, &coop.d_tag);
+ if let Some(items) = coop.tags.as_ref() {
+ for item in items.iter().filter(|v| !v.trim().is_empty()) {
+ push_tag(&mut tags, TAG_T, item);
+ }
+ }
+ if let Some(location) = coop.location.as_ref() {
+ let geohash = location.gcs.geohash.trim();
+ if geohash.is_empty() {
+ return Err(EventEncodeError::EmptyRequiredField("location.gcs.geohash"));
+ }
+ push_tag(&mut tags, TAG_G, geohash);
+ }
+ Ok(tags)
+}
+
+pub fn coop_ref_tags(coop: &RadrootsCoopRef) -> Result<Vec<Vec<String>>, EventEncodeError> {
+ if coop.pubkey.trim().is_empty() {
+ return Err(EventEncodeError::EmptyRequiredField("coop.pubkey"));
+ }
+ if coop.d_tag.trim().is_empty() {
+ return Err(EventEncodeError::EmptyRequiredField("coop.d_tag"));
+ }
+ let mut addr = String::new();
+ addr.push_str(&KIND_COOP.to_string());
+ addr.push(':');
+ addr.push_str(&coop.pubkey);
+ addr.push(':');
+ addr.push_str(&coop.d_tag);
+ let mut tags = Vec::with_capacity(2);
+ push_tag(&mut tags, "p", &coop.pubkey);
+ push_tag(&mut tags, "a", &addr);
+ Ok(tags)
+}
+
+#[cfg(feature = "serde_json")]
+pub fn to_wire_parts(coop: &RadrootsCoop) -> Result<WireEventParts, EventEncodeError> {
+ to_wire_parts_with_kind(coop, KIND_COOP)
+}
+
+#[cfg(feature = "serde_json")]
+pub fn to_wire_parts_with_kind(
+ coop: &RadrootsCoop,
+ kind: u32,
+) -> Result<WireEventParts, EventEncodeError> {
+ if kind != KIND_COOP {
+ return Err(EventEncodeError::InvalidKind(kind));
+ }
+ let tags = coop_build_tags(coop)?;
+ let content = serde_json::to_string(coop).map_err(|_| EventEncodeError::Json)?;
+ Ok(WireEventParts { kind, content, tags })
+}
diff --git a/events-codec/src/coop/list_sets.rs b/events-codec/src/coop/list_sets.rs
@@ -0,0 +1,177 @@
+#![forbid(unsafe_code)]
+
+#[cfg(not(feature = "std"))]
+use alloc::{format, string::{String, ToString}, vec, vec::Vec};
+
+use radroots_events::farm::RadrootsFarmRef;
+use radroots_events::kinds::KIND_FARM;
+use radroots_events::list::RadrootsListEntry;
+use radroots_events::list_set::RadrootsListSet;
+
+use crate::error::EventEncodeError;
+
+const MEMBER_OF_COOPS: &str = "member_of.coops";
+
+fn coop_list_set_id(coop_id: &str, suffix: &str) -> Result<String, EventEncodeError> {
+ let coop_id = coop_id.trim();
+ if coop_id.is_empty() {
+ return Err(EventEncodeError::EmptyRequiredField("coop_id"));
+ }
+ if suffix.trim().is_empty() {
+ return Err(EventEncodeError::EmptyRequiredField("list_set_suffix"));
+ }
+ Ok(format!("coop:{coop_id}:{suffix}"))
+}
+
+fn list_entries<I, S>(tag: &str, values: I) -> Result<Vec<RadrootsListEntry>, EventEncodeError>
+where
+ I: IntoIterator<Item = S>,
+ S: AsRef<str>,
+{
+ let mut entries = Vec::new();
+ for value in values {
+ let value = value.as_ref().trim();
+ if value.is_empty() {
+ return Err(EventEncodeError::EmptyRequiredField("entry.values"));
+ }
+ entries.push(RadrootsListEntry {
+ tag: tag.to_string(),
+ values: vec![value.to_string()],
+ });
+ }
+ Ok(entries)
+}
+
+fn farm_address(farm: &RadrootsFarmRef) -> Result<String, EventEncodeError> {
+ if farm.pubkey.trim().is_empty() {
+ return Err(EventEncodeError::EmptyRequiredField("farm.pubkey"));
+ }
+ if farm.d_tag.trim().is_empty() {
+ return Err(EventEncodeError::EmptyRequiredField("farm.d_tag"));
+ }
+ let mut addr = String::new();
+ addr.push_str(&KIND_FARM.to_string());
+ addr.push(':');
+ addr.push_str(&farm.pubkey);
+ addr.push(':');
+ addr.push_str(&farm.d_tag);
+ Ok(addr)
+}
+
+pub fn coop_members_list_set<I, S>(
+ coop_id: &str,
+ members: I,
+) -> Result<RadrootsListSet, EventEncodeError>
+where
+ I: IntoIterator<Item = S>,
+ S: AsRef<str>,
+{
+ Ok(RadrootsListSet {
+ d_tag: coop_list_set_id(coop_id, "members")?,
+ content: String::new(),
+ entries: list_entries("p", members)?,
+ title: None,
+ description: None,
+ image: None,
+ })
+}
+
+pub fn coop_members_farms_list_set<I>(
+ coop_id: &str,
+ farms: I,
+) -> Result<RadrootsListSet, EventEncodeError>
+where
+ I: IntoIterator<Item = RadrootsFarmRef>,
+{
+ let mut entries = Vec::new();
+ for farm in farms {
+ let address = farm_address(&farm)?;
+ entries.push(RadrootsListEntry {
+ tag: "a".to_string(),
+ values: vec![address],
+ });
+ entries.push(RadrootsListEntry {
+ tag: "p".to_string(),
+ values: vec![farm.pubkey],
+ });
+ }
+ Ok(RadrootsListSet {
+ d_tag: coop_list_set_id(coop_id, "members.farms")?,
+ content: String::new(),
+ entries,
+ title: None,
+ description: None,
+ image: None,
+ })
+}
+
+pub fn coop_owners_list_set<I, S>(
+ coop_id: &str,
+ owners: I,
+) -> Result<RadrootsListSet, EventEncodeError>
+where
+ I: IntoIterator<Item = S>,
+ S: AsRef<str>,
+{
+ Ok(RadrootsListSet {
+ d_tag: coop_list_set_id(coop_id, "members.owners")?,
+ content: String::new(),
+ entries: list_entries("p", owners)?,
+ title: None,
+ description: None,
+ image: None,
+ })
+}
+
+pub fn coop_admins_list_set<I, S>(
+ coop_id: &str,
+ admins: I,
+) -> Result<RadrootsListSet, EventEncodeError>
+where
+ I: IntoIterator<Item = S>,
+ S: AsRef<str>,
+{
+ Ok(RadrootsListSet {
+ d_tag: coop_list_set_id(coop_id, "members.admins")?,
+ content: String::new(),
+ entries: list_entries("p", admins)?,
+ title: None,
+ description: None,
+ image: None,
+ })
+}
+
+pub fn coop_items_list_set<I, S>(
+ coop_id: &str,
+ item_addresses: I,
+) -> Result<RadrootsListSet, EventEncodeError>
+where
+ I: IntoIterator<Item = S>,
+ S: AsRef<str>,
+{
+ Ok(RadrootsListSet {
+ d_tag: coop_list_set_id(coop_id, "items")?,
+ content: String::new(),
+ entries: list_entries("a", item_addresses)?,
+ title: None,
+ description: None,
+ image: None,
+ })
+}
+
+pub fn member_of_coops_list_set<I, S>(
+ coop_pubkeys: I,
+) -> Result<RadrootsListSet, EventEncodeError>
+where
+ I: IntoIterator<Item = S>,
+ S: AsRef<str>,
+{
+ Ok(RadrootsListSet {
+ d_tag: MEMBER_OF_COOPS.to_string(),
+ content: String::new(),
+ entries: list_entries("p", coop_pubkeys)?,
+ title: None,
+ description: None,
+ image: None,
+ })
+}
diff --git a/events-codec/src/coop/mod.rs b/events-codec/src/coop/mod.rs
@@ -0,0 +1,119 @@
+#![forbid(unsafe_code)]
+
+pub mod decode;
+pub mod encode;
+pub mod list_sets;
+
+#[cfg(test)]
+mod tests {
+ use radroots_events::coop::{RadrootsCoop, RadrootsCoopLocation, RadrootsCoopRef};
+ use radroots_events::farm::{RadrootsFarmRef, RadrootsGcsLocation, RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon};
+ use crate::coop::encode::{coop_build_tags, coop_ref_tags};
+ use crate::coop::list_sets::{
+ coop_items_list_set,
+ coop_members_farms_list_set,
+ coop_members_list_set,
+ member_of_coops_list_set,
+ };
+
+ #[test]
+ fn coop_tags_include_required_fields() {
+ let coop = RadrootsCoop {
+ d_tag: "coop-1".to_string(),
+ name: "Test Coop".to_string(),
+ about: None,
+ website: None,
+ picture: None,
+ banner: None,
+ location: Some(RadrootsCoopLocation {
+ primary: None,
+ city: None,
+ region: None,
+ country: None,
+ gcs: RadrootsGcsLocation {
+ lat: 37.0,
+ lng: -122.0,
+ geohash: "9q8yy".to_string(),
+ point: RadrootsGeoJsonPoint {
+ r#type: "Point".to_string(),
+ coordinates: [-122.0, 37.0],
+ },
+ polygon: RadrootsGeoJsonPolygon {
+ r#type: "Polygon".to_string(),
+ coordinates: vec![vec![
+ [-122.0, 37.0],
+ [-122.0, 37.0001],
+ [-122.0001, 37.0001],
+ [-122.0, 37.0],
+ ]],
+ },
+ accuracy: None,
+ altitude: None,
+ tag_0: None,
+ label: None,
+ area: None,
+ elevation: None,
+ soil: None,
+ climate: None,
+ gc_id: None,
+ gc_name: None,
+ gc_admin1_id: None,
+ gc_admin1_name: None,
+ gc_country_id: None,
+ gc_country_name: None,
+ },
+ }),
+ tags: Some(vec!["regional".to_string()]),
+ };
+
+ let tags = coop_build_tags(&coop).expect("tags");
+ assert!(tags.iter().any(|tag| tag.get(0) == Some(&"d".to_string())));
+ assert!(tags.iter().any(|tag| tag.get(0) == Some(&"t".to_string())));
+ assert!(tags.iter().any(|tag| tag.get(0) == Some(&"g".to_string())));
+ }
+
+ #[test]
+ fn coop_ref_tags_include_p_and_a() {
+ let coop = RadrootsCoopRef {
+ pubkey: "coop_pubkey".to_string(),
+ d_tag: "coop-1".to_string(),
+ };
+
+ let tags = coop_ref_tags(&coop).expect("coop ref tags");
+ let has_a = tags.iter().any(|tag| tag.get(0).map(|v| v.as_str()) == Some("a"));
+ let has_p = tags.iter().any(|tag| tag.get(0).map(|v| v.as_str()) == Some("p"));
+ assert!(has_a);
+ assert!(has_p);
+ }
+
+ #[test]
+ fn coop_list_sets_include_expected_tags() {
+ let members = coop_members_list_set("coop-1", ["member_pubkey"]).expect("members list");
+ assert_eq!(members.d_tag, "coop:coop-1:members");
+ assert_eq!(members.entries.len(), 1);
+ assert_eq!(members.entries[0].tag, "p");
+
+ let farm_members = coop_members_farms_list_set(
+ "coop-1",
+ [RadrootsFarmRef {
+ pubkey: "farm_pubkey".to_string(),
+ d_tag: "farm-1".to_string(),
+ }],
+ )
+ .expect("farm members list");
+ assert_eq!(farm_members.d_tag, "coop:coop-1:members.farms");
+ assert!(farm_members.entries.iter().any(|entry| entry.tag == "a"));
+ assert!(farm_members.entries.iter().any(|entry| entry.tag == "p"));
+
+ let items = coop_items_list_set("coop-1", ["30361:coop_pubkey:charter-1"])
+ .expect("items list");
+ assert_eq!(items.d_tag, "coop:coop-1:items");
+ assert_eq!(items.entries.len(), 1);
+ assert_eq!(items.entries[0].tag, "a");
+
+ let claims = member_of_coops_list_set(["coop_pubkey"]).expect("claims list");
+ assert_eq!(claims.d_tag, "member_of.coops");
+ assert_eq!(claims.entries.len(), 1);
+ assert_eq!(claims.entries[0].tag, "p");
+ }
+}
diff --git a/events-codec/src/document/decode.rs b/events-codec/src/document/decode.rs
@@ -0,0 +1,168 @@
+#![cfg(feature = "serde_json")]
+#![forbid(unsafe_code)]
+
+#[cfg(not(feature = "std"))]
+use alloc::{string::{String, ToString}, vec::Vec};
+
+use radroots_events::{
+ RadrootsNostrEvent,
+ document::{RadrootsDocument, RadrootsDocumentEventIndex, RadrootsDocumentEventMetadata},
+ kinds::KIND_DOCUMENT,
+ tags::TAG_D,
+};
+
+use crate::error::EventParseError;
+
+const TAG_A: &str = "a";
+const TAG_P: &str = "p";
+const DEFAULT_KIND: u32 = KIND_DOCUMENT;
+
+fn parse_d_tag(tags: &[Vec<String>]) -> Result<String, EventParseError> {
+ let tag = tags
+ .iter()
+ .find(|t| t.get(0).map(|s| s.as_str()) == Some(TAG_D))
+ .ok_or(EventParseError::MissingTag(TAG_D))?;
+ let value = tag
+ .get(1)
+ .map(|s| s.to_string())
+ .ok_or(EventParseError::InvalidTag(TAG_D))?;
+ if value.trim().is_empty() {
+ return Err(EventParseError::InvalidTag(TAG_D));
+ }
+ Ok(value)
+}
+
+fn parse_subject_pubkey(tags: &[Vec<String>]) -> Result<String, EventParseError> {
+ let tag = tags
+ .iter()
+ .find(|t| t.get(0).map(|s| s.as_str()) == Some(TAG_P))
+ .ok_or(EventParseError::MissingTag(TAG_P))?;
+ let value = tag
+ .get(1)
+ .map(|s| s.to_string())
+ .ok_or(EventParseError::InvalidTag(TAG_P))?;
+ if value.trim().is_empty() {
+ return Err(EventParseError::InvalidTag(TAG_P));
+ }
+ Ok(value)
+}
+
+fn parse_subject_address(tags: &[Vec<String>]) -> Result<Option<String>, EventParseError> {
+ let tag = tags
+ .iter()
+ .find(|t| t.get(0).map(|s| s.as_str()) == Some(TAG_A));
+ let Some(tag) = tag else { return Ok(None) };
+ let value = tag
+ .get(1)
+ .map(|s| s.to_string())
+ .ok_or(EventParseError::InvalidTag(TAG_A))?;
+ if value.trim().is_empty() {
+ return Err(EventParseError::InvalidTag(TAG_A));
+ }
+ Ok(Some(value))
+}
+
+pub fn document_from_event(
+ kind: u32,
+ tags: &[Vec<String>],
+ content: &str,
+) -> Result<RadrootsDocument, EventParseError> {
+ if kind != DEFAULT_KIND {
+ return Err(EventParseError::InvalidKind {
+ expected: "30361",
+ got: kind,
+ });
+ }
+ if content.trim().is_empty() {
+ return Err(EventParseError::InvalidJson("content"));
+ }
+ let d_tag = parse_d_tag(tags)?;
+ let subject_pubkey = parse_subject_pubkey(tags)?;
+ let subject_address = parse_subject_address(tags)?;
+ let mut document: RadrootsDocument =
+ serde_json::from_str(content).map_err(|_| EventParseError::InvalidJson("content"))?;
+
+ if document.d_tag.trim().is_empty() {
+ document.d_tag = d_tag;
+ } else if document.d_tag != d_tag {
+ return Err(EventParseError::InvalidTag(TAG_D));
+ }
+
+ if document.subject.pubkey.trim().is_empty() {
+ document.subject.pubkey = subject_pubkey;
+ } else if document.subject.pubkey != subject_pubkey {
+ return Err(EventParseError::InvalidTag(TAG_P));
+ }
+
+ if let Some(address) = document.subject.address.as_ref() {
+ if address.trim().is_empty() {
+ return Err(EventParseError::InvalidTag(TAG_A));
+ }
+ }
+
+ if let Some(tag_address) = subject_address {
+ match document.subject.address.as_ref() {
+ None => {
+ document.subject.address = Some(tag_address);
+ }
+ Some(existing) => {
+ if existing != &tag_address {
+ return Err(EventParseError::InvalidTag(TAG_A));
+ }
+ }
+ }
+ } else if document.subject.address.is_some() {
+ return Err(EventParseError::MissingTag(TAG_A));
+ }
+
+ Ok(document)
+}
+
+pub fn metadata_from_event(
+ id: String,
+ author: String,
+ published_at: u32,
+ kind: u32,
+ content: String,
+ tags: Vec<Vec<String>>,
+) -> Result<RadrootsDocumentEventMetadata, EventParseError> {
+ let document = document_from_event(kind, &tags, &content)?;
+ Ok(RadrootsDocumentEventMetadata {
+ id,
+ author,
+ published_at,
+ kind,
+ document,
+ })
+}
+
+pub fn index_from_event(
+ id: String,
+ author: String,
+ published_at: u32,
+ kind: u32,
+ content: String,
+ tags: Vec<Vec<String>>,
+ sig: String,
+) -> Result<RadrootsDocumentEventIndex, EventParseError> {
+ let metadata = metadata_from_event(
+ id.clone(),
+ author.clone(),
+ published_at,
+ kind,
+ content.clone(),
+ tags.clone(),
+ )?;
+ Ok(RadrootsDocumentEventIndex {
+ event: RadrootsNostrEvent {
+ id,
+ author,
+ created_at: published_at,
+ kind,
+ content,
+ tags,
+ sig,
+ },
+ metadata,
+ })
+}
diff --git a/events-codec/src/document/encode.rs b/events-codec/src/document/encode.rs
@@ -0,0 +1,78 @@
+#![forbid(unsafe_code)]
+
+#[cfg(not(feature = "std"))]
+use alloc::{string::{String, ToString}, vec::Vec};
+
+use radroots_events::{
+ document::RadrootsDocument,
+ kinds::KIND_DOCUMENT,
+ tags::TAG_D,
+};
+
+use crate::error::EventEncodeError;
+
+#[cfg(feature = "serde_json")]
+use crate::wire::WireEventParts;
+
+const TAG_T: &str = "t";
+const TAG_P: &str = "p";
+const TAG_A: &str = "a";
+
+fn push_tag(tags: &mut Vec<Vec<String>>, key: &str, value: &str) {
+ let mut tag = Vec::with_capacity(2);
+ tag.push(key.to_string());
+ tag.push(value.to_string());
+ tags.push(tag);
+}
+
+pub fn document_build_tags(document: &RadrootsDocument) -> Result<Vec<Vec<String>>, EventEncodeError> {
+ if document.d_tag.trim().is_empty() {
+ return Err(EventEncodeError::EmptyRequiredField("d_tag"));
+ }
+ if document.doc_type.trim().is_empty() {
+ return Err(EventEncodeError::EmptyRequiredField("doc_type"));
+ }
+ if document.title.trim().is_empty() {
+ return Err(EventEncodeError::EmptyRequiredField("title"));
+ }
+ if document.version.trim().is_empty() {
+ return Err(EventEncodeError::EmptyRequiredField("version"));
+ }
+ if document.subject.pubkey.trim().is_empty() {
+ return Err(EventEncodeError::EmptyRequiredField("subject.pubkey"));
+ }
+ let mut tags = Vec::new();
+ push_tag(&mut tags, TAG_D, &document.d_tag);
+ push_tag(&mut tags, TAG_P, &document.subject.pubkey);
+ if let Some(address) = document.subject.address.as_ref() {
+ let address = address.trim();
+ if address.is_empty() {
+ return Err(EventEncodeError::EmptyRequiredField("subject.address"));
+ }
+ push_tag(&mut tags, TAG_A, address);
+ }
+ if let Some(items) = document.tags.as_ref() {
+ for item in items.iter().filter(|v| !v.trim().is_empty()) {
+ push_tag(&mut tags, TAG_T, item);
+ }
+ }
+ Ok(tags)
+}
+
+#[cfg(feature = "serde_json")]
+pub fn to_wire_parts(document: &RadrootsDocument) -> Result<WireEventParts, EventEncodeError> {
+ to_wire_parts_with_kind(document, KIND_DOCUMENT)
+}
+
+#[cfg(feature = "serde_json")]
+pub fn to_wire_parts_with_kind(
+ document: &RadrootsDocument,
+ kind: u32,
+) -> Result<WireEventParts, EventEncodeError> {
+ if kind != KIND_DOCUMENT {
+ return Err(EventEncodeError::InvalidKind(kind));
+ }
+ let tags = document_build_tags(document)?;
+ let content = serde_json::to_string(document).map_err(|_| EventEncodeError::Json)?;
+ Ok(WireEventParts { kind, content, tags })
+}
diff --git a/events-codec/src/document/mod.rs b/events-codec/src/document/mod.rs
@@ -0,0 +1,34 @@
+#![forbid(unsafe_code)]
+
+pub mod decode;
+pub mod encode;
+
+#[cfg(test)]
+mod tests {
+ use radroots_events::document::{RadrootsDocument, RadrootsDocumentSubject};
+ use crate::document::encode::document_build_tags;
+
+ #[test]
+ fn document_tags_include_required_fields() {
+ let document = RadrootsDocument {
+ d_tag: "doc-1".to_string(),
+ doc_type: "charter".to_string(),
+ title: "Sierra Co-op Charter".to_string(),
+ version: "1.0.0".to_string(),
+ summary: None,
+ effective_at: None,
+ body_markdown: None,
+ subject: RadrootsDocumentSubject {
+ pubkey: "coop_pubkey".to_string(),
+ address: Some("30360:coop_pubkey:coop-1".to_string()),
+ },
+ tags: Some(vec!["charter".to_string()]),
+ };
+
+ let tags = document_build_tags(&document).expect("tags");
+ assert!(tags.iter().any(|tag| tag.get(0) == Some(&"d".to_string())));
+ assert!(tags.iter().any(|tag| tag.get(0) == Some(&"p".to_string())));
+ assert!(tags.iter().any(|tag| tag.get(0) == Some(&"a".to_string())));
+ assert!(tags.iter().any(|tag| tag.get(0) == Some(&"t".to_string())));
+ }
+}
diff --git a/events-codec/src/lib.rs b/events-codec/src/lib.rs
@@ -13,6 +13,8 @@ pub mod wire;
pub mod comment;
pub mod follow;
pub mod app_data;
+pub mod document;
+pub mod coop;
pub mod farm;
pub mod gift_wrap;
pub mod message;
diff --git a/events-codec/src/tag_builders.rs b/events-codec/src/tag_builders.rs
@@ -8,6 +8,8 @@ use core::convert::Infallible;
use radroots_events::{
app_data::RadrootsAppData,
comment::RadrootsComment,
+ document::RadrootsDocument,
+ coop::RadrootsCoop,
follow::RadrootsFollow,
farm::RadrootsFarm,
gift_wrap::RadrootsGiftWrap,
@@ -29,6 +31,8 @@ use radroots_events::{
use crate::comment::encode::comment_build_tags;
use crate::error::EventEncodeError;
use crate::app_data::encode::app_data_build_tags;
+use crate::document::encode::document_build_tags;
+use crate::coop::encode::coop_build_tags;
use crate::follow::encode::follow_build_tags;
use crate::farm::encode::farm_build_tags;
use crate::job::encode::JobEncodeError;
@@ -114,6 +118,22 @@ impl RadrootsEventTagBuilder for RadrootsFarm {
}
}
+impl RadrootsEventTagBuilder for RadrootsCoop {
+ type Error = EventEncodeError;
+
+ fn build_tags(&self) -> Result<Vec<Vec<String>>, Self::Error> {
+ coop_build_tags(self)
+ }
+}
+
+impl RadrootsEventTagBuilder for RadrootsDocument {
+ type Error = EventEncodeError;
+
+ fn build_tags(&self) -> Result<Vec<Vec<String>>, Self::Error> {
+ document_build_tags(self)
+ }
+}
+
impl RadrootsEventTagBuilder for RadrootsList {
type Error = EventEncodeError;
diff --git a/events/bindings/ts/src/kinds.ts b/events/bindings/ts/src/kinds.ts
@@ -40,6 +40,8 @@ export const KIND_LIST_SET_STARTER_PACK = 39089;
export const KIND_LIST_SET_MEDIA_STARTER_PACK = 39092;
export const KIND_FARM = 30340;
export const KIND_PLOT = 30350;
+export const KIND_COOP = 30360;
+export const KIND_DOCUMENT = 30361;
export const KIND_APP_DATA = 30078;
export const KIND_LISTING = 30402;
export const KIND_APPLICATION_HANDLER = 31990;
diff --git a/events/bindings/ts/src/types.ts b/events/bindings/ts/src/types.ts
@@ -20,6 +20,24 @@ export type RadrootsCommentEventIndex = { event: RadrootsNostrEvent, metadata: R
export type RadrootsCommentEventMetadata = { id: string, author: string, published_at: number, kind: number, comment: RadrootsComment, };
+export type RadrootsCoop = { d_tag: string, name: string, about?: string | null, website?: string | null, picture?: string | null, banner?: string | null, location?: RadrootsCoopLocation | null, tags?: string[] | null, };
+
+export type RadrootsCoopEventIndex = { event: RadrootsNostrEvent, metadata: RadrootsCoopEventMetadata, };
+
+export type RadrootsCoopEventMetadata = { id: string, author: string, published_at: number, kind: number, coop: RadrootsCoop, };
+
+export type RadrootsCoopLocation = { primary?: string | null, city?: string | null, region?: string | null, country?: string | null, gcs: RadrootsGcsLocation, };
+
+export type RadrootsCoopRef = { pubkey: string, d_tag: string, };
+
+export type RadrootsDocument = { d_tag: string, doc_type: string, title: string, version: string, summary?: string | null, effective_at?: number | null, body_markdown?: string | null, subject: RadrootsDocumentSubject, tags?: string[] | null, };
+
+export type RadrootsDocumentEventIndex = { event: RadrootsNostrEvent, metadata: RadrootsDocumentEventMetadata, };
+
+export type RadrootsDocumentEventMetadata = { id: string, author: string, published_at: number, kind: number, document: RadrootsDocument, };
+
+export type RadrootsDocumentSubject = { pubkey: string, address?: string | null, };
+
export type RadrootsFarm = { d_tag: string, name: string, about?: string | null, website?: string | null, picture?: string | null, banner?: string | null, location?: RadrootsFarmLocation | null, tags?: string[] | null, };
export type RadrootsFarmEventIndex = { event: RadrootsNostrEvent, metadata: RadrootsFarmEventMetadata, };
@@ -156,7 +174,7 @@ export type RadrootsProfileEventIndex = { event: RadrootsNostrEvent, metadata: R
export type RadrootsProfileEventMetadata = { id: string, author: string, published_at: number, kind: number, profile_type?: RadrootsProfileType | null, profile: RadrootsProfile, };
-export type RadrootsProfileType = "individual" | "farm";
+export type RadrootsProfileType = "individual" | "farm" | "coop";
export type RadrootsReaction = { root: RadrootsNostrEventRef, content: string, };
diff --git a/events/src/coop.rs b/events/src/coop.rs
@@ -0,0 +1,76 @@
+#![forbid(unsafe_code)]
+
+use crate::RadrootsNostrEvent;
+use crate::farm::RadrootsGcsLocation;
+#[cfg(feature = "ts-rs")]
+use ts_rs::TS;
+
+#[cfg(not(feature = "std"))]
+use alloc::{string::String, vec::Vec};
+
+#[cfg_attr(feature = "ts-rs", derive(TS))]
+#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug)]
+pub struct RadrootsCoopEventIndex {
+ pub event: RadrootsNostrEvent,
+ pub metadata: RadrootsCoopEventMetadata,
+}
+
+#[cfg_attr(feature = "ts-rs", derive(TS))]
+#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug)]
+pub struct RadrootsCoopEventMetadata {
+ pub id: String,
+ pub author: String,
+ pub published_at: u32,
+ pub kind: u32,
+ pub coop: RadrootsCoop,
+}
+
+#[cfg_attr(feature = "ts-rs", derive(TS))]
+#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug)]
+pub struct RadrootsCoop {
+ pub d_tag: String,
+ pub name: String,
+ #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))]
+ pub about: Option<String>,
+ #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))]
+ pub website: Option<String>,
+ #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))]
+ pub picture: Option<String>,
+ #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))]
+ pub banner: Option<String>,
+ #[cfg_attr(feature = "ts-rs", ts(optional, type = "RadrootsCoopLocation | null"))]
+ pub location: Option<RadrootsCoopLocation>,
+ #[cfg_attr(feature = "ts-rs", ts(optional, type = "string[] | null"))]
+ pub tags: Option<Vec<String>>,
+}
+
+#[cfg_attr(feature = "ts-rs", derive(TS))]
+#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug)]
+pub struct RadrootsCoopRef {
+ pub pubkey: String,
+ pub d_tag: String,
+}
+
+#[cfg_attr(feature = "ts-rs", derive(TS))]
+#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug)]
+pub struct RadrootsCoopLocation {
+ #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))]
+ pub primary: Option<String>,
+ #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))]
+ pub city: Option<String>,
+ #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))]
+ pub region: Option<String>,
+ #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))]
+ pub country: Option<String>,
+ pub gcs: RadrootsGcsLocation,
+}
diff --git a/events/src/document.rs b/events/src/document.rs
@@ -0,0 +1,59 @@
+#![forbid(unsafe_code)]
+
+use crate::RadrootsNostrEvent;
+#[cfg(feature = "ts-rs")]
+use ts_rs::TS;
+
+#[cfg(not(feature = "std"))]
+use alloc::{string::String, vec::Vec};
+
+#[cfg_attr(feature = "ts-rs", derive(TS))]
+#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug)]
+pub struct RadrootsDocumentEventIndex {
+ pub event: RadrootsNostrEvent,
+ pub metadata: RadrootsDocumentEventMetadata,
+}
+
+#[cfg_attr(feature = "ts-rs", derive(TS))]
+#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug)]
+pub struct RadrootsDocumentEventMetadata {
+ pub id: String,
+ pub author: String,
+ pub published_at: u32,
+ pub kind: u32,
+ pub document: RadrootsDocument,
+}
+
+#[cfg_attr(feature = "ts-rs", derive(TS))]
+#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug)]
+pub struct RadrootsDocumentSubject {
+ pub pubkey: String,
+ #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))]
+ pub address: Option<String>,
+}
+
+#[cfg_attr(feature = "ts-rs", derive(TS))]
+#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug)]
+pub struct RadrootsDocument {
+ pub d_tag: String,
+ pub doc_type: String,
+ pub title: String,
+ pub version: String,
+ #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))]
+ pub summary: Option<String>,
+ #[cfg_attr(feature = "ts-rs", ts(optional, type = "number | null"))]
+ pub effective_at: Option<u32>,
+ #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))]
+ pub body_markdown: Option<String>,
+ pub subject: RadrootsDocumentSubject,
+ #[cfg_attr(feature = "ts-rs", ts(optional, type = "string[] | null"))]
+ pub tags: Option<Vec<String>>,
+}
diff --git a/events/src/kinds.rs b/events/src/kinds.rs
@@ -40,6 +40,8 @@ pub const KIND_LIST_SET_STARTER_PACK: u32 = 39089;
pub const KIND_LIST_SET_MEDIA_STARTER_PACK: u32 = 39092;
pub const KIND_FARM: u32 = 30340;
pub const KIND_PLOT: u32 = 30350;
+pub const KIND_COOP: u32 = 30360;
+pub const KIND_DOCUMENT: u32 = 30361;
pub const KIND_APP_DATA: u32 = 30078;
pub const KIND_LISTING: u32 = 30402;
pub const KIND_APPLICATION_HANDLER: u32 = 31990;
@@ -167,6 +169,8 @@ mod kinds_constants_tests {
("KIND_LIST_SET_MEDIA_STARTER_PACK", KIND_LIST_SET_MEDIA_STARTER_PACK),
("KIND_FARM", KIND_FARM),
("KIND_PLOT", KIND_PLOT),
+ ("KIND_COOP", KIND_COOP),
+ ("KIND_DOCUMENT", KIND_DOCUMENT),
("KIND_APP_DATA", KIND_APP_DATA),
("KIND_LISTING", KIND_LISTING),
("KIND_APPLICATION_HANDLER", KIND_APPLICATION_HANDLER),
diff --git a/events/src/lib.rs b/events/src/lib.rs
@@ -18,9 +18,11 @@ pub mod job_request;
pub mod job_result;
pub mod kinds;
pub mod listing;
+pub mod document;
pub mod list;
pub mod list_set;
pub mod app_data;
+pub mod coop;
pub mod farm;
pub mod message;
pub mod plot;
diff --git a/events/src/profile.rs b/events/src/profile.rs
@@ -8,6 +8,7 @@ use alloc::string::String;
pub const RADROOTS_PROFILE_TYPE_TAG_KEY: &str = "t";
pub const RADROOTS_PROFILE_TYPE_TAG_INDIVIDUAL: &str = "radroots:type:individual";
pub const RADROOTS_PROFILE_TYPE_TAG_FARM: &str = "radroots:type:farm";
+pub const RADROOTS_PROFILE_TYPE_TAG_COOP: &str = "radroots:type:coop";
#[cfg_attr(feature = "ts-rs", derive(TS))]
#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))]
@@ -17,12 +18,14 @@ pub const RADROOTS_PROFILE_TYPE_TAG_FARM: &str = "radroots:type:farm";
pub enum RadrootsProfileType {
Individual,
Farm,
+ Coop,
}
pub fn radroots_profile_type_tag_value(profile_type: RadrootsProfileType) -> &'static str {
match profile_type {
RadrootsProfileType::Individual => RADROOTS_PROFILE_TYPE_TAG_INDIVIDUAL,
RadrootsProfileType::Farm => RADROOTS_PROFILE_TYPE_TAG_FARM,
+ RadrootsProfileType::Coop => RADROOTS_PROFILE_TYPE_TAG_COOP,
}
}
@@ -30,6 +33,7 @@ pub fn radroots_profile_type_from_tag_value(value: &str) -> Option<RadrootsProfi
match value {
RADROOTS_PROFILE_TYPE_TAG_INDIVIDUAL => Some(RadrootsProfileType::Individual),
RADROOTS_PROFILE_TYPE_TAG_FARM => Some(RadrootsProfileType::Farm),
+ RADROOTS_PROFILE_TYPE_TAG_COOP => Some(RadrootsProfileType::Coop),
_ => None,
}
}
diff --git a/tangle-events/src/emit.rs b/tangle-events/src/emit.rs
@@ -656,6 +656,7 @@ fn profile_event(
let profile_type = match profile.profile_type.as_str() {
"individual" | "farmer" => Some(RadrootsProfileType::Individual),
"farm" => Some(RadrootsProfileType::Farm),
+ "coop" => Some(RadrootsProfileType::Coop),
other => radroots_profile_type_from_tag_value(other),
};
let profile_event = RadrootsProfile {
diff --git a/tangle-events/src/ingest.rs b/tangle-events/src/ingest.rs
@@ -219,6 +219,7 @@ fn ingest_profile_event<E: SqlExecutor>(
let profile_type = match profile_type {
radroots_events::profile::RadrootsProfileType::Individual => "individual",
radroots_events::profile::RadrootsProfileType::Farm => "farm",
+ radroots_events::profile::RadrootsProfileType::Coop => "coop",
};
let existing = nostr_profile::find_one(