lib

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

commit a3650b8cd8a2c8d8384c15f49dca10d9b841faf8
parent 9a8486d138fc612d4d94693ca6b61b0203d375c5
Author: triesap <tyson@radroots.org>
Date:   Tue,  4 Aug 2026 23:52:30 +0000

feat(event_codec): complete typed authoring plans

- add one immutable authored-plan conversion for every registry-v7 typed contract
- validate historical typed plan profiles without reopening generic authoring paths
- enforce the closed typed authoring inventory against the event contract registry
- prove all eight plans remain byte-identical to the frozen authored wire corpus


Diffstat:
Mcrates/event_codec/src/authoring/mod.rs | 2++
Acrates/event_codec/src/authoring/typed.rs | 683+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/event_codec/src/authoring/wire.rs | 37++++++++++++++++++++++++-------------
Mcrates/event_codec/src/comment/inbound/registry_v7.rs | 2+-
Mcrates/event_codec/src/reply/inbound/registry_v7.rs | 2+-
Acrates/event_codec/tests/authored_typed_plans.rs | 263+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 974 insertions(+), 15 deletions(-)

diff --git a/crates/event_codec/src/authoring/mod.rs b/crates/event_codec/src/authoring/mod.rs @@ -13,8 +13,10 @@ use sha2::{Digest, Sha256}; pub const PLAN_WIRE_VERSION_V1: u32 = 1; const PLAN_DIGEST_DOMAIN: &[u8] = b"radroots.authored_event_plan.v1"; +mod typed; mod wire; +pub use typed::{AuthoredPlanError, REGISTRY_V7_TYPED_AUTHORING_CONTRACT_IDS}; pub use wire::{ HistoricalPlanIntegrity, PLAN_WIRE_MAX_BYTES, PlanDecodeError, PlanRegistryRelation, PlanWireV1, }; diff --git a/crates/event_codec/src/authoring/typed.rs b/crates/event_codec/src/authoring/typed.rs @@ -0,0 +1,683 @@ +//! Closed registry-v7 typed authoring conversions and historical validators. + +#[cfg(not(feature = "std"))] +use alloc::string::{String, ToString}; +#[cfg(all(not(feature = "std"), feature = "json"))] +use alloc::{vec, vec::Vec}; +#[cfg(feature = "std")] +use std::string::String; +#[cfg(all(feature = "std", feature = "json"))] +use std::vec::Vec; + +use core::fmt; +#[cfg(feature = "json")] +use radroots_event::profile::AuthoredProfile; +use radroots_event::{ + contract::{ContractIdentityError, ContractKey, EventAuthoringPolicy}, + envelope::{EventEnvelopeError, EventTags}, + food::availability::FoodAvailabilityDetails, + post::{ + AuthoredAsk, AuthoredPhotoUpdate, AuthoredUpdate, comment::AuthoredNip22Comment, + deletion::AuthoredNip09DeletionRequest, reply::AuthoredNip10Reply, + }, + wire::{ + CanonicalEventIdError, Nip01EventWireParts, compute_canonical_nip01_event_id, + v1::DEFAULT_CONTENT_MAX_BYTES, + }, +}; +use radroots_identity::{Error as PublicKeyError, PublicKey}; + +#[cfg(feature = "json")] +use crate::profile::authored::{ + RadrootsAuthoredProfileEncodeError, authored_profile_to_wire_parts, +}; +use crate::{ + comment::authored::authored_nip22_comment_to_wire_parts, + deletion::authored::authored_nip09_deletion_request_to_wire_parts, + food_availability::authored::{ + RadrootsFoodAvailabilityEncodeError, authored_food_availability_to_wire_parts, + }, + post::authored::{ + authored_ask_to_wire_parts, authored_photo_update_to_wire_parts, + authored_update_to_wire_parts, + }, + reply::authored::authored_nip10_reply_to_wire_parts, +}; +#[cfg(feature = "json")] +use crate::{ + comment::inbound::registry_v7::project_nip22_comment_parts, + deletion::reconciliation_v1::inbound::project_nip09_deletion_request_parts, + food_availability::inbound::registry_v7::{ + RadrootsFoodAvailabilityProjectionOutcome, project_inbound_food_availability_parts, + }, + post::inbound::registry_v7::{RadrootsPostClassification, project_inbound_post_parts}, + reply::inbound::registry_v7::{RadrootsNip10ReplyStyle, project_nip10_reply_parts}, +}; + +use super::{AuthoredEventBody, AuthoredEventPlan}; + +pub const REGISTRY_V7_TYPED_AUTHORING_CONTRACT_IDS: [&str; 8] = [ + "radroots.profile.metadata.v1", + "radroots.social.update.v1", + "radroots.social.photo_update.v1", + "radroots.social.ask.v1", + "radroots.social.reply.v1", + "radroots.social.deletion_request.v1", + "radroots.social.comment.v1", + "radroots.food.availability.v1", +]; + +#[non_exhaustive] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum AuthoredPlanError { + ContractIdentity(ContractIdentityError), + ContractNotTyped { + contract_id: String, + }, + ContractKindMismatch { + expected: u32, + actual: u32, + }, + InvalidAuthor(PublicKeyError), + Envelope(EventEnvelopeError), + ContentTooLarge { + max: usize, + actual: usize, + }, + CanonicalEventId(CanonicalEventIdError), + #[cfg(feature = "json")] + Profile(RadrootsAuthoredProfileEncodeError), + FoodAvailability(RadrootsFoodAvailabilityEncodeError), +} + +impl AuthoredPlanError { + #[must_use] + pub const fn code(&self) -> &'static str { + match self { + Self::ContractIdentity(error) => error.code(), + Self::ContractNotTyped { .. } => "contract_not_typed", + Self::ContractKindMismatch { .. } => "contract_kind_mismatch", + Self::InvalidAuthor(_) => "invalid_author", + Self::Envelope(_) => "invalid_envelope_parts", + Self::ContentTooLarge { .. } => "content_too_large", + Self::CanonicalEventId(_) => "canonical_event_id", + #[cfg(feature = "json")] + Self::Profile(error) => error.code(), + Self::FoodAvailability(error) => error.code(), + } + } +} + +impl fmt::Display for AuthoredPlanError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ContractIdentity(error) => write!(formatter, "invalid contract key: {error}"), + Self::ContractNotTyped { contract_id } => { + write!( + formatter, + "event contract `{contract_id}` is not typed-authorable" + ) + } + Self::ContractKindMismatch { expected, actual } => write!( + formatter, + "typed authored kind {actual} does not match contract kind {expected}" + ), + Self::InvalidAuthor(error) => write!(formatter, "invalid expected author: {error}"), + Self::Envelope(error) => write!(formatter, "invalid authored event parts: {error}"), + Self::ContentTooLarge { max, actual } => { + write!( + formatter, + "authored content is {actual} bytes; max is {max}" + ) + } + Self::CanonicalEventId(error) => write!(formatter, "{error}"), + #[cfg(feature = "json")] + Self::Profile(error) => write!(formatter, "{error}"), + Self::FoodAvailability(error) => write!(formatter, "{error}"), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for AuthoredPlanError {} + +impl AuthoredEventPlan { + #[cfg(feature = "json")] + pub fn from_profile( + profile: &AuthoredProfile, + created_at: u64, + expected_author: impl AsRef<str>, + ) -> Result<Self, AuthoredPlanError> { + let wire = authored_profile_to_wire_parts(profile).map_err(AuthoredPlanError::Profile)?; + build_typed_plan( + "radroots.profile.metadata.v1", + wire, + created_at, + expected_author.as_ref(), + ) + } + + pub fn from_update( + update: &AuthoredUpdate, + created_at: u64, + expected_author: impl AsRef<str>, + ) -> Result<Self, AuthoredPlanError> { + build_typed_plan( + "radroots.social.update.v1", + authored_update_to_wire_parts(update), + created_at, + expected_author.as_ref(), + ) + } + + pub fn from_photo_update( + photo: &AuthoredPhotoUpdate, + created_at: u64, + expected_author: impl AsRef<str>, + ) -> Result<Self, AuthoredPlanError> { + build_typed_plan( + "radroots.social.photo_update.v1", + authored_photo_update_to_wire_parts(photo), + created_at, + expected_author.as_ref(), + ) + } + + pub fn from_ask( + ask: &AuthoredAsk, + created_at: u64, + expected_author: impl AsRef<str>, + ) -> Result<Self, AuthoredPlanError> { + build_typed_plan( + "radroots.social.ask.v1", + authored_ask_to_wire_parts(ask), + created_at, + expected_author.as_ref(), + ) + } + + pub fn from_nip10_reply( + reply: &AuthoredNip10Reply, + created_at: u64, + expected_author: impl AsRef<str>, + ) -> Result<Self, AuthoredPlanError> { + build_typed_plan( + "radroots.social.reply.v1", + authored_nip10_reply_to_wire_parts(reply), + created_at, + expected_author.as_ref(), + ) + } + + pub fn from_nip09_deletion_request( + request: &AuthoredNip09DeletionRequest, + created_at: u64, + expected_author: impl AsRef<str>, + ) -> Result<Self, AuthoredPlanError> { + build_typed_plan( + "radroots.social.deletion_request.v1", + authored_nip09_deletion_request_to_wire_parts(request), + created_at, + expected_author.as_ref(), + ) + } + + pub fn from_nip22_comment( + comment: &AuthoredNip22Comment, + created_at: u64, + expected_author: impl AsRef<str>, + ) -> Result<Self, AuthoredPlanError> { + build_typed_plan( + "radroots.social.comment.v1", + authored_nip22_comment_to_wire_parts(comment), + created_at, + expected_author.as_ref(), + ) + } + + pub fn from_food_availability( + details: &FoodAvailabilityDetails, + created_at: u64, + expected_author: impl AsRef<str>, + ) -> Result<Self, AuthoredPlanError> { + let wire = authored_food_availability_to_wire_parts(details, created_at) + .map_err(AuthoredPlanError::FoodAvailability)?; + build_typed_plan( + "radroots.food.availability.v1", + wire, + created_at, + expected_author.as_ref(), + ) + } +} + +fn build_typed_plan( + contract_id: &str, + wire: Nip01EventWireParts, + created_at: u64, + expected_author: &str, +) -> Result<AuthoredEventPlan, AuthoredPlanError> { + let contract = + ContractKey::current(contract_id).map_err(AuthoredPlanError::ContractIdentity)?; + let definition = contract.contract(); + if definition.authoring_policy() != EventAuthoringPolicy::TypedOnly { + return Err(AuthoredPlanError::ContractNotTyped { + contract_id: contract_id.to_string(), + }); + } + if definition.kind != wire.kind { + return Err(AuthoredPlanError::ContractKindMismatch { + expected: definition.kind, + actual: wire.kind, + }); + } + if wire.content.len() > DEFAULT_CONTENT_MAX_BYTES { + return Err(AuthoredPlanError::ContentTooLarge { + max: DEFAULT_CONTENT_MAX_BYTES, + actual: wire.content.len(), + }); + } + EventTags::new(wire.tags.clone()).map_err(AuthoredPlanError::Envelope)?; + let author = PublicKey::from_hex(expected_author).map_err(AuthoredPlanError::InvalidAuthor)?; + let expected_event_id = compute_canonical_nip01_event_id( + &author.to_hex(), + created_at, + wire.kind, + &wire.tags, + &wire.content, + ) + .map_err(AuthoredPlanError::CanonicalEventId)?; + let body = AuthoredEventBody { + contract, + kind: wire.kind, + tags: wire.tags, + content: wire.content, + }; + Ok(AuthoredEventPlan::from_validated_parts( + body, + author, + created_at, + expected_event_id, + )) +} + +#[cfg(feature = "json")] +pub(super) fn validate_historical_typed_profile( + contract_id: &str, + created_at: u64, + kind: u32, + tags: &[Vec<String>], + content: &str, +) -> Result<(), String> { + match contract_id { + #[cfg(feature = "json")] + "radroots.profile.metadata.v1" => validate_profile(tags, content), + "radroots.social.update.v1" => validate_update(tags, content), + "radroots.social.photo_update.v1" => { + validate_post_profile(RadrootsPostClassification::PhotoUpdate, tags, content) + } + "radroots.social.ask.v1" => { + validate_post_profile(RadrootsPostClassification::Ask, tags, content) + } + "radroots.social.reply.v1" => validate_reply(created_at, kind, tags, content), + "radroots.social.deletion_request.v1" => validate_deletion(created_at, kind, tags, content), + "radroots.social.comment.v1" => validate_comment(created_at, kind, tags, content), + "radroots.food.availability.v1" => validate_food(created_at, kind, tags, content), + _ => Err("historical_typed_profile_unavailable".to_string()), + } +} + +#[cfg(feature = "json")] +fn validate_profile(tags: &[Vec<String>], content: &str) -> Result<(), String> { + use serde::Serialize; + + if !tags.is_empty() { + return Err("profile_tags_forbidden".to_string()); + } + let profile = + crate::profile::inbound::registry_v7::parse_inbound_profile_metadata_registry_v7(content) + .map_err(|error| error.code().to_string())?; + if !profile.residual_fields().is_empty() { + return Err("profile_residual_fields".to_string()); + } + let name = profile + .name() + .ok_or_else(|| "profile_name_missing".to_string())?; + AuthoredProfile::new(name).map_err(|error| error.code().to_string())?; + if profile + .picture() + .into_iter() + .chain(profile.banner()) + .any(|media| radroots_blossom::BlobUrl::parse(media.as_str()).is_err()) + { + return Err("profile_media_url_invalid".to_string()); + } + + #[derive(Serialize)] + struct CanonicalProfile<'a> { + name: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + display_name: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + about: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + picture: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + banner: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + nip05: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + bot: Option<bool>, + } + let canonical = serde_json::to_string(&CanonicalProfile { + name, + display_name: profile.display_name(), + about: profile.about(), + picture: profile.picture().map(|media| media.as_str()), + banner: profile.banner().map(|media| media.as_str()), + nip05: profile.nip05().map(|identifier| identifier.as_str()), + bot: profile.bot(), + }) + .map_err(|_| "profile_json_encode".to_string())?; + if canonical != content { + return Err("profile_noncanonical_json".to_string()); + } + Ok(()) +} + +#[cfg(feature = "json")] +fn validate_update(tags: &[Vec<String>], content: &str) -> Result<(), String> { + if !tags.is_empty() { + return Err("update_tags_forbidden".to_string()); + } + AuthoredUpdate::new(content) + .map(|_| ()) + .map_err(|error| error.code().to_string()) +} + +#[cfg(feature = "json")] +fn validate_post_profile( + expected: RadrootsPostClassification, + tags: &[Vec<String>], + content: &str, +) -> Result<(), String> { + let projection = + project_inbound_post_parts(1, tags, content).map_err(|error| error.code().to_string())?; + if projection.classification() != expected || !projection.diagnostics().is_empty() { + return Err("post_profile_mismatch".to_string()); + } + let mut images = tags; + if expected == RadrootsPostClassification::Ask { + let Some((marker, remaining)) = images.split_first() else { + return Err("ask_marker_missing".to_string()); + }; + if marker.as_slice() != ["t", "radroots-ask"] { + return Err("ask_marker_noncanonical".to_string()); + } + images = remaining; + } + if expected == RadrootsPostClassification::PhotoUpdate && images.is_empty() { + return Err("photo_image_missing".to_string()); + } + if content.trim().is_empty() { + return Err("post_content_missing".to_string()); + } + for tag in images { + if !canonical_imeta_tag(tag) { + return Err("imeta_noncanonical".to_string()); + } + } + if tags.len() != images.len() + usize::from(expected == RadrootsPostClassification::Ask) { + return Err("post_extra_tags".to_string()); + } + Ok(()) +} + +#[cfg(feature = "json")] +fn canonical_imeta_tag(tag: &[String]) -> bool { + if tag.len() < 7 || tag.first().map(String::as_str) != Some("imeta") { + return false; + } + let required = ["url ", "x ", "m ", "dim ", "size ", "alt "]; + tag[1..7] + .iter() + .zip(required) + .all(|(field, prefix)| field.starts_with(prefix) && field.len() > prefix.len()) + && tag[7..] + .iter() + .all(|field| field.starts_with("fallback ") && field.len() > "fallback ".len()) +} + +#[cfg(feature = "json")] +fn validate_reply( + created_at: u64, + kind: u32, + tags: &[Vec<String>], + content: &str, +) -> Result<(), String> { + let projection = project_nip10_reply_parts(kind, tags, content, decimal_digits(created_at)) + .map_err(|error| error.code().to_string())?; + if projection.style() != RadrootsNip10ReplyStyle::Marked + || !projection.citations().is_empty() + || !projection.diagnostics().is_empty() + { + return Err("reply_profile_noncanonical".to_string()); + } + let mut expected = Vec::new(); + let root = projection.root(); + if root.raw_tag().len() != 4 || root.raw_tag().get(3).map(String::as_str) != Some("root") { + return Err("reply_root_noncanonical".to_string()); + } + expected.push(root.raw_tag().to_vec()); + if let Some(parent) = projection.reply_reference() { + if parent.raw_tag().len() != 4 + || parent.raw_tag().get(3).map(String::as_str) != Some("reply") + { + return Err("reply_parent_noncanonical".to_string()); + } + expected.push(parent.raw_tag().to_vec()); + } + let participants = projection.participants(); + let Some(root_author) = participants.first() else { + return Err("reply_author_missing".to_string()); + }; + expected.push(vec!["p".to_string(), root_author.pubkey().to_hex()]); + if projection.reply_reference().is_some() && participants.len() == 2 { + expected.push(vec!["p".to_string(), participants[1].pubkey().to_hex()]); + } else if participants.len() != 1 { + return Err("reply_participants_noncanonical".to_string()); + } + if expected != tags { + return Err("reply_tags_noncanonical".to_string()); + } + Ok(()) +} + +#[cfg(feature = "json")] +fn validate_deletion( + created_at: u64, + kind: u32, + tags: &[Vec<String>], + content: &str, +) -> Result<(), String> { + project_nip09_deletion_request_parts(kind, tags, content, created_at) + .map_err(|error| error.code().to_string())?; + let mut section = 0_u8; + let mut previous_text: Option<&str> = None; + let mut previous_kind = None; + let mut address_kinds = Vec::new(); + let mut kind_hints = Vec::new(); + let mut targets = 0usize; + for tag in tags { + if tag.len() != 2 { + return Err("deletion_tag_shape".to_string()); + } + match tag[0].as_str() { + "e" if section == 0 => { + radroots_event::id::EventId::parse(&tag[1]) + .map_err(|_| "deletion_event_id".to_string())?; + if previous_text.is_some_and(|previous| previous >= tag[1].as_str()) { + return Err("deletion_event_order".to_string()); + } + previous_text = Some(&tag[1]); + targets += 1; + } + "a" if section <= 1 => { + if section == 0 { + previous_text = None; + } + section = 1; + let coordinate = radroots_event::id::Nip01Coordinate::parse(&tag[1]) + .map_err(|_| "deletion_address".to_string())?; + if previous_text.is_some_and(|previous| previous >= tag[1].as_str()) { + return Err("deletion_address_order".to_string()); + } + previous_text = Some(&tag[1]); + address_kinds.push(coordinate.kind()); + targets += 1; + } + "k" => { + section = 2; + let value = tag[1] + .parse::<u32>() + .map_err(|_| "deletion_kind_hint".to_string())?; + if value.to_string() != tag[1] + || previous_kind.is_some_and(|previous| previous >= value) + { + return Err("deletion_kind_order".to_string()); + } + previous_kind = Some(value); + kind_hints.push(value); + } + _ => return Err("deletion_tag_order".to_string()), + } + } + if targets == 0 + || kind_hints.is_empty() + || address_kinds.iter().any(|kind| !kind_hints.contains(kind)) + { + return Err("deletion_target_kind_set".to_string()); + } + Ok(()) +} + +#[cfg(feature = "json")] +fn validate_comment( + created_at: u64, + kind: u32, + tags: &[Vec<String>], + content: &str, +) -> Result<(), String> { + use crate::comment::inbound::registry_v7::RadrootsInboundNip22CommentPosition; + + let projection = project_nip22_comment_parts(kind, tags, content, decimal_digits(created_at)) + .map_err(|error| error.code().to_string())?; + if !projection.diagnostics().is_empty() || !projection.mentions().is_empty() { + return Err("comment_profile_noncanonical".to_string()); + } + let expected_keys: &[&str] = match projection.position() { + RadrootsInboundNip22CommentPosition::TopLevelEvent { .. } => { + &["E", "K", "P", "e", "k", "p"] + } + RadrootsInboundNip22CommentPosition::TopLevelAddress { .. } => { + &["A", "K", "P", "a", "e", "k", "p"] + } + RadrootsInboundNip22CommentPosition::Nested { .. } => match projection.root() { + crate::comment::inbound::registry_v7::RadrootsInboundNip22CommentRoot::Event(_) => { + &["E", "K", "P", "e", "k", "p"] + } + crate::comment::inbound::registry_v7::RadrootsInboundNip22CommentRoot::Address(_) => { + &["A", "K", "P", "e", "k", "p"] + } + }, + }; + if tags.len() != expected_keys.len() + || tags + .iter() + .zip(expected_keys) + .any(|(tag, key)| tag.first().map(String::as_str) != Some(*key)) + || tags.iter().any(|tag| tag.len() < 2 || tag.len() > 4) + { + return Err("comment_tags_noncanonical".to_string()); + } + Ok(()) +} + +#[cfg(feature = "json")] +fn validate_food( + created_at: u64, + kind: u32, + tags: &[Vec<String>], + content: &str, +) -> Result<(), String> { + let event_tags = EventTags::new(tags.to_vec()).map_err(|error| error.to_string())?; + let outcome = project_inbound_food_availability_parts(kind, created_at, &event_tags, content) + .map_err(|error| error.code().to_string())?; + let RadrootsFoodAvailabilityProjectionOutcome::Focused(projection) = outcome else { + return Err("food_profile_excluded".to_string()); + }; + if !projection.diagnostics().is_empty() + || projection.images().iter().any(|image| !image.qualifies()) + { + return Err("food_image_profile".to_string()); + } + let mut expected = vec![ + vec![ + "d".to_string(), + projection.identifier().as_str().to_string(), + ], + vec!["title".to_string(), projection.title().as_str().to_string()], + vec![ + "summary".to_string(), + projection.summary().as_str().to_string(), + ], + vec![ + "published_at".to_string(), + projection.published_at().to_string(), + ], + vec![ + "location".to_string(), + projection.location().as_str().to_string(), + ], + vec![ + "price".to_string(), + projection.price().amount().to_string(), + projection.price().currency().as_str().to_string(), + ], + vec![ + "radroots:price_unit".to_string(), + projection.price().unit().as_str().to_string(), + ], + ]; + if let Some(quantity) = projection.quantity() { + expected.push(vec![ + "radroots:quantity".to_string(), + quantity.amount().to_string(), + quantity.unit().as_str().to_string(), + ]); + } + expected.push(vec![ + "status".to_string(), + projection.status().as_str().to_string(), + ]); + expected.extend( + projection + .images() + .iter() + .map(|image| image.raw_tag().to_vec()), + ); + if expected != tags { + return Err("food_tags_noncanonical".to_string()); + } + Ok(()) +} + +#[cfg(feature = "json")] +fn decimal_digits(mut value: u64) -> usize { + let mut digits = 1usize; + while value >= 10 { + value /= 10; + digits += 1; + } + digits +} diff --git a/crates/event_codec/src/authoring/wire.rs b/crates/event_codec/src/authoring/wire.rs @@ -22,7 +22,7 @@ use radroots_event::{ use radroots_identity::PublicKey; #[cfg(feature = "json")] -use super::AuthoredEventBody; +use super::{AuthoredEventBody, typed::validate_historical_typed_profile}; use super::{AuthoredEventPlan, PLAN_WIRE_VERSION_V1, PlanDigest, PlanDigestError}; /// Hard byte limit applied before JSON parsing or field allocation. @@ -146,14 +146,25 @@ impl PlanWireV1 { actual: wire.kind, }); } - if definition.authoring_policy() != EventAuthoringPolicy::GenericDraft { - return Err(PlanDecodeError::HistoricalProfileUnavailable { - contract_id: definition.id.to_owned(), - }); + match definition.authoring_policy() { + EventAuthoringPolicy::GenericDraft => { + validate_event_contract_parts(wire.kind, &wire.tags, &wire.content, definition.id) + .map_err(|error| PlanDecodeError::HistoricalShape(error.code().to_owned()))?; + } + EventAuthoringPolicy::TypedOnly => validate_historical_typed_profile( + definition.id, + wire.created_at, + wire.kind, + &wire.tags, + &wire.content, + ) + .map_err(PlanDecodeError::HistoricalShape)?, + EventAuthoringPolicy::ReadOnly => { + return Err(PlanDecodeError::HistoricalProfileUnavailable { + contract_id: definition.id.to_owned(), + }); + } } - - validate_event_contract_parts(wire.kind, &wire.tags, &wire.content, definition.id) - .map_err(|error| PlanDecodeError::HistoricalShape(error.code().to_owned()))?; EventTags::new(wire.tags.clone()) .map_err(|error| PlanDecodeError::HistoricalShape(error.to_string()))?; let expected_author = PublicKey::from_hex(&wire.expected_author) @@ -501,18 +512,18 @@ mod tests { } #[test] - fn typed_contract_cannot_enter_through_the_generic_historical_profile() { + fn typed_contract_rejects_a_shape_from_the_wrong_historical_profile() { let typed = mutate(|value| { value["contract_id"] = json!("radroots.social.update.v1"); value["kind"] = json!(1); - value["tags"] = json!([]); + value["tags"] = json!([["g", "u4pru"]]); value["content"] = json!("hello"); }); assert_eq!( PlanWireV1::from_json(&typed), - Err(PlanDecodeError::HistoricalProfileUnavailable { - contract_id: "radroots.social.update.v1".to_owned(), - }) + Err(PlanDecodeError::HistoricalShape( + "update_tags_forbidden".to_owned() + )) ); } diff --git a/crates/event_codec/src/comment/inbound/registry_v7.rs b/crates/event_codec/src/comment/inbound/registry_v7.rs @@ -822,7 +822,7 @@ pub fn project_verified_nip22_comment_event_registry_v7( ) } -fn project_nip22_comment_parts( +pub(crate) fn project_nip22_comment_parts( kind: u32, tags: &[Vec<String>], content: &str, diff --git a/crates/event_codec/src/reply/inbound/registry_v7.rs b/crates/event_codec/src/reply/inbound/registry_v7.rs @@ -385,7 +385,7 @@ pub fn project_verified_nip10_reply_event_registry_v7( ) } -fn project_nip10_reply_parts( +pub(crate) fn project_nip10_reply_parts( kind: u32, tags: &[Vec<String>], content: &str, diff --git a/crates/event_codec/tests/authored_typed_plans.rs b/crates/event_codec/tests/authored_typed_plans.rs @@ -0,0 +1,263 @@ +#![cfg(feature = "json")] + +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + path::Path, +}; + +use radroots_blossom::{BlobDescriptor, BlobUrl, MediaType, Sha256}; +use radroots_event::{ + contract::{EventAuthoringPolicy, all_event_contracts}, + envelope::kind::KIND_CLASSIFIED_LISTING, + food::availability::{ + FoodAvailabilityDetails, FoodAvailabilityDetailsParts, FoodAvailabilityStatus, FoodContent, + FoodCurrency, FoodIdentifier, FoodPrice, FoodPublishedAt, FoodQuantity, FoodText, FoodUnit, + }, + media::AuthoredImage, + post::{ + AuthoredAsk, AuthoredPhotoUpdate, AuthoredPostImage, AuthoredUpdate, PostImageDimensions, + comment::{AuthoredNip22Comment, Nip22EventRootReference}, + deletion::{ + AuthoredNip09DeletionRequest, Nip09DeletionAddressTarget, Nip09DeletionEventTarget, + }, + reply::{AuthoredNip10Reply, Nip10ReplyReference}, + }, + profile::AuthoredProfile, +}; +use radroots_event_codec::authoring::{ + AuthoredEventPlan, PlanWireV1, REGISTRY_V7_TYPED_AUTHORING_CONTRACT_IDS, +}; +use serde::Deserialize; + +const AUTHOR: &str = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; +const OTHER_AUTHOR: &str = "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"; +const CREATED_AT: u64 = 1_784_347_200; +const ROOT_EVENT_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const TARGET_EVENT_ID: &str = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"; +const RELAY: &str = "wss://relay.example.com"; +const WORKSPACE_CORPUS_PATH: &str = + "../../contracts/conformance/vectors/event/authored_operations.v1.json"; + +#[derive(Debug, Deserialize)] +struct Corpus { + vectors: Vec<Vector>, +} + +#[derive(Debug, Deserialize)] +struct Vector { + input: Input, + expected: Expected, +} + +#[derive(Debug, Deserialize)] +struct Input { + contract_id: String, + authoring: String, +} + +#[derive(Debug, Deserialize)] +struct Expected { + kind: u32, + created_at: u64, + pubkey: String, + tags: Vec<Vec<String>>, + content: String, + event_id: String, +} + +#[test] +fn registry_inventory_has_exactly_one_typed_plan_path_and_round_trips_every_plan() { + let plans = typed_plans(); + let registry = all_event_contracts() + .iter() + .filter(|contract| contract.authoring_policy() == EventAuthoringPolicy::TypedOnly) + .map(|contract| contract.id) + .collect::<Vec<_>>(); + assert_eq!(registry, REGISTRY_V7_TYPED_AUTHORING_CONTRACT_IDS); + assert_eq!(plans.len(), REGISTRY_V7_TYPED_AUTHORING_CONTRACT_IDS.len()); + assert_eq!( + plans.keys().copied().collect::<BTreeSet<_>>(), + REGISTRY_V7_TYPED_AUTHORING_CONTRACT_IDS + .into_iter() + .collect::<BTreeSet<_>>() + ); + + for (contract_id, plan) in &plans { + assert_eq!(plan.body().contract().contract_id().as_str(), *contract_id); + let json = PlanWireV1::from_plan(plan).to_json().expect("plan wire"); + assert_eq!( + PlanWireV1::from_json(&json) + .unwrap_or_else(|error| panic!("{contract_id} historical decode failed: {error}")) + .into_plan(), + *plan, + "{contract_id}" + ); + } +} + +#[test] +fn every_typed_plan_is_byte_identical_to_the_frozen_authored_wire_corpus() { + let workspace_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(WORKSPACE_CORPUS_PATH); + if !workspace_path.is_file() { + return; + } + let corpus: Corpus = serde_json::from_str( + &fs::read_to_string(&workspace_path) + .unwrap_or_else(|error| panic!("failed to read {}: {error}", workspace_path.display())), + ) + .expect("authored corpus"); + let expected = corpus + .vectors + .into_iter() + .filter(|vector| vector.input.authoring == "typed") + .map(|vector| (vector.input.contract_id, vector.expected)) + .collect::<BTreeMap<_, _>>(); + let plans = typed_plans(); + assert_eq!(expected.len(), plans.len()); + + for (contract_id, plan) in plans { + let expected = expected + .get(contract_id) + .unwrap_or_else(|| panic!("missing corpus vector for {contract_id}")); + assert_eq!(plan.created_at(), expected.created_at, "{contract_id}"); + assert_eq!(plan.author().to_hex(), expected.pubkey, "{contract_id}"); + assert_eq!(plan.body().kind(), expected.kind, "{contract_id}"); + assert_eq!(plan.body().tags(), expected.tags, "{contract_id}"); + assert_eq!(plan.body().content(), expected.content, "{contract_id}"); + assert_eq!( + plan.expected_event_id().to_hex(), + expected.event_id, + "{contract_id}" + ); + } +} + +fn typed_plans() -> BTreeMap<&'static str, AuthoredEventPlan> { + let mut plans = BTreeMap::new(); + let profile = AuthoredProfile::new("Alice \"Sprout\"") + .expect("profile") + .with_display_name("Alice's Orchard") + .with_about("Tree fruit\nDirect from the farm") + .with_bot(false); + plans.insert( + "radroots.profile.metadata.v1", + AuthoredEventPlan::from_profile(&profile, CREATED_AT, AUTHOR).expect("profile plan"), + ); + + let update = AuthoredUpdate::new("Farm update: \"ready\"\\\nšŸ“").expect("update"); + plans.insert( + "radroots.social.update.v1", + AuthoredEventPlan::from_update(&update, CREATED_AT, AUTHOR).expect("update plan"), + ); + + let image = authored_post_image(); + let photo = AuthoredPhotoUpdate::new(format!("Harvest {}", image.url()), vec![image.clone()]) + .expect("photo"); + plans.insert( + "radroots.social.photo_update.v1", + AuthoredEventPlan::from_photo_update(&photo, CREATED_AT, AUTHOR).expect("photo plan"), + ); + + let ask = + AuthoredAsk::new(format!("Is this ready? {}", image.url()), vec![image]).expect("ask"); + plans.insert( + "radroots.social.ask.v1", + AuthoredEventPlan::from_ask(&ask, CREATED_AT, AUTHOR).expect("ask plan"), + ); + + let reply = AuthoredNip10Reply::direct( + "Direct reply", + Nip10ReplyReference::parse(ROOT_EVENT_ID, OTHER_AUTHOR, Some(RELAY)).expect("root"), + ) + .expect("reply"); + plans.insert( + "radroots.social.reply.v1", + AuthoredEventPlan::from_nip10_reply(&reply, CREATED_AT, AUTHOR).expect("reply plan"), + ); + + let deletion = AuthoredNip09DeletionRequest::new( + "superseded", + vec![Nip09DeletionEventTarget::parse(TARGET_EVENT_ID, 1).expect("event target")], + vec![ + Nip09DeletionAddressTarget::parse(format!("30402:{OTHER_AUTHOR}:carrots")) + .expect("listing target"), + Nip09DeletionAddressTarget::parse(format!("0:{OTHER_AUTHOR}:")) + .expect("profile target"), + ], + ) + .expect("deletion"); + plans.insert( + "radroots.social.deletion_request.v1", + AuthoredEventPlan::from_nip09_deletion_request(&deletion, CREATED_AT, AUTHOR) + .expect("deletion plan"), + ); + + let comment = AuthoredNip22Comment::top_level_event( + "Are these carrots available Saturday?", + Nip22EventRootReference::parse( + ROOT_EVENT_ID, + OTHER_AUTHOR, + KIND_CLASSIFIED_LISTING, + Some(RELAY), + ) + .expect("comment root"), + ) + .expect("comment"); + plans.insert( + "radroots.social.comment.v1", + AuthoredEventPlan::from_nip22_comment(&comment, CREATED_AT, AUTHOR).expect("comment plan"), + ); + + plans.insert( + "radroots.food.availability.v1", + AuthoredEventPlan::from_food_availability(&food_details(), CREATED_AT, AUTHOR) + .expect("food plan"), + ); + plans +} + +fn authored_post_image() -> AuthoredPostImage { + let bytes = b"strawberries"; + let hash = Sha256::digest(bytes); + let media_type = MediaType::parse("image/webp").expect("media type"); + let descriptor = BlobDescriptor::new( + BlobUrl::parse(&format!("https://media.example/{hash}.webp")).expect("blob URL"), + hash, + bytes.len() as u64, + media_type.clone(), + CREATED_AT, + ) + .expect("descriptor") + .approve_reference() + .expect("approved reference") + .verify_bytes(bytes, &media_type) + .expect("verified bytes"); + AuthoredPostImage::new( + AuthoredImage::try_from(descriptor).expect("authored image"), + PostImageDimensions::new(1200, 900).expect("dimensions"), + "Harvest", + ) + .expect("post image") +} + +fn food_details() -> FoodAvailabilityDetails { + FoodAvailabilityDetails::new(FoodAvailabilityDetailsParts { + content: FoodContent::new("Carrots available this week.").expect("content"), + identifier: FoodIdentifier::parse("nantes-carrots").expect("identifier"), + title: FoodText::new("Nantes Carrots").expect("title"), + summary: FoodText::new("Fresh bunches").expect("summary"), + published_at: FoodPublishedAt::new(1_784_347_100).expect("published_at"), + location: FoodText::new("Central Saanich, BC").expect("location"), + price: FoodPrice::new( + "3", + FoodCurrency::parse("CAD").expect("currency"), + FoodUnit::Pound, + ) + .expect("price"), + quantity: Some(FoodQuantity::new("24", FoodUnit::Pound).expect("quantity")), + status: FoodAvailabilityStatus::Active, + images: Vec::new(), + }) + .expect("food details") +}