lib

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

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:
Mevents-codec-wasm/src/lib.rs | 26++++++++++++++++++++++++++
Aevents-codec/src/coop/decode.rs | 107+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aevents-codec/src/coop/encode.rs | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aevents-codec/src/coop/list_sets.rs | 177+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aevents-codec/src/coop/mod.rs | 119+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aevents-codec/src/document/decode.rs | 168+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aevents-codec/src/document/encode.rs | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aevents-codec/src/document/mod.rs | 34++++++++++++++++++++++++++++++++++
Mevents-codec/src/lib.rs | 2++
Mevents-codec/src/tag_builders.rs | 20++++++++++++++++++++
Mevents/bindings/ts/src/kinds.ts | 2++
Mevents/bindings/ts/src/types.ts | 20+++++++++++++++++++-
Aevents/src/coop.rs | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aevents/src/document.rs | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mevents/src/kinds.rs | 4++++
Mevents/src/lib.rs | 2++
Mevents/src/profile.rs | 4++++
Mtangle-events/src/emit.rs | 1+
Mtangle-events/src/ingest.rs | 1+
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(