lib

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

commit ae259126fed72abdb069425a0da9920595681ed0
parent cff6ab4bffb521fb7c498c7fe9a64112125cc03f
Author: triesap <tyson@radroots.org>
Date:   Thu, 11 Jun 2026 17:10:33 -0700

events_codec: add farm workspace codec

- add workspace manifest tag builders and wire-part encoding
- add strict JSON decode for schema, d, h, relay, and kind invariants
- cover canonical tags plus malformed manifest rejection paths
- keep generic app-data behavior unchanged

Diffstat:
Acrates/events_codec/src/farm_workspace/decode.rs | 168+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/events_codec/src/farm_workspace/encode.rs | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/events_codec/src/farm_workspace/mod.rs | 152+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/events_codec/src/lib.rs | 1+
4 files changed, 416 insertions(+), 0 deletions(-)

diff --git a/crates/events_codec/src/farm_workspace/decode.rs b/crates/events_codec/src/farm_workspace/decode.rs @@ -0,0 +1,168 @@ +#![cfg(feature = "serde_json")] + +#[cfg(not(feature = "std"))] +use alloc::{ + string::{String, ToString}, + vec::Vec, +}; + +use radroots_events::{ + RadrootsNostrEvent, + farm_workspace::{ + KIND_FARM_WORKSPACE_MANIFEST, RADROOTS_FARM_WORKSPACE_SCHEMA, RADROOTS_FARM_WORKSPACE_TAG, + RadrootsFarmWorkspaceManifest, + }, + kinds::{KIND_FARM, KIND_FARM_CRDT_CHANGE}, + tags::{TAG_A, TAG_D, TAG_H, TAG_P, TAG_T}, +}; + +use crate::d_tag::validate_d_tag_tag; +use crate::error::EventParseError; +use crate::farm_workspace::encode::validate_manifest; +use crate::field_helpers::{ + optional_tag_value, parse_address_tag_with_kind, required_tag_value, tag_values, + validate_non_empty_tag_value, +}; +use crate::parsed::{RadrootsParsedData, RadrootsParsedEvent}; + +const EXPECTED_KIND: &str = "30078"; + +pub fn farm_workspace_from_event( + kind: u32, + tags: &[Vec<String>], + content: &str, +) -> Result<RadrootsFarmWorkspaceManifest, EventParseError> { + if kind != KIND_FARM_WORKSPACE_MANIFEST { + return Err(EventParseError::InvalidKind { + expected: EXPECTED_KIND, + got: kind, + }); + } + if content.trim().is_empty() { + return Err(EventParseError::InvalidJson("content")); + } + let d_tag = required_tag_value(tags, TAG_D)?; + validate_d_tag_tag(&d_tag, TAG_D)?; + let farm_group_id = required_tag_value(tags, TAG_H)?; + let manifest: RadrootsFarmWorkspaceManifest = + serde_json::from_str(content).map_err(|_| EventParseError::InvalidJson("content"))?; + validate_manifest_content(&manifest)?; + validate_manifest(&manifest).map_err(encode_error_to_parse_error)?; + + if manifest.d_tag != d_tag { + return Err(EventParseError::InvalidTag(TAG_D)); + } + if manifest.farm_group_id != farm_group_id { + return Err(EventParseError::InvalidTag(TAG_H)); + } + if let Some(owner_pubkey) = optional_tag_value(tags, TAG_P)? { + if owner_pubkey != manifest.owner_pubkey { + return Err(EventParseError::InvalidTag(TAG_P)); + } + } + let marker_tags = tag_values(tags, TAG_T)?; + if !marker_tags + .iter() + .any(|value| value == RADROOTS_FARM_WORKSPACE_TAG) + { + return Err(EventParseError::MissingTag(TAG_T)); + } + if let Some(farm) = manifest.farm.as_ref() { + let farm_address = optional_tag_value(tags, TAG_A)?; + if let Some(value) = farm_address { + let address = parse_address_tag_with_kind(&value, KIND_FARM, TAG_A)?; + if address.pubkey != farm.pubkey || address.d_tag != farm.d_tag { + return Err(EventParseError::InvalidTag(TAG_A)); + } + } + } + + Ok(manifest) +} + +pub fn data_from_event( + id: String, + author: String, + published_at: u32, + kind: u32, + content: String, + tags: Vec<Vec<String>>, +) -> Result<RadrootsParsedData<RadrootsFarmWorkspaceManifest>, EventParseError> { + let manifest = farm_workspace_from_event(kind, &tags, &content)?; + Ok(RadrootsParsedData::new( + id, + author, + published_at, + kind, + manifest, + )) +} + +pub fn parsed_from_event( + id: String, + author: String, + published_at: u32, + kind: u32, + content: String, + tags: Vec<Vec<String>>, + sig: String, +) -> Result<RadrootsParsedEvent<RadrootsFarmWorkspaceManifest>, EventParseError> { + let data = data_from_event( + id.clone(), + author.clone(), + published_at, + kind, + content.clone(), + tags.clone(), + )?; + Ok(RadrootsParsedEvent { + event: RadrootsNostrEvent { + id, + author, + created_at: published_at, + kind, + content, + tags, + sig, + }, + data, + }) +} + +fn validate_manifest_content( + manifest: &RadrootsFarmWorkspaceManifest, +) -> Result<(), EventParseError> { + if manifest.schema != RADROOTS_FARM_WORKSPACE_SCHEMA { + return Err(EventParseError::InvalidJson("schema")); + } + validate_non_empty_tag_value(&manifest.farm_group_id, TAG_H)?; + validate_non_empty_tag_value(&manifest.owner_pubkey, TAG_P)?; + if manifest.relays.is_empty() { + return Err(EventParseError::InvalidJson("relays")); + } + if !manifest + .supported_kinds + .contains(&KIND_FARM_WORKSPACE_MANIFEST) + || !manifest.supported_kinds.contains(&KIND_FARM_CRDT_CHANGE) + { + return Err(EventParseError::InvalidJson("supported_kinds")); + } + Ok(()) +} + +fn encode_error_to_parse_error(error: crate::error::EventEncodeError) -> EventParseError { + match error { + crate::error::EventEncodeError::InvalidKind(kind) => EventParseError::InvalidKind { + expected: EXPECTED_KIND, + got: kind, + }, + crate::error::EventEncodeError::EmptyRequiredField(field) + | crate::error::EventEncodeError::InvalidField(field) => match field { + "d_tag" | "farm.d_tag" => EventParseError::InvalidTag(TAG_D), + "farm_group_id" => EventParseError::InvalidTag(TAG_H), + "owner_pubkey" => EventParseError::InvalidTag(TAG_P), + _ => EventParseError::InvalidJson(field), + }, + crate::error::EventEncodeError::Json => EventParseError::InvalidJson("content"), + } +} diff --git a/crates/events_codec/src/farm_workspace/encode.rs b/crates/events_codec/src/farm_workspace/encode.rs @@ -0,0 +1,95 @@ +#[cfg(not(feature = "std"))] +use alloc::{ + string::{String, ToString}, + vec::Vec, +}; + +use radroots_events::{ + farm_workspace::{ + KIND_FARM_WORKSPACE_MANIFEST, RADROOTS_FARM_WORKSPACE_SCHEMA, RADROOTS_FARM_WORKSPACE_TAG, + RadrootsFarmWorkspaceManifest, + }, + kinds::{KIND_FARM, KIND_FARM_CRDT_CHANGE}, + tags::{TAG_A, TAG_D, TAG_H, TAG_P, TAG_T}, +}; + +use crate::d_tag::validate_d_tag; +use crate::error::EventEncodeError; +use crate::field_helpers::{address_string, push_tag, validate_non_empty_field}; +#[cfg(feature = "serde_json")] +use crate::wire::WireEventParts; + +pub fn farm_workspace_build_tags( + manifest: &RadrootsFarmWorkspaceManifest, +) -> Result<Vec<Vec<String>>, EventEncodeError> { + validate_manifest(manifest)?; + let mut tags = Vec::new(); + push_tag(&mut tags, TAG_D, manifest.d_tag.as_str()); + push_tag(&mut tags, TAG_H, manifest.farm_group_id.as_str()); + push_tag(&mut tags, TAG_P, manifest.owner_pubkey.as_str()); + push_tag(&mut tags, TAG_T, RADROOTS_FARM_WORKSPACE_TAG); + if let Some(farm) = manifest.farm.as_ref() { + let address = address_string(KIND_FARM, &farm.pubkey, &farm.d_tag, "farm")?; + push_tag(&mut tags, TAG_A, address); + } + Ok(tags) +} + +#[cfg(feature = "serde_json")] +pub fn to_wire_parts( + manifest: &RadrootsFarmWorkspaceManifest, +) -> Result<WireEventParts, EventEncodeError> { + to_wire_parts_with_kind(manifest, KIND_FARM_WORKSPACE_MANIFEST) +} + +#[cfg(feature = "serde_json")] +pub fn to_wire_parts_with_kind( + manifest: &RadrootsFarmWorkspaceManifest, + kind: u32, +) -> Result<WireEventParts, EventEncodeError> { + if kind != KIND_FARM_WORKSPACE_MANIFEST { + return Err(EventEncodeError::InvalidKind(kind)); + } + let tags = farm_workspace_build_tags(manifest)?; + let content = serde_json::to_string(manifest).map_err(|_| EventEncodeError::Json)?; + Ok(WireEventParts { + kind, + content, + tags, + }) +} + +pub(crate) fn validate_manifest( + manifest: &RadrootsFarmWorkspaceManifest, +) -> Result<(), EventEncodeError> { + validate_d_tag(&manifest.d_tag, "d_tag")?; + validate_non_empty_field(&manifest.farm_group_id, "farm_group_id")?; + validate_non_empty_field(&manifest.name, "name")?; + validate_non_empty_field(&manifest.owner_pubkey, "owner_pubkey")?; + validate_non_empty_field(&manifest.protocol_version, "protocol_version")?; + if manifest.schema != RADROOTS_FARM_WORKSPACE_SCHEMA { + return Err(EventEncodeError::InvalidField("schema")); + } + if manifest.relays.is_empty() { + return Err(EventEncodeError::EmptyRequiredField("relays")); + } + if !manifest + .supported_kinds + .contains(&KIND_FARM_WORKSPACE_MANIFEST) + || !manifest.supported_kinds.contains(&KIND_FARM_CRDT_CHANGE) + { + return Err(EventEncodeError::InvalidField("supported_kinds")); + } + for relay in &manifest.relays { + validate_non_empty_field(&relay.url, "relays.url")?; + } + for media_server in &manifest.media_servers { + validate_non_empty_field(&media_server.url, "media_servers.url")?; + validate_non_empty_field(&media_server.service, "media_servers.service")?; + } + if let Some(farm) = manifest.farm.as_ref() { + validate_non_empty_field(&farm.pubkey, "farm.pubkey")?; + validate_d_tag(&farm.d_tag, "farm.d_tag")?; + } + Ok(()) +} diff --git a/crates/events_codec/src/farm_workspace/mod.rs b/crates/events_codec/src/farm_workspace/mod.rs @@ -0,0 +1,152 @@ +pub mod encode; + +#[cfg(feature = "serde_json")] +pub mod decode; + +#[cfg(all(test, feature = "serde_json"))] +mod tests { + use radroots_events::{ + farm::RadrootsFarmRef, + farm_crdt::KIND_FARM_CRDT_CHANGE, + farm_workspace::{ + KIND_FARM_WORKSPACE_MANIFEST, RADROOTS_FARM_WORKSPACE_PROTOCOL_VERSION, + RADROOTS_FARM_WORKSPACE_SCHEMA, RADROOTS_FARM_WORKSPACE_TAG, + RadrootsFarmWorkspaceManifest, RadrootsFarmWorkspaceMediaServer, + RadrootsFarmWorkspaceRelay, RadrootsFarmWorkspaceRelayMode, + }, + kinds::KIND_POST, + }; + + use crate::error::{EventEncodeError, EventParseError}; + use crate::farm_workspace::decode::farm_workspace_from_event; + use crate::farm_workspace::encode::{ + farm_workspace_build_tags, to_wire_parts, to_wire_parts_with_kind, + }; + + const D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAA"; + const FARM_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAQ"; + const GROUP_ID: &str = "field-group"; + + #[test] + fn farm_workspace_manifest_encodes_canonical_tags_and_decodes() { + let manifest = sample_manifest(); + let parts = to_wire_parts(&manifest).expect("workspace wire parts"); + + assert_eq!(parts.kind, KIND_FARM_WORKSPACE_MANIFEST); + assert!(parts.tags.contains(&tag("d", D_TAG))); + assert!(parts.tags.contains(&tag("h", GROUP_ID))); + assert!(parts.tags.contains(&tag("p", "workspace_owner_pubkey"))); + assert!(parts.tags.contains(&tag("t", RADROOTS_FARM_WORKSPACE_TAG))); + assert!( + parts + .tags + .contains(&tag("a", "30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAQ")) + ); + + let decoded = farm_workspace_from_event(parts.kind, &parts.tags, &parts.content) + .expect("workspace decode"); + assert_eq!(decoded.d_tag, D_TAG); + assert_eq!(decoded.schema, RADROOTS_FARM_WORKSPACE_SCHEMA); + assert_eq!(decoded.farm_group_id, GROUP_ID); + assert_eq!(decoded.supported_kinds, vec![KIND_FARM_CRDT_CHANGE, 30078]); + } + + #[test] + fn farm_workspace_manifest_rejects_missing_h_and_d_mismatch() { + let parts = to_wire_parts(&sample_manifest()).expect("workspace wire parts"); + let without_h = parts + .tags + .iter() + .filter(|tag| tag.first().map(|value| value.as_str()) != Some("h")) + .cloned() + .collect::<Vec<_>>(); + + let missing_h = + farm_workspace_from_event(parts.kind, &without_h, &parts.content).unwrap_err(); + assert!(matches!(missing_h, EventParseError::MissingTag("h"))); + + let mut mismatched_d = parts.tags.clone(); + for tag in mismatched_d.iter_mut() { + if tag.first().map(|value| value.as_str()) == Some("d") { + tag[1] = "AAAAAAAAAAAAAAAAAAAAAg".to_string(); + } + } + let mismatch = + farm_workspace_from_event(parts.kind, &mismatched_d, &parts.content).unwrap_err(); + assert!(matches!(mismatch, EventParseError::InvalidTag("d"))); + } + + #[test] + fn farm_workspace_manifest_rejects_bad_d_tag_kind_and_schema() { + let mut bad_d_tag = sample_manifest(); + bad_d_tag.d_tag = "bad".to_string(); + let encode_err = farm_workspace_build_tags(&bad_d_tag).unwrap_err(); + assert!(matches!( + encode_err, + EventEncodeError::InvalidField("d_tag") + )); + + let wrong_kind = to_wire_parts_with_kind(&sample_manifest(), KIND_POST).unwrap_err(); + assert!(matches!( + wrong_kind, + EventEncodeError::InvalidKind(KIND_POST) + )); + + let mut bad_schema = sample_manifest(); + bad_schema.schema = "radroots.farm.workspace.invalid".to_string(); + let content = serde_json::to_string(&bad_schema).expect("bad schema content"); + let tags = farm_workspace_build_tags(&sample_manifest()).expect("workspace tags"); + let schema_err = + farm_workspace_from_event(KIND_FARM_WORKSPACE_MANIFEST, &tags, &content).unwrap_err(); + assert!(matches!(schema_err, EventParseError::InvalidJson("schema"))); + } + + #[test] + fn farm_workspace_manifest_rejects_missing_field_usage_kinds_and_relays() { + let mut no_relays = sample_manifest(); + no_relays.relays.clear(); + let relay_err = to_wire_parts(&no_relays).unwrap_err(); + assert!(matches!( + relay_err, + EventEncodeError::EmptyRequiredField("relays") + )); + + let mut unsupported = sample_manifest(); + unsupported.supported_kinds = vec![KIND_FARM_WORKSPACE_MANIFEST]; + let supported_err = to_wire_parts(&unsupported).unwrap_err(); + assert!(matches!( + supported_err, + EventEncodeError::InvalidField("supported_kinds") + )); + } + + fn sample_manifest() -> RadrootsFarmWorkspaceManifest { + RadrootsFarmWorkspaceManifest { + d_tag: D_TAG.to_string(), + schema: RADROOTS_FARM_WORKSPACE_SCHEMA.to_string(), + farm_group_id: GROUP_ID.to_string(), + name: "Small Regen Farm".to_string(), + owner_pubkey: "workspace_owner_pubkey".to_string(), + farm: Some(RadrootsFarmRef { + pubkey: "farm_pubkey".to_string(), + d_tag: FARM_D_TAG.to_string(), + }), + relays: vec![RadrootsFarmWorkspaceRelay { + url: "wss://relay.example.invalid/farm/field-group".to_string(), + mode: RadrootsFarmWorkspaceRelayMode::ReadWrite, + }], + media_servers: vec![RadrootsFarmWorkspaceMediaServer { + url: "https://media.example.invalid/farm/field-group".to_string(), + service: "RadrootsPrivateMedia".to_string(), + }], + supported_kinds: vec![KIND_FARM_CRDT_CHANGE, KIND_FARM_WORKSPACE_MANIFEST], + protocol_version: RADROOTS_FARM_WORKSPACE_PROTOCOL_VERSION.to_string(), + created_at_ms: 1_780_000_000_000, + updated_at_ms: None, + } + } + + fn tag(key: &str, value: &str) -> Vec<String> { + vec![key.to_string(), value.to_string()] + } +} diff --git a/crates/events_codec/src/lib.rs b/crates/events_codec/src/lib.rs @@ -18,6 +18,7 @@ pub mod comment; pub mod coop; pub mod document; pub mod farm; +pub mod farm_workspace; pub mod follow; pub mod geochat; pub mod gift_wrap;