cli

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

commit 9531b64e862e0c632576edeb2866c2d6aedb195e
parent 30460646c84f3e588ad0e1705ab49ecd04dd2de3
Author: triesap <tyson@radroots.org>
Date:   Tue, 23 Jun 2026 10:25:50 +0000

publish-proxy: add cli proxy transport

- Replace publish mode config with publish transport and proxy token sources.
- Route farm, listing, and sync publish flows through SDK publish transports.
- Remove legacy direct listing publish and bridge/deferred proxy paths.
- Cover proxy readiness, dry-run listing mutations, and retired surface rejection.

Diffstat:
MCargo.lock | 10++++++++++
MCargo.toml | 2+-
Msrc/cli/global.rs | 4+---
Msrc/cli/input.rs | 2+-
Msrc/cli/mod.rs | 23++++++++++++-----------
Msrc/main.rs | 95++++++++++++++++---------------------------------------------------------------
Msrc/ops/error.rs | 8+++-----
Msrc/ops/exec/basket.rs | 11++++++-----
Msrc/ops/exec/core.rs | 123+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Msrc/ops/exec/farm.rs | 19+++++++++++--------
Msrc/ops/exec/listing.rs | 26++++++++++++--------------
Msrc/ops/exec/market.rs | 11++++++-----
Msrc/ops/exec/order.rs | 11++++++-----
Msrc/ops/exec/runtime.rs | 65+++++++++--------------------------------------------------------
Msrc/out/envelope.rs | 23++++++++++++++---------
Msrc/registry/mod.rs | 13++++++++-----
Msrc/runtime/config.rs | 267++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Msrc/runtime/direct_relay.rs | 199++-----------------------------------------------------------------------------
Msrc/runtime/farm.rs | 196+++++++++++++------------------------------------------------------------------
Msrc/runtime/listing.rs | 954+++++++------------------------------------------------------------------------
Msrc/runtime/local_events.rs | 319+------------------------------------------------------------------------------
Msrc/runtime/order.rs | 26--------------------------
Msrc/runtime/provider.rs | 66+++++++++++++++++++++++++++++++-----------------------------------
Msrc/runtime/sdk.rs | 126+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Msrc/runtime/signer.rs | 6+++---
Msrc/runtime/store.rs | 2+-
Msrc/runtime/sync.rs | 78++++++++----------------------------------------------------------------------
Msrc/view/runtime.rs | 40++++------------------------------------
Mtests/signer_runtime_modes.rs | 29++++++++++++++++++-----------
Mtests/target_cli.rs | 787+++++++++++++++++++------------------------------------------------------------
30 files changed, 817 insertions(+), 2724 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -3815,6 +3815,13 @@ dependencies = [ ] [[package]] +name = "radroots_publish_proxy_protocol" +version = "0.1.0-alpha.2" +dependencies = [ + "serde", +] + +[[package]] name = "radroots_relay_transport" version = "0.1.0-alpha.2" dependencies = [ @@ -3905,6 +3912,7 @@ dependencies = [ name = "radroots_sdk" version = "0.1.0" dependencies = [ + "futures", "hex", "radroots_authority", "radroots_event_store", @@ -3913,8 +3921,10 @@ dependencies = [ "radroots_identity", "radroots_nostr", "radroots_outbox", + "radroots_publish_proxy_protocol", "radroots_relay_transport", "radroots_trade", + "reqwest", "serde", "serde_json", "sha2", diff --git a/Cargo.toml b/Cargo.toml @@ -41,7 +41,7 @@ radroots_replica_db_schema = { path = "../lib/crates/replica_db_schema" } radroots_replica_sync = { path = "../lib/crates/replica_sync" } radroots_runtime = { path = "../lib/crates/runtime" } radroots_runtime_paths = { path = "../lib/crates/runtime_paths" } -radroots_sdk = { path = "../sdk/crates/sdk", features = ["local-runtime"] } +radroots_sdk = { path = "../sdk/crates/sdk", features = ["local-runtime-radrootsd-proxy", "relay-runtime"] } radroots_secret_vault = { path = "../lib/crates/secret_vault", features = ["std", "os-keyring"] } radroots_sql_core = { path = "../lib/crates/sql_core", features = ["native"] } radroots_sp1_host_trade = { path = "../lib/crates/sp1_host_trade" } diff --git a/src/cli/global.rs b/src/cli/global.rs @@ -39,7 +39,7 @@ pub struct RuntimeInvocationArgs { pub account: Option<String>, pub identity_path: Option<PathBuf>, pub signer: Option<String>, - pub publish_mode: Option<String>, + pub publish_transport: Option<String>, pub relay: Vec<String>, pub myc_executable: Option<PathBuf>, pub myc_status_timeout_ms: Option<u64>, @@ -134,7 +134,6 @@ pub struct FarmUpdateArgs { pub struct FarmPublishArgs { pub scope: Option<FarmScopeArg>, pub idempotency_key: Option<String>, - pub signer_session_id: Option<String>, pub print_event: bool, } @@ -184,7 +183,6 @@ pub struct ListingRebindArgs { pub struct ListingMutationArgs { pub file: PathBuf, pub idempotency_key: Option<String>, - pub signer_session_id: Option<String>, pub print_event: bool, pub offline: bool, } diff --git a/src/cli/input.rs b/src/cli/input.rs @@ -27,7 +27,7 @@ pub fn runtime_invocation_args_from_target(args: &TargetCliArgs) -> RuntimeInvoc account: args.account_id.clone(), identity_path: None, signer: None, - publish_mode: args.publish_mode.map(|mode| mode.as_str().to_owned()), + publish_transport: args.publish_transport.map(|mode| mode.as_str().to_owned()), relay: args.relay.clone(), myc_executable: None, myc_status_timeout_ms: None, diff --git a/src/cli/mod.rs b/src/cli/mod.rs @@ -43,17 +43,18 @@ pub enum TargetOutputFormat { } #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] -pub enum TargetPublishMode { - #[value(name = "nostr_relay")] - NostrRelay, - Radrootsd, +pub enum TargetPublishTransport { + #[value(name = "direct_nostr_relay")] + DirectNostrRelay, + #[value(name = "radrootsd_proxy")] + RadrootsdProxy, } -impl TargetPublishMode { +impl TargetPublishTransport { pub fn as_str(self) -> &'static str { match self { - Self::NostrRelay => "nostr_relay", - Self::Radrootsd => "radrootsd", + Self::DirectNostrRelay => "direct_nostr_relay", + Self::RadrootsdProxy => "radrootsd_proxy", } } } @@ -62,7 +63,7 @@ impl TargetPublishMode { #[command( name = "radroots", about = "Operate Radroots local-first trade workflows.", - long_about = "Operate Radroots local-first trade workflows.\n\nPublish modes:\n nostr_relay uses direct relay publish with local signer custody.\n radrootsd is reserved and fails closed for active buyer and seller writes.\n\nRelay mode never silently falls back to radrootsd.", + 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.", disable_help_subcommand = true )] pub struct TargetCliArgs { @@ -73,12 +74,12 @@ pub struct TargetCliArgs { #[arg(long = "relay", global = true)] pub relay: Vec<String>, #[arg( - long = "publish-mode", + long = "publish-transport", global = true, value_enum, - help = "Select nostr_relay direct relay publish or reserved radrootsd guardrail mode" + help = "Select direct_nostr_relay direct relay publish or radrootsd_proxy daemon proxy publish" )] - pub publish_mode: Option<TargetPublishMode>, + 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")] diff --git a/src/main.rs b/src/main.rs @@ -13,7 +13,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; use clap::Parser; -use serde_json::{Value, json}; +use serde_json::Value; use crate::cli::input::runtime_invocation_args_from_target; use crate::cli::{TargetCliArgs, TargetOutputFormat}; @@ -28,13 +28,8 @@ use crate::ops::{ TargetOperationRequest, }; use crate::out::envelope::OutputEnvelope; -use crate::registry::{ - NetworkRequirement, network_requirement, requires_local_signer_mode, - requires_nostr_relay_publish_mode, -}; -use crate::runtime::config::{ - PublishMode, RADROOTSD_PUBLISH_DEFERRED_REASON, RuntimeConfig, SignerBackend, -}; +use crate::registry::{NetworkRequirement, network_requirement, requires_local_signer_mode}; +use crate::runtime::config::{RuntimeConfig, SignerBackend}; use crate::runtime::logging::initialize_logging; static REQUEST_SEQUENCE: AtomicU64 = AtomicU64::new(0); @@ -327,7 +322,7 @@ fn validate_request_contract( config: &RuntimeConfig, ) -> Result<(), OperationAdapterError> { validate_pre_runtime_request_contract(request)?; - validate_publish_mode_contract(request, config)?; + validate_publish_transport_contract(request, config)?; validate_signer_mode_contract(request, config)?; validate_network_contract(request, config)?; Ok(()) @@ -362,7 +357,7 @@ fn validate_signer_mode_contract( ) -> Result<(), OperationAdapterError> { let spec = request.spec(); if matches!(config.signer.backend, SignerBackend::Myc) - && requires_local_signer_mode_for_publish_mode(spec.operation_id, config) + && requires_local_signer_mode_for_publish_transport(spec.operation_id, config) { return Err(OperationAdapterError::SignerModeDeferred { operation_id: spec.operation_id.to_owned(), @@ -423,84 +418,32 @@ fn validate_network_contract( } } -fn requires_local_signer_mode_for_publish_mode(operation_id: &str, config: &RuntimeConfig) -> bool { - if matches!(config.publish.mode, PublishMode::Radrootsd) - && is_publish_mode_routed_operation(operation_id) - { - return false; - } +fn requires_local_signer_mode_for_publish_transport( + operation_id: &str, + config: &RuntimeConfig, +) -> bool { + let _ = config; requires_local_signer_mode(operation_id) } fn requires_pre_runtime_relay_target(operation_id: &str) -> bool { - !is_publish_mode_routed_operation(operation_id) + !is_publish_transport_routed_operation(operation_id) } fn allows_offline_local_mutation(operation_id: &str) -> bool { matches!(operation_id, "listing.publish") } -fn validate_publish_mode_contract( +fn validate_publish_transport_contract( request: &TargetOperationRequest, config: &RuntimeConfig, ) -> Result<(), OperationAdapterError> { - let spec = request.spec(); - if matches!(config.publish.mode, PublishMode::Radrootsd) - && requires_nostr_relay_publish_mode(spec.operation_id) - { - let message = format!( - "`{}` cannot run with publish mode `radrootsd`; {RADROOTSD_PUBLISH_DEFERRED_REASON}", - spec.cli_path - ); - let actions = nostr_relay_publish_mode_recovery_actions(spec.operation_id); - return Err(OperationAdapterError::operation_unavailable_with_detail( - spec.operation_id, - message.clone(), - json!({ - "state": "unavailable", - "reason": message, - "actions": actions, - "publish": { - "mode": config.publish.mode.as_str(), - "source": config.publish.source.as_str(), - "transport_family": config.publish.mode.transport_family(), - "state": "unavailable", - "executable": false, - "provider": { - "provider_runtime_id": "radrootsd", - "state": "unavailable", - } - } - }), - )); - } + let _ = request; + let _ = config; Ok(()) } -fn nostr_relay_publish_mode_recovery_actions(operation_id: &str) -> Vec<String> { - match operation_id { - "farm.publish" => vec![ - "radroots --publish-mode nostr_relay --relay wss://relay.example.com farm publish" - .to_owned(), - ], - "listing.publish" => vec![format!( - "radroots --publish-mode nostr_relay --relay wss://relay.example.com {}", - "listing publish <file>" - )], - "listing.update" => vec![format!( - "radroots --publish-mode nostr_relay --relay wss://relay.example.com {}", - "listing update <file>" - )], - "listing.archive" => vec![format!( - "radroots --publish-mode nostr_relay --relay wss://relay.example.com {}", - "listing archive <file>" - )], - "sync.push" => vec!["radroots --publish-mode nostr_relay sync push".to_owned()], - _ => Vec::new(), - } -} - -fn is_publish_mode_routed_operation(operation_id: &str) -> bool { +fn is_publish_transport_routed_operation(operation_id: &str) -> bool { matches!( operation_id, "farm.publish" | "listing.publish" | "listing.update" | "listing.archive" @@ -581,8 +524,8 @@ fn render_human_envelope( { writeln!(handle, "state: {state}")?; } - if let Some(mode) = human_publish_mode(display) { - writeln!(handle, "publish_mode: {mode}")?; + if let Some(mode) = human_publish_transport(display) { + writeln!(handle, "publish_transport: {mode}")?; } if let Some(state) = human_publish_state(display) { writeln!(handle, "publish_state: {state}")?; @@ -624,10 +567,10 @@ fn human_state(result: &Value) -> Option<&str> { human_string_path(result, &["state"]) } -fn human_publish_mode(result: &Value) -> Option<&str> { +fn human_publish_transport(result: &Value) -> Option<&str> { human_string_path(result, &["publish", "mode"]) .or_else(|| human_string_path(result, &["checks", "publish", "mode"])) - .or_else(|| human_string_path(result, &["publish_mode"])) + .or_else(|| human_string_path(result, &["publish_transport"])) } fn human_publish_state(result: &Value) -> Option<&str> { diff --git a/src/ops/error.rs b/src/ops/error.rs @@ -829,7 +829,6 @@ fn looks_like_signer_failure(value: &str) -> bool { "signer", "sign_event", "sign event", - "signer_session_id", "signer session", "nip46", "nip-46", @@ -848,7 +847,7 @@ fn looks_like_provider_failure(value: &str) -> bool { "provider failed", "radrootsd unavailable", "daemon unavailable", - "bridge provider", + "proxy provider", ], ) } @@ -863,9 +862,8 @@ fn looks_like_operation_failure(value: &str) -> bool { "unsupported operation", "operation unavailable", "operation disabled", - "bridge disabled", - "bridge is disabled", - "bridge.listing.publish is disabled", + "publish proxy disabled", + "publish.event is disabled", ], ) } diff --git a/src/ops/exec/basket.rs b/src/ops/exec/basket.rs @@ -1335,8 +1335,9 @@ mod tests { use crate::runtime::config::{ AccountConfig, AccountSecretContractConfig, HyfConfig, IdentityConfig, InteractionConfig, LocalConfig, LoggingConfig, MigrationConfig, MycConfig, OutputConfig, OutputFormat, - PathsConfig, PublishConfig, PublishMode, PublishModeSource, RelayConfig, RelayConfigSource, - RelayPublishPolicy, RpcConfig, RuntimeConfig, SignerBackend, SignerConfig, Verbosity, + PathsConfig, PublishConfig, PublishTransport, PublishTransportSource, RelayConfig, + RelayConfigSource, RelayPublishPolicy, RpcConfig, RuntimeConfig, SignerBackend, + SignerConfig, Verbosity, }; const LISTING_ADDR: &str = "30402:1111111111111111111111111111111111111111111111111111111111111111:AAAAAAAAAAAAAAAAAAAAAg"; @@ -1912,8 +1913,9 @@ mod tests { backend: SignerBackend::Local, }, publish: PublishConfig { - mode: PublishMode::NostrRelay, - source: PublishModeSource::Defaults, + transport: PublishTransport::DirectNostrRelay, + source: PublishTransportSource::Defaults, + radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(), }, relay: RelayConfig { urls: Vec::new(), @@ -1936,7 +1938,6 @@ mod tests { }, rpc: RpcConfig { url: "http://127.0.0.1:7070".into(), - bridge_bearer_token: None, }, rhi: crate::runtime::config::RhiConfig { trusted_worker_pubkeys: Vec::new(), diff --git a/src/ops/exec/core.rs b/src/ops/exec/core.rs @@ -27,9 +27,7 @@ use crate::runtime::account::{ resolve_account_selector, secret_backend_status, select_account, snapshot, unresolved_account_reason, }; -use crate::runtime::config::{ - PublishMode, RADROOTSD_PUBLISH_DEFERRED_REASON, RuntimeConfig, SignerBackend, -}; +use crate::runtime::config::{PublishTransport, RuntimeConfig, SignerBackend}; use crate::runtime::logging::LoggingState; use crate::runtime::sdk::CliSdkAdapterError; use crate::view::runtime::{ @@ -175,7 +173,7 @@ impl OperationService<HealthCheckRunRequest> for CoreOperationService<'_> { "signer": signer, "publish": { "state": publish.state, - "mode": publish.mode, + "transport": publish.transport, "executable": publish.executable, "reason": publish.reason, }, @@ -196,7 +194,6 @@ impl OperationService<ConfigGetRequest> for CoreOperationService<'_> { let publish = publish_runtime_view(self.config, true, &account); let write_plane = crate::runtime::provider::resolve_write_plane_provider(self.config, &publish); - let bridge_auth_configured = write_plane.bridge_auth_configured; let actions = config_actions(self.config, &account, &publish); let mut result = json!({ "output": { @@ -258,12 +255,15 @@ impl OperationService<ConfigGetRequest> for CoreOperationService<'_> { }, "actions": actions, }); - if matches!(self.config.publish.mode, PublishMode::Radrootsd) { - result["rpc"] = json!({ - "url": self.config.rpc.url, - "bridge_auth_configured": self.config.rpc.bridge_bearer_token.is_some(), + 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(), }); - result["write_plane"]["bridge_auth_configured"] = json!(bridge_auth_configured); } json_operation_result::<ConfigGetResult>(result) } @@ -805,42 +805,46 @@ fn publish_runtime_view( source: config.relay.source.as_str().to_owned(), }; - match config.publish.mode { - PublishMode::NostrRelay => { - let (state, executable, reason) = - nostr_relay_publish_readiness(config, relay_ready, signed_write_required, account); + match config.publish.transport { + PublishTransport::DirectNostrRelay => { + let (state, executable, reason) = direct_nostr_relay_publish_readiness( + config, + relay_ready, + signed_write_required, + account, + ); PublishRuntimeView { - mode: config.publish.mode.as_str().to_owned(), + transport: config.publish.transport.as_str().to_owned(), source, - transport_family: config.publish.mode.transport_family().to_owned(), + transport_family: config.publish.transport.transport_family().to_owned(), state: state.to_owned(), executable, reason: reason.clone(), signed_write_required, relay, provider: PublishProviderRuntimeView { - provider_runtime_id: "nostr_relay".to_owned(), + provider_runtime_id: "direct_nostr_relay".to_owned(), state: state.to_owned(), source: config.relay.source.as_str().to_owned(), reason, }, } } - PublishMode::Radrootsd => { + PublishTransport::RadrootsdProxy => { let (state, executable, reason) = radrootsd_publish_readiness(config); PublishRuntimeView { - mode: config.publish.mode.as_str().to_owned(), + transport: config.publish.transport.as_str().to_owned(), source, - transport_family: config.publish.mode.transport_family().to_owned(), + transport_family: config.publish.transport.transport_family().to_owned(), state: state.to_owned(), executable, reason: reason.clone(), signed_write_required, relay, provider: PublishProviderRuntimeView { - provider_runtime_id: "radrootsd".to_owned(), + provider_runtime_id: "radrootsd_proxy".to_owned(), state: state.to_owned(), - source: "publish mode · local first".to_owned(), + source: "publish transport · local first".to_owned(), reason, }, } @@ -848,7 +852,7 @@ fn publish_runtime_view( } } -fn nostr_relay_publish_readiness( +fn direct_nostr_relay_publish_readiness( config: &RuntimeConfig, relay_ready: bool, signed_write_required: bool, @@ -859,7 +863,7 @@ fn nostr_relay_publish_readiness( "unconfigured", false, Some( - "nostr_relay publish mode requires at least one configured relay for writes" + "direct_nostr_relay publish transport requires at least one configured relay for writes" .to_owned(), ), ); @@ -874,7 +878,7 @@ fn nostr_relay_publish_readiness( "unavailable", false, Some( - "nostr_relay publish mode requires signer mode `local` for signed writes; signer mode `myc` is deferred" + "direct_nostr_relay publish transport requires signer mode `local` for signed writes; signer mode `myc` is deferred" .to_owned(), ), ); @@ -885,7 +889,7 @@ fn nostr_relay_publish_readiness( "unconfigured", false, Some( - "nostr_relay publish mode requires a selected or default write-capable local account for signed writes" + "direct_nostr_relay publish transport requires a selected or default write-capable local account for signed writes" .to_owned(), ), ); @@ -904,12 +908,29 @@ fn nostr_relay_publish_readiness( ("ready", true, None) } -fn radrootsd_publish_readiness(_config: &RuntimeConfig) -> (&'static str, bool, Option<String>) { - ( - "unavailable", - false, - Some(RADROOTSD_PUBLISH_DEFERRED_REASON.to_owned()), - ) +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() + { + return ( + "unconfigured", + false, + Some("radrootsd_proxy publish transport requires a configured token file or token secret id".to_owned()), + ); + } + + if matches!(config.signer.backend, SignerBackend::Myc) { + return ( + "unavailable", + false, + Some( + "radrootsd_proxy publish transport requires signer mode `local` for signed writes; signer mode `myc` is deferred" + .to_owned(), + ), + ); + } + + ("ready", true, None) } fn signer_health_view(config: &RuntimeConfig, account: &AccountResolution) -> Value { @@ -1005,8 +1026,8 @@ fn publish_recovery_actions( } let mut actions = Vec::new(); - match config.publish.mode { - PublishMode::NostrRelay => { + match config.publish.transport { + PublishTransport::DirectNostrRelay => { if config.relay.urls.is_empty() { push_unique( &mut actions, @@ -1025,16 +1046,29 @@ fn publish_recovery_actions( } } } - PublishMode::Radrootsd => { - push_unique( - &mut actions, - "radroots --publish-mode nostr_relay --relay wss://relay.example.com config get", - ); + PublishTransport::RadrootsdProxy => { + if self::proxy_token_configured(config) { + if publish.signed_write_required + && matches!(config.signer.backend, SignerBackend::Myc) + { + push_unique(&mut actions, "radroots signer status get"); + } + } else { + push_unique( + &mut actions, + "configure RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE or RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_SECRET_ID", + ); + } } } 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() +} + fn push_unique(actions: &mut Vec<String>, action: impl Into<String>) { let action = action.into(); if !actions.contains(&action) { @@ -1123,8 +1157,9 @@ mod tests { use crate::runtime::config::{ AccountConfig, AccountSecretContractConfig, HyfConfig, IdentityConfig, InteractionConfig, LocalConfig, LoggingConfig, MigrationConfig, MycConfig, OutputConfig, OutputFormat, - PathsConfig, PublishConfig, PublishMode, PublishModeSource, RelayConfig, RelayConfigSource, - RelayPublishPolicy, RpcConfig, RuntimeConfig, SignerBackend, SignerConfig, Verbosity, + PathsConfig, PublishConfig, PublishTransport, PublishTransportSource, RelayConfig, + RelayConfigSource, RelayPublishPolicy, RpcConfig, RuntimeConfig, SignerBackend, + SignerConfig, Verbosity, }; use crate::runtime::logging::LoggingState; @@ -1340,8 +1375,9 @@ mod tests { backend: SignerBackend::Local, }, publish: PublishConfig { - mode: PublishMode::NostrRelay, - source: PublishModeSource::Defaults, + transport: PublishTransport::DirectNostrRelay, + source: PublishTransportSource::Defaults, + radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(), }, relay: RelayConfig { urls: Vec::new(), @@ -1364,7 +1400,6 @@ mod tests { }, rpc: RpcConfig { url: "http://127.0.0.1:7070".into(), - bridge_bearer_token: None, }, rhi: crate::runtime::config::RhiConfig { trusted_worker_pubkeys: Vec::new(), diff --git a/src/ops/exec/farm.rs b/src/ops/exec/farm.rs @@ -14,7 +14,7 @@ use crate::ops::{ OperationResult, OperationResultData, OperationService, }; use crate::runtime::RuntimeError; -use crate::runtime::config::{PublishMode, RuntimeConfig}; +use crate::runtime::config::{PublishTransport, RuntimeConfig}; use crate::view::runtime::{CommandDisposition, FarmPublishView}; pub struct FarmOperationService<'a> { @@ -172,7 +172,6 @@ impl OperationService<FarmPublishRequest> for FarmOperationService<'_> { .idempotency_key .clone() .or_else(|| string_input(&request, "idempotency_key")), - signer_session_id: string_input(&request, "signer_session_id"), print_event: bool_input(&request, "print_event").unwrap_or(false), }; if request.context.requires_approval_token() { @@ -180,7 +179,10 @@ impl OperationService<FarmPublishRequest> for FarmOperationService<'_> { request.operation_id(), )); } - if matches!(self.config.publish.mode, PublishMode::NostrRelay) { + if matches!( + self.config.publish.transport, + PublishTransport::DirectNostrRelay + ) { require_relay_target(&request, self.config)?; } @@ -390,8 +392,9 @@ mod tests { use crate::runtime::config::{ AccountConfig, AccountSecretContractConfig, HyfConfig, IdentityConfig, InteractionConfig, LocalConfig, LoggingConfig, MigrationConfig, MycConfig, OutputConfig, OutputFormat, - PathsConfig, PublishConfig, PublishMode, PublishModeSource, RelayConfig, RelayConfigSource, - RelayPublishPolicy, RpcConfig, RuntimeConfig, SignerBackend, SignerConfig, Verbosity, + PathsConfig, PublishConfig, PublishTransport, PublishTransportSource, RelayConfig, + RelayConfigSource, RelayPublishPolicy, RpcConfig, RuntimeConfig, SignerBackend, + SignerConfig, Verbosity, }; #[test] @@ -545,8 +548,9 @@ mod tests { backend: SignerBackend::Local, }, publish: PublishConfig { - mode: PublishMode::NostrRelay, - source: PublishModeSource::Defaults, + transport: PublishTransport::DirectNostrRelay, + source: PublishTransportSource::Defaults, + radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(), }, relay: RelayConfig { urls: Vec::new(), @@ -569,7 +573,6 @@ mod tests { }, rpc: RpcConfig { url: "http://127.0.0.1:7070".into(), - bridge_bearer_token: None, }, rhi: crate::runtime::config::RhiConfig { trusted_worker_pubkeys: Vec::new(), diff --git a/src/ops/exec/listing.rs b/src/ops/exec/listing.rs @@ -158,10 +158,9 @@ impl OperationService<ListingUpdateRequest> for ListingOperationService<'_> { } let args = mutation_args(&request)?; let config = mutation_config(self.config, &request); - let view = map_runtime( - request.operation_id(), - crate::runtime::listing::update(&config, &args), - )?; + let view = crate::runtime::listing::update(&config, &args).map_err(|error| { + OperationAdapterError::sdk_adapter_failure(request.operation_id(), error) + })?; mutation_result::<ListingUpdateResult>(request.operation_id(), &view) } } @@ -243,10 +242,9 @@ impl OperationService<ListingArchiveRequest> for ListingOperationService<'_> { } let args = mutation_args(&request)?; let config = mutation_config(self.config, &request); - let view = map_runtime( - request.operation_id(), - crate::runtime::listing::archive(&config, &args), - )?; + let view = crate::runtime::listing::archive(&config, &args).map_err(|error| { + OperationAdapterError::sdk_adapter_failure(request.operation_id(), error) + })?; mutation_result::<ListingArchiveResult>(request.operation_id(), &view) } } @@ -275,7 +273,6 @@ where .idempotency_key .clone() .or_else(|| string_input(request, "idempotency_key")), - signer_session_id: string_input(request, "signer_session_id"), print_event: bool_input(request, "print_event").unwrap_or(false), offline: matches!(request.context.network_mode, OperationNetworkMode::Offline), }) @@ -479,8 +476,9 @@ mod tests { use crate::runtime::config::{ AccountConfig, AccountSecretContractConfig, HyfConfig, IdentityConfig, InteractionConfig, LocalConfig, LoggingConfig, MigrationConfig, MycConfig, OutputConfig, OutputFormat, - PathsConfig, PublishConfig, PublishMode, PublishModeSource, RelayConfig, RelayConfigSource, - RelayPublishPolicy, RpcConfig, RuntimeConfig, SignerBackend, SignerConfig, Verbosity, + PathsConfig, PublishConfig, PublishTransport, PublishTransportSource, RelayConfig, + RelayConfigSource, RelayPublishPolicy, RpcConfig, RuntimeConfig, SignerBackend, + SignerConfig, Verbosity, }; #[test] @@ -616,8 +614,9 @@ mod tests { backend: SignerBackend::Local, }, publish: PublishConfig { - mode: PublishMode::NostrRelay, - source: PublishModeSource::Defaults, + transport: PublishTransport::DirectNostrRelay, + source: PublishTransportSource::Defaults, + radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(), }, relay: RelayConfig { urls: Vec::new(), @@ -640,7 +639,6 @@ mod tests { }, rpc: RpcConfig { url: "http://127.0.0.1:7070".into(), - bridge_bearer_token: None, }, rhi: crate::runtime::config::RhiConfig { trusted_worker_pubkeys: Vec::new(), diff --git a/src/ops/exec/market.rs b/src/ops/exec/market.rs @@ -256,8 +256,9 @@ mod tests { use crate::runtime::config::{ AccountConfig, AccountSecretContractConfig, HyfConfig, IdentityConfig, InteractionConfig, LocalConfig, LoggingConfig, MigrationConfig, MycConfig, OutputConfig, OutputFormat, - PathsConfig, PublishConfig, PublishMode, PublishModeSource, RelayConfig, RelayConfigSource, - RelayPublishPolicy, RpcConfig, RuntimeConfig, SignerBackend, SignerConfig, Verbosity, + PathsConfig, PublishConfig, PublishTransport, PublishTransportSource, RelayConfig, + RelayConfigSource, RelayPublishPolicy, RpcConfig, RuntimeConfig, SignerBackend, + SignerConfig, Verbosity, }; use crate::view::runtime::{ FindPriceView, FindQuantityView, FindResultProvenanceView, FindResultView, FindView, @@ -677,8 +678,9 @@ mod tests { backend: SignerBackend::Local, }, publish: PublishConfig { - mode: PublishMode::NostrRelay, - source: PublishModeSource::Defaults, + transport: PublishTransport::DirectNostrRelay, + source: PublishTransportSource::Defaults, + radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(), }, relay: RelayConfig { urls: Vec::new(), @@ -701,7 +703,6 @@ mod tests { }, rpc: RpcConfig { url: "http://127.0.0.1:7070".into(), - bridge_bearer_token: None, }, rhi: crate::runtime::config::RhiConfig { trusted_worker_pubkeys: Vec::new(), diff --git a/src/ops/exec/order.rs b/src/ops/exec/order.rs @@ -1258,8 +1258,9 @@ mod tests { use crate::runtime::config::{ AccountConfig, AccountSecretContractConfig, HyfConfig, IdentityConfig, InteractionConfig, LocalConfig, LoggingConfig, MigrationConfig, MycConfig, OutputConfig, OutputFormat, - PathsConfig, PublishConfig, PublishMode, PublishModeSource, RelayConfig, RelayConfigSource, - RelayPublishPolicy, RpcConfig, RuntimeConfig, SignerBackend, SignerConfig, Verbosity, + PathsConfig, PublishConfig, PublishTransport, PublishTransportSource, RelayConfig, + RelayConfigSource, RelayPublishPolicy, RpcConfig, RuntimeConfig, SignerBackend, + SignerConfig, Verbosity, }; use crate::view::runtime::OrderDecisionView; @@ -1801,8 +1802,9 @@ mod tests { backend: SignerBackend::Local, }, publish: PublishConfig { - mode: PublishMode::NostrRelay, - source: PublishModeSource::Defaults, + transport: PublishTransport::DirectNostrRelay, + source: PublishTransportSource::Defaults, + radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(), }, relay: RelayConfig { urls: Vec::new(), @@ -1825,7 +1827,6 @@ mod tests { }, rpc: RpcConfig { url: "http://127.0.0.1:7070".into(), - bridge_bearer_token: None, }, rhi: crate::runtime::config::RhiConfig { trusted_worker_pubkeys: Vec::new(), diff --git a/src/ops/exec/runtime.rs b/src/ops/exec/runtime.rs @@ -1,5 +1,5 @@ use serde::Serialize; -use serde_json::{Value, json}; +use serde_json::Value; use crate::cli::global::SyncWatchArgs; use crate::ops::{ @@ -10,7 +10,7 @@ use crate::ops::{ SyncWatchResult, }; use crate::runtime::RuntimeError; -use crate::runtime::config::{PublishMode, RuntimeConfig}; +use crate::runtime::config::RuntimeConfig; use crate::view::runtime::{CommandDisposition, SyncActionView, SyncStatusView}; pub struct RuntimeOperationService<'a> { @@ -80,9 +80,6 @@ impl OperationService<SyncPushRequest> for RuntimeOperationService<'_> { &self, request: OperationRequest<SyncPushRequest>, ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - if matches!(self.config.publish.mode, PublishMode::Radrootsd) { - return Err(sync_push_radrootsd_unavailable(self.config)); - } if request.context.requires_approval_token() { return Err(OperationAdapterError::approval_required("sync.push")); } @@ -111,26 +108,6 @@ impl OperationService<SyncWatchRequest> for RuntimeOperationService<'_> { } } -fn sync_push_radrootsd_unavailable(config: &RuntimeConfig) -> OperationAdapterError { - OperationAdapterError::operation_unavailable_with_detail( - "sync.push", - crate::runtime::sync::RADROOTSD_SYNC_PUSH_UNAVAILABLE_REASON.to_owned(), - json!({ - "publish": { - "mode": config.publish.mode.as_str(), - "source": config.publish.source.as_str(), - "transport_family": config.publish.mode.transport_family(), - "state": "unavailable", - "executable": false, - "provider": { - "provider_runtime_id": "radrootsd", - "state": "unavailable", - } - } - }), - ) -} - fn serialized_operation_result<R, T>(value: &T) -> Result<OperationResult<R>, OperationAdapterError> where R: OperationResultData, @@ -246,13 +223,14 @@ mod tests { use super::RuntimeOperationService; use crate::ops::{ OperationAdapter, OperationContext, OperationRequest, RelayListRequest, - SignerStatusGetRequest, SyncPushRequest, SyncStatusGetRequest, + SignerStatusGetRequest, SyncStatusGetRequest, }; use crate::runtime::config::{ AccountConfig, AccountSecretContractConfig, HyfConfig, IdentityConfig, InteractionConfig, LocalConfig, LoggingConfig, MigrationConfig, MycConfig, OutputConfig, OutputFormat, - PathsConfig, PublishConfig, PublishMode, PublishModeSource, RelayConfig, RelayConfigSource, - RelayPublishPolicy, RpcConfig, RuntimeConfig, SignerBackend, SignerConfig, Verbosity, + PathsConfig, PublishConfig, PublishTransport, PublishTransportSource, RelayConfig, + RelayConfigSource, RelayPublishPolicy, RpcConfig, RuntimeConfig, SignerBackend, + SignerConfig, Verbosity, }; #[test] @@ -313,31 +291,6 @@ mod tests { assert_eq!(envelope.result["actions"][0], "radroots sync pull"); } - #[test] - fn runtime_service_rejects_radrootsd_sync_push_before_approval_or_store_checks() { - let dir = tempdir().expect("tempdir"); - let mut config = sample_config(dir.path(), Vec::new()); - config.publish.mode = PublishMode::Radrootsd; - config.publish.source = PublishModeSource::Flags; - let service = OperationAdapter::new(RuntimeOperationService::new(&config)); - - let sync = OperationRequest::new(OperationContext::default(), SyncPushRequest::default()) - .expect("sync push request"); - let error = service.execute(sync).expect_err("radrootsd sync push"); - let output = error.to_output_error(); - let detail = output.detail.expect("radrootsd detail"); - - assert_eq!(output.code, "operation_unavailable"); - assert_eq!(output.exit_code, 3); - assert_eq!(detail["publish"]["mode"], "radrootsd"); - assert_eq!(detail["publish"]["source"], "cli flags · local first"); - assert_eq!( - detail["publish"]["provider"]["provider_runtime_id"], - "radrootsd" - ); - assert_eq!(detail["class"], "operation"); - } - fn sample_config(root: &Path, relays: Vec<String>) -> RuntimeConfig { let data = root.join("data"); let logs = root.join("logs"); @@ -405,8 +358,9 @@ mod tests { backend: SignerBackend::Local, }, publish: PublishConfig { - mode: PublishMode::NostrRelay, - source: PublishModeSource::Defaults, + transport: PublishTransport::DirectNostrRelay, + source: PublishTransportSource::Defaults, + radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(), }, relay: RelayConfig { urls: relays, @@ -429,7 +383,6 @@ mod tests { }, rpc: RpcConfig { url: "http://127.0.0.1:7070".into(), - bridge_bearer_token: None, }, rhi: crate::runtime::config::RhiConfig { trusted_worker_pubkeys: Vec::new(), diff --git a/src/out/envelope.rs b/src/out/envelope.rs @@ -277,13 +277,15 @@ 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_RPC_BEARER_TOKEN" { + if action + == "configure RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE or RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_SECRET_ID" + { return Some(NextAction { kind: NextActionKind::OperatorConfig, - label: "configure rpc bearer token".to_owned(), + label: "configure radrootsd proxy token source".to_owned(), command: None, description: Some(action.to_owned()), - env_var: Some("RADROOTS_CLI_RPC_BEARER_TOKEN".to_owned()), + env_var: Some("RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE".to_owned()), config_key: None, }); } @@ -324,7 +326,7 @@ fn next_action_label(command: &str) -> String { "--format" | "--account-id" | "--relay" - | "--publish-mode" + | "--publish-transport" | "--idempotency-key" | "--correlation-id" | "--approval-token" @@ -602,14 +604,14 @@ mod tests { fn failure_envelope_derives_operator_config_next_actions() { let mut error = OutputError::new( "operation_unavailable", - "publish mode needs operator configuration", + "publish transport needs operator configuration", CliExitCode::RuntimeUnavailable, ); error.detail = Some(json!({ "actions": [ - "configure RADROOTS_CLI_RPC_BEARER_TOKEN", + "configure RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE or RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_SECRET_ID", "configure signer.remote_nip46 signer_session_ref", - "configure RADROOTS_CLI_RPC_BEARER_TOKEN" + "configure RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE or RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_SECRET_ID" ] })); let envelope = OutputEnvelope::failure( @@ -624,11 +626,14 @@ mod tests { envelope.next_actions[0].kind, NextActionKind::OperatorConfig ); - assert_eq!(envelope.next_actions[0].label, "configure rpc bearer token"); + assert_eq!( + envelope.next_actions[0].label, + "configure radrootsd proxy token source" + ); assert_eq!(envelope.next_actions[0].command, None); assert_eq!( envelope.next_actions[0].env_var.as_deref(), - Some("RADROOTS_CLI_RPC_BEARER_TOKEN") + Some("RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE") ); assert_eq!( envelope.next_actions[1].kind, diff --git a/src/registry/mod.rs b/src/registry/mod.rs @@ -227,7 +227,8 @@ pub fn requires_local_signer_mode(operation_id: &str) -> bool { ) } -pub fn requires_nostr_relay_publish_mode(operation_id: &str) -> bool { +#[cfg(test)] +pub fn requires_direct_nostr_relay_publish_transport(operation_id: &str) -> bool { matches!( operation_id, "sync.push" @@ -260,8 +261,8 @@ mod tests { use super::{ ApprovalPolicy, NetworkRequirement, OPERATION_REGISTRY, OperationRole, RiskLevel, - get_operation, network_requirement, requires_local_signer_mode, - requires_nostr_relay_publish_mode, + get_operation, network_requirement, requires_direct_nostr_relay_publish_transport, + requires_local_signer_mode, }; const EXPECTED_OPERATION_IDS: &[&str] = &[ @@ -597,10 +598,12 @@ mod tests { } #[test] - fn registry_nostr_relay_publish_requirements_are_explicit() { + fn registry_direct_nostr_relay_publish_requirements_are_explicit() { let publish = OPERATION_REGISTRY .iter() - .filter(|operation| requires_nostr_relay_publish_mode(operation.operation_id)) + .filter(|operation| { + requires_direct_nostr_relay_publish_transport(operation.operation_id) + }) .map(|operation| operation.operation_id) .collect::<BTreeSet<_>>(); let expected = [ diff --git a/src/runtime/config.rs b/src/runtime/config.rs @@ -44,14 +44,16 @@ const ENV_CLI_ACCOUNT_SECRET_BACKEND: &str = "RADROOTS_CLI_ACCOUNT_SECRET_BACKEN const ENV_CLI_ACCOUNT_SECRET_FALLBACK: &str = "RADROOTS_CLI_ACCOUNT_SECRET_FALLBACK"; const ENV_CLI_IDENTITY_PATH: &str = "RADROOTS_CLI_IDENTITY_PATH"; const ENV_CLI_SIGNER_BACKEND: &str = "RADROOTS_CLI_SIGNER_BACKEND"; -const ENV_CLI_PUBLISH_MODE: &str = "RADROOTS_CLI_PUBLISH_MODE"; +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_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_RPC_URL: &str = "RADROOTS_CLI_RPC_URL"; -const ENV_CLI_RPC_BEARER_TOKEN: &str = "RADROOTS_CLI_RPC_BEARER_TOKEN"; const ENV_CLI_RHI_TRUSTED_WORKER_PUBKEYS: &str = "RADROOTS_CLI_RHI_TRUSTED_WORKER_PUBKEYS"; const SUPPORTED_ENV_FILE_KEYS: &[&str] = &[ ENV_CLI_OUTPUT_FORMAT, @@ -65,14 +67,15 @@ const SUPPORTED_ENV_FILE_KEYS: &[&str] = &[ ENV_CLI_ACCOUNT_SECRET_FALLBACK, ENV_CLI_IDENTITY_PATH, ENV_CLI_SIGNER_BACKEND, - ENV_CLI_PUBLISH_MODE, + 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_MYC_EXECUTABLE, ENV_CLI_MYC_STATUS_TIMEOUT_MS, ENV_CLI_HYF_ENABLED, ENV_CLI_HYF_EXECUTABLE, - ENV_CLI_RPC_URL, - ENV_CLI_RPC_BEARER_TOKEN, ENV_CLI_RHI_TRUSTED_WORKER_PUBKEYS, ]; @@ -181,29 +184,29 @@ pub struct SignerConfig { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PublishMode { - NostrRelay, - Radrootsd, +pub enum PublishTransport { + DirectNostrRelay, + RadrootsdProxy, } -impl PublishMode { +impl PublishTransport { pub fn as_str(self) -> &'static str { match self { - Self::NostrRelay => "nostr_relay", - Self::Radrootsd => "radrootsd", + Self::DirectNostrRelay => "direct_nostr_relay", + Self::RadrootsdProxy => "radrootsd_proxy", } } pub fn transport_family(self) -> &'static str { match self { - Self::NostrRelay => "nostr_relay", - Self::Radrootsd => "radrootsd", + Self::DirectNostrRelay => "direct_nostr_relay", + Self::RadrootsdProxy => "radrootsd_proxy", } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PublishModeSource { +pub enum PublishTransportSource { Flags, Environment, UserConfig, @@ -211,7 +214,7 @@ pub enum PublishModeSource { Defaults, } -impl PublishModeSource { +impl PublishTransportSource { pub fn as_str(self) -> &'static str { match self { Self::Flags => "cli flags · local first", @@ -225,8 +228,26 @@ impl PublishModeSource { #[derive(Debug, Clone, PartialEq, Eq)] pub struct PublishConfig { - pub mode: PublishMode, - pub source: PublishModeSource, + pub transport: PublishTransport, + pub source: PublishTransportSource, + pub radrootsd_proxy: RadrootsdProxyConfig, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RadrootsdProxyConfig { + pub url: String, + pub token_file: Option<PathBuf>, + pub token_secret_id: Option<String>, +} + +impl Default for RadrootsdProxyConfig { + fn default() -> Self { + Self { + url: DEFAULT_RPC_URL.to_owned(), + token_file: None, + token_secret_id: None, + } + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -359,7 +380,6 @@ pub struct CapabilityBindingInspection { #[derive(Debug, Clone, PartialEq, Eq)] pub struct RpcConfig { pub url: String, - pub bridge_bearer_token: Option<String>, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -463,7 +483,16 @@ struct RelayFileConfig { #[derive(Debug, Default, Deserialize)] #[serde(default, deny_unknown_fields)] struct PublishFileConfig { - mode: Option<String>, + transport: Option<String>, + radrootsd_proxy: Option<RadrootsdProxyFileConfig>, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct RadrootsdProxyFileConfig { + url: Option<String>, + token_file: Option<PathBuf>, + token_secret_id: Option<String>, } #[derive(Debug, Default, Deserialize)] @@ -518,8 +547,6 @@ struct CapabilityBindingSpec { pub(crate) const SIGNER_REMOTE_NIP46_CAPABILITY: &str = "signer.remote_nip46"; pub(crate) const INFERENCE_HYF_STDIO_CAPABILITY: &str = "inference.hyf_stdio"; -pub(crate) const RADROOTSD_PUBLISH_DEFERRED_REASON: &str = "radrootsd publish mode is deferred for the active CLI buyer/seller workflow; use publish mode `nostr_relay` with local signer custody and configured relays"; - const CAPABILITY_BINDING_SPECS: &[CapabilityBindingSpec] = &[ CapabilityBindingSpec { capability_id: SIGNER_REMOTE_NIP46_CAPABILITY, @@ -909,17 +936,14 @@ fn load_cli_config_file(path: &Path) -> Result<Option<CliConfigFile>, RuntimeErr } fn resolve_rpc_config( - env: &dyn Environment, - env_file: &EnvFileValues, + _env: &dyn Environment, + _env_file: &EnvFileValues, user_config: Option<&CliConfigFile>, workspace_config: Option<&CliConfigFile>, ) -> Result<RpcConfig, RuntimeError> { - let url = env_value(env, env_file, &[ENV_CLI_RPC_URL]) - .or_else(|| { - user_config - .and_then(|config| config.rpc.as_ref()) - .and_then(|rpc| rpc.url.clone()) - }) + let url = user_config + .and_then(|config| config.rpc.as_ref()) + .and_then(|rpc| rpc.url.clone()) .or_else(|| { workspace_config .and_then(|config| config.rpc.as_ref()) @@ -929,7 +953,37 @@ fn resolve_rpc_config( Ok(RpcConfig { url: validate_rpc_url(url.as_str())?, - bridge_bearer_token: env_value(env, env_file, &[ENV_CLI_RPC_BEARER_TOKEN]), + }) +} + +fn resolve_radrootsd_proxy_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())) + .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 { + url: validate_rpc_url(url.as_str())?, + token_file, + token_secret_id, }) } @@ -1206,43 +1260,50 @@ fn resolve_publish_config( user_config: Option<&CliConfigFile>, workspace_config: Option<&CliConfigFile>, ) -> Result<PublishConfig, RuntimeError> { - if let Some(value) = args.publish_mode.clone() { + 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 { - mode: parse_publish_mode("--publish-mode", value)?, - source: PublishModeSource::Flags, + 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_MODE]) { + if let Some((key, value)) = env_value_entry(env, env_file, &[ENV_CLI_PUBLISH_TRANSPORT]) { return Ok(PublishConfig { - mode: parse_publish_mode(key.as_str(), value)?, - source: PublishModeSource::Environment, + 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.mode.clone()) + .and_then(|publish| publish.transport.clone()) { return Ok(PublishConfig { - mode: parse_publish_mode("user config [publish].mode", value)?, - source: PublishModeSource::UserConfig, + 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.mode.clone()) + .and_then(|publish| publish.transport.clone()) { return Ok(PublishConfig { - mode: parse_publish_mode("workspace config [publish].mode", value)?, - source: PublishModeSource::WorkspaceConfig, + transport: parse_publish_transport("workspace config [publish].transport", value)?, + source: PublishTransportSource::WorkspaceConfig, + radrootsd_proxy, }); } Ok(PublishConfig { - mode: PublishMode::NostrRelay, - source: PublishModeSource::Defaults, + transport: PublishTransport::DirectNostrRelay, + source: PublishTransportSource::Defaults, + radrootsd_proxy, }) } @@ -1741,14 +1802,14 @@ fn parse_signer_mode(source: &str, value: String) -> Result<SignerBackend, Runti } } -fn parse_publish_mode(source: &str, value: String) -> Result<PublishMode, RuntimeError> { +fn parse_publish_transport(source: &str, value: String) -> Result<PublishTransport, RuntimeError> { match value.trim().to_ascii_lowercase().as_str() { - "nostr_relay" => Ok(PublishMode::NostrRelay), - "radrootsd" => Ok(PublishMode::Radrootsd), + "direct_nostr_relay" => Ok(PublishTransport::DirectNostrRelay), + "radrootsd_proxy" => Ok(PublishTransport::RadrootsdProxy), other => Err(RuntimeError::Config(format!( "{source} must be `{}` or `{}`, got `{other}`", - PublishMode::NostrRelay.as_str(), - PublishMode::Radrootsd.as_str() + PublishTransport::DirectNostrRelay.as_str(), + PublishTransport::RadrootsdProxy.as_str() ))), } } @@ -1853,7 +1914,7 @@ mod tests { CapabilityBindingSource, CapabilityBindingTargetKind, DEFAULT_HYF_EXECUTABLE, DEFAULT_LOG_FILTER, DEFAULT_MYC_STATUS_TIMEOUT_MS, DEFAULT_RPC_URL, EnvFileValues, Environment, HyfConfig, INFERENCE_HYF_STDIO_CAPABILITY, InteractionConfig, OutputConfig, - OutputFormat, PathsConfig, PublishConfig, PublishMode, PublishModeSource, + OutputFormat, PathsConfig, PublishConfig, PublishTransport, PublishTransportSource, RelayConfigSource, RelayPublishPolicy, RuntimeConfig, SignerBackend, Verbosity, parse_env_file_values, }; @@ -1964,7 +2025,7 @@ mod tests { log_stdout: true, identity_path: Some(PathBuf::from("custom-identity.json")), signer: Some("local".to_owned()), - publish_mode: Some("nostr_relay".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), @@ -1982,8 +2043,8 @@ mod tests { ), ("RADROOTS_CLI_SIGNER_BACKEND".to_owned(), "myc".to_owned()), ( - "RADROOTS_CLI_PUBLISH_MODE".to_owned(), - "radrootsd".to_owned(), + "RADROOTS_CLI_PUBLISH_TRANSPORT".to_owned(), + "radrootsd_proxy".to_owned(), ), ( "RADROOTS_CLI_RELAYS_URLS".to_owned(), @@ -2088,8 +2149,9 @@ mod tests { assert_eq!( resolved.publish, PublishConfig { - mode: PublishMode::NostrRelay, - source: PublishModeSource::Flags, + transport: PublishTransport::DirectNostrRelay, + source: PublishTransportSource::Flags, + radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(), } ); assert_eq!( @@ -2133,8 +2195,8 @@ mod tests { ), ("RADROOTS_CLI_SIGNER_BACKEND".to_owned(), "myc".to_owned()), ( - "RADROOTS_CLI_PUBLISH_MODE".to_owned(), - "radrootsd".to_owned(), + "RADROOTS_CLI_PUBLISH_TRANSPORT".to_owned(), + "radrootsd_proxy".to_owned(), ), ( "RADROOTS_CLI_RELAYS_URLS".to_owned(), @@ -2197,8 +2259,9 @@ mod tests { assert_eq!( resolved.publish, PublishConfig { - mode: PublishMode::Radrootsd, - source: PublishModeSource::Environment, + transport: PublishTransport::RadrootsdProxy, + source: PublishTransportSource::Environment, + radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(), } ); assert_eq!( @@ -2281,7 +2344,10 @@ mod tests { Some(RadrootsSecretBackend::EncryptedFile) ); assert_eq!(resolved.signer.backend, SignerBackend::Local); - assert_eq!(resolved.publish.mode, PublishMode::NostrRelay); + assert_eq!( + resolved.publish.transport, + PublishTransport::DirectNostrRelay + ); assert_eq!(resolved.relay.urls, Vec::<String>::new()); assert_eq!(resolved.myc.executable, PathBuf::from("myc")); assert_eq!( @@ -2294,7 +2360,6 @@ mod tests { PathBuf::from(DEFAULT_HYF_EXECUTABLE) ); assert_eq!(resolved.rpc.url, DEFAULT_RPC_URL); - assert_eq!(resolved.rpc.bridge_bearer_token, None); assert_eq!(resolved.rhi.trusted_worker_pubkeys, Vec::<String>::new()); } @@ -2525,14 +2590,14 @@ path = "identity/from-toml.json" ); let env = MapEnvironment::new(BTreeMap::from([( - "RADROOTS_CLI_PUBLISH_MODE".to_owned(), + "RADROOTS_CLI_PUBLISH_TRANSPORT".to_owned(), "relay".to_owned(), )])); let error = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) - .expect_err("invalid publish mode"); - assert!(error.to_string().contains("RADROOTS_CLI_PUBLISH_MODE")); - assert!(error.to_string().contains("nostr_relay")); - assert!(error.to_string().contains("radrootsd")); + .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")); let args = RuntimeInvocationArgs { myc_status_timeout_ms: Some(0), @@ -2557,7 +2622,7 @@ 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_MODE=radrootsd +RADROOTS_CLI_PUBLISH_TRANSPORT=radrootsd_proxy RADROOTS_CLI_RELAYS_URLS=wss://relay.env-file RADROOTS_CLI_MYC_EXECUTABLE=bin/myc RADROOTS_CLI_MYC_STATUS_TIMEOUT_MS=4500 @@ -2583,8 +2648,9 @@ RADROOTS_CLI_HYF_EXECUTABLE=bin/hyfd assert_eq!( resolved.publish, PublishConfig { - mode: PublishMode::Radrootsd, - source: PublishModeSource::Environment, + transport: PublishTransport::RadrootsdProxy, + source: PublishTransportSource::Environment, + radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(), } ); assert_eq!(resolved.relay.urls, vec!["wss://relay.env-file".to_owned()]); @@ -2740,7 +2806,7 @@ RADROOTS_CLI_LOGGING_STDOUT=false } #[test] - fn publish_mode_precedence_tracks_source() { + fn publish_transport_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"); @@ -2750,12 +2816,12 @@ RADROOTS_CLI_LOGGING_STDOUT=false fs::create_dir_all(&app_config_dir).expect("app config dir"); fs::write( repo_local_root.join("config.toml"), - "[publish]\nmode = \"radrootsd\"\n", + "[publish]\ntransport = \"radrootsd_proxy\"\n", ) .expect("write workspace config"); fs::write( app_config_dir.join("config.toml"), - "[publish]\nmode = \"nostr_relay\"\n", + "[publish]\ntransport = \"direct_nostr_relay\"\n", ) .expect("write user config"); @@ -2764,21 +2830,22 @@ RADROOTS_CLI_LOGGING_STDOUT=false repo_local_root.clone(), user_home.clone(), BTreeMap::from([( - "RADROOTS_CLI_PUBLISH_MODE".to_owned(), - "radrootsd".to_owned(), + "RADROOTS_CLI_PUBLISH_TRANSPORT".to_owned(), + "radrootsd_proxy".to_owned(), )]), ); let args = RuntimeInvocationArgs { - publish_mode: Some("nostr_relay".to_owned()), + 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 mode"); + .expect("resolve flag publish transport"); assert_eq!( resolved.publish, PublishConfig { - mode: PublishMode::NostrRelay, - source: PublishModeSource::Flags, + transport: PublishTransport::DirectNostrRelay, + source: PublishTransportSource::Flags, + radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(), } ); @@ -2787,18 +2854,19 @@ RADROOTS_CLI_LOGGING_STDOUT=false repo_local_root.clone(), user_home.clone(), BTreeMap::from([( - "RADROOTS_CLI_PUBLISH_MODE".to_owned(), - "radrootsd".to_owned(), + "RADROOTS_CLI_PUBLISH_TRANSPORT".to_owned(), + "radrootsd_proxy".to_owned(), )]), ); let resolved = RuntimeConfig::resolve_with_env_file(&runtime_args(), &env, &EnvFileValues::default()) - .expect("resolve environment publish mode"); + .expect("resolve environment publish transport"); assert_eq!( resolved.publish, PublishConfig { - mode: PublishMode::Radrootsd, - source: PublishModeSource::Environment, + transport: PublishTransport::RadrootsdProxy, + source: PublishTransportSource::Environment, + radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(), } ); @@ -2810,12 +2878,13 @@ RADROOTS_CLI_LOGGING_STDOUT=false ); let resolved = RuntimeConfig::resolve_with_env_file(&runtime_args(), &env, &EnvFileValues::default()) - .expect("resolve user publish mode"); + .expect("resolve user publish transport"); assert_eq!( resolved.publish, PublishConfig { - mode: PublishMode::NostrRelay, - source: PublishModeSource::UserConfig, + transport: PublishTransport::DirectNostrRelay, + source: PublishTransportSource::UserConfig, + radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(), } ); @@ -2828,12 +2897,13 @@ RADROOTS_CLI_LOGGING_STDOUT=false ); let resolved = RuntimeConfig::resolve_with_env_file(&runtime_args(), &env, &EnvFileValues::default()) - .expect("resolve workspace publish mode"); + .expect("resolve workspace publish transport"); assert_eq!( resolved.publish, PublishConfig { - mode: PublishMode::Radrootsd, - source: PublishModeSource::WorkspaceConfig, + transport: PublishTransport::RadrootsdProxy, + source: PublishTransportSource::WorkspaceConfig, + radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(), } ); @@ -2841,12 +2911,13 @@ RADROOTS_CLI_LOGGING_STDOUT=false 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 mode"); + .expect("resolve default publish transport"); assert_eq!( resolved.publish, PublishConfig { - mode: PublishMode::NostrRelay, - source: PublishModeSource::Defaults, + transport: PublishTransport::DirectNostrRelay, + source: PublishTransportSource::Defaults, + radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(), } ); } @@ -3042,18 +3113,18 @@ RADROOTS_CLI_LOGGING_STDOUT=false fs::create_dir_all(&repo_local_root).expect("workspace config dir"); fs::write( repo_local_root.join("config.toml"), - "[publish]\nmode = \"nostr\"\n", + "[publish]\ntransport = \"nostr\"\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 mode"); + .expect_err("invalid publish transport"); let message = error.to_string(); - assert!(message.contains("workspace config [publish].mode")); - assert!(message.contains("nostr_relay")); - assert!(message.contains("radrootsd")); + assert!(message.contains("workspace config [publish].transport")); + assert!(message.contains("direct_nostr_relay")); + assert!(message.contains("radrootsd_proxy")); } #[test] diff --git a/src/runtime/direct_relay.rs b/src/runtime/direct_relay.rs @@ -1,10 +1,8 @@ use std::time::Duration; -use radroots_events_codec::wire::WireEventParts; -use radroots_identity::RadrootsIdentity; use radroots_nostr::prelude::{ RadrootsNostrClient, RadrootsNostrError, RadrootsNostrEvent, RadrootsNostrFilter, - RadrootsNostrOutput, radroots_nostr_build_event, + RadrootsNostrOutput, }; const RELAY_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); @@ -17,18 +15,6 @@ pub struct DirectRelayFailure { } #[derive(Debug, Clone)] -pub struct DirectRelayPublishReceipt { - pub event: RadrootsNostrEvent, - pub event_id: String, - pub created_at: u32, - pub signature: String, - pub target_relays: Vec<String>, - pub connected_relays: Vec<String>, - pub acknowledged_relays: Vec<String>, - pub failed_relays: Vec<DirectRelayFailure>, -} - -#[derive(Debug, Clone)] pub struct DirectRelayFetchReceipt { pub target_relays: Vec<String>, pub connected_relays: Vec<String>, @@ -37,39 +23,6 @@ pub struct DirectRelayFetchReceipt { } #[derive(Debug, thiserror::Error)] -pub enum DirectRelayPublishError { - #[error("direct relay publish requires at least one configured relay")] - MissingRelays, - #[error("failed to build async runtime for direct relay publish: {0}")] - Runtime(String), - #[error("failed to build Nostr event for direct relay publish: {0}")] - Build(#[source] RadrootsNostrError), - #[error("failed to sign Nostr event for direct relay publish: {0}")] - Sign(#[source] RadrootsNostrError), - #[error("failed to configure relay `{relay}` for direct relay publish: {source}")] - RelayConfig { - relay: String, - #[source] - source: RadrootsNostrError, - }, - #[error("direct relay connection failed: {reason}")] - Connect { - reason: String, - target_relays: Vec<String>, - connected_relays: Vec<String>, - failed_relays: Vec<DirectRelayFailure>, - }, - #[error("direct relay publish failed for event `{event_id}`: {reason}")] - Publish { - event_id: String, - reason: String, - target_relays: Vec<String>, - connected_relays: Vec<String>, - failed_relays: Vec<DirectRelayFailure>, - }, -} - -#[derive(Debug, thiserror::Error)] pub enum DirectRelayFetchError { #[error("direct relay fetch requires at least one configured relay")] MissingRelays, @@ -91,25 +44,6 @@ pub enum DirectRelayFetchError { Fetch(#[source] RadrootsNostrError), } -pub fn publish_signed_event_with_identity( - identity: &RadrootsIdentity, - relay_urls: &[String], - event: RadrootsNostrEvent, -) -> Result<DirectRelayPublishReceipt, DirectRelayPublishError> { - if relay_urls.is_empty() { - return Err(DirectRelayPublishError::MissingRelays); - } - - let runtime = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .map_err(|error| DirectRelayPublishError::Runtime(error.to_string()))?; - - runtime.block_on(publish_signed_event_with_identity_async( - identity, relay_urls, event, - )) -} - pub fn fetch_events_from_relays( relay_urls: &[String], filter: RadrootsNostrFilter, @@ -183,90 +117,6 @@ async fn fetch_events_from_relays_async( }) } -async fn publish_signed_event_with_identity_async( - identity: &RadrootsIdentity, - relay_urls: &[String], - event: RadrootsNostrEvent, -) -> Result<DirectRelayPublishReceipt, DirectRelayPublishError> { - let event_id = event.id.to_hex(); - let created_at = event_created_at_u32(&event); - let signature = event.sig.to_string(); - let client = RadrootsNostrClient::from_identity(identity); - - for relay_url in relay_urls { - client.add_write_relay(relay_url).await.map_err(|source| { - DirectRelayPublishError::RelayConfig { - relay: relay_url.clone(), - source, - } - })?; - } - - let connection_output = client.try_connect(RELAY_CONNECT_TIMEOUT).await; - let connected_relays = connection_output - .success - .iter() - .map(ToString::to_string) - .collect::<Vec<_>>(); - let connection_failed_relays = relay_failures_from_output(&connection_output); - if connection_output.success.is_empty() { - return Err(DirectRelayPublishError::Connect { - reason: summarize_failures(&connection_failed_relays), - target_relays: relay_urls.to_vec(), - connected_relays, - failed_relays: connection_failed_relays, - }); - } - - let publish_output = - client - .send_event(&event) - .await - .map_err(|source| DirectRelayPublishError::Publish { - event_id: event_id.clone(), - reason: source.to_string(), - target_relays: relay_urls.to_vec(), - connected_relays: connected_relays.clone(), - failed_relays: Vec::new(), - })?; - let failed_relays = relay_failures_from_output(&publish_output); - if publish_output.success.is_empty() { - return Err(DirectRelayPublishError::Publish { - event_id: event_id.clone(), - reason: summarize_failures(&failed_relays), - target_relays: relay_urls.to_vec(), - connected_relays, - failed_relays, - }); - } - - Ok(DirectRelayPublishReceipt { - event, - event_id, - created_at, - signature, - target_relays: relay_urls.to_vec(), - connected_relays, - acknowledged_relays: publish_output - .success - .iter() - .map(ToString::to_string) - .collect(), - failed_relays, - }) -} - -pub fn sign_parts_with_identity( - identity: &RadrootsIdentity, - parts: WireEventParts, -) -> Result<RadrootsNostrEvent, DirectRelayPublishError> { - let builder = radroots_nostr_build_event(parts.kind, parts.content, parts.tags) - .map_err(DirectRelayPublishError::Build)?; - builder - .sign_with_keys(identity.keys()) - .map_err(|error| DirectRelayPublishError::Sign(error.into())) -} - fn relay_failures_from_output<T: std::fmt::Debug>( output: &RadrootsNostrOutput<T>, ) -> Vec<DirectRelayFailure> { @@ -292,61 +142,18 @@ fn summarize_failures(failed_relays: &[DirectRelayFailure]) -> String { .join("; ") } -fn event_created_at_u32(event: &radroots_nostr::prelude::RadrootsNostrEvent) -> u32 { - u32::try_from(event.created_at.as_secs()).unwrap_or(u32::MAX) -} - #[cfg(test)] mod tests { use std::time::Duration; - use radroots_events_codec::wire::WireEventParts; - use radroots_identity::RadrootsIdentity; use radroots_nostr::prelude::RadrootsNostrFilter; use super::{ - DirectRelayFetchError, event_created_at_u32, fetch_events_from_relays_async, - fetch_events_from_relays_with_timeout, sign_parts_with_identity, + DirectRelayFetchError, fetch_events_from_relays_async, + fetch_events_from_relays_with_timeout, }; #[test] - fn direct_relay_signed_event_preserves_publish_receipt_parity() { - let identity = RadrootsIdentity::generate(); - let parts = WireEventParts { - kind: 30402, - content: "listing".to_owned(), - tags: vec![ - vec!["d".to_owned(), "listing-1".to_owned()], - vec!["title".to_owned(), "eggs".to_owned()], - ], - }; - let event = sign_parts_with_identity(&identity, parts.clone()).expect("signed event"); - let receipt = super::DirectRelayPublishReceipt { - event: event.clone(), - event_id: event.id.to_hex(), - created_at: event_created_at_u32(&event), - signature: event.sig.to_string(), - target_relays: vec!["ws://127.0.0.1:1234".to_owned()], - connected_relays: vec!["ws://127.0.0.1:1234".to_owned()], - acknowledged_relays: vec!["ws://127.0.0.1:1234".to_owned()], - failed_relays: Vec::new(), - }; - let tags = receipt - .event - .tags - .iter() - .map(|tag| tag.as_slice().to_vec()) - .collect::<Vec<_>>(); - - assert_eq!(receipt.event_id, receipt.event.id.to_hex()); - assert_eq!(receipt.signature, receipt.event.sig.to_string()); - assert_eq!(receipt.created_at, event_created_at_u32(&receipt.event)); - assert_eq!(receipt.event.kind.as_u16() as u32, parts.kind); - assert_eq!(receipt.event.content, parts.content); - assert_eq!(tags, parts.tags); - } - - #[test] fn fetch_events_requires_relays_before_runtime_work() { let err = fetch_events_from_relays_with_timeout( &[], diff --git a/src/runtime/farm.rs b/src/runtime/farm.rs @@ -13,7 +13,7 @@ use radroots_nostr::prelude::RadrootsNostrKeys; use radroots_sdk::{ FarmEnqueuePublishRequest, FarmEnqueueReceipt, FarmPreparePublishRequest, FarmPublishPlan, PushOutboxEventReceipt, PushOutboxEventState, PushOutboxReceipt, PushOutboxRelayOutcomeKind, - PushOutboxRequest, SdkMutationState, SdkRelayTargetPolicy, + PushOutboxRequest, SdkMutationState, }; use serde_json::json; @@ -23,15 +23,15 @@ use crate::cli::global::{ }; use crate::runtime::RuntimeError; use crate::runtime::account::{self, AccountRecordView}; -use crate::runtime::config::{ - PublishMode, RADROOTSD_PUBLISH_DEFERRED_REASON, RuntimeConfig, SignerBackend, -}; +use crate::runtime::config::{PublishTransport, RuntimeConfig, SignerBackend}; use crate::runtime::farm_config::{ self, FarmConfigDocument, FarmConfigScope, FarmConfigSelection, FarmListingDefaults, FarmMissingField, FarmPublicationStatus, ResolvedFarmConfig, SUPPORTED_FARM_CONFIG_VERSION, }; use crate::runtime::local_events::append_local_work; -use crate::runtime::sdk::{CliSdkAdapterError, CliSdkSession, sdk_relay_url_policy}; +use crate::runtime::sdk::{ + CliSdkAdapterError, CliSdkSession, sdk_relay_target_policy, sdk_relay_url_policy, +}; use crate::runtime::signer::{ActorWriteBindingError, resolve_actor_write_authority}; use crate::view::runtime::{ FarmConfigDocumentView, FarmConfigSummaryView, FarmGetView, FarmListingDefaultsView, @@ -43,13 +43,10 @@ use crate::view::runtime::{ const FARM_CONFIG_SOURCE: &str = "farm config · local first"; const FARM_SELLER_ACTOR_SOURCE: &str = "farm_config"; const SDK_FARM_WRITE_SOURCE: &str = "SDK farm publish · local key"; -const RADROOTSD_FARM_WRITE_SOURCE: &str = "radrootsd publish transport · deferred"; const SDK_PROFILE_NOT_SUBMITTED_METHOD: &str = "sdk.farm.profile.not_submitted"; const SDK_FARM_PUBLISH_METHOD: &str = "sdk.farm.publish.v1"; const SDK_PROFILE_NOT_SUBMITTED_REASON: &str = "profile publish is not part of SDK farm.publish.v1; profile draft was not submitted"; -const RADROOTSD_BRIDGE_PROFILE_PUBLISH_METHOD: &str = "bridge.profile.publish"; -const RADROOTSD_BRIDGE_FARM_PUBLISH_METHOD: &str = "bridge.farm.publish"; static D_TAG_COUNTER: AtomicU64 = AtomicU64::new(0); @@ -358,7 +355,7 @@ pub fn status( config_valid: false, account_state: "not_checked".to_owned(), listing_defaults_state: "missing".to_owned(), - publish_mode: config.publish.mode.as_str().to_owned(), + publish_transport: config.publish.transport.as_str().to_owned(), publish_state: "not_checked".to_owned(), publish_executable: false, publish_reason: None, @@ -423,7 +420,7 @@ pub fn status( config_valid: true, account_state: account_state.to_owned(), listing_defaults_state: listing_defaults_state.to_owned(), - publish_mode: config.publish.mode.as_str().to_owned(), + publish_transport: config.publish.transport.as_str().to_owned(), publish_state: publish.state.to_owned(), publish_executable: publish.executable, publish_reason: publish.reason, @@ -499,22 +496,21 @@ fn farm_publish_readiness( config: &RuntimeConfig, account: &AccountRecordView, ) -> FarmPublishReadiness { - match config.publish.mode { - PublishMode::NostrRelay => relay_farm_publish_readiness(config, account), - PublishMode::Radrootsd => radrootsd_farm_publish_readiness(config), - } + relay_farm_publish_readiness(config, account) } fn relay_farm_publish_readiness( config: &RuntimeConfig, account: &AccountRecordView, ) -> FarmPublishReadiness { - if config.relay.urls.is_empty() { + if matches!(config.publish.transport, PublishTransport::DirectNostrRelay) + && config.relay.urls.is_empty() + { return FarmPublishReadiness { state: "unconfigured", executable: false, reason: Some( - "nostr_relay farm publish requires at least one configured relay".to_owned(), + "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()], @@ -526,7 +522,7 @@ fn relay_farm_publish_readiness( state: "unavailable", executable: false, reason: Some( - "nostr_relay farm publish requires signer mode `local`; signer mode `myc` is deferred" + "direct_nostr_relay farm publish requires signer mode `local`; signer mode `myc` is deferred" .to_owned(), ), missing: vec!["Local signer mode".to_owned()], @@ -558,19 +554,6 @@ fn relay_farm_publish_readiness( } } -fn radrootsd_farm_publish_readiness(_config: &RuntimeConfig) -> FarmPublishReadiness { - FarmPublishReadiness { - state: "unavailable", - executable: false, - reason: Some(RADROOTSD_PUBLISH_DEFERRED_REASON.to_owned()), - missing: vec!["Active direct relay publish mode".to_owned()], - actions: vec![ - "radroots --publish-mode nostr_relay --relay wss://relay.example.com farm publish" - .to_owned(), - ], - } -} - pub fn publish( config: &RuntimeConfig, args: &FarmPublishArgs, @@ -639,53 +622,15 @@ pub fn publish( let profile_idempotency_key = component_idempotency_key(args, "profile")?; let farm_idempotency_key = component_idempotency_key(args, "farm")?; - if config.output.dry_run { - return match config.publish.mode { - PublishMode::NostrRelay => publish_via_sdk( - config, - args, - resolved, - account_pubkey, - previews, - profile_idempotency_key, - farm_idempotency_key, - ), - PublishMode::Radrootsd => Ok(radrootsd_preflight_publish_view( - config, - args, - &resolved, - &account_pubkey, - previews, - profile_idempotency_key, - farm_idempotency_key, - "unavailable", - RADROOTSD_PUBLISH_DEFERRED_REASON, - )), - }; - } - - match config.publish.mode { - PublishMode::NostrRelay => publish_via_sdk( - config, - args, - resolved, - account_pubkey, - previews, - profile_idempotency_key, - farm_idempotency_key, - ), - PublishMode::Radrootsd => Ok(radrootsd_preflight_publish_view( - config, - args, - &resolved, - &account_pubkey, - previews, - profile_idempotency_key, - farm_idempotency_key, - "unavailable", - RADROOTSD_PUBLISH_DEFERRED_REASON, - )), - } + publish_via_sdk( + config, + args, + resolved, + account_pubkey, + previews, + profile_idempotency_key, + farm_idempotency_key, + ) } fn publish_via_sdk( @@ -742,11 +687,8 @@ fn publish_via_sdk( resolved.document.selection.account.as_str(), account_pubkey.as_str(), )?; - let mut request = FarmEnqueuePublishRequest::new( - input.actor, - input.farm, - SdkRelayTargetPolicy::UseConfiguredRelays, - ); + let mut request = + FarmEnqueuePublishRequest::new(input.actor, input.farm, sdk_relay_target_policy(config)); if let Some(idempotency_key) = farm_idempotency_key.as_deref() { request = request.try_with_idempotency_key(idempotency_key)?; } @@ -795,13 +737,6 @@ struct FarmPublishEventDraft { event: FarmPublishEventView, } -impl FarmPublishView { - fn with_requested_signer_session_id(mut self, signer_session_id: Option<String>) -> Self { - self.requested_signer_session_id = signer_session_id; - self - } -} - fn missing_publish_view( config: &RuntimeConfig, scope: FarmConfigScope, @@ -827,7 +762,6 @@ fn missing_publish_view( seller_pubkey, seller_actor_source: FARM_SELLER_ACTOR_SOURCE.to_owned(), farm_d_tag, - requested_signer_session_id: args.signer_session_id.clone(), profile: not_submitted_component( profile_publish_rpc_method(config), KIND_PROFILE, @@ -879,7 +813,7 @@ fn resolve_farm_signing_identity( fn base_publish_view( state: &str, config: &RuntimeConfig, - args: &FarmPublishArgs, + _args: &FarmPublishArgs, resolved: &ResolvedFarmConfig, account_pubkey: &str, profile: FarmPublishComponentView, @@ -898,7 +832,6 @@ fn base_publish_view( seller_pubkey: account_pubkey.to_owned(), seller_actor_source: FARM_SELLER_ACTOR_SOURCE.to_owned(), farm_d_tag: resolved.document.selection.farm_d_tag.clone(), - requested_signer_session_id: args.signer_session_id.clone(), profile, farm, local_replica: Vec::new(), @@ -965,7 +898,6 @@ fn preview_component( job_id: None, job_status: None, signer_mode: None, - signer_session_id: None, event_id: None, event_addr: event.as_ref().and_then(|event| event.event_addr.clone()), idempotency_key: idempotency_key.clone(), @@ -1297,66 +1229,6 @@ fn profile_not_submitted_component( } } -fn deferred_component( - rpc_method: &str, - event_kind: u32, - idempotency_key: Option<String>, - args: &FarmPublishArgs, - reason: &str, - event: Option<FarmPublishEventView>, -) -> FarmPublishComponentView { - FarmPublishComponentView { - state: "unavailable".to_owned(), - signer_mode: Some("deferred".to_owned()), - signer_session_id: None, - reason: Some(reason.to_owned()), - ..preview_component(rpc_method, event_kind, idempotency_key, args, event) - } -} - -fn radrootsd_preflight_publish_view( - config: &RuntimeConfig, - args: &FarmPublishArgs, - resolved: &ResolvedFarmConfig, - account_pubkey: &str, - previews: FarmPublishPreviews, - profile_idempotency_key: Option<String>, - farm_idempotency_key: Option<String>, - state: &str, - reason: &str, -) -> FarmPublishView { - let requested_signer_session_id = args.signer_session_id.clone(); - base_publish_view( - state, - config, - args, - resolved, - account_pubkey, - deferred_component( - RADROOTSD_BRIDGE_PROFILE_PUBLISH_METHOD, - KIND_PROFILE, - profile_idempotency_key, - args, - reason, - Some(previews.profile.event), - ), - deferred_component( - RADROOTSD_BRIDGE_FARM_PUBLISH_METHOD, - KIND_FARM, - farm_idempotency_key, - args, - reason, - None, - ), - Some(reason.to_owned()), - vec![ - "radroots --publish-mode nostr_relay --relay wss://relay.example.com farm publish" - .to_owned(), - ], - ) - .with_requested_signer_session_id(requested_signer_session_id) -} - fn persist_farm_publication( config: &RuntimeConfig, resolved: &mut ResolvedFarmConfig, @@ -1385,24 +1257,18 @@ fn persist_publication( } fn farm_write_source(config: &RuntimeConfig) -> &'static str { - match config.publish.mode { - PublishMode::NostrRelay => SDK_FARM_WRITE_SOURCE, - PublishMode::Radrootsd => RADROOTSD_FARM_WRITE_SOURCE, - } + let _ = config; + SDK_FARM_WRITE_SOURCE } fn profile_publish_rpc_method(config: &RuntimeConfig) -> &'static str { - match config.publish.mode { - PublishMode::NostrRelay => SDK_PROFILE_NOT_SUBMITTED_METHOD, - PublishMode::Radrootsd => RADROOTSD_BRIDGE_PROFILE_PUBLISH_METHOD, - } + let _ = config; + SDK_PROFILE_NOT_SUBMITTED_METHOD } fn farm_publish_rpc_method(config: &RuntimeConfig) -> &'static str { - match config.publish.mode { - PublishMode::NostrRelay => SDK_FARM_PUBLISH_METHOD, - PublishMode::Radrootsd => RADROOTSD_BRIDGE_FARM_PUBLISH_METHOD, - } + let _ = config; + SDK_FARM_PUBLISH_METHOD } fn selected_account_for_draft( diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs @@ -23,17 +23,13 @@ use radroots_events::listing::{ use radroots_events::trade_validation::RadrootsTradeValidationListingError; use radroots_events_codec::d_tag::is_d_tag_base64url; use radroots_events_codec::listing::encode::to_wire_parts_with_kind; -use radroots_events_codec::wire::WireEventParts; use radroots_local_events::{LocalEventRecord, LocalRecordFamily, SourceRuntime}; -use radroots_nostr::prelude::{ - RadrootsNostrEvent as SignedNostrEvent, RadrootsNostrKeys, radroots_event_from_nostr, -}; -use radroots_replica_db::{ReplicaSql, migrations}; -use radroots_replica_sync::{RadrootsReplicaIngestOutcome, radroots_replica_ingest_event}; +use radroots_nostr::prelude::RadrootsNostrKeys; +use radroots_replica_db::ReplicaSql; use radroots_sdk::{ ListingEnqueuePublishRequest, ListingEnqueueReceipt, ListingPreparePublishRequest, ListingPublishPlan, PushOutboxEventReceipt, PushOutboxEventState, PushOutboxReceipt, - PushOutboxRelayOutcomeKind, PushOutboxRequest, SdkMutationState, SdkRelayTargetPolicy, + PushOutboxRelayOutcomeKind, PushOutboxRequest, SdkMutationState, }; use radroots_sql_core::SqliteExecutor; use radroots_trade::listing::{RadrootsListingDraftDocumentV1, validation::validate_listing_event}; @@ -46,39 +42,31 @@ use crate::cli::global::{ }; use crate::runtime::RuntimeError; use crate::runtime::account; -use crate::runtime::config::{ - PublishMode, RADROOTSD_PUBLISH_DEFERRED_REASON, RuntimeConfig, SignerBackend, -}; -use crate::runtime::direct_relay::{ - DirectRelayFailure, DirectRelayPublishError, DirectRelayPublishReceipt, - publish_signed_event_with_identity, sign_parts_with_identity, -}; +use crate::runtime::config::{RuntimeConfig, SignerBackend}; use crate::runtime::farm_config; use crate::runtime::local_events::{ - append_local_work, append_signed_event, get_shared_record, list_shared_records_before, - list_shared_records_latest, mark_signed_event_acknowledged, - mark_signed_event_failed_for_publish_error, shared_local_events_db_path, + append_local_work, get_shared_record, list_shared_records_before, list_shared_records_latest, + shared_local_events_db_path, +}; +use crate::runtime::sdk::{ + CliSdkAdapterError, CliSdkSession, sdk_relay_target_policy, sdk_relay_url_policy, }; -use crate::runtime::sdk::{CliSdkAdapterError, CliSdkSession, sdk_relay_url_policy}; -use crate::runtime::signer::{ActorWriteBindingError, resolve_actor_write_authority}; use crate::runtime::sync::{ RelayIngestScope, freshness_for_scope_from_executor, market_refresh, missing_freshness, }; use crate::view::runtime::{ FindPriceView, FindQuantityView, FindResultProvenanceView, ListingAppRecordExportView, ListingAppRecordListView, ListingAppRecordSummaryView, ListingGetView, ListingListView, - ListingMutationEventView, ListingMutationLocalReplicaView, ListingMutationView, ListingNewView, - ListingRebindView, ListingSummaryView, ListingValidateView, ListingValidationIssueView, - MarketReadinessView, RelayFailureView, + ListingMutationEventView, ListingMutationView, ListingNewView, ListingRebindView, + ListingSummaryView, ListingValidateView, ListingValidationIssueView, MarketReadinessView, + RelayFailureView, }; const DRAFT_KIND: &str = "listing_draft_v1"; const LISTING_SOURCE: &str = "local draft · local first"; const LISTING_READ_SOURCE: &str = "local replica · local first"; const LISTING_APP_RECORD_SOURCE: &str = "shared local events · app"; -const RELAY_LISTING_WRITE_SOURCE: &str = "direct Nostr relay publish · local key"; const SDK_LISTING_WRITE_SOURCE: &str = "SDK listing publish · local key"; -const RADROOTSD_LISTING_WRITE_SOURCE: &str = "radrootsd publish transport · deferred"; const LISTING_DRAFTS_DIR: &str = "listings/drafts"; const LISTING_SELLER_ACTOR_SOURCE_FARM_CONFIG: &str = "farm_config"; const LISTING_SELLER_ACTOR_SOURCE_RESOLVED_ACCOUNT: &str = "resolved_account"; @@ -245,12 +233,6 @@ struct CanonicalListingDraft { } #[derive(Debug, Clone)] -struct ListingMutationEventDraft { - event: ListingMutationEventView, - parts: WireEventParts, -} - -#[derive(Debug, Clone)] struct SdkListingPublishInput { canonical: CanonicalListingDraft, actor: RadrootsActorContext, @@ -1770,6 +1752,7 @@ pub fn publish_via_sdk( return Ok(sdk_prepared_publish_view( config, args, + ListingMutationOperation::Publish, &input.canonical, plan, )); @@ -1780,7 +1763,7 @@ pub fn publish_via_sdk( let mut request = ListingEnqueuePublishRequest::from_document( input.actor, input.document, - SdkRelayTargetPolicy::UseConfiguredRelays, + sdk_relay_target_policy(config), ); if let Some(idempotency_key) = args.idempotency_key.as_deref() { request = request.try_with_idempotency_key(idempotency_key)?; @@ -1803,6 +1786,7 @@ pub fn publish_via_sdk( Ok(sdk_enqueued_publish_view( config, args, + ListingMutationOperation::Publish, &input.canonical, enqueue_receipt, push_receipt, @@ -1870,6 +1854,7 @@ fn sdk_listing_signer( fn sdk_prepared_publish_view( config: &RuntimeConfig, args: &ListingMutationArgs, + operation: ListingMutationOperation, canonical: &CanonicalListingDraft, plan: ListingPublishPlan, ) -> ListingMutationView { @@ -1877,7 +1862,7 @@ fn sdk_prepared_publish_view( let event = sdk_plan_event_view(&plan); ListingMutationView { state: "dry_run".to_owned(), - operation: ListingMutationOperation::Publish.as_str().to_owned(), + operation: operation.as_str().to_owned(), source: SDK_LISTING_WRITE_SOURCE.to_owned(), file: args.file.display().to_string(), listing_id: canonical.listing_id.clone(), @@ -1898,8 +1883,6 @@ fn sdk_prepared_publish_view( event_id: Some(plan.expected_event_id.as_str().to_owned()), event_addr: Some(listing_addr), idempotency_key: args.idempotency_key.clone(), - signer_session_id: None, - requested_signer_session_id: args.signer_session_id.clone(), local_replica: None, reason: Some("dry run requested; SDK enqueue and relay push skipped".to_owned()), job: None, @@ -1911,6 +1894,7 @@ fn sdk_prepared_publish_view( fn sdk_enqueued_publish_view( config: &RuntimeConfig, args: &ListingMutationArgs, + operation: ListingMutationOperation, canonical: &CanonicalListingDraft, enqueue: ListingEnqueueReceipt, push: Option<PushOutboxReceipt>, @@ -1934,7 +1918,7 @@ fn sdk_enqueued_publish_view( let listing_addr = enqueue.public_listing_addr.as_str().to_owned(); ListingMutationView { state, - operation: ListingMutationOperation::Publish.as_str().to_owned(), + operation: operation.as_str().to_owned(), source: SDK_LISTING_WRITE_SOURCE.to_owned(), file: args.file.display().to_string(), listing_id: canonical.listing_id.clone(), @@ -1955,8 +1939,6 @@ fn sdk_enqueued_publish_view( event_id: Some(event_id), event_addr: Some(listing_addr), idempotency_key: args.idempotency_key.clone(), - signer_session_id: None, - requested_signer_session_id: args.signer_session_id.clone(), local_replica: None, reason, job: None, @@ -2114,14 +2096,14 @@ fn sdk_relay_outcome_kind(kind: PushOutboxRelayOutcomeKind) -> &'static str { pub fn update( config: &RuntimeConfig, args: &ListingMutationArgs, -) -> Result<ListingMutationView, RuntimeError> { +) -> Result<ListingMutationView, CliSdkAdapterError> { mutate(config, args, ListingMutationOperation::Update) } pub fn archive( config: &RuntimeConfig, args: &ListingMutationArgs, -) -> Result<ListingMutationView, RuntimeError> { +) -> Result<ListingMutationView, CliSdkAdapterError> { mutate(config, args, ListingMutationOperation::Archive) } @@ -2129,8 +2111,8 @@ fn mutate( config: &RuntimeConfig, args: &ListingMutationArgs, operation: ListingMutationOperation, -) -> Result<ListingMutationView, RuntimeError> { - let contents = fs::read_to_string(&args.file)?; +) -> Result<ListingMutationView, CliSdkAdapterError> { + let contents = fs::read_to_string(&args.file).map_err(RuntimeError::from)?; let parsed = toml::from_str::<ListingDraftDocument>(&contents).map_err(|error| { RuntimeError::Config(format!( "invalid listing draft {}: {error}", @@ -2170,228 +2152,72 @@ fn mutate( }); } - let (event_draft, listing_addr) = build_listing_event_draft(&canonical)?; - - if config.output.dry_run - && matches!(config.publish.mode, PublishMode::NostrRelay) - && matches!(config.signer.backend, SignerBackend::Local) - { + if config.output.dry_run && matches!(config.signer.backend, SignerBackend::Local) { validate_local_listing_signer(config, &canonical)?; } - if config.output.dry_run { - let requested_signer_session_id = match config.publish.mode { - PublishMode::NostrRelay => args.signer_session_id.clone(), - PublishMode::Radrootsd => { - return Ok(radrootsd_preflight_view( - config, - args, - operation, - &canonical, - listing_addr, - event_draft.event, - "unavailable", - RADROOTSD_PUBLISH_DEFERRED_REASON, - )); - } - }; - return Ok(ListingMutationView { - state: "dry_run".to_owned(), - operation: operation.as_str().to_owned(), - source: listing_write_source(config).to_owned(), - file: args.file.display().to_string(), - listing_id: canonical.listing_id.clone(), - listing_addr: listing_addr.clone(), - seller_account_id: canonical.seller_account_id.clone(), - seller_pubkey: canonical.seller_pubkey.clone(), - seller_actor_source: canonical.seller_actor_source.clone(), - event_kind: KIND_LISTING, - dry_run: true, - deduplicated: false, - target_relays: Vec::new(), - connected_relays: Vec::new(), - acknowledged_relays: Vec::new(), - failed_relays: Vec::new(), - job_id: None, - job_status: None, - signer_mode: dry_run_signer_mode(config), - event_id: None, - event_addr: Some(listing_addr.clone()), - idempotency_key: args.idempotency_key.clone(), - signer_session_id: None, - requested_signer_session_id, - local_replica: None, - reason: Some(dry_run_reason(config)), - job: None, - event: args.print_event.then_some(event_draft.event), - actions: vec![format!( - "radroots listing {} {}", - operation.as_str(), - args.file.display() - )], - }); - } - - match config.publish.mode { - PublishMode::NostrRelay => mutate_via_direct_relay( - config, - args, - operation, - &canonical, - listing_addr, - event_draft, - ), - PublishMode::Radrootsd => Ok(radrootsd_preflight_view( - config, - args, - operation, - &canonical, - listing_addr, - event_draft.event, - "unavailable", - RADROOTSD_PUBLISH_DEFERRED_REASON, - )), - } + mutate_via_sdk_from_canonical(config, args, operation, canonical) } -fn mutate_via_direct_relay( +fn mutate_via_sdk_from_canonical( config: &RuntimeConfig, args: &ListingMutationArgs, operation: ListingMutationOperation, - canonical: &CanonicalListingDraft, - listing_addr: String, - event_draft: ListingMutationEventDraft, -) -> Result<ListingMutationView, RuntimeError> { - let signing = if matches!(config.signer.backend, SignerBackend::Local) { - resolve_listing_signing_identity(config, canonical)? - } else { - match resolve_actor_write_authority(config, "seller", canonical.seller_pubkey.as_str()) { - Ok(_) => { - return Ok(binding_error_view( - config, - args, - operation, - canonical, - listing_addr, - event_draft.event, - ActorWriteBindingError::Unconfigured( - "listing publish requires signer mode `local`".to_owned(), - ), - )); - } - Err(error) => { - return Ok(binding_error_view( - config, - args, - operation, - canonical, - listing_addr, - event_draft.event, - error, - )); - } - } - }; - - if config.relay.urls.is_empty() { - return Ok(direct_relay_error_view( - config, - args, - operation, - canonical, - listing_addr, - event_draft.event, - DirectRelayPublishError::MissingRelays, + canonical: CanonicalListingDraft, +) -> Result<ListingMutationView, CliSdkAdapterError> { + let actor = RadrootsActorContext::local_account( + canonical.seller_pubkey.as_str(), + canonical.seller_account_id.clone(), + [RadrootsActorRole::Seller], + ) + .map_err(|error| RuntimeError::Config(format!("invalid listing SDK actor: {error}")))?; + let document = RadrootsListingDraftDocumentV1::new(canonical.listing.clone()); + if config.output.dry_run { + let session = CliSdkSession::connect_memory(config)?; + let plan = session + .sdk() + .listings() + .prepare_publish(ListingPreparePublishRequest::from_document(actor, document))?; + return Ok(sdk_prepared_publish_view( + config, args, operation, &canonical, plan, )); } - let signed_event = sign_parts_with_identity(&signing.identity, event_draft.parts) - .map_err(|error| RuntimeError::Network(error.to_string()))?; - let record = append_signed_event( - config, - format!("listing:{}", canonical.listing_id).as_str(), - Some(canonical.seller_account_id.clone()), - Some(canonical.seller_pubkey.clone()), - Some(canonical.farm_d_tag.clone()), - Some(listing_addr.clone()), - &signed_event, - )?; - let receipt = match publish_signed_event_with_identity( - &signing.identity, - &config.relay.urls, - signed_event, - ) { - Ok(receipt) => { - mark_signed_event_acknowledged( - config, - record.record_id.as_str(), - receipt.target_relays.clone(), - receipt.connected_relays.clone(), - receipt.acknowledged_relays.clone(), - receipt.failed_relays.clone(), - )?; - receipt - } - Err( - error @ (DirectRelayPublishError::RelayConfig { .. } - | DirectRelayPublishError::Connect { .. } - | DirectRelayPublishError::Publish { .. }), - ) => { - mark_signed_event_failed_for_publish_error(config, record.record_id.as_str(), &error)?; - let mut event = event_draft.event; - event.event_id = record.event_id.clone(); - event.created_at = record - .event_created_at - .and_then(|created_at| u32::try_from(created_at).ok()); - event.signature = record.event_sig.clone(); - return Ok(direct_relay_error_view( - config, - args, - operation, - canonical, - listing_addr, - event, - error, - )); - } - Err(error) => { - mark_signed_event_failed_for_publish_error(config, record.record_id.as_str(), &error)?; - return Err(RuntimeError::Network(error.to_string())); - } + let session = CliSdkSession::connect(config)?; + let signer = sdk_listing_signer(config, &canonical)?; + let mut request = ListingEnqueuePublishRequest::from_document( + actor, + document, + sdk_relay_target_policy(config), + ); + if let Some(idempotency_key) = args.idempotency_key.as_deref() { + request = request.try_with_idempotency_key(idempotency_key)?; + } + let enqueue_receipt = + session.block_on(session.sdk().listings().enqueue_publish(request, &signer))?; + let push_receipt = if args.offline { + None + } else { + Some( + session.block_on( + session.sdk().sync().push_outbox( + PushOutboxRequest::new() + .with_limit(1) + .with_relay_url_policy(sdk_relay_url_policy(config)), + ), + )?, + ) }; - - Ok(published_mutation_view( + Ok(sdk_enqueued_publish_view( config, args, operation, - canonical, - listing_addr, - event_draft.event, - receipt, + &canonical, + enqueue_receipt, + push_receipt, )) } -fn listing_write_source(config: &RuntimeConfig) -> &'static str { - match config.publish.mode { - PublishMode::NostrRelay => RELAY_LISTING_WRITE_SOURCE, - PublishMode::Radrootsd => RADROOTSD_LISTING_WRITE_SOURCE, - } -} - -fn dry_run_reason(config: &RuntimeConfig) -> String { - match config.publish.mode { - PublishMode::NostrRelay => "dry run requested; relay publish skipped".to_owned(), - PublishMode::Radrootsd => "dry run requested; radrootsd submission skipped".to_owned(), - } -} - -fn dry_run_signer_mode(config: &RuntimeConfig) -> Option<String> { - match config.publish.mode { - PublishMode::NostrRelay => None, - PublishMode::Radrootsd => Some("nip46".to_owned()), - } -} - fn scaffold_contents(draft: &ListingDraftDocument) -> Result<String, RuntimeError> { let toml = toml::to_string_pretty(draft).map_err(|error| { RuntimeError::Config(format!("failed to render listing draft: {error}")) @@ -2410,18 +2236,7 @@ fn validation_context(config: &RuntimeConfig) -> Result<ListingValidationContext fn mutation_validation_context( config: &RuntimeConfig, ) -> Result<ListingValidationContext, RuntimeError> { - match config.publish.mode { - PublishMode::NostrRelay => validation_context(config), - PublishMode::Radrootsd => radrootsd_mutation_validation_context(config), - } -} - -fn radrootsd_mutation_validation_context( - config: &RuntimeConfig, -) -> Result<ListingValidationContext, RuntimeError> { - Ok(ListingValidationContext { - farm_setup_action: farm_setup_action(config)?, - }) + validation_context(config) } fn canonicalize_draft( @@ -2979,202 +2794,6 @@ fn invalid_validation_view( } } -fn build_listing_event_draft( - canonical: &CanonicalListingDraft, -) -> Result<(ListingMutationEventDraft, String), RuntimeError> { - let parts = to_wire_parts_with_kind(&canonical.listing, KIND_LISTING) - .map_err(|error| RuntimeError::Config(format!("invalid listing contract: {error}")))?; - let event = RadrootsNostrEvent { - id: String::new(), - author: canonical.seller_pubkey.clone(), - created_at: 0, - kind: parts.kind, - tags: parts.tags.clone(), - content: parts.content.clone(), - sig: String::new(), - }; - let validated = validate_listing_event(&event) - .map_err(|error| RuntimeError::Config(format!("invalid listing contract: {error}")))?; - Ok(( - ListingMutationEventDraft { - event: ListingMutationEventView { - kind: KIND_LISTING, - author: canonical.seller_pubkey.clone(), - created_at: None, - content: parts.content.clone(), - tags: parts.tags.clone(), - event_id: None, - signature: None, - event_addr: validated.listing_addr.clone(), - }, - parts, - }, - validated.listing_addr, - )) -} - -fn radrootsd_preflight_view( - config: &RuntimeConfig, - args: &ListingMutationArgs, - operation: ListingMutationOperation, - canonical: &CanonicalListingDraft, - listing_addr: String, - event_preview: ListingMutationEventView, - state: &str, - reason: impl Into<String>, -) -> ListingMutationView { - ListingMutationView { - state: state.to_owned(), - operation: operation.as_str().to_owned(), - source: listing_write_source(config).to_owned(), - file: args.file.display().to_string(), - listing_id: canonical.listing_id.clone(), - listing_addr: listing_addr.clone(), - seller_account_id: canonical.seller_account_id.clone(), - seller_pubkey: canonical.seller_pubkey.clone(), - seller_actor_source: canonical.seller_actor_source.clone(), - event_kind: KIND_LISTING, - dry_run: false, - deduplicated: false, - target_relays: Vec::new(), - connected_relays: Vec::new(), - acknowledged_relays: Vec::new(), - failed_relays: Vec::new(), - job_id: None, - job_status: None, - signer_mode: Some("deferred".to_owned()), - event_id: None, - event_addr: Some(listing_addr), - idempotency_key: args.idempotency_key.clone(), - signer_session_id: None, - requested_signer_session_id: args.signer_session_id.clone(), - local_replica: None, - reason: Some(reason.into()), - job: None, - event: args.print_event.then_some(event_preview), - actions: vec![format!( - "radroots --publish-mode nostr_relay --relay wss://relay.example.com listing {} {}", - operation.as_str(), - args.file.display() - )], - } -} - -fn direct_relay_error_view( - config: &RuntimeConfig, - args: &ListingMutationArgs, - operation: ListingMutationOperation, - canonical: &CanonicalListingDraft, - listing_addr: String, - mut event_preview: ListingMutationEventView, - error: DirectRelayPublishError, -) -> ListingMutationView { - let parts = direct_relay_error_view_parts(config.relay.urls.as_slice(), error); - let event_id = parts.event_id.or_else(|| event_preview.event_id.clone()); - event_preview.event_id = event_id.clone(); - - ListingMutationView { - state: "unavailable".to_owned(), - operation: operation.as_str().to_owned(), - source: listing_write_source(config).to_owned(), - file: args.file.display().to_string(), - listing_id: canonical.listing_id.clone(), - listing_addr: listing_addr.clone(), - seller_account_id: canonical.seller_account_id.clone(), - seller_pubkey: canonical.seller_pubkey.clone(), - seller_actor_source: canonical.seller_actor_source.clone(), - event_kind: KIND_LISTING, - dry_run: false, - deduplicated: false, - target_relays: parts.target_relays, - connected_relays: parts.connected_relays, - acknowledged_relays: Vec::new(), - failed_relays: parts.failed_relays, - job_id: None, - job_status: None, - signer_mode: Some(config.signer.backend.as_str().to_owned()), - event_id, - event_addr: Some(listing_addr), - idempotency_key: args.idempotency_key.clone(), - signer_session_id: None, - requested_signer_session_id: args.signer_session_id.clone(), - local_replica: None, - reason: Some(parts.reason), - job: None, - event: args.print_event.then_some(event_preview), - actions: Vec::new(), - } -} - -#[derive(Debug, Clone)] -struct DirectRelayErrorViewParts { - reason: String, - target_relays: Vec<String>, - connected_relays: Vec<String>, - failed_relays: Vec<RelayFailureView>, - event_id: Option<String>, -} - -fn direct_relay_error_view_parts( - configured_relays: &[String], - error: DirectRelayPublishError, -) -> DirectRelayErrorViewParts { - let (reason, target_relays, connected_relays, failed_relays, event_id) = match error { - DirectRelayPublishError::MissingRelays => ( - "direct relay publish requires at least one configured relay".to_owned(), - configured_relays.to_vec(), - Vec::new(), - Vec::new(), - None, - ), - DirectRelayPublishError::RelayConfig { relay, source } => ( - format!("failed to configure relay `{relay}` for direct relay publish: {source}"), - configured_relays.to_vec(), - Vec::new(), - vec![RelayFailureView { - relay, - reason: source.to_string(), - }], - None, - ), - DirectRelayPublishError::Connect { - reason, - target_relays, - connected_relays, - failed_relays, - } => ( - format!("direct relay connection failed: {reason}"), - target_relays, - connected_relays, - relay_failures(failed_relays), - None, - ), - DirectRelayPublishError::Publish { - event_id, - reason, - target_relays, - connected_relays, - failed_relays, - } => ( - format!("direct relay publish failed for event `{event_id}`: {reason}"), - target_relays, - connected_relays, - relay_failures(failed_relays), - Some(event_id), - ), - DirectRelayPublishError::Runtime(_) - | DirectRelayPublishError::Build(_) - | DirectRelayPublishError::Sign(_) => unreachable!(), - }; - DirectRelayErrorViewParts { - reason, - target_relays, - connected_relays, - failed_relays, - event_id, - } -} - fn validate_local_listing_signer( config: &RuntimeConfig, canonical: &CanonicalListingDraft, @@ -3258,217 +2877,6 @@ fn listing_bound_signing_error( } } -fn binding_error_view( - config: &RuntimeConfig, - args: &ListingMutationArgs, - operation: ListingMutationOperation, - canonical: &CanonicalListingDraft, - listing_addr: String, - event_preview: ListingMutationEventView, - error: ActorWriteBindingError, -) -> ListingMutationView { - let reason = error.reason(); - let state = "unconfigured".to_owned(); - let actions = vec!["run radroots signer status get".to_owned()]; - - ListingMutationView { - state: state.clone(), - operation: operation.as_str().to_owned(), - source: listing_write_source(config).to_owned(), - file: args.file.display().to_string(), - listing_id: canonical.listing_id.clone(), - listing_addr, - seller_account_id: canonical.seller_account_id.clone(), - seller_pubkey: canonical.seller_pubkey.clone(), - seller_actor_source: canonical.seller_actor_source.clone(), - event_kind: KIND_LISTING, - dry_run: false, - deduplicated: false, - target_relays: Vec::new(), - connected_relays: Vec::new(), - acknowledged_relays: Vec::new(), - failed_relays: Vec::new(), - job_id: None, - job_status: None, - signer_mode: Some(config.signer.backend.as_str().to_owned()), - signer_session_id: None, - event_id: None, - event_addr: None, - idempotency_key: args.idempotency_key.clone(), - requested_signer_session_id: args.signer_session_id.clone(), - local_replica: None, - reason: Some(reason), - job: None, - event: args.print_event.then_some(event_preview), - actions, - } -} - -fn published_mutation_view( - config: &RuntimeConfig, - args: &ListingMutationArgs, - operation: ListingMutationOperation, - canonical: &CanonicalListingDraft, - listing_addr: String, - mut event: ListingMutationEventView, - receipt: DirectRelayPublishReceipt, -) -> ListingMutationView { - let DirectRelayPublishReceipt { - event: published_event, - event_id, - created_at, - signature, - target_relays, - connected_relays, - acknowledged_relays, - failed_relays, - } = receipt; - debug_assert_eq!(event_id, published_event.id.to_hex()); - debug_assert_eq!(signature, published_event.sig.to_string()); - let local_replica = - listing_local_replica_ingest_view(config, &published_event, Some(listing_addr.clone())); - event.event_id = Some(event_id.clone()); - event.created_at = Some(created_at); - event.signature = Some(signature); - ListingMutationView { - state: match operation { - ListingMutationOperation::Archive => "archived", - ListingMutationOperation::Publish | ListingMutationOperation::Update => "published", - } - .to_owned(), - operation: operation.as_str().to_owned(), - source: listing_write_source(config).to_owned(), - file: args.file.display().to_string(), - listing_id: canonical.listing_id.clone(), - listing_addr: listing_addr.clone(), - seller_account_id: canonical.seller_account_id.clone(), - seller_pubkey: canonical.seller_pubkey.clone(), - seller_actor_source: canonical.seller_actor_source.clone(), - event_kind: KIND_LISTING, - dry_run: false, - deduplicated: false, - target_relays, - connected_relays, - acknowledged_relays, - failed_relays: relay_failures(failed_relays), - job_id: None, - job_status: None, - signer_mode: Some(config.signer.backend.as_str().to_owned()), - signer_session_id: None, - event_id: Some(event_id), - event_addr: Some(listing_addr), - idempotency_key: args.idempotency_key.clone(), - requested_signer_session_id: args.signer_session_id.clone(), - local_replica: Some(local_replica), - reason: None, - job: None, - event: args.print_event.then_some(event), - actions: Vec::new(), - } -} - -fn listing_local_replica_ingest_view( - config: &RuntimeConfig, - event: &SignedNostrEvent, - event_addr: Option<String>, -) -> ListingMutationLocalReplicaView { - ingest_listing_event_into_local_replica( - config.local.replica_db_path.as_path(), - event, - event_addr, - ) -} - -fn ingest_listing_event_into_local_replica( - replica_db_path: &Path, - event: &SignedNostrEvent, - event_addr: Option<String>, -) -> ListingMutationLocalReplicaView { - let event_id = event.id.to_hex(); - if !replica_db_path.exists() { - return ListingMutationLocalReplicaView { - state: "unconfigured".to_owned(), - store_state: "missing".to_owned(), - ingest_outcome: None, - event_id: Some(event_id), - event_addr, - reason: Some("local replica database is not initialized".to_owned()), - actions: vec!["radroots store init".to_owned()], - }; - } - - let executor = match SqliteExecutor::open(replica_db_path) { - Ok(executor) => executor, - Err(error) => { - return listing_local_replica_failed_view( - event_id, - event_addr, - format!("failed to open local replica database: {error}"), - ); - } - }; - if let Err(error) = migrations::run_all_up(&executor) { - return listing_local_replica_failed_view( - event_id, - event_addr, - format!("failed to migrate local replica database: {error}"), - ); - } - - let event = radroots_event_from_nostr(event); - match radroots_replica_ingest_event(&executor, &event) { - Ok(RadrootsReplicaIngestOutcome::Applied) => ListingMutationLocalReplicaView { - state: "applied".to_owned(), - store_state: "ready".to_owned(), - ingest_outcome: Some("applied".to_owned()), - event_id: Some(event_id), - event_addr, - reason: None, - actions: Vec::new(), - }, - Ok(RadrootsReplicaIngestOutcome::Skipped) => ListingMutationLocalReplicaView { - state: "skipped".to_owned(), - store_state: "ready".to_owned(), - ingest_outcome: Some("skipped".to_owned()), - event_id: Some(event_id), - event_addr, - reason: Some("shared replica ingest skipped the event".to_owned()), - actions: Vec::new(), - }, - Err(error) => listing_local_replica_failed_view( - event_id, - event_addr, - format!("failed to ingest listing event into local replica: {error}"), - ), - } -} - -fn listing_local_replica_failed_view( - event_id: String, - event_addr: Option<String>, - reason: String, -) -> ListingMutationLocalReplicaView { - ListingMutationLocalReplicaView { - state: "failed".to_owned(), - store_state: "unavailable".to_owned(), - ingest_outcome: None, - event_id: Some(event_id), - event_addr, - reason: Some(reason), - actions: vec!["radroots store status get".to_owned()], - } -} - -fn relay_failures(failures: Vec<DirectRelayFailure>) -> Vec<RelayFailureView> { - failures - .into_iter() - .map(|failure| RelayFailureView { - relay: failure.relay, - reason: failure.reason, - }) - .collect() -} - fn issue_from_trade_validation( error: RadrootsTradeValidationListingError, contents: &str, @@ -3808,18 +3216,13 @@ fn encode_base64url_no_pad(bytes: [u8; 16]) -> String { #[cfg(test)] mod tests { use super::{ - DRAFT_KIND, ListingDraftDocument, direct_relay_error_view_parts, encode_base64url_no_pad, - generate_d_tag, ingest_listing_event_into_local_replica, sdk_publish_actions, - sdk_publish_reason, sdk_publish_state, sdk_push_acknowledged_relays, + DRAFT_KIND, ListingDraftDocument, encode_base64url_no_pad, generate_d_tag, + sdk_publish_actions, sdk_publish_reason, sdk_publish_state, sdk_push_acknowledged_relays, sdk_push_failed_relays, }; use crate::cli::global::ListingMutationArgs; - use crate::runtime::direct_relay::{DirectRelayFailure, DirectRelayPublishError}; use radroots_events::ids::RadrootsEventId; use radroots_events_codec::d_tag::is_d_tag_base64url; - use radroots_events_codec::wire::WireEventParts; - use radroots_identity::RadrootsIdentity; - use radroots_nostr::prelude::{RadrootsNostrTimestamp, radroots_nostr_build_event}; use radroots_sdk::{ PushOutboxEventReceipt, PushOutboxEventState, PushOutboxRelayOutcomeKind, PushOutboxRelayReceipt, @@ -3839,27 +3242,6 @@ mod tests { } #[test] - fn direct_relay_publish_error_parts_preserve_event_id() { - let parts = direct_relay_error_view_parts( - &["ws://127.0.0.1:19000".to_owned()], - DirectRelayPublishError::Publish { - event_id: "e".repeat(64), - reason: "relay rejected event".to_owned(), - target_relays: vec!["ws://127.0.0.1:19000".to_owned()], - connected_relays: vec!["ws://127.0.0.1:19000".to_owned()], - failed_relays: vec![DirectRelayFailure { - relay: "ws://127.0.0.1:19000".to_owned(), - reason: "relay rejected event".to_owned(), - }], - }, - ); - - assert_eq!(parts.event_id, Some("e".repeat(64))); - assert!(parts.reason.contains("direct relay publish failed")); - assert_eq!(parts.failed_relays.len(), 1); - } - - #[test] fn sdk_push_receipt_helpers_map_published_and_auth_required_states() { let accepted = sdk_push_event( PushOutboxEventState::Published, @@ -3903,122 +3285,6 @@ mod tests { } #[test] - fn local_replica_ingest_reports_missing_store() { - let temp = tempfile::tempdir().expect("tempdir"); - let event = signed_test_listing_event(WireEventParts { - kind: super::KIND_LISTING, - content: "{}".to_owned(), - tags: vec![vec!["d".to_owned(), "listing-1".to_owned()]], - }); - - let view = ingest_listing_event_into_local_replica( - &temp.path().join("missing.sqlite"), - &event, - Some("30402:pubkey:listing-1".to_owned()), - ); - - assert_eq!(view.state, "unconfigured"); - assert_eq!(view.store_state, "missing"); - assert_eq!(view.event_id, Some(event.id.to_hex())); - assert_eq!(view.actions, vec!["radroots store init".to_owned()]); - } - - #[test] - fn local_replica_ingest_preserves_shared_ingest_failure() { - let temp = tempfile::tempdir().expect("tempdir"); - let replica = temp.path().join("replica.sqlite"); - std::fs::File::create(&replica).expect("replica placeholder"); - let event = signed_test_listing_event(WireEventParts { - kind: super::KIND_LISTING, - content: "{}".to_owned(), - tags: vec![vec!["d".to_owned(), "listing-1".to_owned()]], - }); - - let view = ingest_listing_event_into_local_replica( - &replica, - &event, - Some("30402:pubkey:listing-1".to_owned()), - ); - - assert_eq!(view.state, "failed"); - assert_eq!(view.store_state, "unavailable"); - assert_eq!(view.event_id, Some(event.id.to_hex())); - assert!( - view.reason - .as_deref() - .unwrap_or_default() - .contains("failed to ingest listing event into local replica") - ); - } - - #[test] - fn local_replica_ingest_makes_listing_writes_visible_to_local_reads() { - let temp = tempfile::tempdir().expect("tempdir"); - let replica = temp.path().join("replica.sqlite"); - std::fs::File::create(&replica).expect("replica placeholder"); - let identity = RadrootsIdentity::generate(); - let seller_pubkey = identity.public_key_hex(); - let listing_d_tag = "AAAAAAAAAAAAAAAAAAAAAQ"; - let listing_addr = format!( - "{}:{}:{}", - super::KIND_LISTING, - seller_pubkey, - listing_d_tag - ); - - let active = signed_test_listing_event_with_identity( - &identity, - test_listing_wire_parts(&seller_pubkey, listing_d_tag, "active", "Pasture Eggs"), - 1_700_000_001, - ); - let active_view = - ingest_listing_event_into_local_replica(&replica, &active, Some(listing_addr.clone())); - assert_eq!(active_view.state, "applied"); - assert_eq!(active_view.store_state, "ready"); - assert_eq!(active_view.ingest_outcome.as_deref(), Some("applied")); - - let db = super::ReplicaSql::new(super::SqliteExecutor::open(&replica).expect("open db")); - let active_rows = db - .trade_product_search(&["eggs".to_owned()]) - .expect("search active"); - assert_eq!(active_rows.len(), 1); - assert_eq!(active_rows[0].title, "Pasture Eggs"); - assert_eq!( - active_rows[0].listing_addr.as_deref(), - Some(listing_addr.as_str()) - ); - - let updated = signed_test_listing_event_with_identity( - &identity, - test_listing_wire_parts(&seller_pubkey, listing_d_tag, "active", "Market Eggs"), - 1_700_000_002, - ); - let updated_view = - ingest_listing_event_into_local_replica(&replica, &updated, Some(listing_addr.clone())); - assert_eq!(updated_view.state, "applied"); - let db = super::ReplicaSql::new(super::SqliteExecutor::open(&replica).expect("open db")); - let updated_rows = db - .trade_product_search(&["eggs".to_owned()]) - .expect("search updated"); - assert_eq!(updated_rows.len(), 1); - assert_eq!(updated_rows[0].title, "Market Eggs"); - - let archived = signed_test_listing_event_with_identity( - &identity, - test_listing_wire_parts(&seller_pubkey, listing_d_tag, "archived", "Market Eggs"), - 1_700_000_003, - ); - let archived_view = - ingest_listing_event_into_local_replica(&replica, &archived, Some(listing_addr)); - assert_eq!(archived_view.state, "applied"); - let db = super::ReplicaSql::new(super::SqliteExecutor::open(&replica).expect("open db")); - let archived_rows = db - .trade_product_search(&["eggs".to_owned()]) - .expect("search archived"); - assert!(archived_rows.is_empty()); - } - - #[test] fn listing_draft_kind_constant_is_stable() { let document = ListingDraftDocument { version: 1, @@ -4193,80 +3459,8 @@ mod tests { ListingMutationArgs { file: "listing.toml".into(), idempotency_key: None, - signer_session_id: None, print_event: false, offline, } } - - fn signed_test_listing_event( - parts: WireEventParts, - ) -> radroots_nostr::prelude::RadrootsNostrEvent { - let identity = RadrootsIdentity::generate(); - signed_test_listing_event_with_identity(&identity, parts, 1_700_000_001) - } - - fn signed_test_listing_event_with_identity( - identity: &RadrootsIdentity, - parts: WireEventParts, - created_at: u64, - ) -> radroots_nostr::prelude::RadrootsNostrEvent { - radroots_nostr_build_event(parts.kind, parts.content, parts.tags) - .expect("event builder") - .custom_created_at(RadrootsNostrTimestamp::from_secs(created_at)) - .sign_with_keys(identity.keys()) - .expect("signed event") - } - - fn test_listing_wire_parts( - seller_pubkey: &str, - listing_d_tag: &str, - status: &str, - title: &str, - ) -> WireEventParts { - let farm_d_tag = "AAAAAAAAAAAAAAAAAAAAAA"; - WireEventParts { - kind: super::KIND_LISTING, - content: format!("# {title}"), - tags: vec![ - vec!["d".to_owned(), listing_d_tag.to_owned()], - vec![ - "a".to_owned(), - format!( - "{}:{}:{}", - radroots_events::kinds::KIND_FARM, - seller_pubkey, - farm_d_tag - ), - ], - vec!["p".to_owned(), seller_pubkey.to_owned()], - vec!["key".to_owned(), "pasture-eggs".to_owned()], - vec!["title".to_owned(), title.to_owned()], - vec!["category".to_owned(), "eggs".to_owned()], - vec!["summary".to_owned(), "Pasture-raised eggs".to_owned()], - vec!["radroots:primary_bin".to_owned(), "bin-a".to_owned()], - vec![ - "radroots:bin".to_owned(), - "bin-a".to_owned(), - "12".to_owned(), - "each".to_owned(), - "12".to_owned(), - "each".to_owned(), - "dozen".to_owned(), - ], - vec![ - "radroots:price".to_owned(), - "bin-a".to_owned(), - "6".to_owned(), - "USD".to_owned(), - "1".to_owned(), - "each".to_owned(), - "6".to_owned(), - "each".to_owned(), - ], - vec!["inventory".to_owned(), "5".to_owned()], - vec!["status".to_owned(), status.to_owned()], - ], - } - } } diff --git a/src/runtime/local_events.rs b/src/runtime/local_events.rs @@ -5,19 +5,17 @@ use std::time::{SystemTime, UNIX_EPOCH}; use radroots_local_events::{ LocalEventRecord, LocalEventRecordInput, LocalEventsStore, LocalRecordFamily, - LocalRecordStatus, PublishOutboxStatus, RelayDeliveryEvidence, RelayDeliveryFailure, - SourceRuntime, + LocalRecordStatus, PublishOutboxStatus, SourceRuntime, }; use radroots_runtime_paths::{ default_shared_local_events_database_path_from_shared_accounts_data_root, default_shared_local_events_root_from_shared_accounts_data_root, }; use radroots_sql_core::SqliteExecutor; -use serde_json::{Value, json}; +use serde_json::Value; use crate::runtime::RuntimeError; use crate::runtime::config::{PathsConfig, RuntimeConfig}; -use crate::runtime::direct_relay::{DirectRelayFailure, DirectRelayPublishError}; static RECORD_COUNTER: AtomicU64 = AtomicU64::new(0); @@ -60,109 +58,6 @@ pub fn append_local_work( Ok(store.append_record(&input)?) } -pub fn append_signed_event( - config: &RuntimeConfig, - subject: &str, - owner_account_id: Option<String>, - owner_pubkey: Option<String>, - farm_id: Option<String>, - listing_addr: Option<String>, - event: &radroots_nostr::prelude::RadrootsNostrEvent, -) -> Result<LocalEventRecord, RuntimeError> { - let timestamp = current_time_ms()?; - let delivery_evidence = RelayDeliveryEvidence::pending(&config.relay.urls)?; - let relay_set = delivery_evidence.relay_set_fingerprint(); - let input = LocalEventRecordInput { - record_id: format!("cli:signed_event:{subject}:{}", event.id.to_hex()), - family: LocalRecordFamily::SignedEvent, - status: LocalRecordStatus::PendingPublish, - source_runtime: SourceRuntime::Cli, - created_at_ms: timestamp, - inserted_at_ms: timestamp, - owner_account_id, - owner_pubkey, - farm_id, - listing_addr, - local_work_json: None, - event_id: Some(event.id.to_hex()), - event_kind: Some(i64::from(u32::from(event.kind.as_u16()))), - event_pubkey: Some(event.pubkey.to_string()), - event_created_at: Some(event_created_at_i64(event)?), - event_tags_json: Some(json!(event_tags(event))), - event_content: Some(event.content.clone()), - event_sig: Some(event.sig.to_string()), - raw_event_json: Some(raw_event_json(event)?), - outbox_status: PublishOutboxStatus::Pending, - relay_set_fingerprint: relay_set, - relay_delivery_json: Some(delivery_evidence.to_json_value()?), - }; - let store = open_store(config)?; - Ok(store.append_record(&input)?) -} - -pub fn mark_signed_event_acknowledged( - config: &RuntimeConfig, - record_id: &str, - target_relays: Vec<String>, - connected_relays: Vec<String>, - acknowledged_relays: Vec<String>, - failed_relays: Vec<DirectRelayFailure>, -) -> Result<LocalEventRecord, RuntimeError> { - let delivery_evidence = acknowledged_delivery_evidence( - target_relays, - connected_relays, - acknowledged_relays, - failed_relays, - )?; - update_signed_event_outbox( - config, - record_id, - LocalRecordStatus::Published, - PublishOutboxStatus::Acknowledged, - delivery_evidence, - ) -} - -pub fn mark_signed_event_failed( - config: &RuntimeConfig, - record_id: &str, - reason: String, - target_relays: Vec<String>, - connected_relays: Vec<String>, - failed_relays: Vec<DirectRelayFailure>, -) -> Result<LocalEventRecord, RuntimeError> { - let delivery_evidence = failed_delivery_evidence( - target_relays, - connected_relays, - failed_relays, - reason.as_str(), - )?; - update_signed_event_outbox( - config, - record_id, - LocalRecordStatus::Failed, - PublishOutboxStatus::Failed, - delivery_evidence, - ) -} - -pub fn mark_signed_event_failed_for_publish_error( - config: &RuntimeConfig, - record_id: &str, - error: &DirectRelayPublishError, -) -> Result<LocalEventRecord, RuntimeError> { - let (target_relays, connected_relays, failed_relays) = - publish_error_delivery_parts(error, &config.relay.urls); - mark_signed_event_failed( - config, - record_id, - error.to_string(), - target_relays, - connected_relays, - failed_relays, - ) -} - pub fn shared_local_events_db_path(config: &RuntimeConfig) -> Result<PathBuf, RuntimeError> { shared_local_events_db_path_from_paths(&config.paths) } @@ -217,28 +112,6 @@ pub fn get_shared_record( Ok(store.get_record(record_id)?) } -fn update_signed_event_outbox( - config: &RuntimeConfig, - record_id: &str, - status: LocalRecordStatus, - outbox_status: PublishOutboxStatus, - delivery_evidence: RelayDeliveryEvidence, -) -> Result<LocalEventRecord, RuntimeError> { - let relay_set_fingerprint = delivery_evidence.relay_set_fingerprint(); - let relay_delivery_json = delivery_evidence.to_json_value()?; - let store = open_store(config)?; - Ok( - store.update_outbox(&radroots_local_events::LocalEventRecordUpdate { - record_id: record_id.to_owned(), - status, - outbox_status, - relay_set_fingerprint, - relay_delivery_json: Some(relay_delivery_json), - updated_at_ms: current_time_ms()?, - })?, - ) -} - fn open_store(config: &RuntimeConfig) -> Result<LocalEventsStore<SqliteExecutor>, RuntimeError> { let root = shared_local_events_root_from_paths(&config.paths)?; fs::create_dir_all(&root)?; @@ -259,14 +132,8 @@ fn shared_local_events_root_from_paths(paths: &PathsConfig) -> Result<PathBuf, R mod tests { use std::path::PathBuf; - use serde_json::json; - - use super::{ - acknowledged_delivery_evidence, failed_delivery_evidence, - shared_local_events_db_path_from_paths, shared_local_events_root_from_paths, - }; + use super::{shared_local_events_db_path_from_paths, shared_local_events_root_from_paths}; use crate::runtime::config::PathsConfig; - use crate::runtime::direct_relay::DirectRelayFailure; #[test] fn shared_local_events_paths_use_shared_runtime_contract() { @@ -284,71 +151,6 @@ mod tests { ); } - #[test] - fn acknowledged_delivery_evidence_uses_actual_target_relays() { - let evidence = acknowledged_delivery_evidence( - vec![ - "wss://actual-a.example".to_owned(), - "wss://actual-b.example".to_owned(), - ], - vec!["wss://actual-a.example".to_owned()], - vec!["wss://actual-a.example".to_owned()], - vec![DirectRelayFailure { - relay: "wss://actual-b.example".to_owned(), - reason: "timeout".to_owned(), - }], - ) - .expect("acknowledged evidence"); - - assert_eq!( - evidence.relay_set_fingerprint(), - radroots_local_events::canonical_relay_set_fingerprint([ - "wss://actual-a.example", - "wss://actual-b.example" - ]) - ); - assert_eq!( - evidence.to_json_value().expect("delivery json"), - json!({ - "state": "acknowledged", - "target_relays": ["wss://actual-a.example", "wss://actual-b.example"], - "connected_relays": ["wss://actual-a.example"], - "acknowledged_relays": ["wss://actual-a.example"], - "failed_relays": [ - {"relay_url": "wss://actual-b.example", "error": "timeout"} - ] - }) - ); - } - - #[test] - fn failed_delivery_evidence_synthesizes_target_failures_when_transport_has_none() { - let evidence = failed_delivery_evidence( - vec![ - "wss://actual-a.example".to_owned(), - "wss://actual-b.example".to_owned(), - ], - Vec::new(), - Vec::new(), - "publish failed", - ) - .expect("failed evidence"); - - assert_eq!( - evidence.to_json_value().expect("delivery json"), - json!({ - "state": "failed", - "target_relays": ["wss://actual-a.example", "wss://actual-b.example"], - "connected_relays": [], - "acknowledged_relays": [], - "failed_relays": [ - {"relay_url": "wss://actual-a.example", "error": "publish failed"}, - {"relay_url": "wss://actual-b.example", "error": "publish failed"} - ] - }) - ); - } - fn paths_config(shared_accounts_data_root: &str) -> PathsConfig { PathsConfig { profile: "repo_local".to_owned(), @@ -387,118 +189,3 @@ fn current_time_ms() -> Result<i64, RuntimeError> { i64::try_from(duration.as_millis()) .map_err(|_| RuntimeError::Config("current timestamp exceeds i64 milliseconds".to_owned())) } - -fn publish_error_delivery_parts( - error: &DirectRelayPublishError, - relay_urls: &[String], -) -> (Vec<String>, Vec<String>, Vec<DirectRelayFailure>) { - match error { - DirectRelayPublishError::MissingRelays - | DirectRelayPublishError::Runtime(_) - | DirectRelayPublishError::Build(_) - | DirectRelayPublishError::Sign(_) => (relay_urls.to_vec(), Vec::new(), Vec::new()), - DirectRelayPublishError::RelayConfig { relay, source } => ( - relay_urls.to_vec(), - Vec::new(), - vec![DirectRelayFailure { - relay: relay.clone(), - reason: source.to_string(), - }], - ), - DirectRelayPublishError::Connect { - target_relays, - connected_relays, - failed_relays, - .. - } - | DirectRelayPublishError::Publish { - target_relays, - connected_relays, - failed_relays, - .. - } => ( - target_relays.clone(), - connected_relays.clone(), - failed_relays.clone(), - ), - } -} - -fn acknowledged_delivery_evidence( - target_relays: Vec<String>, - connected_relays: Vec<String>, - acknowledged_relays: Vec<String>, - failed_relays: Vec<DirectRelayFailure>, -) -> Result<RelayDeliveryEvidence, RuntimeError> { - RelayDeliveryEvidence::acknowledged( - target_relays, - connected_relays, - acknowledged_relays, - relay_delivery_failures(failed_relays)?, - ) - .map_err(Into::into) -} - -fn failed_delivery_evidence( - target_relays: Vec<String>, - connected_relays: Vec<String>, - failed_relays: Vec<DirectRelayFailure>, - reason: &str, -) -> Result<RelayDeliveryEvidence, RuntimeError> { - let delivery_failures = failed_delivery_failures(failed_relays, &target_relays, reason)?; - RelayDeliveryEvidence::failed(target_relays, connected_relays, delivery_failures) - .map_err(Into::into) -} - -fn relay_delivery_failures( - failures: Vec<DirectRelayFailure>, -) -> Result<Vec<RelayDeliveryFailure>, RuntimeError> { - failures - .into_iter() - .map(|failure| RelayDeliveryFailure::new(failure.relay, failure.reason).map_err(Into::into)) - .collect() -} - -fn failed_delivery_failures( - failed_relays: Vec<DirectRelayFailure>, - target_relays: &[String], - reason: &str, -) -> Result<Vec<RelayDeliveryFailure>, RuntimeError> { - let failures = relay_delivery_failures(failed_relays)?; - if !failures.is_empty() { - return Ok(failures); - } - target_relays - .iter() - .map(|relay| RelayDeliveryFailure::new(relay, reason).map_err(Into::into)) - .collect() -} - -fn event_tags(event: &radroots_nostr::prelude::RadrootsNostrEvent) -> Vec<Vec<String>> { - event - .tags - .iter() - .map(|tag| tag.as_slice().to_vec()) - .collect() -} - -fn event_created_at_i64( - event: &radroots_nostr::prelude::RadrootsNostrEvent, -) -> Result<i64, RuntimeError> { - i64::try_from(event.created_at.as_secs()) - .map_err(|_| RuntimeError::Config("event timestamp exceeds i64 seconds".to_owned())) -} - -fn raw_event_json( - event: &radroots_nostr::prelude::RadrootsNostrEvent, -) -> Result<Value, RuntimeError> { - Ok(json!({ - "id": event.id.to_hex(), - "pubkey": event.pubkey.to_string(), - "created_at": event_created_at_i64(event)?, - "kind": u32::from(event.kind.as_u16()), - "tags": event_tags(event), - "content": event.content.clone(), - "sig": event.sig.to_string(), - })) -} diff --git a/src/runtime/order.rs b/src/runtime/order.rs @@ -919,8 +919,6 @@ pub fn submit( failed_relays: Vec::new(), idempotency_key: args.idempotency_key.clone(), signer_mode: None, - signer_session_id: None, - requested_signer_session_id: None, reason: Some(reason), job: None, issues: Vec::new(), @@ -956,8 +954,6 @@ pub fn submit( failed_relays: Vec::new(), idempotency_key: args.idempotency_key.clone(), signer_mode: None, - signer_session_id: None, - requested_signer_session_id: None, reason: Some(format!("order draft `{}` was not found", args.key)), job: None, issues: Vec::new(), @@ -1004,8 +1000,6 @@ pub fn submit( failed_relays: Vec::new(), idempotency_key: args.idempotency_key.clone(), signer_mode: None, - signer_session_id: None, - requested_signer_session_id: None, reason: Some("order draft is not ready for submit".to_owned()), job: None, issues, @@ -8772,8 +8766,6 @@ fn order_submit_unconfigured_view( failed_relays: Vec::new(), idempotency_key: args.idempotency_key.clone(), signer_mode: None, - signer_session_id: None, - requested_signer_session_id: None, reason: Some(reason.into()), job: None, issues, @@ -8813,8 +8805,6 @@ fn order_submit_app_signed_evidence_view( failed_relays: Vec::new(), idempotency_key: args.idempotency_key.clone(), signer_mode: None, - signer_session_id: None, - requested_signer_session_id: None, reason: Some( "matching signed order request evidence already exists; publish skipped".to_owned(), ), @@ -8850,8 +8840,6 @@ fn order_submit_app_signed_evidence_view( failed_relays: Vec::new(), idempotency_key: args.idempotency_key.clone(), signer_mode: None, - signer_session_id: None, - requested_signer_session_id: None, reason: Some( "signed order request evidence conflicts with the app-authored local order" .to_owned(), @@ -8900,8 +8888,6 @@ fn order_submit_invalid_quantity_view( failed_relays: Vec::new(), idempotency_key: args.idempotency_key.clone(), signer_mode: None, - signer_session_id: None, - requested_signer_session_id: None, reason: Some(reason.into()), job: None, issues, @@ -8972,8 +8958,6 @@ fn order_submit_listing_provenance_preflight_view( failed_relays: Vec::new(), idempotency_key: args.idempotency_key.clone(), signer_mode: Some(config.signer.backend.as_str().to_owned()), - signer_session_id: None, - requested_signer_session_id: None, reason: Some( "order submit requires at least one configured relay that is known to carry the listing" .to_owned(), @@ -9221,8 +9205,6 @@ fn order_submit_deduplicated_view( failed_relays: relay_failures(failed_relays), idempotency_key: args.idempotency_key.clone(), signer_mode: Some(config.signer.backend.as_str().to_owned()), - signer_session_id: None, - requested_signer_session_id: None, reason: Some( "an identical order request is already visible on the configured relays; publish skipped" .to_owned(), @@ -9265,8 +9247,6 @@ fn order_submit_dry_run_view( failed_relays: Vec::new(), idempotency_key: args.idempotency_key.clone(), signer_mode: Some(config.signer.backend.as_str().to_owned()), - signer_session_id: None, - requested_signer_session_id: None, reason: Some("dry run requested; SDK enqueue and relay push skipped".to_owned()), job: None, issues: Vec::new(), @@ -9311,8 +9291,6 @@ fn order_submit_invalid_existing_request_view( failed_relays: relay_failures(failed_relays), idempotency_key: args.idempotency_key.clone(), signer_mode: Some(config.signer.backend.as_str().to_owned()), - signer_session_id: None, - requested_signer_session_id: None, reason: Some(reason.into()), job: None, issues, @@ -9552,8 +9530,6 @@ fn sdk_enqueued_order_submit_view( failed_relays: push_event.map(sdk_push_failed_relays).unwrap_or_default(), idempotency_key: args.idempotency_key.clone(), signer_mode: Some(config.signer.backend.as_str().to_owned()), - signer_session_id: None, - requested_signer_session_id: None, reason: sdk_order_submit_reason(&enqueue.workflow, push_event), job: None, issues: Vec::new(), @@ -9730,8 +9706,6 @@ fn order_binding_error_view( failed_relays: Vec::new(), idempotency_key: args.idempotency_key.clone(), signer_mode: Some(config.signer.backend.as_str().to_owned()), - signer_session_id: None, - requested_signer_session_id: None, reason: Some(reason), job: None, issues: Vec::new(), diff --git a/src/runtime/provider.rs b/src/runtime/provider.rs @@ -2,7 +2,7 @@ use crate::runtime::config::{ CapabilityBindingInspection, CapabilityBindingInspectionState, INFERENCE_HYF_STDIO_CAPABILITY, }; -use crate::runtime::config::{PublishMode, RuntimeConfig}; +use crate::runtime::config::{PublishTransport, RuntimeConfig}; #[cfg(test)] use crate::runtime::hyf; use crate::view::runtime::PublishRuntimeView; @@ -21,7 +21,7 @@ pub enum ProviderProvenance { DirectConfig, #[cfg(test)] Disabled, - PublishMode, + PublishTransport, #[cfg(test)] Unavailable, } @@ -37,7 +37,7 @@ impl ProviderProvenance { Self::DirectConfig => "direct_config", #[cfg(test)] Self::Disabled => "disabled", - Self::PublishMode => "publish_mode", + Self::PublishTransport => "publish_transport", #[cfg(test)] Self::Unavailable => "unavailable", } @@ -67,14 +67,12 @@ pub struct WritePlaneProviderView { pub target_kind: Option<String>, pub target: Option<String>, pub detail: String, - pub bridge_auth_configured: bool, } #[cfg(test)] #[derive(Debug, Clone, PartialEq, Eq)] pub struct ResolvedWritePlaneTarget { pub url: String, - pub bridge_bearer_token: String, } #[cfg(test)] @@ -97,31 +95,27 @@ pub fn resolve_write_plane_provider( config: &RuntimeConfig, publish: &PublishRuntimeView, ) -> WritePlaneProviderView { - let (provider_runtime_id, binding_model, detail, bridge_auth_configured) = - match config.publish.mode { - PublishMode::NostrRelay => ( - "nostr_relay", - "direct_relay_publish", - "direct relay publish is selected; readiness is reported under publish", - false, - ), - PublishMode::Radrootsd => ( - "radrootsd", - "radrootsd_bridge_publish", - "radrootsd bridge publish is selected; readiness is reported under publish", - config.rpc.bridge_bearer_token.is_some(), - ), - }; + 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::RadrootsdProxy => ( + "radrootsd_proxy", + "daemon_proxy_publish", + "radrootsd_proxy publish is selected; readiness is reported under publish", + ), + }; WritePlaneProviderView { provider_runtime_id: provider_runtime_id.to_owned(), binding_model: binding_model.to_owned(), state: publish.state.clone(), - provenance: ProviderProvenance::PublishMode.as_str().to_owned(), + provenance: ProviderProvenance::PublishTransport.as_str().to_owned(), source: publish.source.clone(), target_kind: None, target: None, detail: publish.reason.clone().unwrap_or_else(|| detail.to_owned()), - bridge_auth_configured, } } @@ -268,9 +262,9 @@ mod tests { AccountConfig, AccountSecretContractConfig, CapabilityBindingConfig, CapabilityBindingSource, CapabilityBindingTargetKind, HyfConfig, IdentityConfig, InteractionConfig, LocalConfig, LoggingConfig, MigrationConfig, MycConfig, OutputConfig, - OutputFormat, PathsConfig, PublishConfig, PublishMode, PublishModeSource, RelayConfig, - RelayConfigSource, RelayPublishPolicy, RpcConfig, RuntimeConfig, SignerBackend, - SignerConfig, Verbosity, + OutputFormat, PathsConfig, PublishConfig, PublishTransport, PublishTransportSource, + RelayConfig, RelayConfigSource, RelayPublishPolicy, RpcConfig, RuntimeConfig, + SignerBackend, SignerConfig, Verbosity, }; use crate::view::runtime::{ PublishProviderRuntimeView, PublishRelayRuntimeView, PublishRuntimeView, @@ -340,8 +334,9 @@ mod tests { backend: SignerBackend::Local, }, publish: PublishConfig { - mode: PublishMode::NostrRelay, - source: PublishModeSource::Defaults, + transport: PublishTransport::DirectNostrRelay, + source: PublishTransportSource::Defaults, + radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(), }, relay: RelayConfig { urls: Vec::new(), @@ -364,7 +359,6 @@ mod tests { }, rpc: RpcConfig { url: "http://127.0.0.1:7070".into(), - bridge_bearer_token: None, }, rhi: crate::runtime::config::RhiConfig { trusted_worker_pubkeys: Vec::new(), @@ -379,9 +373,9 @@ mod tests { reason: Option<&str>, ) -> PublishRuntimeView { PublishRuntimeView { - mode: config.publish.mode.as_str().to_owned(), + transport: config.publish.transport.as_str().to_owned(), source: config.publish.source.as_str().to_owned(), - transport_family: config.publish.mode.transport_family().to_owned(), + transport_family: config.publish.transport.transport_family().to_owned(), state: state.to_owned(), executable: state == "ready", reason: reason.map(str::to_owned), @@ -392,7 +386,7 @@ mod tests { source: config.relay.source.as_str().to_owned(), }, provider: PublishProviderRuntimeView { - provider_runtime_id: config.publish.mode.as_str().to_owned(), + provider_runtime_id: config.publish.transport.as_str().to_owned(), state: state.to_owned(), source: config.publish.source.as_str().to_owned(), reason: reason.map(str::to_owned), @@ -406,16 +400,18 @@ mod tests { let publish = publish_view( &config, "unconfigured", - Some("nostr_relay publish mode requires a configured relay"), + Some("direct_nostr_relay publish transport requires a configured relay"), ); let view = resolve_write_plane_provider(&config, &publish); - assert_eq!(view.provider_runtime_id, "nostr_relay"); + assert_eq!(view.provider_runtime_id, "direct_nostr_relay"); assert_eq!(view.binding_model, "direct_relay_publish"); assert_eq!(view.state, "unconfigured"); - assert_eq!(view.provenance, ProviderProvenance::PublishMode.as_str()); + assert_eq!( + view.provenance, + ProviderProvenance::PublishTransport.as_str() + ); assert!(view.target.is_none()); assert!(view.detail.contains("configured relay")); - assert!(!view.bridge_auth_configured); } #[test] diff --git a/src/runtime/sdk.rs b/src/runtime/sdk.rs @@ -1,20 +1,25 @@ #![allow(dead_code)] +use std::fs; use std::future::Future; use std::path::PathBuf; use radroots_authority::RadrootsLocalEventSigner; use radroots_nostr::prelude::RadrootsNostrKeys; use radroots_sdk::{ - RadrootsSdk, RadrootsSdkBuilder, RadrootsSdkError, RadrootsSdkStorageConfig, SdkRelayUrlPolicy, + RadrootsSdk, RadrootsSdkBuilder, RadrootsSdkError, RadrootsSdkStorageConfig, + SdkPublishTransport, SdkRelayUrlPolicy, + adapters::radrootsd::{RadrootsdAuth, RadrootsdProxyConfig as SdkRadrootsdProxyConfig}, }; +use radroots_secret_vault::{RadrootsSecretVault, RadrootsSecretVaultOsKeyring}; use tokio::runtime::{Builder as TokioRuntimeBuilder, Runtime}; use crate::runtime::RuntimeError; use crate::runtime::account; -use crate::runtime::config::RuntimeConfig; +use crate::runtime::config::{PublishTransport, RuntimeConfig}; const SDK_STORAGE_DIR_NAME: &str = "sdk"; +const RADROOTSD_PROXY_SECRET_SERVICE: &str = "org.radroots.cli.radrootsd-proxy"; #[derive(Debug, thiserror::Error)] pub enum CliSdkAdapterError { @@ -29,15 +34,17 @@ pub struct CliSdkConfig { pub storage_root: PathBuf, pub relay_url_policy: SdkRelayUrlPolicy, pub relay_urls: Vec<String>, + pub publish_transport: SdkPublishTransport, } impl CliSdkConfig { - pub fn from_runtime_config(config: &RuntimeConfig) -> Self { - Self { + pub fn from_runtime_config(config: &RuntimeConfig) -> Result<Self, RuntimeError> { + Ok(Self { storage_root: sdk_storage_root(config), relay_url_policy: sdk_relay_url_policy(config), relay_urls: config.relay.urls.clone(), - } + publish_transport: sdk_publish_transport(config)?, + }) } pub fn builder(&self) -> RadrootsSdkBuilder { @@ -46,7 +53,8 @@ impl CliSdkConfig { .storage(RadrootsSdkStorageConfig::Directory( self.storage_root.clone(), )) - .relay_url_policy(self.relay_url_policy), + .relay_url_policy(self.relay_url_policy) + .publish_transport(self.publish_transport.clone()), |builder, relay_url| builder.relay_url(relay_url.clone()), ) } @@ -60,7 +68,7 @@ pub struct CliSdkSession { impl CliSdkSession { pub fn connect(config: &RuntimeConfig) -> Result<Self, CliSdkAdapterError> { - let sdk_config = CliSdkConfig::from_runtime_config(config); + let sdk_config = CliSdkConfig::from_runtime_config(config)?; let runtime = sdk_runtime()?; let sdk = runtime.block_on(sdk_config.builder().build())?; Ok(Self { @@ -71,7 +79,7 @@ impl CliSdkSession { } pub fn connect_memory(config: &RuntimeConfig) -> Result<Self, CliSdkAdapterError> { - let sdk_config = CliSdkConfig::from_runtime_config(config); + let sdk_config = CliSdkConfig::from_runtime_config(config)?; let runtime = sdk_runtime()?; let sdk = runtime.block_on(memory_builder(&sdk_config).build())?; Ok(Self { @@ -151,7 +159,9 @@ pub(crate) fn sdk_runtime() -> Result<Runtime, RuntimeError> { fn memory_builder(config: &CliSdkConfig) -> RadrootsSdkBuilder { config.relay_urls.iter().fold( - RadrootsSdk::builder().relay_url_policy(config.relay_url_policy), + RadrootsSdk::builder() + .relay_url_policy(config.relay_url_policy) + .publish_transport(config.publish_transport.clone()), |builder, relay_url| builder.relay_url(relay_url.clone()), ) } @@ -169,6 +179,66 @@ pub fn sdk_relay_url_policy(config: &RuntimeConfig) -> SdkRelayUrlPolicy { } } +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() + } + } +} + +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 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() + )) + })? + } 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(), + )); + } + Ok(Some(RadrootsdAuth::BearerToken(token.to_owned()))) +} + #[cfg(test)] mod tests { use std::collections::BTreeSet; @@ -185,8 +255,9 @@ mod tests { use crate::runtime::config::{ AccountConfig, AccountSecretContractConfig, HyfConfig, IdentityConfig, InteractionConfig, LocalConfig, LoggingConfig, MigrationConfig, MycConfig, OutputConfig, OutputFormat, - PathsConfig, PublishConfig, PublishMode, PublishModeSource, RelayConfig, RelayConfigSource, - RelayPublishPolicy, RhiConfig, RpcConfig, SignerBackend, SignerConfig, Verbosity, + PathsConfig, PublishConfig, PublishTransport, PublishTransportSource, RelayConfig, + RelayConfigSource, RelayPublishPolicy, RhiConfig, RpcConfig, SignerBackend, SignerConfig, + Verbosity, }; struct DirectRrRsDependency { @@ -358,23 +429,6 @@ mod tests { const LEGACY_DIRECT_RELAY_CONSUMERS: &[LegacyDirectRelayConsumer] = &[ LegacyDirectRelayConsumer { - path: "src/runtime/listing.rs", - required_tokens: &[ - "mutate_via_direct_relay(", - "publish_signed_event_with_identity", - ], - owner: "listing.nostr_relay.write", - reason: "non-migrated listing direct relay write mode outside SDK local publish", - lifecycle: "retain until listing relay publish migrates to SDK-backed write APIs", - }, - LegacyDirectRelayConsumer { - path: "src/runtime/local_events.rs", - required_tokens: &["DirectRelayFailure", "DirectRelayPublishError"], - owner: "local-event.delivery-evidence", - reason: "delivery evidence mapping for non-migrated direct relay publish outcomes", - lifecycle: "retain until delivery evidence moves behind SDK or local-events APIs", - }, - LegacyDirectRelayConsumer { path: "src/runtime/order.rs", required_tokens: &[ "legacy_order_preflight_relay_status", @@ -559,7 +613,7 @@ mod tests { vec!["wss://relay.one".to_owned(), "wss://relay.two".to_owned()], ); - let sdk_config = CliSdkConfig::from_runtime_config(&config); + 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); @@ -617,8 +671,7 @@ mod tests { #[test] fn sdk_sources_do_not_import_cli_types() { - let sdk_src = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../../../domains/radroots/sdk/crates/sdk/src"); + let sdk_src = Path::new(env!("CARGO_MANIFEST_DIR")).join("../sdk/crates/sdk/src"); let mut files = Vec::new(); collect_rs_files(sdk_src.as_path(), &mut files); let forbidden = [ @@ -724,7 +777,10 @@ mod tests { .flat_map(move |dependencies| { dependencies.iter().filter_map(move |(name, value)| { dependency_path(value) - .filter(|path| path.contains("domains/radroots/lib/crates")) + .filter(|path| { + path.contains("../lib/crates") + || path.contains("domains/radroots/lib/crates") + }) .map(|_| format!("{section}:{name}")) }) }) @@ -867,8 +923,9 @@ mod tests { backend: SignerBackend::Local, }, publish: PublishConfig { - mode: PublishMode::NostrRelay, - source: PublishModeSource::Defaults, + transport: PublishTransport::DirectNostrRelay, + source: PublishTransportSource::Defaults, + radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(), }, relay: RelayConfig { urls: relays, @@ -891,7 +948,6 @@ mod tests { }, rpc: RpcConfig { url: "http://127.0.0.1:7070".to_owned(), - bridge_bearer_token: None, }, rhi: RhiConfig { trusted_worker_pubkeys: Vec::new(), diff --git a/src/runtime/signer.rs b/src/runtime/signer.rs @@ -54,7 +54,7 @@ pub struct ActorWriteSignerAuthority { pub provider_runtime_id: String, pub account_identity_id: String, #[serde(skip_serializing_if = "Option::is_none")] - pub provider_signer_session_id: Option<String>, + pub provider_session_ref: Option<String>, } pub fn resolve_signer_status(config: &RuntimeConfig) -> SignerStatusView { @@ -267,7 +267,7 @@ fn disabled_binding_status() -> SignerBindingStatusView { target: None, managed_account_ref: None, signer_session_ref: None, - resolved_signer_session_id: None, + resolved_session_ref: None, matched_session_count: None, reason: Some( "remote myc signer binding is disabled while cli signer mode is `local`".to_owned(), @@ -286,7 +286,7 @@ fn deferred_myc_binding_status() -> SignerBindingStatusView { target: None, managed_account_ref: None, signer_session_ref: None, - resolved_signer_session_id: None, + resolved_session_ref: None, matched_session_count: None, reason: Some(MYC_DEFERRED_REASON.to_owned()), } diff --git a/src/runtime/store.rs b/src/runtime/store.rs @@ -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, "nostr_relay"), + relays: table_row_count(manifest, "direct_nostr_relay"), event_states: table_row_count(manifest, "nostr_event_state"), } } diff --git a/src/runtime/sync.rs b/src/runtime/sync.rs @@ -26,7 +26,7 @@ use serde_json::json; use crate::cli::global::SyncWatchArgs; use crate::runtime::RuntimeError; -use crate::runtime::config::{PublishMode, RuntimeConfig}; +use crate::runtime::config::RuntimeConfig; use crate::runtime::direct_relay::{ DirectRelayFailure, DirectRelayFetchError, DirectRelayFetchReceipt, fetch_events_from_relays, }; @@ -45,8 +45,6 @@ const SYNC_PUSH_ACTION: &str = "radroots sync push"; const SYNC_READY_ACTION: &str = "radroots market product search eggs"; const MARKET_READY_ACTION: &str = "radroots market product search eggs"; const INGEST_SOURCE: &str = "direct Nostr relay fetch · local replica ingest"; -const RADROOTSD_SYNC_PUSH_SOURCE: &str = "radrootsd sync push · deferred"; -pub(crate) const RADROOTSD_SYNC_PUSH_UNAVAILABLE_REASON: &str = "sync push is only available in publish mode `nostr_relay`; radrootsd sync push is not implemented"; const RELAY_FETCH_LIMIT: usize = 1_000; const RELAY_FETCH_MAX_PAGES: usize = 5; const MARKET_FRESHNESS_STALE_AFTER_SECONDS: u64 = 15 * 60; @@ -329,10 +327,6 @@ where } pub fn push(config: &RuntimeConfig) -> Result<SyncActionView, CliSdkAdapterError> { - if matches!(config.publish.mode, PublishMode::Radrootsd) { - return Ok(push_radrootsd_unavailable_view(config)); - } - let session = CliSdkSession::connect(config)?; if config.output.dry_run { let status = session.block_on(session.sdk().sync().status(SyncStatusRequest::new()))?; @@ -413,41 +407,6 @@ fn empty_action_from_snapshot(snapshot: SyncSnapshot, direction: &str) -> SyncAc } } -fn push_radrootsd_unavailable_view(config: &RuntimeConfig) -> SyncActionView { - SyncActionView { - direction: "push".to_owned(), - state: "unavailable".to_owned(), - source: RADROOTSD_SYNC_PUSH_SOURCE.to_owned(), - local_root: config.local.root.display().to_string(), - replica_db: "not_checked".to_owned(), - relay_count: config.relay.urls.len(), - publish_policy: config.relay.publish_policy.as_str().to_owned(), - freshness: SyncFreshnessView { - state: "not_checked".to_owned(), - display: "not checked".to_owned(), - age_seconds: None, - last_event_at: None, - run: None, - }, - queue: legacy_sync_queue(0, 0), - target_relays: config.relay.urls.clone(), - connected_relays: Vec::new(), - acknowledged_relays: Vec::new(), - failed_relays: Vec::new(), - fetched_count: None, - ingested_count: None, - publishable_count: None, - published_count: None, - skipped_count: None, - unsupported_count: None, - failed_count: None, - publish_plan: None, - reason_code: Some("not_implemented".to_owned()), - reason: Some(RADROOTSD_SYNC_PUSH_UNAVAILABLE_REASON.to_owned()), - actions: vec!["radroots --publish-mode nostr_relay sync push".to_owned()], - } -} - 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; @@ -1471,7 +1430,7 @@ mod tests { use super::{ DirectRelayFailure, DirectRelayFetchError, DirectRelayFetchReceipt, RelayIngestScope, - freshness_for_scope, market_refresh_with_fetcher, pull_with_fetcher, push, + freshness_for_scope, market_refresh_with_fetcher, pull_with_fetcher, relay_provenance_relays_for_scope, sdk_push_dry_run_view, sdk_push_view, sdk_sync_status_view, }; @@ -1479,8 +1438,9 @@ mod tests { use crate::runtime::config::{ AccountConfig, AccountSecretContractConfig, HyfConfig, IdentityConfig, InteractionConfig, LocalConfig, LoggingConfig, MigrationConfig, MycConfig, OutputConfig, OutputFormat, - PathsConfig, PublishConfig, PublishMode, PublishModeSource, RelayConfig, RelayConfigSource, - RelayPublishPolicy, RpcConfig, RuntimeConfig, SignerBackend, SignerConfig, Verbosity, + PathsConfig, PublishConfig, PublishTransport, PublishTransportSource, RelayConfig, + RelayConfigSource, RelayPublishPolicy, RpcConfig, RuntimeConfig, SignerBackend, + SignerConfig, Verbosity, }; const FARM_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAA"; @@ -1748,28 +1708,6 @@ mod tests { ); } - #[test] - fn sync_push_rejects_radrootsd_before_store_or_sdk_work() { - let dir = tempdir().expect("tempdir"); - let mut config = sample_config(dir.path(), Vec::new()); - config.publish.mode = PublishMode::Radrootsd; - - let view = push(&config).expect("radrootsd sync push view"); - - assert_eq!(view.state, "unavailable"); - assert_eq!(view.replica_db, "not_checked"); - assert_eq!(view.relay_count, 0); - assert_eq!( - view.reason.as_deref(), - Some(super::RADROOTSD_SYNC_PUSH_UNAVAILABLE_REASON) - ); - assert_eq!( - view.actions, - vec!["radroots --publish-mode nostr_relay sync push"] - ); - assert!(!config.local.replica_db_path.exists()); - } - fn sdk_status_receipt( total_events: i64, outbox_total_events: i64, @@ -2343,8 +2281,9 @@ mod tests { backend: SignerBackend::Local, }, publish: PublishConfig { - mode: PublishMode::NostrRelay, - source: PublishModeSource::Defaults, + transport: PublishTransport::DirectNostrRelay, + source: PublishTransportSource::Defaults, + radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(), }, relay: RelayConfig { urls: relays, @@ -2367,7 +2306,6 @@ mod tests { }, rpc: RpcConfig { url: "http://127.0.0.1:7070".into(), - bridge_bearer_token: None, }, rhi: crate::runtime::config::RhiConfig { trusted_worker_pubkeys: Vec::new(), diff --git a/src/view/runtime.rs b/src/view/runtime.rs @@ -299,7 +299,7 @@ pub struct RelayRuntimeView { #[derive(Debug, Clone, Serialize)] pub struct PublishRuntimeView { - pub mode: String, + pub transport: String, pub source: String, pub transport_family: String, pub state: String, @@ -394,13 +394,11 @@ pub struct WritePlaneRuntimeView { #[serde(skip_serializing_if = "Option::is_none")] pub target: Option<String>, pub detail: String, - pub bridge_auth_configured: bool, } #[derive(Debug, Clone, Serialize)] pub struct RpcRuntimeView { pub url: String, - pub bridge_auth_configured: bool, } #[derive(Debug, Clone, Serialize)] @@ -860,7 +858,7 @@ pub struct FarmStatusView { pub config_valid: bool, pub account_state: String, pub listing_defaults_state: String, - pub publish_mode: String, + pub publish_transport: String, pub publish_state: String, pub publish_executable: bool, #[serde(skip_serializing_if = "Option::is_none")] @@ -920,8 +918,6 @@ pub struct FarmPublishView { pub seller_pubkey: String, pub seller_actor_source: String, pub farm_d_tag: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub requested_signer_session_id: Option<String>, pub profile: FarmPublishComponentView, pub farm: FarmPublishComponentView, #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -966,8 +962,6 @@ pub struct FarmPublishComponentView { #[serde(skip_serializing_if = "Option::is_none")] pub signer_mode: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] - pub signer_session_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] pub event_id: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub event_addr: Option<String>, @@ -1007,11 +1001,7 @@ pub struct FarmPublishJobView { #[serde(skip_serializing_if = "Option::is_none")] pub idempotency_key: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] - pub requested_signer_session_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] pub signer_mode: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub signer_session_id: Option<String>, } #[derive(Debug, Clone, Serialize)] @@ -1412,8 +1402,6 @@ pub struct JobSummaryView { pub state: String, pub terminal: bool, pub signer: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub signer_session_id: Option<String>, pub requested_at_unix: u64, #[serde(skip_serializing_if = "Option::is_none")] pub completed_at_unix: Option<u64>, @@ -1427,8 +1415,6 @@ pub struct JobDetailView { pub state: String, pub terminal: bool, pub signer: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub signer_session_id: Option<String>, pub requested_at_unix: u64, #[serde(skip_serializing_if = "Option::is_none")] pub completed_at_unix: Option<u64>, @@ -1456,8 +1442,6 @@ pub struct JobWatchFrameView { pub state: String, pub terminal: bool, pub signer: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub signer_session_id: Option<String>, pub summary: String, } @@ -1737,10 +1721,6 @@ pub struct OrderSubmitView { #[serde(skip_serializing_if = "Option::is_none")] pub signer_mode: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] - pub signer_session_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub requested_signer_session_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub job: Option<OrderJobView>, @@ -2398,10 +2378,6 @@ pub struct OrderJobView { #[serde(skip_serializing_if = "Option::is_none")] pub signer_mode: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] - pub signer_session_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub requested_signer_session_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] pub event_id: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub event_addr: Option<String>, @@ -2725,7 +2701,7 @@ pub struct SellMutationView { #[serde(default)] pub deduplicated: bool, #[serde(skip_serializing_if = "Option::is_none")] - pub publish_mode: Option<String>, + pub publish_transport: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub job_id: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] @@ -2852,10 +2828,6 @@ pub struct ListingMutationView { #[serde(skip_serializing_if = "Option::is_none")] pub idempotency_key: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] - pub signer_session_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub requested_signer_session_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] pub local_replica: Option<ListingMutationLocalReplicaView>, #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option<String>, @@ -2947,12 +2919,8 @@ pub struct ListingMutationJobView { #[serde(skip_serializing_if = "Option::is_none")] pub idempotency_key: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] - pub requested_signer_session_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] pub signer_mode: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] - pub signer_session_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] pub relay_count: Option<usize>, #[serde(skip_serializing_if = "Option::is_none")] pub acknowledged_relay_count: Option<usize>, @@ -3558,7 +3526,7 @@ pub struct SignerBindingStatusView { #[serde(skip_serializing_if = "Option::is_none")] pub signer_session_ref: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] - pub resolved_signer_session_id: Option<String>, + pub resolved_session_ref: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub matched_session_count: Option<usize>, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/tests/signer_runtime_modes.rs b/tests/signer_runtime_modes.rs @@ -1308,6 +1308,8 @@ fn local_farm_publish_does_not_persist_publication_until_sdk_push_publishes() { assert!(!output.status.success()); assert_eq!(value["operation_id"], "farm.publish"); assert_eq!(value["result"], serde_json::Value::Null); + assert_eq!(value["errors"][0]["code"], "network_unavailable"); + assert_eq!(value["errors"][0]["detail"]["class"], "network"); let detail = &value["errors"][0]["detail"]; assert_eq!(detail["source"], "SDK farm publish · local key"); assert_eq!(detail["profile"]["state"], "not_submitted"); @@ -1981,11 +1983,16 @@ fn local_seller_publish_commands_attempt_configured_relay() { listing_file_arg.as_ref(), ]); assert!(!archive_output.status.success()); - assert_direct_relay_connection_failure( - &archive_value, - "listing.archive", - &["listing", "archive"], + assert_eq!(archive_value["operation_id"], "listing.archive"); + assert_eq!(archive_value["result"], serde_json::Value::Null); + assert_eq!(archive_value["errors"][0]["code"], "network_unavailable"); + assert_eq!(archive_value["errors"][0]["detail"]["class"], "network"); + assert_contains( + &archive_value["errors"][0]["message"], + "SDK relay publish did not reach accepted quorum", ); + assert_no_removed_command_reference(&archive_value, &["listing", "archive"]); + assert_no_daemon_runtime_reference(&archive_value, &["listing", "archive"]); assert_eq!( archive_value["errors"][0]["detail"]["target_relays"][0], relay @@ -1995,7 +2002,7 @@ fn local_seller_publish_commands_attempt_configured_relay() { .as_array() .expect("connected relays") .len(), - 0 + 1 ); assert_eq!( archive_value["errors"][0]["detail"]["failed_relays"] @@ -2116,8 +2123,8 @@ fn local_order_failure_envelopes_are_structured_and_actionable() { let submit_args = [ "--format", "json", - "--publish-mode", - "nostr_relay", + "--publish-transport", + "direct_nostr_relay", "--dry-run", "order", "submit", @@ -2200,8 +2207,8 @@ fn local_order_failure_envelopes_are_structured_and_actionable() { let accept_args = [ "--format", "json", - "--publish-mode", - "nostr_relay", + "--publish-transport", + "direct_nostr_relay", "--dry-run", "order", "accept", @@ -2218,8 +2225,8 @@ fn local_order_failure_envelopes_are_structured_and_actionable() { let decline_args = [ "--format", "json", - "--publish-mode", - "nostr_relay", + "--publish-transport", + "direct_nostr_relay", "--dry-run", "order", "decline", diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -2,7 +2,7 @@ mod support; use std::fs; use std::net::{TcpListener, TcpStream}; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::thread::{self, JoinHandle}; use radroots_events::RadrootsNostrEventPtr; @@ -29,8 +29,8 @@ 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, make_listing_publishable_with_seller, ndjson_from_stdout, radroots, - remove_orderable_listing, replace_latest_listing_event_id, seed_orderable_listing, toml_string, + make_listing_publishable, ndjson_from_stdout, radroots, remove_orderable_listing, + replace_latest_listing_event_id, seed_orderable_listing, toml_string, update_orderable_listing_available_amount, update_orderable_listing_primary_bin_id, write_public_identity_profile, write_secret_identity_profile, }; @@ -55,6 +55,12 @@ 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"); + fs::write(&path, "proxy_test_token\n").expect("write proxy token file"); + path +} + struct RelayFetchServer { endpoint: String, handle: JoinHandle<()>, @@ -770,21 +776,18 @@ fn root_help_exposes_only_target_namespaces() { } #[test] -fn root_help_explains_publish_modes() { +fn root_help_explains_publish_transports() { 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("nostr_relay uses direct relay publish")); - assert!(stdout.contains("radrootsd is reserved and fails closed")); - assert!(stdout.contains("Relay mode never silently falls back")); + assert!(stdout.contains("direct_nostr_relay publishes directly to configured relays")); + assert!(stdout.contains("radrootsd_proxy publishes locally signed events")); assert!(stdout.contains("Inspect local readiness and mode-specific recovery steps")); - assert!( - stdout.contains( - "Select nostr_relay direct relay publish or reserved radrootsd guardrail mode" - ) - ); + assert!(stdout.contains( + "Select direct_nostr_relay direct relay publish or radrootsd_proxy daemon proxy publish" + )); } fn help_lists(stdout: &str, command: &str) -> bool { @@ -794,29 +797,6 @@ fn help_lists(stdout: &str, command: &str) -> bool { }) } -fn assert_radrootsd_deferred_message(value: &Value) { - let message = value["errors"][0]["message"] - .as_str() - .expect("error message"); - assert!(message.contains("radrootsd publish mode is deferred")); - assert!(message.contains("publish mode `nostr_relay`")); - assert!( - !message.contains("signer.remote_nip46"), - "deferred publish-mode message should not suggest signer-session setup: {message}" - ); -} - -fn assert_direct_relay_next_action(actions: &Value, command: &str) { - let action = actions - .as_array() - .expect("next actions") - .iter() - .find(|action| action["command"] == command) - .expect("direct relay next action"); - - assert_eq!(action["kind"], "cli_command"); -} - #[test] fn removed_global_flags_are_rejected_publicly() { for args in [ @@ -839,24 +819,27 @@ fn removed_global_flags_are_rejected_publicly() { } #[test] -fn config_get_exposes_resolved_publish_state() { +fn config_get_exposes_radrootsd_proxy_missing_token_state() { let sandbox = RadrootsCliSandbox::new(); - sandbox.write_app_config("[publish]\nmode = \"radrootsd\"\n"); + sandbox.write_app_config("[publish]\ntransport = \"radrootsd_proxy\"\n"); let value = sandbox.json_success(&["--format", "json", "config", "get"]); assert_eq!(value["operation_id"], "config.get"); - assert_eq!(value["result"]["publish"]["mode"], "radrootsd"); + assert_eq!(value["result"]["publish"]["transport"], "radrootsd_proxy"); assert_eq!( value["result"]["publish"]["source"], "user config · local first" ); - assert_eq!(value["result"]["publish"]["transport_family"], "radrootsd"); - assert_eq!(value["result"]["publish"]["state"], "unavailable"); + assert_eq!( + value["result"]["publish"]["transport_family"], + "radrootsd_proxy" + ); + assert_eq!(value["result"]["publish"]["state"], "unconfigured"); assert_eq!(value["result"]["publish"]["executable"], false); assert_contains( &value["result"]["publish"]["reason"], - "radrootsd publish mode is deferred", + "configured token file or token secret id", ); assert_eq!( value["result"]["account_resolution"]["status"], @@ -864,77 +847,76 @@ fn config_get_exposes_resolved_publish_state() { ); assert_eq!( value["result"]["publish"]["provider"]["provider_runtime_id"], - "radrootsd" + "radrootsd_proxy" ); assert_eq!( value["result"]["write_plane"]["provider_runtime_id"], - "radrootsd" + "radrootsd_proxy" ); assert_eq!( value["result"]["write_plane"]["binding_model"], - "radrootsd_bridge_publish" + "daemon_proxy_publish" + ); + assert_eq!(value["result"]["write_plane"]["state"], "unconfigured"); + assert_eq!( + value["result"]["radrootsd_proxy"]["token_file_configured"], + false ); - assert_eq!(value["result"]["write_plane"]["state"], "unavailable"); assert_eq!( - value["result"]["write_plane"]["bridge_auth_configured"], + value["result"]["radrootsd_proxy"]["token_secret_id_configured"], false ); - assert_eq!(value["result"]["rpc"]["bridge_auth_configured"], false); assert_eq!( value["result"]["actions"][0], - "radroots --publish-mode nostr_relay --relay wss://relay.example.com config get" + "configure RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE or RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_SECRET_ID" ); - assert_direct_relay_next_action( - &value["next_actions"], - "radroots --publish-mode nostr_relay --relay wss://relay.example.com config get", + assert_eq!( + value["next_actions"][0]["env_var"], + "RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE" ); } #[test] -fn config_get_radrootsd_with_bridge_auth_still_reports_deferred_publish_mode() { +fn config_get_radrootsd_proxy_with_token_file_reports_ready_transport() { let sandbox = RadrootsCliSandbox::new(); - sandbox.write_app_config("[publish]\nmode = \"radrootsd\"\n"); + 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_RPC_BEARER_TOKEN", "bridge_test") + .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"); assert!(output.status.success()); assert_eq!(value["operation_id"], "config.get"); - assert_eq!(value["result"]["publish"]["mode"], "radrootsd"); - assert_eq!(value["result"]["publish"]["state"], "unavailable"); - assert_eq!(value["result"]["publish"]["executable"], false); - assert_contains( - &value["result"]["publish"]["reason"], - "radrootsd publish mode is deferred", - ); - assert_eq!(value["result"]["rpc"]["bridge_auth_configured"], true); + assert_eq!(value["result"]["publish"]["transport"], "radrootsd_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"]["actions"][0], - "radroots --publish-mode nostr_relay --relay wss://relay.example.com config get" + value["result"]["radrootsd_proxy"]["token_file_configured"], + true ); assert_eq!( - value["next_actions"] + value["result"]["actions"] .as_array() - .expect("next actions") + .expect("actions") .len(), - 1 - ); - assert_direct_relay_next_action( - &value["next_actions"], - "radroots --publish-mode nostr_relay --relay wss://relay.example.com config get", + 0 ); } #[test] -fn config_get_marks_radrootsd_deferred_even_with_bridge_auth_and_session_binding() { +fn config_get_marks_radrootsd_proxy_unavailable_with_myc_signer() { let sandbox = RadrootsCliSandbox::new(); sandbox.write_app_config( r#"[publish] -mode = "radrootsd" +transport = "radrootsd_proxy" + +[signer] +backend = "myc" [[capability_binding]] capability = "signer.remote_nip46" @@ -944,33 +926,26 @@ 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_RPC_BEARER_TOKEN", "bridge_test") + .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"); assert!(output.status.success()); assert_eq!(value["operation_id"], "config.get"); - assert_eq!(value["result"]["publish"]["mode"], "radrootsd"); - assert_eq!(value["result"]["publish"]["relay"]["ready"], false); + assert_eq!(value["result"]["publish"]["transport"], "radrootsd_proxy"); assert_eq!(value["result"]["publish"]["state"], "unavailable"); assert_eq!(value["result"]["publish"]["executable"], false); - assert_contains( - &value["result"]["publish"]["reason"], - "radrootsd publish mode is deferred", - ); + assert_contains(&value["result"]["publish"]["reason"], "signer mode `local`"); assert_eq!( value["result"]["publish"]["provider"]["state"], "unavailable" ); - assert_eq!(value["result"]["rpc"]["bridge_auth_configured"], true); - assert_eq!( - value["result"]["actions"][0], - "radroots --publish-mode nostr_relay --relay wss://relay.example.com config get" - ); + assert_eq!(value["result"]["actions"][0], "radroots signer status get"); } #[test] @@ -987,7 +962,10 @@ fn config_get_distinguishes_relay_ready_from_missing_signed_write_account() { ]); assert_eq!(value["operation_id"], "config.get"); - assert_eq!(value["result"]["publish"]["mode"], "nostr_relay"); + assert_eq!( + value["result"]["publish"]["transport"], + "direct_nostr_relay" + ); assert_eq!(value["result"]["publish"]["relay"]["ready"], true); assert_eq!(value["result"]["publish"]["signed_write_required"], true); assert_eq!(value["result"]["publish"]["state"], "unconfigured"); @@ -1002,7 +980,7 @@ fn config_get_distinguishes_relay_ready_from_missing_signed_write_account() { ); assert_eq!( value["result"]["write_plane"]["provider_runtime_id"], - "nostr_relay" + "direct_nostr_relay" ); assert_eq!( value["result"]["write_plane"]["binding_model"], @@ -1046,7 +1024,10 @@ fn config_get_marks_relay_publish_ready_with_secret_backed_local_account() { "get", ]); - assert_eq!(value["result"]["publish"]["mode"], "nostr_relay"); + assert_eq!( + value["result"]["publish"]["transport"], + "direct_nostr_relay" + ); assert_eq!(value["result"]["publish"]["relay"]["ready"], true); assert_eq!(value["result"]["publish"]["signed_write_required"], true); assert_eq!(value["result"]["publish"]["state"], "ready"); @@ -1070,7 +1051,10 @@ fn config_get_marks_relay_publish_unavailable_with_deferred_signer_mode() { "get", ]); - assert_eq!(value["result"]["publish"]["mode"], "nostr_relay"); + assert_eq!( + value["result"]["publish"]["transport"], + "direct_nostr_relay" + ); assert_eq!(value["result"]["publish"]["relay"]["ready"], true); assert_eq!(value["result"]["publish"]["signed_write_required"], true); assert_eq!(value["result"]["publish"]["state"], "unavailable"); @@ -1119,8 +1103,10 @@ fn config_get_marks_relay_publish_unconfigured_with_watch_only_account() { fn health_surfaces_publish_state_under_deferred_signer_mode() { let sandbox = RadrootsCliSandbox::new(); let missing_myc = sandbox.root().join("bin/missing-myc"); + let token_file = radrootsd_proxy_token_file(&sandbox); sandbox.write_app_config(&format!( - "[publish]\nmode = \"radrootsd\"\n\n[signer]\nbackend = \"myc\"\n\n[myc]\nexecutable = \"{}\"\n", + "[publish]\ntransport = \"radrootsd_proxy\"\n\n[publish.radrootsd_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()) )); @@ -1128,16 +1114,13 @@ fn health_surfaces_publish_state_under_deferred_signer_mode() { assert_eq!(value["operation_id"], "health.status.get"); assert_eq!(value["result"]["state"], "needs_attention"); - assert_eq!(value["result"]["publish"]["mode"], "radrootsd"); + assert_eq!(value["result"]["publish"]["transport"], "radrootsd_proxy"); assert_eq!(value["result"]["publish"]["executable"], false); assert_eq!( value["result"]["publish"]["provider"]["state"], "unavailable" ); - assert_contains( - &value["result"]["publish"]["reason"], - "radrootsd publish mode is deferred", - ); + assert_contains(&value["result"]["publish"]["reason"], "signer mode `local`"); assert_eq!(value["result"]["store"]["state"], "ready"); assert_eq!( value["result"]["store"]["source"], @@ -1146,17 +1129,14 @@ fn health_surfaces_publish_state_under_deferred_signer_mode() { assert_eq!(value["result"]["store"]["canonical_store"], "sdk"); assert_eq!(value["result"]["signer"]["state"], "unavailable"); assert_eq!(value["result"]["actions"][0], "radroots account create"); - assert_eq!( - value["result"]["actions"][1], - "radroots --publish-mode nostr_relay --relay wss://relay.example.com config get" - ); + assert_eq!(value["result"]["actions"][1], "radroots signer status get"); assert_eq!( value["next_actions"][0]["command"], "radroots account create" ); - assert_direct_relay_next_action( - &value["next_actions"], - "radroots --publish-mode nostr_relay --relay wss://relay.example.com config get", + assert_eq!( + value["next_actions"][1]["command"], + "radroots signer status get" ); assert_eq!(value["errors"].as_array().expect("errors").len(), 0); } @@ -1198,7 +1178,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]\nmode = \"radrootsd\"\n"); + sandbox.write_app_config("[publish]\ntransport = \"radrootsd_proxy\"\n"); let value = sandbox.json_success(&["--format", "json", "health", "check", "run"]); @@ -1209,12 +1189,18 @@ fn health_check_exposes_publish_readiness() { "unresolved" ); assert_eq!(value["result"]["account_resolution"]["source"], "none"); - assert_eq!(value["result"]["checks"]["publish"]["mode"], "radrootsd"); - assert_eq!(value["result"]["checks"]["publish"]["state"], "unavailable"); + assert_eq!( + value["result"]["checks"]["publish"]["transport"], + "radrootsd_proxy" + ); + assert_eq!( + value["result"]["checks"]["publish"]["state"], + "unconfigured" + ); assert_eq!(value["result"]["checks"]["publish"]["executable"], false); assert_contains( &value["result"]["checks"]["publish"]["reason"], - "radrootsd publish mode is deferred", + "configured token file or token secret id", ); assert_eq!(value["result"]["checks"]["store"]["state"], "ready"); assert_eq!( @@ -1226,15 +1212,19 @@ fn health_check_exposes_publish_readiness() { assert_eq!(value["result"]["actions"][0], "radroots account create"); assert_eq!( value["result"]["actions"][1], - "radroots --publish-mode nostr_relay --relay wss://relay.example.com config get" + "configure RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE or RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_SECRET_ID" ); assert_eq!( value["next_actions"][0]["command"], "radroots account create" ); - assert_direct_relay_next_action( - &value["next_actions"], - "radroots --publish-mode nostr_relay --relay wss://relay.example.com config get", + assert_eq!( + value["next_actions"][1]["description"], + "configure RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE or RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_SECRET_ID" + ); + assert_eq!( + value["next_actions"][1]["env_var"], + "RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE" ); assert_eq!(value["errors"].as_array().expect("errors").len(), 0); } @@ -1270,7 +1260,10 @@ 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"]["mode"], "nostr_relay"); + assert_eq!( + value["result"]["checks"]["publish"]["transport"], + "direct_nostr_relay" + ); assert_eq!(value["result"]["checks"]["publish"]["state"], "ready"); assert_eq!(value["result"]["checks"]["publish"]["executable"], true); assert_eq!( @@ -1308,7 +1301,7 @@ fn farm_readiness_check_reports_mode_specific_publish_gates() { } else { &relay_value["result"] }; - assert_eq!(relay_detail["publish_mode"], "nostr_relay"); + assert_eq!(relay_detail["publish_transport"], "direct_nostr_relay"); assert_eq!(relay_detail["publish_state"], "unconfigured"); assert_eq!(relay_detail["publish_executable"], false); assert_eq!(relay_detail["missing"][0], "Configured relay"); @@ -1324,466 +1317,106 @@ signer_session_ref = "session_test" ); let output = sandbox .command() - .env("RADROOTS_CLI_RPC_BEARER_TOKEN", "bridge_test") + .env( + "RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE", + radrootsd_proxy_token_file(&sandbox), + ) .args([ "--format", "json", - "--publish-mode", - "radrootsd", + "--publish-transport", + "radrootsd_proxy", "farm", "readiness", "check", ]) .output() - .expect("run radrootsd farm readiness"); + .expect("run radrootsd proxy farm readiness"); let radrootsd_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_eq!(radrootsd_value["result"]["publish_mode"], "radrootsd"); - assert_eq!(radrootsd_value["result"]["publish_state"], "unavailable"); - assert_eq!(radrootsd_value["result"]["publish_executable"], false); assert_contains( - &radrootsd_value["result"]["reason"], - "radrootsd publish mode is deferred", + &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 --publish-mode nostr_relay --relay wss://relay.example.com farm publish" - ); -} - -#[test] -fn radrootsd_listing_publish_fails_closed_without_bridge_or_relay_side_effects() { - let sandbox = RadrootsCliSandbox::new(); - sandbox.json_success(&["--format", "json", "account", "create"]); - let farm = sandbox.json_success(&[ - "--format", - "json", - "farm", - "create", - "--name", - "Router Farm", - "--location", - "farmstand", - "--country", - "US", - "--delivery-method", - "pickup", - ]); - let listing_file = create_listing_draft(&sandbox, "radrootsd-router"); - make_listing_publishable( - &listing_file, - farm["result"]["config"]["farm_d_tag"] - .as_str() - .expect("farm d tag"), - ); - - let output = sandbox - .command() - .env("RADROOTS_CLI_RPC_URL", "http://127.0.0.1:9") - .env("RADROOTS_CLI_RPC_BEARER_TOKEN", "bridge_test") - .args([ - "--format", - "json", - "--publish-mode", - "radrootsd", - "--approval-token", - "approve", - "--idempotency-key", - "idem_listing", - "listing", - "publish", - listing_file.to_string_lossy().as_ref(), - ]) - .output() - .expect("run radrootsd listing publish"); - let value: Value = serde_json::from_slice(&output.stdout).expect("json output"); - - assert!(!output.status.success()); - assert_eq!(output.status.code(), Some(3)); - assert_eq!(value["operation_id"], "listing.publish"); - assert_eq!(value["result"], Value::Null); - assert_eq!(value["errors"][0]["code"], "operation_unavailable"); - assert_eq!(value["errors"][0]["detail"]["class"], "operation"); - assert_radrootsd_deferred_message(&value); - assert_eq!( - value["errors"][0]["detail"]["actions"][0], - "radroots --publish-mode nostr_relay --relay wss://relay.example.com listing publish <file>" - ); - assert_direct_relay_next_action( - &value["next_actions"], - "radroots --publish-mode nostr_relay --relay wss://relay.example.com listing publish <file>", - ); -} - -#[test] -fn radrootsd_farm_publish_fails_closed_without_bridge_or_relay_side_effects() { - let sandbox = RadrootsCliSandbox::new(); - sandbox.json_success(&["--format", "json", "account", "create"]); - sandbox.json_success(&[ - "--format", - "json", - "farm", - "create", - "--name", - "Router Farm", - "--location", - "farmstand", - "--country", - "US", - "--delivery-method", - "pickup", - ]); - - let output = sandbox - .command() - .env("RADROOTS_CLI_RPC_URL", "http://127.0.0.1:9") - .env("RADROOTS_CLI_RPC_BEARER_TOKEN", "bridge_test") - .args([ - "--format", - "json", - "--publish-mode", - "radrootsd", - "--approval-token", - "approve", - "--idempotency-key", - "idem_farm", - "farm", - "publish", - ]) - .output() - .expect("run radrootsd farm publish"); - let value: Value = serde_json::from_slice(&output.stdout).expect("json output"); - - assert!(!output.status.success()); - assert_eq!(output.status.code(), Some(3)); - assert_eq!(value["operation_id"], "farm.publish"); - assert_eq!(value["result"], Value::Null); - assert_eq!(value["errors"][0]["code"], "operation_unavailable"); - assert_eq!(value["errors"][0]["detail"]["class"], "operation"); - assert_radrootsd_deferred_message(&value); - assert_eq!( - value["errors"][0]["detail"]["actions"][0], - "radroots --publish-mode nostr_relay --relay wss://relay.example.com farm publish" - ); - assert_direct_relay_next_action( - &value["next_actions"], - "radroots --publish-mode nostr_relay --relay wss://relay.example.com farm publish", - ); - - let persisted = sandbox.json_success(&["--format", "json", "farm", "get"]); - assert_eq!( - persisted["result"]["document"]["publication"]["profile_event_id"], - Value::Null - ); - assert_eq!( - persisted["result"]["document"]["publication"]["farm_event_id"], - Value::Null + "radroots farm publish" ); } #[test] -fn radrootsd_farm_publish_ignores_signer_session_binding_and_fails_closed() { - let sandbox = RadrootsCliSandbox::new(); - sandbox.json_success(&["--format", "json", "account", "create"]); - sandbox.json_success(&[ - "--format", - "json", - "farm", - "create", - "--name", - "Binding Farm", - "--location", - "farmstand", - "--country", - "US", - "--delivery-method", - "pickup", - ]); - sandbox.write_app_config("[publish]\nmode = \"radrootsd\"\n"); - - let dry_run_output = sandbox - .command() - .env("RADROOTS_CLI_RPC_BEARER_TOKEN", "bridge_test") - .args(["--format", "json", "--dry-run", "farm", "publish"]) - .output() - .expect("run radrootsd farm publish dry-run"); - let dry_run: Value = serde_json::from_slice(&dry_run_output.stdout).expect("json output"); - - assert!(!dry_run_output.status.success()); - assert_eq!(dry_run_output.status.code(), Some(3)); - assert_eq!(dry_run["operation_id"], "farm.publish"); - assert_eq!(dry_run["errors"][0]["code"], "operation_unavailable"); - assert_eq!(dry_run["errors"][0]["detail"]["class"], "operation"); - assert_radrootsd_deferred_message(&dry_run); - - let live_output = sandbox - .command() - .env("RADROOTS_CLI_RPC_BEARER_TOKEN", "bridge_test") - .args([ +fn radrootsd_proxy_listing_publish_update_and_archive_dry_run_without_direct_relays() { + for operation in ["publish", "update", "archive"] { + let sandbox = RadrootsCliSandbox::new(); + sandbox.json_success(&["--format", "json", "account", "create"]); + let farm = sandbox.json_success(&[ "--format", "json", - "--approval-token", - "approve", "farm", - "publish", - ]) - .output() - .expect("run radrootsd farm publish"); - let live: Value = serde_json::from_slice(&live_output.stdout).expect("json output"); - - assert!(!live_output.status.success()); - assert_eq!(live_output.status.code(), Some(3)); - assert_eq!(live["operation_id"], "farm.publish"); - assert_eq!(live["errors"][0]["code"], "operation_unavailable"); - assert_eq!(live["errors"][0]["detail"]["class"], "operation"); - assert_radrootsd_deferred_message(&live); -} - -#[test] -fn radrootsd_listing_writes_dry_run_fail_closed_before_account_or_bridge_work() { - for operation in ["publish", "update", "archive"] { - let sandbox = RadrootsCliSandbox::new(); - let seller = identity_public(42); - let listing_file = create_listing_draft( - &sandbox, - format!("radrootsd-no-account-dry-run-{operation}").as_str(), - ); - make_listing_publishable_with_seller( + "create", + "--name", + "Proxy Farm", + "--location", + "farmstand", + "--country", + "US", + "--delivery-method", + "pickup", + ]); + let listing_file = + create_listing_draft(&sandbox, format!("radrootsd-proxy-{operation}").as_str()); + make_listing_publishable( &listing_file, - "AAAAAAAAAAAAAAAAAAAAAw", - seller.public_key_hex.as_str(), - ); - sandbox.write_app_config( - r#"[publish] -mode = "radrootsd" - -[[capability_binding]] -capability = "signer.remote_nip46" -provider = "myc" -target_kind = "explicit_endpoint" -target = "http://myc.invalid" -signer_session_ref = "session_test" -"#, + farm["result"]["config"]["farm_d_tag"] + .as_str() + .expect("farm d tag"), ); - let mut command = sandbox.command(); - command - .env("RADROOTS_CLI_RPC_BEARER_TOKEN", "bridge_test") + let output = sandbox + .command() + .env( + "RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE", + radrootsd_proxy_token_file(&sandbox), + ) .args([ "--format", "json", - "--account-id", - "missing-local-account", + "--publish-transport", + "radrootsd_proxy", "--dry-run", "listing", operation, listing_file.to_string_lossy().as_ref(), - ]); - let output = command + ]) .output() - .expect("run radrootsd dry-run listing write"); + .expect("run proxy listing dry run"); let value: Value = serde_json::from_slice(&output.stdout).expect("json output"); - assert!(!output.status.success()); - assert_eq!(output.status.code(), Some(3)); + assert!(output.status.success()); assert_eq!(value["operation_id"], format!("listing.{operation}")); - assert_eq!(value["result"], Value::Null); - assert_eq!(value["errors"][0]["code"], "operation_unavailable"); - assert_eq!(value["errors"][0]["detail"]["class"], "operation"); - assert_radrootsd_deferred_message(&value); - } -} - -#[test] -fn radrootsd_listing_writes_fail_closed_before_account_or_bridge_work() { - for operation in ["publish", "update", "archive"] { - let sandbox = RadrootsCliSandbox::new(); - let seller = identity_public(43); - let listing_file = create_listing_draft( - &sandbox, - format!("radrootsd-no-account-{operation}").as_str(), - ); - make_listing_publishable_with_seller( - &listing_file, - "AAAAAAAAAAAAAAAAAAAAAw", - seller.public_key_hex.as_str(), + assert_eq!(value["result"]["state"], "dry_run"); + assert_eq!(value["result"]["source"], "SDK listing publish · local key"); + assert_eq!(value["result"]["dry_run"], true); + assert_eq!( + value["result"]["target_relays"] + .as_array() + .expect("relays") + .len(), + 0 ); - sandbox.write_app_config( - r#"[publish] -mode = "radrootsd" - -[[capability_binding]] -capability = "signer.remote_nip46" -provider = "myc" -target_kind = "explicit_endpoint" -target = "http://myc.invalid" -signer_session_ref = "session_test" -"#, + assert_contains( + &value["result"]["reason"], + "SDK enqueue and relay push skipped", ); - let mut command = sandbox.command(); - command - .env("RADROOTS_CLI_RPC_BEARER_TOKEN", "bridge_test") - .args([ - "--format", - "json", - "--account-id", - "missing-local-account", - "--approval-token", - "approve", - "listing", - operation, - listing_file.to_string_lossy().as_ref(), - ]); - let output = command.output().expect("run radrootsd listing write"); - let value: Value = serde_json::from_slice(&output.stdout).expect("json output"); - - assert!(!output.status.success()); - assert_eq!(output.status.code(), Some(3)); - assert_eq!(value["operation_id"], format!("listing.{operation}")); - assert_eq!(value["result"], Value::Null); - assert_eq!(value["errors"][0]["code"], "operation_unavailable"); - assert_eq!(value["errors"][0]["detail"]["class"], "operation"); - assert_radrootsd_deferred_message(&value); } } #[test] -fn radrootsd_listing_publish_does_not_surface_bridge_errors_before_guardrail() { - let sandbox = RadrootsCliSandbox::new(); - let listing_file = create_listing_draft(&sandbox, "radrootsd-bridge-error"); - make_listing_publishable(&listing_file, "AAAAAAAAAAAAAAAAAAAAAw"); - sandbox.write_app_config("[publish]\nmode = \"radrootsd\"\n"); - - let mut command = sandbox.command(); - command - .env("RADROOTS_CLI_RPC_URL", "http://127.0.0.1:9") - .env("RADROOTS_CLI_RPC_BEARER_TOKEN", "bridge_test") - .args([ - "--format", - "json", - "--approval-token", - "approve", - "listing", - "publish", - listing_file.to_string_lossy().as_ref(), - ]); - let output = command.output().expect("run radrootsd listing publish"); - let value: Value = serde_json::from_slice(&output.stdout).expect("json output"); - - assert!(!output.status.success()); - assert_eq!(output.status.code(), Some(3)); - assert_eq!(value["operation_id"], "listing.publish"); - assert_eq!(value["result"], Value::Null); - assert_eq!(value["errors"][0]["code"], "operation_unavailable"); - assert_eq!(value["errors"][0]["detail"]["class"], "operation"); - assert_radrootsd_deferred_message(&value); -} - -#[test] -fn radrootsd_listing_publish_fails_closed_before_relay_or_myc_preflight() { - let sandbox = RadrootsCliSandbox::new(); - sandbox.json_success(&["--format", "json", "account", "create"]); - let farm = sandbox.json_success(&[ - "--format", - "json", - "farm", - "create", - "--name", - "Deferred Farm", - "--location", - "farmstand", - "--country", - "US", - "--delivery-method", - "pickup", - ]); - let listing_file = create_listing_draft(&sandbox, "radrootsd-myc-router"); - make_listing_publishable( - &listing_file, - farm["result"]["config"]["farm_d_tag"] - .as_str() - .expect("farm d tag"), - ); - sandbox.write_app_config("[publish]\nmode = \"radrootsd\"\n\n[signer]\nbackend = \"myc\"\n"); - - let (output, value) = sandbox.json_output(&[ - "--format", - "json", - "--approval-token", - "approve", - "listing", - "publish", - listing_file.to_string_lossy().as_ref(), - ]); - - assert!(!output.status.success()); - assert_eq!(output.status.code(), Some(3)); - assert_eq!(value["operation_id"], "listing.publish"); - assert_eq!(value["errors"][0]["code"], "operation_unavailable"); - assert_eq!(value["errors"][0]["detail"]["class"], "operation"); - assert_radrootsd_deferred_message(&value); - assert!( - !value["errors"][0]["message"] - .as_str() - .expect("error message") - .contains("signer mode `myc`") - ); -} - -#[test] -fn radrootsd_publish_mode_routes_listing_update() { - let sandbox = RadrootsCliSandbox::new(); - sandbox.json_success(&["--format", "json", "account", "create"]); - let farm = sandbox.json_success(&[ - "--format", - "json", - "farm", - "create", - "--name", - "Update Farm", - "--location", - "farmstand", - "--country", - "US", - "--delivery-method", - "pickup", - ]); - let listing_file = create_listing_draft(&sandbox, "radrootsd-update-router"); - make_listing_publishable( - &listing_file, - farm["result"]["config"]["farm_d_tag"] - .as_str() - .expect("farm d tag"), - ); - - let (output, value) = sandbox.json_output(&[ - "--format", - "json", - "--publish-mode", - "radrootsd", - "--approval-token", - "approve", - "listing", - "update", - listing_file.to_string_lossy().as_ref(), - ]); - - assert!(!output.status.success()); - assert_eq!(output.status.code(), Some(3)); - assert_eq!(value["operation_id"], "listing.update"); - assert_eq!(value["result"], Value::Null); - assert_eq!(value["errors"][0]["code"], "operation_unavailable"); - assert_eq!(value["errors"][0]["detail"]["class"], "operation"); - assert_radrootsd_deferred_message(&value); - assert_eq!( - value["errors"][0]["detail"]["actions"][0], - "radroots --publish-mode nostr_relay --relay wss://relay.example.com listing update <file>" - ); -} - -#[test] fn listing_update_publish_attempts_direct_relay_with_approval() { let sandbox = RadrootsCliSandbox::new(); sandbox.json_success(&["--format", "json", "account", "create"]); @@ -1828,7 +1461,7 @@ fn listing_update_publish_attempts_direct_relay_with_approval() { assert_eq!(value["errors"][0]["detail"]["class"], "network"); assert_contains( &value["errors"][0]["message"], - "direct relay connection failed", + "SDK relay publish did not reach accepted quorum", ); assert!( !value["errors"][0]["message"] @@ -2252,15 +1885,20 @@ fn next_actions_mirror_result_actions_for_json_and_ndjson() { &["--format", "ndjson", "health", "check", "run"][..], ] { let daemon = RadrootsCliSandbox::new(); - daemon.write_app_config("[publish]\nmode = \"radrootsd\"\n"); + daemon.write_app_config("[publish]\ntransport = \"radrootsd_proxy\"\n"); let output = daemon.command().args(args).output().expect("run ndjson"); let frames = ndjson_from_stdout(&output); let terminal = frames.last().expect("terminal ndjson frame"); assert!(output.status.success(), "{args:?}"); - assert_direct_relay_next_action( - &terminal["payload"]["next_actions"], - "radroots --publish-mode nostr_relay --relay wss://relay.example.com config get", + assert!( + terminal["payload"]["next_actions"] + .as_array() + .expect("next actions") + .iter() + .any(|action| action["description"] + == "configure RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE or RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_SECRET_ID"), + "{args:?}" ); } } @@ -2294,9 +1932,8 @@ fn human_health_status_surfaces_publish_reason_and_actions() { let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); assert!(stdout.starts_with("health.status.get: needs_attention\n")); - assert!(stdout.contains("publish_mode: nostr_relay")); assert!(stdout.contains("publish_state: unconfigured")); - assert!(stdout.contains("reason: nostr_relay publish mode requires a selected or default write-capable local account")); + assert!(stdout.contains("reason: direct_nostr_relay publish transport requires a selected or default write-capable local account for signed writes")); assert!(stdout.contains("- radroots account create")); assert!(serde_json::from_str::<Value>(&stdout).is_err()); } @@ -3138,76 +2775,18 @@ fn order_status_get_invalid_order_id_uses_sdk_error_contract() { } #[test] -fn radrootsd_sync_push_failure_exposes_nostr_relay_recovery_action() { +fn legacy_radrootsd_publish_transport_value_is_rejected() { let sandbox = RadrootsCliSandbox::new(); - let (json_output, value) = sandbox.json_output(&[ - "--format", - "json", - "--publish-mode", - "radrootsd", - "sync", - "push", - ]); - - assert_eq!(json_output.status.code(), Some(3)); - assert_eq!(value["operation_id"], "sync.push"); - assert_eq!(value["result"], Value::Null); - assert_eq!(value["errors"][0]["code"], "operation_unavailable"); - assert_eq!(value["errors"][0]["detail"]["state"], "unavailable"); - assert_eq!(value["errors"][0]["detail"]["publish"]["mode"], "radrootsd"); - assert_eq!( - value["errors"][0]["detail"]["publish"]["state"], - "unavailable" - ); - assert_eq!( - value["errors"][0]["detail"]["actions"][0], - "radroots --publish-mode nostr_relay sync push" - ); - assert_eq!(value["next_actions"][0]["kind"], "cli_command"); - assert_eq!( - value["next_actions"][0]["command"], - "radroots --publish-mode nostr_relay sync push" - ); - - let ndjson_output = sandbox + let output = sandbox .command() - .args([ - "--format", - "ndjson", - "--publish-mode", - "radrootsd", - "sync", - "push", - ]) + .args(["--publish-transport", "radrootsd", "sync", "push"]) .output() - .expect("run sync push ndjson"); - let frames = ndjson_from_stdout(&ndjson_output); - let terminal = frames.last().expect("terminal frame"); - - assert_eq!(ndjson_output.status.code(), Some(3)); - assert_eq!(terminal["operation_id"], "sync.push"); - assert_eq!(terminal["frame_type"], "error"); - assert_eq!( - terminal["payload"]["next_actions"][0]["command"], - "radroots --publish-mode nostr_relay sync push" - ); + .expect("run legacy publish transport"); + let stderr = String::from_utf8(output.stderr).expect("utf8 stderr"); - let human_output = sandbox - .command() - .args(["--publish-mode", "radrootsd", "sync", "push"]) - .output() - .expect("run sync push human"); - let stdout = String::from_utf8(human_output.stdout).expect("utf8 stdout"); - - assert_eq!(human_output.status.code(), Some(3)); - assert!(stdout.starts_with("sync.push: error\n")); - assert!(stdout.contains("error: operation_unavailable")); - assert!(stdout.contains("state: unavailable")); - assert!(stdout.contains("publish_mode: radrootsd")); - assert!(stdout.contains("publish_state: unavailable")); - assert!(stdout.contains("radrootsd publish mode is deferred")); - assert!(stdout.contains("- radroots --publish-mode nostr_relay sync push")); - assert!(serde_json::from_str::<Value>(&stdout).is_err()); + assert!(!output.status.success()); + assert!(stderr.contains("invalid value")); + assert!(stderr.contains("radrootsd_proxy")); } #[test] @@ -7685,11 +7264,11 @@ fn seller_target_flow_acceptance_uses_target_operations() { assert_eq!(unavailable_archive["operation_id"], "listing.archive"); assert_eq!( unavailable_archive["errors"][0]["code"], - "network_unavailable" + "empty_target_relays" ); assert_eq!( unavailable_archive["errors"][0]["detail"]["class"], - "network" + "configuration" ); assert_no_removed_command_reference(&unavailable_archive, &["listing", "archive"]); assert_no_daemon_runtime_reference(&unavailable_archive, &["listing", "archive"]);