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