radrootsd

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

commit 204046dff715289199ddee562240fabb21272c32
parent 3d2363b31982d0b548fe9124ec2f9b14e04fdcf3
Author: triesap <tyson@radroots.org>
Date:   Tue, 23 Jun 2026 21:15:07 +0000

publish-proxy: harden rpc coverage

- reject malformed publish.job.list params
- reject zero job list limits before store access
- prove raw HTTP publish get and list requests
- assert raw HTTP preserves submitted signed events

Diffstat:
Msrc/transport/jsonrpc/methods/publish_proxy.rs | 127++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Msrc/transport/jsonrpc/server.rs | 90++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
2 files changed, 202 insertions(+), 15 deletions(-)

diff --git a/src/transport/jsonrpc/methods/publish_proxy.rs b/src/transport/jsonrpc/methods/publish_proxy.rs @@ -107,9 +107,18 @@ fn register_job_list(module: &mut RpcModule<RpcContext>, registry: &MethodRegist registry.track(METHOD_JOB_LIST); module.register_async_method(METHOD_JOB_LIST, |params, ctx, extensions| async move { let principal = require_publish_principal(&extensions)?; - let params = params - .parse::<JobListParams>() - .unwrap_or(JobListParams { limit: None }); + let params = if params.len_bytes() == 0 || params.as_str() == Some("[]") { + JobListParams { limit: None } + } else { + params + .parse::<JobListParams>() + .map_err(|error| RpcError::InvalidParams(error.to_string()))? + }; + if params.limit == Some(0) { + return Err(RpcError::InvalidParams( + "limit must be greater than zero".to_owned(), + )); + } let configured_limit = ctx.state.publish_proxy.config.job_list_limit; let limit = params .limit @@ -221,8 +230,9 @@ mod tests { serde_json::from_str(event.as_json().as_str()).expect("event wire") } - fn module_with_principal( + fn module_with_principal_and_config( admin: bool, + publish_proxy_config: PublishProxyConfig, ) -> ( RpcModule<RpcContext>, RpcContext, @@ -233,10 +243,6 @@ mod tests { let signed_event = signed_event(&identity); let metadata: RadrootsNostrMetadata = serde_json::from_str(r#"{"name":"radrootsd-test"}"#).expect("metadata"); - let publish_proxy_config = PublishProxyConfig { - daemon_default_publish_relays: vec!["wss://relay.example.com".to_owned()], - ..PublishProxyConfig::default() - }; let state = Radrootsd::new( identity.clone(), metadata, @@ -277,6 +283,23 @@ mod tests { (module, ctx, token, signed_event) } + fn module_with_principal( + admin: bool, + ) -> ( + RpcModule<RpcContext>, + RpcContext, + String, + SignedNostrEventWire, + ) { + module_with_principal_and_config( + admin, + PublishProxyConfig { + daemon_default_publish_relays: vec!["wss://relay.example.com".to_owned()], + ..PublishProxyConfig::default() + }, + ) + } + #[tokio::test] async fn publish_event_records_job_and_deduplicates_idempotency() { let (module, _ctx, _token, event) = module_with_principal(false); @@ -334,6 +357,94 @@ mod tests { } #[tokio::test] + async fn publish_job_list_rejects_malformed_and_zero_limits() { + let (module, _ctx, _token, _event) = module_with_principal(false); + let malformed = r#"{ + "jsonrpc":"2.0", + "method":"publish.job.list", + "params":"bad", + "id":1 + }"#; + let (response, _stream) = module + .raw_json_request(malformed, 1) + .await + .expect("malformed request"); + assert!(response.get().contains("\"code\":-32602")); + + let zero = r#"{ + "jsonrpc":"2.0", + "method":"publish.job.list", + "params":{"limit":0}, + "id":1 + }"#; + let (response, _stream) = module + .raw_json_request(zero, 1) + .await + .expect("zero request"); + assert!(response.get().contains("\"code\":-32602")); + assert!(response.get().contains("limit must be greater than zero")); + } + + #[tokio::test] + async fn publish_job_list_uses_configured_limit_when_omitted_and_caps_positive_limits() { + let mut config = PublishProxyConfig { + daemon_default_publish_relays: vec!["wss://relay.example.com".to_owned()], + ..PublishProxyConfig::default() + }; + config.job_list_limit = 1; + let (module, _ctx, _token, event) = module_with_principal_and_config(false, config); + for idempotency_key in ["idem-list-1", "idem-list-2"] { + let request = format!( + r#"{{ + "jsonrpc":"2.0", + "method":"publish.event", + "params":{{ + "event":{}, + "relays":[], + "relay_policy":"daemon_default_only", + "delivery_policy":{{"mode":"any"}}, + "idempotency_key":"{idempotency_key}" + }}, + "id":1 + }}"#, + serde_json::to_string(&event).expect("event json") + ); + let (response, _stream) = module + .raw_json_request(request.as_str(), 1) + .await + .expect("publish request"); + assert!(response.get().contains("\"deduplicated\":false")); + } + + let omitted = r#"{ + "jsonrpc":"2.0", + "method":"publish.job.list", + "id":1 + }"#; + let (response, _stream) = module + .raw_json_request(omitted, 1) + .await + .expect("omitted request"); + let value: serde_json::Value = + serde_json::from_str(response.get()).expect("omitted response json"); + assert_eq!(value["result"].as_array().expect("jobs").len(), 1); + + let over_limit = r#"{ + "jsonrpc":"2.0", + "method":"publish.job.list", + "params":{"limit":50}, + "id":1 + }"#; + let (response, _stream) = module + .raw_json_request(over_limit, 1) + .await + .expect("over limit request"); + let value: serde_json::Value = + serde_json::from_str(response.get()).expect("over limit response json"); + assert_eq!(value["result"].as_array().expect("jobs").len(), 1); + } + + #[tokio::test] async fn publish_relays_resolve_returns_daemon_default_targets() { let (module, _ctx, _token, event) = module_with_principal(false); let request = format!( diff --git a/src/transport/jsonrpc/server.rs b/src/transport/jsonrpc/server.rs @@ -76,7 +76,9 @@ where #[cfg(test)] mod tests { use super::start_server; - use crate::app::config::{Nip46Config, PublishProxyConfig, RpcConfig}; + use crate::app::config::{ + Nip46Config, PublishProxyConfig, PublishProxyRelayUrlPolicy, RpcConfig, + }; use crate::core::Radrootsd; use crate::core::publish_proxy::{ PublishJobVisibility, PublishPrincipalInit, generate_bearer_token, hash_bearer_token, @@ -91,11 +93,12 @@ mod tests { }; use radroots_publish_proxy_protocol::PublishRelayPolicy; use radroots_relay_transport::RadrootsMockRelayPublishAdapter; + use serde_json::Value; use std::net::{SocketAddr, TcpListener}; use std::sync::Arc; use tokio::io::{AsyncReadExt, AsyncWriteExt}; - const RELAY_PRIMARY: &str = "wss://relay.example.com"; + const RELAY_PRIMARY: &str = "ws://localhost:7777"; fn unused_addr() -> SocketAddr { let listener = TcpListener::bind("127.0.0.1:0").expect("bind local addr"); @@ -133,12 +136,18 @@ mod tests { String::from_utf8(bytes).expect("response utf8") } - fn publish_server_state() -> (Radrootsd, String, RadrootsIdentity) { + fn publish_server_state() -> ( + Radrootsd, + String, + RadrootsIdentity, + RadrootsMockRelayPublishAdapter, + ) { let identity = RadrootsIdentity::generate(); let metadata: RadrootsNostrMetadata = serde_json::from_str(r#"{"name":"radrootsd-test"}"#).expect("metadata"); let publish_proxy_config = PublishProxyConfig { daemon_default_publish_relays: vec![RELAY_PRIMARY.to_owned()], + relay_url_policy: PublishProxyRelayUrlPolicy::Localhost, ..PublishProxyConfig::default() }; let mut state = Radrootsd::new( @@ -148,10 +157,11 @@ mod tests { Nip46Config::default(), ) .expect("state"); + let adapter = RadrootsMockRelayPublishAdapter::new(); state.publish_proxy = state .publish_proxy .clone() - .with_publisher(Arc::new(RadrootsMockRelayPublishAdapter::new())); + .with_publisher(Arc::new(adapter.clone())); let token = generate_bearer_token(); state .publish_proxy @@ -167,7 +177,7 @@ mod tests { expires_at_unix: None, }) .expect("principal"); - (state, token, identity) + (state, token, identity, adapter) } async fn start_publish_server( @@ -186,9 +196,75 @@ mod tests { (addr, handle) } + fn json_response_body(response: &str) -> Value { + let (_headers, body) = response.split_once("\r\n\r\n").expect("http body"); + serde_json::from_str(body).expect("json response body") + } + + #[tokio::test] + async fn raw_http_publish_event_get_and_list_preserve_signed_event() { + let (state, token, identity, adapter) = publish_server_state(); + let event_json = signed_event_json(&identity); + let (addr, handle) = start_publish_server(state, RpcConfig::default()).await; + let publish = format!( + r#"{{ + "jsonrpc":"2.0", + "method":"publish.event", + "params":{{ + "event":{}, + "relays":[], + "relay_policy":"daemon_default_only", + "delivery_policy":{{"mode":"any"}}, + "idempotency_key":"raw-http-idem" + }}, + "id":1 + }}"#, + event_json + ); + let publish_response = post_json(addr, publish.as_str(), Some(token.as_str())).await; + let publish_value = json_response_body(publish_response.as_str()); + let job_id = publish_value["result"]["job"]["job_id"] + .as_str() + .expect("job id") + .to_owned(); + assert_eq!(publish_value["result"]["deduplicated"], false); + assert_eq!( + publish_value["result"]["job"]["status"], + "delivery_satisfied" + ); + + let get = format!( + r#"{{ + "jsonrpc":"2.0", + "method":"publish.job.get", + "params":{{"job_id":"{job_id}"}}, + "id":2 + }}"# + ); + let get_response = post_json(addr, get.as_str(), Some(token.as_str())).await; + let get_value = json_response_body(get_response.as_str()); + assert_eq!(get_value["result"]["job_id"], job_id); + assert_eq!(get_value["result"]["status"], "delivery_satisfied"); + + let list = r#"{ + "jsonrpc":"2.0", + "method":"publish.job.list", + "params":{"limit":10}, + "id":3 + }"#; + let list_response = post_json(addr, list, Some(token.as_str())).await; + let list_value = json_response_body(list_response.as_str()); + let jobs = list_value["result"].as_array().expect("jobs"); + assert_eq!(jobs.len(), 1); + assert_eq!(jobs[0]["job_id"], job_id); + handle.stop().expect("stop server"); + + assert_eq!(adapter.captured_raw_events(), vec![event_json]); + } + #[tokio::test] async fn publish_notifications_do_not_create_jobs() { - let (state, token, identity) = publish_server_state(); + let (state, token, identity, _adapter) = publish_server_state(); let store = state.publish_proxy.store.clone(); let (addr, handle) = start_publish_server(state, RpcConfig::default()).await; let notification = format!( @@ -225,7 +301,7 @@ mod tests { #[tokio::test] async fn batch_requests_are_disabled_by_default() { - let (state, token, identity) = publish_server_state(); + let (state, token, identity, _adapter) = publish_server_state(); let store = state.publish_proxy.store.clone(); let (addr, handle) = start_publish_server(state, RpcConfig::default()).await; let batch = format!(