commit cfd552f8b23a9cef8f70a865aa2f07b1216c5563
parent d9724b8fbfab3c36f9c9f491dc29cd71c2857c0d
Author: triesap <tyson@radroots.org>
Date: Sun, 5 Jul 2026 19:23:46 +0000
events-codec: harden NIP-54 wiki wire formats
- Model wiki redirects as addressable article targets and wiki fork/defer references as paired event/address references.
- Encode merge-request explanations as plaintext and reconstruct merge requests from NIP-54 tags.
- Add exact wire-shape and malformed decoder coverage for NIP-54 wiki articles, redirects, and merge requests.
- Validation: cargo extbuild run -- cargo fmt --all -- --check; cargo extbuild run -- cargo check -p radroots_events --features std,serde,dto-bindgen,knowledge,knowledge-nip54; cargo extbuild run -- cargo test -p radroots_events --features std,serde,dto-bindgen,knowledge,knowledge-nip54; cargo extbuild run -- cargo check -p radroots_events_codec --features std,serde_json,nostr,knowledge,knowledge-nip54; cargo extbuild run -- cargo test -p radroots_events_codec --features std,serde_json,nostr,knowledge,knowledge-nip54.
Diffstat:
7 files changed, 407 insertions(+), 112 deletions(-)
diff --git a/crates/events/src/contract.rs b/crates/events/src/contract.rs
@@ -2384,7 +2384,7 @@ static ALL_EVENT_CONTRACTS: &[RadrootsEventContract] = &[
RadrootsEventClass::Regular,
RadrootsEventPrivacy::Public,
RadrootsActorRole::Any,
- RadrootsContentSchema::JsonObject,
+ RadrootsContentSchema::PlainText,
RadrootsEventDiscriminator::KindOnly,
WIKI_MERGE_REQUEST_TAGS,
KNOWLEDGE_REDUCERS
diff --git a/crates/events/src/generated/dto_roots.rs b/crates/events/src/generated/dto_roots.rs
@@ -69,6 +69,8 @@ pub fn dto_bindgen_roots() -> ::std::vec::Vec<::dto_bindgen::export::RootDescrip
::dto_bindgen::export::RootDescriptor::new::<crate::knowledge::RadrootsKnowledgeSource>(),
::dto_bindgen::export::RootDescriptor::new::<crate::knowledge::RadrootsRightsAssertion>(),
::dto_bindgen::export::RootDescriptor::new::<crate::knowledge::RadrootsWikiArticle>(),
+ ::dto_bindgen::export::RootDescriptor::new::<crate::knowledge::RadrootsWikiArticleVersionRef>(
+ ),
::dto_bindgen::export::RootDescriptor::new::<crate::knowledge::RadrootsWikiMergeRequest>(),
::dto_bindgen::export::RootDescriptor::new::<crate::knowledge::RadrootsWikiRedirect>(),
::dto_bindgen::export::RootDescriptor::new::<crate::list::RadrootsList>(),
diff --git a/crates/events/src/knowledge.rs b/crates/events/src/knowledge.rs
@@ -134,8 +134,17 @@ pub struct RadrootsWikiArticle {
pub summary: Option<String>,
pub topics: Vec<String>,
pub references: Vec<RadrootsNostrEventRef>,
- pub forked_from: Vec<RadrootsNostrEventRef>,
- pub deferred_to: Option<RadrootsNostrEventRef>,
+ pub forked_from: Vec<RadrootsWikiArticleVersionRef>,
+ pub deferred_to: Option<RadrootsWikiArticleVersionRef>,
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
+#[cfg_attr(feature = "dto-bindgen", dto(export))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsWikiArticleVersionRef {
+ pub event_id: String,
+ pub address_ref: RadrootsAddressableRef,
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
@@ -144,7 +153,7 @@ pub struct RadrootsWikiArticle {
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RadrootsWikiRedirect {
pub d_tag: String,
- pub target: RadrootsNostrEventRef,
+ pub target: RadrootsAddressableRef,
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
diff --git a/crates/events_codec/src/knowledge/decode.rs b/crates/events_codec/src/knowledge/decode.rs
@@ -18,10 +18,10 @@ use radroots_events::knowledge::{
RADROOTS_KNOWLEDGE_SOURCE_SCHEMA, RadrootsAddressableRef, RadrootsContributionAttestation,
RadrootsEvidenceBounty, RadrootsKnowledgeChangeProposal, RadrootsKnowledgeClaim,
RadrootsKnowledgeFieldReport, RadrootsKnowledgeRelation, RadrootsKnowledgeReview,
- RadrootsKnowledgeSource, RadrootsWikiArticle, RadrootsWikiMergeRequest, RadrootsWikiRedirect,
- validate_wiki_d_tag,
+ RadrootsKnowledgeSource, RadrootsWikiArticle, RadrootsWikiArticleVersionRef,
+ RadrootsWikiMergeRequest, RadrootsWikiRedirect, validate_wiki_d_tag,
};
-use radroots_events::tags::{TAG_A, TAG_CONTRACT, TAG_D, TAG_E, TAG_SUMMARY, TAG_T};
+use radroots_events::tags::{TAG_A, TAG_CONTRACT, TAG_D, TAG_E, TAG_P, TAG_SUMMARY, TAG_T};
use radroots_events::{RadrootsNostrEvent, RadrootsNostrEventRef};
use serde::de::DeserializeOwned;
@@ -32,8 +32,8 @@ use crate::parsed::{RadrootsParsedData, RadrootsParsedEvent};
const TAG_TITLE: &str = "title";
const TAG_SOURCE: &str = "source";
const TAG_REVIEW_TARGET: &str = "review_target";
-const TAG_FORK: &str = "fork";
-const TAG_DEFERRED_TO: &str = "deferred_to";
+const MARKER_FORK: &str = "fork";
+const MARKER_DEFER: &str = "defer";
const E_MARKER_SOURCE: &str = "source";
fn ensure_kind(kind: u32, expected: u32, name: &'static str) -> Result<(), EventParseError> {
@@ -102,28 +102,23 @@ fn event_refs(
.collect()
}
-fn first_event_ref(
- tags: &[Vec<String>],
- name: &'static str,
-) -> Result<Option<RadrootsNostrEventRef>, EventParseError> {
- let matches = matching_tags(tags, name);
- if matches.len() > 1 {
- return Err(EventParseError::InvalidTag(name));
- }
- matches
- .first()
- .map(|tag| parse_event_ref_tag(tag, name))
- .transpose()
+fn marker(tag: &[String]) -> Option<&str> {
+ tag.last()
+ .map(|value| value.as_str())
+ .filter(|value| matches!(*value, MARKER_FORK | MARKER_DEFER | E_MARKER_SOURCE))
}
-fn address_from_a_tag(
- tags: &[Vec<String>],
+fn unmarked_tags<'a>(tags: &'a [Vec<String>], name: &'static str) -> Vec<&'a Vec<String>> {
+ matching_tags(tags, name)
+ .into_iter()
+ .filter(|tag| marker(tag).is_none())
+ .collect()
+}
+
+fn address_from_tag(
+ tag: &[String],
name: &'static str,
) -> Result<RadrootsAddressableRef, EventParseError> {
- let tag = matching_tags(tags, name)
- .into_iter()
- .next()
- .ok_or(EventParseError::MissingTag(name))?;
let value = tag.get(1).ok_or(EventParseError::InvalidTag(name))?;
let mut parts = value.splitn(3, ':');
let kind = parts
@@ -139,33 +134,107 @@ fn address_from_a_tag(
.next()
.filter(|value| !value.trim().is_empty())
.ok_or(EventParseError::InvalidTag(name))?;
+ let relays_end = if marker(tag).is_some() {
+ tag.len().saturating_sub(1)
+ } else {
+ tag.len()
+ };
Ok(RadrootsAddressableRef {
kind,
pubkey: pubkey.to_string(),
d_tag: d_tag.to_string(),
relays: tag
- .get(2..)
- .map(|values| values.to_vec())
+ .get(2..relays_end)
+ .map(|values| {
+ values
+ .iter()
+ .filter(|value| !value.is_empty())
+ .cloned()
+ .collect()
+ })
.unwrap_or_default(),
})
}
-fn event_ref_from_a_tag(
+fn address_from_a_tag(
tags: &[Vec<String>],
name: &'static str,
-) -> Result<RadrootsNostrEventRef, EventParseError> {
- let address = address_from_a_tag(tags, name)?;
- Ok(RadrootsNostrEventRef {
- id: String::new(),
- author: address.pubkey,
- kind: address.kind,
- d_tag: Some(address.d_tag),
- relays: if address.relays.is_empty() {
- None
- } else {
- Some(address.relays)
- },
- })
+) -> Result<RadrootsAddressableRef, EventParseError> {
+ let matches = unmarked_tags(tags, name);
+ if matches.is_empty() {
+ return Err(EventParseError::MissingTag(name));
+ }
+ if matches.len() > 1 {
+ return Err(EventParseError::InvalidTag(name));
+ }
+ address_from_tag(matches[0], name)
+}
+
+fn wiki_version_refs(
+ tags: &[Vec<String>],
+ marker_name: &'static str,
+) -> Result<Vec<RadrootsWikiArticleVersionRef>, EventParseError> {
+ let address_tags = matching_tags(tags, TAG_A)
+ .into_iter()
+ .filter(|tag| marker(tag) == Some(marker_name))
+ .collect::<Vec<_>>();
+ let event_tags = matching_tags(tags, TAG_E)
+ .into_iter()
+ .filter(|tag| marker(tag) == Some(marker_name))
+ .collect::<Vec<_>>();
+ if address_tags.len() != event_tags.len() {
+ return Err(EventParseError::InvalidTag(TAG_A));
+ }
+ address_tags
+ .into_iter()
+ .zip(event_tags)
+ .map(|(address_tag, event_tag)| {
+ let address_ref = address_from_tag(address_tag, TAG_A)?;
+ if address_ref.kind != KIND_WIKI_ARTICLE {
+ return Err(EventParseError::InvalidTag(TAG_A));
+ }
+ let event_id = event_tag
+ .get(1)
+ .filter(|value| !value.trim().is_empty())
+ .ok_or(EventParseError::InvalidTag(TAG_E))?
+ .clone();
+ Ok(RadrootsWikiArticleVersionRef {
+ event_id,
+ address_ref,
+ })
+ })
+ .collect()
+}
+
+fn wiki_merge_source_event_id(tags: &[Vec<String>]) -> Result<String, EventParseError> {
+ let source_tags = matching_tags(tags, TAG_E)
+ .into_iter()
+ .filter(|tag| marker(tag) == Some(E_MARKER_SOURCE))
+ .collect::<Vec<_>>();
+ if source_tags.len() != 1 {
+ return Err(EventParseError::InvalidTag(TAG_E));
+ }
+ source_tags[0]
+ .get(1)
+ .filter(|value| !value.trim().is_empty())
+ .cloned()
+ .ok_or(EventParseError::InvalidTag(TAG_E))
+}
+
+fn wiki_merge_base_event_id(tags: &[Vec<String>]) -> Result<Option<String>, EventParseError> {
+ let base_tags = unmarked_tags(tags, TAG_E);
+ if base_tags.len() > 1 {
+ return Err(EventParseError::InvalidTag(TAG_E));
+ }
+ base_tags
+ .first()
+ .map(|tag| {
+ tag.get(1)
+ .filter(|value| !value.trim().is_empty())
+ .cloned()
+ .ok_or(EventParseError::InvalidTag(TAG_E))
+ })
+ .transpose()
}
fn json_content<T: DeserializeOwned>(content: &str) -> Result<T, EventParseError> {
@@ -209,22 +278,6 @@ fn require_schema(
Ok(())
}
-fn require_source_marker(
- tags: &[Vec<String>],
- source_event_id: &str,
-) -> Result<(), EventParseError> {
- let has_source = tags.iter().any(|tag| {
- tag.first().map(|value| value.as_str()) == Some(TAG_E)
- && tag.get(1).map(|value| value.as_str()) == Some(source_event_id)
- && tag.iter().skip(2).any(|value| value == E_MARKER_SOURCE)
- });
- if has_source {
- Ok(())
- } else {
- Err(EventParseError::InvalidTag(TAG_E))
- }
-}
-
fn reject_private_coordinate_keys(content: &str) -> Result<(), EventParseError> {
let value: serde_json::Value =
serde_json::from_str(content).map_err(|_| EventParseError::InvalidJson("content"))?;
@@ -265,8 +318,12 @@ pub fn wiki_article_from_event(
let summary = optional_one_value(&event.tags, TAG_SUMMARY)?;
let topics = values(&event.tags, TAG_T);
let references = event_refs(&event.tags, TAG_SOURCE)?;
- let forked_from = event_refs(&event.tags, TAG_FORK)?;
- let deferred_to = first_event_ref(&event.tags, TAG_DEFERRED_TO)?;
+ let forked_from = wiki_version_refs(&event.tags, MARKER_FORK)?;
+ let mut deferred_refs = wiki_version_refs(&event.tags, MARKER_DEFER)?;
+ if deferred_refs.len() > 1 {
+ return Err(EventParseError::InvalidTag(TAG_A));
+ }
+ let deferred_to = deferred_refs.pop();
let article = RadrootsWikiArticle {
d_tag,
title,
@@ -289,7 +346,10 @@ pub fn wiki_redirect_from_event(
}
let d_tag = required_one_value(&event.tags, TAG_D)?;
validate_wiki_d_tag(&d_tag).map_err(|_| EventParseError::InvalidTag(TAG_D))?;
- let target = event_ref_from_a_tag(&event.tags, TAG_A)?;
+ let target = address_from_a_tag(&event.tags, TAG_A)?;
+ if target.kind != KIND_WIKI_ARTICLE {
+ return Err(EventParseError::InvalidTag(TAG_A));
+ }
Ok(parsed(event, RadrootsWikiRedirect { d_tag, target }))
}
@@ -297,10 +357,25 @@ pub fn wiki_merge_request_from_event(
event: RadrootsNostrEvent,
) -> Result<RadrootsParsedEvent<RadrootsWikiMergeRequest>, EventParseError> {
ensure_kind(event.kind, KIND_WIKI_MERGE_REQUEST, "wiki merge request")?;
- let request: RadrootsWikiMergeRequest = json_content(&event.content)?;
- address_from_a_tag(&event.tags, TAG_A)?;
- required_one_value(&event.tags, "p")?;
- require_source_marker(&event.tags, &request.source_version_event_id)?;
+ let target_article = address_from_a_tag(&event.tags, TAG_A)?;
+ if target_article.kind != KIND_WIKI_ARTICLE {
+ return Err(EventParseError::InvalidTag(TAG_A));
+ }
+ let destination_pubkey = required_one_value(&event.tags, TAG_P)?;
+ let base_version_event_id = wiki_merge_base_event_id(&event.tags)?;
+ let source_version_event_id = wiki_merge_source_event_id(&event.tags)?;
+ let explanation = if event.content.is_empty() {
+ None
+ } else {
+ Some(event.content.clone())
+ };
+ let request = RadrootsWikiMergeRequest {
+ target_article,
+ destination_pubkey,
+ base_version_event_id,
+ source_version_event_id,
+ explanation,
+ };
Ok(parsed(event, request))
}
diff --git a/crates/events_codec/src/knowledge/encode.rs b/crates/events_codec/src/knowledge/encode.rs
@@ -22,7 +22,8 @@ use radroots_events::knowledge::{
RadrootsEvidenceBounty, RadrootsKnowledgeChangeProposal, RadrootsKnowledgeClaim,
RadrootsKnowledgeFieldReport, RadrootsKnowledgeRelation, RadrootsKnowledgeReview,
RadrootsKnowledgeReviewTarget, RadrootsKnowledgeSource, RadrootsWikiArticle,
- RadrootsWikiMergeRequest, RadrootsWikiRedirect, validate_wiki_d_tag,
+ RadrootsWikiArticleVersionRef, RadrootsWikiMergeRequest, RadrootsWikiRedirect,
+ validate_wiki_d_tag,
};
use radroots_events::tags::{TAG_A, TAG_CONTRACT, TAG_D, TAG_E, TAG_G, TAG_P, TAG_SUMMARY, TAG_T};
use serde::Serialize;
@@ -36,10 +37,9 @@ const TAG_SOURCE: &str = "source";
const TAG_CITATION: &str = "citation";
const TAG_REVIEW_TARGET: &str = "review_target";
const TAG_EVIDENCE: &str = "evidence";
-const TAG_FORK: &str = "fork";
-const TAG_DEFERRED_TO: &str = "deferred_to";
+const MARKER_FORK: &str = "fork";
+const MARKER_DEFER: &str = "defer";
const E_MARKER_SOURCE: &str = "source";
-const E_MARKER_BASE: &str = "base";
fn push_value(tags: &mut Vec<Vec<String>>, key: &str, value: &str) {
if !value.trim().is_empty() {
@@ -77,22 +77,42 @@ fn address_tag(tag_name: &str, address: &RadrootsAddressableRef) -> Vec<String>
tag
}
-fn event_ref_address_tag(
- tag_name: &str,
- event_ref: &RadrootsNostrEventRef,
-) -> Result<Vec<String>, EventEncodeError> {
- let d_tag = event_ref
- .d_tag
- .as_ref()
- .filter(|value| !value.trim().is_empty())
- .ok_or(EventEncodeError::EmptyRequiredField("target.d_tag"))?;
- let mut tag = Vec::with_capacity(2 + event_ref.relays.as_ref().map(|v| v.len()).unwrap_or(0));
- tag.push(tag_name.to_string());
- tag.push(format!("{}:{}:{}", event_ref.kind, event_ref.author, d_tag));
- if let Some(relays) = &event_ref.relays {
- tag.extend(relays.iter().cloned());
+fn marker_address_tag(address: &RadrootsAddressableRef, marker: &'static str) -> Vec<String> {
+ let mut tag = Vec::with_capacity(4 + address.relays.len());
+ tag.push(TAG_A.to_string());
+ tag.push(address_coordinate(address));
+ if address.relays.is_empty() {
+ tag.push(String::new());
+ } else {
+ tag.extend(address.relays.iter().cloned());
+ }
+ tag.push(marker.to_string());
+ tag
+}
+
+fn marker_event_tag(
+ version_ref: &RadrootsWikiArticleVersionRef,
+ marker: &'static str,
+) -> Vec<String> {
+ let mut tag = Vec::with_capacity(4 + version_ref.address_ref.relays.len());
+ tag.push(TAG_E.to_string());
+ tag.push(version_ref.event_id.clone());
+ if version_ref.address_ref.relays.is_empty() {
+ tag.push(String::new());
+ } else {
+ tag.extend(version_ref.address_ref.relays.iter().cloned());
}
- Ok(tag)
+ tag.push(marker.to_string());
+ tag
+}
+
+fn push_wiki_version_ref_tags(
+ tags: &mut Vec<Vec<String>>,
+ version_ref: &RadrootsWikiArticleVersionRef,
+ marker: &'static str,
+) {
+ tags.push(marker_address_tag(&version_ref.address_ref, marker));
+ tags.push(marker_event_tag(version_ref, marker));
}
fn review_target_ref(target: &RadrootsKnowledgeReviewTarget) -> RadrootsNostrEventRef {
@@ -147,9 +167,11 @@ pub fn wiki_article_build_tags(
push_optional_value(&mut tags, TAG_SUMMARY, article.summary.as_deref());
push_topics(&mut tags, &article.topics);
push_event_refs(&mut tags, TAG_SOURCE, &article.references);
- push_event_refs(&mut tags, TAG_FORK, &article.forked_from);
+ for forked_from in &article.forked_from {
+ push_wiki_version_ref_tags(&mut tags, forked_from, MARKER_FORK);
+ }
if let Some(deferred_to) = &article.deferred_to {
- tags.push(build_event_ref_tag(TAG_DEFERRED_TO, deferred_to));
+ push_wiki_version_ref_tags(&mut tags, deferred_to, MARKER_DEFER);
}
Ok(tags)
}
@@ -170,7 +192,7 @@ pub fn wiki_redirect_build_tags(
validate_wiki_d_tag(&redirect.d_tag).map_err(|_| EventEncodeError::InvalidField("d_tag"))?;
let mut tags = Vec::new();
tags.push(vec![TAG_D.to_string(), redirect.d_tag.clone()]);
- tags.push(event_ref_address_tag(TAG_A, &redirect.target)?);
+ tags.push(address_tag(TAG_A, &redirect.target));
Ok(tags)
}
@@ -203,12 +225,7 @@ pub fn wiki_merge_request_build_tags(
.as_ref()
.filter(|value| !value.trim().is_empty())
{
- tags.push(vec![
- TAG_E.to_string(),
- base.clone(),
- String::new(),
- E_MARKER_BASE.to_string(),
- ]);
+ tags.push(vec![TAG_E.to_string(), base.clone(), String::new()]);
}
tags.push(vec![
TAG_E.to_string(),
@@ -224,7 +241,7 @@ pub fn wiki_merge_request_to_wire_parts(
) -> Result<WireEventParts, EventEncodeError> {
Ok(WireEventParts {
kind: KIND_WIKI_MERGE_REQUEST,
- content: json_content(request)?,
+ content: request.explanation.clone().unwrap_or_default(),
tags: wiki_merge_request_build_tags(request)?,
})
}
diff --git a/crates/events_codec/tests/knowledge.rs b/crates/events_codec/tests/knowledge.rs
@@ -17,8 +17,9 @@ use radroots_events::knowledge::{
RadrootsKnowledgeObservation, RadrootsKnowledgeObservationValue, RadrootsKnowledgeRelation,
RadrootsKnowledgeReview, RadrootsKnowledgeReviewScope, RadrootsKnowledgeReviewScore,
RadrootsKnowledgeReviewTarget, RadrootsKnowledgeSource, RadrootsWikiArticle,
- RadrootsWikiMergeRequest, RadrootsWikiRedirect,
+ RadrootsWikiArticleVersionRef, RadrootsWikiMergeRequest, RadrootsWikiRedirect,
};
+use radroots_events_codec::error::EventParseError;
use radroots_events_codec::knowledge::{
contribution_attestation_from_event, contribution_attestation_to_wire_parts,
evidence_bounty_from_event, evidence_bounty_to_wire_parts,
@@ -50,16 +51,6 @@ fn event_ref(character: char, kind: u32) -> radroots_events::RadrootsNostrEventR
}
}
-fn article_ref() -> radroots_events::RadrootsNostrEventRef {
- radroots_events::RadrootsNostrEventRef {
- id: hex_64('b'),
- author: hex_64('a'),
- kind: KIND_WIKI_ARTICLE,
- d_tag: Some("soil-health".to_string()),
- relays: Some(vec!["wss://relay.radroots.example".to_string()]),
- }
-}
-
fn address_ref() -> RadrootsAddressableRef {
RadrootsAddressableRef {
kind: KIND_WIKI_ARTICLE,
@@ -69,6 +60,13 @@ fn address_ref() -> RadrootsAddressableRef {
}
}
+fn article_version_ref() -> RadrootsWikiArticleVersionRef {
+ RadrootsWikiArticleVersionRef {
+ event_id: hex_64('b'),
+ address_ref: address_ref(),
+ }
+}
+
fn event_from_parts(parts: WireEventParts) -> RadrootsNostrEvent {
RadrootsNostrEvent {
id: hex_64('0'),
@@ -81,6 +79,26 @@ fn event_from_parts(parts: WireEventParts) -> RadrootsNostrEvent {
}
}
+fn assert_parse_error(actual: EventParseError, expected: EventParseError) {
+ match (actual, expected) {
+ (EventParseError::MissingTag(actual), EventParseError::MissingTag(expected))
+ | (EventParseError::InvalidTag(actual), EventParseError::InvalidTag(expected)) => {
+ assert_eq!(actual, expected);
+ }
+ (
+ EventParseError::InvalidKind {
+ expected: actual_expected,
+ got: actual_got,
+ },
+ EventParseError::InvalidKind { expected, got },
+ ) => {
+ assert_eq!(actual_expected, expected);
+ assert_eq!(actual_got, got);
+ }
+ (actual, expected) => panic!("expected {expected:?}, got {actual:?}"),
+ }
+}
+
fn sign_parts(parts: WireEventParts) -> RadrootsNostrEvent {
let tags = parts
.tags
@@ -120,8 +138,8 @@ fn wiki_article() -> RadrootsWikiArticle {
summary: Some("Living soil basics".to_string()),
topics: vec!["soil".to_string(), "health".to_string()],
references: vec![event_ref('2', KIND_KNOWLEDGE_SOURCE)],
- forked_from: Vec::new(),
- deferred_to: None,
+ forked_from: vec![article_version_ref()],
+ deferred_to: Some(article_version_ref()),
}
}
@@ -252,6 +270,34 @@ fn field_report() -> RadrootsKnowledgeFieldReport {
fn knowledge_codecs_roundtrip_all_contracts() {
let article_event = event_from_parts(wiki_article_to_wire_parts(&wiki_article()).unwrap());
validate_event_contract_shape(&article_event, "radroots.wiki.article.v1").unwrap();
+ assert!(article_event.tags.iter().any(|tag| tag
+ == &vec![
+ "a".to_string(),
+ format!("30818:{}:soil-health", hex_64('a')),
+ "wss://relay.radroots.example".to_string(),
+ "fork".to_string()
+ ]));
+ assert!(article_event.tags.iter().any(|tag| tag
+ == &vec![
+ "e".to_string(),
+ hex_64('b'),
+ "wss://relay.radroots.example".to_string(),
+ "fork".to_string()
+ ]));
+ assert!(article_event.tags.iter().any(|tag| tag
+ == &vec![
+ "a".to_string(),
+ format!("30818:{}:soil-health", hex_64('a')),
+ "wss://relay.radroots.example".to_string(),
+ "defer".to_string()
+ ]));
+ assert!(article_event.tags.iter().any(|tag| tag
+ == &vec![
+ "e".to_string(),
+ hex_64('b'),
+ "wss://relay.radroots.example".to_string(),
+ "defer".to_string()
+ ]));
assert_eq!(
wiki_article_from_event(article_event)
.unwrap()
@@ -263,10 +309,16 @@ fn knowledge_codecs_roundtrip_all_contracts() {
let redirect = RadrootsWikiRedirect {
d_tag: "soil".to_string(),
- target: article_ref(),
+ target: address_ref(),
};
let redirect_event = event_from_parts(wiki_redirect_to_wire_parts(&redirect).unwrap());
validate_event_contract_shape(&redirect_event, "radroots.wiki.redirect.v1").unwrap();
+ assert!(redirect_event.tags.iter().any(|tag| tag
+ == &vec![
+ "a".to_string(),
+ format!("30818:{}:soil-health", hex_64('a')),
+ "wss://relay.radroots.example".to_string()
+ ]));
assert_eq!(
wiki_redirect_from_event(redirect_event)
.unwrap()
@@ -274,7 +326,7 @@ fn knowledge_codecs_roundtrip_all_contracts() {
.data
.target
.d_tag,
- Some("soil-health".to_string())
+ "soil-health"
);
let merge = RadrootsWikiMergeRequest {
@@ -286,6 +338,20 @@ fn knowledge_codecs_roundtrip_all_contracts() {
};
let merge_event = event_from_parts(wiki_merge_request_to_wire_parts(&merge).unwrap());
validate_event_contract_shape(&merge_event, "radroots.wiki.merge_request.v1").unwrap();
+ assert_eq!(merge_event.content, "Merge synthetic source");
+ assert!(
+ merge_event
+ .tags
+ .iter()
+ .any(|tag| tag == &vec!["e".to_string(), hex_64('e'), String::new()])
+ );
+ assert!(merge_event.tags.iter().any(|tag| tag
+ == &vec![
+ "e".to_string(),
+ hex_64('f'),
+ String::new(),
+ "source".to_string()
+ ]));
assert_eq!(
wiki_merge_request_from_event(merge_event)
.unwrap()
@@ -457,3 +523,122 @@ fn malformed_knowledge_events_return_stable_decode_codes() {
let parsed_error = knowledge_field_report_from_event(report).unwrap_err();
assert_eq!(parsed_error.code(), "invalid_json");
}
+
+#[test]
+fn malformed_nip54_wiki_shapes_are_rejected() {
+ let mut redirect = event_from_parts(
+ wiki_redirect_to_wire_parts(&RadrootsWikiRedirect {
+ d_tag: "soil".to_string(),
+ target: address_ref(),
+ })
+ .unwrap(),
+ );
+ for tag in &mut redirect.tags {
+ if tag.first().map(|value| value.as_str()) == Some("a") {
+ tag[1] = format!("30023:{}:soil-health", hex_64('a'));
+ }
+ }
+ assert_parse_error(
+ wiki_redirect_from_event(redirect).unwrap_err(),
+ EventParseError::InvalidTag("a"),
+ );
+
+ let merge = RadrootsWikiMergeRequest {
+ target_article: address_ref(),
+ destination_pubkey: hex_64('a'),
+ base_version_event_id: Some(hex_64('e')),
+ source_version_event_id: hex_64('f'),
+ explanation: Some("Merge synthetic source".to_string()),
+ };
+ let mut missing_target = event_from_parts(wiki_merge_request_to_wire_parts(&merge).unwrap());
+ missing_target
+ .tags
+ .retain(|tag| tag.first().map(|value| value.as_str()) != Some("a"));
+ assert_parse_error(
+ wiki_merge_request_from_event(missing_target).unwrap_err(),
+ EventParseError::MissingTag("a"),
+ );
+
+ let mut missing_destination =
+ event_from_parts(wiki_merge_request_to_wire_parts(&merge).unwrap());
+ missing_destination
+ .tags
+ .retain(|tag| tag.first().map(|value| value.as_str()) != Some("p"));
+ assert_parse_error(
+ wiki_merge_request_from_event(missing_destination).unwrap_err(),
+ EventParseError::MissingTag("p"),
+ );
+
+ let mut missing_source = event_from_parts(wiki_merge_request_to_wire_parts(&merge).unwrap());
+ missing_source.tags.retain(|tag| {
+ !(tag.first().map(|value| value.as_str()) == Some("e")
+ && tag.last().map(|value| value.as_str()) == Some("source"))
+ });
+ assert_parse_error(
+ wiki_merge_request_from_event(missing_source).unwrap_err(),
+ EventParseError::InvalidTag("e"),
+ );
+
+ let mut duplicate_source = event_from_parts(wiki_merge_request_to_wire_parts(&merge).unwrap());
+ duplicate_source.tags.push(vec![
+ "e".to_string(),
+ hex_64('a'),
+ String::new(),
+ "source".to_string(),
+ ]);
+ assert_parse_error(
+ wiki_merge_request_from_event(duplicate_source).unwrap_err(),
+ EventParseError::InvalidTag("e"),
+ );
+
+ let mut wrong_merge_target =
+ event_from_parts(wiki_merge_request_to_wire_parts(&merge).unwrap());
+ for tag in &mut wrong_merge_target.tags {
+ if tag.first().map(|value| value.as_str()) == Some("a") {
+ tag[1] = format!("30023:{}:soil-health", hex_64('a'));
+ }
+ }
+ assert_parse_error(
+ wiki_merge_request_from_event(wrong_merge_target).unwrap_err(),
+ EventParseError::InvalidTag("a"),
+ );
+
+ let mut orphan_fork = event_from_parts(wiki_article_to_wire_parts(&wiki_article()).unwrap());
+ let mut removed_fork_event = false;
+ orphan_fork.tags.retain(|tag| {
+ if !removed_fork_event
+ && tag.first().map(|value| value.as_str()) == Some("e")
+ && tag.last().map(|value| value.as_str()) == Some("fork")
+ {
+ removed_fork_event = true;
+ false
+ } else {
+ true
+ }
+ });
+ assert_parse_error(
+ wiki_article_from_event(orphan_fork).unwrap_err(),
+ EventParseError::InvalidTag("a"),
+ );
+
+ let mut duplicate_defer =
+ event_from_parts(wiki_article_to_wire_parts(&wiki_article()).unwrap());
+ duplicate_defer.tags.extend([
+ vec![
+ "a".to_string(),
+ format!("30818:{}:compost", hex_64('a')),
+ String::new(),
+ "defer".to_string(),
+ ],
+ vec![
+ "e".to_string(),
+ hex_64('c'),
+ String::new(),
+ "defer".to_string(),
+ ],
+ ]);
+ assert_parse_error(
+ wiki_article_from_event(duplicate_defer).unwrap_err(),
+ EventParseError::InvalidTag("a"),
+ );
+}
diff --git a/crates/test_fixtures/src/knowledge.rs b/crates/test_fixtures/src/knowledge.rs
@@ -17,7 +17,7 @@ use radroots_events::knowledge::{
RadrootsKnowledgeObservation, RadrootsKnowledgeObservationValue, RadrootsKnowledgeRelation,
RadrootsKnowledgeReview, RadrootsKnowledgeReviewScope, RadrootsKnowledgeReviewScore,
RadrootsKnowledgeReviewTarget, RadrootsKnowledgeSource, RadrootsWikiArticle,
- RadrootsWikiMergeRequest, RadrootsWikiRedirect,
+ RadrootsWikiArticleVersionRef, RadrootsWikiMergeRequest, RadrootsWikiRedirect,
};
use crate::RELAY_PRIMARY_WSS;
@@ -144,6 +144,13 @@ pub fn address_ref() -> RadrootsAddressableRef {
}
}
+pub fn wiki_article_version_ref() -> RadrootsWikiArticleVersionRef {
+ RadrootsWikiArticleVersionRef {
+ event_id: hex_64('b'),
+ address_ref: address_ref(),
+ }
+}
+
pub fn wiki_article() -> RadrootsWikiArticle {
RadrootsWikiArticle {
d_tag: "soil-health".to_string(),
@@ -161,7 +168,7 @@ pub fn wiki_article() -> RadrootsWikiArticle {
pub fn wiki_redirect() -> RadrootsWikiRedirect {
RadrootsWikiRedirect {
d_tag: "soil".to_string(),
- target: wiki_article_ref(),
+ target: address_ref(),
}
}