lib

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

commit f2f14a5901d602adf08526cf0777ec1cafb641df
parent c0da3c5ae7b45a671a0df16c1789d82aa89f7afe
Author: triesap <tyson@radroots.org>
Date:   Tue, 14 Jul 2026 09:51:27 +0000

transport: require raw publish event JSON

- replace transport publish event structs with raw JSON ingress
- remove stale event kind narrowing from publish protocol surfaces
- align replica sync with sealed event envelopes and u64 event-head timestamps
- update transport fetch expectations for ID-verified raw wire ingestion

Diffstat:
Mcrates/replica_schema/src/models/nostr_event_head.rs | 6+++---
Mcrates/replica_sync/src/emit.rs | 10+++++-----
Mcrates/replica_sync/src/ingest.rs | 263+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Mcrates/replica_sync/tests/ingest_roundtrip.rs | 49++++++++++++++++++++++++-------------------------
Mcrates/transport_nostr/tests/transport.rs | 9+++------
Mcrates/transport_publish_protocol/src/lib.rs | 150+++++++++++++++++++++----------------------------------------------------------
6 files changed, 237 insertions(+), 250 deletions(-)

diff --git a/crates/replica_schema/src/models/nostr_event_head.rs b/crates/replica_schema/src/models/nostr_event_head.rs @@ -12,7 +12,7 @@ pub struct NostrEventHead { pub pubkey: String, pub d_tag: String, pub last_event_id: String, - pub last_created_at: u32, + pub last_created_at: u64, pub content_hash: String, } @@ -23,7 +23,7 @@ pub struct INostrEventHeadFields { pub pubkey: String, pub d_tag: String, pub last_event_id: String, - pub last_created_at: u32, + pub last_created_at: u64, pub content_hash: String, } @@ -48,7 +48,7 @@ pub struct INostrEventHeadFieldsFilter { pub pubkey: Option<String>, pub d_tag: Option<String>, pub last_event_id: Option<String>, - pub last_created_at: Option<u32>, + pub last_created_at: Option<u64>, pub content_hash: Option<String>, } diff --git a/crates/replica_sync/src/emit.rs b/crates/replica_sync/src/emit.rs @@ -18,11 +18,11 @@ use radroots_event::profile::{ RADROOTS_PROFILE_TYPE_TAG_KEY, RadrootsProfile, RadrootsProfileType, radroots_profile_type_from_tag_value, radroots_profile_type_tag_value, }; +use radroots_event::wire::RadrootsNip01EventWireParts; use radroots_event_codec::farm::encode as farm_encode; use radroots_event_codec::farm::list_sets as farm_list_sets; use radroots_event_codec::list_set::encode as list_set_encode; use radroots_event_codec::plot::encode as plot_encode; -use radroots_event_codec::wire::WireEventParts; use radroots_replica_schema::farm::{ Farm, IFarmFieldsFilter, IFarmFindMany, IFarmFindOne, IFarmFindOneArgs, }; @@ -185,7 +185,7 @@ pub fn radroots_replica_farm_event( }; let tags = farm_encode::farm_build_tags(&farm_event)?; let content = canonical_json_string(&farm_event)?; - let parts = WireEventParts { + let parts = RadrootsNip01EventWireParts { kind: KIND_FARM, content, tags, @@ -215,7 +215,7 @@ pub fn radroots_replica_plot_events( }; let tags = plot_encode::plot_build_tags(&plot_event)?; let content = canonical_json_string(&plot_event)?; - let parts = WireEventParts { + let parts = RadrootsNip01EventWireParts { kind: KIND_PLOT, content, tags, @@ -659,7 +659,7 @@ fn compare_relation_rows(a: &RelationRow, b: &RelationRow) -> core::cmp::Orderin fn list_set_to_wire_parts( list_set: &radroots_event::list_set::RadrootsListSet, -) -> Result<WireEventParts, RadrootsReplicaEventsError> { +) -> Result<RadrootsNip01EventWireParts, RadrootsReplicaEventsError> { #[cfg(test)] if failpoints::take_list_set_to_wire_error() { return Err(RadrootsReplicaEventsError::InvalidData( @@ -907,7 +907,7 @@ fn load_member_claims_for_member( Ok(result.results) } -fn parts_to_draft(author: &str, parts: WireEventParts) -> RadrootsReplicaEventDraft { +fn parts_to_draft(author: &str, parts: RadrootsNip01EventWireParts) -> RadrootsReplicaEventDraft { RadrootsReplicaEventDraft { kind: parts.kind, author: author.to_string(), diff --git a/crates/replica_sync/src/ingest.rs b/crates/replica_sync/src/ingest.rs @@ -13,6 +13,8 @@ use base64::engine::general_purpose::URL_SAFE_NO_PAD; use radroots_core::RadrootsCoreDecimal; use radroots_event::RadrootsEventEnvelope; +#[cfg(test)] +use radroots_event::RadrootsEventEnvelopeParts; use radroots_event::event_head::{ RadrootsCurrentEventHead, RadrootsEventHeadCandidateResult, RadrootsEventHeadCoordinate, RadrootsEventHeadDecision as ProtocolEventHeadDecision, event_head_candidate_for_event, @@ -192,7 +194,7 @@ fn ingest_event_inner( event: &RadrootsEventEnvelope, factory: &dyn RadrootsReplicaIdFactory, ) -> Result<RadrootsReplicaIngestOutcome, RadrootsReplicaEventsError> { - match event.kind { + match event.kind_u32() { KIND_PROFILE => ingest_profile_event(exec, event), KIND_FARM => ingest_farm_event(exec, event, factory), KIND_PLOT => ingest_plot_event(exec, event, factory), @@ -200,7 +202,7 @@ fn ingest_event_inner( kind if is_nip51_list_set_kind(kind) => ingest_list_set_event(exec, event), _ => Err(RadrootsReplicaEventsError::InvalidData(format!( "unsupported kind {}", - event.kind + event.kind_u32() ))), } } @@ -210,12 +212,12 @@ fn ingest_profile_event( event: &RadrootsEventEnvelope, ) -> Result<RadrootsReplicaIngestOutcome, RadrootsReplicaEventsError> { let data_result = profile_decode::data_from_event( - event.id.clone(), - event.author.clone(), - event.created_at, - event.kind, - event.content.clone(), - event.tags.clone(), + event.id_str().to_owned(), + event.author_str().to_owned(), + event.created_at_u64(), + event.kind_u32(), + event.content().to_owned(), + event.tags_as_vec(), ); let data = data_result?; let profile_type = match data.data.profile_type { @@ -301,7 +303,8 @@ fn ingest_farm_event( event: &RadrootsEventEnvelope, _factory: &dyn RadrootsReplicaIdFactory, ) -> Result<RadrootsReplicaIngestOutcome, RadrootsReplicaEventsError> { - let farm = farm_decode::farm_from_event(event.kind, &event.tags, &event.content)?; + let farm = + farm_decode::farm_from_event(event.kind_u32(), &event.tags_as_vec(), event.content())?; let decision = event_head_decision(exec, event)?; if !decision.apply { return Ok(RadrootsReplicaIngestOutcome::Skipped); @@ -312,7 +315,7 @@ fn ingest_farm_event( created_at: None, updated_at: None, d_tag: Some(farm.d_tag.clone()), - pubkey: Some(event.author.clone()), + pubkey: Some(event.author_str().to_owned()), name: None, about: None, website: None, @@ -336,7 +339,7 @@ fn ingest_farm_event( let farm_id = if let Some(row) = existing.results.first() { let fields = IFarmFieldsPartial { d_tag: Some(Value::from(farm.d_tag.clone())), - pubkey: Some(Value::from(event.author.clone())), + pubkey: Some(Value::from(event.author_str().to_owned())), name: Some(Value::from(farm.name.clone())), about: to_value_opt(farm.about.clone()), website: to_value_opt(farm.website.clone()), @@ -359,7 +362,7 @@ fn ingest_farm_event( } else { let fields = IFarmFields { d_tag: farm.d_tag.clone(), - pubkey: event.author.clone(), + pubkey: event.author_str().to_owned(), name: farm.name.clone(), about: farm.about.clone(), website: farm.website.clone(), @@ -384,7 +387,8 @@ fn ingest_plot_event( event: &RadrootsEventEnvelope, factory: &dyn RadrootsReplicaIdFactory, ) -> Result<RadrootsReplicaIngestOutcome, RadrootsReplicaEventsError> { - let plot = plot_decode::plot_from_event(event.kind, &event.tags, &event.content)?; + let plot = + plot_decode::plot_from_event(event.kind_u32(), &event.tags_as_vec(), event.content())?; let decision = event_head_decision(exec, event)?; if !decision.apply { return Ok(RadrootsReplicaIngestOutcome::Skipped); @@ -459,7 +463,11 @@ fn ingest_listing_event( exec: &dyn SqlExecutor, event: &RadrootsEventEnvelope, ) -> Result<RadrootsReplicaIngestOutcome, RadrootsReplicaEventsError> { - let listing = listing_decode::listing_from_event(event.kind, &event.tags, &event.content)?; + let listing = listing_decode::listing_from_event( + event.kind_u32(), + &event.tags_as_vec(), + event.content(), + )?; let decision = event_head_decision(exec, event)?; if !decision.apply { return Ok(RadrootsReplicaIngestOutcome::Skipped); @@ -481,11 +489,14 @@ fn ingest_list_set_event( exec: &dyn SqlExecutor, event: &RadrootsEventEnvelope, ) -> Result<RadrootsReplicaIngestOutcome, RadrootsReplicaEventsError> { - if event.kind != radroots_event::kinds::KIND_LIST_SET_GENERIC { + if event.kind_u32() != radroots_event::kinds::KIND_LIST_SET_GENERIC { return Ok(RadrootsReplicaIngestOutcome::Skipped); } - let list_set = - list_set_decode::list_set_from_tags(event.kind, event.content.clone(), &event.tags)?; + let list_set = list_set_decode::list_set_from_tags( + event.kind_u32(), + event.content().to_owned(), + &event.tags_as_vec(), + )?; let metadata_count = usize::from(list_set.title.is_some()) + usize::from(list_set.description.is_some()) @@ -509,7 +520,7 @@ fn ingest_list_set_event( if !decision.apply { return Ok(RadrootsReplicaIngestOutcome::Skipped); } - upsert_member_claims(exec, &event.author, &list_set)?; + upsert_member_claims(exec, event.author_str(), &list_set)?; upsert_event_head(exec, &decision)?; return Ok(RadrootsReplicaIngestOutcome::Applied); } @@ -529,7 +540,7 @@ fn ingest_list_set_event( if !decision.apply { return Ok(RadrootsReplicaIngestOutcome::Skipped); } - let farm = find_farm_by_ref(exec, &event.author, &farm_d_tag)?; + let farm = find_farm_by_ref(exec, event.author_str(), &farm_d_tag)?; upsert_farm_members(exec, &farm.id, role, &list_set)?; upsert_event_head(exec, &decision)?; return Ok(RadrootsReplicaIngestOutcome::Applied); @@ -541,7 +552,12 @@ fn ingest_list_set_event( } fn listing_event_addr(event: &RadrootsEventEnvelope, listing: &RadrootsListing) -> String { - format!("{}:{}:{}", event.kind, event.author, listing.d_tag) + format!( + "{}:{}:{}", + event.kind_u32(), + event.author_str(), + listing.d_tag + ) } fn listing_is_orderable(listing: &RadrootsListing) -> bool { @@ -904,11 +920,11 @@ fn event_head_decision( return Ok(EventHeadDecision { apply: false, key: String::new(), - kind: event.kind, - pubkey: event.author.clone(), + kind: event.kind_u32(), + pubkey: event.author_str().to_owned(), d_tag: String::new(), - last_event_id: event.id.clone(), - last_created_at: event.created_at, + last_event_id: event.id_str().to_owned(), + last_created_at: event.created_at_u64(), content_hash: String::new(), }); } @@ -920,9 +936,9 @@ fn event_head_decision( }; let (key, kind, pubkey, d_tag) = event_head_coordinate_fields(&candidate.coordinate); #[cfg(test)] - let content_hash = event_content_hash(&event.content, &event.tags)?; + let content_hash = event_content_hash(event.content(), &event.tags_as_vec())?; #[cfg(not(test))] - let content_hash = event_content_hash(&event.content, &event.tags); + let content_hash = event_content_hash(event.content(), &event.tags_as_vec()); let existing_result = nostr_event_head::find_one( exec, &INostrEventHeadFindOne::On(INostrEventHeadFindOneArgs { @@ -954,8 +970,8 @@ fn event_head_decision( kind, pubkey, d_tag, - last_event_id: event.id.clone(), - last_created_at: event.created_at, + last_event_id: event.id_str().to_owned(), + last_created_at: event.created_at_u64(), content_hash, }) } @@ -1497,7 +1513,7 @@ struct EventHeadDecision { pubkey: String, d_tag: String, last_event_id: String, - last_created_at: u32, + last_created_at: u64, content_hash: String, } @@ -1542,6 +1558,51 @@ mod tests { }; use radroots_sql_core::{ExecOutcome, SqlExecutor, SqliteExecutor}; + fn test_event_envelope( + id: u64, + author: &str, + created_at: u64, + kind: u32, + tags: Vec<Vec<String>>, + content: String, + ) -> RadrootsEventEnvelope { + RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts { + id: format!("{id:064x}"), + author: author.to_string(), + created_at, + kind, + tags, + content, + sig: "f".repeat(128), + }) + .expect("test event envelope") + } + + fn test_event_with_parts( + event: &RadrootsEventEnvelope, + kind: u32, + tags: Vec<Vec<String>>, + content: String, + ) -> RadrootsEventEnvelope { + RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts { + id: event.id_str().to_owned(), + author: event.author_str().to_owned(), + created_at: event.created_at_u64(), + kind, + tags, + content, + sig: event.sig_str().to_owned(), + }) + .expect("test event envelope parts") + } + + fn test_event_with_content( + event: &RadrootsEventEnvelope, + content: String, + ) -> RadrootsEventEnvelope { + test_event_with_parts(event, event.kind_u32(), event.tags_as_vec(), content) + } + struct FixedFactory; impl RadrootsReplicaIdFactory for FixedFactory { @@ -1747,15 +1808,14 @@ mod tests { radroots_profile_type_tag_value(profile_type).to_string(), ]); } - RadrootsEventEnvelope { - id: format!("{id:064x}"), - author: author.to_string(), - created_at, - kind: KIND_PROFILE, + test_event_envelope( + id, + author, + u64::from(created_at), + KIND_PROFILE, tags, - content: serde_json::to_string(&profile).expect("profile json"), - sig: "f".repeat(128), - } + serde_json::to_string(&profile).expect("profile json"), + ) } fn farm_event( @@ -1778,15 +1838,14 @@ mod tests { tags, }; let tags = farm_encode::farm_build_tags(&farm).expect("farm tags"); - RadrootsEventEnvelope { - id: format!("{id:064x}"), - author: author.to_string(), - created_at, - kind: KIND_FARM, + test_event_envelope( + id, + author, + u64::from(created_at), + KIND_FARM, tags, - content: serde_json::to_string(&farm).expect("farm json"), - sig: "f".repeat(128), - } + serde_json::to_string(&farm).expect("farm json"), + ) } #[allow(clippy::too_many_arguments)] @@ -1809,15 +1868,14 @@ mod tests { tags, }; let tags = plot_encode::plot_build_tags(&plot).expect("plot tags"); - RadrootsEventEnvelope { - id: format!("{id:064x}"), - author: author.to_string(), - created_at, - kind: KIND_PLOT, + test_event_envelope( + id, + author, + u64::from(created_at), + KIND_PLOT, tags, - content: serde_json::to_string(&plot).expect("plot json"), - sig: "f".repeat(128), - } + serde_json::to_string(&plot).expect("plot json"), + ) } fn list_set_event( @@ -1828,15 +1886,14 @@ mod tests { list_set: &RadrootsListSet, ) -> RadrootsEventEnvelope { let parts = list_set_encode::to_wire_parts_with_kind(list_set, kind).expect("list set"); - RadrootsEventEnvelope { - id: format!("{id:064x}"), - author: author.to_string(), - created_at, + test_event_envelope( + id, + author, + u64::from(created_at), kind, - tags: parts.tags, - content: parts.content, - sig: "f".repeat(128), - } + parts.tags, + parts.content, + ) } fn listing_event( @@ -1848,12 +1905,12 @@ mod tests { title: &str, ) -> RadrootsEventEnvelope { let farm_d_tag = "AAAAAAAAAAAAAAAAAAAAAA"; - RadrootsEventEnvelope { - id: format!("{id:064x}"), - author: author.to_string(), - created_at, - kind: KIND_LISTING, - tags: vec![ + test_event_envelope( + id, + author, + u64::from(created_at), + KIND_LISTING, + vec![ vec!["d".to_string(), d_tag.to_string()], vec![ "a".to_string(), @@ -1891,9 +1948,8 @@ mod tests { vec!["inventory".to_string(), "5".to_string()], vec!["status".to_string(), status.to_string()], ], - content: format!("# {title}"), - sig: "f".repeat(128), - } + format!("# {title}"), + ) } fn listing_decimal(raw: &str) -> RadrootsCoreDecimal { @@ -2133,15 +2189,14 @@ mod tests { commit_err: None, rollback_count: Arc::new(AtomicUsize::new(0)), }; - let event = RadrootsEventEnvelope { - id: format!("{:064x}", 1u64), - author: "a".repeat(64), - created_at: 1, - kind: KIND_LIST_SET_FOLLOW, - tags: Vec::new(), - content: String::new(), - sig: "f".repeat(128), - }; + let event = test_event_envelope( + 1, + &"a".repeat(64), + 1, + KIND_LIST_SET_FOLLOW, + Vec::new(), + String::new(), + ); let begin_err = radroots_replica_ingest_event_with_factory(&begin_executor, &event, &FixedFactory) .expect_err("begin"); @@ -2181,15 +2236,7 @@ mod tests { commit_err: None, rollback_count: Arc::new(AtomicUsize::new(0)), }; - let unsupported = RadrootsEventEnvelope { - id: format!("{:064x}", 2u64), - author: "a".repeat(64), - created_at: 2, - kind: 42, - tags: Vec::new(), - content: String::new(), - sig: "f".repeat(128), - }; + let unsupported = test_event_envelope(2, &"a".repeat(64), 2, 42, Vec::new(), String::new()); let err = radroots_replica_ingest_event_with_factory( &rollback_executor, &unsupported, @@ -2550,7 +2597,8 @@ mod tests { "active", "Pasture Eggs", ); - active.tags.push(vec![ + let mut active_tags = active.tags_as_vec(); + active_tags.push(vec![ "radroots:discount".to_string(), serde_json::json!({ "scope": "bin", @@ -2565,6 +2613,12 @@ mod tests { }) .to_string(), ]); + active = test_event_with_parts( + &active, + active.kind_u32(), + active_tags, + active.content().to_owned(), + ); assert_eq!( radroots_replica_ingest_event(&exec, &active).expect("active ingest"), RadrootsReplicaIngestOutcome::Applied @@ -2666,7 +2720,7 @@ mod tests { .expect("event state") .result .expect("state row"); - assert_eq!(state.last_event_id, archived.id); + assert_eq!(state.last_event_id, archived.id_str()); let stale_active = listing_event( 499, @@ -2708,7 +2762,8 @@ mod tests { "active", "Half Gram Greens", ); - for tag in &mut active.tags { + let mut active_tags = active.tags_as_vec(); + for tag in &mut active_tags { if tag.first().is_some_and(|name| name == "radroots:bin") { tag[2] = "0.5".to_string(); tag[3] = "g".to_string(); @@ -2725,6 +2780,12 @@ mod tests { tag[7] = "g".to_string(); } } + active = test_event_with_parts( + &active, + active.kind_u32(), + active_tags, + active.content().to_owned(), + ); assert_eq!( radroots_replica_ingest_event(&exec, &active).expect("fractional active ingest"), @@ -3426,8 +3487,7 @@ mod tests { Some(RadrootsProfileType::Individual), "profile-base", ); - let mut profile_bad_content = profile.clone(); - profile_bad_content.content = "{".to_string(); + let profile_bad_content = test_event_with_content(&profile, "{".to_string()); assert!(ingest_profile_event(&exec, &profile_bad_content).is_err()); let profile_query_fail = QueryFailExecutor { @@ -3503,8 +3563,7 @@ mod tests { RadrootsReplicaIngestOutcome::Applied ); - let mut farm_bad_content = farm_seed.clone(); - farm_bad_content.content = "{".to_string(); + let farm_bad_content = test_event_with_content(&farm_seed, "{".to_string()); assert!(ingest_farm_event(&exec, &farm_bad_content, &FixedFactory).is_err()); let farm_query_fail = QueryFailExecutor { @@ -3703,8 +3762,7 @@ mod tests { RadrootsReplicaIngestOutcome::Applied ); - let mut plot_bad_content = plot_seed.clone(); - plot_bad_content.content = "{".to_string(); + let plot_bad_content = test_event_with_content(&plot_seed, "{".to_string()); assert!(ingest_plot_event(&exec, &plot_bad_content, &FixedFactory).is_err()); let plot_query_fail = QueryFailExecutor { @@ -3859,16 +3917,19 @@ mod tests { ); assert!(ingest_plot_event(&plot_state_fail, &plot_state_event, &FixedFactory).is_err()); - let mut list_decode_fail = profile_event( + let list_decode_fail = profile_event( 830, &farm_pubkey, 108, Some(RadrootsProfileType::Farm), "unused", ); - list_decode_fail.kind = KIND_LIST_SET_GENERIC; - list_decode_fail.content = "{".to_string(); - list_decode_fail.tags = Vec::new(); + let list_decode_fail = test_event_with_parts( + &list_decode_fail, + KIND_LIST_SET_GENERIC, + Vec::new(), + "{".to_string(), + ); assert!(ingest_list_set_event(&exec, &list_decode_fail).is_err()); let members_list = farm_list_sets::farm_members_list_set(farm_d_tag, vec!["6".repeat(64)]) diff --git a/crates/replica_sync/tests/ingest_roundtrip.rs b/crates/replica_sync/tests/ingest_roundtrip.rs @@ -1,4 +1,3 @@ -use radroots_event::RadrootsEventEnvelope; use radroots_event::farm::{RadrootsFarm, RadrootsFarmPublicLocation, RadrootsFarmRef}; use radroots_event::gcs::{RadrootsGcsLocation, RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon}; use radroots_event::kinds::{ @@ -11,6 +10,7 @@ use radroots_event::profile::{ RADROOTS_PROFILE_TYPE_TAG_KEY, RadrootsProfile, RadrootsProfileType, radroots_profile_type_tag_value, }; +use radroots_event::{RadrootsEventEnvelope, RadrootsEventEnvelopeParts}; use radroots_event_codec::error::{EventEncodeError, EventParseError}; use radroots_event_codec::farm::encode as farm_encode; use radroots_event_codec::farm::list_sets as farm_list_sets; @@ -180,15 +180,14 @@ fn unwrap_sql_panics_on_error() { } fn draft_to_event(draft: &RadrootsReplicaEventDraft, index: u32) -> RadrootsEventEnvelope { - RadrootsEventEnvelope { - id: format!("{:064x}", index as u64 + 1), - author: draft.author.clone(), - created_at: 1_720_000_000 + index, - kind: draft.kind, - tags: draft.tags.clone(), - content: draft.content.clone(), - sig: "f".repeat(128), - } + event_with_parts( + u64::from(index) + 1, + draft.author.as_str(), + 1_720_000_000 + index, + draft.kind, + draft.content.clone(), + draft.tags.clone(), + ) } fn seed_source( @@ -547,15 +546,14 @@ fn sync_all_selector_and_options_paths_are_supported() { fn ingest_rejects_unsupported_kind() { let exec = SqliteExecutor::open_memory().expect("db"); migrations::run_all_up(&exec).expect("migrations"); - let event = RadrootsEventEnvelope { - id: format!("{:064x}", 1u64), - author: "a".repeat(64), - created_at: 1_720_000_001, - kind: 42, - tags: Vec::new(), - content: String::new(), - sig: "f".repeat(128), - }; + let event = event_with_parts( + 1, + &"a".repeat(64), + 1_720_000_001, + 42, + String::new(), + Vec::new(), + ); let err = radroots_replica_ingest_event(&exec, &event).expect_err("unsupported kind"); assert!(err.to_string().contains("unsupported kind")); } @@ -637,7 +635,7 @@ fn ingest_reports_parse_and_state_error_paths_for_all_kinds() { 11, KIND_PROFILE, "{".to_string(), - profile_ok.tags.clone(), + profile_ok.tags_as_vec(), ); assert!(radroots_replica_ingest_event(&exec, &profile_parse_error).is_err()); @@ -663,7 +661,7 @@ fn ingest_reports_parse_and_state_error_paths_for_all_kinds() { 13, KIND_FARM, "{".to_string(), - farm_seed.tags.clone(), + farm_seed.tags_as_vec(), ); assert!(radroots_replica_ingest_event(&exec, &farm_parse_error).is_err()); @@ -686,7 +684,7 @@ fn ingest_reports_parse_and_state_error_paths_for_all_kinds() { 15, KIND_PLOT, "{".to_string(), - plot_ok.tags.clone(), + plot_ok.tags_as_vec(), ); assert!(radroots_replica_ingest_event(&exec, &plot_parse_error).is_err()); @@ -919,15 +917,16 @@ fn event_with_parts( content: String, tags: Vec<Vec<String>>, ) -> RadrootsEventEnvelope { - RadrootsEventEnvelope { + RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts { id: format!("{id:064x}"), author: author.to_string(), - created_at, + created_at: u64::from(created_at), kind, tags, content, sig: "f".repeat(128), - } + }) + .expect("test event envelope") } fn sample_point(lat: f64, lng: f64) -> RadrootsGeoJsonPoint { diff --git a/crates/transport_nostr/tests/transport.rs b/crates/transport_nostr/tests/transport.rs @@ -1166,10 +1166,10 @@ async fn fetch_ingests_events_and_records_transport_observations() { .await .expect("fetch ingest"); - assert_eq!(receipt.inserted_count, 3); + assert_eq!(receipt.inserted_count, 2); assert_eq!(receipt.duplicate_count, 1); assert_eq!(receipt.unsupported_count, 1); - assert_eq!(receipt.malformed_count, 1); + assert_eq!(receipt.malformed_count, 2); assert_eq!(receipt.eose_count, 1); assert_eq!(receipt.closed_count, 2); assert_eq!(receipt.notice_count, 1); @@ -1218,10 +1218,7 @@ async fn fetch_ingests_events_and_records_transport_observations() { Some(RadrootsEventVerificationStatus::Verified.as_str()) ); assert!(!receipt.events[2].projection_eligible); - assert_eq!( - receipt.events[3].verification_status.as_deref(), - Some(RadrootsEventVerificationStatus::IdMismatch.as_str()) - ); + assert_eq!(receipt.events[3].verification_status, None); assert!(!receipt.events[3].projection_eligible); assert_eq!(receipt.events[4].verification_status, None); assert!(!receipt.events[4].projection_eligible); diff --git a/crates/transport_publish_protocol/src/lib.rs b/crates/transport_publish_protocol/src/lib.rs @@ -16,7 +16,7 @@ use radroots_transport::{ RadrootsTransportTarget, RadrootsTransportTargetFingerprint, RadrootsTransportTargetLabel, }; -pub const API_VERSION: &str = "radrootsd.transport_publish.v4"; +pub const API_VERSION: &str = "radrootsd.transport_publish.v5"; pub const DAEMON_NAME: &str = "radrootsd"; pub const METHOD_CAPABILITIES: &str = "transport.publish.capabilities"; pub const METHOD_EVENT: &str = "transport.publish.event"; @@ -29,7 +29,7 @@ pub enum TransportPublishProtocolError { field: &'static str, expected_len: usize, }, - InvalidKind(u32), + EmptyRawEventJson, EmptyTag { index: usize, }, @@ -130,9 +130,7 @@ impl fmt::Display for TransportPublishProtocolError { field, expected_len, } => write!(f, "{field} must be {expected_len} lowercase hex characters"), - Self::InvalidKind(kind) => { - write!(f, "event kind {kind} exceeds transport publish range") - } + Self::EmptyRawEventJson => f.write_str("raw_event_json must not be empty"), Self::EmptyTag { index } => write!(f, "tag {index} must not be empty"), Self::EmptyIdempotencyKey => f.write_str("idempotency key must not be empty"), Self::EmptyTransportKind { index } => { @@ -255,36 +253,6 @@ impl fmt::Display for TransportPublishProtocolError { impl std::error::Error for TransportPublishProtocolError {} #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SignedEventWire { - pub id: String, - pub pubkey: String, - pub created_at: u64, - pub kind: u32, - pub tags: Vec<Vec<String>>, - pub content: String, - pub sig: String, -} - -impl SignedEventWire { - pub fn validate(&self) -> Result<(), TransportPublishProtocolError> { - validate_lower_hex("id", self.id.as_str(), 64)?; - validate_lower_hex("pubkey", self.pubkey.as_str(), 64)?; - validate_lower_hex("sig", self.sig.as_str(), 128)?; - if self.kind > u16::MAX as u32 { - return Err(TransportPublishProtocolError::InvalidKind(self.kind)); - } - for (index, tag) in self.tags.iter().enumerate() { - if tag.is_empty() { - return Err(TransportPublishProtocolError::EmptyTag { index }); - } - } - Ok(()) - } -} - -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum TransportPublishPreviewBehavior { @@ -651,7 +619,7 @@ fn validate_required_target_fingerprints( #[cfg_attr(feature = "serde", serde(deny_unknown_fields))] #[derive(Clone, Debug, PartialEq, Eq)] pub struct TransportPublishEventRequest { - pub event: SignedEventWire, + pub raw_event_json: String, pub target_policy: TransportPublishTargetPolicy, pub delivery_policy: TransportPublishDeliveryPolicy, #[cfg_attr( @@ -668,7 +636,9 @@ pub struct TransportPublishEventRequest { impl TransportPublishEventRequest { pub fn validate(&self, max_targets: usize) -> Result<(), TransportPublishProtocolError> { - self.event.validate()?; + if self.raw_event_json.is_empty() { + return Err(TransportPublishProtocolError::EmptyRawEventJson); + } self.target_policy.validate(max_targets)?; self.delivery_policy.validate()?; if let TransportPublishTargetPolicy::ExplicitTargets { targets } = &self.target_policy { @@ -855,9 +825,6 @@ impl TransportPublishJobView { } validate_lower_hex("event_id", self.event_id.as_str(), 64)?; validate_lower_hex("pubkey", self.pubkey.as_str(), 64)?; - if self.event_kind > u16::MAX as u32 { - return Err(TransportPublishProtocolError::InvalidKind(self.event_kind)); - } self.target_policy.validate(usize::MAX)?; self.delivery_policy.validate()?; if self.terminal != job_status_is_terminal(self.status) { @@ -961,7 +928,7 @@ pub struct TransportPublishCapabilities { } impl TransportPublishCapabilities { - pub fn v4(max_event_bytes: usize, max_targets_per_request: usize) -> Self { + pub fn v5(max_event_bytes: usize, max_targets_per_request: usize) -> Self { Self { daemon: DAEMON_NAME.to_owned(), api_version: API_VERSION.to_owned(), @@ -976,7 +943,7 @@ impl TransportPublishCapabilities { mode: "scoped_bearer_token".to_owned(), }, publish: TransportPublishSurfaceCapabilities { - signed_event_ingress: true, + raw_event_json_ingress: true, server_side_user_signing: false, max_event_bytes, max_targets_per_request, @@ -1034,7 +1001,7 @@ pub struct TransportPublishAuthCapabilities { #[cfg_attr(feature = "serde", serde(deny_unknown_fields))] #[derive(Clone, Debug, PartialEq, Eq)] pub struct TransportPublishSurfaceCapabilities { - pub signed_event_ingress: bool, + pub raw_event_json_ingress: bool, pub server_side_user_signing: bool, pub max_event_bytes: usize, pub max_targets_per_request: usize, @@ -1410,16 +1377,13 @@ fn validate_job_status_state( mod tests { use super::*; - fn event() -> SignedEventWire { - SignedEventWire { - id: "0".repeat(64), - pubkey: "1".repeat(64), - created_at: 1_700_000_000, - kind: 30_402, - tags: vec![vec!["d".to_owned(), "listing-1".to_owned()]], - content: "{}".to_owned(), - sig: "2".repeat(128), - } + fn raw_event_json() -> String { + format!( + r#"{{"id":"{}","pubkey":"{}","created_at":1700000000,"kind":30402,"tags":[["d","listing-1"]],"content":"{{}}","sig":"{}"}}"#, + "0".repeat(64), + "1".repeat(64), + "2".repeat(128) + ) } fn nostr_outcome(outcome_kind: TransportPublishOutcomeKind) -> TransportPublishTargetOutcome { @@ -1540,10 +1504,10 @@ mod tests { } #[test] - fn transport_publish_capabilities_match_v4_surface() { - let capabilities = TransportPublishCapabilities::v4(1024, 10); + fn transport_publish_capabilities_match_v5_surface() { + let capabilities = TransportPublishCapabilities::v5(1024, 10); - assert_eq!(capabilities.api_version, "radrootsd.transport_publish.v4"); + assert_eq!(capabilities.api_version, "radrootsd.transport_publish.v5"); assert_eq!( capabilities.methods, vec![ @@ -1596,7 +1560,7 @@ mod tests { #[test] fn request_validation_covers_targets_and_policy() { let request = TransportPublishEventRequest { - event: event(), + raw_event_json: raw_event_json(), target_policy: TransportPublishTargetPolicy::explicit_targets(vec![ TransportPublishTarget::nostr("wss://relay.example.com"), ]), @@ -2179,7 +2143,7 @@ mod tests { #[test] fn serde_round_trip_preserves_preview_target() { let request = TransportPublishEventRequest { - event: event(), + raw_event_json: raw_event_json(), target_policy: TransportPublishTargetPolicy::explicit_targets(vec![ TransportPublishTarget::reticulum_preview( TransportPublishPreviewBehavior::DeferDeliveryPlans, @@ -2200,7 +2164,7 @@ mod tests { #[test] fn serde_round_trip_preserves_target_metadata() { let request = TransportPublishEventRequest { - event: event(), + raw_event_json: raw_event_json(), target_policy: TransportPublishTargetPolicy::explicit_targets(vec![ TransportPublishTarget::nostr("wss://relay.example.com") .with_scope("farm.local") @@ -2229,8 +2193,8 @@ mod tests { "id must be 64 lowercase hex characters", ), ( - TransportPublishProtocolError::InvalidKind(70_000), - "event kind 70000 exceeds transport publish range", + TransportPublishProtocolError::EmptyRawEventJson, + "raw_event_json must not be empty", ), ( TransportPublishProtocolError::EmptyTag { index: 2 }, @@ -2320,45 +2284,20 @@ mod tests { } #[test] - fn signed_event_validation_rejects_each_invalid_event_shape() { - let mut invalid_id = event(); - invalid_id.id = "A".repeat(64); - assert!(matches!( - invalid_id.validate(), - Err(TransportPublishProtocolError::InvalidHexField { field: "id", .. }) - )); - - let mut invalid_pubkey = event(); - invalid_pubkey.pubkey = "g".repeat(64); - assert!(matches!( - invalid_pubkey.validate(), - Err(TransportPublishProtocolError::InvalidHexField { - field: "pubkey", - .. - }) - )); - - let mut invalid_sig = event(); - invalid_sig.sig = "2".repeat(127); - assert!(matches!( - invalid_sig.validate(), - Err(TransportPublishProtocolError::InvalidHexField { field: "sig", .. }) - )); - - let mut invalid_kind = event(); - invalid_kind.kind = u16::MAX as u32 + 1; - assert_eq!( - invalid_kind.validate(), - Err(TransportPublishProtocolError::InvalidKind( - u16::MAX as u32 + 1 - )) - ); - - let mut empty_tag = event(); - empty_tag.tags.push(Vec::new()); + fn publish_request_validation_rejects_empty_raw_event_json() { + let empty_raw_event = TransportPublishEventRequest { + raw_event_json: String::new(), + target_policy: TransportPublishTargetPolicy::nostr( + NostrPublishTargetSourcePolicy::DaemonDefaultOnly, + Vec::new(), + ), + delivery_policy: TransportPublishDeliveryPolicy::Any, + idempotency_key: None, + timeout_ms: None, + }; assert_eq!( - empty_tag.validate(), - Err(TransportPublishProtocolError::EmptyTag { index: 1 }) + empty_raw_event.validate(10), + Err(TransportPublishProtocolError::EmptyRawEventJson) ); } @@ -2382,7 +2321,7 @@ mod tests { assert_eq!(nostr.request_target_count(), 1); let mut empty_targets = TransportPublishEventRequest { - event: event(), + raw_event_json: raw_event_json(), target_policy: TransportPublishTargetPolicy::explicit_targets(Vec::new()), delivery_policy: TransportPublishDeliveryPolicy::Any, idempotency_key: None, @@ -2462,7 +2401,7 @@ mod tests { .expect("required policy"); assert_eq!(required_policy.required_target_count(3), 1); let required_request = TransportPublishEventRequest { - event: event(), + raw_event_json: raw_event_json(), target_policy: TransportPublishTargetPolicy::explicit_targets(vec![ required_target.clone(), TransportPublishTarget::nostr("wss://relay.example").with_scope("farm.remote"), @@ -2586,15 +2525,6 @@ mod tests { Err(TransportPublishProtocolError::EmptyJobId) ); - let mut invalid_kind = base.clone(); - invalid_kind.event_kind = u16::MAX as u32 + 1; - assert_eq!( - invalid_kind.validate(), - Err(TransportPublishProtocolError::InvalidKind( - u16::MAX as u32 + 1 - )) - ); - let mut invalid_pubkey = base.clone(); invalid_pubkey.pubkey = "x".repeat(64); assert!(matches!(