radrootsd

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

commit c04ef78acc61daffa4d416a4c79dcb928993ee0a
parent 3e97c1eeff9c387c8e9b60786489355921ecba17
Author: triesap <137732411+triesap@users.noreply.github.com>
Date:   Fri, 22 Aug 2025 16:55:04 -0700

Add nostr `profile` rpc event methods and daemon identity loader.

Diffstat:
MCargo.lock | 24++++++++++++++++++++++++
MCargo.toml | 2++
Mcrates/radrootsd/Cargo.toml | 2++
Mcrates/radrootsd/src/cli.rs | 18+++++++++++++++++-
Acrates/radrootsd/src/identity.rs | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/radrootsd/src/infra/nostr.rs | 25++++++++++++++++++++++---
Mcrates/radrootsd/src/lib.rs | 9++++++---
Mcrates/radrootsd/src/main.rs | 6+++---
Mcrates/radrootsd/src/radrootsd.rs | 3+++
Acrates/radrootsd/src/rpc/events/mod.rs | 1+
Acrates/radrootsd/src/rpc/events/profile/mod.rs | 139+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/radrootsd/src/rpc/mod.rs | 2++
Aidentity.json | 4++++
13 files changed, 293 insertions(+), 10 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1817,6 +1817,15 @@ dependencies = [ ] [[package]] +name = "radroots-events-codec" +version = "0.1.0" +dependencies = [ + "nostr", + "radroots-core", + "radroots-events", +] + +[[package]] name = "radroots-runtime" version = "0.1.0" dependencies = [ @@ -1824,6 +1833,8 @@ dependencies = [ "clap", "config", "serde", + "serde_json", + "tempfile", "thiserror 1.0.69", "tokio", "toml", @@ -1843,6 +1854,7 @@ dependencies = [ "nostr-sdk", "radroots-core", "radroots-events", + "radroots-events-codec", "radroots-runtime", "reqwest", "serde", @@ -1850,6 +1862,7 @@ dependencies = [ "thiserror 1.0.69", "tokio", "tracing", + "uuid", ] [[package]] @@ -2942,6 +2955,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] +name = "uuid" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f33196643e165781c20a5ead5582283a7dacbb87855d867fbc2df3f81eddc1be" +dependencies = [ + "getrandom 0.3.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] name = "valuable" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/Cargo.toml b/Cargo.toml @@ -13,6 +13,7 @@ license = "AGPL-3.0" [workspace.dependencies] radroots-core = { path = "../../crates/crates/core" } radroots-events = { path = "../../crates/crates/events" } +radroots-events-codec = { path = "../../crates/crates/events-codec" } radroots-runtime = { path = "../../crates/crates/runtime" } anyhow = { version = "1" } @@ -26,3 +27,4 @@ serde_json = { version = "1", default-features = false } tokio = { version = "1" } thiserror = { version = "1" } tracing = { version = "0.1" } +uuid = { version = "1.16.0" } diff --git a/crates/radrootsd/Cargo.toml b/crates/radrootsd/Cargo.toml @@ -10,6 +10,7 @@ description = "The radroots daemon binary" [dependencies] radroots-core = { workspace = true, features = ["std", "serde", "typeshare"] } radroots-events = { workspace = true, features = ["serde"] } +radroots-events-codec = { workspace = true, features = ["nostr"] } radroots-runtime = { workspace = true, features = ["cli"] } anyhow = { workspace = true } @@ -23,3 +24,4 @@ serde_json = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["full"] } tracing = { workspace = true } +uuid = { workspace = true, features = ["v4"] } diff --git a/crates/radrootsd/src/cli.rs b/crates/radrootsd/src/cli.rs @@ -13,7 +13,23 @@ pub struct Args { long, value_name = "PATH", value_hint = ValueHint::FilePath, - default_value = "config.toml" + 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 JSON file (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/crates/radrootsd/src/identity.rs b/crates/radrootsd/src/identity.rs @@ -0,0 +1,68 @@ +use radroots_runtime::{JsonFile, RuntimeJsonError}; +use serde::{Deserialize, Serialize}; +use std::{ + path::{Path, PathBuf}, + str::FromStr, +}; +use thiserror::Error; +use tracing::warn; +use uuid::Uuid; + +pub const DEFAULT_IDENTITY_PATH: &str = "identity.json"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Identity { + pub key: String, +} + +#[derive(Debug, Error)] +pub enum IdentityError { + #[error(transparent)] + Store(#[from] RuntimeJsonError), + + #[error("invalid secret key: {0}")] + InvalidSecretKey(String), + + #[error( + "identity file missing at {0} and generation is not permitted (pass --allow-generate-identity)" + )] + GenerationNotAllowed(PathBuf), +} + +impl Identity { + pub fn load_or_generate<P: AsRef<Path>>( + path: Option<P>, + allow_generate: bool, + ) -> Result<JsonFile<Self>, IdentityError> { + let p = path + .map(|p| p.as_ref().to_path_buf()) + .unwrap_or_else(|| PathBuf::from(DEFAULT_IDENTITY_PATH)); + + if p.exists() { + let store = JsonFile::load(&p)?; + return Ok(store); + } + + if !allow_generate { + return Err(IdentityError::GenerationNotAllowed(p)); + } + + let store = JsonFile::load_or_create_with(&p, || { + let keys = nostr::Keys::generate(); + let secret_hex = keys.secret_key().to_secret_hex(); + let tag = Uuid::new_v4(); + warn!( + "No identity file found at {:?}; generated new secret (tag={tag})", + p + ); + Identity { key: secret_hex } + })?; + + Ok(store) + } + + pub fn to_keys(&self) -> Result<nostr::Keys, IdentityError> { + nostr::Keys::from_str(&self.key) + .map_err(|_| IdentityError::InvalidSecretKey(self.key.clone())) + } +} diff --git a/crates/radrootsd/src/infra/nostr.rs b/crates/radrootsd/src/infra/nostr.rs @@ -1,12 +1,31 @@ use nostr::{key::PublicKey, nips::nip19::FromBech32}; use radroots_events::relay_document::models::RadrootsRelayDocument; -use crate::utils::ws_to_http; +use crate::{rpc::RpcError, utils::ws_to_http}; -pub fn parse_pubkey(s: &str) -> Option<PublicKey> { +#[derive(Debug, thiserror::Error)] +pub enum NostrError { + #[error("invalid pubkey format: {0}")] + InvalidPubkey(String), +} + +impl From<NostrError> for RpcError { + fn from(err: NostrError) -> Self { + RpcError::InvalidParams(err.to_string()) + } +} + +pub fn parse_pubkey(s: &str) -> Result<PublicKey, NostrError> { PublicKey::from_bech32(s) .or_else(|_| PublicKey::from_hex(s)) - .ok() + .map_err(|_| NostrError::InvalidPubkey(s.to_string())) +} + +pub fn parse_pubkeys(input: &[String]) -> Result<Vec<PublicKey>, RpcError> { + input + .iter() + .map(|s| parse_pubkey(s).map_err(Into::into)) + .collect() } pub async fn fetch_nip11(ws_url: &str) -> Option<RadrootsRelayDocument> { diff --git a/crates/radrootsd/src/lib.rs b/crates/radrootsd/src/lib.rs @@ -3,6 +3,7 @@ pub mod config; pub mod infra { pub mod nostr; } +pub mod identity; pub mod radrootsd; pub mod rpc; pub mod utils; @@ -12,10 +13,12 @@ use anyhow::Result; pub use cli::Args as cli_args; use tracing::info; -use crate::radrootsd::Radrootsd; +use crate::{identity::Identity, radrootsd::Radrootsd}; + +pub async fn run_radrootsd(settings: &config::Settings, args: &cli_args) -> Result<()> { + let store = Identity::load_or_generate(args.identity.as_ref(), args.allow_generate_identity)?; + let keys = store.value.to_keys()?; -pub async fn run_radrootsd(settings: &config::Settings) -> Result<()> { - let keys = nostr::Keys::generate(); let radrootsd = Radrootsd::new(keys, settings.metadata.clone()); for relay in settings.config.relays.iter() { diff --git a/crates/radrootsd/src/main.rs b/crates/radrootsd/src/main.rs @@ -11,12 +11,12 @@ async fn main() { } async fn setup() -> Result<()> { - let (_args, settings): (cli_args, config::Settings) = + let (args, settings): (cli_args, config::Settings) = radroots_runtime::parse_and_load_path(|a: &cli_args| Some(a.config.as_path()))?; radroots_runtime::init_with(&settings.config.logs_dir, None)?; - info!("Starting radrootsd on {}", settings.config.rpc_addr); + info!("Starting radrootsd"); - run_radrootsd(&settings).await + run_radrootsd(&settings, &args).await } diff --git a/crates/radrootsd/src/radrootsd.rs b/crates/radrootsd/src/radrootsd.rs @@ -5,12 +5,14 @@ use std::time::Instant; pub struct Radrootsd { pub(crate) started: Instant, pub client: Client, + pub pubkey: nostr::PublicKey, pub metadata: nostr::Metadata, pub info: serde_json::Value, } impl Radrootsd { pub fn new(keys: nostr::Keys, metadata: nostr::Metadata) -> Self { + let pubkey = keys.public_key(); let client = Client::new(keys); let info = serde_json::json!({ "version": env!("CARGO_PKG_VERSION"), @@ -20,6 +22,7 @@ impl Radrootsd { Self { started: Instant::now(), client, + pubkey, metadata, info, } diff --git a/crates/radrootsd/src/rpc/events/mod.rs b/crates/radrootsd/src/rpc/events/mod.rs @@ -0,0 +1 @@ +pub mod profile; diff --git a/crates/radrootsd/src/rpc/events/profile/mod.rs b/crates/radrootsd/src/rpc/events/profile/mod.rs @@ -0,0 +1,139 @@ +use std::time::Duration; + +use anyhow::Result; +use jsonrpsee::RpcModule; +use nostr::nips::nip19::ToBech32; +use serde::Deserialize; +use serde_json::{Value as JsonValue, json}; + +use crate::radrootsd::Radrootsd; +use crate::rpc::RpcError; + +use radroots_events::profile::models::RadrootsProfile; +use radroots_events_codec::profile::encode::to_metadata; + +use nostr_sdk::prelude::EventBuilder; + +#[derive(Debug, Deserialize)] +struct PublishProfileParams { + profile: RadrootsProfile, +} + +pub fn module(radrootsd: Radrootsd) -> Result<RpcModule<Radrootsd>> { + let mut m = RpcModule::new(radrootsd); + + m.register_async_method("events.profile.list", |_params, ctx, _| async move { + if ctx.client.relays().await.is_empty() { + return Err(RpcError::NoRelays); + } + + let ctx_pk = ctx.pubkey; + + let filter = nostr::Filter::new() + .authors(vec![ctx_pk]) + .kind(nostr::Kind::Metadata); + + let stored = ctx + .client + .database() + .query(filter.clone()) + .await + .map_err(|e| RpcError::Other(format!("database query failed: {e}")))?; + let fetched = ctx + .client + .fetch_events(filter, Duration::from_secs(10)) + .await + .map_err(|e| RpcError::Other(format!("network fetch failed: {e}")))?; + + let mut latest: Option<nostr::Event> = None; + + let mut consider = |ev: nostr::Event| { + if ev.kind != nostr::Kind::Metadata { + return; + } + if let Some(cur) = &latest { + if ev.created_at > cur.created_at { + latest = Some(ev); + } + } else { + latest = Some(ev); + } + }; + + for ev in stored.into_iter() { + consider(ev); + } + for ev in fetched.into_iter() { + consider(ev); + } + + let ctx_npub = ctx_pk + .to_bech32() + .map_err(|e| RpcError::Other(format!("bech32 encode failed: {e}")))?; + + let row = if let Some(ev) = latest { + let parsed: Option<serde_json::Value> = serde_json::from_str(&ev.content).ok(); + let profile: Option<radroots_events::profile::models::RadrootsProfile> = + serde_json::from_str(&ev.content).ok(); + + json!({ + "author_hex": ctx_pk.to_string(), + "author_npub": ctx_npub, + "event_id": ev.id.to_string(), + "created_at": ev.created_at.as_u64(), + "content": ev.content, + "metadata_json": parsed, + "radroots_profile": profile, + }) + } else { + json!({ + "author_hex": ctx_pk.to_string(), + "author_npub": ctx_npub, + "event_id": null, + "created_at": null, + "content": null, + "metadata_json": null, + "radroots_profile": null + }) + }; + + Ok::<JsonValue, RpcError>(json!({ "profiles": [row] })) + })?; + + m.register_async_method("events.profile.publish", |params, ctx, _| async move { + let relays = ctx.client.relays().await; + if relays.is_empty() { + return Err(RpcError::NoRelays); + } + + let PublishProfileParams { profile } = params + .parse() + .map_err(|e| RpcError::InvalidParams(e.to_string()))?; + + let metadata = to_metadata(&profile).map_err(|e| RpcError::InvalidParams(e.to_string()))?; + + let builder = EventBuilder::metadata(&metadata); + + let output = ctx + .client + .send_event_builder(builder) + .await + .map_err(|e| RpcError::Other(format!("failed to publish metadata: {e}")))?; + + let id_hex = output.id().to_string(); + let sent: Vec<String> = output.success.into_iter().map(|u| u.to_string()).collect(); + let failed: Vec<(String, String)> = output + .failed + .into_iter() + .map(|(u, e)| (u.to_string(), e.to_string())) + .collect(); + + Ok::<JsonValue, RpcError>(json!({ + "id": id_hex, + "sent": sent, + "failed": failed + })) + })?; + + Ok(m) +} diff --git a/crates/radrootsd/src/rpc/mod.rs b/crates/radrootsd/src/rpc/mod.rs @@ -6,6 +6,7 @@ use jsonrpsee::server::{RpcModule, Server, ServerHandle}; use crate::radrootsd::Radrootsd; mod error; +mod events; mod relays; mod system; @@ -17,6 +18,7 @@ pub async fn start_rpc(radrootsd: Radrootsd, addr: SocketAddr) -> Result<ServerH let mut root = RpcModule::new(radrootsd.clone()); root.merge(system::module(radrootsd.clone())?)?; root.merge(relays::module(radrootsd.clone())?)?; + root.merge(events::profile::module(radrootsd.clone())?)?; let handle = server.start(root); Ok(handle) diff --git a/identity.json b/identity.json @@ -0,0 +1,3 @@ +{ + "key": "1ae3ba0030d8e3a9f34bf1f1181cb1aea67d148980f13df86c8976cd0ac1000d" +} +\ No newline at end of file