radrootsd

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

commit 31216709a03aa95680e4af582d35184a61a5fc52
parent cc9a808ffe1f28fa6ff8ff601ed165b42ef661d3
Author: triesap <triesap@radroots.dev>
Date:   Tue,  6 Jan 2026 15:52:15 +0000

nostr: add nip46 listener scaffold

- add transport module and nostr listener entrypoint
- subscribe to nip46 events addressed to service pubkey
- start listener alongside relay connection when relays configured
- log incoming nip46 request events without handling

Diffstat:
M.gitignore | 8--------
Dsrc/api/jsonrpc/context.rs | 17-----------------
Dsrc/api/jsonrpc/error.rs | 30------------------------------
Dsrc/api/jsonrpc/methods/events/mod.rs | 14--------------
Dsrc/api/jsonrpc/methods/events/profile.rs | 134-------------------------------------------------------------------------------
Dsrc/api/jsonrpc/methods/mod.rs | 21---------------------
Dsrc/api/jsonrpc/methods/nip46/connect.rs | 297-------------------------------------------------------------------------------
Dsrc/api/jsonrpc/methods/nip46/get_public_key.rs | 71-----------------------------------------------------------------------
Dsrc/api/jsonrpc/methods/nip46/mod.rs | 34----------------------------------
Dsrc/api/jsonrpc/methods/nip46/nip04_decrypt.rs | 76----------------------------------------------------------------------------
Dsrc/api/jsonrpc/methods/nip46/nip04_encrypt.rs | 73-------------------------------------------------------------------------
Dsrc/api/jsonrpc/methods/nip46/nip44_decrypt.rs | 76----------------------------------------------------------------------------
Dsrc/api/jsonrpc/methods/nip46/nip44_encrypt.rs | 73-------------------------------------------------------------------------
Dsrc/api/jsonrpc/methods/nip46/ping.rs | 58----------------------------------------------------------
Dsrc/api/jsonrpc/methods/nip46/session_close.rs | 34----------------------------------
Dsrc/api/jsonrpc/methods/nip46/session_status.rs | 42------------------------------------------
Dsrc/api/jsonrpc/methods/nip46/sign_event.rs | 78------------------------------------------------------------------------------
Dsrc/api/jsonrpc/methods/nip46/status.rs | 18------------------
Dsrc/api/jsonrpc/methods/relays/add.rs | 28----------------------------
Dsrc/api/jsonrpc/methods/relays/connect.rs | 43-------------------------------------------
Dsrc/api/jsonrpc/methods/relays/list.rs | 14--------------
Dsrc/api/jsonrpc/methods/relays/mod.rs | 22----------------------
Dsrc/api/jsonrpc/methods/relays/remove.rs | 28----------------------------
Dsrc/api/jsonrpc/methods/relays/status.rs | 64----------------------------------------------------------------
Dsrc/api/jsonrpc/mod.rs | 40----------------------------------------
Dsrc/api/jsonrpc/nostr.rs | 64----------------------------------------------------------------
Dsrc/api/jsonrpc/params.rs | 132-------------------------------------------------------------------------------
Dsrc/api/jsonrpc/registry.rs | 23-----------------------
Dsrc/api/jsonrpc/relays.rs | 34----------------------------------
Dsrc/api/jsonrpc/server.rs | 30------------------------------
Dsrc/api/mod.rs | 3---
Asrc/app/mod.rs | 0
Dsrc/build/mod.rs | 1-
Dsrc/cli.rs | 35-----------------------------------
Dsrc/config.rs | 83-------------------------------------------------------------------------------
Asrc/core/mod.rs | 1+
Asrc/core/state.rs | 36++++++++++++++++++++++++++++++++++++
Dsrc/events/mod.rs | 1-
Msrc/lib.rs | 106+++----------------------------------------------------------------------------
Msrc/main.rs | 33+--------------------------------
Dsrc/nip46/client.rs | 156-------------------------------------------------------------------------------
Dsrc/nip46/connection.rs | 211-------------------------------------------------------------------------------
Dsrc/nip46/mod.rs | 5-----
Dsrc/nip46/session.rs | 66------------------------------------------------------------------
Dsrc/radrootsd.rs | 42------------------------------------------
Asrc/transport/jsonrpc/mod.rs | 0
Asrc/transport/mod.rs | 2++
Asrc/transport/nostr/listener.rs | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/transport/nostr/mod.rs | 1+
Dsrc/validate/mod.rs | 1-
50 files changed, 104 insertions(+), 2415 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -20,17 +20,9 @@ Thumbs.db *.pem # local -.tmp* -.archive* .local* .vscode notes*.txt -notes*.md -notes*.json -tree*.txt -git-diff*.txt -prompt*.txt -tree*.txt justfile # dev diff --git a/src/api/jsonrpc/context.rs b/src/api/jsonrpc/context.rs @@ -1,17 +0,0 @@ -#![forbid(unsafe_code)] - -use crate::radrootsd::Radrootsd; - -use super::registry::MethodRegistry; - -#[derive(Clone)] -pub struct RpcContext { - pub state: Radrootsd, - pub methods: MethodRegistry, -} - -impl RpcContext { - pub fn new(state: Radrootsd, methods: MethodRegistry) -> Self { - Self { state, methods } - } -} diff --git a/src/api/jsonrpc/error.rs b/src/api/jsonrpc/error.rs @@ -1,30 +0,0 @@ -#![forbid(unsafe_code)] - -use jsonrpsee::types::{ErrorObject, ErrorObjectOwned}; -use thiserror::Error; - -#[derive(Debug, Error)] -pub enum RpcError { - #[error("failed to add relay {0}: {1}")] - AddRelay(String, String), - #[error("no relays configured; call relays.add first")] - NoRelays, - #[error("invalid params: {0}")] - InvalidParams(String), - #[error("method not found: {0}")] - MethodNotFound(String), - #[error("{0}")] - Other(String), -} - -impl From<RpcError> for ErrorObjectOwned { - fn from(err: RpcError) -> Self { - match err { - RpcError::InvalidParams(msg) => ErrorObject::owned(-32602, msg, None::<()>), - RpcError::MethodNotFound(name) => { - ErrorObject::owned(-32601, format!("method not found: {name}"), None::<()>) - } - other => ErrorObject::owned(-32000, other.to_string(), None::<()>), - } - } -} diff --git a/src/api/jsonrpc/methods/events/mod.rs b/src/api/jsonrpc/methods/events/mod.rs @@ -1,14 +0,0 @@ -#![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 @@ -1,134 +0,0 @@ -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 @@ -1,21 +0,0 @@ -#![forbid(unsafe_code)] - -use anyhow::Result; -use jsonrpsee::server::RpcModule; - -use super::{context::RpcContext, registry::MethodRegistry}; - -pub mod nip46; -pub mod relays; -pub mod events; - -pub fn register_all( - root: &mut RpcModule<RpcContext>, - ctx: RpcContext, - registry: MethodRegistry, -) -> Result<()> { - root.merge(relays::module(ctx.clone(), registry.clone())?)?; - root.merge(nip46::module(ctx.clone(), registry.clone())?)?; - root.merge(events::module(ctx, registry)?)?; - Ok(()) -} diff --git a/src/api/jsonrpc/methods/nip46/connect.rs b/src/api/jsonrpc/methods/nip46/connect.rs @@ -1,297 +0,0 @@ -use std::time::Duration; - -use anyhow::Result; -use jsonrpsee::server::RpcModule; -use serde::{Deserialize, Serialize}; -use tokio::sync::broadcast; -use tokio::time::sleep; -use uuid::Uuid; - -use crate::api::jsonrpc::params::DEFAULT_TIMEOUT_SECS; -use crate::api::jsonrpc::{MethodRegistry, RpcContext, RpcError}; -use crate::nip46::connection::{parse_connect_url, Nip46ConnectInfo, Nip46ConnectMode}; -use crate::nip46::session::Nip46Session; -use radroots_nostr::prelude::{ - radroots_nostr_filter_tag, - radroots_nostr_parse_pubkey, - RadrootsNostrClient, - RadrootsNostrEventBuilder, - RadrootsNostrFilter, - RadrootsNostrKind, - RadrootsNostrKeys, - RadrootsNostrPublicKey, - RadrootsNostrRelayPoolNotification, - RadrootsNostrTimestamp, -}; -use nostr::nips::{nip44, nip46::NostrConnectMessage, nip46::NostrConnectRequest}; -use nostr::JsonUtil; - -#[derive(Debug, Deserialize)] -struct Nip46ConnectParams { - url: String, -} - -#[derive(Clone, Debug, Serialize)] -struct Nip46ConnectResponse { - session_id: String, - mode: Nip46ConnectMode, - remote_signer_pubkey: String, - client_pubkey: String, - relays: Vec<String>, -} - -pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { - registry.track("nip46.connect"); - m.register_async_method("nip46.connect", |params, ctx, _| async move { - let Nip46ConnectParams { url } = params - .parse() - .map_err(|e| RpcError::InvalidParams(e.to_string()))?; - let response = connect_nip46(ctx.as_ref().clone(), url).await?; - Ok::<Nip46ConnectResponse, RpcError>(response) - })?; - Ok(()) -} - -async fn connect_nip46(ctx: RpcContext, url: String) -> Result<Nip46ConnectResponse, RpcError> { - let info = parse_connect_url(&url)?; - match info.mode { - Nip46ConnectMode::Bunker => connect_bunker(ctx, info).await, - Nip46ConnectMode::Nostrconnect => Err(RpcError::InvalidParams( - "nostrconnect mode not supported yet".to_string(), - )), - } -} - -async fn connect_bunker( - ctx: RpcContext, - info: Nip46ConnectInfo, -) -> Result<Nip46ConnectResponse, RpcError> { - if info.relays.is_empty() { - return Err(RpcError::InvalidParams("missing relay".to_string())); - } - - let remote_signer_raw = info.remote_signer_pubkey.as_ref().ok_or_else(|| { - RpcError::InvalidParams("missing remote signer pubkey".to_string()) - })?; - let remote_signer_pubkey = radroots_nostr_parse_pubkey(remote_signer_raw) - .map_err(|e| RpcError::InvalidParams(format!("invalid remote signer: {e}")))?; - - let client_keys = RadrootsNostrKeys::generate(); - let client_pubkey = client_keys.public_key(); - let client = RadrootsNostrClient::new(client_keys.clone()); - - add_relays(&client, &info.relays).await?; - client.connect().await; - client - .wait_for_connection(Duration::from_secs(DEFAULT_TIMEOUT_SECS)) - .await; - - let request_id = send_connect_request( - &client, - &client_keys, - &remote_signer_pubkey, - info.secret.as_deref(), - ) - .await?; - - let response = wait_for_connect_response( - &client, - &client_keys, - &remote_signer_pubkey, - &client_pubkey, - &request_id, - ) - .await?; - - validate_connect_response(&response, info.secret.as_deref())?; - - let session_id = Uuid::new_v4().to_string(); - let session = Nip46Session { - id: session_id.clone(), - client, - client_keys, - client_pubkey, - remote_signer_pubkey, - user_pubkey: None, - relays: info.relays.clone(), - }; - ctx.state.nip46_sessions.insert(session).await; - - Ok(Nip46ConnectResponse { - session_id, - mode: info.mode, - remote_signer_pubkey: remote_signer_raw.to_string(), - client_pubkey: client_pubkey.to_hex(), - relays: info.relays, - }) -} - -async fn add_relays( - client: &RadrootsNostrClient, - relays: &[String], -) -> Result<(), RpcError> { - for relay in relays { - client - .add_relay(relay) - .await - .map_err(|e| RpcError::AddRelay(relay.to_string(), e.to_string()))?; - } - Ok(()) -} - -async fn send_connect_request( - client: &RadrootsNostrClient, - client_keys: &RadrootsNostrKeys, - remote_signer_pubkey: &RadrootsNostrPublicKey, - secret: Option<&str>, -) -> Result<String, RpcError> { - let request = NostrConnectRequest::Connect { - remote_signer_public_key: remote_signer_pubkey.clone(), - secret: secret.map(str::to_string), - }; - let message = NostrConnectMessage::request(&request); - let request_id = message.id().to_string(); - let event = RadrootsNostrEventBuilder::nostr_connect( - client_keys, - remote_signer_pubkey.clone(), - message, - ) - .map_err(|e| RpcError::Other(format!("nip46 connect request failed: {e}")))?; - client - .send_event_builder(event) - .await - .map_err(|e| RpcError::Other(format!("nip46 connect request failed: {e}")))?; - Ok(request_id) -} - -async fn wait_for_connect_response( - client: &RadrootsNostrClient, - client_keys: &RadrootsNostrKeys, - remote_signer_pubkey: &RadrootsNostrPublicKey, - client_pubkey: &RadrootsNostrPublicKey, - request_id: &str, -) -> Result<NostrConnectMessage, RpcError> { - let filter = RadrootsNostrFilter::new() - .kind(RadrootsNostrKind::NostrConnect) - .author(remote_signer_pubkey.clone()) - .since(RadrootsNostrTimestamp::now()); - let filter = radroots_nostr_filter_tag(filter, "p", vec![client_pubkey.to_hex()]) - .map_err(|e| RpcError::Other(format!("nip46 connect filter failed: {e}")))?; - let mut notifications = client.notifications(); - let subscription = client - .subscribe(filter, None) - .await - .map_err(|e| RpcError::Other(format!("nip46 connect failed: {e}")))?; - let timeout = sleep(Duration::from_secs(DEFAULT_TIMEOUT_SECS)); - tokio::pin!(timeout); - - loop { - tokio::select! { - _ = &mut timeout => { - client.unsubscribe(&subscription.val).await; - return Err(RpcError::Other("nip46 connect response not found".to_string())); - } - msg = notifications.recv() => { - let notification = match msg { - Ok(notification) => notification, - Err(broadcast::error::RecvError::Lagged(_)) => continue, - Err(broadcast::error::RecvError::Closed) => { - return Err(RpcError::Other("nip46 connect notification closed".to_string())); - } - }; - let RadrootsNostrRelayPoolNotification::Event { event, .. } = notification else { - continue; - }; - let event = (*event).clone(); - if event.kind != RadrootsNostrKind::NostrConnect || &event.pubkey != remote_signer_pubkey { - continue; - } - let decrypted = nip44::decrypt( - client_keys.secret_key(), - remote_signer_pubkey, - &event.content, - ) - .map_err(|e| RpcError::Other(format!("nip46 connect decrypt failed: {e}")))?; - let message = NostrConnectMessage::from_json(&decrypted) - .map_err(|e| RpcError::Other(format!("nip46 connect response parse failed: {e}")))?; - if message.is_response() && message.id() == request_id { - client.unsubscribe(&subscription.val).await; - return Ok(message); - } - } - } - } -} - -fn validate_connect_response( - response: &NostrConnectMessage, - secret: Option<&str>, -) -> Result<(), RpcError> { - let (result, error) = match response { - NostrConnectMessage::Response { result, error, .. } => (result, error), - _ => { - return Err(RpcError::Other( - "nip46 connect response invalid".to_string(), - )) - } - }; - - if let Some(error) = error { - return Err(RpcError::Other(format!("nip46 connect error: {error}"))); - } - - let result = result - .as_deref() - .ok_or_else(|| RpcError::Other("nip46 connect missing result".to_string()))?; - - if result == "ack" { - return Ok(()); - } - - if secret.is_some_and(|expected| expected == result) { - return Ok(()); - } - - Err(RpcError::Other(format!( - "nip46 connect unexpected result: {result}" - ))) -} - -#[cfg(test)] -mod tests { - use super::validate_connect_response; - use nostr::nips::nip46::{NostrConnectMessage, NostrConnectResponse, ResponseResult}; - - #[test] - fn validate_connect_response_accepts_ack() { - let message = NostrConnectMessage::response( - "1", - NostrConnectResponse::with_result(ResponseResult::Ack), - ); - validate_connect_response(&message, None).expect("ack"); - } - - #[test] - fn validate_connect_response_accepts_secret_match() { - let message = NostrConnectMessage::Response { - id: "1".to_string(), - result: Some("secret".to_string()), - error: None, - }; - validate_connect_response(&message, Some("secret")).expect("secret"); - - let err = validate_connect_response(&message, Some("other")).expect_err("mismatch"); - let msg = format!("{err}"); - assert!(msg.contains("unexpected result")); - } - - #[test] - #[ignore = "auth_url handling not implemented"] - fn validate_connect_response_accepts_auth_url() { - let message = NostrConnectMessage::response( - "1", - NostrConnectResponse::with_result(ResponseResult::AuthUrl), - ); - validate_connect_response(&message, None).expect("auth_url"); - } -} diff --git a/src/api/jsonrpc/methods/nip46/get_public_key.rs b/src/api/jsonrpc/methods/nip46/get_public_key.rs @@ -1,71 +0,0 @@ -use anyhow::Result; -use jsonrpsee::server::RpcModule; -use serde::{Deserialize, Serialize}; - -use crate::api::jsonrpc::{MethodRegistry, RpcContext, RpcError}; -use crate::nip46::session::Nip46Session; -use crate::nip46::client; -use radroots_nostr::prelude::RadrootsNostrPublicKey; -use nostr::nips::nip46::{NostrConnectMethod, NostrConnectRequest, ResponseResult}; - -#[derive(Debug, Deserialize)] -struct Nip46GetPublicKeyParams { - session_id: String, -} - -#[derive(Clone, Debug, Serialize)] -struct Nip46GetPublicKeyResponse { - pubkey: String, -} - -pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { - registry.track("nip46.get_public_key"); - m.register_async_method("nip46.get_public_key", |params, ctx, _| async move { - let Nip46GetPublicKeyParams { session_id } = params - .parse() - .map_err(|e| RpcError::InvalidParams(e.to_string()))?; - let session = ctx - .state - .nip46_sessions - .get(&session_id) - .await - .ok_or_else(|| RpcError::InvalidParams("unknown session".to_string()))?; - let pubkey = request_public_key(&session).await?; - let updated = ctx - .state - .nip46_sessions - .set_user_pubkey(&session_id, pubkey.clone()) - .await; - if !updated { - return Err(RpcError::Other("nip46 session update failed".to_string())); - } - Ok::<Nip46GetPublicKeyResponse, RpcError>(Nip46GetPublicKeyResponse { - pubkey: pubkey.to_hex(), - }) - })?; - Ok(()) -} - -async fn request_public_key( - session: &Nip46Session, -) -> Result<RadrootsNostrPublicKey, RpcError> { - let request = NostrConnectRequest::GetPublicKey; - let response = client::request(session, request, "get_public_key").await?; - let response = response - .to_response(NostrConnectMethod::GetPublicKey) - .map_err(|e| RpcError::Other(format!("nip46 get_public_key failed: {e}")))?; - - if let Some(error) = response.error { - return Err(RpcError::Other(format!("nip46 get_public_key error: {error}"))); - } - - match response.result { - Some(ResponseResult::GetPublicKey(pubkey)) => Ok(pubkey), - Some(_) => Err(RpcError::Other( - "nip46 get_public_key unexpected response".to_string(), - )), - None => Err(RpcError::Other( - "nip46 get_public_key missing response".to_string(), - )), - } -} diff --git a/src/api/jsonrpc/methods/nip46/mod.rs b/src/api/jsonrpc/methods/nip46/mod.rs @@ -1,34 +0,0 @@ -#![forbid(unsafe_code)] - -use anyhow::Result; -use jsonrpsee::server::RpcModule; - -use crate::api::jsonrpc::{MethodRegistry, RpcContext}; - -pub mod status; -pub mod connect; -pub mod ping; -pub mod nip04_encrypt; -pub mod nip04_decrypt; -pub mod nip44_encrypt; -pub mod nip44_decrypt; -pub mod get_public_key; -pub mod sign_event; -pub mod session_status; -pub mod session_close; - -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)?; - ping::register(&mut m, &registry)?; - nip04_encrypt::register(&mut m, &registry)?; - nip04_decrypt::register(&mut m, &registry)?; - nip44_encrypt::register(&mut m, &registry)?; - nip44_decrypt::register(&mut m, &registry)?; - get_public_key::register(&mut m, &registry)?; - sign_event::register(&mut m, &registry)?; - session_status::register(&mut m, &registry)?; - session_close::register(&mut m, &registry)?; - Ok(m) -} diff --git a/src/api/jsonrpc/methods/nip46/nip04_decrypt.rs b/src/api/jsonrpc/methods/nip46/nip04_decrypt.rs @@ -1,76 +0,0 @@ -use anyhow::Result; -use jsonrpsee::server::RpcModule; -use serde::{Deserialize, Serialize}; - -use crate::api::jsonrpc::{MethodRegistry, RpcContext, RpcError}; -use crate::nip46::client; -use crate::nip46::session::Nip46Session; -use radroots_nostr::prelude::radroots_nostr_parse_pubkey; -use nostr::nips::nip46::{NostrConnectMethod, NostrConnectRequest, ResponseResult}; - -#[derive(Debug, Deserialize)] -struct Nip46Nip04DecryptParams { - session_id: String, - pubkey: String, - ciphertext: String, -} - -#[derive(Clone, Debug, Serialize)] -struct Nip46Nip04DecryptResponse { - plaintext: String, -} - -pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { - registry.track("nip46.nip04_decrypt"); - m.register_async_method("nip46.nip04_decrypt", |params, ctx, _| async move { - let Nip46Nip04DecryptParams { - session_id, - pubkey, - ciphertext, - } = params - .parse() - .map_err(|e| RpcError::InvalidParams(e.to_string()))?; - let session = ctx - .state - .nip46_sessions - .get(&session_id) - .await - .ok_or_else(|| RpcError::InvalidParams("unknown session".to_string()))?; - let plaintext = request_nip04_decrypt(&session, pubkey, ciphertext).await?; - Ok::<Nip46Nip04DecryptResponse, RpcError>(Nip46Nip04DecryptResponse { plaintext }) - })?; - Ok(()) -} - -async fn request_nip04_decrypt( - session: &Nip46Session, - pubkey: String, - ciphertext: String, -) -> Result<String, RpcError> { - let public_key = radroots_nostr_parse_pubkey(&pubkey) - .map_err(|e| RpcError::InvalidParams(format!("invalid pubkey: {e}")))?; - let request = NostrConnectRequest::Nip04Decrypt { - public_key, - ciphertext, - }; - let response = client::request(session, request, "nip04_decrypt").await?; - let response = response - .to_response(NostrConnectMethod::Nip04Decrypt) - .map_err(|e| RpcError::Other(format!("nip46 nip04_decrypt failed: {e}")))?; - - if let Some(error) = response.error { - return Err(RpcError::Other(format!( - "nip46 nip04_decrypt error: {error}" - ))); - } - - match response.result { - Some(ResponseResult::Nip04Decrypt { plaintext }) => Ok(plaintext), - Some(_) => Err(RpcError::Other( - "nip46 nip04_decrypt unexpected response".to_string(), - )), - None => Err(RpcError::Other( - "nip46 nip04_decrypt missing response".to_string(), - )), - } -} diff --git a/src/api/jsonrpc/methods/nip46/nip04_encrypt.rs b/src/api/jsonrpc/methods/nip46/nip04_encrypt.rs @@ -1,73 +0,0 @@ -use anyhow::Result; -use jsonrpsee::server::RpcModule; -use serde::{Deserialize, Serialize}; - -use crate::api::jsonrpc::{MethodRegistry, RpcContext, RpcError}; -use crate::nip46::client; -use crate::nip46::session::Nip46Session; -use radroots_nostr::prelude::radroots_nostr_parse_pubkey; -use nostr::nips::nip46::{NostrConnectMethod, NostrConnectRequest, ResponseResult}; - -#[derive(Debug, Deserialize)] -struct Nip46Nip04EncryptParams { - session_id: String, - pubkey: String, - text: String, -} - -#[derive(Clone, Debug, Serialize)] -struct Nip46Nip04EncryptResponse { - ciphertext: String, -} - -pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { - registry.track("nip46.nip04_encrypt"); - m.register_async_method("nip46.nip04_encrypt", |params, ctx, _| async move { - let Nip46Nip04EncryptParams { - session_id, - pubkey, - text, - } = params - .parse() - .map_err(|e| RpcError::InvalidParams(e.to_string()))?; - let session = ctx - .state - .nip46_sessions - .get(&session_id) - .await - .ok_or_else(|| RpcError::InvalidParams("unknown session".to_string()))?; - let ciphertext = request_nip04_encrypt(&session, pubkey, text).await?; - Ok::<Nip46Nip04EncryptResponse, RpcError>(Nip46Nip04EncryptResponse { ciphertext }) - })?; - Ok(()) -} - -async fn request_nip04_encrypt( - session: &Nip46Session, - pubkey: String, - text: String, -) -> Result<String, RpcError> { - let public_key = radroots_nostr_parse_pubkey(&pubkey) - .map_err(|e| RpcError::InvalidParams(format!("invalid pubkey: {e}")))?; - let request = NostrConnectRequest::Nip04Encrypt { public_key, text }; - let response = client::request(session, request, "nip04_encrypt").await?; - let response = response - .to_response(NostrConnectMethod::Nip04Encrypt) - .map_err(|e| RpcError::Other(format!("nip46 nip04_encrypt failed: {e}")))?; - - if let Some(error) = response.error { - return Err(RpcError::Other(format!( - "nip46 nip04_encrypt error: {error}" - ))); - } - - match response.result { - Some(ResponseResult::Nip04Encrypt { ciphertext }) => Ok(ciphertext), - Some(_) => Err(RpcError::Other( - "nip46 nip04_encrypt unexpected response".to_string(), - )), - None => Err(RpcError::Other( - "nip46 nip04_encrypt missing response".to_string(), - )), - } -} diff --git a/src/api/jsonrpc/methods/nip46/nip44_decrypt.rs b/src/api/jsonrpc/methods/nip46/nip44_decrypt.rs @@ -1,76 +0,0 @@ -use anyhow::Result; -use jsonrpsee::server::RpcModule; -use serde::{Deserialize, Serialize}; - -use crate::api::jsonrpc::{MethodRegistry, RpcContext, RpcError}; -use crate::nip46::client; -use crate::nip46::session::Nip46Session; -use radroots_nostr::prelude::radroots_nostr_parse_pubkey; -use nostr::nips::nip46::{NostrConnectMethod, NostrConnectRequest, ResponseResult}; - -#[derive(Debug, Deserialize)] -struct Nip46Nip44DecryptParams { - session_id: String, - pubkey: String, - ciphertext: String, -} - -#[derive(Clone, Debug, Serialize)] -struct Nip46Nip44DecryptResponse { - plaintext: String, -} - -pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { - registry.track("nip46.nip44_decrypt"); - m.register_async_method("nip46.nip44_decrypt", |params, ctx, _| async move { - let Nip46Nip44DecryptParams { - session_id, - pubkey, - ciphertext, - } = params - .parse() - .map_err(|e| RpcError::InvalidParams(e.to_string()))?; - let session = ctx - .state - .nip46_sessions - .get(&session_id) - .await - .ok_or_else(|| RpcError::InvalidParams("unknown session".to_string()))?; - let plaintext = request_nip44_decrypt(&session, pubkey, ciphertext).await?; - Ok::<Nip46Nip44DecryptResponse, RpcError>(Nip46Nip44DecryptResponse { plaintext }) - })?; - Ok(()) -} - -async fn request_nip44_decrypt( - session: &Nip46Session, - pubkey: String, - ciphertext: String, -) -> Result<String, RpcError> { - let public_key = radroots_nostr_parse_pubkey(&pubkey) - .map_err(|e| RpcError::InvalidParams(format!("invalid pubkey: {e}")))?; - let request = NostrConnectRequest::Nip44Decrypt { - public_key, - ciphertext, - }; - let response = client::request(session, request, "nip44_decrypt").await?; - let response = response - .to_response(NostrConnectMethod::Nip44Decrypt) - .map_err(|e| RpcError::Other(format!("nip46 nip44_decrypt failed: {e}")))?; - - if let Some(error) = response.error { - return Err(RpcError::Other(format!( - "nip46 nip44_decrypt error: {error}" - ))); - } - - match response.result { - Some(ResponseResult::Nip44Decrypt { plaintext }) => Ok(plaintext), - Some(_) => Err(RpcError::Other( - "nip46 nip44_decrypt unexpected response".to_string(), - )), - None => Err(RpcError::Other( - "nip46 nip44_decrypt missing response".to_string(), - )), - } -} diff --git a/src/api/jsonrpc/methods/nip46/nip44_encrypt.rs b/src/api/jsonrpc/methods/nip46/nip44_encrypt.rs @@ -1,73 +0,0 @@ -use anyhow::Result; -use jsonrpsee::server::RpcModule; -use serde::{Deserialize, Serialize}; - -use crate::api::jsonrpc::{MethodRegistry, RpcContext, RpcError}; -use crate::nip46::client; -use crate::nip46::session::Nip46Session; -use radroots_nostr::prelude::radroots_nostr_parse_pubkey; -use nostr::nips::nip46::{NostrConnectMethod, NostrConnectRequest, ResponseResult}; - -#[derive(Debug, Deserialize)] -struct Nip46Nip44EncryptParams { - session_id: String, - pubkey: String, - text: String, -} - -#[derive(Clone, Debug, Serialize)] -struct Nip46Nip44EncryptResponse { - ciphertext: String, -} - -pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { - registry.track("nip46.nip44_encrypt"); - m.register_async_method("nip46.nip44_encrypt", |params, ctx, _| async move { - let Nip46Nip44EncryptParams { - session_id, - pubkey, - text, - } = params - .parse() - .map_err(|e| RpcError::InvalidParams(e.to_string()))?; - let session = ctx - .state - .nip46_sessions - .get(&session_id) - .await - .ok_or_else(|| RpcError::InvalidParams("unknown session".to_string()))?; - let ciphertext = request_nip44_encrypt(&session, pubkey, text).await?; - Ok::<Nip46Nip44EncryptResponse, RpcError>(Nip46Nip44EncryptResponse { ciphertext }) - })?; - Ok(()) -} - -async fn request_nip44_encrypt( - session: &Nip46Session, - pubkey: String, - text: String, -) -> Result<String, RpcError> { - let public_key = radroots_nostr_parse_pubkey(&pubkey) - .map_err(|e| RpcError::InvalidParams(format!("invalid pubkey: {e}")))?; - let request = NostrConnectRequest::Nip44Encrypt { public_key, text }; - let response = client::request(session, request, "nip44_encrypt").await?; - let response = response - .to_response(NostrConnectMethod::Nip44Encrypt) - .map_err(|e| RpcError::Other(format!("nip46 nip44_encrypt failed: {e}")))?; - - if let Some(error) = response.error { - return Err(RpcError::Other(format!( - "nip46 nip44_encrypt error: {error}" - ))); - } - - match response.result { - Some(ResponseResult::Nip44Encrypt { ciphertext }) => Ok(ciphertext), - Some(_) => Err(RpcError::Other( - "nip46 nip44_encrypt unexpected response".to_string(), - )), - None => Err(RpcError::Other( - "nip46 nip44_encrypt missing response".to_string(), - )), - } -} diff --git a/src/api/jsonrpc/methods/nip46/ping.rs b/src/api/jsonrpc/methods/nip46/ping.rs @@ -1,58 +0,0 @@ -use anyhow::Result; -use jsonrpsee::server::RpcModule; -use serde::{Deserialize, Serialize}; - -use crate::api::jsonrpc::{MethodRegistry, RpcContext, RpcError}; -use crate::nip46::client; -use crate::nip46::session::Nip46Session; -use nostr::nips::nip46::{NostrConnectMethod, NostrConnectRequest, ResponseResult}; - -#[derive(Debug, Deserialize)] -struct Nip46PingParams { - session_id: String, -} - -#[derive(Clone, Debug, Serialize)] -struct Nip46PingResponse { - result: String, -} - -pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { - registry.track("nip46.ping"); - m.register_async_method("nip46.ping", |params, ctx, _| async move { - let Nip46PingParams { session_id } = params - .parse() - .map_err(|e| RpcError::InvalidParams(e.to_string()))?; - let session = ctx - .state - .nip46_sessions - .get(&session_id) - .await - .ok_or_else(|| RpcError::InvalidParams("unknown session".to_string()))?; - let response = request_ping(&session).await?; - Ok::<Nip46PingResponse, RpcError>(Nip46PingResponse { - result: response, - }) - })?; - Ok(()) -} - -async fn request_ping(session: &Nip46Session) -> Result<String, RpcError> { - let request = NostrConnectRequest::Ping; - let response = client::request(session, request, "ping").await?; - let response = response - .to_response(NostrConnectMethod::Ping) - .map_err(|e| RpcError::Other(format!("nip46 ping failed: {e}")))?; - - if let Some(error) = response.error { - return Err(RpcError::Other(format!("nip46 ping error: {error}"))); - } - - match response.result { - Some(ResponseResult::Pong) => Ok("pong".to_string()), - Some(_) => Err(RpcError::Other( - "nip46 ping unexpected response".to_string(), - )), - None => Err(RpcError::Other("nip46 ping missing response".to_string())), - } -} diff --git a/src/api/jsonrpc/methods/nip46/session_close.rs b/src/api/jsonrpc/methods/nip46/session_close.rs @@ -1,34 +0,0 @@ -use anyhow::Result; -use jsonrpsee::server::RpcModule; -use serde::{Deserialize, Serialize}; - -use crate::api::jsonrpc::{MethodRegistry, RpcContext, RpcError}; - -#[derive(Debug, Deserialize)] -struct Nip46SessionCloseParams { - session_id: String, -} - -#[derive(Clone, Debug, Serialize)] -struct Nip46SessionCloseResponse { - session_id: String, - closed: bool, -} - -pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { - registry.track("nip46.session.close"); - m.register_async_method("nip46.session.close", |params, ctx, _| async move { - let Nip46SessionCloseParams { session_id } = params - .parse() - .map_err(|e| RpcError::InvalidParams(e.to_string()))?; - let closed = ctx.state.nip46_sessions.remove(&session_id).await; - if !closed { - return Err(RpcError::InvalidParams("unknown session".to_string())); - } - Ok::<Nip46SessionCloseResponse, RpcError>(Nip46SessionCloseResponse { - session_id, - closed, - }) - })?; - Ok(()) -} diff --git a/src/api/jsonrpc/methods/nip46/session_status.rs b/src/api/jsonrpc/methods/nip46/session_status.rs @@ -1,42 +0,0 @@ -use anyhow::Result; -use jsonrpsee::server::RpcModule; -use serde::{Deserialize, Serialize}; - -use crate::api::jsonrpc::{MethodRegistry, RpcContext, RpcError}; - -#[derive(Debug, Deserialize)] -struct Nip46SessionStatusParams { - session_id: String, -} - -#[derive(Clone, Debug, Serialize)] -struct Nip46SessionStatusResponse { - session_id: String, - client_pubkey: String, - remote_signer_pubkey: String, - user_pubkey: Option<String>, - relays: Vec<String>, -} - -pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { - registry.track("nip46.session.status"); - m.register_async_method("nip46.session.status", |params, ctx, _| async move { - let Nip46SessionStatusParams { session_id } = params - .parse() - .map_err(|e| RpcError::InvalidParams(e.to_string()))?; - let session = ctx - .state - .nip46_sessions - .get(&session_id) - .await - .ok_or_else(|| RpcError::InvalidParams("unknown session".to_string()))?; - Ok::<Nip46SessionStatusResponse, RpcError>(Nip46SessionStatusResponse { - session_id, - client_pubkey: session.client_pubkey.to_hex(), - remote_signer_pubkey: session.remote_signer_pubkey.to_hex(), - user_pubkey: session.user_pubkey.map(|pubkey| pubkey.to_hex()), - relays: session.relays, - }) - })?; - Ok(()) -} diff --git a/src/api/jsonrpc/methods/nip46/sign_event.rs b/src/api/jsonrpc/methods/nip46/sign_event.rs @@ -1,78 +0,0 @@ -use anyhow::Result; -use jsonrpsee::server::RpcModule; -use serde::{Deserialize, Serialize}; - -use crate::api::jsonrpc::nostr::{event_view, NostrEventView}; -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::UnsignedEvent; - -#[derive(Debug, Deserialize)] -struct Nip46SignEventParams { - session_id: String, - event: Nip46UnsignedEvent, -} - -#[derive(Clone, Debug, Deserialize)] -struct Nip46UnsignedEvent { - kind: u16, - content: String, - tags: Vec<Vec<String>>, - created_at: u64, -} - -#[derive(Clone, Debug, Serialize)] -struct Nip46SignEventResponse { - event: NostrEventView, -} - -pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { - registry.track("nip46.sign_event"); - m.register_async_method("nip46.sign_event", |params, ctx, _| async move { - let Nip46SignEventParams { session_id, event } = params - .parse() - .map_err(|e| RpcError::InvalidParams(e.to_string()))?; - let session = ctx - .state - .nip46_sessions - .get(&session_id) - .await - .ok_or_else(|| RpcError::InvalidParams("unknown session".to_string()))?; - let signed_event = sign_event(&session, event).await?; - Ok::<Nip46SignEventResponse, RpcError>(Nip46SignEventResponse { - event: event_view(&signed_event), - }) - })?; - Ok(()) -} - -async fn sign_event( - session: &Nip46Session, - input: Nip46UnsignedEvent, -) -> Result<nostr::Event, RpcError> { - let user_pubkey = session.user_pubkey.clone().ok_or_else(|| { - RpcError::InvalidParams("missing user pubkey; call nip46.get_public_key".to_string()) - })?; - let tags = parse_tags(input.tags)?; - let unsigned = UnsignedEvent::new( - user_pubkey, - RadrootsNostrTimestamp::from_secs(input.created_at), - RadrootsNostrKind::from_u16(input.kind), - tags, - input.content, - ); - - client::sign_event(session, unsigned, "sign_event").await -} - -fn parse_tags(tags: Vec<Vec<String>>) -> Result<Vec<RadrootsNostrTag>, RpcError> { - tags.into_iter() - .enumerate() - .map(|(idx, tag)| { - RadrootsNostrTag::parse(tag) - .map_err(|e| RpcError::InvalidParams(format!("invalid tag {idx}: {e}"))) - }) - .collect() -} diff --git a/src/api/jsonrpc/methods/nip46/status.rs b/src/api/jsonrpc/methods/nip46/status.rs @@ -1,18 +0,0 @@ -use anyhow::Result; -use jsonrpsee::server::RpcModule; -use serde::Serialize; - -use crate::api::jsonrpc::{MethodRegistry, RpcContext, RpcError}; - -#[derive(Clone, Debug, Serialize)] -struct Nip46StatusResponse { - ready: bool, -} - -pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { - registry.track("nip46.status"); - m.register_method("nip46.status", |_p, _ctx, _| { - Ok::<Nip46StatusResponse, RpcError>(Nip46StatusResponse { ready: true }) - })?; - Ok(()) -} diff --git a/src/api/jsonrpc/methods/relays/add.rs b/src/api/jsonrpc/methods/relays/add.rs @@ -1,28 +0,0 @@ -use anyhow::Result; -use jsonrpsee::server::RpcModule; -use radroots_nostr::prelude::radroots_nostr_add_relay; -use serde::Deserialize; - -use crate::api::jsonrpc::relays::RelayAddedResponse; -use crate::api::jsonrpc::{MethodRegistry, RpcContext, RpcError}; - -#[derive(Debug, Deserialize)] -struct AddParams { - url: String, -} - -pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { - registry.track("relays.add"); - m.register_async_method("relays.add", |params, ctx, _| async move { - let AddParams { url } = params - .parse() - .map_err(|e| RpcError::InvalidParams(e.to_string()))?; - - radroots_nostr_add_relay(&ctx.state.client, &url) - .await - .map_err(|e| RpcError::AddRelay(url.clone(), e.to_string()))?; - - Ok::<RelayAddedResponse, RpcError>(RelayAddedResponse { added: url }) - })?; - Ok(()) -} diff --git a/src/api/jsonrpc/methods/relays/connect.rs b/src/api/jsonrpc/methods/relays/connect.rs @@ -1,43 +0,0 @@ -use anyhow::Result; -use jsonrpsee::server::RpcModule; - -use crate::api::jsonrpc::relays::RelayConnectResponse; -use crate::api::jsonrpc::{MethodRegistry, RpcContext, RpcError}; - -use radroots_nostr::prelude::{radroots_nostr_connect, RadrootsNostrRelayStatus}; - -pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { - registry.track("relays.connect"); - m.register_async_method("relays.connect", |_p, ctx, _| async move { - let relays = ctx.state.client.relays().await; - if relays.is_empty() { - return Err(RpcError::NoRelays); - } - - let mut connected = 0usize; - let mut connecting = 0usize; - let mut disconnected = 0usize; - - for (_, r) in &relays { - match r.status() { - RadrootsNostrRelayStatus::Connected => connected += 1, - RadrootsNostrRelayStatus::Connecting => connecting += 1, - _ => disconnected += 1, - } - } - - let need_connect = disconnected > 0; - if need_connect { - let client = ctx.state.client.clone(); - tokio::spawn(async move { radroots_nostr_connect(&client).await }); - } - - Ok::<RelayConnectResponse, RpcError>(RelayConnectResponse { - connected, - connecting, - disconnected, - spawned_connect: need_connect, - }) - })?; - Ok(()) -} diff --git a/src/api/jsonrpc/methods/relays/list.rs b/src/api/jsonrpc/methods/relays/list.rs @@ -1,14 +0,0 @@ -use anyhow::Result; -use jsonrpsee::server::RpcModule; - -use crate::api::jsonrpc::{MethodRegistry, RpcContext, RpcError}; - -pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { - registry.track("relays.list"); - m.register_async_method("relays.list", |_p, ctx, _| async move { - let relays = ctx.state.client.relays().await; - let out = relays.keys().map(|u| u.to_string()).collect::<Vec<_>>(); - Ok::<Vec<String>, RpcError>(out) - })?; - Ok(()) -} diff --git a/src/api/jsonrpc/methods/relays/mod.rs b/src/api/jsonrpc/methods/relays/mod.rs @@ -1,22 +0,0 @@ -use anyhow::Result; -use jsonrpsee::server::RpcModule; - -use crate::api::jsonrpc::{MethodRegistry, RpcContext}; - -pub mod add; -pub mod connect; -pub mod list; -pub mod remove; -pub mod status; - -pub fn module(ctx: RpcContext, registry: MethodRegistry) -> Result<RpcModule<RpcContext>> { - let mut m = RpcModule::new(ctx); - - add::register(&mut m, &registry)?; - remove::register(&mut m, &registry)?; - list::register(&mut m, &registry)?; - status::register(&mut m, &registry)?; - connect::register(&mut m, &registry)?; - - Ok(m) -} diff --git a/src/api/jsonrpc/methods/relays/remove.rs b/src/api/jsonrpc/methods/relays/remove.rs @@ -1,28 +0,0 @@ -use anyhow::Result; -use jsonrpsee::server::RpcModule; -use radroots_nostr::prelude::radroots_nostr_remove_relay; -use serde::Deserialize; - -use crate::api::jsonrpc::relays::RelayRemovedResponse; -use crate::api::jsonrpc::{MethodRegistry, RpcContext, RpcError}; - -#[derive(Debug, Deserialize)] -struct RemoveParams { - url: String, -} - -pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { - registry.track("relays.remove"); - m.register_async_method("relays.remove", |params, ctx, _| async move { - let RemoveParams { url } = params - .parse() - .map_err(|e| RpcError::InvalidParams(e.to_string()))?; - - radroots_nostr_remove_relay(&ctx.state.client, &url) - .await - .map_err(|e| RpcError::Other(format!("failed to remove relay {url}: {e}")))?; - - Ok::<RelayRemovedResponse, RpcError>(RelayRemovedResponse { removed: url }) - })?; - Ok(()) -} diff --git a/src/api/jsonrpc/methods/relays/status.rs b/src/api/jsonrpc/methods/relays/status.rs @@ -1,64 +0,0 @@ -use anyhow::Result; -use jsonrpsee::server::RpcModule; -use serde::Deserialize; - -use crate::api::jsonrpc::relays::RelayStatusRow; -use crate::api::jsonrpc::{MethodRegistry, RpcContext, RpcError}; -use radroots_nostr::prelude::fetch_nip11; - -#[derive(Debug, Default, Deserialize)] -struct StatusParams { - #[serde(default)] - include_nip11: bool, -} - -pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { - registry.track("relays.status"); - m.register_async_method("relays.status", |params, ctx, _| async move { - let StatusParams { include_nip11 } = params - .parse::<Option<StatusParams>>() - .map_err(|e| RpcError::InvalidParams(e.to_string()))? - .unwrap_or_default(); - - let relays = ctx.state.client.relays().await; - let mut out = Vec::with_capacity(relays.len()); - - for (relay_url, relay) in relays { - let url_str = relay_url.to_string(); - let status_str = format!("{}", relay.status()); - let parsed = reqwest::Url::parse(&url_str).ok(); - - let mut row = RelayStatusRow { - url: url_str.clone(), - status: status_str, - scheme: None, - host: None, - onion: None, - port: None, - nip11: None, - }; - - if let Some(u) = &parsed { - row.scheme = Some(u.scheme().to_string()); - if let Some(h) = u.host_str() { - row.host = Some(h.to_string()); - row.onion = Some(h.ends_with(".onion")); - } - if let Some(p) = u.port() { - row.port = Some(p); - } - } - - if include_nip11 { - if let Some(doc) = fetch_nip11(&row.url).await { - row.nip11 = Some(doc); - } - } - - out.push(row); - } - - Ok::<Vec<RelayStatusRow>, RpcError>(out) - })?; - Ok(()) -} diff --git a/src/api/jsonrpc/mod.rs b/src/api/jsonrpc/mod.rs @@ -1,40 +0,0 @@ -#![forbid(unsafe_code)] - -use std::net::SocketAddr; - -use anyhow::Result; -use jsonrpsee::server::{RpcModule, ServerHandle}; - -use crate::config::RpcConfig; -use crate::radrootsd::Radrootsd; - -mod context; -mod error; -mod nostr; -mod params; -mod relays; -mod registry; -mod server; - -pub mod methods; - -pub use context::RpcContext; -pub use error::RpcError; -pub use registry::MethodRegistry; -pub(crate) use params::DEFAULT_TIMEOUT_SECS; - -pub async fn start_rpc( - state: Radrootsd, - addr: SocketAddr, - rpc_cfg: &RpcConfig, -) -> Result<ServerHandle> { - let registry = MethodRegistry::default(); - let ctx = RpcContext::new(state, registry.clone()); - let server = server::build_server(addr, rpc_cfg).await?; - - let mut root = RpcModule::new(ctx.clone()); - methods::register_all(&mut root, ctx, registry)?; - - let handle = server.start(root); - Ok(handle) -} diff --git a/src/api/jsonrpc/nostr.rs b/src/api/jsonrpc/nostr.rs @@ -1,64 +0,0 @@ -#![forbid(unsafe_code)] - -use serde::Serialize; - -use radroots_nostr::prelude::{ - RadrootsNostrEvent, - RadrootsNostrEventId, - RadrootsNostrOutput, -}; - -#[derive(Clone, Debug, Serialize)] -pub struct NostrEventView { - pub id: String, - pub author: String, - pub created_at: u64, - pub kind: u32, - pub tags: Vec<Vec<String>>, - pub content: String, - pub sig: String, -} - -#[derive(Clone, Debug, Serialize)] -pub struct PublishResponse { - pub id: String, - pub sent: Vec<String>, - pub failed: Vec<(String, String)>, -} - -pub(crate) fn event_tags(event: &RadrootsNostrEvent) -> Vec<Vec<String>> { - event.tags.iter().map(|t| t.as_slice().to_vec()).collect() -} - -pub(crate) fn event_view(event: &RadrootsNostrEvent) -> NostrEventView { - event_view_with_tags(event, event_tags(event)) -} - -pub(crate) fn event_view_with_tags( - event: &RadrootsNostrEvent, - tags: Vec<Vec<String>>, -) -> NostrEventView { - NostrEventView { - id: event.id.to_string(), - author: event.pubkey.to_string(), - created_at: event.created_at.as_secs(), - kind: event.kind.as_u16() as u32, - tags, - content: event.content.clone(), - sig: event.sig.to_string(), - } -} - -pub(crate) fn publish_response( - output: RadrootsNostrOutput<RadrootsNostrEventId>, -) -> PublishResponse { - PublishResponse { - id: output.id().to_string(), - sent: output.success.into_iter().map(|u| u.to_string()).collect(), - failed: output - .failed - .into_iter() - .map(|(u, e)| (u.to_string(), e.to_string())) - .collect(), - } -} diff --git a/src/api/jsonrpc/params.rs b/src/api/jsonrpc/params.rs @@ -1,132 +0,0 @@ -#![forbid(unsafe_code)] - -use serde::Deserialize; - -use crate::api::jsonrpc::RpcError; -use radroots_nostr::prelude::{ - radroots_nostr_parse_pubkeys, - RadrootsNostrFilter, - RadrootsNostrPublicKey, - RadrootsNostrTimestamp, -}; - -pub const DEFAULT_LIMIT: u64 = 50; -pub const MAX_LIMIT: u64 = 1000; -pub const DEFAULT_TIMEOUT_SECS: u64 = 10; - -#[derive(Debug, Default, Deserialize)] -pub struct EventListParams { - #[serde(default)] - pub authors: Option<Vec<String>>, - #[serde(default)] - pub limit: Option<u64>, - #[serde(default)] - pub since: Option<u64>, - #[serde(default)] - pub until: Option<u64>, - #[serde(default)] - pub timeout_secs: Option<u64>, -} - -pub(crate) fn limit_or(limit: Option<u64>) -> usize { - limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize -} - -pub(crate) fn timeout_or(timeout_secs: Option<u64>) -> u64 { - timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS) -} - -pub(crate) fn parse_pubkeys( - label: &str, - values: &[String], -) -> Result<Vec<RadrootsNostrPublicKey>, RpcError> { - radroots_nostr_parse_pubkeys(values) - .map_err(|e| RpcError::InvalidParams(format!("invalid {label}: {e}"))) -} - -pub(crate) fn parse_pubkeys_opt( - label: &str, - values: Option<Vec<String>>, -) -> Result<Option<Vec<RadrootsNostrPublicKey>>, RpcError> { - match values { - Some(values) => Ok(Some(parse_pubkeys(label, &values)?)), - None => Ok(None), - } -} - -pub(crate) fn apply_time_bounds( - mut filter: RadrootsNostrFilter, - since: Option<u64>, - until: Option<u64>, -) -> RadrootsNostrFilter { - if let Some(since) = since { - filter = filter.since(RadrootsNostrTimestamp::from_secs(since)); - } - if let Some(until) = until { - filter = filter.until(RadrootsNostrTimestamp::from_secs(until)); - } - filter -} - -#[cfg(test)] -mod tests { - use super::{ - apply_time_bounds, - limit_or, - parse_pubkeys_opt, - timeout_or, - DEFAULT_LIMIT, - DEFAULT_TIMEOUT_SECS, - MAX_LIMIT, - }; - use crate::api::jsonrpc::RpcError; - use radroots_nostr::prelude::RadrootsNostrFilter; - - #[test] - fn limit_or_defaults_and_caps() { - assert_eq!(limit_or(None), DEFAULT_LIMIT as usize); - assert_eq!(limit_or(Some(MAX_LIMIT + 1)), MAX_LIMIT as usize); - assert_eq!(limit_or(Some(0)), 0); - } - - #[test] - fn timeout_or_defaults() { - assert_eq!(timeout_or(None), DEFAULT_TIMEOUT_SECS); - assert_eq!(timeout_or(Some(3)), 3); - } - - #[test] - fn apply_time_bounds_sets_since_until() { - let filter = RadrootsNostrFilter::new(); - let filter = apply_time_bounds(filter, Some(10), Some(20)); - assert_eq!(filter.since.map(|t| t.as_secs()), Some(10)); - assert_eq!(filter.until.map(|t| t.as_secs()), Some(20)); - } - - #[test] - fn apply_time_bounds_noop_when_empty() { - let filter = RadrootsNostrFilter::new(); - let filter = apply_time_bounds(filter, None, None); - assert!(filter.since.is_none()); - assert!(filter.until.is_none()); - } - - #[test] - fn parse_pubkeys_opt_accepts_valid() { - let key = "1bdebe7b23fccb167fc8843280b789839dfa296ae9fd86cc9769b4813d76d8a4"; - let out = parse_pubkeys_opt("author", Some(vec![key.to_string()])).expect("pubkey"); - let out = out.expect("some"); - assert_eq!(out.len(), 1); - assert_eq!(out[0].to_string(), key); - } - - #[test] - fn parse_pubkeys_opt_rejects_invalid() { - let err = parse_pubkeys_opt("author", Some(vec!["nope".to_string()])) - .expect_err("error"); - match err { - RpcError::InvalidParams(msg) => assert!(msg.contains("invalid author")), - _ => panic!("unexpected error"), - } - } -} diff --git a/src/api/jsonrpc/registry.rs b/src/api/jsonrpc/registry.rs @@ -1,23 +0,0 @@ -#![forbid(unsafe_code)] - -use std::sync::{Arc, RwLock}; - -#[derive(Clone, Default)] -pub struct MethodRegistry { - inner: Arc<RwLock<Vec<String>>>, -} - -impl MethodRegistry { - pub fn track(&self, name: &'static str) { - let mut methods = self.inner.write().unwrap_or_else(|e| e.into_inner()); - if methods.iter().any(|entry| entry == name) { - return; - } - methods.push(name.to_string()); - methods.sort(); - } - - pub fn list(&self) -> Vec<String> { - self.inner.read().unwrap_or_else(|e| e.into_inner()).clone() - } -} diff --git a/src/api/jsonrpc/relays.rs b/src/api/jsonrpc/relays.rs @@ -1,34 +0,0 @@ -#![forbid(unsafe_code)] - -use serde::Serialize; - -use radroots_events::relay_document::RadrootsRelayDocument; - -#[derive(Clone, Debug, Serialize)] -pub(crate) struct RelayAddedResponse { - pub added: String, -} - -#[derive(Clone, Debug, Serialize)] -pub(crate) struct RelayRemovedResponse { - pub removed: String, -} - -#[derive(Clone, Debug, Serialize)] -pub(crate) struct RelayConnectResponse { - pub connected: usize, - pub connecting: usize, - pub disconnected: usize, - pub spawned_connect: bool, -} - -#[derive(Clone, Debug, Serialize)] -pub(crate) struct RelayStatusRow { - pub url: String, - pub status: String, - pub scheme: Option<String>, - pub host: Option<String>, - pub onion: Option<bool>, - pub port: Option<u16>, - pub nip11: Option<RadrootsRelayDocument>, -} diff --git a/src/api/jsonrpc/server.rs b/src/api/jsonrpc/server.rs @@ -1,30 +0,0 @@ -#![forbid(unsafe_code)] - -use std::net::SocketAddr; - -use anyhow::Result; -use jsonrpsee::server::{BatchRequestConfig, Server, ServerBuilder, ServerConfigBuilder}; - -use crate::config::RpcConfig; - -pub async fn build_server(addr: SocketAddr, rpc_cfg: &RpcConfig) -> Result<Server> { - let mut builder = ServerConfigBuilder::new() - .max_request_body_size(rpc_cfg.max_request_body_size) - .max_response_body_size(rpc_cfg.max_response_body_size) - .max_connections(rpc_cfg.max_connections) - .max_subscriptions_per_connection(rpc_cfg.max_subscriptions_per_connection) - .set_message_buffer_capacity(rpc_cfg.message_buffer_capacity); - - if let Some(limit) = rpc_cfg.batch_request_limit { - let cfg = if limit == 0 { - BatchRequestConfig::Disabled - } else { - BatchRequestConfig::Limit(limit) - }; - builder = builder.set_batch_request_config(cfg); - } - - let server_cfg = builder.build(); - let server = ServerBuilder::with_config(server_cfg).build(addr).await?; - Ok(server) -} diff --git a/src/api/mod.rs b/src/api/mod.rs @@ -1,3 +0,0 @@ -#![forbid(unsafe_code)] - -pub mod jsonrpc; diff --git a/src/app/mod.rs b/src/app/mod.rs diff --git a/src/build/mod.rs b/src/build/mod.rs @@ -1 +0,0 @@ -#![forbid(unsafe_code)] diff --git a/src/cli.rs b/src/cli.rs @@ -1,35 +0,0 @@ -use std::path::PathBuf; - -use clap::{Parser, ValueHint, command}; - -#[derive(Parser, Debug, Clone)] -#[command( - about = env!("CARGO_PKG_DESCRIPTION"), - author = env!("CARGO_PKG_AUTHORS"), - version = env!("CARGO_PKG_VERSION") -)] -pub struct Args { - #[arg( - long, - value_name = "PATH", - value_hint = ValueHint::FilePath, - default_value = "config.toml", - help = "Path to the daemon configuration file (defaults to config.toml)" - )] - pub config: PathBuf, - - #[arg( - long, - value_name = "PATH", - value_hint = ValueHint::FilePath, - help = "Path to the daemon identity file (json, txt, or raw 32-byte key; defaults to identity.json)", - )] - pub identity: Option<PathBuf>, - - #[arg( - long, - action = clap::ArgAction::SetTrue, - help = "Allow generating a new identity file if missing; if not set and identity file is absent, the daemon will fail" - )] - pub allow_generate_identity: bool, -} diff --git a/src/config.rs b/src/config.rs @@ -1,83 +0,0 @@ -use radroots_nostr::prelude::RadrootsNostrMetadata; -use serde::{Deserialize, Serialize}; - -fn default_rpc_addr() -> String { - "127.0.0.1:7070".to_string() -} - -fn default_max_request_body_size() -> u32 { - 10 * 1024 * 1024 -} - -fn default_max_response_body_size() -> u32 { - 10 * 1024 * 1024 -} - -fn default_max_connections() -> u32 { - 100 -} - -fn default_max_subscriptions_per_connection() -> u32 { - 1024 -} - -fn default_message_buffer_capacity() -> u32 { - 1024 -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct RpcConfig { - #[serde(default = "default_rpc_addr")] - pub addr: String, - #[serde(default = "default_max_request_body_size")] - pub max_request_body_size: u32, - #[serde(default = "default_max_response_body_size")] - pub max_response_body_size: u32, - #[serde(default = "default_max_connections")] - pub max_connections: u32, - #[serde(default = "default_max_subscriptions_per_connection")] - pub max_subscriptions_per_connection: u32, - #[serde(default = "default_message_buffer_capacity")] - pub message_buffer_capacity: u32, - #[serde(default)] - pub batch_request_limit: Option<u32>, -} - -impl Default for RpcConfig { - fn default() -> Self { - Self { - addr: default_rpc_addr(), - max_request_body_size: default_max_request_body_size(), - max_response_body_size: default_max_response_body_size(), - max_connections: default_max_connections(), - max_subscriptions_per_connection: default_max_subscriptions_per_connection(), - message_buffer_capacity: default_message_buffer_capacity(), - batch_request_limit: None, - } - } -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct Configuration { - pub logs_dir: String, - #[serde(default)] - pub rpc: RpcConfig, - #[serde(default)] - pub rpc_addr: Option<String>, - #[serde(default)] - pub relays: Vec<String>, -} - -impl Configuration { - pub fn rpc_addr(&self) -> &str { - self.rpc_addr - .as_deref() - .unwrap_or(self.rpc.addr.as_str()) - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Settings { - pub metadata: RadrootsNostrMetadata, - pub config: Configuration, -} diff --git a/src/core/mod.rs b/src/core/mod.rs @@ -0,0 +1 @@ +pub mod state; diff --git a/src/core/state.rs b/src/core/state.rs @@ -0,0 +1,36 @@ +use std::time::Instant; + +use radroots_nostr::prelude::{ + RadrootsNostrClient, + RadrootsNostrKeys, + RadrootsNostrMetadata, + RadrootsNostrPublicKey, +}; + +#[derive(Clone)] +pub struct Radrootsd { + pub(crate) started: Instant, + pub client: RadrootsNostrClient, + pub pubkey: RadrootsNostrPublicKey, + pub metadata: RadrootsNostrMetadata, + pub info: serde_json::Value, +} + +impl Radrootsd { + pub fn new(keys: RadrootsNostrKeys, metadata: RadrootsNostrMetadata) -> Self { + let pubkey = keys.public_key(); + let client = RadrootsNostrClient::new(keys); + let info = serde_json::json!({ + "version": env!("CARGO_PKG_VERSION"), + "build": option_env!("GIT_HASH").unwrap_or("unknown"), + }); + + Self { + started: Instant::now(), + client, + pubkey, + metadata, + info, + } + } +} diff --git a/src/events/mod.rs b/src/events/mod.rs @@ -1 +0,0 @@ -#![forbid(unsafe_code)] diff --git a/src/lib.rs b/src/lib.rs @@ -1,105 +1,5 @@ #![forbid(unsafe_code)] -pub mod api; -pub mod build; -pub mod cli; -pub mod config; -pub mod events; -pub mod nip46; -pub mod radrootsd; -pub mod validate; - -use anyhow::Result; - -pub use cli::Args as cli_args; -use tracing::info; - -use crate::radrootsd::Radrootsd; -use radroots_identity::RadrootsIdentity; -use radroots_events::profile::RadrootsProfileType; -use radroots_events_codec::profile::encode::profile_type_tags; -use radroots_nostr::prelude::{ - radroots_nostr_build_metadata_event, - radroots_nostr_publish_identity_profile_with_type, - RadrootsNostrTag, - RadrootsNostrTagKind, -}; - -pub async fn run_radrootsd(settings: &config::Settings, args: &cli_args) -> Result<()> { - let identity = RadrootsIdentity::load_or_generate( - args.identity.as_ref(), - args.allow_generate_identity, - )?; - let keys = identity.keys().clone(); - - let radrootsd = Radrootsd::new(keys, settings.metadata.clone()); - - for relay in settings.config.relays.iter() { - radrootsd.client.add_relay(relay).await?; - } - - if !settings.config.relays.is_empty() { - let client = radrootsd.client.clone(); - let md = settings.metadata.clone(); - let identity = identity.clone(); - let has_metadata = serde_json::to_value(&md) - .ok() - .and_then(|v| v.as_object().cloned()) - .map(|o| !o.is_empty()) - .unwrap_or(false); - - tokio::spawn(async move { - client.connect().await; - let profile_published = - match radroots_nostr_publish_identity_profile_with_type( - &client, - &identity, - Some(RadrootsProfileType::Radrootsd), - ) - .await - { - Ok(Some(_)) => true, - Ok(None) => false, - Err(e) => { - tracing::warn!("Failed to publish identity profile: {e}"); - false - } - }; - if has_metadata && !profile_published { - let mut tags = Vec::new(); - for mut tag in profile_type_tags(RadrootsProfileType::Radrootsd) { - if tag.is_empty() { - continue; - } - let key = tag.remove(0); - tags.push(RadrootsNostrTag::custom( - RadrootsNostrTagKind::Custom(key.into()), - tag, - )); - } - let builder = radroots_nostr_build_metadata_event(&md).tags(tags); - if let Err(e) = client.send_event_builder(builder).await { - tracing::warn!("Failed to publish metadata on startup: {e}"); - } else { - tracing::info!("Published metadata on startup"); - } - } - }); - } - - let addr: std::net::SocketAddr = settings.config.rpc_addr().parse()?; - let handle = api::jsonrpc::start_rpc(radrootsd.clone(), addr, &settings.config.rpc).await?; - info!("JSON-RPC listening on {addr}"); - - let stop_handle = handle.clone(); - - tokio::select! { - _ = radroots_runtime::shutdown_signal() => { - tracing::info!("Shutting down…"); - let _ = stop_handle.stop(); - } - _ = handle.stopped() => {} - } - - Ok(()) -} +pub mod app; +pub mod core; +pub mod transport; diff --git a/src/main.rs b/src/main.rs @@ -1,32 +1 @@ -#![forbid(unsafe_code)] - -use anyhow::{Context, Result}; -use radrootsd::{cli_args, config, run_radrootsd}; -use std::process::ExitCode; -use tracing::info; - -#[tokio::main] -async fn main() -> ExitCode { - match run().await { - Ok(()) => ExitCode::SUCCESS, - Err(err) => { - tracing::error!(error = ?err, "Fatal error"); - eprintln!("Fatal error: {err:#}"); - ExitCode::FAILURE - } - } -} - -async fn run() -> Result<()> { - let (args, settings): (cli_args, config::Settings) = - radroots_runtime::parse_and_load_path_with_init( - |a: &cli_args| Some(a.config.as_path()), - |cfg: &config::Settings| cfg.config.logs_dir.as_str(), - None, - ) - .context("load configuration")?; - - info!("Starting radrootsd"); - - run_radrootsd(&settings, &args).await -} +fn main() {} diff --git a/src/nip46/client.rs b/src/nip46/client.rs @@ -1,156 +0,0 @@ -#![forbid(unsafe_code)] - -use std::time::Duration; - -use crate::api::jsonrpc::{DEFAULT_TIMEOUT_SECS, RpcError}; -use crate::nip46::session::Nip46Session; -use radroots_nostr::prelude::{ - radroots_nostr_filter_tag, - RadrootsNostrEventBuilder, - RadrootsNostrFilter, - RadrootsNostrKind, - RadrootsNostrRelayPoolNotification, - RadrootsNostrTimestamp, -}; -use nostr::nips::{ - nip44, - nip46::{NostrConnectMessage, NostrConnectMethod, NostrConnectRequest, ResponseResult}, -}; -use nostr::JsonUtil; -use nostr::UnsignedEvent; -use tokio::sync::broadcast; -use tokio::time::sleep; - -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, - request: NostrConnectRequest, - label: &str, -) -> Result<NostrConnectMessage, RpcError> { - let request_id = send_request(session, request, label).await?; - wait_for_response(session, &request_id, label).await -} - -async fn send_request( - session: &Nip46Session, - request: NostrConnectRequest, - label: &str, -) -> Result<String, RpcError> { - session.client.connect().await; - session - .client - .wait_for_connection(Duration::from_secs(DEFAULT_TIMEOUT_SECS)) - .await; - - let message = NostrConnectMessage::request(&request); - let request_id = message.id().to_string(); - let event = RadrootsNostrEventBuilder::nostr_connect( - &session.client_keys, - session.remote_signer_pubkey.clone(), - message, - ) - .map_err(|e| RpcError::Other(format!("nip46 {label} failed: {e}")))?; - - session - .client - .send_event_builder(event) - .await - .map_err(|e| RpcError::Other(format!("nip46 {label} failed: {e}")))?; - - Ok(request_id) -} - -async fn wait_for_response( - session: &Nip46Session, - request_id: &str, - label: &str, -) -> Result<NostrConnectMessage, RpcError> { - let filter = RadrootsNostrFilter::new() - .kind(RadrootsNostrKind::NostrConnect) - .author(session.remote_signer_pubkey.clone()) - .since(RadrootsNostrTimestamp::now()); - let filter = radroots_nostr_filter_tag(filter, "p", vec![session.client_pubkey.to_hex()]) - .map_err(|e| RpcError::Other(format!("nip46 {label} failed: {e}")))?; - let mut notifications = session.client.notifications(); - let subscription = session - .client - .subscribe(filter, None) - .await - .map_err(|e| RpcError::Other(format!("nip46 {label} failed: {e}")))?; - let timeout = sleep(Duration::from_secs(DEFAULT_TIMEOUT_SECS)); - tokio::pin!(timeout); - - loop { - tokio::select! { - _ = &mut timeout => { - session.client.unsubscribe(&subscription.val).await; - return Err(RpcError::Other(format!("nip46 {label} response not found"))); - } - msg = notifications.recv() => { - let notification = match msg { - Ok(notification) => notification, - Err(broadcast::error::RecvError::Lagged(_)) => continue, - Err(broadcast::error::RecvError::Closed) => { - return Err(RpcError::Other(format!("nip46 {label} notification closed"))); - } - }; - let RadrootsNostrRelayPoolNotification::Event { event, .. } = notification else { - continue; - }; - let event = (*event).clone(); - if event.kind != RadrootsNostrKind::NostrConnect - || event.pubkey != session.remote_signer_pubkey - { - continue; - } - let decrypted = nip44::decrypt( - session.client_keys.secret_key(), - &session.remote_signer_pubkey, - &event.content, - ) - .map_err(|e| RpcError::Other(format!("nip46 {label} failed: {e}")))?; - let message = NostrConnectMessage::from_json(&decrypted) - .map_err(|e| RpcError::Other(format!("nip46 {label} failed: {e}")))?; - if message.is_response() && message.id() == request_id { - session.client.unsubscribe(&subscription.val).await; - return Ok(message); - } - } - } - } -} diff --git a/src/nip46/connection.rs b/src/nip46/connection.rs @@ -1,211 +0,0 @@ -#![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); - } - - #[test] - fn parse_bunker_with_metadata() { - let url = format!("bunker://{HEX_PUBKEY}?relay=wss%3A%2F%2Frelay.example.com&name=Radroots&url=https%3A%2F%2Fradroots.org&image=https%3A%2F%2Fradroots.org%2Flogo.png"); - 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.name.as_deref(), Some("Radroots")); - assert_eq!(info.url.as_deref(), Some("https://radroots.org")); - assert_eq!(info.image.as_deref(), Some("https://radroots.org/logo.png")); - } - - #[test] - fn parse_nostrconnect_with_metadata() { - let url = format!("nostrconnect://{HEX_PUBKEY}?relay=wss%3A%2F%2Frelay.example.com&secret=token&name=Radroots&url=https%3A%2F%2Fradroots.org"); - 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.name.as_deref(), Some("Radroots")); - assert_eq!(info.url.as_deref(), Some("https://radroots.org")); - } -} diff --git a/src/nip46/mod.rs b/src/nip46/mod.rs @@ -1,5 +0,0 @@ -#![forbid(unsafe_code)] - -pub mod connection; -pub mod client; -pub mod session; diff --git a/src/nip46/session.rs b/src/nip46/session.rs @@ -1,66 +0,0 @@ -#![forbid(unsafe_code)] - -use std::collections::HashMap; -use std::sync::Arc; - -use tokio::sync::Mutex; - -use radroots_nostr::prelude::{ - RadrootsNostrClient, - RadrootsNostrKeys, - RadrootsNostrPublicKey, -}; - -#[derive(Clone)] -pub struct Nip46SessionStore { - inner: Arc<Mutex<HashMap<String, Nip46Session>>>, -} - -#[derive(Clone)] -pub struct Nip46Session { - pub id: String, - pub client: RadrootsNostrClient, - pub client_keys: RadrootsNostrKeys, - pub client_pubkey: RadrootsNostrPublicKey, - pub remote_signer_pubkey: RadrootsNostrPublicKey, - pub user_pubkey: Option<RadrootsNostrPublicKey>, - pub relays: Vec<String>, -} - -impl Nip46SessionStore { - pub fn new() -> Self { - Self { - inner: Arc::new(Mutex::new(HashMap::new())), - } - } - - pub async fn insert(&self, session: Nip46Session) { - let mut sessions = self.inner.lock().await; - sessions.insert(session.id.clone(), session); - } - - pub async fn get(&self, session_id: &str) -> Option<Nip46Session> { - let sessions = self.inner.lock().await; - sessions.get(session_id).cloned() - } - - pub async fn remove(&self, session_id: &str) -> bool { - let mut sessions = self.inner.lock().await; - sessions.remove(session_id).is_some() - } - - pub async fn set_user_pubkey( - &self, - session_id: &str, - pubkey: RadrootsNostrPublicKey, - ) -> bool { - let mut sessions = self.inner.lock().await; - match sessions.get_mut(session_id) { - Some(session) => { - session.user_pubkey = Some(pubkey); - true - } - None => false, - } - } -} diff --git a/src/radrootsd.rs b/src/radrootsd.rs @@ -1,41 +0,0 @@ -use std::time::Instant; - -use radroots_nostr::prelude::{ - RadrootsNostrClient, - RadrootsNostrKeys, - RadrootsNostrMetadata, - RadrootsNostrPublicKey, -}; - -use crate::nip46::session::Nip46SessionStore; - -#[derive(Clone)] -pub struct Radrootsd { - pub(crate) started: Instant, - pub client: RadrootsNostrClient, - pub pubkey: RadrootsNostrPublicKey, - pub metadata: RadrootsNostrMetadata, - pub info: serde_json::Value, - pub(crate) nip46_sessions: Nip46SessionStore, -} - -impl Radrootsd { - pub fn new(keys: RadrootsNostrKeys, metadata: RadrootsNostrMetadata) -> Self { - let pubkey = keys.public_key(); - let client = RadrootsNostrClient::new(keys); - let info = serde_json::json!({ - "version": env!("CARGO_PKG_VERSION"), - "build": option_env!("GIT_HASH").unwrap_or("unknown"), - }); - let nip46_sessions = Nip46SessionStore::new(); - - Self { - started: Instant::now(), - client, - pubkey, - metadata, - info, - nip46_sessions, - } - } -} -\ No newline at end of file diff --git a/src/transport/jsonrpc/mod.rs b/src/transport/jsonrpc/mod.rs diff --git a/src/transport/mod.rs b/src/transport/mod.rs @@ -0,0 +1,2 @@ +pub mod jsonrpc; +pub mod nostr; diff --git a/src/transport/nostr/listener.rs b/src/transport/nostr/listener.rs @@ -0,0 +1,60 @@ +use std::time::Duration; + +use anyhow::{anyhow, Result}; +use tokio::sync::broadcast; +use tracing::{info, warn}; + +use crate::core::state::Radrootsd; +use radroots_nostr::prelude::{ + radroots_nostr_filter_tag, + RadrootsNostrFilter, + RadrootsNostrKind, + RadrootsNostrRelayPoolNotification, + RadrootsNostrTimestamp, +}; + +const DEFAULT_TIMEOUT_SECS: u64 = 10; + +pub fn spawn_nip46_listener(radrootsd: Radrootsd) { + tokio::spawn(async move { + if let Err(error) = run_nip46_listener(radrootsd).await { + warn!("NIP-46 listener stopped: {error}"); + } + }); +} + +async fn run_nip46_listener(radrootsd: Radrootsd) -> Result<()> { + radrootsd.client.connect().await; + radrootsd + .client + .wait_for_connection(Duration::from_secs(DEFAULT_TIMEOUT_SECS)) + .await; + + let filter = RadrootsNostrFilter::new() + .kind(RadrootsNostrKind::NostrConnect) + .since(RadrootsNostrTimestamp::now()); + let filter = + radroots_nostr_filter_tag(filter, "p", vec![radrootsd.pubkey.to_hex()])?; + let mut notifications = radrootsd.client.notifications(); + let subscription = radrootsd.client.subscribe(filter, None).await?; + + info!("NIP-46 listener subscribed: {}", subscription.val); + + loop { + let notification = match notifications.recv().await { + Ok(notification) => notification, + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => { + return Err(anyhow!("nip46 listener notification closed")); + } + }; + let RadrootsNostrRelayPoolNotification::Event { event, .. } = notification else { + continue; + }; + let event = (*event).clone(); + if event.kind != RadrootsNostrKind::NostrConnect { + continue; + } + info!("NIP-46 request received: {}", event.id); + } +} diff --git a/src/transport/nostr/mod.rs b/src/transport/nostr/mod.rs @@ -0,0 +1 @@ +pub mod listener; diff --git a/src/validate/mod.rs b/src/validate/mod.rs @@ -1 +0,0 @@ -#![forbid(unsafe_code)]