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