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