cli

Command-line interface for Radroots
git clone https://radroots.dev/git/cli.git
Log | Files | Refs | README | LICENSE

commit c83384f32fa144e1ba51160e7b7f63531248aba3
parent 30c47396a172af443694a6e174944ee4ee02d337
Author: triesap <tyson@radroots.org>
Date:   Tue,  7 Jul 2026 00:34:15 +0000

cli: add transport and mesh command surfaces

- add transport profile, outbox, mesh scope, and mesh policy target operations
- replace active relay and publish-transport config with transport profile config
- align SDK runtime, status output, validation gates, and sync provenance wording
- update CLI integration coverage for removed surfaces and transport profiles

Diffstat:
MCargo.lock | 58+++++++++++++++++++++++++++++++++++++++-------------------
MCargo.toml | 4+++-
Msrc/cli/global.rs | 2--
Msrc/cli/input.rs | 45++++++++++++++++++++++++++++++++++++++++-----
Asrc/cli/mesh.rs | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/cli/mod.rs | 87+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Asrc/cli/transport.rs | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/main.rs | 96++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Msrc/ops/context.rs | 2--
Msrc/ops/error.rs | 16+++-------------
Msrc/ops/exec/basket.rs | 6++++--
Msrc/ops/exec/core.rs | 73+++++++++++++++++++++++++++++--------------------------------------------
Msrc/ops/exec/farm.rs | 13++++++-------
Msrc/ops/exec/listing.rs | 12+++++++-----
Msrc/ops/exec/market.rs | 10++++++----
Msrc/ops/exec/runtime.rs | 171++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Msrc/ops/exec/trade.rs | 18+++++++++++++-----
Msrc/ops/mod.rs | 7++-----
Msrc/ops/target.rs | 10+++++++++-
Msrc/out/envelope.rs | 16+++++++---------
Msrc/out/terminal/actions.rs | 6+++---
Msrc/out/terminal/renderer.rs | 4++--
Msrc/out/terminal/renderers/runtime.rs | 147+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Msrc/out/terminal/renderers/validation.rs | 4++--
Msrc/out/terminal/values.rs | 6+++---
Asrc/registry/mesh.rs | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/registry/mod.rs | 52+++++++++++++++++++++++++++++++++++++++-------------
Dsrc/registry/relay.rs | 17-----------------
Asrc/registry/transport.rs | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/runtime/config.rs | 857++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Msrc/runtime/farm.rs | 90++++++++++++++++++++++++++++++++++++++++----------------------------------------
Msrc/runtime/listing.rs | 113+++++++++++++++++++++++++++++++++++++++----------------------------------------
Msrc/runtime/local_events.rs | 2--
Asrc/runtime/mesh.rs | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/runtime/mod.rs | 3++-
Dsrc/runtime/network.rs | 42------------------------------------------
Msrc/runtime/order.rs | 194+++++++++++++++++++++++++++++++++----------------------------------------------
Msrc/runtime/provider.rs | 32+++++++++++++++++---------------
Msrc/runtime/sdk.rs | 193++++++++++++++++++++++++++++++++++---------------------------------------------
Msrc/runtime/store.rs | 4++--
Msrc/runtime/sync.rs | 170++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Asrc/runtime/transport.rs | 299+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/runtime/validation_receipt.rs | 6+++---
Msrc/view/runtime.rs | 103++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Mtests/signer_runtime_modes.rs | 161+++++++++++++++++++++++++++----------------------------------------------------
Mtests/support/mod.rs | 121+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mtests/target_cli.rs | 625+++++++++++++++++++++++++++++++++----------------------------------------------
47 files changed, 2733 insertions(+), 1528 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -3630,7 +3630,6 @@ dependencies = [ "radroots_nostr_connect", "radroots_nostr_signer", "radroots_protected_store", - "radroots_relay_transport", "radroots_replica_db", "radroots_replica_db_schema", "radroots_replica_sync", @@ -3641,6 +3640,7 @@ dependencies = [ "radroots_sp1_host_trade", "radroots_sql_core", "radroots_trade", + "radroots_transport_nostr", "serde", "serde_json", "tar", @@ -3668,6 +3668,7 @@ version = "0.1.0-alpha.2" dependencies = [ "radroots_events", "radroots_nostr", + "radroots_transport", "serde", "serde_json", "sqlx", @@ -3813,6 +3814,7 @@ dependencies = [ "hex", "radroots_event_store", "radroots_events", + "radroots_transport", "serde", "serde_json", "sha2", @@ -3840,23 +3842,6 @@ dependencies = [ ] [[package]] -name = "radroots_relay_transport" -version = "0.1.0-alpha.2" -dependencies = [ - "futures", - "nostr", - "radroots_event_store", - "radroots_events", - "radroots_nostr", - "radroots_outbox", - "serde", - "serde_json", - "thiserror 1.0.69", - "tokio", - "url", -] - -[[package]] name = "radroots_replica_db" version = "0.1.0-alpha.2" dependencies = [ @@ -3945,9 +3930,11 @@ dependencies = [ "radroots_nostr_signer", "radroots_outbox", "radroots_publish_proxy_protocol", - "radroots_relay_transport", "radroots_runtime_paths", "radroots_trade", + "radroots_transport", + "radroots_transport_nostr", + "radroots_transport_reticulum", "reqwest", "serde", "serde_json", @@ -4020,6 +4007,39 @@ dependencies = [ ] [[package]] +name = "radroots_transport" +version = "0.1.0-alpha.2" +dependencies = [ + "serde", + "sha2", +] + +[[package]] +name = "radroots_transport_nostr" +version = "0.1.0-alpha.2" +dependencies = [ + "futures", + "nostr", + "radroots_event_store", + "radroots_events", + "radroots_nostr", + "radroots_outbox", + "radroots_transport", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "url", +] + +[[package]] +name = "radroots_transport_reticulum" +version = "0.1.0-alpha.2" +dependencies = [ + "radroots_transport", +] + +[[package]] name = "radroots_types" version = "0.1.0-alpha.2" dependencies = [ diff --git a/Cargo.toml b/Cargo.toml @@ -42,7 +42,9 @@ radroots_protected_store = { path = "../lib/crates/protected_store", features = radroots_replica_db = { path = "../lib/crates/replica_db" } radroots_replica_db_schema = { path = "../lib/crates/replica_db_schema" } radroots_replica_sync = { path = "../lib/crates/replica_sync" } -radroots_relay_transport = { path = "../lib/crates/relay_transport", default-features = false, features = ["runtime-tokio"] } +radroots_transport_nostr = { path = "../lib/crates/transport_nostr", default-features = false, features = [ + "runtime-tokio", +] } radroots_runtime = { path = "../lib/crates/runtime" } radroots_runtime_paths = { path = "../lib/crates/runtime_paths" } radroots_sdk = { path = "../sdk/crates/sdk", features = ["local-runtime-radrootsd-proxy", "relay-runtime"] } diff --git a/src/cli/global.rs b/src/cli/global.rs @@ -38,8 +38,6 @@ pub struct RuntimeInvocationArgs { pub account: Option<String>, pub identity_path: Option<PathBuf>, pub signer: Option<String>, - pub publish_transport: Option<String>, - pub relay: Vec<String>, pub myc_executable: Option<PathBuf>, pub myc_status_timeout_ms: Option<u64>, pub hyf_enabled: bool, diff --git a/src/cli/input.rs b/src/cli/input.rs @@ -26,8 +26,6 @@ pub fn runtime_invocation_args_from_target(args: &TargetCliArgs) -> RuntimeInvoc account: args.account_id.clone(), identity_path: None, signer: None, - publish_transport: args.publish_transport.map(|mode| mode.as_str().to_owned()), - relay: args.relay.clone(), myc_executable: None, myc_status_timeout_ms: None, hyf_enabled: false, @@ -45,9 +43,10 @@ pub fn target_operation_input(command: &TargetCommand) -> OperationData { AccountCommand, AccountSelectionCommand, BasketAdjustmentCommand, BasketCommand, BasketItemCommand, BasketQuoteCommand, FarmCommand, FarmFulfillmentCommand, FarmLocationCommand, FarmProfileCommand, ListingAppCommand, ListingCommand, MarketCommand, - MarketListingCommand, MarketProductCommand, StoreBackupCommand, StoreCommand, - TradeAppCommand, TradeCommand, TradeEventCommand, TradeRevisionCommand, TradeStatusCommand, - ValidationCommand, ValidationReceiptCommand, + MarketListingCommand, MarketProductCommand, MeshCommand, MeshScopeCommand, + StoreBackupCommand, StoreCommand, TradeAppCommand, TradeCommand, TradeEventCommand, + TradeRevisionCommand, TradeStatusCommand, TransportCommand, TransportOutboxCommand, + TransportProfileCommand, ValidationCommand, ValidationReceiptCommand, }; let mut input = OperationData::new(); @@ -324,6 +323,42 @@ pub fn target_operation_input(command: &TargetCommand) -> OperationData { } }, }, + TargetCommand::Transport(args) => match &args.command { + TransportCommand::Profile(profile) => match &profile.command { + TransportProfileCommand::Set(args) => { + input.insert( + "kind".to_owned(), + Value::String(args.kind.as_str().to_owned()), + ); + insert_string_array(&mut input, "nostr_relays", args.nostr_relay.as_slice()); + if let Some(behavior) = args.reticulum_preview_behavior { + input.insert( + "reticulum_preview_behavior".to_owned(), + Value::String(behavior.as_str().to_owned()), + ); + } + insert_string(&mut input, "proxy_url", &args.proxy_url); + } + TransportProfileCommand::Get => {} + }, + TransportCommand::Status => {} + TransportCommand::Outbox(outbox) => match outbox.command { + TransportOutboxCommand::Status | TransportOutboxCommand::Push => {} + }, + }, + TargetCommand::Mesh(args) => match &args.command { + MeshCommand::Scope(scope) => match &scope.command { + MeshScopeCommand::Set(args) => { + input.insert( + "scope".to_owned(), + Value::String(args.scope.as_str().to_owned()), + ); + } + MeshScopeCommand::Get => {} + }, + MeshCommand::Status => {} + MeshCommand::Policy(_) => {} + }, _ => {} } input diff --git a/src/cli/mesh.rs b/src/cli/mesh.rs @@ -0,0 +1,58 @@ +use clap::{Args, Subcommand, ValueEnum}; + +#[derive(Debug, Clone, Args)] +pub struct MeshArgs { + #[command(subcommand)] + pub command: MeshCommand, +} + +#[derive(Debug, Clone, Subcommand)] +pub enum MeshCommand { + Scope(MeshScopeArgs), + Status, + Policy(MeshPolicyArgs), +} + +#[derive(Debug, Clone, Args)] +pub struct MeshScopeArgs { + #[command(subcommand)] + pub command: MeshScopeCommand, +} + +#[derive(Debug, Clone, Subcommand)] +pub enum MeshScopeCommand { + Get, + Set(MeshScopeSetArgs), +} + +#[derive(Debug, Clone, Args)] +pub struct MeshScopeSetArgs { + #[arg(long = "scope", value_enum)] + pub scope: MeshScopeArg, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum MeshScopeArg { + Disabled, + LocalPreview, +} + +impl MeshScopeArg { + pub fn as_str(self) -> &'static str { + match self { + Self::Disabled => "disabled", + Self::LocalPreview => "local_preview", + } + } +} + +#[derive(Debug, Clone, Args)] +pub struct MeshPolicyArgs { + #[command(subcommand)] + pub command: MeshPolicyCommand, +} + +#[derive(Debug, Clone, Copy, Subcommand)] +pub enum MeshPolicyCommand { + Check, +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs @@ -8,11 +8,12 @@ pub mod health; pub mod input; pub mod listing; pub mod market; -pub mod relay; +pub mod mesh; pub mod signer; pub mod store; pub mod sync; pub mod trade; +pub mod transport; pub mod validation; pub mod workspace; @@ -23,11 +24,12 @@ pub use farm::*; pub use health::*; pub use listing::*; pub use market::*; -pub use relay::*; +pub use mesh::*; pub use signer::*; pub use store::*; pub use sync::*; pub use trade::*; +pub use transport::*; pub use validation::*; pub use workspace::*; @@ -40,28 +42,11 @@ pub enum TargetOutputFormat { Ndjson, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] -pub enum TargetPublishTransport { - #[value(name = "direct_nostr_relay")] - DirectNostrRelay, - #[value(name = "radrootsd_proxy")] - RadrootsdProxy, -} - -impl TargetPublishTransport { - pub fn as_str(self) -> &'static str { - match self { - Self::DirectNostrRelay => "direct_nostr_relay", - Self::RadrootsdProxy => "radrootsd_proxy", - } - } -} - #[derive(Debug, Parser, Clone)] #[command( name = "radroots", about = "Operate Radroots local-first trade workflows.", - long_about = "Operate Radroots local-first trade workflows.\n\nPublish transports:\n direct_nostr_relay publishes directly to configured relays with local signer custody.\n radrootsd_proxy publishes locally signed events through the local daemon proxy.", + long_about = "Operate Radroots local-first trade workflows.", disable_help_subcommand = true )] pub struct TargetCliArgs { @@ -69,15 +54,6 @@ pub struct TargetCliArgs { pub format: Option<TargetOutputFormat>, #[arg(long = "account-id", global = true)] pub account_id: Option<String>, - #[arg(long = "relay", global = true)] - pub relay: Vec<String>, - #[arg( - long = "publish-transport", - global = true, - value_enum, - help = "Select direct_nostr_relay direct relay publish or radrootsd_proxy daemon proxy publish" - )] - pub publish_transport: Option<TargetPublishTransport>, #[arg(long = "offline", global = true, action = ArgAction::SetTrue, conflicts_with = "online")] pub offline: bool, #[arg(long = "online", global = true, action = ArgAction::SetTrue, conflicts_with = "offline")] @@ -114,8 +90,10 @@ pub enum TargetCommand { Account(AccountArgs), #[command(about = "Inspect signer readiness for local relay writes.")] Signer(SignerArgs), - #[command(about = "List configured relay targets for direct relay mode.")] - Relay(RelayArgs), + #[command(about = "Manage transport profiles and outbox delivery.")] + Transport(TransportArgs), + #[command(about = "Inspect mesh scope and Reticulum preview policy.")] + Mesh(MeshArgs), #[command(about = "Initialize and inspect the local replica store.")] Store(StoreArgs), #[command(about = "Read from relay events into the local replica.")] @@ -170,8 +148,26 @@ impl TargetCommand { SignerStatusCommand::Get => "signer.status.get", }, }, - Self::Relay(args) => match args.command { - RelayCommand::List => "relay.list", + Self::Transport(args) => match &args.command { + TransportCommand::Profile(profile) => match &profile.command { + TransportProfileCommand::Get => "transport.profile.get", + TransportProfileCommand::Set(_) => "transport.profile.set", + }, + TransportCommand::Status => "transport.status", + TransportCommand::Outbox(outbox) => match outbox.command { + TransportOutboxCommand::Status => "transport.outbox.status", + TransportOutboxCommand::Push => "transport.outbox.push", + }, + }, + Self::Mesh(args) => match &args.command { + MeshCommand::Scope(scope) => match &scope.command { + MeshScopeCommand::Get => "mesh.scope.get", + MeshScopeCommand::Set(_) => "mesh.scope.set", + }, + MeshCommand::Status => "mesh.status", + MeshCommand::Policy(policy) => match policy.command { + MeshPolicyCommand::Check => "mesh.policy.check", + }, }, Self::Store(args) => match &args.command { StoreCommand::Init => "store.init", @@ -324,7 +320,8 @@ mod tests { "config", "account", "signer", - "relay", + "transport", + "mesh", "store", "sync", "farm", @@ -349,10 +346,6 @@ mod tests { "ndjson", "--account-id", "acct_test", - "--relay", - "wss://relay.one", - "--relay", - "wss://relay.two", "--offline", "--dry-run", "--idempotency-key", @@ -370,10 +363,6 @@ mod tests { assert_eq!(parsed.format, Some(TargetOutputFormat::Ndjson)); assert_eq!(parsed.account_id.as_deref(), Some("acct_test")); - assert_eq!( - parsed.relay, - vec!["wss://relay.one".to_owned(), "wss://relay.two".to_owned()] - ); assert!(parsed.offline); assert!(parsed.dry_run); assert_eq!(parsed.idempotency_key.as_deref(), Some("idem_test")); @@ -393,6 +382,20 @@ mod tests { } #[test] + fn target_parser_rejects_removed_relay_flag() { + let error = TargetCliArgs::try_parse_from([ + "radroots", + "--relay", + "wss://relay.example.com", + "workspace", + "get", + ]) + .expect_err("removed relay flag should be rejected"); + + assert_eq!(error.kind(), clap::error::ErrorKind::UnknownArgument); + } + + #[test] fn target_parser_accepts_account_attach_secret_inputs() { let parsed = TargetCliArgs::try_parse_from([ "radroots", diff --git a/src/cli/transport.rs b/src/cli/transport.rs @@ -0,0 +1,84 @@ +use clap::{Args, Subcommand, ValueEnum}; + +#[derive(Debug, Clone, Args)] +pub struct TransportArgs { + #[command(subcommand)] + pub command: TransportCommand, +} + +#[derive(Debug, Clone, Subcommand)] +pub enum TransportCommand { + Profile(TransportProfileArgs), + Status, + Outbox(TransportOutboxArgs), +} + +#[derive(Debug, Clone, Args)] +pub struct TransportProfileArgs { + #[command(subcommand)] + pub command: TransportProfileCommand, +} + +#[derive(Debug, Clone, Subcommand)] +pub enum TransportProfileCommand { + Get, + Set(TransportProfileSetArgs), +} + +#[derive(Debug, Clone, Args)] +pub struct TransportProfileSetArgs { + #[arg(long = "kind", value_enum)] + pub kind: TransportProfileKindArg, + #[arg(long = "nostr-relay")] + pub nostr_relay: Vec<String>, + #[arg(long = "reticulum-preview-behavior", value_enum)] + pub reticulum_preview_behavior: Option<ReticulumPreviewBehaviorArg>, + #[arg(long = "proxy-url")] + pub proxy_url: Option<String>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum TransportProfileKindArg { + LocalOnly, + Nostr, + ReticulumPreview, + Proxy, +} + +impl TransportProfileKindArg { + pub fn as_str(self) -> &'static str { + match self { + Self::LocalOnly => "local_only", + Self::Nostr => "nostr", + Self::ReticulumPreview => "reticulum_preview", + Self::Proxy => "proxy", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum ReticulumPreviewBehaviorArg { + RejectDeliveryAttempts, + DeferDeliveryPlans, +} + +impl ReticulumPreviewBehaviorArg { + pub fn as_str(self) -> &'static str { + match self { + Self::RejectDeliveryAttempts => "reject_delivery_attempts", + Self::DeferDeliveryPlans => "defer_delivery_plans", + } + } +} + +#[derive(Debug, Clone, Args)] +pub struct TransportOutboxArgs { + #[command(subcommand)] + pub command: TransportOutboxCommand, +} + +#[derive(Debug, Clone, Copy, Subcommand)] +pub enum TransportOutboxCommand { + Status, + Push, +} diff --git a/src/main.rs b/src/main.rs @@ -29,10 +29,12 @@ use radroots_cli::out::terminal::renderer::{ TerminalRenderContext, TerminalVerbosity, render_terminal_document, }; use radroots_cli::registry::{ - NetworkRequirement, OPERATION_REGISTRY, network_requirement, requires_local_signer_mode, + ApprovalPolicy, NetworkRequirement, OPERATION_REGISTRY, network_requirement, + requires_local_signer_mode, }; use radroots_cli::runtime::config::{ - OutputFormat as RuntimeOutputFormat, RuntimeConfig, SignerBackend, Verbosity, + OutputFormat as RuntimeOutputFormat, RuntimeConfig, SignerBackend, TransportProfileKind, + Verbosity, }; use radroots_cli::runtime::logging::initialize_logging; @@ -149,7 +151,31 @@ fn execute_request( TargetOperationRequest::SignerStatusGet(request) => { execute_with(RuntimeOperationService::new(config), request) } - TargetOperationRequest::RelayList(request) => { + TargetOperationRequest::TransportProfileGet(request) => { + execute_with(RuntimeOperationService::new(config), request) + } + TargetOperationRequest::TransportProfileSet(request) => { + execute_with(RuntimeOperationService::new(config), request) + } + TargetOperationRequest::TransportStatus(request) => { + execute_with(RuntimeOperationService::new(config), request) + } + TargetOperationRequest::TransportOutboxStatus(request) => { + execute_with(RuntimeOperationService::new(config), request) + } + TargetOperationRequest::TransportOutboxPush(request) => { + execute_with(RuntimeOperationService::new(config), request) + } + TargetOperationRequest::MeshScopeGet(request) => { + execute_with(RuntimeOperationService::new(config), request) + } + TargetOperationRequest::MeshScopeSet(request) => { + execute_with(RuntimeOperationService::new(config), request) + } + TargetOperationRequest::MeshStatus(request) => { + execute_with(RuntimeOperationService::new(config), request) + } + TargetOperationRequest::MeshPolicyCheck(request) => { execute_with(RuntimeOperationService::new(config), request) } TargetOperationRequest::SyncStatusGet(request) => { @@ -414,7 +440,7 @@ fn validate_network_contract( return Err(OperationAdapterError::OfflineForbidden { operation_id: spec.operation_id.to_owned(), message: format!( - "`{}` requires relay, provider, or workflow network access", + "`{}` requires transport, provider, or workflow network access", spec.cli_path ), }); @@ -426,13 +452,13 @@ fn validate_network_contract( dry_run_requires_network, } = requirement && (!request.context().dry_run || dry_run_requires_network) - && requires_pre_runtime_relay_target(spec.operation_id) - && config.relay.urls.is_empty() + && requires_pre_runtime_transport_target(spec.operation_id) + && !transport_profile_is_usable_for_delivery(config) { return Err(OperationAdapterError::NetworkUnavailable { operation_id: spec.operation_id.to_owned(), message: format!( - "`{}` requires at least one configured relay for online execution", + "`{}` requires a delivery-capable transport profile for online execution", spec.cli_path ), }); @@ -450,7 +476,7 @@ fn requires_local_signer_mode_for_publish_transport( requires_local_signer_mode(operation_id) } -fn requires_pre_runtime_relay_target(operation_id: &str) -> bool { +fn requires_pre_runtime_transport_target(operation_id: &str) -> bool { !is_publish_transport_routed_operation(operation_id) } @@ -462,18 +488,66 @@ fn validate_publish_transport_contract( request: &TargetOperationRequest, config: &RuntimeConfig, ) -> Result<(), OperationAdapterError> { - let _ = request; - let _ = config; + let spec = request.spec(); + if !is_publish_transport_routed_operation(spec.operation_id) { + return Ok(()); + } + if request.context().dry_run + || matches!( + request.context().network_mode, + OperationNetworkMode::Offline + ) + || (spec.approval_policy == ApprovalPolicy::Required + && request.context().requires_approval_token()) + { + return Ok(()); + } + if matches!( + config.transport.profile, + TransportProfileKind::LocalOnly | TransportProfileKind::ReticulumPreview + ) { + return Err(OperationAdapterError::NetworkUnavailable { + operation_id: spec.operation_id.to_owned(), + message: format!( + "`{}` requires a delivery-capable transport profile; active profile `{}` cannot deliver", + spec.cli_path, + config.transport.profile.as_str() + ), + }); + } + if matches!(config.transport.profile, TransportProfileKind::Nostr) + && config.transport.nostr_relay_urls.is_empty() + { + return Err(OperationAdapterError::NetworkUnavailable { + operation_id: spec.operation_id.to_owned(), + message: format!( + "`{}` requires at least one configured Nostr relay in the active transport profile", + spec.cli_path + ), + }); + } Ok(()) } fn is_publish_transport_routed_operation(operation_id: &str) -> bool { matches!( operation_id, - "farm.publish" | "listing.publish" | "listing.update" | "listing.archive" + "farm.publish" + | "listing.publish" + | "listing.update" + | "listing.archive" + | "transport.outbox.push" ) } +fn transport_profile_is_usable_for_delivery(config: &RuntimeConfig) -> bool { + match config.transport.profile { + TransportProfileKind::Nostr => !config.transport.nostr_relay_urls.is_empty(), + TransportProfileKind::Proxy => true, + TransportProfileKind::LocalOnly | TransportProfileKind::ReticulumPreview => false, + } +} + fn failure_envelope( request: &TargetOperationRequest, error: OperationAdapterError, diff --git a/src/ops/context.rs b/src/ops/context.rs @@ -64,7 +64,6 @@ impl Default for OperationInputMode { pub struct OperationContext { pub output_format: OperationOutputFormat, pub account_id: Option<String>, - pub relays: Vec<String>, pub network_mode: OperationNetworkMode, pub dry_run: bool, pub idempotency_key: Option<String>, @@ -84,7 +83,6 @@ impl OperationContext { .map(OperationOutputFormat::from) .unwrap_or_default(), account_id: args.account_id.clone(), - relays: args.relay.clone(), network_mode: if args.offline { OperationNetworkMode::Offline } else if args.online { diff --git a/src/ops/error.rs b/src/ops/error.rs @@ -587,8 +587,8 @@ fn sdk_recovery_next_actions( RadrootsSdkRecoveryAction::InspectLocalStores => { Some("radroots store status get".to_owned()) } - RadrootsSdkRecoveryAction::ConfigureRelayTargets => { - Some("radroots relay list".to_owned()) + RadrootsSdkRecoveryAction::ConfigureTransportTargets => { + Some("radroots transport profile get".to_owned()) } RadrootsSdkRecoveryAction::SelectAuthorizedActor => { Some("radroots account list".to_owned()) @@ -774,15 +774,7 @@ fn classify_runtime_failure( } if contains_any( &lowered, - &[ - "provider", - "write-plane", - "write plane", - "radrootsd", - "bridge", - "rpc", - "daemon", - ], + &["provider", "write-plane", "write plane", "rpc", "proxy"], ) { return match availability { RuntimeFailureAvailability::Unconfigured => { @@ -851,8 +843,6 @@ fn looks_like_provider_failure(value: &str) -> bool { "provider unconfigured", "provider runtime", "provider failed", - "radrootsd unavailable", - "daemon unavailable", "proxy provider", ], ) diff --git a/src/ops/exec/basket.rs b/src/ops/exec/basket.rs @@ -1906,10 +1906,11 @@ mod tests { signer: SignerConfig { backend: SignerBackend::Local, }, + transport: crate::runtime::config::TransportConfig::local_only(), publish: PublishConfig { - transport: PublishTransport::DirectNostrRelay, + transport: PublishTransport::Nostr, source: PublishTransportSource::Defaults, - radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(), + proxy: crate::runtime::config::ProxyTransportConfig::default(), }, relay: RelayConfig { urls: Vec::new(), @@ -1930,6 +1931,7 @@ mod tests { enabled: false, executable: PathBuf::from("hyfd"), }, + mesh: crate::runtime::config::MeshConfig::disabled(), rpc: RpcConfig { url: "http://127.0.0.1:7070".into(), }, diff --git a/src/ops/exec/core.rs b/src/ops/exec/core.rs @@ -197,7 +197,7 @@ impl OperationService<ConfigGetRequest> for CoreOperationService<'_> { let write_plane = crate::runtime::provider::resolve_write_plane_provider(self.config, &publish); let actions = config_actions(self.config, &account, &publish); - let mut result = json!({ + let result = json!({ "output": { "format": self.config.output.format.as_str(), "verbosity": self.config.output.verbosity.as_str(), @@ -225,12 +225,9 @@ impl OperationService<ConfigGetRequest> for CoreOperationService<'_> { "signer": { "mode": self.config.signer.backend.as_str(), }, + "transport": crate::runtime::transport::profile(self.config), + "mesh": crate::runtime::mesh::scope(self.config), "publish": publish, - "relay": { - "count": self.config.relay.urls.len(), - "urls": self.config.relay.urls, - "source": self.config.relay.source.as_str(), - }, "myc": { "executable": self.config.myc.executable.display().to_string(), "status_timeout_ms": self.config.myc.status_timeout_ms, @@ -257,16 +254,6 @@ impl OperationService<ConfigGetRequest> for CoreOperationService<'_> { }, "actions": actions, }); - if matches!( - self.config.publish.transport, - PublishTransport::RadrootsdProxy - ) { - result["radrootsd_proxy"] = json!({ - "url": self.config.publish.radrootsd_proxy.url, - "token_file_configured": self.config.publish.radrootsd_proxy.token_file.is_some(), - "token_secret_id_configured": self.config.publish.radrootsd_proxy.token_secret_id.is_some(), - }); - } json_operation_result::<ConfigGetResult>(result) } } @@ -816,13 +803,9 @@ fn publish_runtime_view( }; match config.publish.transport { - PublishTransport::DirectNostrRelay => { - let (state, executable, reason) = direct_nostr_relay_publish_readiness( - config, - relay_ready, - signed_write_required, - account, - ); + PublishTransport::Nostr => { + let (state, executable, reason) = + nostr_publish_readiness(config, relay_ready, signed_write_required, account); PublishRuntimeView { transport: config.publish.transport.as_str().to_owned(), source, @@ -833,15 +816,15 @@ fn publish_runtime_view( signed_write_required, relay, provider: PublishProviderRuntimeView { - provider_runtime_id: "direct_nostr_relay".to_owned(), + provider_runtime_id: "nostr".to_owned(), state: state.to_owned(), source: config.relay.source.as_str().to_owned(), reason, }, } } - PublishTransport::RadrootsdProxy => { - let (state, executable, reason) = radrootsd_publish_readiness(config); + PublishTransport::Proxy => { + let (state, executable, reason) = proxy_publish_readiness(config); PublishRuntimeView { transport: config.publish.transport.as_str().to_owned(), source, @@ -852,9 +835,9 @@ fn publish_runtime_view( signed_write_required, relay, provider: PublishProviderRuntimeView { - provider_runtime_id: "radrootsd_proxy".to_owned(), + provider_runtime_id: "proxy".to_owned(), state: state.to_owned(), - source: "publish transport · local first".to_owned(), + source: "publish transport · transport profile".to_owned(), reason, }, } @@ -862,7 +845,7 @@ fn publish_runtime_view( } } -fn direct_nostr_relay_publish_readiness( +fn nostr_publish_readiness( config: &RuntimeConfig, relay_ready: bool, signed_write_required: bool, @@ -873,7 +856,7 @@ fn direct_nostr_relay_publish_readiness( "unconfigured", false, Some( - "direct_nostr_relay publish transport requires at least one configured relay for writes" + "Nostr transport profile requires at least one configured Nostr relay for writes" .to_owned(), ), ); @@ -897,7 +880,7 @@ fn direct_nostr_relay_publish_readiness( "unconfigured", false, Some( - "direct_nostr_relay publish transport requires a selected or default write-capable local account for signed writes" + "Nostr transport profile requires a selected or default write-capable local account for signed writes" .to_owned(), ), ); @@ -916,14 +899,15 @@ fn direct_nostr_relay_publish_readiness( ("ready", true, None) } -fn radrootsd_publish_readiness(config: &RuntimeConfig) -> (&'static str, bool, Option<String>) { - if config.publish.radrootsd_proxy.token_file.is_none() - && config.publish.radrootsd_proxy.token_secret_id.is_none() - { +fn proxy_publish_readiness(config: &RuntimeConfig) -> (&'static str, bool, Option<String>) { + if config.publish.proxy.token_file.is_none() && config.publish.proxy.token_secret_id.is_none() { return ( "unconfigured", false, - Some("radrootsd_proxy publish transport requires a configured token file or token secret id".to_owned()), + Some( + "proxy transport profile requires a configured token file or token secret id" + .to_owned(), + ), ); } @@ -1089,11 +1073,11 @@ fn publish_recovery_actions( let mut actions = Vec::new(); match config.publish.transport { - PublishTransport::DirectNostrRelay => { + PublishTransport::Nostr => { if config.relay.urls.is_empty() { push_unique( &mut actions, - "radroots --relay wss://relay.example.com sync pull", + "radroots transport profile set --kind nostr --nostr-relay wss://relay.example.com", ); } if publish.signed_write_required { @@ -1108,7 +1092,7 @@ fn publish_recovery_actions( } } } - PublishTransport::RadrootsdProxy => { + PublishTransport::Proxy => { if self::proxy_token_configured(config) { if publish.signed_write_required && matches!(config.signer.backend, SignerBackend::Myc) @@ -1118,7 +1102,7 @@ fn publish_recovery_actions( } else { push_unique( &mut actions, - "configure RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE or RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_SECRET_ID", + "configure RADROOTS_CLI_TRANSPORT_PROXY_TOKEN_FILE or RADROOTS_CLI_TRANSPORT_PROXY_TOKEN_SECRET_ID", ); } } @@ -1127,8 +1111,7 @@ fn publish_recovery_actions( } fn proxy_token_configured(config: &RuntimeConfig) -> bool { - config.publish.radrootsd_proxy.token_file.is_some() - || config.publish.radrootsd_proxy.token_secret_id.is_some() + config.publish.proxy.token_file.is_some() || config.publish.proxy.token_secret_id.is_some() } fn push_unique(actions: &mut Vec<String>, action: impl Into<String>) { @@ -1464,10 +1447,11 @@ mod tests { signer: SignerConfig { backend: SignerBackend::Local, }, + transport: crate::runtime::config::TransportConfig::local_only(), publish: PublishConfig { - transport: PublishTransport::DirectNostrRelay, + transport: PublishTransport::Nostr, source: PublishTransportSource::Defaults, - radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(), + proxy: crate::runtime::config::ProxyTransportConfig::default(), }, relay: RelayConfig { urls: Vec::new(), @@ -1488,6 +1472,7 @@ mod tests { enabled: false, executable: PathBuf::from("hyfd"), }, + mesh: crate::runtime::config::MeshConfig::disabled(), rpc: RpcConfig { url: "http://127.0.0.1:7070".into(), }, diff --git a/src/ops/exec/farm.rs b/src/ops/exec/farm.rs @@ -232,10 +232,7 @@ impl OperationService<FarmPublishRequest> for FarmOperationService<'_> { request.operation_id(), )); } - if matches!( - self.config.publish.transport, - PublishTransport::DirectNostrRelay - ) { + if matches!(self.config.publish.transport, PublishTransport::Nostr) { require_relay_target(&request, self.config)?; } @@ -368,7 +365,7 @@ where Err(OperationAdapterError::NetworkUnavailable { operation_id: request.operation_id().to_owned(), message: format!( - "`{}` requires at least one configured relay for direct relay publication", + "`{}` requires at least one configured Nostr relay in the active transport profile", request.spec.cli_path ), }) @@ -879,10 +876,11 @@ mod tests { signer: SignerConfig { backend: SignerBackend::Local, }, + transport: crate::runtime::config::TransportConfig::local_only(), publish: PublishConfig { - transport: PublishTransport::DirectNostrRelay, + transport: PublishTransport::Nostr, source: PublishTransportSource::Defaults, - radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(), + proxy: crate::runtime::config::ProxyTransportConfig::default(), }, relay: RelayConfig { urls: Vec::new(), @@ -903,6 +901,7 @@ mod tests { enabled: false, executable: PathBuf::from("hyfd"), }, + mesh: crate::runtime::config::MeshConfig::disabled(), rpc: RpcConfig { url: "http://127.0.0.1:7070".into(), }, diff --git a/src/ops/exec/listing.rs b/src/ops/exec/listing.rs @@ -335,10 +335,10 @@ where fn listing_relay_unavailable(view: &ListingMutationView) -> bool { matches!( view.source.as_str(), - "direct Nostr relay publish · local key" | "SDK listing publish · configured signer" + "Nostr transport publish · local key" | "SDK listing publish · configured signer" ) && (view.reason.as_deref().is_some_and(|reason| { - reason.contains("configured relay") - || reason.contains("direct relay connection failed") + reason.contains("configured Nostr relay") + || reason.contains("Nostr transport connection failed") || reason.contains("SDK relay publish") }) || !view.target_relays.is_empty() || !view.connected_relays.is_empty() @@ -606,10 +606,11 @@ mod tests { signer: SignerConfig { backend: SignerBackend::Local, }, + transport: crate::runtime::config::TransportConfig::local_only(), publish: PublishConfig { - transport: PublishTransport::DirectNostrRelay, + transport: PublishTransport::Nostr, source: PublishTransportSource::Defaults, - radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(), + proxy: crate::runtime::config::ProxyTransportConfig::default(), }, relay: RelayConfig { urls: Vec::new(), @@ -630,6 +631,7 @@ mod tests { enabled: false, executable: PathBuf::from("hyfd"), }, + mesh: crate::runtime::config::MeshConfig::disabled(), rpc: RpcConfig { url: "http://127.0.0.1:7070".into(), }, diff --git a/src/ops/exec/market.rs b/src/ops/exec/market.rs @@ -82,7 +82,7 @@ fn market_refresh_view(mut view: SyncActionView) -> SyncActionView { actions.push("radroots store init".to_owned()); } if view.relay_count == 0 { - actions.push("radroots --relay wss://relay.example.com market refresh".to_owned()); + actions.push("radroots transport profile set --kind nostr --nostr-relay wss://relay.example.com".to_owned()); } if actions.is_empty() { actions.extend(std::mem::take(&mut view.actions)); @@ -361,7 +361,7 @@ mod tests { assert_eq!(envelope.result["state"], "unconfigured"); assert_eq!( envelope.result["actions"][0], - "radroots --relay wss://relay.example.com market refresh" + "radroots transport profile set --kind nostr --nostr-relay wss://relay.example.com" ); } @@ -670,10 +670,11 @@ mod tests { signer: SignerConfig { backend: SignerBackend::Local, }, + transport: crate::runtime::config::TransportConfig::local_only(), publish: PublishConfig { - transport: PublishTransport::DirectNostrRelay, + transport: PublishTransport::Nostr, source: PublishTransportSource::Defaults, - radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(), + proxy: crate::runtime::config::ProxyTransportConfig::default(), }, relay: RelayConfig { urls: Vec::new(), @@ -694,6 +695,7 @@ mod tests { enabled: false, executable: PathBuf::from("hyfd"), }, + mesh: crate::runtime::config::MeshConfig::disabled(), rpc: RpcConfig { url: "http://127.0.0.1:7070".into(), }, diff --git a/src/ops/exec/runtime.rs b/src/ops/exec/runtime.rs @@ -3,11 +3,16 @@ use serde_json::Value; use crate::cli::global::SyncWatchArgs; use crate::ops::{ + MeshPolicyCheckRequest, MeshPolicyCheckResult, MeshScopeGetRequest, MeshScopeGetResult, + MeshScopeSetRequest, MeshScopeSetResult, MeshStatusRequest, MeshStatusResult, OperationAdapterError, OperationRequest, OperationRequestData, OperationRequestPayload, - OperationResult, OperationResultData, OperationService, RelayListRequest, RelayListResult, - SignerStatusGetRequest, SignerStatusGetResult, SyncPullRequest, SyncPullResult, - SyncPushRequest, SyncPushResult, SyncStatusGetRequest, SyncStatusGetResult, SyncWatchRequest, - SyncWatchResult, + OperationResult, OperationResultData, OperationService, SignerStatusGetRequest, + SignerStatusGetResult, SyncPullRequest, SyncPullResult, SyncPushRequest, SyncPushResult, + SyncStatusGetRequest, SyncStatusGetResult, SyncWatchRequest, SyncWatchResult, + TransportOutboxPushRequest, TransportOutboxPushResult, TransportOutboxStatusRequest, + TransportOutboxStatusResult, TransportProfileGetRequest, TransportProfileGetResult, + TransportProfileSetRequest, TransportProfileSetResult, TransportStatusRequest, + TransportStatusResult, }; use crate::runtime::RuntimeError; use crate::runtime::config::RuntimeConfig; @@ -35,15 +40,126 @@ impl OperationService<SignerStatusGetRequest> for RuntimeOperationService<'_> { } } -impl OperationService<RelayListRequest> for RuntimeOperationService<'_> { - type Result = RelayListResult; +impl OperationService<TransportProfileGetRequest> for RuntimeOperationService<'_> { + type Result = TransportProfileGetResult; fn execute( &self, - _request: OperationRequest<RelayListRequest>, + _request: OperationRequest<TransportProfileGetRequest>, ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let view = crate::runtime::network::relay_list(self.config); - serialized_operation_result::<RelayListResult, _>(&view) + let view = crate::runtime::transport::profile(self.config); + serialized_operation_result::<TransportProfileGetResult, _>(&view) + } +} + +impl OperationService<TransportProfileSetRequest> for RuntimeOperationService<'_> { + type Result = TransportProfileSetResult; + + fn execute( + &self, + request: OperationRequest<TransportProfileSetRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let view = map_runtime( + "transport.profile.set", + crate::runtime::transport::set_profile(self.config, request.payload.input()), + )?; + serialized_operation_result::<TransportProfileSetResult, _>(&view) + } +} + +impl OperationService<TransportStatusRequest> for RuntimeOperationService<'_> { + type Result = TransportStatusResult; + + fn execute( + &self, + _request: OperationRequest<TransportStatusRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let view = crate::runtime::transport::status(self.config); + serialized_operation_result::<TransportStatusResult, _>(&view) + } +} + +impl OperationService<TransportOutboxStatusRequest> for RuntimeOperationService<'_> { + type Result = TransportOutboxStatusResult; + + fn execute( + &self, + _request: OperationRequest<TransportOutboxStatusRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let view = crate::runtime::transport::outbox_status(self.config).map_err(|error| { + OperationAdapterError::sdk_adapter_failure("transport.outbox.status", error) + })?; + serialized_operation_result::<TransportOutboxStatusResult, _>(&view) + } +} + +impl OperationService<TransportOutboxPushRequest> for RuntimeOperationService<'_> { + type Result = TransportOutboxPushResult; + + fn execute( + &self, + request: OperationRequest<TransportOutboxPushRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + if request.context.requires_approval_token() { + return Err(OperationAdapterError::approval_required( + "transport.outbox.push", + )); + } + let view = crate::runtime::transport::outbox_push(self.config).map_err(|error| { + OperationAdapterError::sdk_adapter_failure("transport.outbox.push", error) + })?; + serialized_operation_result::<TransportOutboxPushResult, _>(&view) + } +} + +impl OperationService<MeshScopeGetRequest> for RuntimeOperationService<'_> { + type Result = MeshScopeGetResult; + + fn execute( + &self, + _request: OperationRequest<MeshScopeGetRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let view = crate::runtime::mesh::scope(self.config); + serialized_operation_result::<MeshScopeGetResult, _>(&view) + } +} + +impl OperationService<MeshScopeSetRequest> for RuntimeOperationService<'_> { + type Result = MeshScopeSetResult; + + fn execute( + &self, + request: OperationRequest<MeshScopeSetRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let view = map_runtime( + "mesh.scope.set", + crate::runtime::mesh::set_scope(self.config, request.payload.input()), + )?; + serialized_operation_result::<MeshScopeSetResult, _>(&view) + } +} + +impl OperationService<MeshStatusRequest> for RuntimeOperationService<'_> { + type Result = MeshStatusResult; + + fn execute( + &self, + _request: OperationRequest<MeshStatusRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let view = crate::runtime::mesh::status(self.config); + serialized_operation_result::<MeshStatusResult, _>(&view) + } +} + +impl OperationService<MeshPolicyCheckRequest> for RuntimeOperationService<'_> { + type Result = MeshPolicyCheckResult; + + fn execute( + &self, + _request: OperationRequest<MeshPolicyCheckRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let view = crate::runtime::mesh::policy_check(self.config); + serialized_operation_result::<MeshPolicyCheckResult, _>(&view) } } @@ -220,8 +336,8 @@ mod tests { use super::RuntimeOperationService; use crate::ops::{ - OperationAdapter, OperationContext, OperationRequest, RelayListRequest, - SignerStatusGetRequest, SyncStatusGetRequest, + OperationAdapter, OperationContext, OperationRequest, SignerStatusGetRequest, + SyncStatusGetRequest, TransportProfileGetRequest, }; use crate::runtime::config::{ AccountConfig, AccountSecretContractConfig, HyfConfig, IdentityConfig, InteractionConfig, @@ -231,7 +347,7 @@ mod tests { }; #[test] - fn runtime_service_backs_signer_and_relay_status() { + fn runtime_service_backs_signer_and_transport_profile() { let dir = tempdir().expect("tempdir"); let config = sample_config(dir.path(), vec!["wss://relay.test".into()]); let service = OperationAdapter::new(RuntimeOperationService::new(&config)); @@ -249,16 +365,19 @@ mod tests { assert_eq!(signer_envelope.operation_id, "signer.status.get"); assert_eq!(signer_envelope.result["state"], "unconfigured"); - let relay = OperationRequest::new(OperationContext::default(), RelayListRequest::default()) - .expect("relay list request"); - let relay_envelope = service - .execute(relay) - .expect("relay list result") - .to_envelope(OperationContext::default().envelope_context("req_relay")) - .expect("relay envelope"); - assert_eq!(relay_envelope.operation_id, "relay.list"); - assert_eq!(relay_envelope.result["state"], "configured"); - assert_eq!(relay_envelope.result["count"], 1); + let profile = OperationRequest::new( + OperationContext::default(), + TransportProfileGetRequest::default(), + ) + .expect("transport profile request"); + let profile_envelope = service + .execute(profile) + .expect("transport profile result") + .to_envelope(OperationContext::default().envelope_context("req_transport")) + .expect("transport profile envelope"); + assert_eq!(profile_envelope.operation_id, "transport.profile.get"); + assert_eq!(profile_envelope.result["state"], "configured"); + assert_eq!(profile_envelope.result["profile_id"], "nostr"); } #[test] @@ -353,10 +472,13 @@ mod tests { signer: SignerConfig { backend: SignerBackend::Local, }, + transport: crate::runtime::config::TransportConfig::from_nostr_relay_urls( + relays.clone(), + ), publish: PublishConfig { - transport: PublishTransport::DirectNostrRelay, + transport: PublishTransport::Nostr, source: PublishTransportSource::Defaults, - radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(), + proxy: crate::runtime::config::ProxyTransportConfig::default(), }, relay: RelayConfig { urls: relays, @@ -377,6 +499,7 @@ mod tests { enabled: false, executable: PathBuf::from("hyfd"), }, + mesh: crate::runtime::config::MeshConfig::disabled(), rpc: RpcConfig { url: "http://127.0.0.1:7070".into(), }, diff --git a/src/ops/exec/trade.rs b/src/ops/exec/trade.rs @@ -1662,14 +1662,20 @@ mod tests { assert_eq!(envelope.errors[0].code, "operation_unavailable"); assert_eq!(envelope.errors[0].exit_code, 3); - assert!(envelope.errors[0].message.contains("configured relay")); + assert!( + envelope.errors[0] + .message + .contains("configured Nostr transport profile") + ); assert_eq!( envelope.errors[0].detail.as_ref().unwrap()["actions"][0], - "radroots --relay wss://relay.example.com trade event list" + "radroots transport profile set --kind nostr --nostr-relay wss://relay.example.com" ); assert_eq!( envelope.next_actions[0].command.as_deref(), - Some("radroots --relay wss://relay.example.com trade event list") + Some( + "radroots transport profile set --kind nostr --nostr-relay wss://relay.example.com" + ) ); } @@ -1806,10 +1812,11 @@ mod tests { signer: SignerConfig { backend: SignerBackend::Local, }, + transport: crate::runtime::config::TransportConfig::local_only(), publish: PublishConfig { - transport: PublishTransport::DirectNostrRelay, + transport: PublishTransport::Nostr, source: PublishTransportSource::Defaults, - radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(), + proxy: crate::runtime::config::ProxyTransportConfig::default(), }, relay: RelayConfig { urls: Vec::new(), @@ -1830,6 +1837,7 @@ mod tests { enabled: false, executable: PathBuf::from("hyfd"), }, + mesh: crate::runtime::config::MeshConfig::disabled(), rpc: RpcConfig { url: "http://127.0.0.1:7070".into(), }, diff --git a/src/ops/mod.rs b/src/ops/mod.rs @@ -61,8 +61,6 @@ mod tests { "json", "--account-id", "acct_test", - "--relay", - "wss://relay.one", "--online", "--dry-run", "--idempotency-key", @@ -86,7 +84,6 @@ mod tests { assert_eq!(context.output_format, OperationOutputFormat::Json); assert_eq!(context.account_id.as_deref(), Some("acct_test")); - assert_eq!(context.relays, vec!["wss://relay.one".to_owned()]); assert_eq!(context.network_mode, OperationNetworkMode::Online); assert!(context.dry_run); assert_eq!(context.idempotency_key.as_deref(), Some("idem_test")); @@ -601,7 +598,7 @@ mod tests { ( OperationAdapterError::unavailable( "listing.publish", - "radrootsd bridge is unavailable".to_owned(), + "proxy provider is unavailable".to_owned(), ), "provider_unavailable", "provider", @@ -657,7 +654,7 @@ mod tests { ( OperationAdapterError::runtime_failure( "farm.publish", - RuntimeError::Network("direct relay connection failed".to_owned()), + RuntimeError::Network("Nostr transport connection failed".to_owned()), ), "network_unavailable", "network", diff --git a/src/ops/target.rs b/src/ops/target.rs @@ -189,7 +189,15 @@ target_operation_contracts! { AccountSelectionUpdate => (AccountSelectionUpdateRequest, AccountSelectionUpdateResult, "account.selection.update"), AccountSelectionClear => (AccountSelectionClearRequest, AccountSelectionClearResult, "account.selection.clear"), SignerStatusGet => (SignerStatusGetRequest, SignerStatusGetResult, "signer.status.get"), - RelayList => (RelayListRequest, RelayListResult, "relay.list"), + TransportProfileGet => (TransportProfileGetRequest, TransportProfileGetResult, "transport.profile.get"), + TransportProfileSet => (TransportProfileSetRequest, TransportProfileSetResult, "transport.profile.set"), + TransportStatus => (TransportStatusRequest, TransportStatusResult, "transport.status"), + TransportOutboxStatus => (TransportOutboxStatusRequest, TransportOutboxStatusResult, "transport.outbox.status"), + TransportOutboxPush => (TransportOutboxPushRequest, TransportOutboxPushResult, "transport.outbox.push"), + MeshScopeGet => (MeshScopeGetRequest, MeshScopeGetResult, "mesh.scope.get"), + MeshScopeSet => (MeshScopeSetRequest, MeshScopeSetResult, "mesh.scope.set"), + MeshStatus => (MeshStatusRequest, MeshStatusResult, "mesh.status"), + MeshPolicyCheck => (MeshPolicyCheckRequest, MeshPolicyCheckResult, "mesh.policy.check"), StoreInit => (StoreInitRequest, StoreInitResult, "store.init"), StoreStatusGet => (StoreStatusGetRequest, StoreStatusGetResult, "store.status.get"), StoreExport => (StoreExportRequest, StoreExportResult, "store.export"), diff --git a/src/out/envelope.rs b/src/out/envelope.rs @@ -277,14 +277,14 @@ fn next_actions_from_actions_value(actions_value: Option<&Value>) -> Vec<NextAct fn next_action_from_action_string(action: &str) -> Option<NextAction> { let action = action.trim(); if action - == "configure RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE or RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_SECRET_ID" + == "configure RADROOTS_CLI_TRANSPORT_PROXY_TOKEN_FILE or RADROOTS_CLI_TRANSPORT_PROXY_TOKEN_SECRET_ID" { return Some(NextAction { kind: NextActionKind::OperatorConfig, - label: "configure radrootsd proxy token source".to_owned(), + label: "configure proxy token source".to_owned(), command: None, description: Some(action.to_owned()), - env_var: Some("RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE".to_owned()), + env_var: Some("RADROOTS_CLI_TRANSPORT_PROXY_TOKEN_FILE".to_owned()), config_key: None, }); } @@ -324,8 +324,6 @@ fn next_action_label(command: &str) -> String { part, "--format" | "--account-id" - | "--relay" - | "--publish-transport" | "--idempotency-key" | "--correlation-id" | "--approval-token" @@ -608,9 +606,9 @@ mod tests { ); error.detail = Some(json!({ "actions": [ - "configure RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE or RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_SECRET_ID", + "configure RADROOTS_CLI_TRANSPORT_PROXY_TOKEN_FILE or RADROOTS_CLI_TRANSPORT_PROXY_TOKEN_SECRET_ID", "configure signer.remote_nip46 signer_session_ref", - "configure RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE or RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_SECRET_ID" + "configure RADROOTS_CLI_TRANSPORT_PROXY_TOKEN_FILE or RADROOTS_CLI_TRANSPORT_PROXY_TOKEN_SECRET_ID" ] })); let envelope = OutputEnvelope::failure( @@ -627,12 +625,12 @@ mod tests { ); assert_eq!( envelope.next_actions[0].label, - "configure radrootsd proxy token source" + "configure proxy token source" ); assert_eq!(envelope.next_actions[0].command, None); assert_eq!( envelope.next_actions[0].env_var.as_deref(), - Some("RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE") + Some("RADROOTS_CLI_TRANSPORT_PROXY_TOKEN_FILE") ); assert_eq!( envelope.next_actions[1].kind, diff --git a/src/out/terminal/actions.rs b/src/out/terminal/actions.rs @@ -316,8 +316,8 @@ mod tests { kind: NextActionKind::OperatorConfig, label: "configure token".to_owned(), command: None, - description: Some("configure RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE".to_owned()), - env_var: Some("RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE".to_owned()), + description: Some("configure RADROOTS_CLI_TRANSPORT_PROXY_TOKEN_FILE".to_owned()), + env_var: Some("RADROOTS_CLI_TRANSPORT_PROXY_TOKEN_FILE".to_owned()), config_key: None, }; @@ -327,7 +327,7 @@ mod tests { assert_eq!(terminal[0].kind, TerminalActionKind::Setup); assert_eq!( terminal[0].description.as_deref(), - Some("configure RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE") + Some("configure RADROOTS_CLI_TRANSPORT_PROXY_TOKEN_FILE") ); } } diff --git a/src/out/terminal/renderer.rs b/src/out/terminal/renderer.rs @@ -210,7 +210,7 @@ mod tests { "Listing published", )) .with_field(TerminalField::new("Listing", "AAAAAAAAAAAAAAAAAAAAAg")) - .with_field(TerminalField::new("Transport", "direct nostr relay")) + .with_field(TerminalField::new("Transport", "nostr")) .with_field(TerminalField::new("Relays", "2 acknowledged · 0 failed")) .with_field(TerminalField::new("Event", "9f3a…c12")) .with_next(TerminalAction::command( @@ -219,7 +219,7 @@ mod tests { assert_eq!( render_terminal_document(&document, &TerminalRenderContext::default()), - "✓ Listing published\n\n Listing AAAAAAAAAAAAAAAAAAAAAg\n Transport direct nostr relay\n Relays 2 acknowledged · 0 failed\n Event 9f3a…c12\n\nNext\n radroots listing get AAAAAAAAAAAAAAAAAAAAAg" + "✓ Listing published\n\n Listing AAAAAAAAAAAAAAAAAAAAAg\n Transport nostr\n Relays 2 acknowledged · 0 failed\n Event 9f3a…c12\n\nNext\n radroots listing get AAAAAAAAAAAAAAAAAAAAAg" ); } diff --git a/src/out/terminal/renderers/runtime.rs b/src/out/terminal/renderers/runtime.rs @@ -11,7 +11,15 @@ use super::common; pub fn register(registry: TerminalRendererRegistry) -> TerminalRendererRegistry { registry .register("signer.status.get", &RUNTIME_RENDERER) - .register("relay.list", &RUNTIME_RENDERER) + .register("transport.profile.get", &RUNTIME_RENDERER) + .register("transport.profile.set", &RUNTIME_RENDERER) + .register("transport.status", &RUNTIME_RENDERER) + .register("transport.outbox.status", &RUNTIME_RENDERER) + .register("transport.outbox.push", &RUNTIME_RENDERER) + .register("mesh.scope.get", &RUNTIME_RENDERER) + .register("mesh.scope.set", &RUNTIME_RENDERER) + .register("mesh.status", &RUNTIME_RENDERER) + .register("mesh.policy.check", &RUNTIME_RENDERER) } struct RuntimeRenderer; @@ -25,7 +33,14 @@ impl TerminalOperationRenderer for RuntimeRenderer { } let result = common::result(envelope); match envelope.operation_id.as_str() { - "relay.list" => relay_document(envelope, result), + "transport.profile.get" | "transport.profile.set" => { + transport_profile_document(envelope, result) + } + "transport.status" => transport_status_document(envelope, result), + "transport.outbox.status" => transport_outbox_status_document(envelope, result), + "transport.outbox.push" => transport_outbox_push_document(envelope, result), + "mesh.scope.get" | "mesh.scope.set" => mesh_scope_document(envelope, result), + "mesh.status" | "mesh.policy.check" => mesh_status_document(envelope, result), _ => signer_document(envelope, result), } } @@ -77,36 +92,134 @@ fn signer_document(envelope: &OutputEnvelope, result: &Value) -> TerminalDocumen document } -fn relay_document(envelope: &OutputEnvelope, result: &Value) -> TerminalDocument { - let title = common::title_for(envelope, "Relays"); +fn transport_profile_document(envelope: &OutputEnvelope, result: &Value) -> TerminalDocument { + let title = common::title_for(envelope, "Transport Profile"); let mut document = common::document_with_title(envelope, title); common::push_path_field(&mut document, "State", result, &["state"]); - common::push_count_field(&mut document, "Count", result, &["count"]); common::push_path_field(&mut document, "Source", result, &["source"]); - let rows = common::array(result, &["relays"]) + common::push_path_field(&mut document, "Profile", result, &["profile_id"]); + common::push_path_field(&mut document, "Kind", result, &["transport_kind"]); + common::push_path_field(&mut document, "Configured", result, &["configured_state"]); + common::push_path_field( + &mut document, + "Implementation", + result, + &["implementation_state"], + ); + common::push_bool_field(&mut document, "Usable", result, &["usable_for_delivery"]); + common::push_path_field(&mut document, "Message", result, &["message"]); + if let Some(relays) = common::array(result, &["nostr_relays"]) { + let rows = relays + .iter() + .map(|relay| TerminalTableRow::new(vec![relay.as_str().unwrap_or_default().to_owned()])) + .collect::<Vec<_>>(); + document.sections.push(common::table_section( + "Nostr relays", + vec![TerminalTableColumn::new("URL", 12, 48)], + rows, + "No Nostr relays configured", + )); + } + document +} + +fn transport_status_document(envelope: &OutputEnvelope, result: &Value) -> TerminalDocument { + let title = common::title_for(envelope, "Transport Status"); + let mut document = common::document_with_title(envelope, title); + common::push_path_field(&mut document, "State", result, &["state"]); + common::push_path_field(&mut document, "Source", result, &["source"]); + let rows = common::array(result, &["transports"]) .into_iter() .flatten() - .map(|relay| { + .map(|transport| { TerminalTableRow::new(vec![ - common::string(relay, &["url"]).unwrap_or_default(), - common::bool_path(relay, &["read"]) - .map(|value| if value { "yes" } else { "no" }.to_owned()) - .unwrap_or_default(), - common::bool_path(relay, &["write"]) + common::string(transport, &["profile_id"]).unwrap_or_default(), + common::string(transport, &["transport_kind"]).unwrap_or_default(), + common::string(transport, &["configured_state"]).unwrap_or_default(), + common::string(transport, &["implementation_state"]).unwrap_or_default(), + common::bool_path(transport, &["usable_for_delivery"]) .map(|value| if value { "yes" } else { "no" }.to_owned()) .unwrap_or_default(), ]) }) .collect::<Vec<_>>(); document.sections.push(common::table_section( - "Relays", + "Transports", vec![ - TerminalTableColumn::new("URL", 12, 42), - TerminalTableColumn::new("Read", 4, 4), - TerminalTableColumn::new("Write", 5, 5), + TerminalTableColumn::new("Profile", 7, 18), + TerminalTableColumn::new("Kind", 4, 12), + TerminalTableColumn::new("Configured", 10, 18), + TerminalTableColumn::new("Implementation", 14, 24), + TerminalTableColumn::new("Usable", 6, 6), ], rows, - "No relays configured", + "No transports reported", )); document } + +fn transport_outbox_status_document(envelope: &OutputEnvelope, result: &Value) -> TerminalDocument { + let title = common::title_for(envelope, "Transport Outbox"); + let mut document = common::document_with_title(envelope, title); + common::push_path_field(&mut document, "State", result, &["state"]); + common::push_path_field(&mut document, "Profile", result, &["transport_profile"]); + common::push_count_field(&mut document, "Total", result, &["total_count"]); + common::push_count_field(&mut document, "Pending", result, &["pending_count"]); + common::push_count_field(&mut document, "Retryable", result, &["retryable_count"]); + common::push_count_field(&mut document, "Terminal", result, &["terminal_count"]); + common::push_count_field( + &mut document, + "Ready signed", + result, + &["ready_signed_count"], + ); + common::push_path_field(&mut document, "Last error", result, &["last_error"]); + document +} + +fn transport_outbox_push_document(envelope: &OutputEnvelope, result: &Value) -> TerminalDocument { + let title = common::title_for(envelope, "Transport Outbox Push"); + let mut document = common::document_with_title(envelope, title); + common::push_path_field(&mut document, "State", result, &["state"]); + common::push_count_field(&mut document, "Attempted", result, &["attempted_events"]); + common::push_count_field(&mut document, "Published", result, &["published_events"]); + common::push_count_field(&mut document, "Retryable", result, &["retryable_events"]); + common::push_count_field(&mut document, "Terminal", result, &["terminal_events"]); + common::push_count_field(&mut document, "Targets", result, &["target_count"]); + common::push_path_field(&mut document, "Reason", result, &["reason"]); + document +} + +fn mesh_scope_document(envelope: &OutputEnvelope, result: &Value) -> TerminalDocument { + let title = common::title_for(envelope, "Mesh Scope"); + let mut document = common::document_with_title(envelope, title); + common::push_path_field(&mut document, "State", result, &["state"]); + common::push_path_field(&mut document, "Scope", result, &["scope"]); + common::push_path_field( + &mut document, + "Implementation", + result, + &["implementation_state"], + ); + common::push_path_field(&mut document, "Message", result, &["message"]); + document +} + +fn mesh_status_document(envelope: &OutputEnvelope, result: &Value) -> TerminalDocument { + let title = common::title_for(envelope, "Mesh"); + let mut document = common::document_with_title(envelope, title); + common::push_path_field(&mut document, "State", result, &["state"]); + common::push_path_field(&mut document, "Scope", result, &["scope"]); + common::push_path_field(&mut document, "Kind", result, &["transport_kind"]); + common::push_path_field(&mut document, "Configured", result, &["configured_state"]); + common::push_path_field( + &mut document, + "Implementation", + result, + &["implementation_state"], + ); + common::push_bool_field(&mut document, "Usable", result, &["usable_for_delivery"]); + common::push_path_field(&mut document, "Decision", result, &["decision"]); + common::push_path_field(&mut document, "Message", result, &["message"]); + document +} diff --git a/src/out/terminal/renderers/validation.rs b/src/out/terminal/renderers/validation.rs @@ -253,14 +253,14 @@ mod tests { "invalid_count": 0, "receipts": [], "invalid_receipts": [], - "actions": ["radroots --relay wss://relay.example.com validation receipt list --trade-id trade_test"] + "actions": ["radroots transport profile set --kind nostr --nostr-relay wss://relay.example.com"] }), ); let document = VALIDATION_RENDERER.render(&envelope, &TerminalRenderContext::default()); let rendered = render_terminal_document(&document, &TerminalRenderContext::default()); assert!(rendered.contains("Validation receipts")); - assert!(rendered.contains("--trade-id trade_test")); + assert!(rendered.contains("trade_test")); assert!(!rendered.contains("--order-id")); } diff --git a/src/out/terminal/values.rs b/src/out/terminal/values.rs @@ -4,8 +4,8 @@ use serde_json::Value; pub fn transport_label(value: &str) -> String { match value { - "direct_nostr_relay" => "direct nostr relay", - "radrootsd_proxy" => "radrootsd proxy", + "nostr" => "nostr", + "proxy" => "proxy", "local" => "local", "preview" => "preview", other => other, @@ -105,7 +105,7 @@ mod tests { #[test] fn formats_domain_values() { - assert_eq!(transport_label("direct_nostr_relay"), "direct nostr relay"); + assert_eq!(transport_label("nostr"), "nostr"); assert_eq!( relay_summary(2, 0, "acknowledged"), "2 acknowledged · 0 failed" diff --git a/src/registry/mesh.rs b/src/registry/mesh.rs @@ -0,0 +1,65 @@ +use super::{ApprovalPolicy, OperationRole, OperationSpec, RiskLevel}; + +pub const MESH_SCOPE_GET: OperationSpec = operation!( + "mesh.scope.get", + "radroots mesh scope get", + "mesh", + "mesh_scope_get", + "MeshScopeGetRequest", + "MeshScopeGetResult", + "Read the configured mesh scope.", + Any, + false, + None, + Low, + false, + false +); + +pub const MESH_SCOPE_SET: OperationSpec = operation!( + "mesh.scope.set", + "radroots mesh scope set --scope disabled", + "mesh", + "mesh_scope_set", + "MeshScopeSetRequest", + "MeshScopeSetResult", + "Write the configured mesh scope.", + Any, + true, + Required, + High, + false, + true +); + +pub const MESH_STATUS: OperationSpec = operation!( + "mesh.status", + "radroots mesh status", + "mesh", + "mesh_status", + "MeshStatusRequest", + "MeshStatusResult", + "Read mesh implementation status.", + Any, + false, + None, + Low, + false, + false +); + +pub const MESH_POLICY_CHECK: OperationSpec = operation!( + "mesh.policy.check", + "radroots mesh policy check", + "mesh", + "mesh_policy_check", + "MeshPolicyCheckRequest", + "MeshPolicyCheckResult", + "Evaluate mesh delivery policy for the active preview state.", + Any, + false, + None, + Low, + false, + false +); diff --git a/src/registry/mod.rs b/src/registry/mod.rs @@ -88,11 +88,12 @@ mod farm; mod health; mod listing; mod market; -mod relay; +mod mesh; mod signer; mod store; mod sync; mod trade; +mod transport; mod validation; mod workspace; @@ -112,7 +113,15 @@ pub const OPERATION_REGISTRY: &[OperationSpec] = &[ account::ACCOUNT_SELECTION_UPDATE, account::ACCOUNT_SELECTION_CLEAR, signer::SIGNER_STATUS_GET, - relay::RELAY_LIST, + transport::TRANSPORT_PROFILE_GET, + transport::TRANSPORT_PROFILE_SET, + transport::TRANSPORT_STATUS, + transport::TRANSPORT_OUTBOX_STATUS, + transport::TRANSPORT_OUTBOX_PUSH, + mesh::MESH_SCOPE_GET, + mesh::MESH_SCOPE_SET, + mesh::MESH_STATUS, + mesh::MESH_POLICY_CHECK, store::STORE_INIT, store::STORE_STATUS_GET, store::STORE_EXPORT, @@ -185,6 +194,7 @@ pub fn network_requirement(operation_id: &str) -> NetworkRequirement { match operation_id { "sync.pull" | "sync.push" + | "transport.outbox.push" | "sync.watch" | "market.refresh" | "farm.publish" @@ -214,6 +224,7 @@ pub fn requires_local_signer_mode(operation_id: &str) -> bool { matches!( operation_id, "sync.push" + | "transport.outbox.push" | "trade.submit" | "trade.accept" | "trade.decline" @@ -224,11 +235,11 @@ pub fn requires_local_signer_mode(operation_id: &str) -> bool { ) } -#[cfg(test)] -pub fn requires_direct_nostr_relay_publish_transport(operation_id: &str) -> bool { +pub fn requires_nostr_publish_transport(operation_id: &str) -> bool { matches!( operation_id, "sync.push" + | "transport.outbox.push" | "farm.publish" | "listing.publish" | "listing.update" @@ -258,8 +269,8 @@ mod tests { use super::{ ApprovalPolicy, NetworkRequirement, OPERATION_REGISTRY, OperationRole, RiskLevel, - get_operation, network_requirement, requires_direct_nostr_relay_publish_transport, - requires_local_signer_mode, + get_operation, network_requirement, requires_local_signer_mode, + requires_nostr_publish_transport, }; const EXPECTED_OPERATION_IDS: &[&str] = &[ @@ -278,7 +289,15 @@ mod tests { "account.selection.update", "account.selection.clear", "signer.status.get", - "relay.list", + "transport.profile.get", + "transport.profile.set", + "transport.status", + "transport.outbox.status", + "transport.outbox.push", + "mesh.scope.get", + "mesh.scope.set", + "mesh.status", + "mesh.policy.check", "store.init", "store.status.get", "store.export", @@ -354,6 +373,9 @@ mod tests { "store.backup.restore", "sync.pull", "sync.push", + "transport.profile.set", + "transport.outbox.push", + "mesh.scope.set", "farm.create", "farm.rebind", "farm.profile.update", @@ -396,7 +418,7 @@ mod tests { .copied() .collect::<BTreeSet<_>>(); assert_eq!(actual, expected); - assert_eq!(OPERATION_REGISTRY.len(), 76); + assert_eq!(OPERATION_REGISTRY.len(), 84); } #[test] @@ -448,6 +470,9 @@ mod tests { "listing.publish", "listing.update", "listing.archive", + "transport.profile.set", + "transport.outbox.push", + "mesh.scope.set", "trade.submit", "trade.rebind", "trade.accept", @@ -513,7 +538,7 @@ mod tests { "health.check.run", "config.get", "account.list", - "relay.list", + "transport.outbox.push", "farm.location.set", "sync.pull", "sync.push", @@ -560,6 +585,7 @@ mod tests { "trade.revision.propose", "trade.revision.accept", "trade.revision.decline", + "transport.outbox.push", "trade.event.list", "validation.receipt.get", "validation.receipt.list", @@ -587,6 +613,7 @@ mod tests { "trade.revision.propose", "trade.revision.accept", "trade.revision.decline", + "transport.outbox.push", ] .into_iter() .collect::<BTreeSet<_>>(); @@ -595,16 +622,15 @@ mod tests { } #[test] - fn registry_direct_nostr_relay_publish_requirements_are_explicit() { + fn registry_nostr_publish_requirements_are_explicit() { let publish = OPERATION_REGISTRY .iter() - .filter(|operation| { - requires_direct_nostr_relay_publish_transport(operation.operation_id) - }) + .filter(|operation| requires_nostr_publish_transport(operation.operation_id)) .map(|operation| operation.operation_id) .collect::<BTreeSet<_>>(); let expected = [ "sync.push", + "transport.outbox.push", "farm.publish", "listing.publish", "listing.update", diff --git a/src/registry/relay.rs b/src/registry/relay.rs @@ -1,17 +0,0 @@ -use super::{ApprovalPolicy, OperationRole, OperationSpec, RiskLevel}; - -pub const RELAY_LIST: OperationSpec = operation!( - "relay.list", - "radroots relay list", - "relay", - "relay_list", - "RelayListRequest", - "RelayListResult", - "List configured relays.", - Any, - false, - None, - Low, - true, - false -); diff --git a/src/registry/transport.rs b/src/registry/transport.rs @@ -0,0 +1,81 @@ +use super::{ApprovalPolicy, OperationRole, OperationSpec, RiskLevel}; + +pub const TRANSPORT_PROFILE_GET: OperationSpec = operation!( + "transport.profile.get", + "radroots transport profile get", + "transport", + "transport_profile_get", + "TransportProfileGetRequest", + "TransportProfileGetResult", + "Read the active transport profile.", + Any, + false, + None, + Low, + false, + false +); + +pub const TRANSPORT_PROFILE_SET: OperationSpec = operation!( + "transport.profile.set", + "radroots transport profile set --kind local-only", + "transport", + "transport_profile_set", + "TransportProfileSetRequest", + "TransportProfileSetResult", + "Write the active transport profile.", + Any, + true, + Required, + High, + false, + true +); + +pub const TRANSPORT_STATUS: OperationSpec = operation!( + "transport.status", + "radroots transport status", + "transport", + "transport_status", + "TransportStatusRequest", + "TransportStatusResult", + "Read transport implementation readiness.", + Any, + false, + None, + Low, + false, + false +); + +pub const TRANSPORT_OUTBOX_STATUS: OperationSpec = operation!( + "transport.outbox.status", + "radroots transport outbox status", + "transport", + "transport_outbox_status", + "TransportOutboxStatusRequest", + "TransportOutboxStatusResult", + "Read SDK transport outbox status.", + Any, + false, + None, + Low, + false, + false +); + +pub const TRANSPORT_OUTBOX_PUSH: OperationSpec = operation!( + "transport.outbox.push", + "radroots transport outbox push", + "transport", + "transport_outbox_push", + "TransportOutboxPushRequest", + "TransportOutboxPushResult", + "Push ready SDK outbox events through the active transport profile.", + Any, + true, + Required, + High, + true, + true +); diff --git a/src/runtime/config.rs b/src/runtime/config.rs @@ -4,10 +4,12 @@ use std::io::IsTerminal; use std::path::Path; use std::path::PathBuf; -use radroots_local_events::{RelayUrlValidationError, normalize_relay_url}; use radroots_runtime::{parse_bool_value, parse_strict_env_file, parse_u64_value}; use radroots_runtime_paths::RadrootsPathResolver; use radroots_secret_vault::{RadrootsHostVaultPolicy, RadrootsSecretBackend}; +use radroots_transport_nostr::{ + RadrootsRelayTransportError, RadrootsRelayUrl, RadrootsRelayUrlPolicy, +}; use serde::Deserialize; use url::Url; @@ -39,17 +41,20 @@ const ENV_CLI_ACCOUNT_SELECTOR: &str = "RADROOTS_CLI_ACCOUNT_SELECTOR"; const ENV_CLI_ACCOUNT_SECRET_BACKEND: &str = "RADROOTS_CLI_ACCOUNT_SECRET_BACKEND"; const ENV_CLI_IDENTITY_PATH: &str = "RADROOTS_CLI_IDENTITY_PATH"; const ENV_CLI_SIGNER_BACKEND: &str = "RADROOTS_CLI_SIGNER_BACKEND"; -const ENV_CLI_PUBLISH_TRANSPORT: &str = "RADROOTS_CLI_PUBLISH_TRANSPORT"; -const ENV_CLI_RELAYS_URLS: &str = "RADROOTS_CLI_RELAYS_URLS"; -const ENV_CLI_RADROOTSD_PROXY_URL: &str = "RADROOTS_CLI_RADROOTSD_PROXY_URL"; -const ENV_CLI_RADROOTSD_PROXY_TOKEN_FILE: &str = "RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE"; -const ENV_CLI_RADROOTSD_PROXY_TOKEN_SECRET_ID: &str = - "RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_SECRET_ID"; +const ENV_CLI_TRANSPORT_PROFILE: &str = "RADROOTS_CLI_TRANSPORT_PROFILE"; +const ENV_CLI_TRANSPORT_NOSTR_RELAY_URLS: &str = "RADROOTS_CLI_TRANSPORT_NOSTR_RELAY_URLS"; +const ENV_CLI_TRANSPORT_RETICULUM_PREVIEW_BEHAVIOR: &str = + "RADROOTS_CLI_TRANSPORT_RETICULUM_PREVIEW_BEHAVIOR"; +const ENV_CLI_TRANSPORT_PROXY_URL: &str = "RADROOTS_CLI_TRANSPORT_PROXY_URL"; +const ENV_CLI_TRANSPORT_PROXY_TOKEN_FILE: &str = "RADROOTS_CLI_TRANSPORT_PROXY_TOKEN_FILE"; +const ENV_CLI_TRANSPORT_PROXY_TOKEN_SECRET_ID: &str = + "RADROOTS_CLI_TRANSPORT_PROXY_TOKEN_SECRET_ID"; const ENV_CLI_MYC_EXECUTABLE: &str = "RADROOTS_CLI_MYC_EXECUTABLE"; const ENV_CLI_MYC_STATUS_TIMEOUT_MS: &str = "RADROOTS_CLI_MYC_STATUS_TIMEOUT_MS"; const ENV_CLI_HYF_ENABLED: &str = "RADROOTS_CLI_HYF_ENABLED"; const ENV_CLI_HYF_EXECUTABLE: &str = "RADROOTS_CLI_HYF_EXECUTABLE"; const ENV_CLI_RHI_TRUSTED_WORKER_PUBKEYS: &str = "RADROOTS_CLI_RHI_TRUSTED_WORKER_PUBKEYS"; +const ENV_CLI_MESH_SCOPE: &str = "RADROOTS_CLI_MESH_SCOPE"; const SUPPORTED_ENV_FILE_KEYS: &[&str] = &[ ENV_CLI_OUTPUT_FORMAT, ENV_CLI_LOG_FILTER, @@ -61,16 +66,18 @@ const SUPPORTED_ENV_FILE_KEYS: &[&str] = &[ ENV_CLI_ACCOUNT_SECRET_BACKEND, ENV_CLI_IDENTITY_PATH, ENV_CLI_SIGNER_BACKEND, - ENV_CLI_PUBLISH_TRANSPORT, - ENV_CLI_RELAYS_URLS, - ENV_CLI_RADROOTSD_PROXY_URL, - ENV_CLI_RADROOTSD_PROXY_TOKEN_FILE, - ENV_CLI_RADROOTSD_PROXY_TOKEN_SECRET_ID, + ENV_CLI_TRANSPORT_PROFILE, + ENV_CLI_TRANSPORT_NOSTR_RELAY_URLS, + ENV_CLI_TRANSPORT_RETICULUM_PREVIEW_BEHAVIOR, + ENV_CLI_TRANSPORT_PROXY_URL, + ENV_CLI_TRANSPORT_PROXY_TOKEN_FILE, + ENV_CLI_TRANSPORT_PROXY_TOKEN_SECRET_ID, ENV_CLI_MYC_EXECUTABLE, ENV_CLI_MYC_STATUS_TIMEOUT_MS, ENV_CLI_HYF_ENABLED, ENV_CLI_HYF_EXECUTABLE, ENV_CLI_RHI_TRUSTED_WORKER_PUBKEYS, + ENV_CLI_MESH_SCOPE, ]; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -175,23 +182,110 @@ pub struct SignerConfig { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TransportProfileKind { + LocalOnly, + Nostr, + ReticulumPreview, + Proxy, +} + +impl TransportProfileKind { + pub fn as_str(self) -> &'static str { + match self { + Self::LocalOnly => "local_only", + Self::Nostr => "nostr", + Self::ReticulumPreview => "reticulum_preview", + Self::Proxy => "proxy", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReticulumPreviewBehavior { + RejectDeliveryAttempts, + DeferDeliveryPlans, +} + +impl ReticulumPreviewBehavior { + pub fn as_str(self) -> &'static str { + match self { + Self::RejectDeliveryAttempts => "reject_delivery_attempts", + Self::DeferDeliveryPlans => "defer_delivery_plans", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TransportConfigSource { + Environment, + UserConfig, + WorkspaceConfig, + Defaults, +} + +impl TransportConfigSource { + pub fn as_str(self) -> &'static str { + match self { + Self::Environment => "environment · transport profile", + Self::UserConfig => "user config · transport profile", + Self::WorkspaceConfig => "workspace config · transport profile", + Self::Defaults => "defaults · transport profile", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TransportConfig { + pub profile: TransportProfileKind, + pub source: TransportConfigSource, + pub nostr_relay_urls: Vec<String>, + pub reticulum_preview_behavior: ReticulumPreviewBehavior, + pub proxy: ProxyTransportConfig, +} + +impl TransportConfig { + pub fn local_only() -> Self { + Self { + profile: TransportProfileKind::LocalOnly, + source: TransportConfigSource::Defaults, + nostr_relay_urls: Vec::new(), + reticulum_preview_behavior: ReticulumPreviewBehavior::RejectDeliveryAttempts, + proxy: ProxyTransportConfig::default(), + } + } + + pub fn from_nostr_relay_urls(nostr_relay_urls: Vec<String>) -> Self { + if nostr_relay_urls.is_empty() { + return Self::local_only(); + } + Self { + profile: TransportProfileKind::Nostr, + source: TransportConfigSource::Defaults, + nostr_relay_urls, + reticulum_preview_behavior: ReticulumPreviewBehavior::RejectDeliveryAttempts, + proxy: ProxyTransportConfig::default(), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PublishTransport { - DirectNostrRelay, - RadrootsdProxy, + Nostr, + Proxy, } impl PublishTransport { pub fn as_str(self) -> &'static str { match self { - Self::DirectNostrRelay => "direct_nostr_relay", - Self::RadrootsdProxy => "radrootsd_proxy", + Self::Nostr => "nostr", + Self::Proxy => "proxy", } } pub fn transport_family(self) -> &'static str { match self { - Self::DirectNostrRelay => "direct_nostr_relay", - Self::RadrootsdProxy => "radrootsd_proxy", + Self::Nostr => "nostr", + Self::Proxy => "proxy", } } } @@ -208,11 +302,11 @@ pub enum PublishTransportSource { impl PublishTransportSource { pub fn as_str(self) -> &'static str { match self { - Self::Flags => "cli flags · local first", - Self::Environment => "environment · local first", - Self::UserConfig => "user config · local first", - Self::WorkspaceConfig => "workspace config · local first", - Self::Defaults => "defaults · local first", + Self::Flags => "cli flags · transport profile", + Self::Environment => "environment · transport profile", + Self::UserConfig => "user config · transport profile", + Self::WorkspaceConfig => "workspace config · transport profile", + Self::Defaults => "defaults · transport profile", } } } @@ -221,17 +315,17 @@ impl PublishTransportSource { pub struct PublishConfig { pub transport: PublishTransport, pub source: PublishTransportSource, - pub radrootsd_proxy: RadrootsdProxyConfig, + pub proxy: ProxyTransportConfig, } #[derive(Debug, Clone, PartialEq, Eq)] -pub struct RadrootsdProxyConfig { +pub struct ProxyTransportConfig { pub url: String, pub token_file: Option<PathBuf>, pub token_secret_id: Option<String>, } -impl Default for RadrootsdProxyConfig { +impl Default for ProxyTransportConfig { fn default() -> Self { Self { url: DEFAULT_RPC_URL.to_owned(), @@ -303,6 +397,55 @@ pub struct HyfConfig { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MeshScope { + Disabled, + LocalPreview, +} + +impl MeshScope { + pub fn as_str(self) -> &'static str { + match self { + Self::Disabled => "disabled", + Self::LocalPreview => "local_preview", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MeshConfig { + pub scope: MeshScope, + pub source: MeshConfigSource, +} + +impl MeshConfig { + pub fn disabled() -> Self { + Self { + scope: MeshScope::Disabled, + source: MeshConfigSource::Defaults, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MeshConfigSource { + Environment, + UserConfig, + WorkspaceConfig, + Defaults, +} + +impl MeshConfigSource { + pub fn as_str(self) -> &'static str { + match self { + Self::Environment => "environment · local first", + Self::UserConfig => "user config · local first", + Self::WorkspaceConfig => "workspace config · local first", + Self::Defaults => "defaults · local first", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CapabilityBindingTargetKind { ManagedInstance, ExplicitEndpoint, @@ -386,11 +529,13 @@ pub struct RuntimeConfig { pub account_secret_contract: AccountSecretContractConfig, pub identity: IdentityConfig, pub signer: SignerConfig, + pub transport: TransportConfig, pub publish: PublishConfig, pub relay: RelayConfig, pub local: LocalConfig, pub myc: MycConfig, pub hyf: HyfConfig, + pub mesh: MeshConfig, pub rpc: RpcConfig, pub rhi: RhiConfig, pub capability_bindings: Vec<CapabilityBindingConfig>, @@ -412,11 +557,11 @@ struct CliConfigFile { logging: Option<LoggingFileConfig>, account: Option<AccountFileConfig>, identity: Option<IdentityFileConfig>, - relays: Option<RelayFileConfig>, - publish: Option<PublishFileConfig>, + transport: Option<TransportFileConfig>, signer: Option<SignerFileConfig>, myc: Option<MycFileConfig>, hyf: Option<HyfFileConfig>, + mesh: Option<MeshFileConfig>, rpc: Option<RpcFileConfig>, rhi: Option<RhiFileConfig>, capability_binding: Option<Vec<CapabilityBindingFileConfig>>, @@ -457,21 +602,28 @@ struct IdentityFileConfig { #[derive(Debug, Default, Deserialize)] #[serde(default, deny_unknown_fields)] -struct RelayFileConfig { - urls: Option<Vec<String>>, - publish_policy: Option<String>, +struct TransportFileConfig { + profile: Option<String>, + nostr: Option<NostrTransportFileConfig>, + reticulum_preview: Option<ReticulumPreviewFileConfig>, + proxy: Option<TransportProxyFileConfig>, } #[derive(Debug, Default, Deserialize)] #[serde(default, deny_unknown_fields)] -struct PublishFileConfig { - transport: Option<String>, - radrootsd_proxy: Option<RadrootsdProxyFileConfig>, +struct NostrTransportFileConfig { + relay_urls: Option<Vec<String>>, } #[derive(Debug, Default, Deserialize)] #[serde(default, deny_unknown_fields)] -struct RadrootsdProxyFileConfig { +struct ReticulumPreviewFileConfig { + behavior: Option<String>, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct TransportProxyFileConfig { url: Option<String>, token_file: Option<PathBuf>, token_secret_id: Option<String>, @@ -509,6 +661,12 @@ struct HyfFileConfig { executable: Option<PathBuf>, } +#[derive(Debug, Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct MeshFileConfig { + scope: Option<String>, +} + #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] struct CapabilityBindingFileConfig { @@ -642,6 +800,12 @@ impl RuntimeConfig { )?, }; validate_logging_output_contract(&output, &logging)?; + let transport = resolve_transport_config( + env, + env_file, + app_config.as_ref(), + workspace_config.as_ref(), + )?; Ok(Self { capability_bindings: resolve_capability_bindings( app_config.as_ref(), @@ -711,20 +875,9 @@ impl RuntimeConfig { app_config.as_ref(), workspace_config.as_ref(), )?, - publish: resolve_publish_config( - args, - env, - env_file, - app_config.as_ref(), - workspace_config.as_ref(), - )?, - relay: resolve_relay_config( - args, - env, - env_file, - app_config.as_ref(), - workspace_config.as_ref(), - )?, + transport: transport.clone(), + publish: publish_config_from_transport(&transport), + relay: relay_config_from_transport(&transport), local: LocalConfig { root: paths.app_data_root.join(DEFAULT_LOCAL_STATE_DIR), replica_db_path: paths @@ -763,6 +916,12 @@ impl RuntimeConfig { workspace_config.as_ref(), ), }, + mesh: resolve_mesh_config( + env, + env_file, + app_config.as_ref(), + workspace_config.as_ref(), + )?, rpc: resolve_rpc_config( env, env_file, @@ -889,37 +1048,188 @@ fn resolve_rpc_config( }) } -fn resolve_radrootsd_proxy_config( +fn resolve_transport_config( env: &dyn Environment, env_file: &EnvFileValues, user_config: Option<&CliConfigFile>, workspace_config: Option<&CliConfigFile>, -) -> Result<RadrootsdProxyConfig, RuntimeError> { - let user_proxy = user_config - .and_then(|config| config.publish.as_ref()) - .and_then(|publish| publish.radrootsd_proxy.as_ref()); - let workspace_proxy = workspace_config - .and_then(|config| config.publish.as_ref()) - .and_then(|publish| publish.radrootsd_proxy.as_ref()); - let url = env_value(env, env_file, &[ENV_CLI_RADROOTSD_PROXY_URL]) - .or_else(|| user_proxy.and_then(|proxy| proxy.url.clone())) - .or_else(|| workspace_proxy.and_then(|proxy| proxy.url.clone())) +) -> Result<TransportConfig, RuntimeError> { + if let Some((key, value)) = env_value_entry(env, env_file, &[ENV_CLI_TRANSPORT_PROFILE]) { + let profile = parse_transport_profile_kind(key.as_str(), value.as_str())?; + let reticulum_preview_behavior = env_value( + env, + env_file, + &[ENV_CLI_TRANSPORT_RETICULUM_PREVIEW_BEHAVIOR], + ) + .map(|value| { + parse_reticulum_preview_behavior( + ENV_CLI_TRANSPORT_RETICULUM_PREVIEW_BEHAVIOR, + value.as_str(), + ) + }) + .transpose()? + .unwrap_or(ReticulumPreviewBehavior::RejectDeliveryAttempts); + return Ok(TransportConfig { + profile, + source: TransportConfigSource::Environment, + nostr_relay_urls: env_value(env, env_file, &[ENV_CLI_TRANSPORT_NOSTR_RELAY_URLS]) + .map(|value| { + parse_relay_env_value(value.as_str(), ENV_CLI_TRANSPORT_NOSTR_RELAY_URLS) + }) + .transpose()? + .unwrap_or_default(), + reticulum_preview_behavior, + proxy: resolve_transport_proxy_env(env, env_file)?, + }); + } + + if let Some(transport) = user_config.and_then(|config| config.transport.as_ref()) { + return resolve_transport_file_config( + transport, + TransportConfigSource::UserConfig, + "user config [transport]", + ); + } + + if let Some(transport) = workspace_config.and_then(|config| config.transport.as_ref()) { + return resolve_transport_file_config( + transport, + TransportConfigSource::WorkspaceConfig, + "workspace config [transport]", + ); + } + + Ok(TransportConfig { + profile: TransportProfileKind::LocalOnly, + source: TransportConfigSource::Defaults, + nostr_relay_urls: Vec::new(), + reticulum_preview_behavior: ReticulumPreviewBehavior::RejectDeliveryAttempts, + proxy: ProxyTransportConfig::default(), + }) +} + +fn resolve_transport_file_config( + transport: &TransportFileConfig, + source: TransportConfigSource, + source_label: &str, +) -> Result<TransportConfig, RuntimeError> { + let Some(profile_value) = transport.profile.as_deref() else { + if transport.nostr.is_some() + || transport.reticulum_preview.is_some() + || transport.proxy.is_some() + { + return Err(RuntimeError::Config(format!( + "{source_label}.profile must be set when profile-specific transport tables are present" + ))); + } + return Ok(TransportConfig { + profile: TransportProfileKind::LocalOnly, + source, + nostr_relay_urls: Vec::new(), + reticulum_preview_behavior: ReticulumPreviewBehavior::RejectDeliveryAttempts, + proxy: ProxyTransportConfig::default(), + }); + }; + let profile = + parse_transport_profile_kind(format!("{source_label}.profile").as_str(), profile_value)?; + let nostr_relay_urls = transport + .nostr + .as_ref() + .and_then(|nostr| nostr.relay_urls.clone()) + .map(|urls| normalize_relay_urls(urls, format!("{source_label}.nostr.relay_urls").as_str())) + .transpose()? + .unwrap_or_default(); + let reticulum_preview_behavior = transport + .reticulum_preview + .as_ref() + .and_then(|preview| preview.behavior.as_deref()) + .map(|value| { + parse_reticulum_preview_behavior( + format!("{source_label}.reticulum_preview.behavior").as_str(), + value, + ) + }) + .transpose()? + .unwrap_or(ReticulumPreviewBehavior::RejectDeliveryAttempts); + Ok(TransportConfig { + profile, + source, + nostr_relay_urls, + reticulum_preview_behavior, + proxy: resolve_transport_proxy_file(transport.proxy.as_ref(), source_label)?, + }) +} + +fn resolve_transport_proxy_env( + env: &dyn Environment, + env_file: &EnvFileValues, +) -> Result<ProxyTransportConfig, RuntimeError> { + let url = env_value(env, env_file, &[ENV_CLI_TRANSPORT_PROXY_URL]) .unwrap_or_else(|| DEFAULT_RPC_URL.to_owned()); - let token_file = env_value(env, env_file, &[ENV_CLI_RADROOTSD_PROXY_TOKEN_FILE]) - .map(PathBuf::from) - .or_else(|| user_proxy.and_then(|proxy| proxy.token_file.clone())) - .or_else(|| workspace_proxy.and_then(|proxy| proxy.token_file.clone())); - let token_secret_id = env_value(env, env_file, &[ENV_CLI_RADROOTSD_PROXY_TOKEN_SECRET_ID]) - .or_else(|| user_proxy.and_then(|proxy| proxy.token_secret_id.clone())) - .or_else(|| workspace_proxy.and_then(|proxy| proxy.token_secret_id.clone())); - - Ok(RadrootsdProxyConfig { + let token_file = + env_value(env, env_file, &[ENV_CLI_TRANSPORT_PROXY_TOKEN_FILE]).map(PathBuf::from); + let token_secret_id = env_value(env, env_file, &[ENV_CLI_TRANSPORT_PROXY_TOKEN_SECRET_ID]); + Ok(ProxyTransportConfig { url: validate_rpc_url(url.as_str())?, token_file, token_secret_id, }) } +fn resolve_transport_proxy_file( + proxy: Option<&TransportProxyFileConfig>, + source_label: &str, +) -> Result<ProxyTransportConfig, RuntimeError> { + let url = proxy + .and_then(|proxy| proxy.url.clone()) + .unwrap_or_else(|| DEFAULT_RPC_URL.to_owned()); + Ok(ProxyTransportConfig { + url: validate_rpc_url(url.as_str()).map_err(|error| match error { + RuntimeError::Config(message) => { + RuntimeError::Config(format!("{source_label}.proxy.url is invalid: {message}")) + } + other => other, + })?, + token_file: proxy.and_then(|proxy| proxy.token_file.clone()), + token_secret_id: proxy.and_then(|proxy| proxy.token_secret_id.clone()), + }) +} + +fn publish_config_from_transport(transport: &TransportConfig) -> PublishConfig { + PublishConfig { + transport: match transport.profile { + TransportProfileKind::Proxy => PublishTransport::Proxy, + TransportProfileKind::LocalOnly + | TransportProfileKind::Nostr + | TransportProfileKind::ReticulumPreview => PublishTransport::Nostr, + }, + source: match transport.source { + TransportConfigSource::Environment => PublishTransportSource::Environment, + TransportConfigSource::UserConfig => PublishTransportSource::UserConfig, + TransportConfigSource::WorkspaceConfig => PublishTransportSource::WorkspaceConfig, + TransportConfigSource::Defaults => PublishTransportSource::Defaults, + }, + proxy: transport.proxy.clone(), + } +} + +fn relay_config_from_transport(transport: &TransportConfig) -> RelayConfig { + RelayConfig { + urls: if matches!(transport.profile, TransportProfileKind::Nostr) { + transport.nostr_relay_urls.clone() + } else { + Vec::new() + }, + publish_policy: RelayPublishPolicy::Any, + source: match transport.source { + TransportConfigSource::Environment => RelayConfigSource::Environment, + TransportConfigSource::UserConfig => RelayConfigSource::UserConfig, + TransportConfigSource::WorkspaceConfig => RelayConfigSource::WorkspaceConfig, + TransportConfigSource::Defaults => RelayConfigSource::Defaults, + }, + } +} + fn resolve_rhi_config( env: &dyn Environment, env_file: &EnvFileValues, @@ -1105,59 +1415,6 @@ fn normalize_binding_ref(value: Option<&str>) -> Option<String> { value.map(ToOwned::to_owned) } -fn resolve_relay_config( - args: &RuntimeInvocationArgs, - env: &dyn Environment, - env_file: &EnvFileValues, - user_config: Option<&CliConfigFile>, - workspace_config: Option<&CliConfigFile>, -) -> Result<RelayConfig, RuntimeError> { - let publish_policy = resolve_relay_publish_policy(user_config, workspace_config)? - .unwrap_or(RelayPublishPolicy::Any); - - if !args.relay.is_empty() { - return Ok(RelayConfig { - urls: normalize_relay_urls(args.relay.clone(), "--relay")?, - publish_policy, - source: RelayConfigSource::Flags, - }); - } - - if let Some(value) = env_value(env, env_file, &[ENV_CLI_RELAYS_URLS]) { - return Ok(RelayConfig { - urls: parse_relay_env_value(value.as_str(), ENV_CLI_RELAYS_URLS)?, - publish_policy, - source: RelayConfigSource::Environment, - }); - } - - if let Some(relay) = user_config.and_then(|config| config.relays.as_ref()) { - if let Some(urls) = relay.urls.clone() { - return Ok(RelayConfig { - urls: normalize_relay_urls(urls, "user config [relays].urls")?, - publish_policy, - source: RelayConfigSource::UserConfig, - }); - } - } - - if let Some(relay) = workspace_config.and_then(|config| config.relays.as_ref()) { - if let Some(urls) = relay.urls.clone() { - return Ok(RelayConfig { - urls: normalize_relay_urls(urls, "workspace config [relays].urls")?, - publish_policy, - source: RelayConfigSource::WorkspaceConfig, - }); - } - } - - Ok(RelayConfig { - urls: Vec::new(), - publish_policy, - source: RelayConfigSource::Defaults, - }) -} - fn resolve_signer_config( args: &RuntimeInvocationArgs, env: &dyn Environment, @@ -1186,60 +1443,6 @@ fn resolve_signer_config( Ok(SignerConfig { backend }) } -fn resolve_publish_config( - args: &RuntimeInvocationArgs, - env: &dyn Environment, - env_file: &EnvFileValues, - user_config: Option<&CliConfigFile>, - workspace_config: Option<&CliConfigFile>, -) -> Result<PublishConfig, RuntimeError> { - let radrootsd_proxy = - resolve_radrootsd_proxy_config(env, env_file, user_config, workspace_config)?; - if let Some(value) = args.publish_transport.clone() { - return Ok(PublishConfig { - transport: parse_publish_transport("--publish-transport", value)?, - source: PublishTransportSource::Flags, - radrootsd_proxy, - }); - } - - if let Some((key, value)) = env_value_entry(env, env_file, &[ENV_CLI_PUBLISH_TRANSPORT]) { - return Ok(PublishConfig { - transport: parse_publish_transport(key.as_str(), value)?, - source: PublishTransportSource::Environment, - radrootsd_proxy, - }); - } - - if let Some(value) = user_config - .and_then(|config| config.publish.as_ref()) - .and_then(|publish| publish.transport.clone()) - { - return Ok(PublishConfig { - transport: parse_publish_transport("user config [publish].transport", value)?, - source: PublishTransportSource::UserConfig, - radrootsd_proxy, - }); - } - - if let Some(value) = workspace_config - .and_then(|config| config.publish.as_ref()) - .and_then(|publish| publish.transport.clone()) - { - return Ok(PublishConfig { - transport: parse_publish_transport("workspace config [publish].transport", value)?, - source: PublishTransportSource::WorkspaceConfig, - radrootsd_proxy, - }); - } - - Ok(PublishConfig { - transport: PublishTransport::DirectNostrRelay, - source: PublishTransportSource::Defaults, - radrootsd_proxy, - }) -} - fn resolve_myc_config( args: &RuntimeInvocationArgs, env: &dyn Environment, @@ -1380,32 +1583,48 @@ fn resolve_hyf_executable( .unwrap_or_else(|| PathBuf::from(DEFAULT_HYF_EXECUTABLE)) } -fn resolve_relay_publish_policy( +fn resolve_mesh_config( + env: &dyn Environment, + env_file: &EnvFileValues, user_config: Option<&CliConfigFile>, workspace_config: Option<&CliConfigFile>, -) -> Result<Option<RelayPublishPolicy>, RuntimeError> { +) -> Result<MeshConfig, RuntimeError> { + if let Some((key, value)) = env_value_entry(env, env_file, &[ENV_CLI_MESH_SCOPE]) { + return Ok(MeshConfig { + scope: parse_mesh_scope(key.as_str(), value.as_str())?, + source: MeshConfigSource::Environment, + }); + } if let Some(value) = user_config - .and_then(|config| config.relays.as_ref()) - .and_then(|relay| relay.publish_policy.as_deref()) + .and_then(|config| config.mesh.as_ref()) + .and_then(|mesh| mesh.scope.as_deref()) { - return parse_relay_publish_policy(value).map(Some); + return Ok(MeshConfig { + scope: parse_mesh_scope("user config [mesh].scope", value)?, + source: MeshConfigSource::UserConfig, + }); } - if let Some(value) = workspace_config - .and_then(|config| config.relays.as_ref()) - .and_then(|relay| relay.publish_policy.as_deref()) + .and_then(|config| config.mesh.as_ref()) + .and_then(|mesh| mesh.scope.as_deref()) { - return parse_relay_publish_policy(value).map(Some); + return Ok(MeshConfig { + scope: parse_mesh_scope("workspace config [mesh].scope", value)?, + source: MeshConfigSource::WorkspaceConfig, + }); } - - Ok(None) + Ok(MeshConfig { + scope: MeshScope::Disabled, + source: MeshConfigSource::Defaults, + }) } -fn parse_relay_publish_policy(value: &str) -> Result<RelayPublishPolicy, RuntimeError> { +fn parse_mesh_scope(source: &str, value: &str) -> Result<MeshScope, RuntimeError> { match value.trim().to_ascii_lowercase().as_str() { - "any" => Ok(RelayPublishPolicy::Any), + "disabled" => Ok(MeshScope::Disabled), + "local_preview" => Ok(MeshScope::LocalPreview), other => Err(RuntimeError::Config(format!( - "[relays].publish_policy must be `any`, got `{other}`" + "{source} must be `disabled` or `local_preview`, got `{other}`" ))), } } @@ -1459,16 +1678,25 @@ fn validate_relay_url(value: &str, source: &str) -> Result<String, RuntimeError> "{source} contains an empty relay url" ))); } - normalize_relay_url(trimmed).map_err(|error| match error { - RelayUrlValidationError::UnsupportedScheme(_) => RuntimeError::Config(format!( - "{source} must use websocket relay urls, got `{trimmed}`" - )), - _ => RuntimeError::Config(format!( - "{source} contains invalid relay url `{trimmed}`: {error}" + RadrootsRelayUrl::parse(trimmed, nostr_relay_url_policy_for_url(trimmed)).map(|relay| relay.into_string()).map_err(|error| match error { + RadrootsRelayTransportError::UnsupportedRelayScheme { .. } + | RadrootsRelayTransportError::WsRequiresLocalhostPolicy { .. } => { + RuntimeError::Config(format!("{source} must use websocket relay urls allowed by the active Nostr policy, got `{trimmed}`")) + } + other => RuntimeError::Config(format!( + "{source} contains invalid relay url `{trimmed}`: {other}" )), }) } +fn nostr_relay_url_policy_for_url(value: &str) -> RadrootsRelayUrlPolicy { + if value.trim_start().starts_with("ws://") { + RadrootsRelayUrlPolicy::Localhost + } else { + RadrootsRelayUrlPolicy::Public + } +} + fn resolve_env_file_path(args: &RuntimeInvocationArgs, env: &dyn Environment) -> Option<PathBuf> { args.env_file .clone() @@ -1735,14 +1963,30 @@ fn parse_signer_mode(source: &str, value: String) -> Result<SignerBackend, Runti } } -fn parse_publish_transport(source: &str, value: String) -> Result<PublishTransport, RuntimeError> { +fn parse_transport_profile_kind( + source: &str, + value: &str, +) -> Result<TransportProfileKind, RuntimeError> { match value.trim().to_ascii_lowercase().as_str() { - "direct_nostr_relay" => Ok(PublishTransport::DirectNostrRelay), - "radrootsd_proxy" => Ok(PublishTransport::RadrootsdProxy), + "local_only" => Ok(TransportProfileKind::LocalOnly), + "nostr" => Ok(TransportProfileKind::Nostr), + "reticulum_preview" => Ok(TransportProfileKind::ReticulumPreview), + "proxy" => Ok(TransportProfileKind::Proxy), other => Err(RuntimeError::Config(format!( - "{source} must be `{}` or `{}`, got `{other}`", - PublishTransport::DirectNostrRelay.as_str(), - PublishTransport::RadrootsdProxy.as_str() + "{source} must be `local_only`, `nostr`, `reticulum_preview`, or `proxy`, got `{other}`" + ))), + } +} + +fn parse_reticulum_preview_behavior( + source: &str, + value: &str, +) -> Result<ReticulumPreviewBehavior, RuntimeError> { + match value.trim().to_ascii_lowercase().as_str() { + "reject_delivery_attempts" => Ok(ReticulumPreviewBehavior::RejectDeliveryAttempts), + "defer_delivery_plans" => Ok(ReticulumPreviewBehavior::DeferDeliveryPlans), + other => Err(RuntimeError::Config(format!( + "{source} must be `reject_delivery_attempts` or `defer_delivery_plans`, got `{other}`" ))), } } @@ -1803,8 +2047,8 @@ mod tests { DEFAULT_LOG_FILTER, DEFAULT_MYC_STATUS_TIMEOUT_MS, DEFAULT_RPC_URL, EnvFileValues, Environment, HyfConfig, INFERENCE_HYF_STDIO_CAPABILITY, InteractionConfig, OutputConfig, OutputFormat, PathsConfig, PublishConfig, PublishTransport, PublishTransportSource, - RelayConfigSource, RelayPublishPolicy, RuntimeConfig, SignerBackend, Verbosity, - parse_env_file_values, + RelayConfigSource, RelayPublishPolicy, RuntimeConfig, SignerBackend, TransportConfigSource, + TransportProfileKind, Verbosity, parse_env_file_values, }; use crate::cli::global::{RuntimeInvocationArgs, RuntimeOutputFormatArg}; use radroots_runtime_paths::{RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform}; @@ -1912,8 +2156,6 @@ mod tests { log_stdout: false, identity_path: Some(PathBuf::from("custom-identity.json")), signer: Some("local".to_owned()), - publish_transport: Some("direct_nostr_relay".to_owned()), - relay: vec!["wss://relay.one".to_owned(), "wss://relay.two".to_owned()], myc_executable: Some(PathBuf::from("bin/myc-cli")), myc_status_timeout_ms: Some(2500), hyf_enabled: true, @@ -1933,12 +2175,12 @@ mod tests { ), ("RADROOTS_CLI_SIGNER_BACKEND".to_owned(), "myc".to_owned()), ( - "RADROOTS_CLI_PUBLISH_TRANSPORT".to_owned(), - "radrootsd_proxy".to_owned(), + "RADROOTS_CLI_TRANSPORT_PROFILE".to_owned(), + "nostr".to_owned(), ), ( - "RADROOTS_CLI_RELAYS_URLS".to_owned(), - "wss://relay.env".to_owned(), + "RADROOTS_CLI_TRANSPORT_NOSTR_RELAY_URLS".to_owned(), + "wss://relay.one,wss://relay.two".to_owned(), ), ( "RADROOTS_CLI_MYC_EXECUTABLE".to_owned(), @@ -2034,19 +2276,24 @@ mod tests { } ); assert_eq!(resolved.signer.backend, SignerBackend::Local); + assert_eq!(resolved.transport.profile, TransportProfileKind::Nostr); + assert_eq!( + resolved.transport.source, + TransportConfigSource::Environment + ); assert_eq!( resolved.publish, PublishConfig { - transport: PublishTransport::DirectNostrRelay, - source: PublishTransportSource::Flags, - radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(), + transport: PublishTransport::Nostr, + source: PublishTransportSource::Environment, + proxy: crate::runtime::config::ProxyTransportConfig::default(), } ); assert_eq!( resolved.relay.urls, vec!["wss://relay.one".to_owned(), "wss://relay.two".to_owned()] ); - assert_eq!(resolved.relay.source, RelayConfigSource::Flags); + assert_eq!(resolved.relay.source, RelayConfigSource::Environment); assert_eq!(resolved.relay.publish_policy, RelayPublishPolicy::Any); assert_eq!(resolved.myc.executable, PathBuf::from("bin/myc-cli")); assert_eq!(resolved.myc.status_timeout_ms, 2500); @@ -2083,11 +2330,11 @@ mod tests { ), ("RADROOTS_CLI_SIGNER_BACKEND".to_owned(), "myc".to_owned()), ( - "RADROOTS_CLI_PUBLISH_TRANSPORT".to_owned(), - "radrootsd_proxy".to_owned(), + "RADROOTS_CLI_TRANSPORT_PROFILE".to_owned(), + "nostr".to_owned(), ), ( - "RADROOTS_CLI_RELAYS_URLS".to_owned(), + "RADROOTS_CLI_TRANSPORT_NOSTR_RELAY_URLS".to_owned(), "wss://relay.one,wss://relay.two".to_owned(), ), ( @@ -2139,12 +2386,17 @@ mod tests { ); assert_eq!(resolved.identity.path, PathBuf::from("state/identity.json")); assert_eq!(resolved.signer.backend, SignerBackend::Myc); + assert_eq!(resolved.transport.profile, TransportProfileKind::Nostr); + assert_eq!( + resolved.transport.source, + TransportConfigSource::Environment + ); assert_eq!( resolved.publish, PublishConfig { - transport: PublishTransport::RadrootsdProxy, + transport: PublishTransport::Nostr, source: PublishTransportSource::Environment, - radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(), + proxy: crate::runtime::config::ProxyTransportConfig::default(), } ); assert_eq!( @@ -2181,7 +2433,7 @@ mod tests { "old-identity.json".to_owned(), ), ("RADROOTS_SIGNER".to_owned(), "myc".to_owned()), - ("RADROOTS_PUBLISH_MODE".to_owned(), "radrootsd".to_owned()), + ("RADROOTS_PUBLISH_MODE".to_owned(), "proxy".to_owned()), ( "RADROOTS_RELAYS".to_owned(), "wss://old-relay.example".to_owned(), @@ -2219,10 +2471,7 @@ mod tests { RadrootsSecretBackend::HostVault(RadrootsHostVaultPolicy::desktop()) ); assert_eq!(resolved.signer.backend, SignerBackend::Local); - assert_eq!( - resolved.publish.transport, - PublishTransport::DirectNostrRelay - ); + assert_eq!(resolved.publish.transport, PublishTransport::Nostr); assert_eq!(resolved.relay.urls, Vec::<String>::new()); assert_eq!(resolved.myc.executable, PathBuf::from("myc")); assert_eq!( @@ -2486,14 +2735,14 @@ path = "identity/from-toml.json" ); let env = MapEnvironment::new(BTreeMap::from([( - "RADROOTS_CLI_PUBLISH_TRANSPORT".to_owned(), + "RADROOTS_CLI_TRANSPORT_PROFILE".to_owned(), "relay".to_owned(), )])); let error = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) - .expect_err("invalid publish transport"); - assert!(error.to_string().contains("RADROOTS_CLI_PUBLISH_TRANSPORT")); - assert!(error.to_string().contains("direct_nostr_relay")); - assert!(error.to_string().contains("radrootsd_proxy")); + .expect_err("invalid transport profile"); + assert!(error.to_string().contains("RADROOTS_CLI_TRANSPORT_PROFILE")); + assert!(error.to_string().contains("local_only")); + assert!(error.to_string().contains("reticulum_preview")); let args = RuntimeInvocationArgs { myc_status_timeout_ms: Some(0), @@ -2518,8 +2767,8 @@ RADROOTS_CLI_LOGGING_STDOUT=false RADROOTS_CLI_ACCOUNT_SELECTOR=acct_env_file RADROOTS_CLI_IDENTITY_PATH=state/identity.json RADROOTS_CLI_SIGNER_BACKEND=myc -RADROOTS_CLI_PUBLISH_TRANSPORT=radrootsd_proxy -RADROOTS_CLI_RELAYS_URLS=wss://relay.env-file +RADROOTS_CLI_TRANSPORT_PROFILE=nostr +RADROOTS_CLI_TRANSPORT_NOSTR_RELAY_URLS=wss://relay.env-file RADROOTS_CLI_MYC_EXECUTABLE=bin/myc RADROOTS_CLI_MYC_STATUS_TIMEOUT_MS=4500 RADROOTS_CLI_HYF_ENABLED=true @@ -2541,12 +2790,17 @@ RADROOTS_CLI_HYF_EXECUTABLE=bin/hyfd assert_eq!(resolved.account.selector.as_deref(), Some("acct_env_file")); assert_eq!(resolved.identity.path, PathBuf::from("state/identity.json")); assert_eq!(resolved.signer.backend, SignerBackend::Myc); + assert_eq!(resolved.transport.profile, TransportProfileKind::Nostr); + assert_eq!( + resolved.transport.source, + TransportConfigSource::Environment + ); assert_eq!( resolved.publish, PublishConfig { - transport: PublishTransport::RadrootsdProxy, + transport: PublishTransport::Nostr, source: PublishTransportSource::Environment, - radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(), + proxy: crate::runtime::config::ProxyTransportConfig::default(), } ); assert_eq!(resolved.relay.urls, vec!["wss://relay.env-file".to_owned()]); @@ -2645,7 +2899,7 @@ RADROOTS_CLI_LOGGING_STDOUT=true } #[test] - fn user_relay_config_overrides_workspace_relay_config() { + fn user_transport_config_overrides_workspace_transport_config() { let temp = tempdir().expect("tempdir"); let workspace_root = temp.path().join("workspace"); let repo_local_root = workspace_root.join("infra/local/runtime/radroots"); @@ -2655,12 +2909,12 @@ RADROOTS_CLI_LOGGING_STDOUT=true fs::create_dir_all(&app_config_dir).expect("app config dir"); fs::write( repo_local_root.join("config.toml"), - "[relays]\nurls = [\"wss://relay.workspace\"]\npublish_policy = \"any\"\n", + "[transport]\nprofile = \"nostr\"\n\n[transport.nostr]\nrelay_urls = [\"wss://relay.workspace\"]\n", ) .expect("write workspace config"); fs::write( app_config_dir.join("config.toml"), - "[relays]\nurls = [\"wss://relay.user\", \"wss://relay.workspace\"]\n", + "[transport]\nprofile = \"nostr\"\n\n[transport.nostr]\nrelay_urls = [\"wss://relay.user\", \"wss://relay.workspace\"]\n", ) .expect("write user config"); @@ -2690,6 +2944,8 @@ RADROOTS_CLI_LOGGING_STDOUT=true let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) .expect("resolve config"); + assert_eq!(resolved.transport.profile, TransportProfileKind::Nostr); + assert_eq!(resolved.transport.source, TransportConfigSource::UserConfig); assert_eq!( resolved.relay.urls, vec![ @@ -2702,7 +2958,7 @@ RADROOTS_CLI_LOGGING_STDOUT=true } #[test] - fn publish_transport_precedence_tracks_source() { + fn transport_profile_precedence_tracks_source() { let temp = tempdir().expect("tempdir"); let workspace_root = temp.path().join("workspace"); let repo_local_root = workspace_root.join("infra/local/runtime/radroots"); @@ -2712,12 +2968,12 @@ RADROOTS_CLI_LOGGING_STDOUT=true fs::create_dir_all(&app_config_dir).expect("app config dir"); fs::write( repo_local_root.join("config.toml"), - "[publish]\ntransport = \"radrootsd_proxy\"\n", + "[transport]\nprofile = \"proxy\"\n\n[transport.proxy]\nurl = \"http://127.0.0.1:7070\"\n", ) .expect("write workspace config"); fs::write( app_config_dir.join("config.toml"), - "[publish]\ntransport = \"direct_nostr_relay\"\n", + "[transport]\nprofile = \"nostr\"\n\n[transport.nostr]\nrelay_urls = [\"wss://relay.user\"]\n", ) .expect("write user config"); @@ -2726,22 +2982,24 @@ RADROOTS_CLI_LOGGING_STDOUT=true repo_local_root.clone(), user_home.clone(), BTreeMap::from([( - "RADROOTS_CLI_PUBLISH_TRANSPORT".to_owned(), - "radrootsd_proxy".to_owned(), + "RADROOTS_CLI_TRANSPORT_PROFILE".to_owned(), + "proxy".to_owned(), )]), ); - let args = RuntimeInvocationArgs { - publish_transport: Some("direct_nostr_relay".to_owned()), - ..runtime_args() - }; - let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) - .expect("resolve flag publish transport"); + let resolved = + RuntimeConfig::resolve_with_env_file(&runtime_args(), &env, &EnvFileValues::default()) + .expect("resolve environment transport profile"); + assert_eq!(resolved.transport.profile, TransportProfileKind::Proxy); + assert_eq!( + resolved.transport.source, + TransportConfigSource::Environment + ); assert_eq!( resolved.publish, PublishConfig { - transport: PublishTransport::DirectNostrRelay, - source: PublishTransportSource::Flags, - radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(), + transport: PublishTransport::Proxy, + source: PublishTransportSource::Environment, + proxy: crate::runtime::config::ProxyTransportConfig::default(), } ); @@ -2750,8 +3008,8 @@ RADROOTS_CLI_LOGGING_STDOUT=true repo_local_root.clone(), user_home.clone(), BTreeMap::from([( - "RADROOTS_CLI_PUBLISH_TRANSPORT".to_owned(), - "radrootsd_proxy".to_owned(), + "RADROOTS_CLI_TRANSPORT_PROFILE".to_owned(), + "proxy".to_owned(), )]), ); let resolved = @@ -2760,9 +3018,9 @@ RADROOTS_CLI_LOGGING_STDOUT=true assert_eq!( resolved.publish, PublishConfig { - transport: PublishTransport::RadrootsdProxy, + transport: PublishTransport::Proxy, source: PublishTransportSource::Environment, - radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(), + proxy: crate::runtime::config::ProxyTransportConfig::default(), } ); @@ -2774,13 +3032,15 @@ RADROOTS_CLI_LOGGING_STDOUT=true ); let resolved = RuntimeConfig::resolve_with_env_file(&runtime_args(), &env, &EnvFileValues::default()) - .expect("resolve user publish transport"); + .expect("resolve user transport profile"); + assert_eq!(resolved.transport.profile, TransportProfileKind::Nostr); + assert_eq!(resolved.transport.source, TransportConfigSource::UserConfig); assert_eq!( resolved.publish, PublishConfig { - transport: PublishTransport::DirectNostrRelay, + transport: PublishTransport::Nostr, source: PublishTransportSource::UserConfig, - radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(), + proxy: crate::runtime::config::ProxyTransportConfig::default(), } ); @@ -2793,13 +3053,18 @@ RADROOTS_CLI_LOGGING_STDOUT=true ); let resolved = RuntimeConfig::resolve_with_env_file(&runtime_args(), &env, &EnvFileValues::default()) - .expect("resolve workspace publish transport"); + .expect("resolve workspace transport profile"); + assert_eq!(resolved.transport.profile, TransportProfileKind::Proxy); + assert_eq!( + resolved.transport.source, + TransportConfigSource::WorkspaceConfig + ); assert_eq!( resolved.publish, PublishConfig { - transport: PublishTransport::RadrootsdProxy, + transport: PublishTransport::Proxy, source: PublishTransportSource::WorkspaceConfig, - radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(), + proxy: crate::runtime::config::ProxyTransportConfig::default(), } ); @@ -2807,13 +3072,15 @@ RADROOTS_CLI_LOGGING_STDOUT=true let env = repo_local_env(workspace_root, repo_local_root, user_home, BTreeMap::new()); let resolved = RuntimeConfig::resolve_with_env_file(&runtime_args(), &env, &EnvFileValues::default()) - .expect("resolve default publish transport"); + .expect("resolve default transport profile"); + assert_eq!(resolved.transport.profile, TransportProfileKind::LocalOnly); + assert_eq!(resolved.transport.source, TransportConfigSource::Defaults); assert_eq!( resolved.publish, PublishConfig { - transport: PublishTransport::DirectNostrRelay, + transport: PublishTransport::Nostr, source: PublishTransportSource::Defaults, - radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(), + proxy: crate::runtime::config::ProxyTransportConfig::default(), } ); } @@ -3001,7 +3268,7 @@ RADROOTS_CLI_LOGGING_STDOUT=true } #[test] - fn invalid_publish_config_reports_config_source() { + fn invalid_transport_config_reports_config_source() { let temp = tempdir().expect("tempdir"); let workspace_root = temp.path().join("workspace"); let repo_local_root = workspace_root.join("infra/local/runtime/radroots"); @@ -3009,18 +3276,18 @@ RADROOTS_CLI_LOGGING_STDOUT=true fs::create_dir_all(&repo_local_root).expect("workspace config dir"); fs::write( repo_local_root.join("config.toml"), - "[publish]\ntransport = \"nostr\"\n", + "[transport]\nprofile = \"relay\"\n", ) .expect("write workspace config"); let env = repo_local_env(workspace_root, repo_local_root, user_home, BTreeMap::new()); let error = RuntimeConfig::resolve_with_env_file(&runtime_args(), &env, &EnvFileValues::default()) - .expect_err("invalid publish transport"); + .expect_err("invalid transport profile"); let message = error.to_string(); - assert!(message.contains("workspace config [publish].transport")); - assert!(message.contains("direct_nostr_relay")); - assert!(message.contains("radrootsd_proxy")); + assert!(message.contains("workspace config [transport].profile")); + assert!(message.contains("local_only")); + assert!(message.contains("reticulum_preview")); } #[test] @@ -3110,7 +3377,7 @@ target = "bin/user-hyfd" r#" [[capability_binding]] capability = "write_plane.trade_jsonrpc" -provider = "radrootsd" + provider = "legacy_proxy" target_kind = "explicit_endpoint" target = "https://rpc.workspace.test/jsonrpc" "#, @@ -3179,11 +3446,17 @@ target = "workflow-default" "wss://relay.example:abc", " ", ] { - let args = RuntimeInvocationArgs { - relay: vec![relay.to_owned()], - ..runtime_args() - }; - let env = MapEnvironment::new(BTreeMap::new()); + let args = runtime_args(); + let env = MapEnvironment::new(BTreeMap::from([ + ( + "RADROOTS_CLI_TRANSPORT_PROFILE".to_owned(), + "nostr".to_owned(), + ), + ( + "RADROOTS_CLI_TRANSPORT_NOSTR_RELAY_URLS".to_owned(), + relay.to_owned(), + ), + ])); let error = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) .expect_err("invalid relay url"); @@ -3197,10 +3470,16 @@ target = "workflow-default" #[test] fn relay_env_value_rejects_empty_entries() { - let env = MapEnvironment::new(BTreeMap::from([( - super::ENV_CLI_RELAYS_URLS.to_owned(), - "wss://relay.example,,wss://relay-two.example".to_owned(), - )])); + let env = MapEnvironment::new(BTreeMap::from([ + ( + "RADROOTS_CLI_TRANSPORT_PROFILE".to_owned(), + "nostr".to_owned(), + ), + ( + super::ENV_CLI_TRANSPORT_NOSTR_RELAY_URLS.to_owned(), + "wss://relay.example,,wss://relay-two.example".to_owned(), + ), + ])); let error = RuntimeConfig::resolve_with_env_file(&runtime_args(), &env, &EnvFileValues::default()) .expect_err("empty relay entry"); @@ -3210,15 +3489,21 @@ target = "workflow-default" #[test] fn valid_ipv6_relay_url_resolves() { - let args = RuntimeInvocationArgs { - relay: vec![" wss://[2001:db8::1]:443/relay ".to_owned()], - ..runtime_args() - }; - let env = MapEnvironment::new(BTreeMap::new()); + let args = runtime_args(); + let env = MapEnvironment::new(BTreeMap::from([ + ( + "RADROOTS_CLI_TRANSPORT_PROFILE".to_owned(), + "nostr".to_owned(), + ), + ( + "RADROOTS_CLI_TRANSPORT_NOSTR_RELAY_URLS".to_owned(), + " ws://[::1]:443/relay ".to_owned(), + ), + ])); let config = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) .expect("valid relay url"); - assert_eq!(config.relay.urls, vec!["wss://[2001:db8::1]:443/relay"]); + assert_eq!(config.relay.urls, vec!["ws://[::1]:443/relay"]); } #[test] @@ -3479,7 +3764,7 @@ RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT=.local/radroots/dev ("[signer]\nmode = \"local\"\n", "unknown field `mode`"), ( "[relays]\nurls = [\"wss://relay.example\"]\nextra = true\n", - "unknown field `extra`", + "unknown field `relays`", ), ] { fs::write(repo_local_root.join("config.toml"), raw).expect("write config"); diff --git a/src/runtime/farm.rs b/src/runtime/farm.rs @@ -15,7 +15,7 @@ use radroots_sdk::{ FarmPrivateLocationClearRequest, FarmPrivateLocationInput, FarmPrivateLocationLookupCandidate, FarmPrivateLocationReceipt, FarmPrivateLocationSetRequest, FarmPrivateLocationSetResult, FarmPublishPlan, GeocoderLocalityQuery, PushOutboxEventReceipt, PushOutboxEventState, - PushOutboxReceipt, PushOutboxRelayOutcomeKind, PushOutboxRequest, SdkExactLocation, + PushOutboxReceipt, PushOutboxRequest, PushOutboxTargetOutcomeKind, SdkExactLocation, SdkMutationState, }; use serde_json::json; @@ -34,7 +34,7 @@ use crate::runtime::farm_config::{ }; use crate::runtime::local_events::append_local_work; use crate::runtime::sdk::{ - CliSdkAdapterError, CliSdkSession, sdk_relay_target_policy, sdk_relay_url_policy, + CliSdkAdapterError, CliSdkSession, sdk_nostr_relay_url_policy, sdk_target_policy, validate_configured_signer_for_actor, }; use crate::runtime::signer::ActorWriteBindingError; @@ -621,17 +621,16 @@ fn relay_farm_publish_readiness( config: &RuntimeConfig, account: &AccountRecordView, ) -> FarmPublishReadiness { - if matches!(config.publish.transport, PublishTransport::DirectNostrRelay) - && config.relay.urls.is_empty() - { + if matches!(config.publish.transport, PublishTransport::Nostr) && config.relay.urls.is_empty() { return FarmPublishReadiness { state: "unconfigured", executable: false, - reason: Some( - "direct_nostr_relay farm publish requires at least one configured relay".to_owned(), - ), - missing: vec!["Configured relay".to_owned()], - actions: vec!["radroots --relay wss://relay.example.com farm publish".to_owned()], + reason: Some("farm publish requires a configured Nostr transport profile".to_owned()), + missing: vec!["Configured Nostr transport profile".to_owned()], + actions: vec![ + "radroots transport profile set --kind nostr --nostr-relay wss://relay.example.com" + .to_owned(), + ], }; } @@ -816,7 +815,7 @@ fn publish_via_sdk( "farm seller", )?; let mut request = - FarmEnqueuePublishRequest::new(input.actor, input.farm, sdk_relay_target_policy(config)); + FarmEnqueuePublishRequest::new(input.actor, input.farm, sdk_target_policy(config)); if let Some(idempotency_key) = farm_idempotency_key.as_deref() { request = request.try_with_idempotency_key(idempotency_key)?; } @@ -825,7 +824,7 @@ fn publish_via_sdk( session.sdk().sync().push_outbox( PushOutboxRequest::new() .with_limit(1) - .with_relay_url_policy(sdk_relay_url_policy(config)), + .with_nostr_relay_url_policy(sdk_nostr_relay_url_policy(config)), ), )?; let view = sdk_enqueued_publish_view( @@ -1233,71 +1232,72 @@ fn sdk_publish_actions(push_event: Option<&PushOutboxEventReceipt>) -> Vec<Strin fn sdk_push_target_relays(event: &PushOutboxEventReceipt) -> Vec<String> { event - .relays + .targets .iter() - .map(|relay| relay.relay_url.clone()) + .map(|target| target.endpoint_uri.clone()) .collect() } fn sdk_push_connected_relays(event: &PushOutboxEventReceipt) -> Vec<String> { event - .relays + .targets .iter() - .filter(|relay| relay.attempted) - .map(|relay| relay.relay_url.clone()) + .filter(|target| target.attempted) + .map(|target| target.endpoint_uri.clone()) .collect() } fn sdk_push_acknowledged_relays(event: &PushOutboxEventReceipt) -> Vec<String> { event - .relays + .targets .iter() - .filter(|relay| { + .filter(|target| { matches!( - relay.outcome_kind, - PushOutboxRelayOutcomeKind::Accepted - | PushOutboxRelayOutcomeKind::DuplicateAccepted + target.outcome_kind, + PushOutboxTargetOutcomeKind::Accepted + | PushOutboxTargetOutcomeKind::DuplicateAccepted ) }) - .map(|relay| relay.relay_url.clone()) + .map(|target| target.endpoint_uri.clone()) .collect() } fn sdk_push_failed_relays(event: &PushOutboxEventReceipt) -> Vec<RelayFailureView> { event - .relays + .targets .iter() - .filter(|relay| { + .filter(|target| { !matches!( - relay.outcome_kind, - PushOutboxRelayOutcomeKind::Accepted - | PushOutboxRelayOutcomeKind::DuplicateAccepted + target.outcome_kind, + PushOutboxTargetOutcomeKind::Accepted + | PushOutboxTargetOutcomeKind::DuplicateAccepted ) }) - .map(|relay| RelayFailureView { - relay: relay.relay_url.clone(), - reason: relay + .map(|target| RelayFailureView { + relay: target.endpoint_uri.clone(), + reason: target .message .clone() - .unwrap_or_else(|| sdk_relay_outcome_kind(relay.outcome_kind).to_owned()), + .unwrap_or_else(|| sdk_target_outcome_kind(target.outcome_kind).to_owned()), }) .collect() } -fn sdk_relay_outcome_kind(kind: PushOutboxRelayOutcomeKind) -> &'static str { +fn sdk_target_outcome_kind(kind: PushOutboxTargetOutcomeKind) -> &'static str { match kind { - PushOutboxRelayOutcomeKind::Accepted => "accepted", - PushOutboxRelayOutcomeKind::DuplicateAccepted => "duplicate_accepted", - PushOutboxRelayOutcomeKind::Blocked => "blocked", - PushOutboxRelayOutcomeKind::RateLimited => "rate_limited", - PushOutboxRelayOutcomeKind::Invalid => "invalid", - PushOutboxRelayOutcomeKind::PowRequired => "pow_required", - PushOutboxRelayOutcomeKind::Restricted => "restricted", - PushOutboxRelayOutcomeKind::AuthRequired => "auth_required", - PushOutboxRelayOutcomeKind::Error => "error", - PushOutboxRelayOutcomeKind::Timeout => "timeout", - PushOutboxRelayOutcomeKind::ConnectionFailed => "connection_failed", - PushOutboxRelayOutcomeKind::Unknown => "unknown", + PushOutboxTargetOutcomeKind::Accepted => "accepted", + PushOutboxTargetOutcomeKind::DuplicateAccepted => "duplicate_accepted", + PushOutboxTargetOutcomeKind::Blocked => "blocked", + PushOutboxTargetOutcomeKind::RateLimited => "rate_limited", + PushOutboxTargetOutcomeKind::Invalid => "invalid", + PushOutboxTargetOutcomeKind::PowRequired => "pow_required", + PushOutboxTargetOutcomeKind::Restricted => "restricted", + PushOutboxTargetOutcomeKind::AuthRequired => "auth_required", + PushOutboxTargetOutcomeKind::Error => "error", + PushOutboxTargetOutcomeKind::Timeout => "timeout", + PushOutboxTargetOutcomeKind::ConnectionFailed => "connection_failed", + PushOutboxTargetOutcomeKind::TargetUriRejected => "target_uri_rejected", + PushOutboxTargetOutcomeKind::Unknown => "unknown", _ => "unknown", } } diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs @@ -28,7 +28,7 @@ use radroots_replica_db::ReplicaSql; use radroots_sdk::{ ListingEnqueuePublishRequest, ListingEnqueueReceipt, ListingPreparePublishRequest, ListingPublishPlan, PushOutboxEventReceipt, PushOutboxEventState, PushOutboxReceipt, - PushOutboxRelayOutcomeKind, PushOutboxRequest, SdkMutationState, + PushOutboxRequest, PushOutboxTargetOutcomeKind, SdkMutationState, }; use radroots_sql_core::SqliteExecutor; use radroots_trade::listing::{RadrootsListingDraftDocumentV1, validation::validate_listing_event}; @@ -48,7 +48,7 @@ use crate::runtime::local_events::{ shared_local_events_db_path, }; use crate::runtime::sdk::{ - CliSdkAdapterError, CliSdkSession, sdk_relay_target_policy, sdk_relay_url_policy, + CliSdkAdapterError, CliSdkSession, sdk_nostr_relay_url_policy, sdk_target_policy, validate_configured_signer_for_actor, }; use crate::runtime::sync::{ @@ -1769,7 +1769,7 @@ pub fn publish_via_sdk( let mut request = ListingEnqueuePublishRequest::from_document( input.actor, input.document, - sdk_relay_target_policy(config), + sdk_target_policy(config), ); if let Some(idempotency_key) = args.idempotency_key.as_deref() { request = request.try_with_idempotency_key(idempotency_key)?; @@ -1783,7 +1783,7 @@ pub fn publish_via_sdk( session.sdk().sync().push_outbox( PushOutboxRequest::new() .with_limit(1) - .with_relay_url_policy(sdk_relay_url_policy(config)), + .with_nostr_relay_url_policy(sdk_nostr_relay_url_policy(config)), ), )?, ) @@ -2020,71 +2020,72 @@ fn sdk_publish_actions( fn sdk_push_target_relays(event: &PushOutboxEventReceipt) -> Vec<String> { event - .relays + .targets .iter() - .map(|relay| relay.relay_url.clone()) + .map(|target| target.endpoint_uri.clone()) .collect() } fn sdk_push_connected_relays(event: &PushOutboxEventReceipt) -> Vec<String> { event - .relays + .targets .iter() - .filter(|relay| relay.attempted) - .map(|relay| relay.relay_url.clone()) + .filter(|target| target.attempted) + .map(|target| target.endpoint_uri.clone()) .collect() } fn sdk_push_acknowledged_relays(event: &PushOutboxEventReceipt) -> Vec<String> { event - .relays + .targets .iter() - .filter(|relay| { + .filter(|target| { matches!( - relay.outcome_kind, - PushOutboxRelayOutcomeKind::Accepted - | PushOutboxRelayOutcomeKind::DuplicateAccepted + target.outcome_kind, + PushOutboxTargetOutcomeKind::Accepted + | PushOutboxTargetOutcomeKind::DuplicateAccepted ) }) - .map(|relay| relay.relay_url.clone()) + .map(|target| target.endpoint_uri.clone()) .collect() } fn sdk_push_failed_relays(event: &PushOutboxEventReceipt) -> Vec<RelayFailureView> { event - .relays + .targets .iter() - .filter(|relay| { + .filter(|target| { !matches!( - relay.outcome_kind, - PushOutboxRelayOutcomeKind::Accepted - | PushOutboxRelayOutcomeKind::DuplicateAccepted + target.outcome_kind, + PushOutboxTargetOutcomeKind::Accepted + | PushOutboxTargetOutcomeKind::DuplicateAccepted ) }) - .map(|relay| RelayFailureView { - relay: relay.relay_url.clone(), - reason: relay + .map(|target| RelayFailureView { + relay: target.endpoint_uri.clone(), + reason: target .message .clone() - .unwrap_or_else(|| sdk_relay_outcome_kind(relay.outcome_kind).to_owned()), + .unwrap_or_else(|| sdk_target_outcome_kind(target.outcome_kind).to_owned()), }) .collect() } -fn sdk_relay_outcome_kind(kind: PushOutboxRelayOutcomeKind) -> &'static str { +fn sdk_target_outcome_kind(kind: PushOutboxTargetOutcomeKind) -> &'static str { match kind { - PushOutboxRelayOutcomeKind::Accepted => "accepted", - PushOutboxRelayOutcomeKind::DuplicateAccepted => "duplicate_accepted", - PushOutboxRelayOutcomeKind::Blocked => "blocked", - PushOutboxRelayOutcomeKind::RateLimited => "rate_limited", - PushOutboxRelayOutcomeKind::Invalid => "invalid", - PushOutboxRelayOutcomeKind::PowRequired => "pow_required", - PushOutboxRelayOutcomeKind::Restricted => "restricted", - PushOutboxRelayOutcomeKind::AuthRequired => "auth_required", - PushOutboxRelayOutcomeKind::Error => "error", - PushOutboxRelayOutcomeKind::Timeout => "timeout", - PushOutboxRelayOutcomeKind::ConnectionFailed => "connection_failed", - PushOutboxRelayOutcomeKind::Unknown => "unknown", + PushOutboxTargetOutcomeKind::Accepted => "accepted", + PushOutboxTargetOutcomeKind::DuplicateAccepted => "duplicate_accepted", + PushOutboxTargetOutcomeKind::Blocked => "blocked", + PushOutboxTargetOutcomeKind::RateLimited => "rate_limited", + PushOutboxTargetOutcomeKind::Invalid => "invalid", + PushOutboxTargetOutcomeKind::PowRequired => "pow_required", + PushOutboxTargetOutcomeKind::Restricted => "restricted", + PushOutboxTargetOutcomeKind::AuthRequired => "auth_required", + PushOutboxTargetOutcomeKind::Error => "error", + PushOutboxTargetOutcomeKind::Timeout => "timeout", + PushOutboxTargetOutcomeKind::ConnectionFailed => "connection_failed", + PushOutboxTargetOutcomeKind::TargetUriRejected => "target_uri_rejected", + PushOutboxTargetOutcomeKind::Unknown => "unknown", _ => "unknown", } } @@ -2185,11 +2186,8 @@ fn mutate_via_sdk_from_canonical( canonical.seller_pubkey.as_str(), "listing seller", )?; - let mut request = ListingEnqueuePublishRequest::from_document( - actor, - document, - sdk_relay_target_policy(config), - ); + let mut request = + ListingEnqueuePublishRequest::from_document(actor, document, sdk_target_policy(config)); if let Some(idempotency_key) = args.idempotency_key.as_deref() { request = request.try_with_idempotency_key(idempotency_key)?; } @@ -2202,7 +2200,7 @@ fn mutate_via_sdk_from_canonical( session.sdk().sync().push_outbox( PushOutboxRequest::new() .with_limit(1) - .with_relay_url_policy(sdk_relay_url_policy(config)), + .with_nostr_relay_url_policy(sdk_nostr_relay_url_policy(config)), ), )?, ) @@ -3151,8 +3149,8 @@ mod tests { use radroots_events::ids::RadrootsEventId; use radroots_events_codec::d_tag::is_d_tag_base64url; use radroots_sdk::{ - PushOutboxEventReceipt, PushOutboxEventState, PushOutboxRelayOutcomeKind, - PushOutboxRelayReceipt, + PushOutboxEventReceipt, PushOutboxEventState, PushOutboxTargetOutcomeKind, + PushOutboxTargetReceipt, }; #[test] @@ -3172,7 +3170,7 @@ mod tests { fn sdk_push_receipt_helpers_map_published_and_auth_required_states() { let accepted = sdk_push_event( PushOutboxEventState::Published, - PushOutboxRelayOutcomeKind::Accepted, + PushOutboxTargetOutcomeKind::Accepted, Some("accepted".to_owned()), ); let args = listing_mutation_args(false); @@ -3188,7 +3186,7 @@ mod tests { let auth_required = sdk_push_event( PushOutboxEventState::PublishRetryable, - PushOutboxRelayOutcomeKind::AuthRequired, + PushOutboxTargetOutcomeKind::AuthRequired, Some("auth required".to_owned()), ); let failed = sdk_push_failed_relays(&auth_required); @@ -3349,7 +3347,7 @@ mod tests { fn sdk_push_event( final_state: PushOutboxEventState, - outcome_kind: PushOutboxRelayOutcomeKind, + outcome_kind: PushOutboxTargetOutcomeKind, message: Option<String>, ) -> PushOutboxEventReceipt { PushOutboxEventReceipt { @@ -3359,24 +3357,25 @@ mod tests { attempted_count: 1, accepted_count: usize::from(matches!( outcome_kind, - PushOutboxRelayOutcomeKind::Accepted - | PushOutboxRelayOutcomeKind::DuplicateAccepted + PushOutboxTargetOutcomeKind::Accepted + | PushOutboxTargetOutcomeKind::DuplicateAccepted )), retryable_count: usize::from(matches!( outcome_kind, - PushOutboxRelayOutcomeKind::AuthRequired - | PushOutboxRelayOutcomeKind::Timeout - | PushOutboxRelayOutcomeKind::ConnectionFailed + PushOutboxTargetOutcomeKind::AuthRequired + | PushOutboxTargetOutcomeKind::Timeout + | PushOutboxTargetOutcomeKind::ConnectionFailed )), terminal_count: 0, quorum: 1, quorum_met: matches!( outcome_kind, - PushOutboxRelayOutcomeKind::Accepted - | PushOutboxRelayOutcomeKind::DuplicateAccepted + PushOutboxTargetOutcomeKind::Accepted + | PushOutboxTargetOutcomeKind::DuplicateAccepted ), - relays: vec![PushOutboxRelayReceipt { - relay_url: "ws://127.0.0.1:19000".to_owned(), + targets: vec![PushOutboxTargetReceipt { + transport_kind: "nostr".to_owned(), + endpoint_uri: "ws://127.0.0.1:19000".to_owned(), outcome_kind, attempted: true, message, diff --git a/src/runtime/local_events.rs b/src/runtime/local_events.rs @@ -51,8 +51,6 @@ pub fn append_local_work( event_sig: None, raw_event_json: None, outbox_status: PublishOutboxStatus::None, - relay_set_fingerprint: None, - relay_delivery_json: None, }; let store = open_store(config)?; Ok(store.append_record(&input)?) diff --git a/src/runtime/mesh.rs b/src/runtime/mesh.rs @@ -0,0 +1,76 @@ +use serde_json::Value as JsonValue; +use toml::{Value, map::Map}; + +use crate::ops::OperationData; +use crate::runtime::RuntimeError; +use crate::runtime::config::RuntimeConfig; +use crate::runtime::transport::update_app_config_table; +use crate::view::runtime::{MeshPolicyCheckView, MeshScopeView, MeshStatusView}; + +const MESH_SOURCE: &str = "mesh config"; + +pub fn scope(config: &RuntimeConfig) -> MeshScopeView { + scope_view(config.mesh.scope.as_str(), "ready") +} + +pub fn set_scope( + config: &RuntimeConfig, + input: &OperationData, +) -> Result<MeshScopeView, RuntimeError> { + let scope = input + .get("scope") + .and_then(JsonValue::as_str) + .unwrap_or("disabled"); + match scope { + "disabled" | "local_preview" => {} + other => { + return Err(RuntimeError::Config(format!( + "mesh scope `{other}` is not supported" + ))); + } + } + let mut mesh = Map::new(); + mesh.insert("scope".to_owned(), Value::String(scope.to_owned())); + update_app_config_table(config, "mesh", Value::Table(mesh))?; + Ok(scope_view(scope, "configured")) +} + +pub fn status(config: &RuntimeConfig) -> MeshStatusView { + let scope = config.mesh.scope.as_str(); + MeshStatusView { + state: "ready".to_owned(), + source: MESH_SOURCE.to_owned(), + scope: scope.to_owned(), + transport_kind: "reticulum".to_owned(), + configured_state: scope.to_owned(), + implementation_state: "preview_unavailable".to_owned(), + usable_for_delivery: false, + message: "Reticulum mesh preview is explicit and unavailable for real delivery".to_owned(), + } +} + +pub fn policy_check(config: &RuntimeConfig) -> MeshPolicyCheckView { + MeshPolicyCheckView { + state: "ready".to_owned(), + source: MESH_SOURCE.to_owned(), + scope: config.mesh.scope.as_str().to_owned(), + policy: "reticulum_preview_delivery".to_owned(), + transport_kind: "reticulum".to_owned(), + usable_for_delivery: false, + decision: "reject_delivery_attempt".to_owned(), + message: "Reticulum preview never falls back to Nostr and cannot deliver real events" + .to_owned(), + } +} + +fn scope_view(scope: &str, state: &str) -> MeshScopeView { + MeshScopeView { + state: state.to_owned(), + source: MESH_SOURCE.to_owned(), + scope: scope.to_owned(), + implementation_state: "preview_unavailable".to_owned(), + message: "Mesh delivery is disabled unless a preview scope is explicitly configured" + .to_owned(), + actions: vec!["radroots mesh policy check".to_owned()], + } +} diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs @@ -7,7 +7,7 @@ pub mod hyf; pub mod listing; pub mod local_events; pub mod logging; -pub mod network; +pub mod mesh; pub mod order; pub mod paths; pub mod provider; @@ -15,6 +15,7 @@ pub mod sdk; pub mod signer; pub mod store; pub mod sync; +pub mod transport; pub mod validation_receipt; use std::process::ExitCode; diff --git a/src/runtime/network.rs b/src/runtime/network.rs @@ -1,42 +0,0 @@ -use crate::runtime::config::RuntimeConfig; -use crate::view::runtime::{RelayEntryView, RelayListView}; - -pub fn relay_list(config: &RuntimeConfig) -> RelayListView { - let relays = config - .relay - .urls - .iter() - .cloned() - .map(|url| RelayEntryView { - url, - read: true, - write: true, - }) - .collect::<Vec<_>>(); - - let state = if relays.is_empty() { - "unconfigured" - } else { - "configured" - }; - - RelayListView { - state: state.to_owned(), - source: config.relay.source.as_str().to_owned(), - publish_policy: config.relay.publish_policy.as_str().to_owned(), - count: relays.len(), - reason: relays - .is_empty() - .then_some("no relays are configured for this operator session".to_owned()), - relays, - actions: relay_actions(config), - } -} - -fn relay_actions(config: &RuntimeConfig) -> Vec<String> { - if config.relay.urls.is_empty() { - vec!["radroots --relay wss://relay.example.com relay list".to_owned()] - } else { - Vec::new() - } -} diff --git a/src/runtime/order.rs b/src/runtime/order.rs @@ -32,16 +32,13 @@ use radroots_events_codec::d_tag::is_d_tag_base64url; use radroots_events_codec::order::{order_event_context_from_tags, order_request_from_event}; use radroots_local_events::{ BUYER_ORDER_REQUEST_LOCAL_WORK_RECORD_KIND, LocalEventRecord, LocalRecordFamily, - LocalRecordStatus, PublishOutboxStatus, RelayDeliveryEvidence, RelayDeliveryState, - SourceRuntime, normalize_relay_urls, validate_supported_buyer_order_request_local_work_payload, + LocalRecordStatus, PublishOutboxStatus, SourceRuntime, + validate_supported_buyer_order_request_local_work_payload, }; use radroots_nostr::prelude::{ RadrootsNostrEvent, RadrootsNostrFilter, radroots_event_from_nostr, radroots_nostr_filter_tag, radroots_nostr_kind, }; -use radroots_relay_transport::{ - RadrootsRelayFetchFailure, RadrootsRelayFetchedEventsReceipt, RadrootsRelayTransportError, -}; use radroots_replica_db::{ ReplicaSql, ReplicaTradeProductSummaryRow, nostr_event_head, trade_product, }; @@ -52,19 +49,23 @@ use radroots_replica_db_schema::trade_product::{ ITradeProductFieldsFilter, ITradeProductFindMany, TradeProduct, }; use radroots_sdk::{ - AckPolicy, PrivacyPreflightConfirmation, ProductSensitivityField, PublishMode, - PushOutboxEventReceipt, PushOutboxEventState, PushOutboxReceipt, PushOutboxRelayOutcomeKind, - RelayResolutionPolicy, SdkMutationState, SdkTradeStatusSource, TradeAcceptRequest, - TradeCancelRequest, TradeCancellationPlan, TradeCancellationReceipt, TradeDecisionPlan, - TradeDecisionReceipt, TradeDeclineRequest, TradeEvidenceMode, TradeMutationOutcome, - TradeProposeRequest, TradeRevisionDecisionPlan, TradeRevisionDecisionReceipt, - TradeRevisionDecisionRequest, TradeRevisionProposalPlan, TradeRevisionProposalReceipt, - TradeRevisionProposalRequest, TradeStatusReceipt, TradeStatusRequest, TradeSubmitPlan, - TradeSubmitReceipt, TradeWorkflowEnqueueReceipt, + PrivacyPreflightConfirmation, ProductSensitivityField, PublishMode, PushOutboxEventReceipt, + PushOutboxEventState, PushOutboxReceipt, PushOutboxTargetOutcomeKind, SatisfactionPolicy, + SdkMutationState, SdkTradeStatusSource, TargetPolicy, TradeAcceptRequest, TradeCancelRequest, + TradeCancellationPlan, TradeCancellationReceipt, TradeDecisionPlan, TradeDecisionReceipt, + TradeDeclineRequest, TradeEvidenceMode, TradeMutationOutcome, TradeProposeRequest, + TradeRevisionDecisionPlan, TradeRevisionDecisionReceipt, TradeRevisionDecisionRequest, + TradeRevisionProposalPlan, TradeRevisionProposalReceipt, TradeRevisionProposalRequest, + TradeStatusReceipt, TradeStatusRequest, TradeSubmitPlan, TradeSubmitReceipt, + TradeWorkflowEnqueueReceipt, }; use radroots_sql_core::SqliteExecutor; use radroots_trade::identity::RadrootsTradeLocator; use radroots_trade::order::canonicalize_order_request_for_signer; +use radroots_transport_nostr::{ + RadrootsRelayFetchFailure, RadrootsRelayFetchedEventsReceipt, RadrootsRelayTransportError, + RadrootsRelayUrl, RadrootsRelayUrlPolicy, +}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -105,7 +106,7 @@ const ORDER_CANCELLATION_SOURCE: &str = "SDK trade cancellation · local key"; const ORDER_EVENT_LIST_SOURCE: &str = "shared relay transport fetch · selected seller identity"; const ORDER_STATUS_SDK_SOURCE: &str = "SDK local trade projection"; const ORDER_EVENT_LIST_RELAY_ACTION: &str = - "radroots --relay wss://relay.example.com trade event list"; + "radroots transport profile set --kind nostr --nostr-relay wss://relay.example.com"; const ORDER_BUYER_ACTOR_SOURCE_RESOLVED_ACCOUNT: &str = "resolved_account"; const ORDER_BUYER_ACTOR_SOURCE_REBIND: &str = "order_rebind"; const ORDER_APP_RECORD_LIST_LIMIT: u32 = 500; @@ -161,10 +162,10 @@ fn trade_publish_mode(config: &RuntimeConfig) -> PublishMode { } } -fn trade_ack_policy(mode: PublishMode) -> Result<AckPolicy, RuntimeError> { +fn trade_satisfaction_policy(mode: PublishMode) -> Result<SatisfactionPolicy, RuntimeError> { Ok(match mode { - PublishMode::DryRun | PublishMode::EnqueueOnly => AckPolicy::NoWait, - PublishMode::EnqueueAndPublish => AckPolicy::AtLeastOneRelay, + PublishMode::DryRun | PublishMode::EnqueueOnly => SatisfactionPolicy::NoWait, + PublishMode::EnqueueAndPublish => SatisfactionPolicy::AtLeastOneTarget, _ => { return Err(RuntimeError::Config( "unsupported SDK publish mode for CLI trade workflow".to_owned(), @@ -173,8 +174,8 @@ fn trade_ack_policy(mode: PublishMode) -> Result<AckPolicy, RuntimeError> { }) } -fn trade_relay_resolution_policy() -> RelayResolutionPolicy { - RelayResolutionPolicy::configured_relays() +fn trade_target_policy() -> TargetPolicy { + TargetPolicy::use_transport_profile() } fn trade_privacy_confirmation(confirm_public_note: bool) -> PrivacyPreflightConfirmation { @@ -860,7 +861,7 @@ pub fn app_record_export( app_order.loaded.document.order.order_id ), format!( - "radroots --relay wss://relay.example.com trade submit {}", + "radroots trade submit {}", app_order.loaded.document.order.order_id ), ], @@ -1167,7 +1168,7 @@ pub fn event_list( return Ok(order_event_list_unconfigured( None, ORDER_ACTOR_CONTEXT_NETWORK_ONLY, - "trade event list requires at least one configured relay".to_owned(), + "trade event list requires a configured Nostr transport profile".to_owned(), Vec::new(), vec![ORDER_EVENT_LIST_RELAY_ACTION.to_owned()], )); @@ -1314,7 +1315,7 @@ fn decide_trade_via_sdk( let status = trade_status_for_locator(config, &session, locator.clone())?; let status_view = sdk_order_status_view(status.clone()); let publish_mode = trade_publish_mode(config); - let ack_policy = trade_ack_policy(publish_mode)?; + let ack_policy = trade_satisfaction_policy(publish_mode)?; let outcome = match args.decision { TradeDecisionArg::Accept => { let commitments = inventory_commitments_from_status(&status)?; @@ -1322,7 +1323,7 @@ fn decide_trade_via_sdk( actor, locator, commitments, - trade_relay_resolution_policy(), + trade_target_policy(), publish_mode, ack_policy, TradeEvidenceMode::ResyncBeforeMutation, @@ -1346,7 +1347,7 @@ fn decide_trade_via_sdk( actor, locator, reason, - trade_relay_resolution_policy(), + trade_target_policy(), publish_mode, ack_policy, TradeEvidenceMode::ResyncBeforeMutation, @@ -1378,7 +1379,7 @@ fn propose_revision_via_sdk( let status_view = sdk_order_status_view(status); let revision = revision_request_parts_from_status(args, &status_view)?; let publish_mode = trade_publish_mode(config); - let ack_policy = trade_ack_policy(publish_mode)?; + let ack_policy = trade_satisfaction_policy(publish_mode)?; let mut request = TradeRevisionProposalRequest::new( actor, locator, @@ -1386,7 +1387,7 @@ fn propose_revision_via_sdk( revision.items.clone(), revision.economics.clone(), args.reason.trim(), - trade_relay_resolution_policy(), + trade_target_policy(), publish_mode, ack_policy, TradeEvidenceMode::ResyncBeforeMutation, @@ -1441,13 +1442,13 @@ fn decide_revision_via_sdk( }, }; let publish_mode = trade_publish_mode(config); - let ack_policy = trade_ack_policy(publish_mode)?; + let ack_policy = trade_satisfaction_policy(publish_mode)?; let mut request = TradeRevisionDecisionRequest::new( actor, locator, revision_id, decision, - trade_relay_resolution_policy(), + trade_target_policy(), publish_mode, ack_policy, TradeEvidenceMode::ResyncBeforeMutation, @@ -1489,12 +1490,12 @@ fn cancel_trade_via_sdk( let status = trade_status_for_locator(config, &session, locator.clone())?; let status_view = sdk_order_status_view(status); let publish_mode = trade_publish_mode(config); - let ack_policy = trade_ack_policy(publish_mode)?; + let ack_policy = trade_satisfaction_policy(publish_mode)?; let mut request = TradeCancelRequest::new( actor, locator, args.reason.trim(), - trade_relay_resolution_policy(), + trade_target_policy(), publish_mode, ack_policy, TradeEvidenceMode::ResyncBeforeMutation, @@ -2955,48 +2956,8 @@ fn resolve_shared_signed_listing_provenance( listing_addr: &str, listing_event_id: Option<&str>, ) -> Result<Option<SharedListingProvenance>, RuntimeError> { - let mut candidates = list_shared_records_latest(config, ORDER_APP_RECORD_LIST_LIMIT)? - .into_iter() - .filter(|record| record.family == LocalRecordFamily::SignedEvent) - .filter(|record| record.status == LocalRecordStatus::Published) - .filter(|record| record.event_kind == Some(i64::from(KIND_LISTING))) - .filter(|record| record.listing_addr.as_deref() == Some(listing_addr)) - .filter(|record| { - listing_event_id.is_none() || record.event_id.as_deref() == listing_event_id - }) - .filter_map(|record| { - let event_id = record.event_id?; - if !is_valid_event_id(event_id.as_str()) { - return None; - } - let delivery = record.relay_delivery_json.as_ref()?; - let evidence = RelayDeliveryEvidence::from_json_value(delivery).ok()?; - let relays = listing_provenance_relays_from_delivery_evidence(evidence).ok()?; - if relays.is_empty() { - return None; - } - Some(SharedListingProvenance { event_id, relays }) - }) - .collect::<Vec<_>>(); - candidates.sort_by(|left, right| left.event_id.cmp(&right.event_id)); - candidates.dedup_by(|left, right| left.event_id == right.event_id); - if candidates.len() > 1 && listing_event_id.is_none() { - return Err(RuntimeError::Config(format!( - "listing address `{listing_addr}` has multiple published shared local listing events; run `radroots market refresh` or pass a current listing event id source" - ))); - } - Ok(candidates.pop()) -} - -fn listing_provenance_relays_from_delivery_evidence( - evidence: RelayDeliveryEvidence, -) -> Result<Vec<String>, String> { - let relays = match evidence.state { - RelayDeliveryState::Acknowledged => evidence.acknowledged_relays, - RelayDeliveryState::Observed => evidence.observed_relays, - RelayDeliveryState::Pending | RelayDeliveryState::Failed => Vec::new(), - }; - normalize_listing_relay_set(relays) + let _ = (config, listing_addr, listing_event_id); + Ok(None) } fn trade_product_listing_addr_filter(listing_addr: &str) -> ITradeProductFieldsFilter { @@ -3948,10 +3909,7 @@ fn app_order_record_summary( vec![ format!("radroots trade get {}", document.order.order_id), format!("radroots trade app export {}", record.record_id), - format!( - "radroots --relay wss://relay.example.com trade submit {}", - document.order.order_id - ), + format!("radroots trade submit {}", document.order.order_id), ] } else if app_order_issue_present(&issues, APP_ORDER_ALREADY_SUBMITTED_ISSUE) { vec![format!( @@ -4894,7 +4852,7 @@ fn propose_trade_via_sdk( ) -> Result<OrderSubmitView, CliSdkAdapterError> { let actor = sdk_trade_actor(account, RadrootsActorRole::Buyer, "propose")?; let publish_mode = trade_publish_mode(config); - let ack_policy = trade_ack_policy(publish_mode)?; + let ack_policy = trade_satisfaction_policy(publish_mode)?; let economics = loaded.document.order.economics.clone().ok_or_else(|| { RuntimeError::Config("trade draft is missing quote economics".to_owned()) @@ -4922,7 +4880,7 @@ fn propose_trade_via_sdk( )?, items, economics, - trade_relay_resolution_policy(), + trade_target_policy(), publish_mode, ack_policy, ) @@ -5063,71 +5021,72 @@ fn sdk_order_submit_actions(push_event: Option<&PushOutboxEventReceipt>) -> Vec< fn sdk_push_target_relays(event: &PushOutboxEventReceipt) -> Vec<String> { event - .relays + .targets .iter() - .map(|relay| relay.relay_url.clone()) + .map(|target| target.endpoint_uri.clone()) .collect() } fn sdk_push_connected_relays(event: &PushOutboxEventReceipt) -> Vec<String> { event - .relays + .targets .iter() - .filter(|relay| relay.attempted) - .map(|relay| relay.relay_url.clone()) + .filter(|target| target.attempted) + .map(|target| target.endpoint_uri.clone()) .collect() } fn sdk_push_acknowledged_relays(event: &PushOutboxEventReceipt) -> Vec<String> { event - .relays + .targets .iter() - .filter(|relay| { + .filter(|target| { matches!( - relay.outcome_kind, - PushOutboxRelayOutcomeKind::Accepted - | PushOutboxRelayOutcomeKind::DuplicateAccepted + target.outcome_kind, + PushOutboxTargetOutcomeKind::Accepted + | PushOutboxTargetOutcomeKind::DuplicateAccepted ) }) - .map(|relay| relay.relay_url.clone()) + .map(|target| target.endpoint_uri.clone()) .collect() } fn sdk_push_failed_relays(event: &PushOutboxEventReceipt) -> Vec<RelayFailureView> { event - .relays + .targets .iter() - .filter(|relay| { + .filter(|target| { !matches!( - relay.outcome_kind, - PushOutboxRelayOutcomeKind::Accepted - | PushOutboxRelayOutcomeKind::DuplicateAccepted + target.outcome_kind, + PushOutboxTargetOutcomeKind::Accepted + | PushOutboxTargetOutcomeKind::DuplicateAccepted ) }) - .map(|relay| RelayFailureView { - relay: relay.relay_url.clone(), - reason: relay + .map(|target| RelayFailureView { + relay: target.endpoint_uri.clone(), + reason: target .message .clone() - .unwrap_or_else(|| sdk_relay_outcome_kind(relay.outcome_kind).to_owned()), + .unwrap_or_else(|| sdk_target_outcome_kind(target.outcome_kind).to_owned()), }) .collect() } -fn sdk_relay_outcome_kind(kind: PushOutboxRelayOutcomeKind) -> &'static str { +fn sdk_target_outcome_kind(kind: PushOutboxTargetOutcomeKind) -> &'static str { match kind { - PushOutboxRelayOutcomeKind::Accepted => "accepted", - PushOutboxRelayOutcomeKind::DuplicateAccepted => "duplicate_accepted", - PushOutboxRelayOutcomeKind::Blocked => "blocked", - PushOutboxRelayOutcomeKind::RateLimited => "rate_limited", - PushOutboxRelayOutcomeKind::Invalid => "invalid", - PushOutboxRelayOutcomeKind::PowRequired => "pow_required", - PushOutboxRelayOutcomeKind::Restricted => "restricted", - PushOutboxRelayOutcomeKind::AuthRequired => "auth_required", - PushOutboxRelayOutcomeKind::Error => "error", - PushOutboxRelayOutcomeKind::Timeout => "timeout", - PushOutboxRelayOutcomeKind::ConnectionFailed => "connection_failed", - PushOutboxRelayOutcomeKind::Unknown => "unknown", + PushOutboxTargetOutcomeKind::Accepted => "accepted", + PushOutboxTargetOutcomeKind::DuplicateAccepted => "duplicate_accepted", + PushOutboxTargetOutcomeKind::Blocked => "blocked", + PushOutboxTargetOutcomeKind::RateLimited => "rate_limited", + PushOutboxTargetOutcomeKind::Invalid => "invalid", + PushOutboxTargetOutcomeKind::PowRequired => "pow_required", + PushOutboxTargetOutcomeKind::Restricted => "restricted", + PushOutboxTargetOutcomeKind::AuthRequired => "auth_required", + PushOutboxTargetOutcomeKind::Error => "error", + PushOutboxTargetOutcomeKind::Timeout => "timeout", + PushOutboxTargetOutcomeKind::ConnectionFailed => "connection_failed", + PushOutboxTargetOutcomeKind::TargetUriRejected => "target_uri_rejected", + PushOutboxTargetOutcomeKind::Unknown => "unknown", _ => "unknown", } } @@ -5437,7 +5396,16 @@ where I: IntoIterator<Item = S>, S: AsRef<str>, { - normalize_relay_urls(values).map_err(|error| error.to_string()) + let mut normalized = Vec::new(); + for value in values { + let relay = RadrootsRelayUrl::parse(value.as_ref(), RadrootsRelayUrlPolicy::Public) + .map_err(|error| error.to_string())? + .into_string(); + if !normalized.contains(&relay) { + normalized.push(relay); + } + } + Ok(normalized) } fn order_listing_relays(document: &OrderDraftDocument) -> Vec<String> { diff --git a/src/runtime/provider.rs b/src/runtime/provider.rs @@ -96,15 +96,15 @@ pub fn resolve_write_plane_provider( publish: &PublishRuntimeView, ) -> WritePlaneProviderView { let (provider_runtime_id, binding_model, detail) = match config.publish.transport { - PublishTransport::DirectNostrRelay => ( - "direct_nostr_relay", - "direct_relay_publish", - "direct relay publish is selected; readiness is reported under publish", + PublishTransport::Nostr => ( + "nostr", + "nostr_transport", + "Nostr transport profile is selected; readiness is reported under publish", ), - PublishTransport::RadrootsdProxy => ( - "radrootsd_proxy", - "daemon_proxy_publish", - "radrootsd_proxy publish is selected; readiness is reported under publish", + PublishTransport::Proxy => ( + "proxy", + "proxy_transport", + "proxy transport profile is selected; readiness is reported under publish", ), }; WritePlaneProviderView { @@ -326,10 +326,11 @@ mod tests { signer: SignerConfig { backend: SignerBackend::Local, }, + transport: crate::runtime::config::TransportConfig::local_only(), publish: PublishConfig { - transport: PublishTransport::DirectNostrRelay, + transport: PublishTransport::Nostr, source: PublishTransportSource::Defaults, - radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(), + proxy: crate::runtime::config::ProxyTransportConfig::default(), }, relay: RelayConfig { urls: Vec::new(), @@ -350,6 +351,7 @@ mod tests { enabled: hyf_enabled, executable: PathBuf::from("hyfd"), }, + mesh: crate::runtime::config::MeshConfig::disabled(), rpc: RpcConfig { url: "http://127.0.0.1:7070".into(), }, @@ -388,23 +390,23 @@ mod tests { } #[test] - fn write_plane_provider_tracks_direct_relay_publish() { + fn write_plane_provider_tracks_nostr_transport_publish() { let config = sample_config(Vec::new(), false); let publish = publish_view( &config, "unconfigured", - Some("direct_nostr_relay publish transport requires a configured relay"), + Some("Nostr transport profile requires a configured Nostr relay"), ); let view = resolve_write_plane_provider(&config, &publish); - assert_eq!(view.provider_runtime_id, "direct_nostr_relay"); - assert_eq!(view.binding_model, "direct_relay_publish"); + assert_eq!(view.provider_runtime_id, "nostr"); + assert_eq!(view.binding_model, "nostr_transport"); assert_eq!(view.state, "unconfigured"); assert_eq!( view.provenance, ProviderProvenance::PublishTransport.as_str() ); assert!(view.target.is_none()); - assert!(view.detail.contains("configured relay")); + assert!(view.detail.contains("configured Nostr relay")); } #[test] diff --git a/src/runtime/sdk.rs b/src/runtime/sdk.rs @@ -1,4 +1,3 @@ -use std::fs; use std::future::Future; use std::path::PathBuf; use std::sync::Arc; @@ -15,18 +14,18 @@ use radroots_nostr_connect::prelude::{ RADROOTS_NOSTR_CONNECT_RPC_KIND, RadrootsNostrConnectBunkerUri, RadrootsNostrConnectClientTarget, RadrootsNostrConnectError, RadrootsNostrConnectUri, }; -use radroots_relay_transport::{ +use radroots_sdk::{ + NostrProfile, NostrRelayUrlPolicy, ProxyProfile, RadrootsClient, RadrootsClientBuilder, + RadrootsSdkError, RadrootsSdkLocalKeySigner, RadrootsSdkMycNip46RequestPolicy, + RadrootsSdkMycNip46Signer, RadrootsSdkNip46Transport, RadrootsSdkNip46TransportFuture, + RadrootsSdkSignerProvider, RadrootsSdkStorageConfig, + ReticulumPreviewBehavior as SdkReticulumPreviewBehavior, ReticulumPreviewProfile, TargetPolicy, + TransportProfile, +}; +use radroots_transport_nostr::{ RadrootsNostrClientFetchAdapter, RadrootsRelayFetchRequest, RadrootsRelayFetchedEventsReceipt, RadrootsRelayTransportError, fetch_relay_events_blocking, }; -use radroots_sdk::{ - RadrootsClient, RadrootsClientBuilder, RadrootsSdkError, RadrootsSdkLocalKeySigner, - RadrootsSdkMycNip46RequestPolicy, RadrootsSdkMycNip46Signer, RadrootsSdkNip46Transport, - RadrootsSdkNip46TransportFuture, RadrootsSdkSignerProvider, RadrootsSdkStorageConfig, - SdkPublishTransport, SdkRelayUrlPolicy, - adapters::radrootsd::{RadrootsdAuth, RadrootsdProxyConfig as SdkRadrootsdProxyConfig}, -}; -use radroots_secret_vault::{RadrootsSecretVault, RadrootsSecretVaultOsKeyring}; use tokio::runtime::{Builder as TokioRuntimeBuilder, Runtime}; use tokio::sync::{Mutex, broadcast}; use tokio::time::{Instant, timeout}; @@ -35,12 +34,11 @@ use url::Url; use crate::runtime::RuntimeError; use crate::runtime::account; use crate::runtime::config::{ - CapabilityBindingTargetKind, PublishTransport, RuntimeConfig, SIGNER_REMOTE_NIP46_CAPABILITY, - SignerBackend, + CapabilityBindingTargetKind, ReticulumPreviewBehavior, RuntimeConfig, + SIGNER_REMOTE_NIP46_CAPABILITY, SignerBackend, TransportProfileKind, }; const SDK_STORAGE_DIR_NAME: &str = "sdk"; -const RADROOTSD_PROXY_SECRET_SERVICE: &str = "org.radroots.cli.radrootsd-proxy"; const CLI_RELAY_FETCH_TIMEOUT_MS: u64 = 10_000; pub(crate) const MYC_NIP46_SESSION_SECRET_SERVICE: &str = "org.radroots.cli.myc-nip46-session"; @@ -56,9 +54,8 @@ pub enum CliSdkAdapterError { pub struct CliSdkConfig { pub storage_root: PathBuf, pub geonames_cache_root: PathBuf, - pub relay_url_policy: SdkRelayUrlPolicy, - pub relay_urls: Vec<String>, - pub publish_transport: SdkPublishTransport, + pub nostr_relay_url_policy: NostrRelayUrlPolicy, + pub transport_profile: TransportProfile, } impl CliSdkConfig { @@ -66,23 +63,18 @@ impl CliSdkConfig { Ok(Self { storage_root: sdk_storage_root(config), geonames_cache_root: config.paths.shared_cache_root.clone(), - relay_url_policy: sdk_relay_url_policy(config), - relay_urls: config.relay.urls.clone(), - publish_transport: sdk_publish_transport(config)?, + nostr_relay_url_policy: sdk_nostr_relay_url_policy(config), + transport_profile: sdk_transport_profile(config)?, }) } pub fn builder(&self) -> RadrootsClientBuilder { - self.relay_urls.iter().fold( - RadrootsClient::builder() - .storage(RadrootsSdkStorageConfig::Directory( - self.storage_root.clone(), - )) - .geonames_cache_root(self.geonames_cache_root.clone()) - .relay_url_policy(self.relay_url_policy) - .publish_transport(self.publish_transport.clone()), - |builder, relay_url| builder.relay_url(relay_url.clone()), - ) + RadrootsClient::builder() + .storage(RadrootsSdkStorageConfig::Directory( + self.storage_root.clone(), + )) + .geonames_cache_root(self.geonames_cache_root.clone()) + .transport_profile(self.transport_profile.clone()) } } @@ -606,86 +598,56 @@ pub(crate) fn fetch_relay_events_via_shared_transport( } fn memory_builder(config: &CliSdkConfig) -> RadrootsClientBuilder { - config.relay_urls.iter().fold( - RadrootsClient::builder() - .geonames_cache_root(config.geonames_cache_root.clone()) - .relay_url_policy(config.relay_url_policy) - .publish_transport(config.publish_transport.clone()), - |builder, relay_url| builder.relay_url(relay_url.clone()), - ) + RadrootsClient::builder() + .geonames_cache_root(config.geonames_cache_root.clone()) + .transport_profile(config.transport_profile.clone()) } -pub fn sdk_relay_url_policy(config: &RuntimeConfig) -> SdkRelayUrlPolicy { +pub fn sdk_nostr_relay_url_policy(config: &RuntimeConfig) -> NostrRelayUrlPolicy { if config - .relay - .urls + .transport + .nostr_relay_urls .iter() .any(|relay_url| relay_url.starts_with("ws://")) { - SdkRelayUrlPolicy::Localhost + NostrRelayUrlPolicy::Localhost } else { - SdkRelayUrlPolicy::Public + NostrRelayUrlPolicy::Public } } -pub fn sdk_relay_target_policy(config: &RuntimeConfig) -> radroots_sdk::SdkRelayTargetPolicy { - match config.publish.transport { - PublishTransport::DirectNostrRelay => { - radroots_sdk::SdkRelayTargetPolicy::UseConfiguredRelays - } - PublishTransport::RadrootsdProxy => { - radroots_sdk::SdkRelayTargetPolicy::use_publish_transport() - } - } +pub fn sdk_target_policy(_config: &RuntimeConfig) -> TargetPolicy { + TargetPolicy::use_transport_profile() } -fn sdk_publish_transport(config: &RuntimeConfig) -> Result<SdkPublishTransport, RuntimeError> { - match config.publish.transport { - PublishTransport::DirectNostrRelay => Ok(SdkPublishTransport::DirectNostrRelay), - PublishTransport::RadrootsdProxy => { - let mut proxy_config = - SdkRadrootsdProxyConfig::new(config.publish.radrootsd_proxy.url.clone()); - if let Some(auth) = radrootsd_proxy_auth(config)? { - proxy_config = proxy_config.with_auth(auth); - } - Ok(SdkPublishTransport::RadrootsdProxy(proxy_config)) +fn sdk_transport_profile(config: &RuntimeConfig) -> Result<TransportProfile, RuntimeError> { + match config.transport.profile { + TransportProfileKind::LocalOnly => Ok(TransportProfile::local_only()), + TransportProfileKind::Nostr => { + let profile = NostrProfile::new( + config.transport.nostr_relay_urls.iter().map(String::as_str), + sdk_nostr_relay_url_policy(config), + ) + .map_err(|error| RuntimeError::Config(error.to_string()))?; + Ok(TransportProfile::nostr(profile)) } - } -} - -fn radrootsd_proxy_auth(config: &RuntimeConfig) -> Result<Option<RadrootsdAuth>, RuntimeError> { - let proxy = &config.publish.radrootsd_proxy; - let token = if let Some(path) = proxy.token_file.as_ref() { - fs::read_to_string(path).map_err(|error| { - RuntimeError::Config(format!( - "failed to read radrootsd proxy token file {}: {error}", - path.display() + TransportProfileKind::ReticulumPreview => { + let behavior = match config.transport.reticulum_preview_behavior { + ReticulumPreviewBehavior::RejectDeliveryAttempts => { + SdkReticulumPreviewBehavior::RejectDeliveryAttempts + } + ReticulumPreviewBehavior::DeferDeliveryPlans => { + SdkReticulumPreviewBehavior::DeferDeliveryPlans + } + }; + Ok(TransportProfile::reticulum_preview( + ReticulumPreviewProfile::preview_unavailable().with_behavior(behavior), )) - })? - } else if let Some(secret_id) = proxy.token_secret_id.as_ref() { - let vault = RadrootsSecretVaultOsKeyring::new(RADROOTSD_PROXY_SECRET_SERVICE); - vault - .load_secret(secret_id) - .map_err(|error| { - RuntimeError::Config(format!( - "failed to load radrootsd proxy token secret `{secret_id}`: {error}" - )) - })? - .ok_or_else(|| { - RuntimeError::Config(format!( - "radrootsd proxy token secret `{secret_id}` was not found" - )) - })? - } else { - return Ok(None); - }; - let token = token.trim(); - if token.is_empty() { - return Err(RuntimeError::Config( - "radrootsd proxy bearer token is empty".to_owned(), - )); + } + TransportProfileKind::Proxy => Ok(TransportProfile::proxy(ProxyProfile::new( + config.transport.proxy.url.clone(), + ))), } - Ok(Some(RadrootsdAuth::BearerToken(token.to_owned()))) } #[cfg(test)] @@ -783,9 +745,9 @@ mod tests { }, DirectRrRsDependency { section: "dependencies", - name: "radroots_relay_transport", - owner: "cli-shared-relay-read-boundary", - reason: "shared fail-closed relay fetch receipts for trade event list, sync pull, and market refresh", + name: "radroots_transport_nostr", + owner: "cli-nostr-transport-read-boundary", + reason: "shared fail-closed Nostr relay fetch receipts for trade event list, sync pull, and market refresh", lifecycle: "retain until those read surfaces are fully SDK-owned", }, DirectRrRsDependency { @@ -881,7 +843,7 @@ mod tests { }, ]; - const DIRECT_RELAY_FETCH_DISALLOWED_TOKENS: &[&str] = &[ + const NOSTR_RELAY_FETCH_DISALLOWED_TOKENS: &[&str] = &[ "pub mod direct_relay", "use crate::runtime::direct_relay", "fetch_events_from_relays", @@ -1150,9 +1112,15 @@ mod tests { let sdk_config = CliSdkConfig::from_runtime_config(&config).expect("sdk config"); assert_eq!(sdk_config.storage_root, config.local.root.join("sdk")); - assert_eq!(sdk_config.relay_url_policy, SdkRelayUrlPolicy::Public); assert_eq!( - sdk_config.relay_urls, + sdk_config.nostr_relay_url_policy, + NostrRelayUrlPolicy::Public + ); + let TransportProfile::Nostr { profile } = sdk_config.transport_profile else { + panic!("expected Nostr transport profile"); + }; + assert_eq!( + profile.relay_urls(), vec!["wss://relay.one".to_owned(), "wss://relay.two".to_owned()] ); } @@ -1162,7 +1130,10 @@ mod tests { let root = tempdir().expect("tempdir"); let config = sample_config(root.path(), vec!["ws://127.0.0.1:8080".to_owned()]); - assert_eq!(sdk_relay_url_policy(&config), SdkRelayUrlPolicy::Localhost); + assert_eq!( + sdk_nostr_relay_url_policy(&config), + NostrRelayUrlPolicy::Localhost + ); } #[test] @@ -1298,7 +1269,7 @@ mod tests { } #[test] - fn cli_production_sources_reject_direct_relay_fetch_helpers() { + fn cli_production_sources_reject_unowned_relay_fetch_helpers() { let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); let mut files = Vec::new(); collect_rs_files(manifest_dir.join("src").as_path(), &mut files); @@ -1311,7 +1282,7 @@ mod tests { let relative_path = relative_source_path(manifest_dir, file.as_path()); match production_source_without_tests(&relative_path, &source) { Ok(production_source) => { - direct_relay_fetch_findings(&relative_path, production_source.as_str()) + nostr_relay_fetch_findings(&relative_path, production_source.as_str()) } Err(error) => vec![error], } @@ -1320,7 +1291,7 @@ mod tests { assert!( findings.is_empty(), - "CLI production sources contain direct relay fetch helpers:\n{}", + "CLI production sources contain unowned Nostr relay fetch helpers:\n{}", findings.join("\n") ); } @@ -1723,13 +1694,13 @@ mod tests { .collect() } - fn direct_relay_fetch_findings(label: &str, source: &str) -> Vec<String> { - DIRECT_RELAY_FETCH_DISALLOWED_TOKENS + fn nostr_relay_fetch_findings(label: &str, source: &str) -> Vec<String> { + NOSTR_RELAY_FETCH_DISALLOWED_TOKENS .iter() .flat_map(|token| { source.match_indices(token).map(move |(index, _)| { format!( - "{label}:{} uses direct relay fetch token `{token}`", + "{label}:{} uses unowned Nostr relay fetch token `{token}`", line_number(source, index) ) }) @@ -2151,10 +2122,13 @@ mod tests { signer: SignerConfig { backend: SignerBackend::Local, }, + transport: crate::runtime::config::TransportConfig::from_nostr_relay_urls( + relays.clone(), + ), publish: PublishConfig { - transport: PublishTransport::DirectNostrRelay, + transport: PublishTransport::Nostr, source: PublishTransportSource::Defaults, - radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(), + proxy: crate::runtime::config::ProxyTransportConfig::default(), }, relay: RelayConfig { urls: relays, @@ -2175,6 +2149,7 @@ mod tests { enabled: false, executable: PathBuf::from("hyfd"), }, + mesh: crate::runtime::config::MeshConfig::disabled(), rpc: RpcConfig { url: "http://127.0.0.1:7070".to_owned(), }, diff --git a/src/runtime/store.rs b/src/runtime/store.rs @@ -412,7 +412,7 @@ fn sdk_event_store_status_view( store: sdk_sqlite_status_view(status.store), total_events: status.total_events, projection_eligible_events: status.projection_eligible_events, - relay_observations: status.relay_observations, + transport_observations: status.transport_observations, last_event_seq: status.last_event_seq, last_event_updated_at_ms: status.last_event_updated_at_ms, } @@ -680,7 +680,7 @@ fn manifest_counts(manifest: &ReplicaDbExportManifestRs) -> LocalReplicaCountsVi farms: table_row_count(manifest, "farm"), listings: table_row_count(manifest, "trade_product"), profiles: table_row_count(manifest, "nostr_profile"), - relays: table_row_count(manifest, "direct_nostr_relay"), + relays: table_row_count(manifest, "nostr"), event_states: table_row_count(manifest, "nostr_event_state"), } } diff --git a/src/runtime/sync.rs b/src/runtime/sync.rs @@ -11,19 +11,19 @@ use radroots_events::kinds::{ use radroots_nostr::prelude::{ RadrootsNostrFilter, RadrootsNostrTimestamp, radroots_event_from_nostr, radroots_nostr_kind, }; -use radroots_relay_transport::{ - RadrootsRelayFetchFailure, RadrootsRelayFetchedEventsReceipt, RadrootsRelayTransportError, -}; use radroots_replica_db::{ReplicaSql, migrations}; use radroots_replica_sync::{ RadrootsReplicaEventsError, RadrootsReplicaIngestOutcome, radroots_replica_ingest_event, radroots_replica_sync_status, }; use radroots_sdk::{ - PushOutboxEventReceipt, PushOutboxReceipt, PushOutboxRelayOutcomeKind, PushOutboxRequest, - SyncStatusReceipt, SyncStatusRequest, + PushOutboxEventReceipt, PushOutboxReceipt, PushOutboxRequest, PushOutboxTargetOutcomeKind, + PushOutboxTargetReceipt, SyncStatusReceipt, SyncStatusRequest, }; use radroots_sql_core::{SqlExecutor, SqliteExecutor}; +use radroots_transport_nostr::{ + RadrootsRelayFetchFailure, RadrootsRelayFetchedEventsReceipt, RadrootsRelayTransportError, +}; use serde::Deserialize; use serde_json::json; @@ -32,7 +32,7 @@ use crate::runtime::RuntimeError; use crate::runtime::config::RuntimeConfig; use crate::runtime::sdk::{ CliSdkAdapterError, CliSdkSession, fetch_relay_events_via_shared_transport, - sdk_relay_url_policy, + sdk_nostr_relay_url_policy, }; use crate::view::runtime::{ RelayFailureView, SyncActionView, SyncFreshnessView, SyncQueueView, SyncRunFreshnessView, @@ -42,7 +42,8 @@ use crate::view::runtime::{ const SYNC_SOURCE: &str = "local replica · local first"; const SDK_SYNC_SOURCE: &str = "SDK canonical event store and outbox"; const SDK_PUSH_SOURCE: &str = "SDK outbox push"; -const RELAY_PULL_SETUP_ACTION: &str = "radroots --relay wss://relay.example.com sync pull"; +const RELAY_PULL_SETUP_ACTION: &str = + "radroots transport profile set --kind nostr --nostr-relay wss://relay.example.com"; const SYNC_PULL_ACTION: &str = "radroots sync pull"; const SYNC_PUSH_ACTION: &str = "radroots sync push"; const SYNC_READY_ACTION: &str = "radroots market product search eggs"; @@ -340,7 +341,7 @@ pub fn push(config: &RuntimeConfig) -> Result<SyncActionView, CliSdkAdapterError } let receipt = session.block_on(session.sdk().sync().push_outbox( - PushOutboxRequest::new().with_relay_url_policy(sdk_relay_url_policy(config)), + PushOutboxRequest::new().with_nostr_relay_url_policy(sdk_nostr_relay_url_policy(config)), ))?; let status = session.block_on(session.sdk().sync().status(SyncStatusRequest::new()))?; Ok(sdk_push_view(config, receipt, status)) @@ -415,7 +416,7 @@ fn empty_action_from_snapshot(snapshot: SyncSnapshot, direction: &str) -> SyncAc fn sdk_sync_status_view(config: &RuntimeConfig, receipt: SyncStatusReceipt) -> SyncStatusView { let actions = sdk_sync_status_actions(&receipt); - let relay_count = receipt.relay_targets.configured_count; + let relay_count = receipt.transport_profile.configured_nostr_relay_count; SyncStatusView { state: "ready".to_owned(), source: SDK_SYNC_SOURCE.to_owned(), @@ -449,7 +450,7 @@ fn sdk_push_dry_run_view(config: &RuntimeConfig, status: SyncStatusReceipt) -> S state, sdk_sync_queue(&status), sdk_sync_freshness(&status), - status.relay_targets.configured_relays, + status.transport_profile.configured_nostr_relays, Vec::new(), Vec::new(), Vec::new(), @@ -646,78 +647,81 @@ fn sdk_sync_freshness(receipt: &SyncStatusReceipt) -> SyncFreshnessView { } fn sdk_push_target_relays(receipt: &PushOutboxReceipt, status: &SyncStatusReceipt) -> Vec<String> { - let mut relays = Vec::new(); - for relay in receipt.events.iter().flat_map(|event| event.relays.iter()) { - if !relays.contains(&relay.relay_url) { - relays.push(relay.relay_url.clone()); + let mut targets = Vec::new(); + for target in receipt.events.iter().flat_map(|event| event.targets.iter()) { + if !targets.contains(&target.endpoint_uri) { + targets.push(target.endpoint_uri.clone()); } } - if relays.is_empty() { - relays.extend(status.relay_targets.configured_relays.clone()); + if targets.is_empty() { + targets.extend(status.transport_profile.configured_nostr_relays.clone()); } - relays + targets } fn sdk_push_connected_relays(receipt: &PushOutboxReceipt) -> Vec<String> { - sdk_push_relays_matching(receipt, |_, relay| relay.attempted) + sdk_push_targets_matching(receipt, |_, target| target.attempted) } fn sdk_push_acknowledged_relays(receipt: &PushOutboxReceipt) -> Vec<String> { - sdk_push_relays_matching(receipt, |_, relay| sdk_relay_accepted(relay.outcome_kind)) + sdk_push_targets_matching(receipt, |_, target| { + sdk_target_accepted(target.outcome_kind) + }) } -fn sdk_push_relays_matching( +fn sdk_push_targets_matching( receipt: &PushOutboxReceipt, - predicate: impl Fn(&PushOutboxEventReceipt, &radroots_sdk::PushOutboxRelayReceipt) -> bool, + predicate: impl Fn(&PushOutboxEventReceipt, &PushOutboxTargetReceipt) -> bool, ) -> Vec<String> { - let mut relays = Vec::new(); + let mut targets = Vec::new(); for event in &receipt.events { - for relay in &event.relays { - if predicate(event, relay) && !relays.contains(&relay.relay_url) { - relays.push(relay.relay_url.clone()); + for target in &event.targets { + if predicate(event, target) && !targets.contains(&target.endpoint_uri) { + targets.push(target.endpoint_uri.clone()); } } } - relays + targets } fn sdk_push_failed_relays(receipt: &PushOutboxReceipt) -> Vec<RelayFailureView> { receipt .events .iter() - .flat_map(|event| event.relays.iter()) - .filter(|relay| !sdk_relay_accepted(relay.outcome_kind)) - .map(|relay| RelayFailureView { - relay: relay.relay_url.clone(), - reason: relay + .flat_map(|event| event.targets.iter()) + .filter(|target| !sdk_target_accepted(target.outcome_kind)) + .map(|target| RelayFailureView { + relay: target.endpoint_uri.clone(), + reason: target .message .clone() - .unwrap_or_else(|| sdk_relay_outcome_kind(relay.outcome_kind).to_owned()), + .unwrap_or_else(|| sdk_target_outcome_kind(target.outcome_kind).to_owned()), }) .collect() } -fn sdk_relay_accepted(kind: PushOutboxRelayOutcomeKind) -> bool { +fn sdk_target_accepted(kind: PushOutboxTargetOutcomeKind) -> bool { matches!( kind, - PushOutboxRelayOutcomeKind::Accepted | PushOutboxRelayOutcomeKind::DuplicateAccepted + PushOutboxTargetOutcomeKind::Accepted | PushOutboxTargetOutcomeKind::DuplicateAccepted ) } -fn sdk_relay_outcome_kind(kind: PushOutboxRelayOutcomeKind) -> &'static str { +fn sdk_target_outcome_kind(kind: PushOutboxTargetOutcomeKind) -> &'static str { match kind { - PushOutboxRelayOutcomeKind::Accepted => "accepted", - PushOutboxRelayOutcomeKind::DuplicateAccepted => "duplicate_accepted", - PushOutboxRelayOutcomeKind::Blocked => "blocked", - PushOutboxRelayOutcomeKind::RateLimited => "rate_limited", - PushOutboxRelayOutcomeKind::Invalid => "invalid", - PushOutboxRelayOutcomeKind::PowRequired => "pow_required", - PushOutboxRelayOutcomeKind::Restricted => "restricted", - PushOutboxRelayOutcomeKind::AuthRequired => "auth_required", - PushOutboxRelayOutcomeKind::Error => "error", - PushOutboxRelayOutcomeKind::Timeout => "timeout", - PushOutboxRelayOutcomeKind::ConnectionFailed => "connection_failed", - PushOutboxRelayOutcomeKind::Unknown => "unknown", + PushOutboxTargetOutcomeKind::Accepted => "accepted", + PushOutboxTargetOutcomeKind::DuplicateAccepted => "duplicate_accepted", + PushOutboxTargetOutcomeKind::Blocked => "blocked", + PushOutboxTargetOutcomeKind::RateLimited => "rate_limited", + PushOutboxTargetOutcomeKind::Invalid => "invalid", + PushOutboxTargetOutcomeKind::PowRequired => "pow_required", + PushOutboxTargetOutcomeKind::Restricted => "restricted", + PushOutboxTargetOutcomeKind::AuthRequired => "auth_required", + PushOutboxTargetOutcomeKind::Error => "error", + PushOutboxTargetOutcomeKind::Timeout => "timeout", + PushOutboxTargetOutcomeKind::ConnectionFailed => "connection_failed", + PushOutboxTargetOutcomeKind::TargetUriRejected => "target_uri_rejected", + PushOutboxTargetOutcomeKind::Unknown => "unknown", _ => "unknown", } } @@ -1441,16 +1445,16 @@ mod tests { use radroots_nostr::prelude::{ RadrootsNostrEvent, RadrootsNostrFilter, RadrootsNostrTimestamp, radroots_nostr_build_event, }; - use radroots_relay_transport::{ - RadrootsRelayFetchFailure, RadrootsRelayFetchedEvent, RadrootsRelayFetchedEventsReceipt, - RadrootsRelayTransportError, - }; use radroots_sdk::{ PushOutboxEventReceipt, PushOutboxEventState, PushOutboxReceipt, - PushOutboxRelayOutcomeKind, PushOutboxRelayReceipt, SyncEventStoreStatus, SyncOutboxStatus, - SyncRelayTargetSummary, SyncStatusReceipt, SyncStatusSource, + PushOutboxTargetOutcomeKind, PushOutboxTargetReceipt, SyncEventStoreStatus, + SyncOutboxStatus, SyncStatusReceipt, SyncStatusSource, SyncTransportProfileSummary, }; use radroots_secret_vault::RadrootsSecretBackend; + use radroots_transport_nostr::{ + RadrootsRelayFetchFailure, RadrootsRelayFetchedEvent, RadrootsRelayFetchedEventsReceipt, + RadrootsRelayTransportError, + }; use tempfile::tempdir; use super::{ @@ -1503,7 +1507,9 @@ mod tests { assert_eq!(view.state, "unconfigured"); assert_eq!( view.actions, - vec!["radroots --relay wss://relay.example.com sync pull"] + vec![ + "radroots transport profile set --kind nostr --nostr-relay wss://relay.example.com" + ] ); } @@ -1667,14 +1673,14 @@ mod tests { sdk_push_event( "a", PushOutboxEventState::Published, - PushOutboxRelayOutcomeKind::Accepted, + PushOutboxTargetOutcomeKind::Accepted, "wss://relay-a.example.com", Some("accepted".to_owned()), ), sdk_push_event( "b", PushOutboxEventState::PublishRetryable, - PushOutboxRelayOutcomeKind::AuthRequired, + PushOutboxTargetOutcomeKind::AuthRequired, "wss://relay-b.example.com", Some("auth-required: login".to_owned()), ), @@ -1750,7 +1756,7 @@ mod tests { event_store: SyncEventStoreStatus { total_events, projection_eligible_events: total_events, - relay_observations: 0, + transport_observations: 0, last_event_seq: (total_events > 0).then_some(total_events), last_event_updated_at_ms: (total_events > 0).then_some(1_700_000_000_000), }, @@ -1765,9 +1771,10 @@ mod tests { last_attempt_at_ms, last_error, }, - relay_targets: SyncRelayTargetSummary { - configured_count: relays.len(), - configured_relays: relays.iter().map(|relay| (*relay).to_owned()).collect(), + transport_profile: SyncTransportProfileSummary { + transport_profile_id: "nostr".to_owned(), + configured_nostr_relay_count: relays.len(), + configured_nostr_relays: relays.iter().map(|relay| (*relay).to_owned()).collect(), }, } } @@ -1775,7 +1782,7 @@ mod tests { fn sdk_push_event( event_id_prefix: &str, final_state: PushOutboxEventState, - outcome_kind: PushOutboxRelayOutcomeKind, + outcome_kind: PushOutboxTargetOutcomeKind, relay_url: &str, message: Option<String>, ) -> PushOutboxEventReceipt { @@ -1787,33 +1794,34 @@ mod tests { attempted_count: 1, accepted_count: usize::from(matches!( outcome_kind, - PushOutboxRelayOutcomeKind::Accepted - | PushOutboxRelayOutcomeKind::DuplicateAccepted + PushOutboxTargetOutcomeKind::Accepted + | PushOutboxTargetOutcomeKind::DuplicateAccepted )), retryable_count: usize::from(matches!( outcome_kind, - PushOutboxRelayOutcomeKind::AuthRequired - | PushOutboxRelayOutcomeKind::Timeout - | PushOutboxRelayOutcomeKind::ConnectionFailed + PushOutboxTargetOutcomeKind::AuthRequired + | PushOutboxTargetOutcomeKind::Timeout + | PushOutboxTargetOutcomeKind::ConnectionFailed )), terminal_count: usize::from(matches!( outcome_kind, - PushOutboxRelayOutcomeKind::Blocked - | PushOutboxRelayOutcomeKind::RateLimited - | PushOutboxRelayOutcomeKind::Invalid - | PushOutboxRelayOutcomeKind::PowRequired - | PushOutboxRelayOutcomeKind::Restricted - | PushOutboxRelayOutcomeKind::Error - | PushOutboxRelayOutcomeKind::Unknown + PushOutboxTargetOutcomeKind::Blocked + | PushOutboxTargetOutcomeKind::RateLimited + | PushOutboxTargetOutcomeKind::Invalid + | PushOutboxTargetOutcomeKind::PowRequired + | PushOutboxTargetOutcomeKind::Restricted + | PushOutboxTargetOutcomeKind::Error + | PushOutboxTargetOutcomeKind::Unknown )), quorum: 1, quorum_met: matches!( outcome_kind, - PushOutboxRelayOutcomeKind::Accepted - | PushOutboxRelayOutcomeKind::DuplicateAccepted + PushOutboxTargetOutcomeKind::Accepted + | PushOutboxTargetOutcomeKind::DuplicateAccepted ), - relays: vec![PushOutboxRelayReceipt { - relay_url: relay_url.to_owned(), + targets: vec![PushOutboxTargetReceipt { + transport_kind: "nostr".to_owned(), + endpoint_uri: relay_url.to_owned(), outcome_kind, attempted: true, message, @@ -2337,10 +2345,13 @@ mod tests { signer: SignerConfig { backend: SignerBackend::Local, }, + transport: crate::runtime::config::TransportConfig::from_nostr_relay_urls( + relays.clone(), + ), publish: PublishConfig { - transport: PublishTransport::DirectNostrRelay, + transport: PublishTransport::Nostr, source: PublishTransportSource::Defaults, - radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(), + proxy: crate::runtime::config::ProxyTransportConfig::default(), }, relay: RelayConfig { urls: relays, @@ -2361,6 +2372,7 @@ mod tests { enabled: false, executable: PathBuf::from("hyfd"), }, + mesh: crate::runtime::config::MeshConfig::disabled(), rpc: RpcConfig { url: "http://127.0.0.1:7070".into(), }, diff --git a/src/runtime/transport.rs b/src/runtime/transport.rs @@ -0,0 +1,299 @@ +use std::fs; + +use radroots_sdk::{PushOutboxRequest, SyncStatusRequest}; +use serde_json::Value as JsonValue; +use toml::{Value, map::Map}; + +use crate::ops::OperationData; +use crate::runtime::RuntimeError; +use crate::runtime::config::{RuntimeConfig, TransportProfileKind}; +use crate::runtime::sdk::{CliSdkAdapterError, CliSdkSession, sdk_nostr_relay_url_policy}; +use crate::view::runtime::{ + TransportOutboxPushView, TransportOutboxStatusView, TransportProfileView, TransportStatusView, +}; + +const TRANSPORT_SOURCE: &str = "transport profile config"; + +pub fn profile(config: &RuntimeConfig) -> TransportProfileView { + active_profile_view(config) +} + +pub fn set_profile( + config: &RuntimeConfig, + input: &OperationData, +) -> Result<TransportProfileView, RuntimeError> { + let kind = string_input(input, "kind").unwrap_or("local_only"); + let mut transport = Map::new(); + transport.insert("profile".to_owned(), Value::String(kind.to_owned())); + match kind { + "local_only" => {} + "nostr" => { + let relays = string_array_input(input, "nostr_relays"); + if relays.is_empty() { + return Err(RuntimeError::Config( + "transport profile `nostr` requires at least one --nostr-relay".to_owned(), + )); + } + let mut nostr = Map::new(); + nostr.insert( + "relay_urls".to_owned(), + Value::Array(relays.into_iter().map(Value::String).collect()), + ); + transport.insert("nostr".to_owned(), Value::Table(nostr)); + } + "reticulum_preview" => { + let behavior = string_input(input, "reticulum_preview_behavior") + .unwrap_or("reject_delivery_attempts"); + let mut preview = Map::new(); + preview.insert("behavior".to_owned(), Value::String(behavior.to_owned())); + transport.insert("reticulum_preview".to_owned(), Value::Table(preview)); + } + "proxy" => { + let Some(url) = string_input(input, "proxy_url") else { + return Err(RuntimeError::Config( + "transport profile `proxy` requires --proxy-url".to_owned(), + )); + }; + let mut proxy = Map::new(); + proxy.insert("url".to_owned(), Value::String(url.to_owned())); + transport.insert("proxy".to_owned(), Value::Table(proxy)); + } + other => { + return Err(RuntimeError::Config(format!( + "transport profile kind `{other}` is not supported" + ))); + } + } + update_app_config_table(config, "transport", Value::Table(transport))?; + Ok(profile_view_from_parts( + kind, + string_array_input(input, "nostr_relays"), + string_input(input, "reticulum_preview_behavior").map(str::to_owned), + string_input(input, "proxy_url").map(str::to_owned), + "configured", + )) +} + +pub fn status(config: &RuntimeConfig) -> TransportStatusView { + TransportStatusView { + state: "ready".to_owned(), + source: TRANSPORT_SOURCE.to_owned(), + transports: vec![ + active_profile_view(config), + profile_view_from_parts( + "reticulum_preview", + Vec::new(), + Some("reject_delivery_attempts".to_owned()), + None, + "preview_unavailable", + ), + ], + } +} + +pub fn outbox_status( + config: &RuntimeConfig, +) -> Result<TransportOutboxStatusView, CliSdkAdapterError> { + let session = CliSdkSession::connect(config)?; + let receipt = session.block_on(session.sdk().sync().status(SyncStatusRequest::new()))?; + Ok(TransportOutboxStatusView { + state: "ready".to_owned(), + source: "SDK transport outbox".to_owned(), + transport_profile: receipt.transport_profile.transport_profile_id, + total_count: receipt.outbox.total_events, + pending_count: receipt.outbox.pending_events, + retryable_count: receipt.outbox.retryable_events, + terminal_count: receipt.outbox.terminal_events, + ready_signed_count: receipt.outbox.ready_signed_events, + publishing_count: receipt.outbox.publishing_events, + last_attempt_at_ms: receipt.outbox.last_attempt_at_ms, + last_error: receipt.outbox.last_error, + actions: vec!["radroots transport outbox push".to_owned()], + }) +} + +pub fn outbox_push(config: &RuntimeConfig) -> Result<TransportOutboxPushView, CliSdkAdapterError> { + if config.output.dry_run { + let status = outbox_status(config)?; + return Ok(TransportOutboxPushView { + state: "dry_run".to_owned(), + source: "SDK transport outbox".to_owned(), + attempted_events: 0, + published_events: 0, + retryable_events: 0, + terminal_events: 0, + target_count: usize::try_from(status.ready_signed_count).unwrap_or_default(), + reason: Some("dry run requested; transport outbox push skipped".to_owned()), + actions: vec!["radroots transport outbox status".to_owned()], + }); + } + let session = CliSdkSession::connect(config)?; + let receipt = session.block_on(session.sdk().sync().push_outbox( + PushOutboxRequest::new().with_nostr_relay_url_policy(sdk_nostr_relay_url_policy(config)), + ))?; + let target_count = receipt + .events + .iter() + .flat_map(|event| event.targets.iter()) + .count(); + let failed_count = receipt.retryable_events + receipt.terminal_events; + let state = if receipt.attempted_events == 0 { + "ready" + } else if receipt.published_events > 0 && failed_count > 0 { + "partial" + } else if failed_count > 0 { + "unavailable" + } else if receipt.published_events > 0 { + "published" + } else { + "ready" + } + .to_owned(); + Ok(TransportOutboxPushView { + state, + source: "SDK transport outbox".to_owned(), + attempted_events: receipt.attempted_events, + published_events: receipt.published_events, + retryable_events: receipt.retryable_events, + terminal_events: receipt.terminal_events, + target_count, + reason: (receipt.attempted_events == 0) + .then_some("SDK outbox had no ready signed events to push".to_owned()), + actions: vec!["radroots transport outbox status".to_owned()], + }) +} + +fn active_profile_view(config: &RuntimeConfig) -> TransportProfileView { + match config.transport.profile { + TransportProfileKind::LocalOnly => { + profile_view_from_parts("local_only", Vec::new(), None, None, "configured") + } + TransportProfileKind::Nostr => profile_view_from_parts( + "nostr", + config.transport.nostr_relay_urls.clone(), + None, + None, + if config.transport.nostr_relay_urls.is_empty() { + "unconfigured" + } else { + "configured" + }, + ), + TransportProfileKind::ReticulumPreview => profile_view_from_parts( + "reticulum_preview", + Vec::new(), + Some( + config + .transport + .reticulum_preview_behavior + .as_str() + .to_owned(), + ), + None, + "preview_unavailable", + ), + TransportProfileKind::Proxy => profile_view_from_parts( + "proxy", + Vec::new(), + None, + Some(config.transport.proxy.url.clone()), + "configured", + ), + } +} + +fn profile_view_from_parts( + profile_id: &str, + nostr_relays: Vec<String>, + reticulum_preview_behavior: Option<String>, + proxy_url: Option<String>, + configured_state: &str, +) -> TransportProfileView { + let transport_kind = match profile_id { + "nostr" => "nostr", + "reticulum_preview" => "reticulum", + "proxy" => "proxy", + _ => "local", + }; + let implementation_state = match profile_id { + "nostr" => "available", + "reticulum_preview" => "preview_unavailable", + "proxy" => "delegated", + _ => "local_only", + }; + let usable_for_delivery = + matches!(profile_id, "nostr" | "proxy") && configured_state == "configured"; + let message = match profile_id { + "nostr" if usable_for_delivery => "Nostr relay transport is configured for delivery", + "nostr" => "Nostr transport requires configured Nostr relay targets", + "reticulum_preview" => "Reticulum preview is explicit and unavailable for real delivery", + "proxy" => "Proxy transport delegates delivery to the configured endpoint", + _ => "Local-only profile does not deliver to network transports", + }; + TransportProfileView { + state: configured_state.to_owned(), + source: TRANSPORT_SOURCE.to_owned(), + profile_id: profile_id.to_owned(), + transport_kind: transport_kind.to_owned(), + configured_state: configured_state.to_owned(), + implementation_state: implementation_state.to_owned(), + usable_for_delivery, + message: message.to_owned(), + nostr_relays, + reticulum_preview_behavior, + proxy_url, + actions: if usable_for_delivery { + Vec::new() + } else { + vec![ + "radroots transport profile set --kind nostr --nostr-relay wss://relay.example.com" + .to_owned(), + ] + }, + } +} + +pub(crate) fn update_app_config_table( + config: &RuntimeConfig, + key: &str, + value: Value, +) -> Result<(), RuntimeError> { + let path = &config.paths.app_config_path; + let mut document = if path.exists() { + let raw = fs::read_to_string(path)?; + toml::from_str::<Value>(&raw) + .map_err(|error| RuntimeError::Config(format!("failed to parse app config: {error}")))? + } else { + Value::Table(Map::new()) + }; + let Some(table) = document.as_table_mut() else { + return Err(RuntimeError::Config( + "app config root must be a TOML table".to_owned(), + )); + }; + table.remove("publish"); + table.remove("relays"); + table.insert(key.to_owned(), value); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let rendered = toml::to_string_pretty(&document) + .map_err(|error| RuntimeError::Config(format!("failed to render app config: {error}")))?; + fs::write(path, rendered)?; + Ok(()) +} + +fn string_input<'a>(input: &'a OperationData, key: &str) -> Option<&'a str> { + input.get(key).and_then(JsonValue::as_str) +} + +fn string_array_input(input: &OperationData, key: &str) -> Vec<String> { + input + .get(key) + .and_then(JsonValue::as_array) + .into_iter() + .flatten() + .filter_map(JsonValue::as_str) + .map(str::to_owned) + .collect() +} diff --git a/src/runtime/validation_receipt.rs b/src/runtime/validation_receipt.rs @@ -362,7 +362,7 @@ fn inspection_from_sdk_receipt( failed_relays, reason_code: Some("validation_receipt_not_found".to_owned()), reason: Some(format!( - "validation receipt event `{receipt_event_id}` was not found on configured relays" + "validation receipt event `{receipt_event_id}` was not found on configured Nostr relays" )), sdk_error: None, actions: Vec::new(), @@ -638,8 +638,8 @@ fn sdk_error_parts(error: RadrootsSdkError) -> ValidationReceiptSdkErrorParts { }; let actions = if error.code() == "empty_target_relays" { vec![ - "radroots --relay wss://relay.example.com validation receipt list --trade-id <trade-id>" - .to_owned(), + "radroots transport profile set --kind nostr --nostr-relay wss://relay.example.com" + .to_owned(), ] } else { Vec::new() diff --git a/src/view/runtime.rs b/src/view/runtime.rs @@ -617,7 +617,7 @@ pub struct SdkEventStoreStatusView { pub store: SdkSqliteStatusView, pub total_events: i64, pub projection_eligible_events: i64, - pub relay_observations: i64, + pub transport_observations: i64, #[serde(skip_serializing_if = "Option::is_none")] pub last_event_seq: Option<i64>, #[serde(skip_serializing_if = "Option::is_none")] @@ -3262,56 +3262,99 @@ impl LocalExportView { } #[derive(Debug, Clone, Serialize)] -pub struct RelayListView { +pub struct TransportProfileView { pub state: String, pub source: String, - pub publish_policy: String, - pub count: usize, + pub profile_id: String, + pub transport_kind: String, + pub configured_state: String, + pub implementation_state: String, + pub usable_for_delivery: bool, + pub message: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub nostr_relays: Vec<String>, #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - pub relays: Vec<RelayEntryView>, + pub reticulum_preview_behavior: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub proxy_url: Option<String>, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub actions: Vec<String>, } -impl RelayListView { - pub fn disposition(&self) -> CommandDisposition { - match self.state.as_str() { - "unconfigured" => CommandDisposition::Unconfigured, - _ => CommandDisposition::Success, - } - } +#[derive(Debug, Clone, Serialize)] +pub struct TransportStatusView { + pub state: String, + pub source: String, + pub transports: Vec<TransportProfileView>, } #[derive(Debug, Clone, Serialize)] -pub struct RelayEntryView { - pub url: String, - pub read: bool, - pub write: bool, +pub struct TransportOutboxStatusView { + pub state: String, + pub source: String, + pub transport_profile: String, + pub total_count: i64, + pub pending_count: i64, + pub retryable_count: i64, + pub terminal_count: i64, + pub ready_signed_count: i64, + pub publishing_count: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_attempt_at_ms: Option<i64>, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_error: Option<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub actions: Vec<String>, } #[derive(Debug, Clone, Serialize)] -pub struct NetStatusView { +pub struct TransportOutboxPushView { pub state: String, pub source: String, - pub session: String, - pub relay_count: usize, - pub publish_policy: String, - pub signer_mode: String, - pub account_resolution: AccountResolutionView, + pub attempted_events: usize, + pub published_events: usize, + pub retryable_events: usize, + pub terminal_events: usize, + pub target_count: usize, #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option<String>, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub actions: Vec<String>, } -impl NetStatusView { - pub fn disposition(&self) -> CommandDisposition { - match self.state.as_str() { - "unconfigured" => CommandDisposition::Unconfigured, - _ => CommandDisposition::Success, - } - } +#[derive(Debug, Clone, Serialize)] +pub struct MeshScopeView { + pub state: String, + pub source: String, + pub scope: String, + pub implementation_state: String, + pub message: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub actions: Vec<String>, +} + +#[derive(Debug, Clone, Serialize)] +pub struct MeshStatusView { + pub state: String, + pub source: String, + pub scope: String, + pub transport_kind: String, + pub configured_state: String, + pub implementation_state: String, + pub usable_for_delivery: bool, + pub message: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct MeshPolicyCheckView { + pub state: String, + pub source: String, + pub scope: String, + pub policy: String, + pub transport_kind: String, + pub usable_for_delivery: bool, + pub decision: String, + pub message: String, } #[derive(Debug, Clone, Serialize)] diff --git a/tests/signer_runtime_modes.rs b/tests/signer_runtime_modes.rs @@ -889,7 +889,7 @@ fn myc_mode_allows_read_inspection_commands() { &["--format", "json", "workspace", "get"][..], &["--format", "json", "config", "get"][..], &["--format", "json", "account", "list"][..], - &["--format", "json", "relay", "list"][..], + &["--format", "json", "transport", "profile", "get"][..], ] { let (output, value) = sandbox.json_output(args); @@ -920,11 +920,10 @@ fn local_listing_publish_fails_without_local_account_authority() { account_id, ]); + sandbox.write_nostr_transport_profile(&["ws://127.0.0.1:9"]); let (output, value) = sandbox.json_output(&[ "--format", "json", - "--relay", - "ws://127.0.0.1:9", "--approval-token", "approve", "listing", @@ -963,6 +962,7 @@ fn local_listing_publish_dry_run_validates_local_account_authority() { account_id, ]); + sandbox.write_nostr_transport_profile(&["ws://127.0.0.1:9"]); let (output, value) = sandbox.json_output(&[ "--format", "json", @@ -999,6 +999,7 @@ fn local_listing_update_dry_run_validates_local_account_authority() { account_id, ]); + sandbox.write_nostr_transport_profile(&["ws://127.0.0.1:9"]); let (output, value) = sandbox.json_output(&[ "--format", "json", @@ -1027,6 +1028,7 @@ fn local_listing_update_dry_run_rejects_mismatched_local_account() { .as_str() .expect("second account id"); + sandbox.write_nostr_transport_profile(&["ws://127.0.0.1:9"]); let (output, value) = sandbox.json_output(&[ "--format", "json", @@ -1068,9 +1070,12 @@ fn local_listing_publish_fails_without_configured_relay() { assert!(!output.status.success()); assert_eq!(value["operation_id"], "listing.publish"); assert_eq!(value["result"], serde_json::Value::Null); - assert_eq!(value["errors"][0]["code"], "empty_target_relays"); - assert_eq!(value["errors"][0]["detail"]["class"], "configuration"); - assert_contains(&value["errors"][0]["message"], "sdk empty target relays"); + assert_eq!(value["errors"][0]["code"], "network_unavailable"); + assert_eq!(value["errors"][0]["detail"]["class"], "network"); + assert_contains( + &value["errors"][0]["message"], + "requires a delivery-capable transport profile", + ); assert_no_removed_command_reference(&value, &["listing", "publish"]); assert_no_daemon_runtime_reference(&value, &["listing", "publish"]); } @@ -1082,6 +1087,7 @@ fn local_listing_publish_dry_run_does_not_sign_matching_listing() { let listing_file = create_listing_draft(&sandbox, "local-dry-run"); make_listing_publishable(&listing_file, "AAAAAAAAAAAAAAAAAAAAAw"); + sandbox.write_nostr_transport_profile(&["ws://127.0.0.1:9"]); let value = sandbox.json_success(&[ "--format", "json", @@ -1121,6 +1127,7 @@ fn local_listing_archive_dry_run_validates_local_account_authority() { .as_str() .expect("second account id"); + sandbox.write_nostr_transport_profile(&["ws://127.0.0.1:9"]); let (output, value) = sandbox.json_output(&[ "--format", "json", @@ -1153,11 +1160,10 @@ fn local_listing_publish_fails_when_selected_account_does_not_match_seller() { .expect("second account id"); assert_ne!(first_account_id, second_account_id); + sandbox.write_nostr_transport_profile(&["ws://127.0.0.1:9"]); let (output, value) = sandbox.json_output(&[ "--format", "json", - "--relay", - "ws://127.0.0.1:9", "--account-id", second_account_id, "--approval-token", @@ -1203,15 +1209,8 @@ fn local_farm_publish_dry_run_validates_secret_backed_account() { "pickup", ]); - let value = sandbox.json_success(&[ - "--format", - "json", - "--relay", - "ws://127.0.0.1:9", - "--dry-run", - "farm", - "publish", - ]); + sandbox.write_nostr_transport_profile(&["ws://127.0.0.1:9"]); + let value = sandbox.json_success(&["--format", "json", "--dry-run", "farm", "publish"]); assert_eq!(value["operation_id"], "farm.publish"); assert_eq!(value["dry_run"], true); @@ -1254,7 +1253,7 @@ fn local_farm_publish_dry_run_fails_without_configured_relay() { assert_eq!(value["errors"][0]["detail"]["class"], "network"); assert_contains( &value["errors"][0]["message"], - "requires at least one configured relay", + "requires at least one configured Nostr relay in the active transport profile", ); assert_no_removed_command_reference(&value, &["farm", "publish", "--dry-run"]); assert_no_daemon_runtime_reference(&value, &["farm", "publish", "--dry-run"]); @@ -1299,7 +1298,7 @@ fn local_farm_publish_fails_without_configured_relay() { assert_eq!(value["errors"][0]["detail"]["class"], "network"); assert_contains( &value["errors"][0]["message"], - "requires at least one configured relay", + "requires a delivery-capable transport profile", ); assert_no_removed_command_reference(&value, &["farm", "publish"]); assert_no_daemon_runtime_reference(&value, &["farm", "publish"]); @@ -1332,11 +1331,10 @@ fn farm_setup_actions_offer_publish_only_when_relay_publish_executable() { assert_action_present(&unconfigured, "radroots farm readiness check"); assert_action_absent(&unconfigured, "radroots farm publish"); + sandbox.write_nostr_transport_profile(&["ws://127.0.0.1:9"]); let configured = sandbox.json_success(&[ "--format", "json", - "--relay", - "ws://127.0.0.1:9", "farm", "profile", "update", @@ -1367,11 +1365,10 @@ fn farm_setup_actions_withhold_publish_for_watch_only_account() { public_identity_file.to_string_lossy().as_ref(), ]); + sandbox.write_nostr_transport_profile(&["ws://127.0.0.1:9"]); let created = sandbox.json_success(&[ "--format", "json", - "--relay", - "ws://127.0.0.1:9", "farm", "create", "--name", @@ -1394,8 +1391,6 @@ fn farm_setup_actions_withhold_publish_for_watch_only_account() { let updated = sandbox.json_success(&[ "--format", "json", - "--relay", - "ws://127.0.0.1:9", "farm", "profile", "update", @@ -1433,11 +1428,10 @@ fn local_farm_publish_reports_sdk_push_failure_without_profile_publish() { ]); let relay_url = "ws://127.0.0.1:9"; + sandbox.write_nostr_transport_profile(&[relay_url]); let (output, value) = sandbox.json_output(&[ "--format", "json", - "--relay", - relay_url, "--approval-token", "approve", "--idempotency-key", @@ -1519,11 +1513,10 @@ fn local_farm_publish_does_not_persist_publication_until_sdk_push_publishes() { ]); let relay_url = "ws://127.0.0.1:9"; + sandbox.write_nostr_transport_profile(&[relay_url]); let (output, value) = sandbox.json_output(&[ "--format", "json", - "--relay", - relay_url, "--approval-token", "approve", "--idempotency-key", @@ -1784,15 +1777,9 @@ fn farm_rebind_is_explicit_and_publish_defaults_ignore_ambient_selection() { assert_next_action_present(&missing_rebind, "radroots account import <path>"); assert_next_action_present(&missing_rebind, "radroots account create"); - let publish_dry_run = sandbox.json_success(&[ - "--format", - "json", - "--relay", - "ws://127.0.0.1:9", - "--dry-run", - "farm", - "publish", - ]); + sandbox.write_nostr_transport_profile(&["ws://127.0.0.1:9"]); + let publish_dry_run = + sandbox.json_success(&["--format", "json", "--dry-run", "farm", "publish"]); assert_eq!(publish_dry_run["operation_id"], "farm.publish"); assert_eq!(publish_dry_run["result"]["state"], "dry_run"); assert_eq!( @@ -2098,15 +2085,8 @@ fn farm_rebind_allows_watch_only_target_and_attach_secret_recovers_publish() { watch_account_id ); - let readiness = sandbox.json_success(&[ - "--format", - "json", - "--relay", - "ws://127.0.0.1:9", - "farm", - "readiness", - "check", - ]); + sandbox.write_nostr_transport_profile(&["ws://127.0.0.1:9"]); + let readiness = sandbox.json_success(&["--format", "json", "farm", "readiness", "check"]); assert_eq!(readiness["operation_id"], "farm.readiness.check"); assert_eq!(readiness["result"]["publish_state"], "unconfigured"); assert_eq!( @@ -2118,15 +2098,8 @@ fn farm_rebind_allows_watch_only_target_and_attach_secret_recovers_publish() { format!("radroots account attach-secret {watch_account_id} <path>").as_str(), ); - let (publish_output, publish) = sandbox.json_output(&[ - "--format", - "json", - "--relay", - "ws://127.0.0.1:9", - "--dry-run", - "farm", - "publish", - ]); + let (publish_output, publish) = + sandbox.json_output(&["--format", "json", "--dry-run", "farm", "publish"]); assert!(!publish_output.status.success()); assert_eq!(publish["operation_id"], "farm.publish"); assert_eq!(publish["errors"][0]["code"], "account_watch_only"); @@ -2141,15 +2114,7 @@ fn farm_rebind_allows_watch_only_target_and_attach_secret_recovers_publish() { watch_account_id, secret_identity_file.to_string_lossy().as_ref(), ]); - let recovered = sandbox.json_success(&[ - "--format", - "json", - "--relay", - "ws://127.0.0.1:9", - "--dry-run", - "farm", - "publish", - ]); + let recovered = sandbox.json_success(&["--format", "json", "--dry-run", "farm", "publish"]); assert_eq!(recovered["operation_id"], "farm.publish"); assert_eq!(recovered["result"]["state"], "dry_run"); assert_eq!(recovered["result"]["seller_account_id"], watch_account_id); @@ -2186,11 +2151,10 @@ fn local_seller_publish_commands_attempt_configured_relay() { .expect("farm d tag"); let relay = "ws://127.0.0.1:9"; + sandbox.write_nostr_transport_profile(&[relay]); let (farm_output, farm_value) = sandbox.json_output(&[ "--format", "json", - "--relay", - relay, "--approval-token", "approve", "farm", @@ -2227,8 +2191,6 @@ fn local_seller_publish_commands_attempt_configured_relay() { let (publish_output, publish_value) = sandbox.json_output(&[ "--format", "json", - "--relay", - relay, "--approval-token", "approve", "listing", @@ -2268,8 +2230,6 @@ fn local_seller_publish_commands_attempt_configured_relay() { let (archive_output, archive_value) = sandbox.json_output(&[ "--format", "json", - "--relay", - relay, "--approval-token", "approve", "listing", @@ -2336,8 +2296,6 @@ fn local_seller_publish_commands_attempt_configured_relay() { let (order_output, order_value) = sandbox.json_output(&[ "--format", "json", - "--relay", - relay, "--approval-token", "approve", "trade", @@ -2347,15 +2305,19 @@ fn local_seller_publish_commands_attempt_configured_relay() { assert!(!order_output.status.success()); assert_eq!(order_value["operation_id"], "trade.submit"); assert_eq!(order_value["result"], serde_json::Value::Null); - assert_eq!(order_value["errors"][0]["code"], "invalid_relay_url"); - assert_eq!(order_value["errors"][0]["detail"]["class"], "configuration"); + assert_eq!(order_value["errors"][0]["code"], "operation_unavailable"); + assert_eq!(order_value["errors"][0]["detail"]["class"], "operation"); assert_eq!( order_value["errors"][0]["detail"]["operation_id"], "trade.submit" ); assert_contains( &order_value["errors"][0]["message"], - "loopback IPv4 address", + "trade draft is not ready for submit", + ); + assert_eq!( + order_value["errors"][0]["detail"]["issues"][0]["code"], + "listing_provenance_missing" ); assert_no_removed_command_reference(&order_value, &["trade", "submit"]); assert_no_daemon_runtime_reference(&order_value, &["trade", "submit"]); @@ -2366,10 +2328,9 @@ fn local_order_event_list_attempts_configured_shared_relay_transport() { let sandbox = RadrootsCliSandbox::new(); sandbox.json_success(&["--format", "json", "account", "create"]); let relay = "ws://127.0.0.1:9"; + sandbox.write_nostr_transport_profile(&[relay]); - let (output, value) = sandbox.json_output(&[ - "--format", "json", "--relay", relay, "trade", "event", "list", - ]); + let (output, value) = sandbox.json_output(&["--format", "json", "trade", "event", "list"]); assert!(!output.status.success()); assert_relay_transport_fetch_failure(&value, "trade.event.list", &["trade", "event", "list"]); @@ -2415,11 +2376,10 @@ fn local_order_failure_envelopes_are_structured_and_actionable() { ); assert_no_daemon_runtime_reference(&watch, &watch_args); + sandbox.write_nostr_transport_profile(&["ws://127.0.0.1:9"]); let submit_args = [ "--format", "json", - "--publish-transport", - "direct_nostr_relay", "--dry-run", "trade", "submit", @@ -2455,6 +2415,7 @@ fn local_order_failure_envelopes_are_structured_and_actionable() { ); assert_no_daemon_runtime_reference(&status, &status_args); + sandbox.write_app_config("[transport]\nprofile = \"local_only\"\n"); let event_list_no_relay_args = ["--format", "json", "trade", "event", "list"]; let (event_list_no_relay_output, event_list_no_relay) = sandbox.json_output(&event_list_no_relay_args); @@ -2469,19 +2430,12 @@ fn local_order_failure_envelopes_are_structured_and_actionable() { ); assert_eq!( event_list_no_relay["next_actions"][0]["command"], - "radroots --relay wss://relay.example.com trade event list" + "radroots transport profile set --kind nostr --nostr-relay wss://relay.example.com" ); assert_no_daemon_runtime_reference(&event_list_no_relay, &event_list_no_relay_args); - let event_list_no_account_args = [ - "--format", - "json", - "--relay", - "ws://127.0.0.1:9", - "trade", - "event", - "list", - ]; + sandbox.write_nostr_transport_profile(&["ws://127.0.0.1:9"]); + let event_list_no_account_args = ["--format", "json", "trade", "event", "list"]; let (event_list_no_account_output, event_list_no_account) = sandbox.json_output(&event_list_no_account_args); assert!(!event_list_no_account_output.status.success()); @@ -2502,8 +2456,6 @@ fn local_order_failure_envelopes_are_structured_and_actionable() { let accept_args = [ "--format", "json", - "--publish-transport", - "direct_nostr_relay", "--dry-run", "trade", "accept", @@ -2520,8 +2472,6 @@ fn local_order_failure_envelopes_are_structured_and_actionable() { let decline_args = [ "--format", "json", - "--publish-transport", - "direct_nostr_relay", "--dry-run", "trade", "decline", @@ -2573,15 +2523,9 @@ fn watch_only_farm_publish_dry_run_fails_as_account_watch_only() { "pickup", ]); - let (output, value) = sandbox.json_output(&[ - "--format", - "json", - "--relay", - "ws://127.0.0.1:9", - "--dry-run", - "farm", - "publish", - ]); + sandbox.write_nostr_transport_profile(&["ws://127.0.0.1:9"]); + let (output, value) = + sandbox.json_output(&["--format", "json", "--dry-run", "farm", "publish"]); assert!(!output.status.success()); assert_eq!(value["operation_id"], "farm.publish"); @@ -2608,11 +2552,10 @@ fn watch_only_listing_publish_fails_as_account_watch_only() { let listing_file = create_listing_draft(&sandbox, "watch-only-publish"); make_listing_publishable(&listing_file, "AAAAAAAAAAAAAAAAAAAAAw"); + sandbox.write_nostr_transport_profile(&["ws://127.0.0.1:9"]); let (output, value) = sandbox.json_output(&[ "--format", "json", - "--relay", - "ws://127.0.0.1:9", "--approval-token", "approve", "listing", @@ -2649,6 +2592,7 @@ fn watch_only_listing_update_dry_run_fails_as_account_watch_only() { let listing_file = create_listing_draft(&sandbox, "watch-only-update"); make_listing_publishable(&listing_file, "AAAAAAAAAAAAAAAAAAAAAw"); + sandbox.write_nostr_transport_profile(&["ws://127.0.0.1:9"]); let (output, value) = sandbox.json_output(&[ "--format", "json", @@ -2683,7 +2627,10 @@ fn myc_listing_publish_does_not_fallback_to_local_account() { ) .as_str(), ); - configure_myc_mode(&sandbox, &myc); + sandbox.write_app_config(&format!( + "[transport]\nprofile = \"nostr\"\n\n[transport.nostr]\nrelay_urls = [\"ws://127.0.0.1:9\"]\n\n[signer]\nbackend = \"myc\"\n\n[myc]\nexecutable = \"{}\"\n", + toml_string(myc.display().to_string().as_str()) + )); let (output, value) = sandbox.json_output(&[ "--format", diff --git a/tests/support/mod.rs b/tests/support/mod.rs @@ -10,8 +10,7 @@ use radroots_events::kinds::{KIND_FARM, KIND_LISTING}; use radroots_identity::{RadrootsIdentity, RadrootsIdentityPublic}; use radroots_local_events::{ LocalEventRecord, LocalEventRecordInput, LocalEventsStore, LocalRecordFamily, - LocalRecordStatus, PublishOutboxStatus, RelayDeliveryEvidence, SourceRuntime, - canonical_relay_set_fingerprint, + LocalRecordStatus, PublishOutboxStatus, SourceRuntime, }; use radroots_protected_store::RadrootsProtectedFileSecretVault; use radroots_replica_sync::{RadrootsReplicaIngestOutcome, radroots_replica_ingest_event}; @@ -24,7 +23,6 @@ use tempfile::TempDir; use std::os::unix::fs::PermissionsExt; static COMMAND_LOCK: Mutex<()> = Mutex::new(()); -pub const ORDERABLE_LISTING_RELAY: &str = "ws://127.0.0.1:9"; pub fn radroots() -> Command { Command::cargo_bin("radroots").expect("binary") @@ -111,6 +109,20 @@ impl RadrootsCliSandbox { path } + pub fn write_nostr_transport_profile(&self, relay_urls: &[&str]) -> PathBuf { + let relays = relay_urls + .iter() + .map(|relay| format!("\"{}\"", relay.replace('"', "\\\""))) + .collect::<Vec<_>>() + .join(", "); + self.write_app_config( + format!( + "[transport]\nprofile = \"nostr\"\n\n[transport.nostr]\nrelay_urls = [{relays}]\n" + ) + .as_str(), + ) + } + pub fn replica_db_path(&self) -> PathBuf { self.root .path() @@ -159,6 +171,8 @@ impl RadrootsCliSandbox { const _: () = { let _ = ndjson_from_stdout as fn(&Output) -> Vec<Value>; let _ = RadrootsCliSandbox::write_workspace_config as fn(&RadrootsCliSandbox, &str) -> PathBuf; + let _ = RadrootsCliSandbox::write_nostr_transport_profile + as fn(&RadrootsCliSandbox, &[&str]) -> PathBuf; let _ = RadrootsCliSandbox::replica_db_path as fn(&RadrootsCliSandbox) -> PathBuf; let _ = RadrootsCliSandbox::local_event_records as fn(&RadrootsCliSandbox) -> Vec<LocalEventRecord>; @@ -318,6 +332,98 @@ pub fn seed_orderable_listing(sandbox: &RadrootsCliSandbox, listing_addr: &str) event_id } +#[allow(dead_code)] +pub fn seed_market_refresh_provenance(sandbox: &RadrootsCliSandbox, relay_urls: &[&str]) { + let executor = SqliteExecutor::open(sandbox.replica_db_path()).expect("open replica db"); + executor + .exec( + "CREATE TABLE IF NOT EXISTS radroots_cli_sync_run ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scope TEXT NOT NULL, + relay_set_fingerprint TEXT NOT NULL, + target_relays_json TEXT NOT NULL, + connected_relays_json TEXT NOT NULL, + failed_relays_json TEXT NOT NULL, + started_at INTEGER NOT NULL, + completed_at INTEGER, + state TEXT NOT NULL, + fetched_count INTEGER NOT NULL, + ingested_count INTEGER NOT NULL, + skipped_count INTEGER NOT NULL, + unsupported_count INTEGER NOT NULL, + failed_count INTEGER NOT NULL, + failure_reason TEXT + ); + CREATE INDEX IF NOT EXISTS idx_radroots_cli_sync_run_scope_started + ON radroots_cli_sync_run(scope, started_at DESC);", + "[]", + ) + .expect("create sync run table"); + let relays = relay_urls + .iter() + .map(|relay| relay.to_string()) + .collect::<Vec<_>>(); + executor + .exec( + "INSERT INTO radroots_cli_sync_run ( + scope, + relay_set_fingerprint, + target_relays_json, + connected_relays_json, + failed_relays_json, + started_at, + completed_at, + state, + fetched_count, + ingested_count, + skipped_count, + unsupported_count, + failed_count, + failure_reason + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14);", + json!([ + "market_refresh", + relay_set_fingerprint(&relays), + serde_json::to_string(&relays).expect("target relays json"), + serde_json::to_string(&relays).expect("connected relays json"), + serde_json::to_string(&Vec::<String>::new()).expect("failed relays json"), + 1_779_000_001_000_i64, + 1_779_000_001_001_i64, + "success", + 1_i64, + 1_i64, + 0_i64, + 0_i64, + 0_i64, + Value::Null + ]) + .to_string() + .as_str(), + ) + .expect("insert market refresh provenance"); +} + +#[allow(dead_code)] +fn relay_set_fingerprint(relays: &[String]) -> String { + let mut normalized = relays + .iter() + .map(|relay| relay.trim().to_ascii_lowercase()) + .filter(|relay| !relay.is_empty()) + .collect::<Vec<_>>(); + normalized.sort(); + normalized.dedup(); + let mut hash = 0xcbf29ce484222325_u64; + for relay in normalized { + for byte in relay.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100000001b3); + } + hash ^= 0xff; + hash = hash.wrapping_mul(0x100000001b3); + } + format!("relayset_{hash:016x}") +} + fn seed_orderable_listing_signed_event( sandbox: &RadrootsCliSandbox, event: &RadrootsNostrEvent, @@ -329,13 +435,6 @@ fn seed_orderable_listing_signed_event( let executor = SqliteExecutor::open(database_path).expect("open local events"); let store = LocalEventsStore::new(executor); store.migrate_up().expect("migrate local events"); - let delivery = RelayDeliveryEvidence::acknowledged( - [ORDERABLE_LISTING_RELAY], - [ORDERABLE_LISTING_RELAY], - [ORDERABLE_LISTING_RELAY], - Vec::new(), - ) - .expect("listing relay delivery evidence"); store .append_record(&LocalEventRecordInput { record_id: format!("test:signed_listing:{}", event.id), @@ -358,8 +457,6 @@ fn seed_orderable_listing_signed_event( event_sig: Some(event.sig.clone()), raw_event_json: Some(json!(event)), outbox_status: PublishOutboxStatus::Acknowledged, - relay_set_fingerprint: canonical_relay_set_fingerprint([ORDERABLE_LISTING_RELAY]), - relay_delivery_json: Some(delivery.to_json_value().expect("delivery json")), }) .expect("append listing signed event record"); } diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -23,8 +23,7 @@ use radroots_events_codec::order::order_request_event_build; use radroots_identity::RadrootsIdentity; use radroots_local_events::{ BUYER_ORDER_REQUEST_LOCAL_WORK_RECORD_KIND, LocalEventRecordInput, LocalEventsStore, - LocalRecordFamily, LocalRecordStatus, PublishOutboxStatus, RelayDeliveryEvidence, - SourceRuntime, canonical_relay_set_fingerprint, + LocalRecordFamily, LocalRecordStatus, PublishOutboxStatus, SourceRuntime, }; use radroots_nostr::prelude::{RadrootsNostrEvent, radroots_nostr_build_event}; use radroots_nostr_connect::prelude::{ @@ -39,16 +38,17 @@ use serde_json::Value; use serde_json::json; use support::{ - ORDERABLE_LISTING_RELAY, RadrootsCliSandbox, assert_contains, - assert_no_daemon_runtime_reference, assert_no_removed_command_reference, create_listing_draft, - duplicate_orderable_listing_row, identity_public, identity_secret, json_from_stdout, - make_listing_publishable, ndjson_from_stdout, radroots, remove_orderable_listing, - replace_latest_listing_event_id, seed_orderable_listing, store_test_session_secret, - toml_string, update_orderable_listing_available_amount, - update_orderable_listing_primary_bin_id, write_public_identity_profile, - write_secret_identity_profile, + RadrootsCliSandbox, assert_contains, assert_no_daemon_runtime_reference, + assert_no_removed_command_reference, create_listing_draft, duplicate_orderable_listing_row, + identity_public, identity_secret, json_from_stdout, make_listing_publishable, + ndjson_from_stdout, radroots, remove_orderable_listing, replace_latest_listing_event_id, + seed_market_refresh_provenance, seed_orderable_listing, store_test_session_secret, toml_string, + update_orderable_listing_available_amount, update_orderable_listing_primary_bin_id, + write_public_identity_profile, write_secret_identity_profile, }; +const ORDERABLE_LISTING_RELAY: &str = "wss://relay.example.com"; + const LISTING_ADDR: &str = "30402:1111111111111111111111111111111111111111111111111111111111111111:AAAAAAAAAAAAAAAAAAAAAg"; const DERIVED_PROJECTION_SYNC_PUSH_FARM_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAA"; @@ -69,12 +69,22 @@ fn test_pubkey(value: &str) -> RadrootsPublicKey { value.parse().expect("valid public key") } -fn radrootsd_proxy_token_file(sandbox: &RadrootsCliSandbox) -> PathBuf { - let path = sandbox.root().join("radrootsd_proxy.token"); +fn proxy_token_file(sandbox: &RadrootsCliSandbox) -> PathBuf { + let path = sandbox.root().join("proxy.token"); fs::write(&path, "proxy_test_token\n").expect("write proxy token file"); path } +fn configure_nostr_transport(sandbox: &RadrootsCliSandbox, relay_url: &str) { + sandbox.write_nostr_transport_profile(&[relay_url]); +} + +fn write_proxy_transport_config(sandbox: &RadrootsCliSandbox, body: &str) -> PathBuf { + sandbox.write_app_config( + format!("[transport]\nprofile = \"proxy\"\n\n[transport.proxy]\n{body}").as_str(), + ) +} + struct RelayFetchServer { endpoint: String, handle: JoinHandle<()>, @@ -100,33 +110,31 @@ impl RelayFetchServer { } } -struct RadrootsdProxyJsonRpcServer { +struct ProxyJsonRpcServer { endpoint: String, handle: JoinHandle<Value>, } -impl RadrootsdProxyJsonRpcServer { - fn once(expected_token: &'static str) -> Self { - let listener = TcpListener::bind("127.0.0.1:0").expect("bind radrootsd proxy"); - listener - .set_nonblocking(true) - .expect("radrootsd proxy nonblocking"); +impl ProxyJsonRpcServer { + fn once(expected_token: Option<&'static str>) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind proxy"); + listener.set_nonblocking(true).expect("proxy nonblocking"); let endpoint = format!("http://{}", listener.local_addr().expect("proxy addr")); let handle = thread::spawn(move || { let deadline = Instant::now() + Duration::from_secs(30); loop { match listener.accept() { Ok((stream, _)) => { - return handle_radrootsd_proxy_connection(stream, expected_token); + return handle_proxy_connection(stream, expected_token); } Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { assert!( Instant::now() < deadline, - "timed out waiting for radrootsd proxy request" + "timed out waiting for proxy request" ); thread::sleep(Duration::from_millis(10)); } - Err(error) => panic!("accept radrootsd proxy connection: {error}"), + Err(error) => panic!("accept proxy connection: {error}"), } } }); @@ -138,7 +146,7 @@ impl RadrootsdProxyJsonRpcServer { } fn join(self) -> Value { - self.handle.join().expect("radrootsd proxy server join") + self.handle.join().expect("proxy server join") } } @@ -481,12 +489,12 @@ signer_session_ref = "{}" ) } -fn handle_radrootsd_proxy_connection(mut stream: TcpStream, expected_token: &str) -> Value { +fn handle_proxy_connection(mut stream: TcpStream, expected_token: Option<&str>) -> Value { let mut bytes = Vec::new(); let mut buffer = [0_u8; 1024]; let body_start = loop { - let read = stream.read(&mut buffer).expect("read radrootsd proxy"); - assert!(read > 0, "radrootsd proxy request closed before headers"); + let read = stream.read(&mut buffer).expect("read proxy"); + assert!(read > 0, "proxy request closed before headers"); bytes.extend_from_slice(&buffer[..read]); if let Some(index) = http_body_start(&bytes) { break index; @@ -495,19 +503,21 @@ fn handle_radrootsd_proxy_connection(mut stream: TcpStream, expected_token: &str let headers = String::from_utf8(bytes[..body_start].to_vec()).expect("headers utf8"); let content_length = http_content_length(headers.as_str()); while bytes.len() < body_start + content_length { - let read = stream.read(&mut buffer).expect("read radrootsd proxy body"); - assert!(read > 0, "radrootsd proxy request closed before body"); + let read = stream.read(&mut buffer).expect("read proxy body"); + assert!(read > 0, "proxy request closed before body"); bytes.extend_from_slice(&buffer[..read]); } let body = String::from_utf8(bytes[body_start..body_start + content_length].to_vec()) .expect("body utf8"); - assert!( - headers - .to_ascii_lowercase() - .contains(format!("authorization: bearer {expected_token}").as_str()), - "radrootsd proxy request missing expected bearer auth: {headers}" - ); - let request: Value = serde_json::from_str(body.as_str()).expect("radrootsd proxy json"); + if let Some(expected_token) = expected_token { + assert!( + headers + .to_ascii_lowercase() + .contains(format!("authorization: bearer {expected_token}").as_str()), + "proxy request missing expected bearer auth: {headers}" + ); + } + let request: Value = serde_json::from_str(body.as_str()).expect("proxy json"); assert_eq!(request["jsonrpc"], "2.0"); assert_eq!(request["method"], "publish.event"); let event = &request["params"]["event"]; @@ -558,7 +568,7 @@ fn handle_radrootsd_proxy_connection(mut stream: TcpStream, expected_token: &str ); stream .write_all(raw_response.as_bytes()) - .expect("write radrootsd proxy response"); + .expect("write proxy response"); request } @@ -702,8 +712,6 @@ fn seed_app_farm_record( event_sig: None, raw_event_json: None, outbox_status: PublishOutboxStatus::None, - relay_set_fingerprint: None, - relay_delivery_json: None, }, sandbox, ); @@ -889,8 +897,6 @@ fn seed_app_listing_record_identity_variant( event_sig: None, raw_event_json: None, outbox_status: PublishOutboxStatus::None, - relay_set_fingerprint: None, - relay_delivery_json: None, }, sandbox, ); @@ -1098,8 +1104,6 @@ fn seed_app_order_record_variant_with_record_id( event_sig: None, raw_event_json: None, outbox_status: PublishOutboxStatus::None, - relay_set_fingerprint: None, - relay_delivery_json: None, }, sandbox, ); @@ -1194,13 +1198,6 @@ fn append_app_signed_order_request_record( .iter() .map(|tag| tag.as_slice().to_vec()) .collect::<Vec<_>>(); - let delivery = RelayDeliveryEvidence::acknowledged( - [ORDERABLE_LISTING_RELAY], - [ORDERABLE_LISTING_RELAY], - [ORDERABLE_LISTING_RELAY], - Vec::new(), - ) - .expect("order request delivery evidence"); let record_id = format!("app:signed_event:{event_id}"); append_app_local_record( LocalEventRecordInput { @@ -1235,8 +1232,6 @@ fn append_app_signed_order_request_record( "sig": event.sig.to_string(), })), outbox_status: PublishOutboxStatus::Acknowledged, - relay_set_fingerprint: canonical_relay_set_fingerprint([ORDERABLE_LISTING_RELAY]), - relay_delivery_json: Some(delivery.to_json_value().expect("delivery json")), }, sandbox, ); @@ -1255,7 +1250,8 @@ fn root_help_exposes_only_target_namespaces() { "config", "account", "signer", - "relay", + "transport", + "mesh", "store", "sync", "farm", @@ -1263,6 +1259,7 @@ fn root_help_exposes_only_target_namespaces() { "market", "basket", "trade", + "validation", ] { assert!( help_lists(&stdout, namespace), @@ -1271,8 +1268,8 @@ fn root_help_exposes_only_target_namespaces() { } for removed in [ - "setup", "status", "doctor", "sell", "find", "local", "net", "myc", "rpc", "product", - "runtime", "job", "message", "approval", "agent", + "setup", "status", "doctor", "sell", "find", "local", "net", "myc", "rpc", "relay", + "product", "runtime", "job", "message", "approval", "agent", ] { assert!( !help_lists(&stdout, removed), @@ -1282,18 +1279,16 @@ fn root_help_exposes_only_target_namespaces() { } #[test] -fn root_help_explains_publish_transports() { +fn root_help_explains_transport_and_mesh_surfaces() { let output = radroots().arg("--help").output().expect("run root help"); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); - assert!(stdout.contains("direct_nostr_relay publishes directly to configured relays")); - assert!(stdout.contains("radrootsd_proxy publishes locally signed events")); + assert!(stdout.contains("Manage transport profiles and outbox delivery.")); + assert!(stdout.contains("Inspect mesh scope and Reticulum preview policy.")); assert!(stdout.contains("Inspect local readiness and mode-specific recovery steps")); - assert!(stdout.contains( - "Select direct_nostr_relay direct relay publish or radrootsd_proxy daemon proxy publish" - )); + assert!(stdout.contains("Show effective configuration and publish-plane readiness.")); } fn help_lists(stdout: &str, command: &str) -> bool { @@ -1325,22 +1320,19 @@ fn removed_global_flags_are_rejected_publicly() { } #[test] -fn config_get_exposes_radrootsd_proxy_missing_token_state() { +fn config_get_exposes_proxy_missing_token_state() { let sandbox = RadrootsCliSandbox::new(); - sandbox.write_app_config("[publish]\ntransport = \"radrootsd_proxy\"\n"); + write_proxy_transport_config(&sandbox, ""); let value = sandbox.json_success(&["--format", "json", "config", "get"]); assert_eq!(value["operation_id"], "config.get"); - assert_eq!(value["result"]["publish"]["transport"], "radrootsd_proxy"); + assert_eq!(value["result"]["publish"]["transport"], "proxy"); assert_eq!( value["result"]["publish"]["source"], - "user config · local first" - ); - assert_eq!( - value["result"]["publish"]["transport_family"], - "radrootsd_proxy" + "user config · transport profile" ); + assert_eq!(value["result"]["publish"]["transport_family"], "proxy"); assert_eq!(value["result"]["publish"]["state"], "unconfigured"); assert_eq!(value["result"]["publish"]["executable"], false); assert_contains( @@ -1353,58 +1345,52 @@ fn config_get_exposes_radrootsd_proxy_missing_token_state() { ); assert_eq!( value["result"]["publish"]["provider"]["provider_runtime_id"], - "radrootsd_proxy" + "proxy" ); assert_eq!( value["result"]["write_plane"]["provider_runtime_id"], - "radrootsd_proxy" + "proxy" ); assert_eq!( value["result"]["write_plane"]["binding_model"], - "daemon_proxy_publish" + "proxy_transport" ); assert_eq!(value["result"]["write_plane"]["state"], "unconfigured"); + assert_eq!(value["result"]["transport"]["profile_id"], "proxy"); assert_eq!( - value["result"]["radrootsd_proxy"]["token_file_configured"], - false - ); - assert_eq!( - value["result"]["radrootsd_proxy"]["token_secret_id_configured"], - false + value["result"]["transport"]["proxy_url"], + "http://127.0.0.1:7070" ); assert_eq!( value["result"]["actions"][0], - "configure RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE or RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_SECRET_ID" + "configure RADROOTS_CLI_TRANSPORT_PROXY_TOKEN_FILE or RADROOTS_CLI_TRANSPORT_PROXY_TOKEN_SECRET_ID" ); assert_eq!( value["next_actions"][0]["env_var"], - "RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE" + "RADROOTS_CLI_TRANSPORT_PROXY_TOKEN_FILE" ); } #[test] -fn config_get_radrootsd_proxy_with_token_file_reports_ready_transport() { +fn config_get_proxy_with_token_file_reports_ready_transport() { let sandbox = RadrootsCliSandbox::new(); - sandbox.write_app_config("[publish]\ntransport = \"radrootsd_proxy\"\n"); - let token_file = radrootsd_proxy_token_file(&sandbox); - - let mut command = sandbox.command(); - command - .env("RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE", token_file) - .args(["--format", "json", "config", "get"]); - let output = command.output().expect("run config get"); - let value: Value = serde_json::from_slice(&output.stdout).expect("json output"); + let token_file = proxy_token_file(&sandbox); + write_proxy_transport_config( + &sandbox, + format!( + "token_file = \"{}\"\n", + toml_string(token_file.display().to_string().as_str()) + ) + .as_str(), + ); - assert!(output.status.success()); + let value = sandbox.json_success(&["--format", "json", "config", "get"]); assert_eq!(value["operation_id"], "config.get"); - assert_eq!(value["result"]["publish"]["transport"], "radrootsd_proxy"); + assert_eq!(value["result"]["publish"]["transport"], "proxy"); assert_eq!(value["result"]["publish"]["state"], "ready"); assert_eq!(value["result"]["publish"]["executable"], true); assert_eq!(value["result"]["publish"]["reason"], Value::Null); - assert_eq!( - value["result"]["radrootsd_proxy"]["token_file_configured"], - true - ); + assert_eq!(value["result"]["transport"]["profile_id"], "proxy"); assert_eq!( value["result"]["actions"] .as_array() @@ -1415,11 +1401,15 @@ fn config_get_radrootsd_proxy_with_token_file_reports_ready_transport() { } #[test] -fn config_get_marks_radrootsd_proxy_unconfigured_with_incomplete_myc_signer() { +fn config_get_marks_proxy_unconfigured_with_incomplete_myc_signer() { let sandbox = RadrootsCliSandbox::new(); - sandbox.write_app_config( - r#"[publish] -transport = "radrootsd_proxy" + let token_file = proxy_token_file(&sandbox); + sandbox.write_app_config(&format!( + r#"[transport] +profile = "proxy" + +[transport.proxy] +token_file = "{}" [signer] backend = "myc" @@ -1431,19 +1421,12 @@ target_kind = "explicit_endpoint" target = "http://myc.invalid" signer_session_ref = "session_ready" "#, - ); - let token_file = radrootsd_proxy_token_file(&sandbox); - - let mut command = sandbox.command(); - command - .env("RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE", token_file) - .args(["--format", "json", "config", "get"]); - let output = command.output().expect("run config get"); - let value: Value = serde_json::from_slice(&output.stdout).expect("json output"); + toml_string(token_file.display().to_string().as_str()) + )); - assert!(output.status.success()); + let value = sandbox.json_success(&["--format", "json", "config", "get"]); assert_eq!(value["operation_id"], "config.get"); - assert_eq!(value["result"]["publish"]["transport"], "radrootsd_proxy"); + assert_eq!(value["result"]["publish"]["transport"], "proxy"); assert_eq!(value["result"]["publish"]["state"], "unconfigured"); assert_eq!(value["result"]["publish"]["executable"], false); assert_contains(&value["result"]["publish"]["reason"], "signer.remote_nip46"); @@ -1457,21 +1440,12 @@ signer_session_ref = "session_ready" #[test] fn config_get_distinguishes_relay_ready_from_missing_signed_write_account() { let sandbox = RadrootsCliSandbox::new(); + configure_nostr_transport(&sandbox, "ws://127.0.0.1:19001"); - let value = sandbox.json_success(&[ - "--format", - "json", - "--relay", - "ws://127.0.0.1:19001", - "config", - "get", - ]); + let value = sandbox.json_success(&["--format", "json", "config", "get"]); assert_eq!(value["operation_id"], "config.get"); - assert_eq!( - value["result"]["publish"]["transport"], - "direct_nostr_relay" - ); + assert_eq!(value["result"]["publish"]["transport"], "nostr"); assert_eq!(value["result"]["publish"]["relay"]["ready"], true); assert_eq!(value["result"]["publish"]["signed_write_required"], true); assert_eq!(value["result"]["publish"]["state"], "unconfigured"); @@ -1486,11 +1460,11 @@ fn config_get_distinguishes_relay_ready_from_missing_signed_write_account() { ); assert_eq!( value["result"]["write_plane"]["provider_runtime_id"], - "direct_nostr_relay" + "nostr" ); assert_eq!( value["result"]["write_plane"]["binding_model"], - "direct_relay_publish" + "nostr_transport" ); assert_eq!(value["result"]["write_plane"]["state"], "unconfigured"); assert_eq!(value["result"]["rpc"], Value::Null); @@ -1503,37 +1477,18 @@ fn config_get_distinguishes_relay_ready_from_missing_signed_write_account() { value["next_actions"][0]["command"], "radroots account create" ); - assert_no_daemon_runtime_reference( - &value, - &[ - "--format", - "json", - "--relay", - "ws://127.0.0.1:19001", - "config", - "get", - ], - ); + assert_no_daemon_runtime_reference(&value, &["--format", "json", "config", "get"]); } #[test] fn config_get_marks_relay_publish_ready_with_secret_backed_local_account() { let sandbox = RadrootsCliSandbox::new(); sandbox.json_success(&["--format", "json", "account", "create"]); + configure_nostr_transport(&sandbox, "ws://127.0.0.1:19002"); - let value = sandbox.json_success(&[ - "--format", - "json", - "--relay", - "ws://127.0.0.1:19002", - "config", - "get", - ]); + let value = sandbox.json_success(&["--format", "json", "config", "get"]); - assert_eq!( - value["result"]["publish"]["transport"], - "direct_nostr_relay" - ); + assert_eq!(value["result"]["publish"]["transport"], "nostr"); assert_eq!(value["result"]["publish"]["relay"]["ready"], true); assert_eq!(value["result"]["publish"]["signed_write_required"], true); assert_eq!(value["result"]["publish"]["state"], "ready"); @@ -1546,21 +1501,11 @@ fn config_get_marks_relay_publish_ready_with_secret_backed_local_account() { fn config_get_marks_relay_publish_unconfigured_with_missing_myc_binding() { let sandbox = RadrootsCliSandbox::new(); sandbox.json_success(&["--format", "json", "account", "create"]); - sandbox.write_app_config("[signer]\nbackend = \"myc\"\n"); + sandbox.write_app_config("[transport]\nprofile = \"nostr\"\n\n[transport.nostr]\nrelay_urls = [\"ws://127.0.0.1:19003\"]\n\n[signer]\nbackend = \"myc\"\n"); - let value = sandbox.json_success(&[ - "--format", - "json", - "--relay", - "ws://127.0.0.1:19003", - "config", - "get", - ]); + let value = sandbox.json_success(&["--format", "json", "config", "get"]); - assert_eq!( - value["result"]["publish"]["transport"], - "direct_nostr_relay" - ); + assert_eq!(value["result"]["publish"]["transport"], "nostr"); assert_eq!(value["result"]["publish"]["relay"]["ready"], true); assert_eq!(value["result"]["publish"]["signed_write_required"], true); assert_eq!(value["result"]["publish"]["state"], "unconfigured"); @@ -1592,14 +1537,8 @@ fn config_get_marks_relay_publish_unconfigured_with_watch_only_account() { public_identity_file.to_string_lossy().as_ref(), ]); - let value = sandbox.json_success(&[ - "--format", - "json", - "--relay", - "ws://127.0.0.1:19004", - "config", - "get", - ]); + configure_nostr_transport(&sandbox, "ws://127.0.0.1:19004"); + let value = sandbox.json_success(&["--format", "json", "config", "get"]); assert_eq!(value["result"]["publish"]["relay"]["ready"], true); assert_eq!(value["result"]["publish"]["signed_write_required"], true); @@ -1612,9 +1551,9 @@ fn config_get_marks_relay_publish_unconfigured_with_watch_only_account() { fn health_surfaces_publish_state_under_missing_myc_binding() { let sandbox = RadrootsCliSandbox::new(); let missing_myc = sandbox.root().join("bin/missing-myc"); - let token_file = radrootsd_proxy_token_file(&sandbox); + let token_file = proxy_token_file(&sandbox); sandbox.write_app_config(&format!( - "[publish]\ntransport = \"radrootsd_proxy\"\n\n[publish.radrootsd_proxy]\ntoken_file = \"{}\"\n\n[signer]\nbackend = \"myc\"\n\n[myc]\nexecutable = \"{}\"\n", + "[transport]\nprofile = \"proxy\"\n\n[transport.proxy]\ntoken_file = \"{}\"\n\n[signer]\nbackend = \"myc\"\n\n[myc]\nexecutable = \"{}\"\n", toml_string(token_file.display().to_string().as_str()), toml_string(missing_myc.display().to_string().as_str()) )); @@ -1623,7 +1562,7 @@ fn health_surfaces_publish_state_under_missing_myc_binding() { assert_eq!(value["operation_id"], "health.status.get"); assert_eq!(value["result"]["state"], "needs_attention"); - assert_eq!(value["result"]["publish"]["transport"], "radrootsd_proxy"); + assert_eq!(value["result"]["publish"]["transport"], "proxy"); assert_eq!(value["result"]["publish"]["executable"], false); assert_eq!( value["result"]["publish"]["provider"]["state"], @@ -1656,16 +1595,9 @@ fn health_surfaces_publish_state_under_missing_myc_binding() { #[test] fn health_status_distinguishes_relay_ready_from_missing_signed_write_account() { let sandbox = RadrootsCliSandbox::new(); + configure_nostr_transport(&sandbox, "ws://127.0.0.1:19005"); - let value = sandbox.json_success(&[ - "--format", - "json", - "--relay", - "ws://127.0.0.1:19005", - "health", - "status", - "get", - ]); + let value = sandbox.json_success(&["--format", "json", "health", "status", "get"]); assert_eq!(value["operation_id"], "health.status.get"); assert_eq!(value["result"]["state"], "needs_attention"); @@ -1690,7 +1622,7 @@ fn health_status_distinguishes_relay_ready_from_missing_signed_write_account() { #[test] fn health_check_exposes_publish_readiness() { let sandbox = RadrootsCliSandbox::new(); - sandbox.write_app_config("[publish]\ntransport = \"radrootsd_proxy\"\n"); + write_proxy_transport_config(&sandbox, ""); let value = sandbox.json_success(&["--format", "json", "health", "check", "run"]); @@ -1701,10 +1633,7 @@ fn health_check_exposes_publish_readiness() { "unresolved" ); assert_eq!(value["result"]["account_resolution"]["source"], "none"); - assert_eq!( - value["result"]["checks"]["publish"]["transport"], - "radrootsd_proxy" - ); + assert_eq!(value["result"]["checks"]["publish"]["transport"], "proxy"); assert_eq!( value["result"]["checks"]["publish"]["state"], "unconfigured" @@ -1724,7 +1653,7 @@ fn health_check_exposes_publish_readiness() { assert_eq!(value["result"]["actions"][0], "radroots account create"); assert_eq!( value["result"]["actions"][1], - "configure RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE or RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_SECRET_ID" + "configure RADROOTS_CLI_TRANSPORT_PROXY_TOKEN_FILE or RADROOTS_CLI_TRANSPORT_PROXY_TOKEN_SECRET_ID" ); assert_eq!( value["next_actions"][0]["command"], @@ -1732,11 +1661,11 @@ fn health_check_exposes_publish_readiness() { ); assert_eq!( value["next_actions"][1]["description"], - "configure RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE or RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_SECRET_ID" + "configure RADROOTS_CLI_TRANSPORT_PROXY_TOKEN_FILE or RADROOTS_CLI_TRANSPORT_PROXY_TOKEN_SECRET_ID" ); assert_eq!( value["next_actions"][1]["env_var"], - "RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE" + "RADROOTS_CLI_TRANSPORT_PROXY_TOKEN_FILE" ); assert_eq!(value["errors"].as_array().expect("errors").len(), 0); } @@ -1746,16 +1675,9 @@ fn health_check_marks_relay_publish_ready_with_secret_backed_local_account() { let sandbox = RadrootsCliSandbox::new(); sandbox.json_success(&["--format", "json", "workspace", "init"]); sandbox.json_success(&["--format", "json", "account", "create"]); + configure_nostr_transport(&sandbox, "ws://127.0.0.1:19006"); - let value = sandbox.json_success(&[ - "--format", - "json", - "--relay", - "ws://127.0.0.1:19006", - "health", - "check", - "run", - ]); + let value = sandbox.json_success(&["--format", "json", "health", "check", "run"]); assert_eq!(value["operation_id"], "health.check.run"); assert_eq!(value["result"]["state"], "ready"); @@ -1772,10 +1694,7 @@ fn health_check_marks_relay_publish_ready_with_secret_backed_local_account() { value["result"]["account_resolution"]["resolved_account"]["write_capable"], true ); - assert_eq!( - value["result"]["checks"]["publish"]["transport"], - "direct_nostr_relay" - ); + assert_eq!(value["result"]["checks"]["publish"]["transport"], "nostr"); assert_eq!(value["result"]["checks"]["publish"]["state"], "ready"); assert_eq!(value["result"]["checks"]["publish"]["executable"], true); assert_eq!( @@ -1817,56 +1736,49 @@ fn farm_readiness_check_reports_mode_specific_publish_gates() { } else { &relay_value["result"] }; - assert_eq!(relay_detail["publish_transport"], "direct_nostr_relay"); + assert_eq!(relay_detail["publish_transport"], "nostr"); assert_eq!(relay_detail["publish_state"], "unconfigured"); assert_eq!(relay_detail["publish_executable"], false); - assert_eq!(relay_detail["missing"][0], "Configured relay"); + assert_eq!( + relay_detail["missing"][0], + "Configured Nostr transport profile" + ); - sandbox.write_app_config( - r#"[[capability_binding]] + let proxy_token_path = proxy_token_file(&sandbox); + sandbox.write_app_config(&format!( + r#"[transport] +profile = "proxy" + +[transport.proxy] +token_file = "{}" + +[[capability_binding]] capability = "signer.remote_nip46" provider = "myc" target_kind = "explicit_endpoint" target = "http://myc.invalid" signer_session_ref = "session_test" "#, - ); + toml_string(proxy_token_path.display().to_string().as_str()) + )); let output = sandbox .command() - .env( - "RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE", - radrootsd_proxy_token_file(&sandbox), - ) - .args([ - "--format", - "json", - "--publish-transport", - "radrootsd_proxy", - "farm", - "readiness", - "check", - ]) + .args(["--format", "json", "farm", "readiness", "check"]) .output() - .expect("run radrootsd proxy farm readiness"); - let radrootsd_value: Value = serde_json::from_slice(&output.stdout).expect("json output"); + .expect("run proxy farm readiness"); + let proxy_value: Value = serde_json::from_slice(&output.stdout).expect("json output"); assert!(output.status.success()); - assert_eq!(radrootsd_value["operation_id"], "farm.readiness.check"); - assert_contains( - &radrootsd_value["result"]["publish_transport"], - "radrootsd_proxy", - ); - assert_eq!(radrootsd_value["result"]["publish_state"], "ready"); - assert_eq!(radrootsd_value["result"]["publish_executable"], true); - assert_eq!(radrootsd_value["result"]["reason"], Value::Null); - assert_eq!( - radrootsd_value["result"]["actions"][0], - "radroots farm publish" - ); + assert_eq!(proxy_value["operation_id"], "farm.readiness.check"); + assert_contains(&proxy_value["result"]["publish_transport"], "proxy"); + assert_eq!(proxy_value["result"]["publish_state"], "ready"); + assert_eq!(proxy_value["result"]["publish_executable"], true); + assert_eq!(proxy_value["result"]["reason"], Value::Null); + assert_eq!(proxy_value["result"]["actions"][0], "radroots farm publish"); } #[test] -fn radrootsd_proxy_listing_publish_update_and_archive_dry_run_without_direct_publish_relays() { +fn proxy_listing_publish_update_and_archive_dry_run_without_direct_publish_relays() { for operation in ["publish", "update", "archive"] { let sandbox = RadrootsCliSandbox::new(); sandbox.json_success(&["--format", "json", "account", "create"]); @@ -1888,8 +1800,7 @@ fn radrootsd_proxy_listing_publish_update_and_archive_dry_run_without_direct_pub "--delivery-method", "pickup", ]); - let listing_file = - create_listing_draft(&sandbox, format!("radrootsd-proxy-{operation}").as_str()); + let listing_file = create_listing_draft(&sandbox, format!("proxy-{operation}").as_str()); make_listing_publishable( &listing_file, farm["result"]["config"]["farm_d_tag"] @@ -1897,17 +1808,17 @@ fn radrootsd_proxy_listing_publish_update_and_archive_dry_run_without_direct_pub .expect("farm d tag"), ); + let token_file = proxy_token_file(&sandbox); + sandbox.write_app_config(&format!( + "[transport]\nprofile = \"proxy\"\n\n[transport.proxy]\ntoken_file = \"{}\"\n", + toml_string(token_file.display().to_string().as_str()) + )); + let output = sandbox .command() - .env( - "RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE", - radrootsd_proxy_token_file(&sandbox), - ) .args([ "--format", "json", - "--publish-transport", - "radrootsd_proxy", "--dry-run", "listing", operation, @@ -1940,16 +1851,16 @@ fn radrootsd_proxy_listing_publish_update_and_archive_dry_run_without_direct_pub } #[test] -fn radrootsd_proxy_listing_publish_non_dry_run_uses_local_jsonrpc_server() { +fn proxy_listing_publish_non_dry_run_uses_local_jsonrpc_server() { let sandbox = RadrootsCliSandbox::new(); - let proxy = RadrootsdProxyJsonRpcServer::once("proxy_test_token"); - let token_file = radrootsd_proxy_token_file(&sandbox); + let proxy = ProxyJsonRpcServer::once(None); + let token_file = proxy_token_file(&sandbox); sandbox.write_app_config( format!( - r#"[publish] -transport = "radrootsd_proxy" + r#"[transport] +profile = "proxy" -[publish.radrootsd_proxy] +[transport.proxy] url = "{}" token_file = "{}" "#, @@ -1977,7 +1888,7 @@ token_file = "{}" "--delivery-method", "pickup", ]); - let listing_file = create_listing_draft(&sandbox, "radrootsd-proxy-live"); + let listing_file = create_listing_draft(&sandbox, "proxy-live"); make_listing_publishable( &listing_file, farm["result"]["config"]["farm_d_tag"] @@ -2118,6 +2029,25 @@ fn direct_publish_listing_uses_myc_nip46_sdk_signer() { .expect("farm d tag"), ); + sandbox.write_app_config( + format!( + r#"[transport] +profile = "nostr" + +[transport.nostr] +relay_urls = ["{}"] + +{}"#, + toml_string(relay_endpoint.as_str()), + myc_nip46_config( + remote_pubkey.as_str(), + relay_endpoint.as_str(), + account_id, + "session_ready", + ) + ) + .as_str(), + ); let output = sandbox .command() .args([ @@ -2125,8 +2055,6 @@ fn direct_publish_listing_uses_myc_nip46_sdk_signer() { "json", "--approval-token", "approve", - "--relay", - relay_endpoint.as_str(), "listing", "publish", listing_file.to_string_lossy().as_ref(), @@ -2163,7 +2091,7 @@ fn direct_publish_listing_uses_myc_nip46_sdk_signer() { } #[test] -fn radrootsd_proxy_listing_publish_uses_myc_nip46_sdk_signer() { +fn proxy_listing_publish_uses_myc_nip46_sdk_signer() { let sandbox = RadrootsCliSandbox::new(); let user_identity = identity_secret(93); let client_identity = identity_secret(94); @@ -2177,8 +2105,8 @@ fn radrootsd_proxy_listing_publish_uses_myc_nip46_sdk_signer() { user_keys, Nip46RelayFinish::SignResponse, ); - let proxy = RadrootsdProxyJsonRpcServer::once("proxy_test_token"); - let token_file = radrootsd_proxy_token_file(&sandbox); + let proxy = ProxyJsonRpcServer::once(None); + let token_file = proxy_token_file(&sandbox); let public_identity_file = write_public_identity_profile(&sandbox, "myc-proxy-user", &user_public); let imported = sandbox.json_success(&[ @@ -2200,10 +2128,10 @@ fn radrootsd_proxy_listing_publish_uses_myc_nip46_sdk_signer() { client_identity.secret_key_hex().as_str(), ); let config = format!( - r#"[publish] -transport = "radrootsd_proxy" + r#"[transport] +profile = "proxy" -[publish.radrootsd_proxy] +[transport.proxy] url = "{}" token_file = "{}" @@ -2236,7 +2164,7 @@ token_file = "{}" "--delivery-method", "pickup", ]); - let listing_file = create_listing_draft(&sandbox, "myc-radrootsd-proxy-live"); + let listing_file = create_listing_draft(&sandbox, "myc-proxy-live"); make_listing_publishable( &listing_file, farm["result"]["config"]["farm_d_tag"] @@ -2318,11 +2246,10 @@ fn listing_update_publish_attempts_direct_publish_with_approval() { .expect("farm d tag"), ); + configure_nostr_transport(&sandbox, "ws://127.0.0.1:9"); let (output, value) = sandbox.json_output(&[ "--format", "json", - "--relay", - "ws://127.0.0.1:9", "--approval-token", "approve", "listing", @@ -2764,9 +2691,9 @@ fn next_actions_mirror_result_actions_for_json_and_ndjson() { &["--format", "ndjson", "health", "status", "get"][..], &["--format", "ndjson", "health", "check", "run"][..], ] { - let daemon = RadrootsCliSandbox::new(); - daemon.write_app_config("[publish]\ntransport = \"radrootsd_proxy\"\n"); - let output = daemon.command().args(args).output().expect("run ndjson"); + let proxy = RadrootsCliSandbox::new(); + write_proxy_transport_config(&proxy, ""); + let output = proxy.command().args(args).output().expect("run ndjson"); let frames = ndjson_from_stdout(&output); let terminal = frames.last().expect("terminal ndjson frame"); @@ -2777,7 +2704,7 @@ fn next_actions_mirror_result_actions_for_json_and_ndjson() { .expect("next actions") .iter() .any(|action| action["description"] - == "configure RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE or RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_SECRET_ID"), + == "configure RADROOTS_CLI_TRANSPORT_PROXY_TOKEN_FILE or RADROOTS_CLI_TRANSPORT_PROXY_TOKEN_SECRET_ID"), "{args:?}" ); } @@ -2959,10 +2886,11 @@ fn environment_human_output_format_is_rejected() { #[test] fn terminal_health_status_surfaces_publish_reason_and_actions() { let sandbox = RadrootsCliSandbox::new(); + configure_nostr_transport(&sandbox, "ws://127.0.0.1:19007"); let output = sandbox .command() - .args(["--relay", "ws://127.0.0.1:19007", "health", "status", "get"]) + .args(["health", "status", "get"]) .output() .expect("run terminal health status"); @@ -2971,7 +2899,7 @@ fn terminal_health_status_surfaces_publish_reason_and_actions() { assert!(stdout.starts_with("! Health needs attention\n")); assert!(stdout.contains("Publish unconfigured")); - assert!(stdout.contains("Reason direct_nostr_relay publish transport requires a selected or default write-capable local account for signed writes")); + assert!(stdout.contains("Reason Nostr transport profile requires a selected or default write-capable local account for signed writes")); assert!(stdout.contains("Next\n radroots account create")); assert!(serde_json::from_str::<Value>(&stdout).is_err()); } @@ -3348,16 +3276,9 @@ fn offline_allows_supported_external_dry_run() { assert_eq!(publish["result"]["state"], "dry_run"); sandbox.json_success(&["--format", "json", "store", "init"]); - let sync_push = sandbox.json_success(&[ - "--format", - "json", - "--offline", - "--relay", - "ws://127.0.0.1:9", - "--dry-run", - "sync", - "push", - ]); + configure_nostr_transport(&sandbox, "ws://127.0.0.1:9"); + let sync_push = + sandbox.json_success(&["--format", "json", "--offline", "--dry-run", "sync", "push"]); assert_eq!(sync_push["operation_id"], "sync.push"); assert_eq!(sync_push["result"]["state"], "ready"); @@ -3392,13 +3313,12 @@ fn offline_listing_publish_enqueues_sdk_outbox_without_direct_publish_push() { make_listing_publishable(&listing_file, farm_d_tag); let relay = "ws://127.0.0.1:9"; let local_event_records_before_publish = sandbox.local_event_records().len(); + configure_nostr_transport(&sandbox, relay); let publish = sandbox.json_success(&[ "--format", "json", "--offline", - "--relay", - relay, "--approval-token", "approve", "listing", @@ -3462,13 +3382,12 @@ fn listing_publish_idempotency_conflict_maps_sdk_partial_mutation_recovery() { make_listing_publishable(&listing_file, farm_d_tag); let relay = "ws://127.0.0.1:9"; let idempotency_key = "listing-idem-conflict"; + configure_nostr_transport(&sandbox, relay); sandbox.json_success(&[ "--format", "json", "--offline", - "--relay", - relay, "--approval-token", "approve", "--idempotency-key", @@ -3488,8 +3407,6 @@ fn listing_publish_idempotency_conflict_maps_sdk_partial_mutation_recovery() { "--format", "json", "--offline", - "--relay", - relay, "--approval-token", "approve", "--idempotency-key", @@ -3776,7 +3693,7 @@ fn online_requires_relay_for_external_network_operations() { value["errors"][0]["message"] .as_str() .expect("message") - .contains("requires at least one configured relay") + .contains("requires a delivery-capable transport profile") ); } } @@ -3807,16 +3724,9 @@ fn order_status_get_uses_sdk_local_projection_without_relay_fetch() { let listener = TcpListener::bind("127.0.0.1:0").expect("bind closed relay"); let closed_relay = format!("ws://{}", listener.local_addr().expect("relay addr")); drop(listener); - let with_closed_relay = sandbox.json_success(&[ - "--format", - "json", - "--relay", - closed_relay.as_str(), - "trade", - "status", - "get", - "ord_missing", - ]); + configure_nostr_transport(&sandbox, closed_relay.as_str()); + let with_closed_relay = + sandbox.json_success(&["--format", "json", "trade", "status", "get", "ord_missing"]); assert_eq!(with_closed_relay["operation_id"], "trade.status.get"); assert_eq!(with_closed_relay["result"]["state"], "missing"); @@ -3848,18 +3758,17 @@ fn order_status_get_invalid_order_id_uses_sdk_error_contract() { } #[test] -fn legacy_radrootsd_publish_transport_value_is_rejected() { +fn removed_publish_transport_flag_is_rejected() { let sandbox = RadrootsCliSandbox::new(); let output = sandbox .command() - .args(["--publish-transport", "radrootsd", "sync", "push"]) + .args(["--publish-transport", "proxy", "sync", "push"]) .output() - .expect("run legacy publish transport"); + .expect("run removed publish transport flag"); let stderr = String::from_utf8(output.stderr).expect("utf8 stderr"); assert!(!output.status.success()); - assert!(stderr.contains("invalid value")); - assert!(stderr.contains("radrootsd_proxy")); + assert!(stderr.contains("unexpected argument") || stderr.contains("unrecognized")); } #[test] @@ -3890,7 +3799,7 @@ fn online_order_event_watch_returns_deferred_without_relay_preflight() { !value["errors"][0]["message"] .as_str() .expect("message") - .contains("configured relay") + .contains("configured Nostr relay") ); assert_no_daemon_runtime_reference(&value, &["trade", "event", "watch"]); } @@ -5855,12 +5764,11 @@ fn farm_publish_uses_sdk_outbox_without_legacy_signed_event_records() { ]); let relay_url = "ws://127.0.0.1:9"; let local_event_records_before_publish = sandbox.local_event_records().len(); + configure_nostr_transport(&sandbox, relay_url); let (output, publish) = sandbox.json_output(&[ "--format", "json", - "--relay", - relay_url, "--approval-token", "approve", "farm", @@ -5925,12 +5833,11 @@ fn listing_publish_failure_uses_sdk_outbox_without_legacy_local_event_record() { make_listing_publishable(&listing_file, farm_d_tag); let relay_url = "ws://127.0.0.1:9"; let local_event_records_before_publish = sandbox.local_event_records().len(); + configure_nostr_transport(&sandbox, relay_url); let (output, publish) = sandbox.json_output(&[ "--format", "json", - "--relay", - relay_url, "--approval-token", "approve", "listing", @@ -5999,12 +5906,11 @@ fn sync_push_sdk_outbox_failure_reports_network_unavailable() { let listing_file = create_listing_draft(&sandbox, "sync-sdk-push-eggs"); make_listing_publishable(&listing_file, farm_d_tag); let relay = "ws://127.0.0.1:9"; + configure_nostr_transport(&sandbox, relay); let publish = sandbox.json_success(&[ "--format", "json", "--offline", - "--relay", - relay, "--approval-token", "approve", "listing", @@ -6029,8 +5935,6 @@ fn sync_push_sdk_outbox_failure_reports_network_unavailable() { let (output, value) = sandbox.json_output(&[ "--format", "json", - "--relay", - relay, "--approval-token", "approve", "sync", @@ -6089,8 +5993,6 @@ fn sync_push_ignores_derived_projection_pending_queue_for_sdk_canonical_push() { let value = sandbox.json_success(&[ "--format", "json", - "--relay", - "ws://127.0.0.1:9", "--approval-token", "approve", "sync", @@ -6152,15 +6054,9 @@ fn buyer_market_sync_basket_dry_runs_preflight_without_mutating_local_state() { assert_eq!(sync_push["result"]["published_count"], 0); sandbox.json_success(&["--format", "json", "store", "init"]); - let relay_refresh = sandbox.json_success(&[ - "--format", - "json", - "--relay", - "ws://127.0.0.1:9", - "--dry-run", - "market", - "refresh", - ]); + configure_nostr_transport(&sandbox, "ws://127.0.0.1:9"); + let relay_refresh = + sandbox.json_success(&["--format", "json", "--dry-run", "market", "refresh"]); assert_eq!(relay_refresh["operation_id"], "market.refresh"); assert_eq!(relay_refresh["dry_run"], true); assert_eq!(relay_refresh["result"]["state"], "ready"); @@ -6171,15 +6067,7 @@ fn buyer_market_sync_basket_dry_runs_preflight_without_mutating_local_state() { assert_eq!(relay_refresh["result"]["fetched_count"], 0); assert_eq!(relay_refresh["result"]["ingested_count"], 0); - let sync_push_ready = sandbox.json_success(&[ - "--format", - "json", - "--relay", - "ws://127.0.0.1:9", - "--dry-run", - "sync", - "push", - ]); + let sync_push_ready = sandbox.json_success(&["--format", "json", "--dry-run", "sync", "push"]); assert_eq!(sync_push_ready["operation_id"], "sync.push"); assert_eq!(sync_push_ready["dry_run"], true); assert_eq!(sync_push_ready["result"]["state"], "ready"); @@ -6288,7 +6176,9 @@ fn buyer_market_sync_basket_dry_runs_preflight_without_mutating_local_state() { #[test] fn market_order_request_readiness_gates_buyer_intent_actions() { let sandbox = RadrootsCliSandbox::new(); + configure_nostr_transport(&sandbox, ORDERABLE_LISTING_RELAY); seed_orderable_listing(&sandbox, LISTING_ADDR); + seed_market_refresh_provenance(&sandbox, &[ORDERABLE_LISTING_RELAY]); let search = sandbox.json_success(&["--format", "json", "market", "product", "search", "eggs"]); assert_eq!(search["operation_id"], "market.product.search"); @@ -6533,11 +6423,7 @@ fn required_approval_token_rejects_absent_empty_and_whitespace_values() { "listing.archive", &["listing", "archive", "missing-listing.toml"], ); - assert_required_approval_token_rejected( - &sandbox, - "sync.push", - &["--relay", "ws://127.0.0.1:9", "sync", "push"], - ); + assert_required_approval_token_rejected(&sandbox, "sync.push", &["sync", "push"]); assert_required_approval_token_rejected(&sandbox, "trade.submit", &["trade", "submit"]); assert_required_approval_token_rejected( &sandbox, @@ -6642,7 +6528,9 @@ fn order_submit_missing_order_returns_not_found_while_read_view_stays_successful fn create_ready_order(sandbox: &RadrootsCliSandbox, basket_id: &str) -> String { sandbox.json_success(&["--format", "json", "account", "create"]); + configure_nostr_transport(sandbox, ORDERABLE_LISTING_RELAY); seed_orderable_listing(sandbox, LISTING_ADDR); + seed_market_refresh_provenance(sandbox, &[ORDERABLE_LISTING_RELAY]); sandbox.json_success(&["--format", "json", "basket", "create", basket_id]); sandbox.json_success(&[ "--format", @@ -6659,6 +6547,7 @@ fn create_ready_order(sandbox: &RadrootsCliSandbox, basket_id: &str) -> String { "2", ]); let quote = sandbox.json_success(&["--format", "json", "basket", "quote", "create", basket_id]); + sandbox.write_app_config("[transport]\nprofile = \"local_only\"\n"); quote["result"]["quote"]["trade_id"] .as_str() .expect("order id") @@ -6766,7 +6655,9 @@ fn buyer_target_flow_acceptance_uses_target_operations() { assert_eq!(signer["result"]["signer_account_id"], account_id); assert_no_removed_command_reference(&signer, &["signer", "status", "get"]); + configure_nostr_transport(&sandbox, ORDERABLE_LISTING_RELAY); let listing_event_id = seed_orderable_listing(&sandbox, LISTING_ADDR); + seed_market_refresh_provenance(&sandbox, &[ORDERABLE_LISTING_RELAY]); let search = sandbox.json_success(&["--format", "json", "market", "product", "search", "eggs"]); assert_eq!(search["operation_id"], "market.product.search"); @@ -6879,7 +6770,10 @@ fn buyer_target_flow_acceptance_uses_target_operations() { assert_eq!(submit["result"]["state"], "dry_run"); assert_eq!(submit["result"]["source"], "SDK trade submit · local key"); assert_eq!(submit["result"]["event_kind"], 3422); - assert!(submit["result"]["target_relays"].is_null()); + assert_eq!( + submit["result"]["target_relays"][0], + ORDERABLE_LISTING_RELAY + ); assert_eq!( submit["result"]["event_id"] .as_str() @@ -6890,6 +6784,7 @@ fn buyer_target_flow_acceptance_uses_target_operations() { assert_no_removed_command_reference(&submit, &["trade", "submit", "--dry-run"]); assert_no_daemon_runtime_reference(&submit, &["trade", "submit", "--dry-run"]); + sandbox.write_app_config("[transport]\nprofile = \"local_only\"\n"); let (output, unavailable_submit) = sandbox.json_output(&[ "--format", "json", @@ -6905,7 +6800,7 @@ fn buyer_target_flow_acceptance_uses_target_operations() { assert_eq!(unavailable_submit["result"], Value::Null); assert_eq!( unavailable_submit["errors"][0]["code"], - "empty_target_relays" + "empty_transport_targets" ); assert_eq!( unavailable_submit["errors"][0]["detail"]["class"], @@ -6913,13 +6808,13 @@ fn buyer_target_flow_acceptance_uses_target_operations() { ); assert_eq!( unavailable_submit["errors"][0]["detail"]["detail"]["operation"], - "sdk relay target set" + "publish transport profile" ); assert!( unavailable_submit["errors"][0]["message"] .as_str() .expect("message") - .contains("empty target relays") + .contains("empty transport targets") ); assert_no_removed_command_reference(&unavailable_submit, &["trade", "submit"]); assert_no_daemon_runtime_reference(&unavailable_submit, &["trade", "submit"]); @@ -7056,7 +6951,9 @@ fn order_get_marks_watch_only_bound_buyer_unready() { .expect("watch account id"); assert_eq!(imported["result"]["account"]["custody"], "watch_only"); + configure_nostr_transport(&sandbox, ORDERABLE_LISTING_RELAY); seed_orderable_listing(&sandbox, LISTING_ADDR); + seed_market_refresh_provenance(&sandbox, &[ORDERABLE_LISTING_RELAY]); sandbox.json_success(&["--format", "json", "basket", "create", "watch_buyer"]); sandbox.json_success(&[ "--format", @@ -7303,13 +7200,12 @@ fn order_rebind_refuses_visible_published_request() { .as_str() .expect("target account id"); let relay = RelayFetchServer::with_events(vec![event]); + configure_nostr_transport(&sandbox, relay.endpoint()); let (output, value) = sandbox.json_output(&[ "--format", "json", "--dry-run", - "--relay", - relay.endpoint(), "trade", "rebind", order_id, @@ -7413,13 +7309,12 @@ fn order_status_and_event_list_use_draft_context_after_account_override_drift() assert_eq!(status["result"]["decoded_count"], 0); let event_list_relay = RelayFetchServer::with_events(vec![event]); + configure_nostr_transport(&sandbox, event_list_relay.endpoint()); let events = sandbox.json_success(&[ "--format", "json", "--account-id", drift_account_id, - "--relay", - event_list_relay.endpoint(), "trade", "event", "list", @@ -7518,12 +7413,9 @@ fn order_cancel_uses_bound_buyer_after_default_account_drift() { assert!(!cancel_output.status.success()); assert_eq!(cancel["operation_id"], "trade.cancel"); assert_eq!(cancel["result"], Value::Null); - assert_eq!(cancel["errors"][0]["code"], "invalid_request"); - assert_eq!(cancel["errors"][0]["detail"]["class"], "request"); - assert_contains( - &cancel["errors"][0]["message"], - "requires a locally projected trade", - ); + assert_eq!(cancel["errors"][0]["code"], "empty_transport_targets"); + assert_eq!(cancel["errors"][0]["detail"]["class"], "configuration"); + assert_contains(&cancel["errors"][0]["message"], "empty transport targets"); } #[test] @@ -7534,6 +7426,7 @@ fn buyer_side_order_writes_reject_conflicting_account_override_for_local_draft() let drift_account_id = drift_account["result"]["account"]["id"] .as_str() .expect("drift account id"); + configure_nostr_transport(&sandbox, "ws://127.0.0.1:9"); for (operation_id, command) in [ ( @@ -7544,8 +7437,6 @@ fn buyer_side_order_writes_reject_conflicting_account_override_for_local_draft() "--dry-run", "--account-id", drift_account_id, - "--relay", - "ws://127.0.0.1:9", "trade", "revision", "accept", @@ -7562,8 +7453,6 @@ fn buyer_side_order_writes_reject_conflicting_account_override_for_local_draft() "--dry-run", "--account-id", drift_account_id, - "--relay", - "ws://127.0.0.1:9", "trade", "cancel", order_id.as_str(), @@ -7592,12 +7481,11 @@ fn order_submit_non_dry_run_uses_sdk_relay_validation_without_replica_freshness_ let sandbox = RadrootsCliSandbox::new(); let order_id = create_ready_order(&sandbox, "freshness_missing_db"); fs::remove_file(sandbox.replica_db_path()).expect("remove replica db"); + configure_nostr_transport(&sandbox, "ws://127.0.0.1:9"); let (output, value) = sandbox.json_output(&[ "--format", "json", - "--relay", - "ws://127.0.0.1:9", "--approval-token", "approve", "trade", @@ -7639,12 +7527,11 @@ fn order_submit_non_dry_run_uses_sdk_relay_validation_without_listing_state_gate let sandbox = RadrootsCliSandbox::new(); let order_id = create_ready_order(&sandbox, "freshness_missing_listing"); remove_orderable_listing(&sandbox, LISTING_ADDR); + configure_nostr_transport(&sandbox, "ws://127.0.0.1:9"); let (output, value) = sandbox.json_output(&[ "--format", "json", - "--relay", - "ws://127.0.0.1:9", "--approval-token", "approve", "trade", @@ -7666,12 +7553,11 @@ fn order_submit_non_dry_run_uses_sdk_relay_validation_without_listing_event_gate let order_id = create_ready_order(&sandbox, "freshness_superseded_listing"); let replacement_event_id = "3".repeat(64); replace_latest_listing_event_id(&sandbox, LISTING_ADDR, replacement_event_id.as_str()); + configure_nostr_transport(&sandbox, "ws://127.0.0.1:9"); let (output, value) = sandbox.json_output(&[ "--format", "json", - "--relay", - "ws://127.0.0.1:9", "--approval-token", "approve", "trade", @@ -7691,7 +7577,9 @@ fn order_submit_non_dry_run_uses_sdk_relay_validation_without_listing_event_gate fn order_submit_non_dry_run_uses_sdk_relay_validation_without_quantity_gate() { let sandbox = RadrootsCliSandbox::new(); sandbox.json_success(&["--format", "json", "account", "create"]); + configure_nostr_transport(&sandbox, ORDERABLE_LISTING_RELAY); seed_orderable_listing(&sandbox, LISTING_ADDR); + seed_market_refresh_provenance(&sandbox, &[ORDERABLE_LISTING_RELAY]); sandbox.json_success(&["--format", "json", "basket", "create", "over_quantity"]); sandbox.json_success(&[ "--format", @@ -7718,12 +7606,11 @@ fn order_submit_non_dry_run_uses_sdk_relay_validation_without_quantity_gate() { let order_id = quote["result"]["quote"]["trade_id"] .as_str() .expect("order id"); + configure_nostr_transport(&sandbox, "ws://127.0.0.1:9"); let (output, value) = sandbox.json_output(&[ "--format", "json", - "--relay", - "ws://127.0.0.1:9", "--approval-token", "approve", "trade", @@ -7746,12 +7633,11 @@ fn order_submit_non_dry_run_uses_sdk_relay_validation_without_bin_gate() { let sandbox = RadrootsCliSandbox::new(); let order_id = create_ready_order(&sandbox, "unknown_bin"); rewrite_order_bin(&sandbox, order_id.as_str(), "unknown-bin"); + configure_nostr_transport(&sandbox, "ws://127.0.0.1:9"); let (output, value) = sandbox.json_output(&[ "--format", "json", - "--relay", - "ws://127.0.0.1:9", "--approval-token", "approve", "trade", @@ -7989,7 +7875,9 @@ fn order_submit_dry_run_does_not_use_local_primary_bin_preflight() { fn order_submit_dry_run_does_not_use_local_quantity_preflight() { let sandbox = RadrootsCliSandbox::new(); sandbox.json_success(&["--format", "json", "account", "create"]); + configure_nostr_transport(&sandbox, ORDERABLE_LISTING_RELAY); seed_orderable_listing(&sandbox, LISTING_ADDR); + seed_market_refresh_provenance(&sandbox, &[ORDERABLE_LISTING_RELAY]); sandbox.json_success(&["--format", "json", "basket", "create", "dry_over_quantity"]); sandbox.json_success(&[ "--format", @@ -8033,7 +7921,9 @@ fn ready_order_submit_dry_run_validates_local_buyer_authority() { let first_account_id = first["result"]["account"]["id"] .as_str() .expect("first account id"); + configure_nostr_transport(&sandbox, ORDERABLE_LISTING_RELAY); let listing_event_id = seed_orderable_listing(&sandbox, LISTING_ADDR); + seed_market_refresh_provenance(&sandbox, &[ORDERABLE_LISTING_RELAY]); sandbox.json_success(&["--format", "json", "basket", "create", "ready_order"]); sandbox.json_success(&[ "--format", @@ -8077,7 +7967,10 @@ fn ready_order_submit_dry_run_validates_local_buyer_authority() { assert_eq!(dry_run["result"]["state"], "dry_run"); assert_eq!(dry_run["result"]["source"], "SDK trade submit · local key"); assert_eq!(dry_run["result"]["event_kind"], 3422); - assert!(dry_run["result"]["target_relays"].is_null()); + assert_eq!( + dry_run["result"]["target_relays"][0], + ORDERABLE_LISTING_RELAY + ); assert_no_daemon_runtime_reference(&dry_run, &["trade", "submit", "--dry-run"]); let second = sandbox.json_success(&["--format", "json", "account", "create"]); @@ -8122,8 +8015,6 @@ fn ready_order_submit_dry_run_validates_local_buyer_authority() { "json", "--account-id", second_account_id, - "--relay", - "ws://127.0.0.1:9", "--approval-token", "approve", "trade", @@ -8275,11 +8166,11 @@ fn seller_target_flow_acceptance_uses_target_operations() { assert_eq!(unavailable_publish["operation_id"], "listing.publish"); assert_eq!( unavailable_publish["errors"][0]["code"], - "empty_target_relays" + "network_unavailable" ); assert_eq!( unavailable_publish["errors"][0]["detail"]["class"], - "configuration" + "network" ); assert_no_removed_command_reference(&unavailable_publish, &["listing", "publish"]); assert_no_daemon_runtime_reference(&unavailable_publish, &["listing", "publish"]); @@ -8297,11 +8188,11 @@ fn seller_target_flow_acceptance_uses_target_operations() { assert_eq!(unavailable_archive["operation_id"], "listing.archive"); assert_eq!( unavailable_archive["errors"][0]["code"], - "empty_target_relays" + "network_unavailable" ); assert_eq!( unavailable_archive["errors"][0]["detail"]["class"], - "configuration" + "network" ); assert_no_removed_command_reference(&unavailable_archive, &["listing", "archive"]); assert_no_daemon_runtime_reference(&unavailable_archive, &["listing", "archive"]);