commit 9e025eabfce1c8547ed43943601406dc87381347
parent b4e083f3beb8dd59f6506a129980133cc3a12233
Author: triesap <tyson@radroots.org>
Date: Sat, 18 Jul 2026 11:14:43 +0000
nostr: migrate RHI service presence
- publish strict kind-0 metadata through the authored Profile wire contract before NIP-89 discovery
- reject unverified media, unsupported fields, invalid NIP-05 values, and legacy service-role tags
- exact-pin Radroots 1.0.0-alpha.1 and migrate explicit SDK interoperability and logging defaults
- cover deterministic wire output, fail-closed metadata, dependency pins, and removed compatibility paths
Diffstat:
6 files changed, 235 insertions(+), 52 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -1087,6 +1087,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"
@@ -1174,6 +1180,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"
@@ -1585,14 +1597,24 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "radroots_authority"
-version = "0.1.0-alpha.2"
+version = "1.0.0-alpha.1"
dependencies = [
"radroots_event",
]
[[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",
@@ -1601,18 +1623,21 @@ 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",
"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",
@@ -1623,10 +1648,9 @@ dependencies = [
[[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",
@@ -1639,9 +1663,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",
@@ -1650,7 +1675,7 @@ dependencies = [
[[package]]
name = "radroots_nostr"
-version = "0.1.0-alpha.2"
+version = "1.0.0-alpha.1"
dependencies = [
"nostr",
"nostr-sdk",
@@ -1665,7 +1690,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",
@@ -1677,7 +1702,7 @@ dependencies = [
[[package]]
name = "radroots_runtime"
-version = "0.1.0-alpha.2"
+version = "1.0.0-alpha.1"
dependencies = [
"anyhow",
"chacha20poly1305",
@@ -1700,7 +1725,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",
@@ -1708,11 +1733,11 @@ dependencies = [
[[package]]
name = "radroots_secret_vault"
-version = "0.1.0-alpha.2"
+version = "1.0.0-alpha.1"
[[package]]
name = "radroots_trade"
-version = "0.1.0-alpha.2"
+version = "1.0.0-alpha.1"
dependencies = [
"base64 0.22.1",
"hex",
diff --git a/Cargo.toml b/Cargo.toml
@@ -11,15 +11,15 @@ description = "Radroots trade agreement attestation worker"
resolver = "2"
[workspace.dependencies]
-radroots_core = { path = "../lib/crates/core" }
-radroots_event = { path = "../lib/crates/event" }
-radroots_event_codec = { path = "../lib/crates/event_codec" }
-radroots_identity = { path = "../lib/crates/identity" }
-radroots_log = { path = "../lib/crates/log" }
-radroots_nostr = { path = "../lib/crates/nostr" }
-radroots_runtime = { path = "../lib/crates/runtime" }
-radroots_runtime_paths = { path = "../lib/crates/runtime_paths" }
-radroots_trade = { path = "../lib/crates/trade" }
+radroots_core = { version = "=1.0.0-alpha.1", path = "../lib/crates/core" }
+radroots_event = { version = "=1.0.0-alpha.1", path = "../lib/crates/event" }
+radroots_event_codec = { version = "=1.0.0-alpha.1", path = "../lib/crates/event_codec" }
+radroots_identity = { version = "=1.0.0-alpha.1", path = "../lib/crates/identity" }
+radroots_log = { version = "=1.0.0-alpha.1", path = "../lib/crates/log" }
+radroots_nostr = { version = "=1.0.0-alpha.1", path = "../lib/crates/nostr" }
+radroots_runtime = { version = "=1.0.0-alpha.1", path = "../lib/crates/runtime" }
+radroots_runtime_paths = { version = "=1.0.0-alpha.1", path = "../lib/crates/runtime_paths" }
+radroots_trade = { version = "=1.0.0-alpha.1", path = "../lib/crates/trade" }
[features]
default = []
@@ -30,7 +30,7 @@ unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] }
[dependencies]
radroots_core = { workspace = true, features = ["std", "serde"] }
radroots_event = { workspace = true, features = ["serde"] }
-radroots_event_codec = { workspace = true, features = ["nostr"] }
+radroots_event_codec = { workspace = true, features = ["nostr", "serde_json"] }
radroots_identity = { workspace = true }
radroots_log = { workspace = true }
radroots_nostr = { workspace = true, features = ["client", "codec", "events", "http"] }
diff --git a/src/features/trade_agreement_attestation.rs b/src/features/trade_agreement_attestation.rs
@@ -722,7 +722,8 @@ pub async fn subscriber(
}
let subscription_id = subscribe_io(&client, filter).await?;
- let mut notifications = client.notifications();
+ let sdk_client = client.clone().into_inner();
+ let mut notifications = sdk_client.notifications();
let mut notifications_closed = false;
loop {
diff --git a/src/lib.rs b/src/lib.rs
@@ -10,8 +10,12 @@ pub mod rhi;
pub use cli::Args as cli_args;
-use anyhow::Result;
-use radroots_event::kinds::TRADE_MUTATION_EVENT_KINDS;
+use anyhow::{Context, Result, anyhow, bail};
+use radroots_event::{
+ kinds::TRADE_MUTATION_EVENT_KINDS,
+ profile::{RadrootsAuthoredProfile, RadrootsNip05Identifier},
+};
+use radroots_event_codec::profile::authored::authored_profile_to_wire_parts;
use std::time::Duration;
use crate::features::trade_agreement_attestation::{
@@ -20,9 +24,9 @@ use crate::features::trade_agreement_attestation::{
};
use crate::identity_storage::load_service_identity;
use crate::rhi::{Rhi, start_subscriber_with_policy};
-use radroots_identity::RadrootsIdentity;
use radroots_nostr::prelude::{
- RadrootsNostrApplicationHandlerSpec, radroots_nostr_bootstrap_service_presence,
+ RadrootsNostrApplicationHandlerSpec, RadrootsNostrEventBuilder, RadrootsNostrMetadata,
+ radroots_nostr_build_event, radroots_nostr_publish_application_handler,
};
use tracing::{info, warn};
@@ -72,28 +76,82 @@ fn take_bootstrap_hook_result() -> Option<Result<(), String>> {
async fn bootstrap_presence(
client: &radroots_nostr::prelude::RadrootsNostrClient,
- identity: &RadrootsIdentity,
- metadata: &radroots_nostr::prelude::RadrootsNostrMetadata,
+ metadata: &RadrootsNostrMetadata,
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,
- None,
- metadata,
- handler_spec,
- Duration::from_secs(5),
- )
+ if let Some(result) = take_bootstrap_hook_result() {
+ return result.map_err(anyhow::Error::msg);
+ }
+
+ client.connect().await;
+ client.wait_for_connection(Duration::from_secs(5)).await;
+
+ let builder = build_authored_service_profile_event(metadata)?;
+ client
+ .send_event_builder(builder)
.await
- .map(|_| ())
- .map_err(anyhow::Error::from),
- };
- bootstrap_result?;
+ .context("publish strict RHI service Profile")?;
+
+ radroots_nostr_publish_application_handler(client, handler_spec)
+ .await
+ .context("publish RHI application-handler event")?;
Ok(())
}
+fn build_authored_service_profile_event(
+ metadata: &RadrootsNostrMetadata,
+) -> Result<RadrootsNostrEventBuilder> {
+ let profile = authored_service_profile(metadata)?;
+ let wire =
+ authored_profile_to_wire_parts(&profile).context("encode strict RHI service Profile")?;
+ radroots_nostr_build_event(wire.kind, wire.content, wire.tags)
+ .context("build strict RHI service Profile event")
+}
+
+fn authored_service_profile(metadata: &RadrootsNostrMetadata) -> Result<RadrootsAuthoredProfile> {
+ if metadata.picture.is_some() || metadata.banner.is_some() {
+ bail!(
+ "RHI service Profile media requires byte-verified Blossom descriptors and proven BUD-02 upload completion"
+ );
+ }
+ if metadata.website.is_some() || metadata.lud06.is_some() || metadata.lud16.is_some() {
+ bail!("RHI service Profile contains fields outside the strict authored contract");
+ }
+
+ let name = metadata
+ .name
+ .clone()
+ .ok_or_else(|| anyhow!("RHI service Profile requires metadata.name"))?;
+ let mut profile =
+ RadrootsAuthoredProfile::new(name).context("validate strict RHI service Profile name")?;
+ 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() {
+ profile = profile.with_nip05(
+ RadrootsNip05Identifier::parse(nip05)
+ .context("validate strict RHI service Profile NIP-05 identifier")?,
+ );
+ }
+
+ for key in metadata.custom.keys() {
+ if key != "bot" {
+ bail!("RHI service Profile contains unsupported metadata field `{key}`");
+ }
+ }
+ if let Some(bot) = metadata.custom.get("bot") {
+ profile = profile.with_bot(
+ bot.as_bool()
+ .ok_or_else(|| anyhow!("RHI service Profile `bot` field must be a Boolean"))?,
+ );
+ }
+
+ Ok(profile)
+}
+
#[cfg_attr(coverage_nightly, coverage(off))]
async fn wait_for_shutdown_or_stopped(handle: crate::rhi::RhiHandle) -> RunRhiWaitOutcome {
#[cfg(test)]
@@ -150,7 +208,7 @@ pub async fn run_rhi(settings: &config::Settings, args: &cli_args) -> Result<()>
relays: relays.clone(),
nostrconnect_url: None,
};
- if let Err(e) = bootstrap_presence(&client, &identity, &md, &handler_spec).await {
+ if let Err(e) = bootstrap_presence(&client, &md, &handler_spec).await {
warn!("Failed to publish service presence on startup: {e}");
} else {
info!("Published service presence on startup");
@@ -186,8 +244,9 @@ pub async fn run_rhi(settings: &config::Settings, args: &cli_args) -> Result<()>
RunRhiWaitOutcome::Stopped => {}
}
- client.unsubscribe_all().await;
- client.disconnect().await;
+ let sdk_client = client.into_inner();
+ sdk_client.unsubscribe_all().await;
+ sdk_client.disconnect().await;
Ok(())
}
@@ -200,8 +259,9 @@ pub fn release_product_handler_kinds() -> &'static [u32] {
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use super::{
- RUN_RHI_AUTO_STOP, RUN_RHI_SKIP_SUBSCRIBER, RunRhiWaitOutcome, bootstrap_presence,
- release_product_handler_kinds, run_rhi, run_rhi_bootstrap_hook, run_rhi_wait_hook,
+ RUN_RHI_AUTO_STOP, RUN_RHI_SKIP_SUBSCRIBER, RunRhiWaitOutcome, authored_service_profile,
+ bootstrap_presence, build_authored_service_profile_event, release_product_handler_kinds,
+ run_rhi, run_rhi_bootstrap_hook, run_rhi_wait_hook,
};
use crate::{cli_args, config};
use radroots_event::kinds::TRADE_MUTATION_EVENT_KINDS;
@@ -336,7 +396,6 @@ mod tests {
Some(Err("forced bootstrap failure".to_string()));
let keys = radroots_nostr::prelude::RadrootsNostrKeys::generate();
let client = radroots_nostr::prelude::RadrootsNostrClient::new(keys.clone());
- let identity = radroots_identity::RadrootsIdentity::new(keys);
let metadata: radroots_nostr::prelude::RadrootsNostrMetadata =
serde_json::from_str(r#"{"name":"rhi-test"}"#).expect("metadata");
let spec = radroots_nostr::prelude::RadrootsNostrApplicationHandlerSpec {
@@ -347,9 +406,81 @@ mod tests {
relays: Vec::new(),
nostrconnect_url: None,
};
- let err = bootstrap_presence(&client, &identity, &metadata, &spec)
+ let err = bootstrap_presence(&client, &metadata, &spec)
.await
.expect_err("forced error");
assert!(format!("{err:#}").contains("forced bootstrap failure"));
}
+
+ #[test]
+ fn service_profile_uses_only_strict_authored_fields() {
+ let metadata: radroots_nostr::prelude::RadrootsNostrMetadata = serde_json::from_str(
+ r#"{
+ "name":"rhi",
+ "display_name":"Radroots agreement attestation",
+ "about":"Attests trade agreement projections",
+ "nip05":"rhi@EXAMPLE.COM",
+ "bot":true
+ }"#,
+ )
+ .expect("metadata");
+
+ let profile = authored_service_profile(&metadata).expect("strict profile");
+
+ assert_eq!(profile.name(), "rhi");
+ assert_eq!(
+ profile.display_name(),
+ Some("Radroots agreement attestation")
+ );
+ assert_eq!(profile.about(), Some("Attests trade agreement projections"));
+ assert_eq!(
+ profile.nip05().map(|identifier| identifier.as_str()),
+ Some("rhi@example.com")
+ );
+ assert_eq!(profile.bot(), Some(true));
+ assert!(profile.picture().is_none());
+ assert!(profile.banner().is_none());
+
+ let keys = radroots_nostr::prelude::RadrootsNostrKeys::generate();
+ let event = build_authored_service_profile_event(&metadata)
+ .expect("strict Profile event")
+ .build(keys.public_key());
+ assert_eq!(event.kind.as_u16(), 0);
+ assert!(event.tags.is_empty());
+ assert_eq!(
+ event.content,
+ r#"{"name":"rhi","display_name":"Radroots agreement attestation","about":"Attests trade agreement projections","nip05":"rhi@example.com","bot":true}"#
+ );
+ }
+
+ #[test]
+ fn service_profile_rejects_unverified_media_and_unsupported_fields() {
+ for (metadata, expected) in [
+ (
+ r#"{"name":"rhi","picture":"https://cdn.example/rhi.png"}"#,
+ "byte-verified Blossom descriptors",
+ ),
+ (
+ r#"{"name":"rhi","website":"https://radroots.org"}"#,
+ "outside the strict authored contract",
+ ),
+ (
+ r#"{"name":"rhi","bot":"yes"}"#,
+ "`bot` field must be a Boolean",
+ ),
+ (
+ r#"{"name":"rhi","legacy_role":"worker"}"#,
+ "unsupported metadata field `legacy_role`",
+ ),
+ (r#"{}"#, "requires metadata.name"),
+ (
+ r#"{"name":"rhi","nip05":"RHI@example.com"}"#,
+ "validate strict RHI service Profile NIP-05 identifier",
+ ),
+ ] {
+ let metadata = serde_json::from_str(metadata).expect("metadata");
+ let error = authored_service_profile(&metadata).expect_err("must fail closed");
+ assert!(format!("{error:#}").contains(expected), "{error:#}");
+ }
+ }
}
diff --git a/src/main.rs b/src/main.rs
@@ -105,6 +105,7 @@ fn init_rhi_logging(settings: &config::Settings) -> Result<()> {
stdout: settings.config.logging.stdout,
default_level: Some(settings.config.logging.filter.clone()),
file_layout: LogFileLayout::PrefixedDate,
+ ..LoggingOptions::default()
})
.context("initialize logging")
}
diff --git a/tests/source_guards.rs b/tests/source_guards.rs
@@ -24,6 +24,25 @@ fn rhi_manifest_has_no_sdk_or_legacy_proof_dependency() {
}
#[test]
+fn rhi_manifest_exact_pins_radroots_contract() {
+ let manifest: toml::Value = toml::from_str(&read_repo_file("Cargo.toml")).expect("manifest");
+ let dependencies = manifest["workspace"]["dependencies"]
+ .as_table()
+ .expect("workspace dependencies");
+
+ for (name, dependency) in dependencies {
+ if !name.starts_with("radroots_") {
+ continue;
+ }
+ assert_eq!(
+ dependency["version"].as_str(),
+ Some("=1.0.0-alpha.1"),
+ "RHI must exact-pin {name} to the governed event contract release"
+ );
+ }
+}
+
+#[test]
fn rhi_release_product_surface_has_no_order_or_receipt_modules() {
for forbidden_path in [
"src/features/trade_listing/mod.rs",
@@ -99,7 +118,13 @@ fn rhi_agreement_attestation_is_release_product_optional_infrastructure() {
assert!(
lib.contains("trade_mutation_subscription_kinds()")
- && lib.contains("&TRADE_MUTATION_EVENT_KINDS"),
+ && lib.contains("&TRADE_MUTATION_EVENT_KINDS")
+ && lib.contains("RadrootsAuthoredProfile")
+ && lib.contains("authored_profile_to_wire_parts")
+ && lib.contains("radroots_nostr_publish_application_handler")
+ && !lib.contains("radroots_nostr_bootstrap_service_presence")
+ && !lib.contains("RadrootsProfileType")
+ && lib.contains("client.into_inner()"),
"RHI service presence must advertise canonical release-product trade mutation kinds"
);
assert!(