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:
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")
+}