radrootsd

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

commit 92761a7f6b505fe881a73a9addd4f5fa379e834f
parent 67f46d7d2f1849604b6ae5d1d39656731c16f820
Author: triesap <triesap@radroots.dev>
Date:   Tue,  6 Jan 2026 00:17:16 +0000

nip46: derive clone for rpc responses

- derive Clone for nip46 status response
- derive Clone for nip46 connect mode and info
- satisfy jsonrpsee IntoResponse bounds
- keep rpc payload types consistent

Diffstat:
MCargo.lock | 1+
MCargo.toml | 1+
Asrc/api/jsonrpc/methods/nip46/connect.rs | 23+++++++++++++++++++++++
Msrc/api/jsonrpc/methods/nip46/mod.rs | 2++
Msrc/api/jsonrpc/methods/nip46/status.rs | 2+-
Asrc/nip46/connection.rs | 187+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/nip46/mod.rs | 2++
7 files changed, 217 insertions(+), 1 deletion(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1873,6 +1873,7 @@ dependencies = [ "thiserror 1.0.69", "tokio", "tracing", + "url", "uuid", ] diff --git a/Cargo.toml b/Cargo.toml @@ -26,3 +26,4 @@ tokio = { version = "1", features = ["full"] } thiserror = { version = "1" } tracing = { version = "0.1" } uuid = { version = "1.16.0", features = ["v4"] } +url = { version = "2.5.4" } diff --git a/src/api/jsonrpc/methods/nip46/connect.rs b/src/api/jsonrpc/methods/nip46/connect.rs @@ -0,0 +1,23 @@ +use anyhow::Result; +use jsonrpsee::server::RpcModule; +use serde::Deserialize; + +use crate::api::jsonrpc::{MethodRegistry, RpcContext, RpcError}; +use crate::nip46::connection::{parse_connect_url, Nip46ConnectInfo}; + +#[derive(Debug, Deserialize)] +struct Nip46ConnectParams { + url: String, +} + +pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { + registry.track("nip46.connect"); + m.register_method("nip46.connect", |params, _ctx, _| { + let Nip46ConnectParams { url } = params + .parse() + .map_err(|e| RpcError::InvalidParams(e.to_string()))?; + let info: Nip46ConnectInfo = parse_connect_url(&url)?; + Ok::<Nip46ConnectInfo, RpcError>(info) + })?; + Ok(()) +} diff --git a/src/api/jsonrpc/methods/nip46/mod.rs b/src/api/jsonrpc/methods/nip46/mod.rs @@ -6,9 +6,11 @@ use jsonrpsee::server::RpcModule; use crate::api::jsonrpc::{MethodRegistry, RpcContext}; pub mod status; +pub mod connect; pub fn module(ctx: RpcContext, registry: MethodRegistry) -> Result<RpcModule<RpcContext>> { let mut m = RpcModule::new(ctx); status::register(&mut m, &registry)?; + connect::register(&mut m, &registry)?; Ok(m) } diff --git a/src/api/jsonrpc/methods/nip46/status.rs b/src/api/jsonrpc/methods/nip46/status.rs @@ -4,7 +4,7 @@ use serde::Serialize; use crate::api::jsonrpc::{MethodRegistry, RpcContext, RpcError}; -#[derive(Debug, Serialize)] +#[derive(Clone, Debug, Serialize)] struct Nip46StatusResponse { ready: bool, } diff --git a/src/nip46/connection.rs b/src/nip46/connection.rs @@ -0,0 +1,187 @@ +#![forbid(unsafe_code)] + +use serde::Serialize; +use url::Url; + +use crate::api::jsonrpc::RpcError; +use radroots_nostr::prelude::radroots_nostr_parse_pubkey; + +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Nip46ConnectMode { + Bunker, + Nostrconnect, +} + +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +pub struct Nip46ConnectInfo { + pub mode: Nip46ConnectMode, + pub remote_signer_pubkey: Option<String>, + pub client_pubkey: Option<String>, + pub relays: Vec<String>, + pub secret: Option<String>, + pub perms: Vec<String>, + pub name: Option<String>, + pub url: Option<String>, + pub image: Option<String>, +} + +pub fn parse_connect_url(raw: &str) -> Result<Nip46ConnectInfo, RpcError> { + let url = Url::parse(raw) + .map_err(|e| RpcError::InvalidParams(format!("invalid connect url: {e}")))?; + match url.scheme() { + "bunker" => parse_bunker_url(&url), + "nostrconnect" => parse_nostrconnect_url(&url), + scheme => Err(RpcError::InvalidParams(format!( + "unsupported connect scheme: {scheme}" + ))), + } +} + +fn parse_bunker_url(url: &Url) -> Result<Nip46ConnectInfo, RpcError> { + let username = url.username(); + let host = url + .host_str() + .ok_or_else(|| RpcError::InvalidParams("missing remote signer".to_string()))?; + let remote_signer_raw = if username.is_empty() { host } else { username }; + let remote_signer = radroots_nostr_parse_pubkey(remote_signer_raw) + .map_err(|e| RpcError::InvalidParams(format!("invalid remote signer: {e}")))? + .to_hex(); + + let mut relays = parse_relays(url); + if !username.is_empty() { + if let Some(relay_host) = host_to_relay(url) { + relays.insert(0, relay_host); + } + } + + Ok(Nip46ConnectInfo { + mode: Nip46ConnectMode::Bunker, + remote_signer_pubkey: Some(remote_signer), + client_pubkey: None, + relays, + secret: parse_optional_param(url, "secret"), + perms: parse_perms(url), + name: parse_optional_param(url, "name"), + url: parse_optional_param(url, "url"), + image: parse_optional_param(url, "image"), + }) +} + +fn parse_nostrconnect_url(url: &Url) -> Result<Nip46ConnectInfo, RpcError> { + let host = url + .host_str() + .ok_or_else(|| RpcError::InvalidParams("missing client pubkey".to_string()))?; + let client_pubkey = radroots_nostr_parse_pubkey(host) + .map_err(|e| RpcError::InvalidParams(format!("invalid client pubkey: {e}")))? + .to_hex(); + + let relays = parse_relays(url); + if relays.is_empty() { + return Err(RpcError::InvalidParams("missing relay".to_string())); + } + + let secret = parse_optional_param(url, "secret") + .ok_or_else(|| RpcError::InvalidParams("missing secret".to_string()))?; + + Ok(Nip46ConnectInfo { + mode: Nip46ConnectMode::Nostrconnect, + remote_signer_pubkey: None, + client_pubkey: Some(client_pubkey), + relays, + secret: Some(secret), + perms: parse_perms(url), + name: parse_optional_param(url, "name"), + url: parse_optional_param(url, "url"), + image: parse_optional_param(url, "image"), + }) +} + +fn parse_relays(url: &Url) -> Vec<String> { + url.query_pairs() + .filter_map(|(key, value)| { + if key == "relay" && !value.trim().is_empty() { + Some(value.to_string()) + } else { + None + } + }) + .collect() +} + +fn parse_optional_param(url: &Url, key: &str) -> Option<String> { + url.query_pairs() + .find_map(|(k, value)| { + if k == key { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + } else { + None + } + }) +} + +fn parse_perms(url: &Url) -> Vec<String> { + parse_optional_param(url, "perms") + .map(|value| { + value + .split(',') + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .map(|entry| entry.to_string()) + .collect() + }) + .unwrap_or_default() +} + +fn host_to_relay(url: &Url) -> Option<String> { + let host = url.host_str()?; + let port = url.port(); + let base = match port { + Some(port) => format!("{host}:{port}"), + None => host.to_string(), + }; + Some(format!("wss://{base}")) +} + +#[cfg(test)] +mod tests { + use super::{parse_connect_url, Nip46ConnectMode}; + + const HEX_PUBKEY: &str = + "1bdebe7b23fccb167fc8843280b789839dfa296ae9fd86cc9769b4813d76d8a4"; + + #[test] + fn parse_bunker_with_relay_host() { + let url = format!("bunker://{HEX_PUBKEY}@relay.example.com"); + let info = parse_connect_url(&url).expect("info"); + assert_eq!(info.mode, Nip46ConnectMode::Bunker); + assert_eq!(info.remote_signer_pubkey.as_deref(), Some(HEX_PUBKEY)); + assert_eq!(info.relays, vec!["wss://relay.example.com"]); + } + + #[test] + fn parse_bunker_with_query_relay() { + let url = format!("bunker://{HEX_PUBKEY}?relay=wss%3A%2F%2Frelay.example.com&secret=abc"); + let info = parse_connect_url(&url).expect("info"); + assert_eq!(info.mode, Nip46ConnectMode::Bunker); + assert_eq!(info.remote_signer_pubkey.as_deref(), Some(HEX_PUBKEY)); + assert_eq!(info.relays, vec!["wss://relay.example.com"]); + assert_eq!(info.secret.as_deref(), Some("abc")); + } + + #[test] + fn parse_nostrconnect_requires_secret_and_relay() { + let url = format!("nostrconnect://{HEX_PUBKEY}?relay=wss%3A%2F%2Frelay.example.com&secret=token&perms=sign_event%3A1,nip44_encrypt"); + let info = parse_connect_url(&url).expect("info"); + assert_eq!(info.mode, Nip46ConnectMode::Nostrconnect); + assert_eq!(info.client_pubkey.as_deref(), Some(HEX_PUBKEY)); + assert_eq!(info.relays, vec!["wss://relay.example.com"]); + assert_eq!(info.secret.as_deref(), Some("token")); + assert_eq!(info.perms.len(), 2); + } +} diff --git a/src/nip46/mod.rs b/src/nip46/mod.rs @@ -1 +1,3 @@ #![forbid(unsafe_code)] + +pub mod connection;