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:
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