radrootsd

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

commit dc617ddb71230a8fae98d648c9be7547f96edc5b
parent 7e9c0fe41c06e8a556bd19dbbb653e207480fdd2
Author: triesap <tyson@radroots.org>
Date:   Sat, 18 Jul 2026 11:32:19 +0000

nostr: align daemon with alpha-1 contracts

- publish strict signed Profile and NIP-89 presence events after verified relay connection
- reject URL-only media and unsupported metadata outside the authored Profile contract
- migrate NIP-46 notification interop and structured capability contract assertions
- pin Radroots alpha-1 dependencies and refresh the Rust 1.97 Nix overlay

Diffstat:
MCargo.lock | 56++++++++++++++++++++++++++++++++++++++++++--------------
MCargo.toml | 19+++++++++++--------
Mconfig.toml | 6+-----
Mflake.lock | 6+++---
Msrc/app/config.rs | 162++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/app/runtime.rs | 189+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Msrc/transport/jsonrpc/methods/mod.rs | 46+++++++++++++++++++++++++---------------------
Msrc/transport/jsonrpc/methods/nip46/connect.rs | 4++--
Msrc/transport/jsonrpc/nip46/client.rs | 2+-
Msrc/transport/nostr/listener.rs | 2+-
Mtests/source_boundary.rs | 24+++++++++++++++++-------
11 files changed, 424 insertions(+), 92 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1248,6 +1248,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] name = "js-sys" version = "0.3.91" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1420,6 +1426,12 @@ dependencies = [ ] [[package]] +name = "mediatype" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "120fa187be19d9962f0926633453784691731018a2bf936ddb4e29101b79c4a7" + +[[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1862,8 +1874,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] +name = "radroots_blossom" +version = "1.0.0-alpha.1" +dependencies = [ + "mediatype", + "serde", + "sha2", + "url", +] + +[[package]] name = "radroots_core" -version = "0.1.0-alpha.2" +version = "1.0.0-alpha.1" dependencies = [ "rust_decimal", "rust_decimal_macros", @@ -1872,31 +1894,35 @@ dependencies = [ [[package]] name = "radroots_event" -version = "0.1.0-alpha.2" +version = "1.0.0-alpha.1" dependencies = [ "hex", + "jiff-tzdb", + "radroots_blossom", "radroots_core", "secp256k1", "serde", "serde_json", "sha2", + "url", ] [[package]] name = "radroots_event_codec" -version = "0.1.0-alpha.2" +version = "1.0.0-alpha.1" dependencies = [ "nostr", "radroots_core", "radroots_event", + "serde", + "serde_json", ] [[package]] name = "radroots_identity" -version = "0.1.0-alpha.2" +version = "1.0.0-alpha.1" dependencies = [ "nostr", - "radroots_event", "radroots_protected_store", "radroots_runtime", "radroots_runtime_paths", @@ -1909,9 +1935,10 @@ dependencies = [ [[package]] name = "radroots_log" -version = "0.1.0-alpha.2" +version = "1.0.0-alpha.1" dependencies = [ "chrono", + "serde_json", "thiserror 1.0.69", "tracing", "tracing-appender", @@ -1920,7 +1947,7 @@ dependencies = [ [[package]] name = "radroots_nostr" -version = "0.1.0-alpha.2" +version = "1.0.0-alpha.1" dependencies = [ "nostr", "nostr-sdk", @@ -1935,7 +1962,7 @@ dependencies = [ [[package]] name = "radroots_protected_store" -version = "0.1.0-alpha.2" +version = "1.0.0-alpha.1" dependencies = [ "chacha20poly1305", "getrandom 0.2.17", @@ -1947,7 +1974,7 @@ dependencies = [ [[package]] name = "radroots_runtime" -version = "0.1.0-alpha.2" +version = "1.0.0-alpha.1" dependencies = [ "anyhow", "chacha20poly1305", @@ -1970,7 +1997,7 @@ dependencies = [ [[package]] name = "radroots_runtime_paths" -version = "0.1.0-alpha.2" +version = "1.0.0-alpha.1" dependencies = [ "serde", "thiserror 1.0.69", @@ -1978,11 +2005,11 @@ dependencies = [ [[package]] name = "radroots_secret_vault" -version = "0.1.0-alpha.2" +version = "1.0.0-alpha.1" [[package]] name = "radroots_transport" -version = "0.1.0-alpha.2" +version = "1.0.0-alpha.1" dependencies = [ "serde", "sha2", @@ -1990,7 +2017,7 @@ dependencies = [ [[package]] name = "radroots_transport_nostr" -version = "0.1.0-alpha.2" +version = "1.0.0-alpha.1" dependencies = [ "futures", "nostr", @@ -2005,7 +2032,7 @@ dependencies = [ [[package]] name = "radroots_transport_publish_protocol" -version = "0.1.0-alpha.2" +version = "1.0.0-alpha.1" dependencies = [ "radroots_transport", "serde", @@ -2021,6 +2048,7 @@ dependencies = [ "jsonrpsee", "nostr", "radroots_event", + "radroots_event_codec", "radroots_identity", "radroots_nostr", "radroots_runtime", diff --git a/Cargo.toml b/Cargo.toml @@ -11,26 +11,29 @@ description = "Radroots local runtime daemon for storage, sync, and relay publis resolver = "2" [workspace.dependencies] -radroots_event = { path = "../lib/crates/event" } -radroots_identity = { path = "../lib/crates/identity" } -radroots_nostr = { path = "../lib/crates/nostr" } -radroots_transport_publish_protocol = { path = "../lib/crates/transport_publish_protocol" } -radroots_transport_nostr = { path = "../lib/crates/transport_nostr", default-features = false } -radroots_transport = { path = "../lib/crates/transport", default-features = false } -radroots_runtime = { path = "../lib/crates/runtime" } +radroots_event = { path = "../lib/crates/event", version = "=1.0.0-alpha.1" } +radroots_event_codec = { path = "../lib/crates/event_codec", version = "=1.0.0-alpha.1" } +radroots_identity = { path = "../lib/crates/identity", version = "=1.0.0-alpha.1" } +radroots_nostr = { path = "../lib/crates/nostr", version = "=1.0.0-alpha.1" } +radroots_transport_publish_protocol = { path = "../lib/crates/transport_publish_protocol", version = "=1.0.0-alpha.1" } +radroots_transport_nostr = { path = "../lib/crates/transport_nostr", version = "=1.0.0-alpha.1", default-features = false } +radroots_transport = { path = "../lib/crates/transport", version = "=1.0.0-alpha.1", default-features = false } +radroots_runtime = { path = "../lib/crates/runtime", version = "=1.0.0-alpha.1" } +radroots_runtime_paths = { path = "../lib/crates/runtime_paths", version = "=1.0.0-alpha.1" } [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } [dependencies] radroots_event = { workspace = true, features = ["serde", "signature"] } +radroots_event_codec = { workspace = true, features = ["serde_json"] } radroots_identity = { workspace = true } radroots_nostr = { workspace = true, features = ["client", "codec", "events", "http"] } radroots_transport_publish_protocol = { workspace = true, features = ["std", "serde"] } radroots_transport_nostr = { workspace = true, features = ["std", "client"] } radroots_transport = { workspace = true } radroots_runtime = { workspace = true, features = ["cli"] } -radroots_runtime_paths = { path = "../lib/crates/runtime_paths" } +radroots_runtime_paths = { workspace = true } nostr = { version = "0.44.2", features = ["nip46"] } anyhow = { version = "1" } diff --git a/config.toml b/config.toml @@ -21,12 +21,8 @@ name = "radrootsd" # display_name = "" # about = "" -# picture = "" -# banner = "" -# banner = "" # nip05 = "" -# lud06 = "" -# lud16 = "" +# bot = true [config] relays = [ diff --git a/flake.lock b/flake.lock @@ -29,11 +29,11 @@ ] }, "locked": { - "lastModified": 1774926780, - "narHash": "sha256-JMdDYn0F+swYBILlpCeHDbCSyzqkeSGNxZ/Q5J584jM=", + "lastModified": 1784350408, + "narHash": "sha256-OstzLWL5t7Xe14xEC6GIMJCp0PrYNTSA0El7GG2av88=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "962a0934d0e32f42d1b5e49186f9595f9b178d2d", + "rev": "3c38e1e1ba9c8d7030f7b5a801398ea7d8a6fdc0", "type": "github" }, "original": { diff --git a/src/app/config.rs b/src/app/config.rs @@ -1,4 +1,5 @@ use anyhow::{Context, Result, bail}; +use radroots_event::profile::{RadrootsAuthoredProfile, RadrootsNip05Identifier}; use radroots_nostr::prelude::RadrootsNostrMetadata; use radroots_runtime::RadrootsNostrServiceConfig; use serde::{Deserialize, Serialize}; @@ -394,8 +395,81 @@ pub struct Settings { } impl Settings { + pub fn authored_profile(&self) -> Result<RadrootsAuthoredProfile> { + let metadata = &self.metadata; + let name = metadata + .name + .as_deref() + .ok_or_else(|| anyhow::anyhow!("metadata.name is required for the authored Profile"))?; + let mut profile = RadrootsAuthoredProfile::new(name.to_owned()) + .context("metadata.name is invalid for the authored Profile")?; + + if let Some(display_name) = metadata.display_name.as_ref() { + profile = profile.with_display_name(display_name.clone()); + } + if let Some(about) = metadata.about.as_ref() { + profile = profile.with_about(about.clone()); + } + if let Some(nip05) = metadata.nip05.as_deref() { + let nip05 = RadrootsNip05Identifier::parse(nip05) + .context("metadata.nip05 is invalid for the authored Profile")?; + profile = profile.with_nip05(nip05); + } + + let media_fields = [ + metadata.picture.as_ref().map(|_| "picture"), + metadata.banner.as_ref().map(|_| "banner"), + ] + .into_iter() + .flatten() + .collect::<Vec<_>>(); + if !media_fields.is_empty() { + bail!( + "metadata.{} cannot be authored from URL-only configuration; Profile media requires a byte-verified Blossom descriptor", + media_fields.join(" and metadata.") + ); + } + + let bot = match metadata.custom.get("bot") { + Some(serde_json::Value::Bool(value)) => Some(*value), + Some(_) => bail!("metadata.bot must be a Boolean for the authored Profile"), + None => None, + }; + if let Some(bot) = bot { + profile = profile.with_bot(bot); + } + + let mut unsupported_fields = Vec::new(); + if metadata.website.is_some() { + unsupported_fields.push("website".to_owned()); + } + if metadata.lud06.is_some() { + unsupported_fields.push("lud06".to_owned()); + } + if metadata.lud16.is_some() { + unsupported_fields.push("lud16".to_owned()); + } + unsupported_fields.extend( + metadata + .custom + .keys() + .filter(|key| key.as_str() != "bot") + .cloned(), + ); + if !unsupported_fields.is_empty() { + bail!( + "metadata fields are not supported by the strict authored Profile contract: {}", + unsupported_fields.join(", ") + ); + } + + Ok(profile) + } + pub fn validate(&self) -> Result<()> { - self.config.validate() + self.config.validate()?; + self.authored_profile()?; + Ok(()) } } @@ -411,11 +485,13 @@ mod tests { RadrootsdRuntimeContractOutput, default_runtime_paths_for_process, resolve_runtime_paths_with_resolver, runtime_contract_with_selection, }; + use radroots_event::profile::RadrootsNip05Identifier; use radroots_runtime::RadrootsNostrServiceConfig; use radroots_runtime_paths::{ RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPathResolver, RadrootsPlatform, RadrootsRuntimePathSelection, }; + use serde_json::json; fn linux_resolver(home: &str) -> RadrootsPathResolver { RadrootsPathResolver::new( @@ -532,6 +608,90 @@ mod tests { } #[test] + fn authored_profile_accepts_only_strict_metadata_fields() { + let mut metadata = radroots_nostr::prelude::RadrootsNostrMetadata::new() + .name("radrootsd") + .display_name("Radroots daemon") + .about("local relay publishing") + .nip05("daemon@radroots.example"); + metadata.custom.insert("bot".to_owned(), json!(true)); + let settings = super::Settings { + metadata, + config: Configuration { + service: service_config(), + rpc: RpcConfig::default(), + rpc_addr: None, + nip46: Nip46Config::default(), + transport_publish: TransportPublishConfig::default(), + }, + }; + + let profile = settings.authored_profile().expect("authored profile"); + assert_eq!(profile.name(), "radrootsd"); + assert_eq!(profile.display_name(), Some("Radroots daemon")); + assert_eq!(profile.about(), Some("local relay publishing")); + assert_eq!( + profile.nip05().map(RadrootsNip05Identifier::as_str), + Some("daemon@radroots.example") + ); + assert_eq!(profile.bot(), Some(true)); + assert!(profile.picture().is_none()); + assert!(profile.banner().is_none()); + } + + #[test] + fn authored_profile_rejects_url_only_media() { + let metadata = radroots_nostr::prelude::RadrootsNostrMetadata::new() + .name("radrootsd") + .picture(url::Url::parse("https://blossom.example/blob").expect("picture URL")); + let settings = super::Settings { + metadata, + config: Configuration { + service: service_config(), + rpc: RpcConfig::default(), + rpc_addr: None, + nip46: Nip46Config::default(), + transport_publish: TransportPublishConfig::default(), + }, + }; + + let error = settings + .authored_profile() + .expect_err("URL-only media must fail closed"); + assert!( + error + .to_string() + .contains("byte-verified Blossom descriptor") + ); + } + + #[test] + fn authored_profile_rejects_missing_invalid_and_unsupported_fields() { + let mut settings = super::Settings { + metadata: radroots_nostr::prelude::RadrootsNostrMetadata::new(), + config: Configuration { + service: service_config(), + rpc: RpcConfig::default(), + rpc_addr: None, + nip46: Nip46Config::default(), + transport_publish: TransportPublishConfig::default(), + }, + }; + assert!(settings.authored_profile().is_err()); + + settings.metadata.name = Some("radrootsd".to_owned()); + settings.metadata.nip05 = Some("invalid".to_owned()); + assert!(settings.authored_profile().is_err()); + + settings.metadata.nip05 = None; + settings.metadata.website = Some("https://radroots.example".to_owned()); + let error = settings + .authored_profile() + .expect_err("unsupported fields must fail closed"); + assert!(error.to_string().contains("website")); + } + + #[test] fn runtime_paths_follow_interactive_user_contract() { let paths = resolve_runtime_paths_with_resolver( &linux_resolver("/home/treesap"), diff --git a/src/app/runtime.rs b/src/app/runtime.rs @@ -1,4 +1,4 @@ -use anyhow::Result; +use anyhow::{Context, Result, bail}; use jsonrpsee::server::ServerHandle; use radroots_identity::RadrootsIdentity; use std::time::Duration; @@ -16,13 +16,12 @@ use crate::transport::jsonrpc; #[cfg(not(test))] use crate::transport::nostr::listener::spawn_nip46_listener; #[cfg(not(test))] -use anyhow::Context; -#[cfg(not(test))] use clap::Parser; -use radroots_event::profile::RadrootsProfileType; +use radroots_event::profile::RadrootsAuthoredProfile; +use radroots_event_codec::profile::authored::authored_profile_to_wire_parts; use radroots_nostr::prelude::{ RadrootsNostrApplicationHandlerSpec, RadrootsNostrKind, - radroots_nostr_bootstrap_service_presence, + radroots_nostr_build_application_handler_event, radroots_nostr_build_event, }; use std::path::PathBuf; @@ -267,24 +266,63 @@ fn log_runtime_startup_report(report: &RadrootsdRuntimeStartupReport) { async fn bootstrap_presence( client: &radroots_nostr::prelude::RadrootsNostrClient, identity: &RadrootsIdentity, - metadata: &radroots_nostr::prelude::RadrootsNostrMetadata, + profile: &RadrootsAuthoredProfile, handler_spec: &RadrootsNostrApplicationHandlerSpec, ) -> Result<()> { - let bootstrap_result: Result<()> = match take_bootstrap_hook_result() { - Some(result) => result.map_err(anyhow::Error::msg), - None => radroots_nostr_bootstrap_service_presence( - client, - identity, - Some(RadrootsProfileType::Radrootsd), - metadata, - handler_spec, - Duration::from_secs(5), - ) + if let Some(result) = take_bootstrap_hook_result() { + return result.map_err(anyhow::Error::msg); + } + + let (profile_event, handler_event) = + build_service_presence_events(identity, profile, handler_spec)?; + + client.connect().await; + client.wait_for_connection(Duration::from_secs(5)).await; + if !client + .relays() .await - .map(|_| ()) - .map_err(anyhow::Error::from), - }; - bootstrap_result?; + .into_values() + .any(|relay| relay.is_connected()) + { + bail!("service presence requires at least one connected relay"); + } + + publish_presence_event(client, &profile_event, "service Profile").await?; + publish_presence_event(client, &handler_event, "NIP-89 application handler").await?; + Ok(()) +} + +fn build_service_presence_events( + identity: &RadrootsIdentity, + profile: &RadrootsAuthoredProfile, + handler_spec: &RadrootsNostrApplicationHandlerSpec, +) -> Result<(nostr::Event, nostr::Event)> { + let profile_wire = authored_profile_to_wire_parts(profile) + .context("encode strict authored service Profile")?; + let profile_event = + radroots_nostr_build_event(profile_wire.kind, profile_wire.content, profile_wire.tags) + .context("build service Profile event")? + .sign_with_keys(identity.keys()) + .context("sign service Profile event")?; + let handler_event = radroots_nostr_build_application_handler_event(handler_spec) + .context("build NIP-89 application handler event")? + .sign_with_keys(identity.keys()) + .context("sign NIP-89 application handler event")?; + Ok((profile_event, handler_event)) +} + +async fn publish_presence_event( + client: &radroots_nostr::prelude::RadrootsNostrClient, + event: &nostr::Event, + event_name: &str, +) -> Result<()> { + let output = client + .send_event(event) + .await + .with_context(|| format!("publish {event_name}"))?; + if output.success.is_empty() { + bail!("{event_name} publication did not succeed on any relay"); + } Ok(()) } @@ -292,6 +330,7 @@ async fn bootstrap_presence( async fn publish_service_presence( client: radroots_nostr::prelude::RadrootsNostrClient, identity: RadrootsIdentity, + profile: RadrootsAuthoredProfile, metadata: radroots_nostr::prelude::RadrootsNostrMetadata, service_cfg: radroots_runtime::RadrootsNostrServiceConfig, nip46_config: config::Nip46Config, @@ -305,21 +344,29 @@ async fn publish_service_presence( relays: service_cfg.relays.clone(), nostrconnect_url: nip46_config.nostrconnect_url.clone(), }; - bootstrap_presence(&client, &identity, &metadata, &handler_spec).await + bootstrap_presence(&client, &identity, &profile, &handler_spec).await } #[cfg_attr(coverage_nightly, coverage(off))] async fn maybe_publish_service_presence( client: radroots_nostr::prelude::RadrootsNostrClient, identity: RadrootsIdentity, + profile: RadrootsAuthoredProfile, metadata: radroots_nostr::prelude::RadrootsNostrMetadata, service_cfg: radroots_runtime::RadrootsNostrServiceConfig, nip46_config: config::Nip46Config, ) { #[cfg(test)] { - let result = - publish_service_presence(client, identity, metadata, service_cfg, nip46_config).await; + let result = publish_service_presence( + client, + identity, + profile, + metadata, + service_cfg, + nip46_config, + ) + .await; if let Err(err) = result { warn!("Failed to publish service presence on startup: {err}"); } else { @@ -329,8 +376,15 @@ async fn maybe_publish_service_presence( #[cfg(not(test))] tokio::spawn(async move { - let result = - publish_service_presence(client, identity, metadata, service_cfg, nip46_config).await; + let result = publish_service_presence( + client, + identity, + profile, + metadata, + service_cfg, + nip46_config, + ) + .await; if let Err(err) = result { warn!("Failed to publish service presence on startup: {err}"); } else { @@ -446,7 +500,8 @@ async fn handle_command(command: cli::Command, settings: &config::Settings) -> R pub async fn run() -> Result<()> { let (args, settings): (cli::Args, config::Settings) = load_args_and_settings()?; - settings.config.validate()?; + settings.validate()?; + let authored_profile = settings.authored_profile()?; #[cfg(not(test))] { @@ -481,6 +536,7 @@ pub async fn run() -> Result<()> { maybe_publish_service_presence( radrootsd.client.clone(), identity.clone(), + authored_profile, settings.metadata.clone(), settings.config.service.clone(), settings.config.nip46.clone(), @@ -518,14 +574,17 @@ fn service_presence_kinds() -> Vec<u32> { #[cfg_attr(coverage_nightly, coverage(off))] mod tests { use super::{ - RadrootsdRuntimeStartupReport, RunWaitOutcome, run, run_bootstrap_hook, run_load_hook, - run_start_rpc_hook, run_wait_hook, runtime_startup_report, + RadrootsdRuntimeStartupReport, RunWaitOutcome, build_service_presence_events, run, + run_bootstrap_hook, run_load_hook, run_start_rpc_hook, run_wait_hook, + runtime_startup_report, }; use crate::app::{cli, config, paths}; use crate::core::Radrootsd; use crate::transport::jsonrpc; use radroots_identity::RadrootsIdentity; - use radroots_nostr::prelude::RadrootsNostrMetadata; + use radroots_nostr::prelude::{ + RadrootsNostrApplicationHandlerSpec, RadrootsNostrKind, RadrootsNostrMetadata, + }; use std::path::Path; use std::path::PathBuf; use tokio::sync::{Mutex, MutexGuard}; @@ -838,6 +897,78 @@ mod tests { } #[test] + fn service_presence_events_use_strict_profile_and_nip89_contracts() { + let identity = RadrootsIdentity::generate(); + let settings = settings_with_relays(vec!["wss://relay.example.com".to_owned()]); + let profile = settings.authored_profile().expect("authored profile"); + let handler_spec = RadrootsNostrApplicationHandlerSpec { + kinds: super::service_presence_kinds(), + identifier: Some("radrootsd".to_owned()), + metadata: Some(settings.metadata), + extra_tags: Vec::new(), + relays: settings.config.service.relays, + nostrconnect_url: None, + }; + + let (profile_event, handler_event) = + build_service_presence_events(&identity, &profile, &handler_spec) + .expect("service presence events"); + + assert_eq!(profile_event.kind, RadrootsNostrKind::Metadata); + assert!(profile_event.tags.is_empty()); + assert_eq!(profile_event.pubkey, identity.public_key()); + assert_eq!( + serde_json::from_str::<serde_json::Value>(&profile_event.content) + .expect("profile JSON"), + serde_json::json!({"name": "radrootsd-test"}) + ); + assert_eq!( + handler_event.kind, + RadrootsNostrKind::Custom(radroots_event::kinds::KIND_APPLICATION_HANDLER as u16) + ); + assert_eq!(handler_event.pubkey, identity.public_key()); + assert!( + handler_event.tags.iter().any(|tag| { + tag.as_slice() == ["d".to_owned(), "radrootsd".to_owned()].as_slice() + }) + ); + assert!(handler_event.tags.iter().any(|tag| { + tag.as_slice() + == [ + "k".to_owned(), + RadrootsNostrKind::NostrConnect.as_u16().to_string(), + ] + .as_slice() + })); + assert!(handler_event.tags.iter().any(|tag| { + tag.as_slice() == ["relay".to_owned(), "wss://relay.example.com".to_owned()].as_slice() + })); + assert!(handler_event.verify().is_ok()); + } + + #[tokio::test] + async fn bootstrap_presence_fails_closed_without_a_connected_relay() { + let _guard = test_guard().await; + let identity = RadrootsIdentity::generate(); + let client = radroots_nostr::prelude::RadrootsNostrClient::from_identity(&identity); + let settings = settings_with_relays(Vec::new()); + let profile = settings.authored_profile().expect("authored profile"); + let handler_spec = RadrootsNostrApplicationHandlerSpec { + kinds: super::service_presence_kinds(), + identifier: Some("radrootsd".to_owned()), + metadata: Some(settings.metadata), + extra_tags: Vec::new(), + relays: Vec::new(), + nostrconnect_url: None, + }; + + let error = super::bootstrap_presence(&client, &identity, &profile, &handler_spec) + .await + .expect_err("missing relay connection must fail closed"); + assert!(error.to_string().contains("at least one connected relay")); + } + + #[test] fn runtime_startup_report_prefers_explicit_cli_paths() { let args = cli::Args { service: radroots_runtime::RadrootsServiceCliArgs { diff --git a/src/transport/jsonrpc/methods/mod.rs b/src/transport/jsonrpc/methods/mod.rs @@ -137,27 +137,31 @@ mod tests { ) .await .expect("request"); - assert!(response.get().contains("\"scoped_bearer_token\"")); - assert!(response.get().contains("\"raw_event_json_ingress\":true")); - assert!(response.get().contains("\"transports\":[")); - assert!( - response - .get() - .contains("\"api_version\":\"radrootsd.transport_publish.v5\"") - ); - assert!(response.get().contains("\"transport\":\"reticulum\"")); - assert!(response.get().contains("\"configured\":true")); - assert!(response.get().contains("\"implementation\":\"real\"")); - assert!(response.get().contains("\"usable_for_delivery\":false")); - assert!( - response - .get() - .contains("\"capabilities\":{\"deliver\":false,\"fetch\":false}") - ); - assert!( - response - .get() - .contains(RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE) + let response: serde_json::Value = + serde_json::from_str(response.get()).expect("capabilities response JSON"); + let result = &response["result"]; + assert_eq!(result["api_version"], "radrootsd.transport_publish.v5"); + assert_eq!(result["auth"]["mode"], "scoped_bearer_token"); + assert_eq!(result["publish"]["raw_event_json_ingress"], true); + let reticulum = result["publish"]["transports"] + .as_array() + .expect("transport capabilities") + .iter() + .find(|transport| transport["transport"] == "reticulum") + .expect("Reticulum capability"); + assert_eq!(reticulum["configured"], true); + assert_eq!(reticulum["implementation"], "real"); + assert_eq!(reticulum["usable_for_delivery"], false); + assert_eq!( + reticulum["capabilities"], + serde_json::json!({ + "deliver": false, + "fetch": false, + "discovery": false, + "gateway_forwarding": false, + "receipt_observation": false, + }) ); + assert_eq!(reticulum["message"], RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE); } } diff --git a/src/transport/jsonrpc/methods/nip46/connect.rs b/src/transport/jsonrpc/methods/nip46/connect.rs @@ -117,7 +117,7 @@ async fn connect_bunker( &client_pubkey, RadrootsNostrTimestamp::now(), )?; - let notifications = client.notifications(); + let notifications = client.clone().into_inner().notifications(); let subscription = client .subscribe(filter, None) .await @@ -437,7 +437,7 @@ async fn wait_for_nostrconnect_response( .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 mut notifications = client.clone().into_inner().notifications(); let subscription = client .subscribe(filter, None) .await diff --git a/src/transport/jsonrpc/nip46/client.rs b/src/transport/jsonrpc/nip46/client.rs @@ -64,7 +64,7 @@ pub async fn request( let message = NostrConnectMessage::request(&request); let request_id = message.id().to_string(); let filter = response_filter(session, RadrootsNostrTimestamp::now(), label)?; - let notifications = session.client.notifications(); + let notifications = session.client.clone().into_inner().notifications(); let subscription = session .client .subscribe(filter, None) diff --git a/src/transport/nostr/listener.rs b/src/transport/nostr/listener.rs @@ -40,7 +40,7 @@ async fn run_nip46_listener(radrootsd: Radrootsd) -> Result<()> { .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 mut notifications = radrootsd.client.clone().into_inner().notifications(); let subscription = radrootsd.client.subscribe(filter, None).await?; info!("NIP-46 listener subscribed: {}", subscription.val); diff --git a/tests/source_boundary.rs b/tests/source_boundary.rs @@ -438,13 +438,20 @@ fn transport_publish_capabilities_expose_per_transport_readiness() { ); for required in [ "transport.publish.capabilities", - r#"\"api_version\":\"radrootsd.transport_publish.v5\""#, - r#"\"raw_event_json_ingress\":true"#, - r#"\"transport\":\"reticulum\""#, - r#"\"configured\":true"#, - r#"\"implementation\":\"real\""#, - r#"\"usable_for_delivery\":false"#, - r#"\"capabilities\":{\"deliver\":false,\"fetch\":false}"#, + "serde_json::from_str(response.get())", + r#"result["api_version"]"#, + "radrootsd.transport_publish.v5", + r#"result["publish"]["raw_event_json_ingress"]"#, + r#"transport["transport"] == "reticulum""#, + r#"reticulum["configured"]"#, + r#"reticulum["implementation"]"#, + r#"reticulum["usable_for_delivery"]"#, + r#"reticulum["capabilities"]"#, + r#""deliver": false"#, + r#""fetch": false"#, + r#""discovery": false"#, + r#""gateway_forwarding": false"#, + r#""receipt_observation": false"#, "RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE", ] { assert!( @@ -467,6 +474,9 @@ fn transport_publish_capabilities_expose_per_transport_readiness() { "pub struct TransportPublishOperationCapabilities", "pub deliver: bool,", "pub fetch: bool,", + "pub discovery: bool,", + "pub gateway_forwarding: bool,", + "pub receipt_observation: bool,", "RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE", ] { assert!(