radrootsd

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

commit 17f10ab220aceea8921e3d7d6c9e94aec55e9d96
parent 5b62b872c753dca50b7112217256c871898dd726
Author: triesap <triesap@radroots.dev>
Date:   Wed,  7 Jan 2026 17:03:48 +0000

nip89: publish handler announcement on startup

- build kind 31990 event with k/d tags for NIP-46
- include metadata JSON content when available
- publish via relay client during startup connect
- warn on build/publish failures without aborting

Diffstat:
Msrc/app/runtime.rs | 28++++++++++++++++++++++++++++
Msrc/core/nip46/session.rs | 20+++++++++++++++++++-
Msrc/transport/jsonrpc/methods/nip46/connect.rs | 17+++++++++++++++++
Msrc/transport/nostr/listener.rs | 11++++++++++-
4 files changed, 74 insertions(+), 2 deletions(-)

diff --git a/src/app/runtime.rs b/src/app/runtime.rs @@ -6,11 +6,14 @@ use crate::app::{cli, config}; use crate::core::Radrootsd; use crate::transport::jsonrpc; use crate::transport::nostr::listener::spawn_nip46_listener; +use radroots_events::kinds::KIND_APPLICATION_HANDLER; use radroots_events::profile::RadrootsProfileType; use radroots_events_codec::profile::encode::profile_type_tags; use radroots_nostr::prelude::{ + radroots_nostr_build_event, radroots_nostr_build_metadata_event, radroots_nostr_publish_identity_profile_with_type, + RadrootsNostrKind, RadrootsNostrTag, RadrootsNostrTagKind, }; @@ -87,6 +90,31 @@ pub async fn run() -> Result<()> { tracing::info!("Published metadata on startup"); } } + + let nip46_kind = RadrootsNostrKind::NostrConnect.as_u16().to_string(); + let nip89_content = if has_metadata { + serde_json::to_string(&md).unwrap_or_default() + } else { + String::new() + }; + let nip89_tags = vec![ + vec!["d".to_string(), nip46_kind.clone()], + vec!["k".to_string(), nip46_kind], + ]; + let nip89_builder = + radroots_nostr_build_event(KIND_APPLICATION_HANDLER, nip89_content, nip89_tags); + match nip89_builder { + Ok(builder) => { + if let Err(e) = client.send_event_builder(builder).await { + tracing::warn!("Failed to publish NIP-89 announcement: {e}"); + } else { + tracing::info!("Published NIP-89 announcement"); + } + } + Err(e) => { + tracing::warn!("Failed to build NIP-89 announcement: {e}"); + } + } }); spawn_nip46_listener(radrootsd.clone()); diff --git a/src/core/nip46/session.rs b/src/core/nip46/session.rs @@ -1,6 +1,6 @@ #![forbid(unsafe_code)] -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::time::{Duration, Instant}; use std::sync::Arc; @@ -16,6 +16,7 @@ use nostr::nips::nip46::NostrConnectRequest; #[derive(Clone)] pub struct Nip46SessionStore { inner: Arc<Mutex<HashMap<String, Nip46Session>>>, + used_secrets: Arc<Mutex<HashSet<String>>>, } #[derive(Clone)] @@ -53,6 +54,7 @@ impl Nip46SessionStore { pub fn new() -> Self { Self { inner: Arc::new(Mutex::new(HashMap::new())), + used_secrets: Arc::new(Mutex::new(HashSet::new())), } } @@ -159,6 +161,15 @@ impl Nip46SessionStore { listed.sort_by(|left, right| left.id.cmp(&right.id)); listed } + + pub async fn claim_secret(&self, secret: &str) -> bool { + let mut secrets = self.used_secrets.lock().await; + if secrets.contains(secret) { + return false; + } + secrets.insert(secret.to_string()); + true + } } impl Nip46Session { @@ -303,4 +314,11 @@ mod tests { assert!(sign_event_allowed(&perms, 1)); assert!(!sign_event_allowed(&perms, 3)); } + + #[tokio::test] + async fn claim_secret_rejects_reuse() { + let store = Nip46SessionStore::new(); + assert!(store.claim_secret("secret").await); + assert!(!store.claim_secret("secret").await); + } } diff --git a/src/transport/jsonrpc/methods/nip46/connect.rs b/src/transport/jsonrpc/methods/nip46/connect.rs @@ -117,6 +117,7 @@ async fn connect_bunker( .await?; validate_connect_response(&response, info.secret.as_deref())?; + claim_secret(&ctx, info.secret.as_deref()).await?; let perms = filter_perms(&info.perms, &ctx.state.nip46_config.perms); let expires_at = session_expires_at(ctx.state.nip46_config.session_ttl_secs); @@ -197,6 +198,7 @@ async fn connect_nostrconnect( ) .await?; validate_nostrconnect_response(&response, secret)?; + claim_secret(&ctx, info.secret.as_deref()).await?; let perms = filter_perms(&info.perms, &ctx.state.nip46_config.perms); let expires_at = session_expires_at(ctx.state.nip46_config.session_ttl_secs); @@ -241,6 +243,21 @@ async fn add_relays(client: &RadrootsNostrClient, relays: &[String]) -> Result<( Ok(()) } +async fn claim_secret(ctx: &RpcContext, secret: Option<&str>) -> Result<(), RpcError> { + let Some(secret) = secret else { + return Ok(()); + }; + let trimmed = secret.trim(); + if trimmed.is_empty() { + return Err(RpcError::InvalidParams("secret is empty".to_string())); + } + if ctx.state.nip46_sessions.claim_secret(trimmed).await { + Ok(()) + } else { + Err(RpcError::InvalidParams("secret already used".to_string())) + } +} + async fn send_connect_request( client: &RadrootsNostrClient, client_keys: &RadrootsNostrKeys, diff --git a/src/transport/nostr/listener.rs b/src/transport/nostr/listener.rs @@ -124,11 +124,20 @@ pub(crate) async fn handle_request( match request { NostrConnectRequest::Connect { remote_signer_public_key, - .. + secret, } => { if remote_signer_public_key != radrootsd.pubkey { return NostrConnectResponse::with_error("remote signer pubkey mismatch"); } + if let Some(secret) = secret.as_deref() { + let trimmed = secret.trim(); + if trimmed.is_empty() { + return NostrConnectResponse::with_error("secret is empty"); + } + if !radrootsd.nip46_sessions.claim_secret(trimmed).await { + return NostrConnectResponse::with_error("connect secret already used"); + } + } let session_id = client_pubkey.to_hex(); let expires_at = session_expires_at(radrootsd.nip46_config.session_ttl_secs);