radrootsd

JSON-RPC bridge for Radroots event publishing
git clone https://radroots.dev/git/radrootsd.git
Log | Files | Refs | README | LICENSE

commit ba2e09c8e8c96882e37c2fbcc9ef9729b2b87b8e
parent 6822e38097326b5d0b5217c3fd711ac67e9ca60a
Author: triesap <tyson@radroots.org>
Date:   Tue, 14 Jul 2026 09:51:45 +0000

transport: parse publish requests from raw event JSON

- verify publish events through canonical wire and signed-event APIs
- authorize principals from verified signed-event pubkeys and kinds
- fingerprint exact raw event JSON and store typed event metadata
- update JSON-RPC publish tests and guards for v5 raw ingress

Diffstat:
MCargo.lock | 1+
MCargo.toml | 2+-
Msrc/core/transport_publish.rs | 298++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Msrc/transport/jsonrpc/methods/mod.rs | 4++--
Msrc/transport/jsonrpc/methods/transport_publish.rs | 25++++++++++++-------------
Msrc/transport/jsonrpc/server.rs | 16++++++++--------
Mtests/source_boundary.rs | 3++-
7 files changed, 192 insertions(+), 157 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1789,6 +1789,7 @@ version = "0.1.0-alpha.2" dependencies = [ "hex", "radroots_core", + "secp256k1", "serde", "serde_json", "sha2", diff --git a/Cargo.toml b/Cargo.toml @@ -23,7 +23,7 @@ radroots_runtime = { path = "../lib/crates/runtime" } unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } [dependencies] -radroots_event = { workspace = true, features = ["serde"] } +radroots_event = { workspace = true, features = ["serde", "signature"] } radroots_identity = { workspace = true } radroots_nostr = { workspace = true, features = ["client", "codec", "events", "http"] } radroots_transport_publish_protocol = { workspace = true, features = ["std", "serde"] } diff --git a/src/core/transport_publish.rs b/src/core/transport_publish.rs @@ -8,11 +8,12 @@ use std::str::FromStr; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -use radroots_event::RadrootsEventEnvelope; -use radroots_event::draft::{RadrootsDraftError, RadrootsSignedEvent, RadrootsSignedEventParts}; +use radroots_event::draft::{ + RadrootsSignatureVerificationError, RadrootsSignedEvent, RadrootsSignedEventError, +}; +use radroots_event::wire::{RadrootsEventWireError, RadrootsNip01EventWire}; use radroots_nostr::prelude::{ - RadrootsNostrClient, RadrootsNostrEventVerification, RadrootsNostrFilter, RadrootsNostrKind, - RadrootsNostrPublicKey, radroots_nostr_verify_event, + RadrootsNostrClient, RadrootsNostrFilter, RadrootsNostrKind, RadrootsNostrPublicKey, }; use radroots_transport::{ RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI, RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE, @@ -26,11 +27,11 @@ use radroots_transport_nostr::{ RadrootsRelayTargetSet, RadrootsRelayTransportError, RadrootsRelayUrl, RadrootsRelayUrlPolicy, }; use radroots_transport_publish_protocol::{ - NostrPublishTargetSourcePolicy, SignedEventWire, TransportPublishDeliveryPolicy, - TransportPublishEventRequest, TransportPublishEventResponse, TransportPublishJobStatus, - TransportPublishJobView, TransportPublishOutcomeKind, TransportPublishPreviewBehavior, - TransportPublishTarget, TransportPublishTargetOutcome, TransportPublishTargetPolicy, - TransportPublishTargetPolicyName, TransportPublishTargetSource, + NostrPublishTargetSourcePolicy, TransportPublishDeliveryPolicy, TransportPublishEventRequest, + TransportPublishEventResponse, TransportPublishJobStatus, TransportPublishJobView, + TransportPublishOutcomeKind, TransportPublishPreviewBehavior, TransportPublishTarget, + TransportPublishTargetOutcome, TransportPublishTargetPolicy, TransportPublishTargetPolicyName, + TransportPublishTargetSource, }; use rusqlite::types::Type; use rusqlite::{Connection, OptionalExtension, Row, params}; @@ -143,10 +144,12 @@ pub enum TransportPublishError { InvalidScope(String), #[error("invalid signed Nostr event: {0}")] InvalidSignedEvent(String), - #[error("signed Nostr event verification failed: {0:?}")] - SignedEventVerification(RadrootsNostrEventVerification), - #[error("signed Nostr event conversion error: {0}")] - Draft(#[from] RadrootsDraftError), + #[error("signed event wire error: {0}")] + EventWire(#[from] RadrootsEventWireError), + #[error("signed event conversion error: {0}")] + SignedEvent(#[from] RadrootsSignedEventError), + #[error("signed event signature verification failed: {0}")] + SignedEventSignature(#[from] RadrootsSignatureVerificationError), #[error("transport publish relay error: {0}")] Relay(#[from] RadrootsRelayTransportError), #[error("transport publish transport error: {0}")] @@ -237,29 +240,30 @@ impl TransportPublish { "publish request validation failed: {error}" )) })?; - principal.allows_event(&request)?; - let signed_event = signed_event_from_wire(&request.event)?; - if signed_event.raw_json.len() > self.config.max_event_bytes { + if request.raw_event_json.len() > self.config.max_event_bytes { return Err(TransportPublishError::InvalidSignedEvent( "signed event exceeds transport_publish max_event_bytes".to_owned(), )); } + let signed_event = signed_event_from_raw_json(request.raw_event_json.as_str())?; + principal.allows_event(&signed_event, &request)?; let effective_timeout_ms = effective_publish_timeout_ms(&self.config, request.timeout_ms)?; let _permit = self.acquire_publish_permit()?; let request_fingerprint = request_intent_fingerprint( principal.principal_id.as_str(), - signed_event.raw_json.as_str(), + signed_event.raw_json(), &request, effective_timeout_ms, )?; let resolution = self - .resolve_targets_for_request(signed_event.pubkey.as_str(), &request) + .resolve_targets_for_request(signed_event.pubkey_str(), &request) .await?; validate_delivery_policy_for_resolution(&request.delivery_policy, &resolution)?; let target_snapshots = target_snapshots_from_resolution(&resolution); let response = self.store.record_publish_job(PublishJobInsert { principal_id: principal.principal_id.clone(), idempotency_key: request.idempotency_key.clone(), + event: PublishEventMetadata::from_signed_event(&signed_event), request: request.clone(), request_fingerprint, effective_target_count: resolution.target_count(), @@ -747,19 +751,21 @@ pub struct PublishPrincipal { impl PublishPrincipal { pub fn allows_event( &self, + signed_event: &RadrootsSignedEvent, request: &TransportPublishEventRequest, ) -> Result<(), TransportPublishError> { - ensure_lower_hex("pubkey", request.event.pubkey.as_str(), 64)?; + let pubkey = signed_event.pubkey_str(); + ensure_lower_hex("pubkey", pubkey, 64)?; if !self .allowed_pubkeys .iter() - .any(|pubkey| pubkey == &request.event.pubkey) + .any(|allowed_pubkey| allowed_pubkey == pubkey) { return Err(TransportPublishError::InvalidScope( "principal is not allowed to publish for event pubkey".to_owned(), )); } - if !self.allowed_kinds.contains(&request.event.kind) { + if !self.allowed_kinds.contains(&signed_event.kind()) { return Err(TransportPublishError::InvalidScope( "principal is not allowed to publish event kind".to_owned(), )); @@ -835,6 +841,7 @@ impl PublishPrincipal { pub struct PublishJobInsert { pub principal_id: String, pub idempotency_key: Option<String>, + pub event: PublishEventMetadata, pub request: TransportPublishEventRequest, pub request_fingerprint: String, pub effective_target_count: usize, @@ -842,6 +849,23 @@ pub struct PublishJobInsert { } #[derive(Debug, Clone, PartialEq, Eq)] +pub struct PublishEventMetadata { + pub event_id: String, + pub pubkey: String, + pub kind: u32, +} + +impl PublishEventMetadata { + fn from_signed_event(signed_event: &RadrootsSignedEvent) -> Self { + Self { + event_id: signed_event.id_str().to_owned(), + pubkey: signed_event.pubkey_str().to_owned(), + kind: signed_event.kind(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] pub struct ResolvedPublishRelay { pub url: RadrootsRelayUrl, pub source: TransportPublishTargetSource, @@ -1196,9 +1220,9 @@ impl TransportPublishStore { insert.idempotency_key, insert.request_fingerprint, serde_json::to_string(&TransportPublishJobStatus::Publishing)?, - insert.request.event.id, - insert.request.event.pubkey, - insert.request.event.kind, + insert.event.event_id, + insert.event.pubkey, + insert.event.kind, serde_json::to_string(&insert.request.target_policy)?, serde_json::to_string(&insert.request.delivery_policy)?, insert.request.target_policy.request_target_count(), @@ -2323,15 +2347,6 @@ fn validate_principal_init(input: &PublishPrincipalInit) -> Result<(), Transport "principal must include at least one allowed kind".to_owned(), )); } - if input - .allowed_kinds - .iter() - .any(|kind| *kind > u16::MAX as u32) - { - return Err(TransportPublishError::InvalidScope( - "allowed kind exceeds transport publish range".to_owned(), - )); - } if input.allowed_target_policies.is_empty() { return Err(TransportPublishError::InvalidScope( "principal must include at least one allowed target policy".to_owned(), @@ -2436,42 +2451,12 @@ pub fn parse_explicit_transport_kind(value: &str) -> Result<String, TransportPub Ok(kind.canonical_label()) } -fn signed_event_from_wire( - event: &SignedEventWire, +fn signed_event_from_raw_json( + raw_json: &str, ) -> Result<RadrootsSignedEvent, TransportPublishError> { - event - .validate() - .map_err(|error| TransportPublishError::InvalidSignedEvent(error.to_string()))?; - let created_at = u32::try_from(event.created_at).map_err(|_| { - TransportPublishError::InvalidSignedEvent( - "signed event created_at exceeds daemon-supported range".to_owned(), - ) - })?; - let raw_json = serde_json::to_string(event)?; - let radroots_event = RadrootsEventEnvelope { - id: event.id.clone(), - author: event.pubkey.clone(), - created_at, - kind: event.kind, - tags: event.tags.clone(), - content: event.content.clone(), - sig: event.sig.clone(), - }; - match radroots_nostr_verify_event(&radroots_event) { - RadrootsNostrEventVerification::Verified => {} - verification => return Err(TransportPublishError::SignedEventVerification(verification)), - } - RadrootsSignedEvent::new(RadrootsSignedEventParts { - id: event.id.clone(), - pubkey: event.pubkey.clone(), - created_at, - kind: event.kind, - tags: event.tags.clone(), - content: event.content.clone(), - sig: event.sig.clone(), - raw_json, - }) - .map_err(TransportPublishError::from) + let wire = RadrootsNip01EventWire::parse_json(raw_json)?; + let signed_event = RadrootsSignedEvent::from_wire_verified_id(wire, raw_json.to_owned())?; + Ok(signed_event.verify_signature()?.into_signed_event()) } fn request_intent_fingerprint( @@ -3034,13 +3019,13 @@ struct TransportPublishStorageIntegerRangeError { fn checked_event_kind_column(row: &Row<'_>, index: usize) -> Result<u32, rusqlite::Error> { let value = row.get::<_, i64>(index)?; - if !(0..=i64::from(u16::MAX)).contains(&value) { + if !(0..=i64::from(u32::MAX)).contains(&value) { return Err(integer_conversion_error( index, TransportPublishStorageIntegerRangeError { field: "event_kind", value, - target: "u16", + target: "u32", }, )); } @@ -3134,8 +3119,8 @@ fn current_unix_millis() -> i64 { #[cfg(test)] mod tests { use super::{ - PublishJobInsert, PublishJobVisibility, PublishPrincipal, PublishPrincipalInit, - SCHEMA_VERSION, TRANSPORT_KIND_NOSTR, TRANSPORT_KIND_RETICULUM, + PublishEventMetadata, PublishJobInsert, PublishJobVisibility, PublishPrincipal, + PublishPrincipalInit, SCHEMA_VERSION, TRANSPORT_KIND_NOSTR, TRANSPORT_KIND_RETICULUM, TRANSPORT_PUBLISH_SCHEMA_SQL, TransportPublish, TransportPublishError, TransportPublishStore, generate_bearer_token, hash_bearer_token, parse_nostr_source_policy, }; @@ -3144,13 +3129,11 @@ mod tests { }; use nostr::JsonUtil; use radroots_identity::RadrootsIdentity; - use radroots_nostr::prelude::{ - RadrootsNostrEventVerification, RadrootsNostrTimestamp, radroots_nostr_build_event, - }; + use radroots_nostr::prelude::{RadrootsNostrTimestamp, radroots_nostr_build_event}; use radroots_transport::{RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE, RadrootsTransportTarget}; use radroots_transport_nostr::{RadrootsMockRelayPublishAdapter, RadrootsRelayOutcome}; use radroots_transport_publish_protocol::{ - NostrPublishTargetSourcePolicy, SignedEventWire, TransportPublishDeliveryPolicy, + NostrPublishTargetSourcePolicy, TransportPublishDeliveryPolicy, TransportPublishEventRequest, TransportPublishJobStatus, TransportPublishOutcomeKind, TransportPublishPreviewBehavior, TransportPublishTarget, TransportPublishTargetOutcome, TransportPublishTargetPolicy, TransportPublishTargetPolicyName, @@ -3164,21 +3147,27 @@ mod tests { const RELAY_SECONDARY: &str = "wss://relay-2.example.com"; const RELAY_FORBIDDEN: &str = "wss://forbidden-relay.example.com"; - fn event(pubkey: &str, kind: u32) -> SignedEventWire { - SignedEventWire { - id: "0".repeat(64), + fn event_metadata(pubkey: &str, kind: u32) -> PublishEventMetadata { + PublishEventMetadata { + event_id: "0".repeat(64), pubkey: pubkey.to_owned(), - created_at: 1_700_000_000, kind, - tags: vec![vec!["d".to_owned(), "listing-1".to_owned()]], - content: "{}".to_owned(), - sig: "1".repeat(128), } } + fn raw_event_json(pubkey: &str, kind: u32) -> String { + format!( + r#"{{"id":"{}","pubkey":"{}","created_at":1700000000,"kind":{},"tags":[["d","listing-1"]],"content":"{{}}","sig":"{}"}}"#, + "0".repeat(64), + pubkey, + kind, + "1".repeat(128) + ) + } + fn request(pubkey: &str, kind: u32) -> TransportPublishEventRequest { TransportPublishEventRequest { - event: event(pubkey, kind), + raw_event_json: raw_event_json(pubkey, kind), target_policy: TransportPublishTargetPolicy::nostr( NostrPublishTargetSourcePolicy::DaemonDefaultOnly, Vec::new(), @@ -3239,7 +3228,7 @@ mod tests { .expect("user version") } - fn signed_event(identity: &RadrootsIdentity, content: &str) -> SignedEventWire { + fn signed_event(identity: &RadrootsIdentity, content: &str) -> String { let event = radroots_nostr_build_event( 30_402, content, @@ -3249,18 +3238,29 @@ mod tests { .custom_created_at(RadrootsNostrTimestamp::from_secs(1_700_000_000)) .sign_with_keys(identity.keys()) .expect("signed event"); - serde_json::from_str(event.as_json().as_str()).expect("event wire") + event.as_json() + } + + fn raw_event_with_field( + raw_event_json: String, + field: &str, + value: serde_json::Value, + ) -> String { + let mut event: serde_json::Value = + serde_json::from_str(raw_event_json.as_str()).expect("raw event json"); + event[field] = value; + serde_json::to_string(&event).expect("mutated raw event") } fn publish_request( - event: SignedEventWire, + raw_event_json: String, relays: Vec<String>, source_policy: NostrPublishTargetSourcePolicy, delivery_policy: TransportPublishDeliveryPolicy, idempotency_key: Option<&str>, ) -> TransportPublishEventRequest { TransportPublishEventRequest { - event, + raw_event_json, target_policy: TransportPublishTargetPolicy::nostr(source_policy, relays), delivery_policy, idempotency_key: idempotency_key.map(str::to_owned), @@ -3269,11 +3269,11 @@ mod tests { } fn reticulum_publish_request( - event: SignedEventWire, + raw_event_json: String, behavior: TransportPublishPreviewBehavior, ) -> TransportPublishEventRequest { TransportPublishEventRequest { - event, + raw_event_json, target_policy: TransportPublishTargetPolicy::explicit_targets(vec![ TransportPublishTarget::reticulum_preview(behavior), ]), @@ -3610,11 +3610,13 @@ mod tests { let store = TransportPublishStore::memory().expect("store"); let token = generate_bearer_token(); let token_hash = hash_bearer_token(token.as_str()); + let accepted_identity = RadrootsIdentity::generate(); + let denied_identity = RadrootsIdentity::generate(); let principal = store .create_principal(PublishPrincipalInit { label: "tester".to_owned(), token_hash: token_hash.clone(), - allowed_pubkeys: vec!["a".repeat(64)], + allowed_pubkeys: vec![accepted_identity.public_key_hex()], allowed_kinds: vec![30_402], allowed_target_policies: vec![TransportPublishTargetPolicyName::Nostr], allowed_explicit_transport_kinds: Vec::new(), @@ -3634,15 +3636,34 @@ mod tests { .principal_id, principal.principal_id ); - let denied = request("b".repeat(64).as_str(), 30_402); - assert!(principal.allows_event(&denied).is_err()); + let denied = publish_request( + signed_event(&denied_identity, "{}"), + Vec::new(), + NostrPublishTargetSourcePolicy::DaemonDefaultOnly, + TransportPublishDeliveryPolicy::Any, + None, + ); + let denied_signed = + super::signed_event_from_raw_json(denied.raw_event_json.as_str()).expect("denied raw"); + assert!(principal.allows_event(&denied_signed, &denied).is_err()); - let accepted = request("a".repeat(64).as_str(), 30_402); - principal.allows_event(&accepted).expect("scope"); + let accepted = publish_request( + signed_event(&accepted_identity, "{}"), + Vec::new(), + NostrPublishTargetSourcePolicy::DaemonDefaultOnly, + TransportPublishDeliveryPolicy::Any, + None, + ); + let accepted_signed = super::signed_event_from_raw_json(accepted.raw_event_json.as_str()) + .expect("accepted raw"); + principal + .allows_event(&accepted_signed, &accepted) + .expect("scope"); let response = store .record_publish_job(PublishJobInsert { principal_id: principal.principal_id.clone(), idempotency_key: Some("idem-1".to_owned()), + event: PublishEventMetadata::from_signed_event(&accepted_signed), request: accepted.clone(), request_fingerprint: "fingerprint-1".to_owned(), effective_target_count: 1, @@ -3657,6 +3678,7 @@ mod tests { .record_publish_job(PublishJobInsert { principal_id: principal.principal_id.clone(), idempotency_key: Some("idem-1".to_owned()), + event: PublishEventMetadata::from_signed_event(&accepted_signed), request: accepted, request_fingerprint: "fingerprint-1".to_owned(), effective_target_count: 1, @@ -3687,6 +3709,7 @@ mod tests { .record_publish_job(PublishJobInsert { principal_id: principal.principal_id.clone(), idempotency_key: Some("idem-invalid-target-count".to_owned()), + event: event_metadata(pubkey.as_str(), 30_402), request: request.clone(), request_fingerprint: "fingerprint-invalid-target-count".to_owned(), effective_target_count: 1, @@ -3737,6 +3760,7 @@ mod tests { .record_publish_job(PublishJobInsert { principal_id: principal.principal_id.clone(), idempotency_key: Some("idem-invalid-target-count".to_owned()), + event: event_metadata(pubkey.as_str(), 30_402), request, request_fingerprint: "fingerprint-invalid-target-count".to_owned(), effective_target_count: 1, @@ -3752,7 +3776,7 @@ mod tests { #[test] fn store_egress_rejects_impossible_persisted_event_kind_values() { - for invalid_kind in [-1_i64, i64::from(u16::MAX) + 1] { + for invalid_kind in [-1_i64, i64::from(u32::MAX) + 1] { let store = TransportPublishStore::memory().expect("store"); let pubkey = "a".repeat(64); let principal = store_principal(&store, pubkey.as_str()); @@ -3761,6 +3785,7 @@ mod tests { .record_publish_job(PublishJobInsert { principal_id: principal.principal_id.clone(), idempotency_key: Some(format!("idem-invalid-kind-{invalid_kind}")), + event: event_metadata(pubkey.as_str(), 30_402), request: request.clone(), request_fingerprint: format!("fingerprint-invalid-kind-{invalid_kind}"), effective_target_count: 1, @@ -3811,6 +3836,7 @@ mod tests { .record_publish_job(PublishJobInsert { principal_id: principal.principal_id.clone(), idempotency_key: Some(format!("idem-invalid-kind-{invalid_kind}")), + event: event_metadata(pubkey.as_str(), 30_402), request, request_fingerprint: format!("fingerprint-invalid-kind-{invalid_kind}"), effective_target_count: 1, @@ -3835,6 +3861,7 @@ mod tests { .record_publish_job(PublishJobInsert { principal_id: principal.principal_id.clone(), idempotency_key: Some("idem-negative-target-count".to_owned()), + event: event_metadata(pubkey.as_str(), 30_402), request: request.clone(), request_fingerprint: "fingerprint-negative-target-count".to_owned(), effective_target_count: 1, @@ -3885,6 +3912,7 @@ mod tests { .record_publish_job(PublishJobInsert { principal_id: principal.principal_id.clone(), idempotency_key: Some("idem-negative-target-count".to_owned()), + event: event_metadata(pubkey.as_str(), 30_402), request, request_fingerprint: "fingerprint-negative-target-count".to_owned(), effective_target_count: 1, @@ -3908,6 +3936,7 @@ mod tests { .record_publish_job(PublishJobInsert { principal_id: principal.principal_id.clone(), idempotency_key: Some("idem-negative-latency".to_owned()), + event: event_metadata(pubkey.as_str(), 30_402), request: request.clone(), request_fingerprint: "fingerprint-negative-latency".to_owned(), effective_target_count: 1, @@ -3958,6 +3987,7 @@ mod tests { .record_publish_job(PublishJobInsert { principal_id: principal.principal_id.clone(), idempotency_key: Some("idem-negative-latency".to_owned()), + event: event_metadata(pubkey.as_str(), 30_402), request, request_fingerprint: "fingerprint-negative-latency".to_owned(), effective_target_count: 1, @@ -3985,6 +4015,7 @@ mod tests { .record_publish_job(PublishJobInsert { principal_id: principal.principal_id.clone(), idempotency_key: Some("idem-explicit-drift".to_owned()), + event: event_metadata(pubkey.as_str(), 30_402), request, request_fingerprint: "fingerprint-explicit-drift".to_owned(), effective_target_count: 1, @@ -4029,6 +4060,7 @@ mod tests { .record_publish_job(PublishJobInsert { principal_id: principal.principal_id.clone(), idempotency_key: Some("idem-explicit-scope-drift".to_owned()), + event: event_metadata(pubkey.as_str(), 30_402), request, request_fingerprint: "fingerprint-explicit-scope-drift".to_owned(), effective_target_count: 1, @@ -4073,7 +4105,7 @@ mod tests { .create_principal(PublishPrincipalInit { label: "tester".to_owned(), token_hash, - allowed_pubkeys: vec![pubkey], + allowed_pubkeys: vec![pubkey.clone()], allowed_kinds: vec![30_402], allowed_target_policies: vec![TransportPublishTargetPolicyName::Nostr], allowed_explicit_transport_kinds: Vec::new(), @@ -4089,6 +4121,7 @@ mod tests { .record_publish_job(PublishJobInsert { principal_id: principal.principal_id.clone(), idempotency_key: Some("idem-interrupted".to_owned()), + event: event_metadata(pubkey.as_str(), 30_402), request, request_fingerprint: "fingerprint-interrupted".to_owned(), effective_target_count: 1, @@ -4144,6 +4177,7 @@ mod tests { .record_publish_job(PublishJobInsert { principal_id: principal.principal_id.clone(), idempotency_key: Some("idem-interrupted-scoped".to_owned()), + event: event_metadata(pubkey.as_str(), 30_402), request, request_fingerprint: "fingerprint-interrupted-scoped".to_owned(), effective_target_count: 1, @@ -4205,6 +4239,7 @@ mod tests { .record_publish_job(PublishJobInsert { principal_id: principal.principal_id.clone(), idempotency_key: Some("idem-recovered-drift".to_owned()), + event: event_metadata(pubkey.as_str(), 30_402), request, request_fingerprint: "fingerprint-recovered-drift".to_owned(), effective_target_count: 1, @@ -4247,7 +4282,7 @@ mod tests { .create_principal(PublishPrincipalInit { label: "tester".to_owned(), token_hash, - allowed_pubkeys: vec![pubkey], + allowed_pubkeys: vec![pubkey.clone()], allowed_kinds: vec![30_402], allowed_target_policies: vec![TransportPublishTargetPolicyName::Nostr], allowed_explicit_transport_kinds: Vec::new(), @@ -4293,9 +4328,9 @@ mod tests { "fingerprint-missing-snapshot", serde_json::to_string(&TransportPublishJobStatus::Publishing) .expect("status"), - request.event.id, - request.event.pubkey, - request.event.kind, + "0".repeat(64), + pubkey, + 30_402_i64, serde_json::to_string(&request.target_policy).expect("target policy"), serde_json::to_string(&request.delivery_policy).expect("delivery policy"), request.target_policy.request_target_count(), @@ -4651,7 +4686,7 @@ mod tests { PublishJobVisibility::Own, ); let event = signed_event(&identity, "{}"); - let raw_event = serde_json::to_string(&event).expect("raw event"); + let raw_event = event.clone(); let response = proxy .publish_event( &principal, @@ -4692,8 +4727,11 @@ mod tests { false, PublishJobVisibility::Own, ); - let mut event = signed_event(&identity, "trusted"); - event.content = "tampered".to_owned(); + let event = raw_event_with_field( + signed_event(&identity, "trusted"), + "content", + serde_json::Value::String("tampered".to_owned()), + ); let error = proxy .publish_event( &principal, @@ -4708,12 +4746,7 @@ mod tests { .await .expect_err("tampered event should fail"); - assert!(matches!( - error, - TransportPublishError::SignedEventVerification( - RadrootsNostrEventVerification::IdMismatch - ) - )); + assert!(matches!(error, TransportPublishError::EventWire(_))); assert!(adapter.captured_raw_events().is_empty()); } @@ -4728,9 +4761,13 @@ mod tests { false, PublishJobVisibility::Own, ); - let mut event = signed_event(&identity, "{}"); - let replacement = if event.sig.starts_with('0') { "1" } else { "0" }; - event.sig.replace_range(0..1, replacement); + let event = signed_event(&identity, "{}"); + let parsed: serde_json::Value = serde_json::from_str(event.as_str()).expect("event json"); + let sig = parsed["sig"].as_str().expect("sig"); + let replacement = if sig.starts_with('0') { "1" } else { "0" }; + let mut tampered_sig = sig.to_owned(); + tampered_sig.replace_range(0..1, replacement); + let event = raw_event_with_field(event, "sig", serde_json::Value::String(tampered_sig)); let error = proxy .publish_event( &principal, @@ -4747,9 +4784,7 @@ mod tests { assert!(matches!( error, - TransportPublishError::SignedEventVerification( - RadrootsNostrEventVerification::SignatureInvalid - ) + TransportPublishError::SignedEventSignature(_) )); assert!(adapter.captured_raw_events().is_empty()); } @@ -4765,8 +4800,10 @@ mod tests { false, PublishJobVisibility::Own, ); - let mut event = signed_event(&identity, "{}"); - event.id = event.id.to_uppercase(); + let event = signed_event(&identity, "{}"); + let parsed: serde_json::Value = serde_json::from_str(event.as_str()).expect("event json"); + let event_id = parsed["id"].as_str().expect("event id").to_uppercase(); + let event = raw_event_with_field(event, "id", serde_json::Value::String(event_id)); let error = proxy .publish_event( &principal, @@ -4781,10 +4818,7 @@ mod tests { .await .expect_err("malformed field should fail"); - assert!(matches!( - error, - TransportPublishError::InvalidSignedEvent(_) - )); + assert!(matches!(error, TransportPublishError::EventWire(_))); assert!(adapter.captured_raw_events().is_empty()); } @@ -5181,9 +5215,9 @@ mod tests { let principal = explicit_target_principal(&proxy, identity.public_key_hex(), PublishJobVisibility::Own); let event = signed_event(&identity, "{}"); - let raw_event = serde_json::to_string(&event).expect("raw event"); + let raw_event = event.clone(); let request = TransportPublishEventRequest { - event, + raw_event_json: event, target_policy: TransportPublishTargetPolicy::explicit_targets(vec![ TransportPublishTarget::nostr(RELAY_PRIMARY), ]), @@ -5219,9 +5253,9 @@ mod tests { let principal = explicit_target_principal(&proxy, identity.public_key_hex(), PublishJobVisibility::Own); let event = signed_event(&identity, "{}"); - let raw_event = serde_json::to_string(&event).expect("raw event"); + let raw_event = event.clone(); let request = TransportPublishEventRequest { - event, + raw_event_json: event, target_policy: TransportPublishTargetPolicy::explicit_targets(vec![ TransportPublishTarget::nostr(RELAY_PRIMARY) .with_scope("farm.local") @@ -5266,9 +5300,9 @@ mod tests { let principal = explicit_target_principal(&proxy, identity.public_key_hex(), PublishJobVisibility::Own); let event = signed_event(&identity, "{}"); - let raw_event = serde_json::to_string(&event).expect("raw event"); + let raw_event = event.clone(); let request = TransportPublishEventRequest { - event, + raw_event_json: event, target_policy: TransportPublishTargetPolicy::explicit_targets(vec![ TransportPublishTarget::nostr(RELAY_PRIMARY) .with_scope("farm.a") @@ -5337,7 +5371,7 @@ mod tests { let required_target = RadrootsTransportTarget::nostr_relay(RELAY_PRIMARY).expect("required target"); let request = TransportPublishEventRequest { - event: signed_event(&identity, "{}"), + raw_event_json: signed_event(&identity, "{}"), target_policy: TransportPublishTargetPolicy::explicit_targets(vec![ TransportPublishTarget::nostr(RELAY_PRIMARY), TransportPublishTarget::nostr(RELAY_SECONDARY), @@ -5384,7 +5418,7 @@ mod tests { let required_target = RadrootsTransportTarget::nostr_relay(RELAY_PRIMARY).expect("required target"); let request = TransportPublishEventRequest { - event: signed_event(&identity, "{}"), + raw_event_json: signed_event(&identity, "{}"), target_policy: TransportPublishTargetPolicy::explicit_targets(vec![ TransportPublishTarget::nostr(RELAY_PRIMARY), TransportPublishTarget::nostr(RELAY_SECONDARY), @@ -5425,7 +5459,7 @@ mod tests { let stale_target = RadrootsTransportTarget::nostr_relay(RELAY_SECONDARY).expect("stale target"); let request = TransportPublishEventRequest { - event: signed_event(&identity, "{}"), + raw_event_json: signed_event(&identity, "{}"), target_policy: TransportPublishTargetPolicy::explicit_targets(vec![ TransportPublishTarget::nostr(RELAY_PRIMARY), ]), @@ -5464,7 +5498,7 @@ mod tests { let principal = explicit_target_principal(&proxy, identity.public_key_hex(), PublishJobVisibility::Own); let request = TransportPublishEventRequest { - event: signed_event(&identity, "{}"), + raw_event_json: signed_event(&identity, "{}"), target_policy: TransportPublishTargetPolicy::explicit_targets(vec![ TransportPublishTarget::nostr(RELAY_PRIMARY), TransportPublishTarget::nostr(RELAY_PRIMARY), diff --git a/src/transport/jsonrpc/methods/mod.rs b/src/transport/jsonrpc/methods/mod.rs @@ -138,12 +138,12 @@ mod tests { .await .expect("request"); assert!(response.get().contains("\"scoped_bearer_token\"")); - assert!(response.get().contains("\"signed_event_ingress\":true")); + assert!(response.get().contains("\"raw_event_json_ingress\":true")); assert!(response.get().contains("\"transports\":[")); assert!( response .get() - .contains("\"api_version\":\"radrootsd.transport_publish.v4\"") + .contains("\"api_version\":\"radrootsd.transport_publish.v5\"") ); assert!(response.get().contains("\"transport\":\"reticulum\"")); assert!(response.get().contains("\"configured\":true")); diff --git a/src/transport/jsonrpc/methods/transport_publish.rs b/src/transport/jsonrpc/methods/transport_publish.rs @@ -37,7 +37,7 @@ fn register_capabilities( registry.track(METHOD_CAPABILITIES); module.register_async_method(METHOD_CAPABILITIES, |_params, ctx, extensions| async move { require_publish_principal(&extensions)?; - Ok::<TransportPublishCapabilities, RpcError>(TransportPublishCapabilities::v4( + Ok::<TransportPublishCapabilities, RpcError>(TransportPublishCapabilities::v5( ctx.state.transport_publish.config.max_event_bytes, ctx.state.transport_publish.config.max_targets_per_request, )) @@ -116,8 +116,9 @@ fn rpc_error_from_transport_publish(error: TransportPublishError) -> RpcError { match error { TransportPublishError::InvalidScope(message) => RpcError::Unauthorized(message), TransportPublishError::InvalidSignedEvent(message) => RpcError::InvalidParams(message), - TransportPublishError::SignedEventVerification(_) - | TransportPublishError::Draft(_) + TransportPublishError::EventWire(_) + | TransportPublishError::SignedEvent(_) + | TransportPublishError::SignedEventSignature(_) | TransportPublishError::Relay(_) => RpcError::InvalidParams(error.to_string()), TransportPublishError::IdempotencyConflict(_) => RpcError::Other(error.to_string()), other => RpcError::Other(other.to_string()), @@ -146,10 +147,10 @@ mod tests { }; use radroots_transport_nostr::RadrootsMockRelayPublishAdapter; use radroots_transport_publish_protocol::{ - NostrPublishTargetSourcePolicy, SignedEventWire, TransportPublishTargetPolicyName, + NostrPublishTargetSourcePolicy, TransportPublishTargetPolicyName, }; - fn signed_event(identity: &RadrootsIdentity) -> SignedEventWire { + fn signed_event(identity: &RadrootsIdentity) -> String { let event = radroots_nostr_build_event( 30_402, "{}", @@ -159,13 +160,13 @@ mod tests { .custom_created_at(RadrootsNostrTimestamp::from_secs(1_700_000_000)) .sign_with_keys(identity.keys()) .expect("signed event"); - serde_json::from_str(event.as_json().as_str()).expect("event wire") + event.as_json() } fn module_with_principal_and_config( admin: bool, transport_publish_config: TransportPublishConfig, - ) -> (RpcModule<RpcContext>, RpcContext, String, SignedEventWire) { + ) -> (RpcModule<RpcContext>, RpcContext, String, String) { let identity = RadrootsIdentity::generate(); let signed_event = signed_event(&identity); let metadata: RadrootsNostrMetadata = @@ -214,9 +215,7 @@ mod tests { (module, ctx, token, signed_event) } - fn module_with_principal( - admin: bool, - ) -> (RpcModule<RpcContext>, RpcContext, String, SignedEventWire) { + fn module_with_principal(admin: bool) -> (RpcModule<RpcContext>, RpcContext, String, String) { module_with_principal_and_config( admin, TransportPublishConfig { @@ -237,7 +236,7 @@ mod tests { "jsonrpc":"2.0", "method":"transport.publish.event", "params":{{ - "event":{}, + "raw_event_json":{}, "target_policy":{{"kind":"nostr","source_policy":"daemon_default_only","relay_urls":[]}}, "delivery_policy":{{"mode":"any"}}, "idempotency_key":"idem-1" @@ -268,7 +267,7 @@ mod tests { "jsonrpc":"2.0", "method":"transport.publish.event", "params":{{ - "event":{}, + "raw_event_json":{}, "target_policy":{{"kind":"nostr","source_policy":"daemon_default_only","relay_urls":[]}}, "delivery_policy":{{"mode":"any"}} }}, @@ -357,7 +356,7 @@ mod tests { "jsonrpc":"2.0", "method":"transport.publish.event", "params":{{ - "event":{}, + "raw_event_json":{}, "target_policy":{{"kind":"nostr","source_policy":"daemon_default_only","relay_urls":[]}}, "delivery_policy":{{"mode":"any"}}, "idempotency_key":"{idempotency_key}" diff --git a/src/transport/jsonrpc/server.rs b/src/transport/jsonrpc/server.rs @@ -259,14 +259,14 @@ mod tests { "jsonrpc":"2.0", "method":"transport.publish.event", "params":{{ - "event":{}, + "raw_event_json":{}, "target_policy":{{"kind":"nostr","source_policy":"daemon_default_only","relay_urls":[]}}, "delivery_policy":{{"mode":"any"}}, "idempotency_key":"raw-http-idem" }}, "id":1 }}"#, - event_json + serde_json::to_string(&event_json).expect("raw event param") ); let publish_response = post_json(addr, publish.as_str(), Some(token.as_str())).await; let publish_value = json_response_body(publish_response.as_str()); @@ -329,14 +329,14 @@ mod tests { "jsonrpc":"2.0", "method":"transport.publish.event", "params":{{ - "event":{}, + "raw_event_json":{}, "target_policy":{{"kind":"nostr","source_policy":"daemon_default_only","relay_urls":[]}}, "delivery_policy":{{"mode":"any"}}, "idempotency_key":"raw-http-public-dns-reject" }}, "id":1 }}"#, - event_json + serde_json::to_string(&event_json).expect("raw event param") ); let publish_response = post_json(addr, publish.as_str(), Some(token.as_str())).await; handle.stop().expect("stop server"); @@ -365,12 +365,12 @@ mod tests { "jsonrpc":"2.0", "method":"transport.publish.event", "params":{{ - "event":{}, + "raw_event_json":{}, "target_policy":{{"kind":"nostr","source_policy":"daemon_default_only","relay_urls":[]}}, "delivery_policy":{{"mode":"any"}} }} }}"#, - signed_event_json(&identity) + serde_json::to_string(&signed_event_json(&identity)).expect("raw event param") ); let response = post_json(addr, notification.as_str(), Some(token.as_str())).await; handle.stop().expect("stop server"); @@ -401,13 +401,13 @@ mod tests { "jsonrpc":"2.0", "method":"transport.publish.event", "params":{{ - "event":{}, + "raw_event_json":{}, "target_policy":{{"kind":"nostr","source_policy":"daemon_default_only","relay_urls":[]}}, "delivery_policy":{{"mode":"any"}} }}, "id":1 }}]"#, - signed_event_json(&identity) + serde_json::to_string(&signed_event_json(&identity)).expect("raw event param") ); let response = post_json(addr, batch.as_str(), Some(token.as_str())).await; handle.stop().expect("stop server"); diff --git a/tests/source_boundary.rs b/tests/source_boundary.rs @@ -409,7 +409,8 @@ fn transport_publish_capabilities_expose_per_transport_readiness() { ); for required in [ "transport.publish.capabilities", - r#"\"api_version\":\"radrootsd.transport_publish.v4\""#, + r#"\"api_version\":\"radrootsd.transport_publish.v5\""#, + r#"\"raw_event_json_ingress\":true"#, r#"\"transport\":\"reticulum\""#, r#"\"configured\":true"#, r#"\"implementation\":\"preview_unavailable\""#,