radrootsd

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

commit 433f1b58b1d8dda29a8e020d891f21d6c73fb0ab
parent 87a0f082773f2dc9b4f4295d2ef5bef9d7f16a57
Author: triesap <triesap@radroots.dev>
Date:   Tue,  6 Jan 2026 01:45:42 +0000

events: add profile publish rpc

- add events module with profile publish
- canonicalize profile metadata json
- support nip46-signed profile publishing
- reuse nip46 signing helper

Diffstat:
Asrc/api/jsonrpc/methods/events/mod.rs | 14++++++++++++++
Asrc/api/jsonrpc/methods/events/profile.rs | 134+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/api/jsonrpc/methods/mod.rs | 4+++-
Msrc/api/jsonrpc/methods/nip46/sign_event.rs | 31+------------------------------
Msrc/nip46/client.rs | 42+++++++++++++++++++++++++++++++++++++++++-
5 files changed, 193 insertions(+), 32 deletions(-)

diff --git a/src/api/jsonrpc/methods/events/mod.rs b/src/api/jsonrpc/methods/events/mod.rs @@ -0,0 +1,14 @@ +#![forbid(unsafe_code)] + +use anyhow::Result; +use jsonrpsee::server::RpcModule; + +use crate::api::jsonrpc::{MethodRegistry, RpcContext}; + +pub mod profile; + +pub fn module(ctx: RpcContext, registry: MethodRegistry) -> Result<RpcModule<RpcContext>> { + let mut m = RpcModule::new(ctx); + profile::register(&mut m, &registry)?; + Ok(m) +} diff --git a/src/api/jsonrpc/methods/events/profile.rs b/src/api/jsonrpc/methods/events/profile.rs @@ -0,0 +1,134 @@ +use anyhow::Result; +use jsonrpsee::server::RpcModule; +use serde::Deserialize; +use serde_json::{Map, Value}; + +use crate::api::jsonrpc::nostr::{publish_response, PublishResponse}; +use crate::api::jsonrpc::{MethodRegistry, RpcContext, RpcError}; +use crate::nip46::client as nip46_client; +use radroots_events::profile::{RadrootsProfile, RadrootsProfileType}; +use radroots_events_codec::profile::encode::to_wire_parts_with_profile_type; +use radroots_nostr::prelude::{radroots_nostr_build_event, radroots_nostr_send_event}; + +#[derive(Debug, Deserialize)] +struct PublishProfileParams { + profile: RadrootsProfile, + profile_type: RadrootsProfileType, + session_id: Option<String>, +} + +pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { + registry.track("events.profile.publish"); + m.register_async_method("events.profile.publish", |params, ctx, _| async move { + let PublishProfileParams { + profile, + profile_type, + session_id, + } = params + .parse() + .map_err(|e| RpcError::InvalidParams(e.to_string()))?; + + let parts = to_wire_parts_with_profile_type(&profile, Some(profile_type)) + .map_err(|e| RpcError::InvalidParams(e.to_string()))?; + let content = canonicalize_json_string(&parts.content)?; + let builder = radroots_nostr_build_event(parts.kind, content, parts.tags) + .map_err(|e| RpcError::Other(format!("failed to build profile event: {e}")))?; + + let response = match session_id { + Some(session_id) => publish_with_session(ctx.as_ref().clone(), session_id, builder).await?, + None => publish_with_runtime(ctx.as_ref().clone(), builder).await?, + }; + + Ok::<PublishResponse, RpcError>(response) + })?; + + Ok(()) +} + +async fn publish_with_runtime( + ctx: RpcContext, + builder: radroots_nostr::prelude::RadrootsNostrEventBuilder, +) -> Result<PublishResponse, RpcError> { + let relays = ctx.state.client.relays().await; + if relays.is_empty() { + return Err(RpcError::NoRelays); + } + + let output = radroots_nostr_send_event(&ctx.state.client, builder) + .await + .map_err(|e| RpcError::Other(format!("failed to publish metadata: {e}")))?; + + Ok(publish_response(output)) +} + +async fn publish_with_session( + ctx: RpcContext, + session_id: String, + builder: radroots_nostr::prelude::RadrootsNostrEventBuilder, +) -> Result<PublishResponse, RpcError> { + let session = ctx + .state + .nip46_sessions + .get(&session_id) + .await + .ok_or_else(|| RpcError::InvalidParams("unknown session".to_string()))?; + if session.relays.is_empty() { + return Err(RpcError::NoRelays); + } + let user_pubkey = session.user_pubkey.clone().ok_or_else(|| { + RpcError::InvalidParams("missing user pubkey; call nip46.get_public_key".to_string()) + })?; + let unsigned = builder.build(user_pubkey); + let signed = nip46_client::sign_event(&session, unsigned, "profile.publish").await?; + let output = session + .client + .send_event(&signed) + .await + .map_err(|e| RpcError::Other(format!("failed to publish metadata: {e}")))?; + + Ok(publish_response(output)) +} + +fn canonicalize_json_string(content: &str) -> Result<String, RpcError> { + let value: Value = serde_json::from_str(content) + .map_err(|e| RpcError::InvalidParams(format!("invalid metadata json: {e}")))?; + let canonical = canonicalize_value(value); + serde_json::to_string(&canonical) + .map_err(|e| RpcError::Other(format!("canonical json failed: {e}"))) +} + +fn canonicalize_value(value: Value) -> Value { + match value { + Value::Object(map) => canonicalize_object(map), + Value::Array(values) => { + let values = values + .into_iter() + .map(canonicalize_value) + .collect::<Vec<_>>(); + Value::Array(values) + } + other => other, + } +} + +fn canonicalize_object(map: Map<String, Value>) -> Value { + let mut entries = map.into_iter().collect::<Vec<_>>(); + entries.sort_by(|a, b| a.0.cmp(&b.0)); + let mut ordered = Map::new(); + for (key, value) in entries { + ordered.insert(key, canonicalize_value(value)); + } + Value::Object(ordered) +} + +#[cfg(test)] +mod tests { + use super::canonicalize_json_string; + + #[test] + fn canonicalize_json_string_orders_keys() { + let input = r#"{"b":1,"a":{"d":2,"c":3}}"#; + let canonical = canonicalize_json_string(input).expect("canonical"); + assert_eq!(canonical, r#"{"a":{"c":3,"d":2},"b":1}"#); + } +} diff --git a/src/api/jsonrpc/methods/mod.rs b/src/api/jsonrpc/methods/mod.rs @@ -7,6 +7,7 @@ use super::{context::RpcContext, registry::MethodRegistry}; pub mod nip46; pub mod relays; +pub mod events; pub fn register_all( root: &mut RpcModule<RpcContext>, @@ -14,6 +15,7 @@ pub fn register_all( registry: MethodRegistry, ) -> Result<()> { root.merge(relays::module(ctx.clone(), registry.clone())?)?; - root.merge(nip46::module(ctx, registry)?)?; + root.merge(nip46::module(ctx.clone(), registry.clone())?)?; + root.merge(events::module(ctx, registry)?)?; Ok(()) } diff --git a/src/api/jsonrpc/methods/nip46/sign_event.rs b/src/api/jsonrpc/methods/nip46/sign_event.rs @@ -7,7 +7,6 @@ use crate::api::jsonrpc::{MethodRegistry, RpcContext, RpcError}; use crate::nip46::client; use crate::nip46::session::Nip46Session; use radroots_nostr::prelude::{RadrootsNostrKind, RadrootsNostrTag, RadrootsNostrTimestamp}; -use nostr::nips::nip46::{NostrConnectMethod, NostrConnectRequest, ResponseResult}; use nostr::UnsignedEvent; #[derive(Debug, Deserialize)] @@ -65,35 +64,7 @@ async fn sign_event( input.content, ); - let request = NostrConnectRequest::SignEvent(unsigned); - let response = client::request(session, request, "sign_event").await?; - let response = response - .to_response(NostrConnectMethod::SignEvent) - .map_err(|e| RpcError::Other(format!("nip46 sign_event failed: {e}")))?; - - if let Some(error) = response.error { - return Err(RpcError::Other(format!("nip46 sign_event error: {error}"))); - } - - let event = match response.result { - Some(ResponseResult::SignEvent(event)) => *event, - Some(_) => { - return Err(RpcError::Other( - "nip46 sign_event unexpected response".to_string(), - )) - } - None => { - return Err(RpcError::Other( - "nip46 sign_event missing response".to_string(), - )) - } - }; - - event - .verify() - .map_err(|e| RpcError::Other(format!("nip46 sign_event invalid event: {e}")))?; - - Ok(event) + client::sign_event(session, unsigned, "sign_event").await } fn parse_tags(tags: Vec<Vec<String>>) -> Result<Vec<RadrootsNostrTag>, RpcError> { diff --git a/src/nip46/client.rs b/src/nip46/client.rs @@ -10,8 +10,48 @@ use radroots_nostr::prelude::{ RadrootsNostrFilter, RadrootsNostrKind, }; -use nostr::nips::{nip44, nip46::NostrConnectMessage, nip46::NostrConnectRequest}; +use nostr::nips::{ + nip44, + nip46::{NostrConnectMessage, NostrConnectMethod, NostrConnectRequest, ResponseResult}, +}; use nostr::JsonUtil; +use nostr::UnsignedEvent; + +pub async fn sign_event( + session: &Nip46Session, + unsigned: UnsignedEvent, + label: &str, +) -> Result<nostr::Event, RpcError> { + let req = NostrConnectRequest::SignEvent(unsigned); + let response = request(session, req, label).await?; + let response = response + .to_response(NostrConnectMethod::SignEvent) + .map_err(|e| RpcError::Other(format!("nip46 {label} failed: {e}")))?; + + if let Some(error) = response.error { + return Err(RpcError::Other(format!("nip46 {label} error: {error}"))); + } + + let event = match response.result { + Some(ResponseResult::SignEvent(event)) => *event, + Some(_) => { + return Err(RpcError::Other(format!( + "nip46 {label} unexpected response" + ))) + } + None => { + return Err(RpcError::Other(format!( + "nip46 {label} missing response" + ))) + } + }; + + event + .verify() + .map_err(|e| RpcError::Other(format!("nip46 {label} invalid event: {e}")))?; + + Ok(event) +} pub async fn request( session: &Nip46Session,