cli

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

commit af4dd2dac29f2d56bc086f383546b460071b5a7b
parent 0130077087fd3520e4675033a8fe6dc06819229c
Author: triesap <tyson@radroots.org>
Date:   Sun, 10 May 2026 15:03:44 +0000

cli: align deferred publish guardrails

- make radrootsd publish mode fail closed for active buyer and seller writes
- expose account-resolution status and align signer write-kind readiness
- add listing.update signer and relay publish preflight parity
- update CLI help, docs, and guardrail regression coverage

Diffstat:
Msrc/domain/runtime.rs | 1+
Msrc/main.rs | 29+++++++++++++++++++++++------
Msrc/operation_core.rs | 57++++++++++-----------------------------------------------
Msrc/operation_registry.rs | 15+++++++++++++--
Msrc/runtime/accounts.rs | 7+++++++
Msrc/runtime/config.rs | 1+
Msrc/runtime/farm.rs | 455+++++++------------------------------------------------------------------------
Msrc/runtime/listing.rs | 327+++++++------------------------------------------------------------------------
Msrc/runtime/signer.rs | 111++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Msrc/target_cli.rs | 4++--
Mtests/target_cli.rs | 642+++++++++++++++++++++----------------------------------------------------------
11 files changed, 387 insertions(+), 1262 deletions(-)

diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -502,6 +502,7 @@ impl AccountSummaryView { #[derive(Debug, Clone, Serialize)] pub struct AccountResolutionView { + pub status: String, pub source: String, #[serde(skip_serializing_if = "Option::is_none")] pub resolved_account: Option<AccountSummaryView>, diff --git a/src/main.rs b/src/main.rs @@ -42,7 +42,9 @@ use crate::operation_registry::{ }; use crate::operation_runtime::RuntimeOperationService; use crate::output_contract::OutputEnvelope; -use crate::runtime::config::{PublishMode, RuntimeConfig, SignerBackend}; +use crate::runtime::config::{ + PublishMode, RADROOTSD_PUBLISH_DEFERRED_REASON, RuntimeConfig, SignerBackend, +}; use crate::runtime::logging::initialize_logging; use crate::runtime_args::{RuntimeInvocationArgs, RuntimeOutputFormatArg}; use crate::target_cli::{TargetCliArgs, TargetOutputFormat}; @@ -483,7 +485,7 @@ fn validate_publish_mode_contract( && requires_nostr_relay_publish_mode(spec.operation_id) { let message = format!( - "`{}` cannot run with publish mode `radrootsd`; radrootsd publish transport is only implemented for farm and listing publish operations", + "`{}` cannot run with publish mode `radrootsd`; {RADROOTSD_PUBLISH_DEFERRED_REASON}", spec.cli_path ); let actions = nostr_relay_publish_mode_recovery_actions(spec.operation_id); @@ -512,10 +514,25 @@ fn validate_publish_mode_contract( } fn nostr_relay_publish_mode_recovery_actions(operation_id: &str) -> Vec<String> { - if operation_id == "sync.push" { - vec!["radroots --publish-mode nostr_relay sync push".to_owned()] - } else { - Vec::new() + 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(), } } diff --git a/src/operation_core.rs b/src/operation_core.rs @@ -31,7 +31,7 @@ use crate::runtime::accounts::{ unresolved_account_reason, }; use crate::runtime::config::{ - PublishMode, RuntimeConfig, SIGNER_REMOTE_NIP46_CAPABILITY, SignerBackend, + PublishMode, RADROOTSD_PUBLISH_DEFERRED_REASON, RuntimeConfig, SignerBackend, }; use crate::runtime::logging::LoggingState; use crate::runtime_args::LocalExportFormatArg; @@ -201,6 +201,7 @@ impl OperationService<ConfigGetRequest> for CoreOperationService<'_> { "store_path": self.config.account.store_path.display().to_string(), "secrets_dir": self.config.account.secrets_dir.display().to_string(), }, + "account_resolution": account_resolution_view(&account), "signer": { "mode": self.config.signer.backend.as_str(), }, @@ -814,47 +815,14 @@ fn nostr_relay_publish_readiness( ("ready", true, None) } -fn radrootsd_publish_readiness(config: &RuntimeConfig) -> (&'static str, bool, Option<String>) { - if config.rpc.bridge_bearer_token.is_none() { - return ( - "unconfigured", - false, - Some( - "radrootsd publish requires bridge bearer token configuration from RADROOTS_RPC_BEARER_TOKEN" - .to_owned(), - ), - ); - } - - if !radrootsd_signer_session_binding_configured(config) { - return ( - "unconfigured", - false, - Some( - "radrootsd publish requires a signer.remote_nip46 capability binding with signer_session_ref for config and health readiness" - .to_owned(), - ), - ); - } - +fn radrootsd_publish_readiness(_config: &RuntimeConfig) -> (&'static str, bool, Option<String>) { ( - "ready", - true, - Some( - "radrootsd bridge endpoint, bridge auth, and signer-session binding are configured; live bridge readiness is verified when publish runs" - .to_owned(), - ), + "unavailable", + false, + Some(RADROOTSD_PUBLISH_DEFERRED_REASON.to_owned()), ) } -fn radrootsd_signer_session_binding_configured(config: &RuntimeConfig) -> bool { - config - .capability_binding(SIGNER_REMOTE_NIP46_CAPABILITY) - .and_then(|binding| binding.signer_session_ref.as_deref()) - .map(str::trim) - .is_some_and(|value| !value.is_empty()) -} - fn health_status_state(store_state: &str, publish: &PublishRuntimeView) -> &'static str { if store_state == "ready" && publish_runtime_ready(publish) { "ready" @@ -941,15 +909,10 @@ fn publish_recovery_actions( } } PublishMode::Radrootsd => { - if config.rpc.bridge_bearer_token.is_none() { - push_unique(&mut actions, "configure RADROOTS_RPC_BEARER_TOKEN"); - } - if !radrootsd_signer_session_binding_configured(config) { - push_unique( - &mut actions, - "configure signer.remote_nip46 signer_session_ref", - ); - } + push_unique( + &mut actions, + "radroots --publish-mode nostr_relay --relay wss://relay.example.com config get", + ); } } actions diff --git a/src/operation_registry.rs b/src/operation_registry.rs @@ -1158,8 +1158,8 @@ pub fn get_operation(operation_id: &str) -> Option<&'static OperationSpec> { pub fn network_requirement(operation_id: &str) -> NetworkRequirement { match operation_id { "sync.pull" | "sync.push" | "sync.watch" | "market.refresh" | "farm.publish" - | "listing.publish" | "listing.archive" | "order.submit" | "order.status.get" - | "order.event.list" => NetworkRequirement::External { + | "listing.publish" | "listing.update" | "listing.archive" | "order.submit" + | "order.status.get" | "order.event.list" => NetworkRequirement::External { dry_run_requires_network: false, }, "order.accept" @@ -1183,6 +1183,7 @@ pub fn requires_local_signer_mode(operation_id: &str) -> bool { | "farm.publish" | "sync.push" | "listing.publish" + | "listing.update" | "listing.archive" | "order.submit" | "order.accept" @@ -1200,6 +1201,10 @@ pub fn requires_nostr_relay_publish_mode(operation_id: &str) -> bool { matches!( operation_id, "sync.push" + | "farm.publish" + | "listing.publish" + | "listing.update" + | "listing.archive" | "order.submit" | "order.accept" | "order.decline" @@ -1511,6 +1516,7 @@ mod tests { "market.refresh", "farm.publish", "listing.publish", + "listing.update", "listing.archive", "order.submit", "order.accept", @@ -1542,6 +1548,7 @@ mod tests { "sync.push", "farm.publish", "listing.publish", + "listing.update", "listing.archive", "order.submit", "order.accept", @@ -1568,6 +1575,10 @@ mod tests { .collect::<BTreeSet<_>>(); let expected = [ "sync.push", + "farm.publish", + "listing.publish", + "listing.update", + "listing.archive", "order.submit", "order.accept", "order.decline", diff --git a/src/runtime/accounts.rs b/src/runtime/accounts.rs @@ -529,6 +529,12 @@ pub fn account_summary_view(account: &AccountRecordView) -> AccountSummaryView { pub fn account_resolution_view(resolution: &AccountResolution) -> AccountResolutionView { AccountResolutionView { + status: if resolution.resolved_account.is_some() { + "resolved" + } else { + "unresolved" + } + .to_owned(), source: resolution.source.as_str().to_owned(), resolved_account: resolution .resolved_account @@ -543,6 +549,7 @@ pub fn account_resolution_view(resolution: &AccountResolution) -> AccountResolut pub fn empty_account_resolution_view() -> AccountResolutionView { AccountResolutionView { + status: "unresolved".to_owned(), source: AccountResolutionSource::None.as_str().to_owned(), resolved_account: None, default_account: None, diff --git a/src/runtime/config.rs b/src/runtime/config.rs @@ -461,6 +461,7 @@ 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 { diff --git a/src/runtime/farm.rs b/src/runtime/farm.rs @@ -12,25 +12,19 @@ use radroots_events_codec::wire::WireEventParts; use radroots_nostr::prelude::radroots_event_from_nostr; use radroots_replica_db::migrations; use radroots_replica_sync::{RadrootsReplicaIngestOutcome, radroots_replica_ingest_event}; -use radroots_sdk::{ - RadrootsSdkClient, RadrootsSdkConfig, RadrootsdAuth, SdkEnvironment, SdkPublishError, - SdkPublishReceipt, SdkRadrootsdFarmPublishOptions, SdkRadrootsdProfilePublishOptions, - SdkRadrootsdPublishReceipt, SdkRadrootsdSignerSessionRef, SdkTransportMode, - SdkTransportReceipt, SignerConfig as SdkSignerConfig, -}; use radroots_sql_core::SqliteExecutor; use serde_json::json; use crate::domain::runtime::{ FarmConfigDocumentView, FarmConfigSummaryView, FarmGetView, FarmListingDefaultsView, - FarmPublicationView, FarmPublishComponentView, FarmPublishEventView, FarmPublishJobView, + FarmPublicationView, FarmPublishComponentView, FarmPublishEventView, FarmPublishLocalReplicaView, FarmPublishView, FarmRebindView, FarmSelectionView, FarmSetView, FarmSetupView, FarmStatusView, RelayFailureView, }; use crate::runtime::RuntimeError; use crate::runtime::accounts::{self, AccountRecordView}; use crate::runtime::config::{ - PublishMode, RuntimeConfig, SIGNER_REMOTE_NIP46_CAPABILITY, SignerBackend, + PublishMode, RADROOTSD_PUBLISH_DEFERRED_REASON, RuntimeConfig, SignerBackend, }; use crate::runtime::direct_relay::{ DirectRelayFailure, DirectRelayPublishError, DirectRelayPublishReceipt, @@ -49,7 +43,7 @@ use crate::runtime_args::{ const FARM_CONFIG_SOURCE: &str = "farm config · local first"; const FARM_SELLER_ACTOR_SOURCE: &str = "farm_config"; const RELAY_FARM_WRITE_SOURCE: &str = "direct Nostr relay publish · local key"; -const RADROOTSD_FARM_WRITE_SOURCE: &str = "radrootsd publish transport · signer session"; +const RADROOTSD_FARM_WRITE_SOURCE: &str = "radrootsd publish transport · deferred"; const RADROOTSD_BRIDGE_PROFILE_PUBLISH_METHOD: &str = "bridge.profile.publish"; const RADROOTSD_BRIDGE_FARM_PUBLISH_METHOD: &str = "bridge.farm.publish"; @@ -545,42 +539,16 @@ fn relay_farm_publish_readiness( } } -fn radrootsd_farm_publish_readiness(config: &RuntimeConfig) -> FarmPublishReadiness { - if config.rpc.bridge_bearer_token.is_none() { - return FarmPublishReadiness { - state: "unconfigured", - executable: false, - reason: Some( - "radrootsd farm publish requires bridge bearer token configuration from RADROOTS_RPC_BEARER_TOKEN" - .to_owned(), - ), - missing: vec!["Radrootsd bridge bearer token".to_owned()], - actions: vec!["configure RADROOTS_RPC_BEARER_TOKEN".to_owned()], - }; - } - - if resolve_radrootsd_signer_session_id(config, &FarmPublishArgs::default()).is_none() { - return FarmPublishReadiness { - state: "unconfigured", - executable: false, - reason: Some( - "radrootsd farm publish requires a signer.remote_nip46 capability binding with signer_session_ref" - .to_owned(), - ), - missing: vec!["Signer session binding".to_owned()], - actions: vec!["configure signer.remote_nip46 signer_session_ref".to_owned()], - }; - } - +fn radrootsd_farm_publish_readiness(_config: &RuntimeConfig) -> FarmPublishReadiness { FarmPublishReadiness { - state: "ready", - executable: true, - reason: Some( - "radrootsd bridge endpoint, bridge auth, and signer-session binding are configured; live bridge readiness is verified when farm publish runs" + 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(), - ), - missing: Vec::new(), - actions: vec!["radroots farm publish".to_owned()], + ], } } @@ -674,15 +642,17 @@ pub fn publish( profile_idempotency_key, farm_idempotency_key, ), - PublishMode::Radrootsd => publish_via_radrootsd( + PublishMode::Radrootsd => Ok(radrootsd_preflight_publish_view( config, args, - resolved, - account_pubkey, + &resolved, + &account_pubkey, previews, profile_idempotency_key, farm_idempotency_key, - ), + "unavailable", + RADROOTSD_PUBLISH_DEFERRED_REASON, + )), } } @@ -741,59 +711,17 @@ fn dry_run_publish_view( vec!["radroots farm publish".to_owned()], )) } - PublishMode::Radrootsd => { - let Some(signer_session_id) = resolve_radrootsd_signer_session_id(config, args) else { - return Ok(radrootsd_preflight_publish_view( - config, - args, - resolved, - account_pubkey, - previews, - profile_idempotency_key, - farm_idempotency_key, - "unconfigured", - "radrootsd farm publish dry-run requires a signer.remote_nip46 capability binding with signer_session_ref", - )); - }; - if config.rpc.bridge_bearer_token.is_none() { - return Ok(radrootsd_preflight_publish_view( - config, - args, - resolved, - account_pubkey, - previews, - profile_idempotency_key, - farm_idempotency_key, - "unconfigured", - "radrootsd bridge bearer token is required for farm publish dry-run; set RADROOTS_RPC_BEARER_TOKEN", - )); - } - - Ok(base_publish_view( - "dry_run", - config, - args, - resolved, - account_pubkey, - radrootsd_preview_component( - RADROOTSD_BRIDGE_PROFILE_PUBLISH_METHOD, - KIND_PROFILE, - profile_idempotency_key, - args, - Some(previews.profile.event), - ), - radrootsd_preview_component( - RADROOTSD_BRIDGE_FARM_PUBLISH_METHOD, - KIND_FARM, - farm_idempotency_key, - args, - Some(previews.farm.event), - ), - Some("dry run requested; radrootsd submission skipped".to_owned()), - vec!["radroots farm publish".to_owned()], - ) - .with_requested_signer_session_id(Some(signer_session_id))) - } + PublishMode::Radrootsd => Ok(radrootsd_preflight_publish_view( + config, + args, + resolved, + account_pubkey, + previews, + profile_idempotency_key, + farm_idempotency_key, + "unavailable", + RADROOTSD_PUBLISH_DEFERRED_REASON, + )), } } @@ -895,136 +823,6 @@ fn publish_via_direct_relay( Ok(view) } -fn publish_via_radrootsd( - config: &RuntimeConfig, - args: &FarmPublishArgs, - mut resolved: ResolvedFarmConfig, - account_pubkey: String, - previews: FarmPublishPreviews, - profile_idempotency_key: Option<String>, - farm_idempotency_key: Option<String>, -) -> Result<FarmPublishView, RuntimeError> { - let Some(signer_session_id) = resolve_radrootsd_signer_session_id(config, args) else { - return Ok(radrootsd_preflight_publish_view( - config, - args, - &resolved, - &account_pubkey, - previews, - profile_idempotency_key, - farm_idempotency_key, - "unconfigured", - "radrootsd farm publish requires a signer.remote_nip46 capability binding with signer_session_ref", - )); - }; - if config.rpc.bridge_bearer_token.is_none() { - return Ok(radrootsd_preflight_publish_view( - config, - args, - &resolved, - &account_pubkey, - previews, - profile_idempotency_key, - farm_idempotency_key, - "unconfigured", - "radrootsd bridge bearer token is required for farm publish; set RADROOTS_RPC_BEARER_TOKEN", - )); - } - - let client = radrootsd_publish_client(config)?; - let runtime = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .map_err(|error| { - RuntimeError::Network(format!("build radrootsd farm publish runtime: {error}")) - })?; - let signer_session = SdkRadrootsdSignerSessionRef::from_session_id(signer_session_id.clone()); - let mut profile_options = - SdkRadrootsdProfilePublishOptions::from_signer_session_ref(&signer_session); - if let Some(idempotency_key) = profile_idempotency_key.as_deref() { - profile_options = profile_options.with_idempotency_key(idempotency_key.to_owned()); - } - let mut farm_options = SdkRadrootsdFarmPublishOptions::from_signer_session_ref(&signer_session); - if let Some(idempotency_key) = farm_idempotency_key.as_deref() { - farm_options = farm_options.with_idempotency_key(idempotency_key.to_owned()); - } - - let profile_receipt = runtime - .block_on(client.profile().publish_profile_via_radrootsd_with_options( - &resolved.document.profile, - Some(RadrootsProfileType::Farm), - &profile_options, - )) - .map_err(map_sdk_farm_publish_error)?; - let profile_component = radrootsd_published_component( - RADROOTSD_BRIDGE_PROFILE_PUBLISH_METHOD, - KIND_PROFILE, - profile_idempotency_key.clone(), - args, - previews.profile.event.clone(), - signer_session_id.as_str(), - profile_receipt, - )?; - if let Some(event_id) = profile_component.event_id.clone() { - persist_profile_publication(config, &mut resolved, event_id)?; - } - - let farm_receipt = match runtime.block_on( - client - .farm() - .publish_farm_via_radrootsd_with_options(&resolved.document.farm, &farm_options), - ) { - Ok(receipt) => receipt, - Err(error) => { - return Ok(base_publish_view( - "partial", - config, - args, - &resolved, - &account_pubkey, - profile_component, - radrootsd_failed_component( - RADROOTSD_BRIDGE_FARM_PUBLISH_METHOD, - KIND_FARM, - farm_idempotency_key, - args, - previews.farm.event, - signer_session_id.as_str(), - error, - ), - Some("farm publish failed after profile publish through radrootsd".to_owned()), - vec!["radroots farm publish".to_owned()], - ) - .with_requested_signer_session_id(Some(signer_session_id))); - } - }; - let farm_component = radrootsd_published_component( - RADROOTSD_BRIDGE_FARM_PUBLISH_METHOD, - KIND_FARM, - farm_idempotency_key, - args, - previews.farm.event, - signer_session_id.as_str(), - farm_receipt, - )?; - if let Some(event_id) = farm_component.event_id.clone() { - persist_farm_publication(config, &mut resolved, event_id)?; - } - - Ok(base_publish_view( - "published", - config, - args, - &resolved, - &account_pubkey, - profile_component, - farm_component, - None, - Vec::new(), - ) - .with_requested_signer_session_id(Some(signer_session_id))) -} - #[derive(Debug, Clone)] struct FarmPublishPreviews { profile: FarmPublishEventDraft, @@ -1497,14 +1295,7 @@ fn radrootsd_preflight_publish_view( state: &str, reason: &str, ) -> FarmPublishView { - let signer_session_id = resolve_radrootsd_signer_session_id(config, args); - let actions = if signer_session_id.is_none() { - vec!["configure signer.remote_nip46 signer_session_ref".to_owned()] - } else if config.rpc.bridge_bearer_token.is_none() { - vec!["configure RADROOTS_RPC_BEARER_TOKEN".to_owned()] - } else { - vec!["radroots farm publish".to_owned()] - }; + let requested_signer_session_id = args.signer_session_id.clone(); base_publish_view( state, config, @@ -1513,8 +1304,8 @@ fn radrootsd_preflight_publish_view( account_pubkey, FarmPublishComponentView { state: state.to_owned(), - signer_mode: Some("nip46".to_owned()), - signer_session_id: signer_session_id.clone(), + signer_mode: Some("deferred".to_owned()), + signer_session_id: None, reason: Some(reason.to_owned()), ..radrootsd_preview_component( RADROOTSD_BRIDGE_PROFILE_PUBLISH_METHOD, @@ -1526,8 +1317,8 @@ fn radrootsd_preflight_publish_view( }, FarmPublishComponentView { state: state.to_owned(), - signer_mode: Some("nip46".to_owned()), - signer_session_id: signer_session_id.clone(), + signer_mode: Some("deferred".to_owned()), + signer_session_id: None, reason: Some(reason.to_owned()), ..radrootsd_preview_component( RADROOTSD_BRIDGE_FARM_PUBLISH_METHOD, @@ -1538,9 +1329,12 @@ fn radrootsd_preflight_publish_view( ) }, Some(reason.to_owned()), - actions, + vec![ + "radroots --publish-mode nostr_relay --relay wss://relay.example.com farm publish" + .to_owned(), + ], ) - .with_requested_signer_session_id(signer_session_id) + .with_requested_signer_session_id(requested_signer_session_id) } fn radrootsd_preview_component( @@ -1551,182 +1345,11 @@ fn radrootsd_preview_component( event: Option<FarmPublishEventView>, ) -> FarmPublishComponentView { FarmPublishComponentView { - signer_mode: Some("nip46".to_owned()), + signer_mode: Some("deferred".to_owned()), ..preview_component(rpc_method, event_kind, idempotency_key, args, event) } } -fn radrootsd_published_component( - rpc_method: &str, - fallback_event_kind: u32, - idempotency_key: Option<String>, - args: &FarmPublishArgs, - mut event: FarmPublishEventView, - requested_signer_session_id: &str, - receipt: SdkPublishReceipt, -) -> Result<FarmPublishComponentView, RuntimeError> { - let SdkPublishReceipt { - event_kind, - event_id, - transport_receipt, - .. - } = receipt; - let SdkTransportReceipt::Radrootsd(radrootsd) = transport_receipt else { - return Err(RuntimeError::Config( - "radrootsd farm publish returned a non-radrootsd transport receipt".to_owned(), - )); - }; - if let Some(event_id) = event_id.as_ref() { - event.event_id = Some(event_id.clone()); - } - if radrootsd.event_addr.is_some() { - event.event_addr = radrootsd.event_addr.clone(); - } - let job_status = radrootsd.status.clone(); - let state = job_status.clone().unwrap_or_else(|| { - if radrootsd.accepted { - "accepted" - } else { - "submitted" - } - .to_owned() - }); - let job = radrootsd_farm_job_view( - rpc_method, - idempotency_key.clone(), - requested_signer_session_id, - &radrootsd, - job_status.clone(), - ); - - Ok(FarmPublishComponentView { - state, - rpc_method: rpc_method.to_owned(), - event_kind: event_kind.unwrap_or(fallback_event_kind), - deduplicated: radrootsd.deduplicated, - target_relays: Vec::new(), - connected_relays: Vec::new(), - acknowledged_relays: Vec::new(), - failed_relays: Vec::new(), - job_id: radrootsd.job_id.clone(), - job_status, - signer_mode: radrootsd.signer_mode.clone(), - signer_session_id: radrootsd.signer_session_id.clone(), - event_id, - event_addr: event.event_addr.clone(), - idempotency_key, - reason: None, - job: Some(job), - event: args.print_event.then_some(event), - }) -} - -fn radrootsd_failed_component( - rpc_method: &str, - event_kind: u32, - idempotency_key: Option<String>, - args: &FarmPublishArgs, - event: FarmPublishEventView, - signer_session_id: &str, - error: SdkPublishError, -) -> FarmPublishComponentView { - let reason = error.to_string(); - FarmPublishComponentView { - state: "failed".to_owned(), - rpc_method: rpc_method.to_owned(), - event_kind, - deduplicated: false, - target_relays: Vec::new(), - connected_relays: Vec::new(), - acknowledged_relays: Vec::new(), - failed_relays: Vec::new(), - job_id: None, - job_status: Some("failed".to_owned()), - signer_mode: Some("nip46".to_owned()), - signer_session_id: Some(signer_session_id.to_owned()), - event_id: None, - event_addr: event.event_addr.clone(), - idempotency_key: idempotency_key.clone(), - reason: Some(reason.clone()), - job: Some(FarmPublishJobView { - rpc_method: rpc_method.to_owned(), - state: "failed".to_owned(), - job_id: None, - idempotency_key, - requested_signer_session_id: Some(signer_session_id.to_owned()), - signer_mode: Some("nip46".to_owned()), - signer_session_id: Some(signer_session_id.to_owned()), - }), - event: args.print_event.then_some(event), - } -} - -fn radrootsd_farm_job_view( - rpc_method: &str, - idempotency_key: Option<String>, - requested_signer_session_id: &str, - receipt: &SdkRadrootsdPublishReceipt, - state: Option<String>, -) -> FarmPublishJobView { - FarmPublishJobView { - rpc_method: rpc_method.to_owned(), - state: state.unwrap_or_else(|| "accepted".to_owned()), - job_id: receipt.job_id.clone(), - idempotency_key, - requested_signer_session_id: Some(requested_signer_session_id.to_owned()), - signer_mode: receipt.signer_mode.clone(), - signer_session_id: receipt.signer_session_id.clone(), - } -} - -fn radrootsd_publish_client(config: &RuntimeConfig) -> Result<RadrootsSdkClient, RuntimeError> { - let mut sdk_config = RadrootsSdkConfig::for_environment(SdkEnvironment::Custom); - sdk_config.transport = SdkTransportMode::Radrootsd; - sdk_config.signer = SdkSignerConfig::Nip46; - sdk_config.radrootsd.endpoint = Some(config.rpc.url.clone()); - sdk_config.radrootsd.auth = config - .rpc - .bridge_bearer_token - .clone() - .map(RadrootsdAuth::BearerToken) - .unwrap_or(RadrootsdAuth::None); - RadrootsSdkClient::from_config(sdk_config) - .map_err(|error| RuntimeError::Config(format!("configure radrootsd farm publish: {error}"))) -} - -fn map_sdk_farm_publish_error(error: SdkPublishError) -> RuntimeError { - let message = format!("radrootsd farm publish failed: {error}"); - match error { - SdkPublishError::Config(_) - | SdkPublishError::Encode(_) - | SdkPublishError::UnsupportedTransport { .. } - | SdkPublishError::UnsupportedSignerMode { .. } => RuntimeError::Config(message), - SdkPublishError::Relay(_) - | SdkPublishError::RelaySetup { .. } - | SdkPublishError::RelayNotAcknowledged { .. } - | SdkPublishError::Radrootsd(_) => RuntimeError::Network(message), - } -} - -fn resolve_radrootsd_signer_session_id( - config: &RuntimeConfig, - args: &FarmPublishArgs, -) -> Option<String> { - args.signer_session_id - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_owned) - .or_else(|| { - config - .capability_binding(SIGNER_REMOTE_NIP46_CAPABILITY) - .and_then(|binding| binding.signer_session_ref.as_deref()) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_owned) - }) -} - fn persist_profile_publication( config: &RuntimeConfig, resolved: &mut ResolvedFarmConfig, diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs @@ -23,12 +23,6 @@ use radroots_events_codec::wire::WireEventParts; use radroots_nostr::prelude::{RadrootsNostrEvent as SignedNostrEvent, radroots_event_from_nostr}; use radroots_replica_db::{ReplicaSql, migrations}; use radroots_replica_sync::{RadrootsReplicaIngestOutcome, radroots_replica_ingest_event}; -use radroots_sdk::{ - RadrootsSdkClient, RadrootsSdkConfig, RadrootsdAuth, SdkEnvironment, SdkPublishError, - SdkPublishReceipt, SdkRadrootsdListingPublishOptions, SdkRadrootsdPublishReceipt, - SdkRadrootsdSignerSessionRef, SdkTransportMode, SdkTransportReceipt, - SignerConfig as SdkSignerConfig, -}; use radroots_sql_core::SqliteExecutor; use radroots_trade::listing::publish::validate_listing_for_seller; use radroots_trade::listing::validation::validate_listing_event; @@ -37,14 +31,14 @@ use serde_json::json; use crate::domain::runtime::{ FindPriceView, FindQuantityView, FindResultProvenanceView, ListingGetView, ListingListView, - ListingMutationEventView, ListingMutationJobView, ListingMutationLocalReplicaView, - ListingMutationView, ListingNewView, ListingRebindView, ListingSummaryView, - ListingValidateView, ListingValidationIssueView, RelayFailureView, + ListingMutationEventView, ListingMutationLocalReplicaView, ListingMutationView, ListingNewView, + ListingRebindView, ListingSummaryView, ListingValidateView, ListingValidationIssueView, + RelayFailureView, }; use crate::runtime::RuntimeError; use crate::runtime::accounts; use crate::runtime::config::{ - PublishMode, RuntimeConfig, SIGNER_REMOTE_NIP46_CAPABILITY, SignerBackend, + PublishMode, RADROOTSD_PUBLISH_DEFERRED_REASON, RuntimeConfig, SignerBackend, }; use crate::runtime::direct_relay::{ DirectRelayFailure, DirectRelayPublishError, DirectRelayPublishReceipt, @@ -63,8 +57,7 @@ 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 RELAY_LISTING_WRITE_SOURCE: &str = "direct Nostr relay publish · local key"; -const RADROOTSD_LISTING_WRITE_SOURCE: &str = "radrootsd publish transport · signer session"; -const RADROOTSD_BRIDGE_LISTING_PUBLISH_METHOD: &str = "bridge.listing.publish"; +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"; @@ -1117,32 +1110,16 @@ fn mutate( let requested_signer_session_id = match config.publish.mode { PublishMode::NostrRelay => args.signer_session_id.clone(), PublishMode::Radrootsd => { - if config.rpc.bridge_bearer_token.is_none() { - return Ok(radrootsd_preflight_view( - config, - args, - operation, - &canonical, - listing_addr, - event_draft.event, - "unconfigured", - "radrootsd bridge bearer token is required for listing publish dry-run; set RADROOTS_RPC_BEARER_TOKEN", - )); - } - let Some(signer_session_id) = resolve_radrootsd_signer_session_id(config, args) - else { - return Ok(radrootsd_preflight_view( - config, - args, - operation, - &canonical, - listing_addr, - event_draft.event, - "unconfigured", - "radrootsd listing publish dry-run requires a signer.remote_nip46 capability binding with signer_session_ref", - )); - }; - Some(signer_session_id) + return Ok(radrootsd_preflight_view( + config, + args, + operation, + &canonical, + listing_addr, + event_draft.event, + "unavailable", + RADROOTSD_PUBLISH_DEFERRED_REASON, + )); } }; return Ok(ListingMutationView { @@ -1191,14 +1168,16 @@ fn mutate( listing_addr, event_draft, ), - PublishMode::Radrootsd => mutate_via_radrootsd( + PublishMode::Radrootsd => Ok(radrootsd_preflight_view( config, args, operation, &canonical, listing_addr, - event_draft, - ), + event_draft.event, + "unavailable", + RADROOTSD_PUBLISH_DEFERRED_REASON, + )), } } @@ -1275,264 +1254,6 @@ fn mutate_via_direct_relay( )) } -fn mutate_via_radrootsd( - config: &RuntimeConfig, - args: &ListingMutationArgs, - operation: ListingMutationOperation, - canonical: &CanonicalListingDraft, - listing_addr: String, - event_draft: ListingMutationEventDraft, -) -> Result<ListingMutationView, RuntimeError> { - let Some(signer_session_id) = resolve_radrootsd_signer_session_id(config, args) else { - return Ok(radrootsd_preflight_view( - config, - args, - operation, - canonical, - listing_addr, - event_draft.event, - "unconfigured", - "radrootsd listing publish requires a signer.remote_nip46 capability binding with signer_session_ref", - )); - }; - if config.rpc.bridge_bearer_token.is_none() { - return Ok(radrootsd_preflight_view( - config, - args, - operation, - canonical, - listing_addr, - event_draft.event, - "unconfigured", - "radrootsd bridge bearer token is required for listing publish; set RADROOTS_RPC_BEARER_TOKEN", - )); - } - - let receipt = publish_listing_via_radrootsd( - config, - &canonical.listing, - signer_session_id.as_str(), - args.idempotency_key.as_deref(), - )?; - - radrootsd_mutation_view( - config, - args, - operation, - canonical, - listing_addr, - event_draft.event, - signer_session_id, - receipt, - ) -} - -fn publish_listing_via_radrootsd( - config: &RuntimeConfig, - listing: &RadrootsListing, - signer_session_id: &str, - idempotency_key: Option<&str>, -) -> Result<SdkPublishReceipt, RuntimeError> { - let mut sdk_config = RadrootsSdkConfig::for_environment(SdkEnvironment::Custom); - sdk_config.transport = SdkTransportMode::Radrootsd; - sdk_config.signer = SdkSignerConfig::Nip46; - sdk_config.radrootsd.endpoint = Some(config.rpc.url.clone()); - sdk_config.radrootsd.auth = config - .rpc - .bridge_bearer_token - .clone() - .map(RadrootsdAuth::BearerToken) - .unwrap_or(RadrootsdAuth::None); - - let client = RadrootsSdkClient::from_config(sdk_config).map_err(|error| { - RuntimeError::Config(format!("configure radrootsd listing publish: {error}")) - })?; - let signer_session = SdkRadrootsdSignerSessionRef::from_session_id(signer_session_id); - let mut options = SdkRadrootsdListingPublishOptions::from_signer_session_ref(&signer_session); - if let Some(idempotency_key) = idempotency_key.filter(|value| !value.trim().is_empty()) { - options = options.with_idempotency_key(idempotency_key.to_owned()); - } - let runtime = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .map_err(|error| { - RuntimeError::Network(format!("build radrootsd listing publish runtime: {error}")) - })?; - - runtime - .block_on( - client - .listing() - .publish_listing_via_radrootsd_with_options(listing, &options), - ) - .map_err(map_sdk_listing_publish_error) -} - -fn map_sdk_listing_publish_error(error: SdkPublishError) -> RuntimeError { - let message = format!("radrootsd listing publish failed: {error}"); - match error { - SdkPublishError::Config(_) - | SdkPublishError::Encode(_) - | SdkPublishError::UnsupportedTransport { .. } - | SdkPublishError::UnsupportedSignerMode { .. } => RuntimeError::Config(message), - SdkPublishError::Relay(_) - | SdkPublishError::RelaySetup { .. } - | SdkPublishError::RelayNotAcknowledged { .. } - | SdkPublishError::Radrootsd(_) => RuntimeError::Network(message), - } -} - -fn resolve_radrootsd_signer_session_id( - config: &RuntimeConfig, - args: &ListingMutationArgs, -) -> Option<String> { - args.signer_session_id - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_owned) - .or_else(|| { - config - .capability_binding(SIGNER_REMOTE_NIP46_CAPABILITY) - .and_then(|binding| binding.signer_session_ref.as_deref()) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_owned) - }) -} - -fn radrootsd_mutation_view( - config: &RuntimeConfig, - args: &ListingMutationArgs, - operation: ListingMutationOperation, - canonical: &CanonicalListingDraft, - listing_addr: String, - mut event: ListingMutationEventView, - requested_session_id: String, - receipt: SdkPublishReceipt, -) -> Result<ListingMutationView, RuntimeError> { - let SdkPublishReceipt { - event_kind, - event_id, - transport_receipt, - .. - } = receipt; - let SdkTransportReceipt::Radrootsd(radrootsd) = transport_receipt else { - return Err(RuntimeError::Config( - "radrootsd listing publish returned a non-radrootsd transport receipt".to_owned(), - )); - }; - if let Some(event_id) = event_id.as_ref() { - event.event_id = Some(event_id.clone()); - } - let daemon_identity = radrootsd - .event_addr - .as_deref() - .and_then(daemon_listing_identity); - if let Some(identity) = daemon_identity.as_ref() - && (!identity - .seller_pubkey - .eq_ignore_ascii_case(canonical.seller_pubkey.as_str()) - || identity.listing_id != canonical.listing_id) - { - return Err(RuntimeError::Config(format!( - "radrootsd listing publish returned event_addr identity `{}` that does not match listing draft `{}`", - radrootsd.event_addr.as_deref().unwrap_or_default(), - listing_addr - ))); - } - let event_addr = radrootsd - .event_addr - .clone() - .unwrap_or_else(|| listing_addr.clone()); - event.event_addr = event_addr.clone(); - let listing_id = daemon_identity - .as_ref() - .map(|identity| identity.listing_id.clone()) - .unwrap_or_else(|| canonical.listing_id.clone()); - let seller_pubkey = canonical.seller_pubkey.clone(); - event.author = seller_pubkey.clone(); - let job_status = radrootsd.status.clone(); - let state = match operation { - ListingMutationOperation::Archive => "archived", - ListingMutationOperation::Publish | ListingMutationOperation::Update => "published", - } - .to_owned(); - let job = radrootsd_job_view(args, &requested_session_id, &radrootsd, job_status.clone()); - - Ok(ListingMutationView { - state, - operation: operation.as_str().to_owned(), - source: listing_write_source(config).to_owned(), - file: args.file.display().to_string(), - listing_id, - listing_addr: event_addr.clone(), - seller_account_id: canonical.seller_account_id.clone(), - seller_pubkey, - seller_actor_source: canonical.seller_actor_source.clone(), - event_kind: event_kind.unwrap_or(KIND_LISTING), - dry_run: false, - deduplicated: radrootsd.deduplicated, - target_relays: Vec::new(), - connected_relays: Vec::new(), - acknowledged_relays: Vec::new(), - failed_relays: Vec::new(), - job_id: radrootsd.job_id.clone(), - job_status, - signer_mode: radrootsd.signer_mode.clone(), - event_id, - event_addr: Some(event_addr), - idempotency_key: args.idempotency_key.clone(), - signer_session_id: radrootsd.signer_session_id.clone(), - requested_signer_session_id: Some(requested_session_id), - local_replica: None, - reason: None, - job: Some(job), - event: args.print_event.then_some(event), - actions: Vec::new(), - }) -} - -#[derive(Debug, Clone)] -struct DaemonListingIdentity { - seller_pubkey: String, - listing_id: String, -} - -fn daemon_listing_identity(event_addr: &str) -> Option<DaemonListingIdentity> { - let (kind, rest) = event_addr.split_once(':')?; - if kind.parse::<u32>().ok()? != KIND_LISTING { - return None; - } - let (seller_pubkey, listing_id) = rest.split_once(':')?; - if seller_pubkey.trim().is_empty() || listing_id.trim().is_empty() || listing_id.contains(':') { - return None; - } - Some(DaemonListingIdentity { - seller_pubkey: seller_pubkey.to_owned(), - listing_id: listing_id.to_owned(), - }) -} - -fn radrootsd_job_view( - args: &ListingMutationArgs, - requested_session_id: &str, - receipt: &SdkRadrootsdPublishReceipt, - state: Option<String>, -) -> ListingMutationJobView { - ListingMutationJobView { - rpc_method: RADROOTSD_BRIDGE_LISTING_PUBLISH_METHOD.to_owned(), - state: state.unwrap_or_else(|| "accepted".to_owned()), - job_id: receipt.job_id.clone(), - idempotency_key: args.idempotency_key.clone(), - requested_signer_session_id: Some(requested_session_id.to_owned()), - signer_mode: receipt.signer_mode.clone(), - signer_session_id: receipt.signer_session_id.clone(), - relay_count: receipt.relay_count, - acknowledged_relay_count: receipt.acknowledged_relay_count, - } -} - fn listing_write_source(config: &RuntimeConfig) -> &'static str { match config.publish.mode { PublishMode::NostrRelay => RELAY_LISTING_WRITE_SOURCE, @@ -2182,7 +1903,7 @@ fn radrootsd_preflight_view( failed_relays: Vec::new(), job_id: None, job_status: None, - signer_mode: Some("nip46".to_owned()), + signer_mode: Some("deferred".to_owned()), event_id: None, event_addr: Some(listing_addr), idempotency_key: args.idempotency_key.clone(), @@ -2192,7 +1913,11 @@ fn radrootsd_preflight_view( reason: Some(reason.into()), job: None, event: args.print_event.then_some(event_preview), - actions: Vec::new(), + actions: vec![format!( + "radroots --publish-mode nostr_relay --relay wss://relay.example.com listing {} {}", + operation.as_str(), + args.file.display() + )], } } diff --git a/src/runtime/signer.rs b/src/runtime/signer.rs @@ -7,8 +7,9 @@ use crate::runtime::accounts::AccountRuntimeFailure; use crate::runtime::accounts::{SHARED_ACCOUNT_STORE_SOURCE, empty_account_resolution_view}; use crate::runtime::config::{RuntimeConfig, SIGNER_REMOTE_NIP46_CAPABILITY, SignerBackend}; use radroots_events::kinds::{ - KIND_FARM, KIND_LISTING, KIND_PROFILE, KIND_TRADE_ORDER_DECISION, KIND_TRADE_ORDER_REQUEST, - KIND_TRADE_ORDER_REVISION, KIND_TRADE_ORDER_REVISION_RESPONSE, + KIND_FARM, KIND_LISTING, KIND_PROFILE, KIND_TRADE_CANCEL, KIND_TRADE_FULFILLMENT_UPDATE, + KIND_TRADE_ORDER_DECISION, KIND_TRADE_ORDER_REQUEST, KIND_TRADE_ORDER_REVISION, + KIND_TRADE_ORDER_REVISION_RESPONSE, KIND_TRADE_RECEIPT, }; use radroots_nostr_accounts::prelude::RadrootsNostrAccountStatus; use radroots_nostr_signer::prelude::{ @@ -292,44 +293,64 @@ fn deferred_myc_binding_status() -> SignerBindingStatusView { } } -fn cli_write_kinds() -> [CliWriteKind; 9] { +fn cli_write_kinds() -> [CliWriteKind; 14] { [ CliWriteKind { - command: "farm profile publish", + command: "sync.push", event_kind: KIND_PROFILE, }, CliWriteKind { - command: "farm publish", + command: "farm.publish", event_kind: KIND_FARM, }, CliWriteKind { - command: "listing publish", + command: "listing.publish", event_kind: KIND_LISTING, }, CliWriteKind { - command: "order submit", + command: "listing.update", + event_kind: KIND_LISTING, + }, + CliWriteKind { + command: "listing.archive", + event_kind: KIND_LISTING, + }, + CliWriteKind { + command: "order.submit", event_kind: KIND_TRADE_ORDER_REQUEST, }, CliWriteKind { - command: "order accept", + command: "order.accept", event_kind: KIND_TRADE_ORDER_DECISION, }, CliWriteKind { - command: "order decline", + command: "order.decline", event_kind: KIND_TRADE_ORDER_DECISION, }, CliWriteKind { - command: "order revision propose", + command: "order.cancel", + event_kind: KIND_TRADE_CANCEL, + }, + CliWriteKind { + command: "order.revision.propose", event_kind: KIND_TRADE_ORDER_REVISION, }, CliWriteKind { - command: "order revision accept", + command: "order.revision.accept", event_kind: KIND_TRADE_ORDER_REVISION_RESPONSE, }, CliWriteKind { - command: "order revision decline", + command: "order.revision.decline", event_kind: KIND_TRADE_ORDER_REVISION_RESPONSE, }, + CliWriteKind { + command: "order.fulfillment.update", + event_kind: KIND_TRADE_FULFILLMENT_UPDATE, + }, + CliWriteKind { + command: "order.receipt.record", + event_kind: KIND_TRADE_RECEIPT, + }, ] } @@ -374,15 +395,45 @@ mod tests { use radroots_events::kinds::KIND_TRADE_FORBIDDEN_3431; use super::{ - KIND_TRADE_ORDER_DECISION, KIND_TRADE_ORDER_REQUEST, KIND_TRADE_ORDER_REVISION, - KIND_TRADE_ORDER_REVISION_RESPONSE, cli_write_kinds, + KIND_TRADE_CANCEL, KIND_TRADE_FULFILLMENT_UPDATE, KIND_TRADE_ORDER_DECISION, + KIND_TRADE_ORDER_REQUEST, KIND_TRADE_ORDER_REVISION, KIND_TRADE_ORDER_REVISION_RESPONSE, + KIND_TRADE_RECEIPT, cli_write_kinds, }; #[test] + fn write_kind_readiness_matches_active_signed_mutations() { + let commands: Vec<&str> = cli_write_kinds() + .iter() + .map(|write_kind| write_kind.command) + .collect(); + + assert_eq!( + commands, + [ + "sync.push", + "farm.publish", + "listing.publish", + "listing.update", + "listing.archive", + "order.submit", + "order.accept", + "order.decline", + "order.cancel", + "order.revision.propose", + "order.revision.accept", + "order.revision.decline", + "order.fulfillment.update", + "order.receipt.record", + ] + ); + assert!(!commands.contains(&"signer.status.get")); + } + + #[test] fn order_submit_readiness_uses_active_order_request_kind() { let write_kind = cli_write_kinds() .into_iter() - .find(|kind| kind.command == "order submit") + .find(|kind| kind.command == "order.submit") .expect("order submit readiness"); assert_eq!(write_kind.event_kind, KIND_TRADE_ORDER_REQUEST); @@ -391,7 +442,7 @@ mod tests { #[test] fn order_decision_readiness_uses_active_order_decision_kind() { - for command in ["order accept", "order decline"] { + for command in ["order.accept", "order.decline"] { let write_kind = cli_write_kinds() .into_iter() .find(|kind| kind.command == command) @@ -406,13 +457,13 @@ mod tests { fn order_revision_readiness_uses_active_revision_kinds() { let proposal = cli_write_kinds() .into_iter() - .find(|kind| kind.command == "order revision propose") + .find(|kind| kind.command == "order.revision.propose") .expect("order revision propose readiness"); assert_eq!(proposal.event_kind, KIND_TRADE_ORDER_REVISION); assert_ne!(proposal.event_kind, KIND_TRADE_FORBIDDEN_3431); - for command in ["order revision accept", "order revision decline"] { + for command in ["order.revision.accept", "order.revision.decline"] { let write_kind = cli_write_kinds() .into_iter() .find(|kind| kind.command == command) @@ -422,4 +473,28 @@ mod tests { assert_ne!(write_kind.event_kind, KIND_TRADE_FORBIDDEN_3431); } } + + #[test] + fn order_follow_on_readiness_uses_active_trade_kinds() { + let cancel = cli_write_kinds() + .into_iter() + .find(|kind| kind.command == "order.cancel") + .expect("order cancel readiness"); + assert_eq!(cancel.event_kind, KIND_TRADE_CANCEL); + assert_ne!(cancel.event_kind, KIND_TRADE_FORBIDDEN_3431); + + let fulfillment = cli_write_kinds() + .into_iter() + .find(|kind| kind.command == "order.fulfillment.update") + .expect("order fulfillment readiness"); + assert_eq!(fulfillment.event_kind, KIND_TRADE_FULFILLMENT_UPDATE); + assert_ne!(fulfillment.event_kind, KIND_TRADE_FORBIDDEN_3431); + + let receipt = cli_write_kinds() + .into_iter() + .find(|kind| kind.command == "order.receipt.record") + .expect("order receipt readiness"); + assert_eq!(receipt.event_kind, KIND_TRADE_RECEIPT); + assert_ne!(receipt.event_kind, KIND_TRADE_FORBIDDEN_3431); + } } diff --git a/src/target_cli.rs b/src/target_cli.rs @@ -31,7 +31,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 uses daemon-backed publish for supported farm and listing publish flows.\n\nRelay mode never silently falls back to radrootsd.", + 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.", disable_help_subcommand = true )] pub struct TargetCliArgs { @@ -45,7 +45,7 @@ pub struct TargetCliArgs { long = "publish-mode", global = true, value_enum, - help = "Select nostr_relay direct relay publish or radrootsd daemon-backed publish" + help = "Select nostr_relay direct relay publish or reserved radrootsd guardrail mode" )] pub publish_mode: Option<TargetPublishMode>, #[arg(long = "offline", global = true, action = ArgAction::SetTrue, conflicts_with = "online")] diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -1,7 +1,6 @@ mod support; use std::fs; -use std::io::{Read, Write}; use std::net::{TcpListener, TcpStream}; use std::path::Path; use std::sync::mpsc::{self, Receiver}; @@ -9,7 +8,6 @@ use std::thread::{self, JoinHandle}; use std::time::Duration; use radroots_events::RadrootsNostrEventPtr; -use radroots_events::kinds::{KIND_FARM, KIND_PROFILE}; use radroots_events::trade::{ RadrootsTradeOrderEconomics, RadrootsTradeOrderItem, RadrootsTradeOrderRequested, }; @@ -37,191 +35,6 @@ const LISTING_ADDR: &str = "30402:1111111111111111111111111111111111111111111111111111111111111111:AAAAAAAAAAAAAAAAAAAAAg"; const SYNC_PUSH_FARM_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAA"; -struct JsonRpcRequest { - headers: String, - body: Value, -} - -struct OneShotJsonRpcServer { - endpoint: String, - requests: Receiver<JsonRpcRequest>, - handle: JoinHandle<()>, -} - -impl OneShotJsonRpcServer { - fn listing_publish() -> Self { - Self::listing_publish_response(json!({ - "jsonrpc": "2.0", - "id": "radroots-sdk-listing-publish", - "result": { - "deduplicated": false, - "job": { - "job_id": "job_listing_publish_test", - "command": "bridge.listing.publish", - "status": "published", - "terminal": true, - "recovered_after_restart": false, - "signer_mode": "nip46", - "signer_session_id": "session_test", - "event_kind": 30402, - "event_id": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", - "event_addr": null, - "relay_count": 2, - "acknowledged_relay_count": 1 - } - } - })) - } - - fn farm_publish() -> Self { - Self::jsonrpc_sequence(vec![ - json!({ - "jsonrpc": "2.0", - "id": "radroots-sdk-profile-publish", - "result": { - "deduplicated": false, - "job": { - "job_id": "job_profile_publish_test", - "command": "bridge.profile.publish", - "status": "published", - "terminal": true, - "recovered_after_restart": false, - "signer_mode": "nip46", - "signer_session_id": "session_test", - "event_kind": 0, - "event_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "event_addr": null, - "relay_count": 2, - "acknowledged_relay_count": 2 - } - } - }), - json!({ - "jsonrpc": "2.0", - "id": "radroots-sdk-farm-publish", - "result": { - "deduplicated": false, - "job": { - "job_id": "job_farm_publish_test", - "command": "bridge.farm.publish", - "status": "published", - "terminal": true, - "recovered_after_restart": false, - "signer_mode": "nip46", - "signer_session_id": "session_test", - "event_kind": KIND_FARM, - "event_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "event_addr": format!("{KIND_FARM}:daemon_test:radrootsd-farm"), - "relay_count": 2, - "acknowledged_relay_count": 1 - } - } - }), - ]) - } - - fn listing_publish_error(message: &str) -> Self { - Self::listing_publish_response(json!({ - "jsonrpc": "2.0", - "id": "radroots-sdk-listing-publish", - "error": { - "code": -32000, - "message": message - } - })) - } - - fn listing_publish_response(response: Value) -> Self { - Self::jsonrpc_sequence(vec![response]) - } - - fn jsonrpc_sequence(responses: Vec<Value>) -> Self { - let listener = TcpListener::bind("127.0.0.1:0").expect("bind fake radrootsd"); - let endpoint = format!( - "http://{}/jsonrpc", - listener.local_addr().expect("fake radrootsd addr") - ); - let (tx, requests) = mpsc::channel(); - let handle = thread::spawn(move || { - for response in responses { - let (mut stream, _) = listener.accept().expect("accept fake radrootsd request"); - let request = read_jsonrpc_request(&mut stream); - tx.send(request).expect("send fake radrootsd request"); - let response = response.to_string(); - write!( - stream, - "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", - response.len(), - response - ) - .expect("write fake radrootsd response"); - } - }); - Self { - endpoint, - requests, - handle, - } - } - - fn take_request(self) -> JsonRpcRequest { - self.take_requests(1) - .into_iter() - .next() - .expect("one fake radrootsd request") - } - - fn take_requests(self, count: usize) -> Vec<JsonRpcRequest> { - let request = (0..count) - .map(|_| { - self.requests - .recv_timeout(Duration::from_secs(5)) - .expect("fake radrootsd request") - }) - .collect::<Vec<_>>(); - self.handle.join().expect("fake radrootsd join"); - request - } -} - -fn read_jsonrpc_request(stream: &mut TcpStream) -> JsonRpcRequest { - let mut bytes = Vec::new(); - let mut buffer = [0_u8; 1024]; - loop { - let count = stream.read(&mut buffer).expect("read fake radrootsd"); - assert!(count > 0, "fake radrootsd request ended before headers"); - bytes.extend_from_slice(&buffer[..count]); - if let Some(header_end) = find_header_end(&bytes) { - let headers = String::from_utf8_lossy(&bytes[..header_end]).to_string(); - let content_length = content_length(&headers); - let body_start = header_end + 4; - while bytes.len() < body_start + content_length { - let count = stream.read(&mut buffer).expect("read fake radrootsd body"); - assert!(count > 0, "fake radrootsd request ended before body"); - bytes.extend_from_slice(&buffer[..count]); - } - let body = serde_json::from_slice(&bytes[body_start..body_start + content_length]) - .expect("fake radrootsd json body"); - return JsonRpcRequest { headers, body }; - } - } -} - -fn find_header_end(bytes: &[u8]) -> Option<usize> { - bytes.windows(4).position(|window| window == b"\r\n\r\n") -} - -fn content_length(headers: &str) -> usize { - headers - .lines() - .find_map(|line| { - let (name, value) = line.split_once(':')?; - name.eq_ignore_ascii_case("content-length") - .then(|| value.trim().parse::<usize>().expect("content length")) - }) - .expect("content-length header") -} - struct RelayPublishServer { endpoint: String, requests: Receiver<Value>, @@ -445,12 +258,13 @@ fn root_help_explains_publish_modes() { let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); assert!(stdout.contains("nostr_relay uses direct relay publish")); - assert!(stdout.contains("radrootsd uses daemon-backed publish")); + assert!(stdout.contains("radrootsd is reserved and fails closed")); assert!(stdout.contains("Relay mode never silently falls back")); assert!(stdout.contains("Inspect local readiness and mode-specific recovery steps")); assert!( - stdout - .contains("Select nostr_relay direct relay publish or radrootsd daemon-backed publish") + stdout.contains( + "Select nostr_relay direct relay publish or reserved radrootsd guardrail mode" + ) ); } @@ -461,47 +275,27 @@ fn help_lists(stdout: &str, command: &str) -> bool { }) } -fn assert_public_signer_session_binding_message(value: &Value) { +fn assert_radrootsd_deferred_message(value: &Value) { let message = value["errors"][0]["message"] .as_str() .expect("error message"); - assert!(message.contains("signer.remote_nip46")); - assert!(message.contains("signer_session_ref")); + assert!(message.contains("radrootsd publish mode is deferred")); + assert!(message.contains("publish mode `nostr_relay`")); assert!( - !message.contains("signer_session_id"), - "public CLI message should not reference unavailable explicit session input: {message}" + !message.contains("signer.remote_nip46"), + "deferred publish-mode message should not suggest signer-session setup: {message}" ); } -fn assert_rpc_bearer_token_next_action(actions: &Value) { - let action = actions - .as_array() - .expect("next actions") - .iter() - .find(|action| action["env_var"] == "RADROOTS_RPC_BEARER_TOKEN") - .expect("rpc bearer token next action"); - - assert_eq!(action["kind"], "operator_config"); - assert_eq!(action["label"], "configure rpc bearer token"); - assert_eq!(action["command"], Value::Null); - assert_eq!(action["description"], "configure RADROOTS_RPC_BEARER_TOKEN"); -} - -fn assert_signer_session_next_action(actions: &Value) { +fn assert_direct_relay_next_action(actions: &Value, command: &str) { let action = actions .as_array() .expect("next actions") .iter() - .find(|action| action["config_key"] == "signer.remote_nip46.signer_session_ref") - .expect("signer session next action"); + .find(|action| action["command"] == command) + .expect("direct relay next action"); - assert_eq!(action["kind"], "operator_config"); - assert_eq!(action["label"], "configure signer session binding"); - assert_eq!(action["command"], Value::Null); - assert_eq!( - action["description"], - "configure signer.remote_nip46 signer_session_ref" - ); + assert_eq!(action["kind"], "cli_command"); } #[test] @@ -539,9 +333,16 @@ fn config_get_exposes_resolved_publish_state() { "user config · local first" ); assert_eq!(value["result"]["publish"]["transport_family"], "radrootsd"); - assert_eq!(value["result"]["publish"]["state"], "unconfigured"); + assert_eq!(value["result"]["publish"]["state"], "unavailable"); assert_eq!(value["result"]["publish"]["executable"], false); - assert_contains(&value["result"]["publish"]["reason"], "bridge bearer token"); + assert_contains( + &value["result"]["publish"]["reason"], + "radrootsd publish mode is deferred", + ); + assert_eq!( + value["result"]["account_resolution"]["status"], + "unresolved" + ); assert_eq!( value["result"]["publish"]["provider"]["provider_runtime_id"], "radrootsd" @@ -554,7 +355,7 @@ fn config_get_exposes_resolved_publish_state() { value["result"]["write_plane"]["binding_model"], "radrootsd_bridge_publish" ); - assert_eq!(value["result"]["write_plane"]["state"], "unconfigured"); + assert_eq!(value["result"]["write_plane"]["state"], "unavailable"); assert_eq!( value["result"]["write_plane"]["bridge_auth_configured"], false @@ -562,18 +363,16 @@ fn config_get_exposes_resolved_publish_state() { assert_eq!(value["result"]["rpc"]["bridge_auth_configured"], false); assert_eq!( value["result"]["actions"][0], - "configure RADROOTS_RPC_BEARER_TOKEN" + "radroots --publish-mode nostr_relay --relay wss://relay.example.com config get" ); - assert_eq!( - value["result"]["actions"][1], - "configure signer.remote_nip46 signer_session_ref" + assert_direct_relay_next_action( + &value["next_actions"], + "radroots --publish-mode nostr_relay --relay wss://relay.example.com config get", ); - assert_rpc_bearer_token_next_action(&value["next_actions"]); - assert_signer_session_next_action(&value["next_actions"]); } #[test] -fn config_get_radrootsd_missing_signer_binding_mirrors_operator_next_action() { +fn config_get_radrootsd_with_bridge_auth_still_reports_deferred_publish_mode() { let sandbox = RadrootsCliSandbox::new(); sandbox.write_app_config("[publish]\nmode = \"radrootsd\"\n"); @@ -587,10 +386,16 @@ fn config_get_radrootsd_missing_signer_binding_mirrors_operator_next_action() { assert!(output.status.success()); assert_eq!(value["operation_id"], "config.get"); assert_eq!(value["result"]["publish"]["mode"], "radrootsd"); - assert_eq!(value["result"]["publish"]["state"], "unconfigured"); + 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"]["actions"][0], - "configure signer.remote_nip46 signer_session_ref" + "radroots --publish-mode nostr_relay --relay wss://relay.example.com config get" ); assert_eq!( value["next_actions"] @@ -599,11 +404,14 @@ fn config_get_radrootsd_missing_signer_binding_mirrors_operator_next_action() { .len(), 1 ); - assert_signer_session_next_action(&value["next_actions"]); + assert_direct_relay_next_action( + &value["next_actions"], + "radroots --publish-mode nostr_relay --relay wss://relay.example.com config get", + ); } #[test] -fn config_get_marks_radrootsd_listing_publish_ready_with_bridge_auth_and_session_binding() { +fn config_get_marks_radrootsd_deferred_even_with_bridge_auth_and_session_binding() { let sandbox = RadrootsCliSandbox::new(); sandbox.write_app_config( r#"[publish] @@ -629,14 +437,21 @@ signer_session_ref = "session_ready" 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"]["state"], "ready"); - assert_eq!(value["result"]["publish"]["executable"], true); + assert_eq!(value["result"]["publish"]["state"], "unavailable"); + assert_eq!(value["result"]["publish"]["executable"], false); assert_contains( &value["result"]["publish"]["reason"], - "live bridge readiness is verified when publish runs", + "radrootsd publish mode is deferred", + ); + assert_eq!( + value["result"]["publish"]["provider"]["state"], + "unavailable" ); - assert_eq!(value["result"]["publish"]["provider"]["state"], "ready"); 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" + ); } #[test] @@ -798,26 +613,27 @@ fn health_surfaces_publish_state_under_deferred_signer_mode() { assert_eq!(value["result"]["publish"]["executable"], false); assert_eq!( value["result"]["publish"]["provider"]["state"], - "unconfigured" + "unavailable" + ); + assert_contains( + &value["result"]["publish"]["reason"], + "radrootsd publish mode is deferred", ); - assert_contains(&value["result"]["publish"]["reason"], "bridge bearer token"); assert_eq!(value["result"]["actions"][0], "radroots store init"); assert_eq!(value["result"]["actions"][1], "radroots account create"); assert_eq!( value["result"]["actions"][2], - "configure RADROOTS_RPC_BEARER_TOKEN" - ); - assert_eq!( - value["result"]["actions"][3], - "configure signer.remote_nip46 signer_session_ref" + "radroots --publish-mode nostr_relay --relay wss://relay.example.com config get" ); assert_eq!(value["next_actions"][0]["command"], "radroots store init"); assert_eq!( value["next_actions"][1]["command"], "radroots account create" ); - assert_rpc_bearer_token_next_action(&value["next_actions"]); - assert_signer_session_next_action(&value["next_actions"]); + assert_direct_relay_next_action( + &value["next_actions"], + "radroots --publish-mode nostr_relay --relay wss://relay.example.com config get", + ); assert_eq!(value["errors"].as_array().expect("errors").len(), 0); } @@ -864,28 +680,27 @@ fn health_check_exposes_publish_readiness() { assert_eq!(value["operation_id"], "health.check.run"); assert_eq!(value["result"]["state"], "needs_attention"); assert_eq!(value["result"]["checks"]["publish"]["mode"], "radrootsd"); - assert_eq!( - value["result"]["checks"]["publish"]["state"], - "unconfigured" - ); + assert_eq!(value["result"]["checks"]["publish"]["state"], "unavailable"); assert_eq!(value["result"]["checks"]["publish"]["executable"], false); + assert_contains( + &value["result"]["checks"]["publish"]["reason"], + "radrootsd publish mode is deferred", + ); assert_eq!(value["result"]["actions"][0], "radroots store init"); assert_eq!(value["result"]["actions"][1], "radroots account create"); assert_eq!( value["result"]["actions"][2], - "configure RADROOTS_RPC_BEARER_TOKEN" - ); - assert_eq!( - value["result"]["actions"][3], - "configure signer.remote_nip46 signer_session_ref" + "radroots --publish-mode nostr_relay --relay wss://relay.example.com config get" ); assert_eq!(value["next_actions"][0]["command"], "radroots store init"); assert_eq!( value["next_actions"][1]["command"], "radroots account create" ); - assert_rpc_bearer_token_next_action(&value["next_actions"]); - assert_signer_session_next_action(&value["next_actions"]); + assert_direct_relay_next_action( + &value["next_actions"], + "radroots --publish-mode nostr_relay --relay wss://relay.example.com config get", + ); assert_eq!(value["errors"].as_array().expect("errors").len(), 0); } @@ -978,16 +793,20 @@ signer_session_ref = "session_test" 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"], "ready"); - assert_eq!(radrootsd_value["result"]["publish_executable"], true); + 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", + ); assert_eq!( radrootsd_value["result"]["actions"][0], - "radroots farm publish" + "radroots --publish-mode nostr_relay --relay wss://relay.example.com farm publish" ); } #[test] -fn radrootsd_listing_publish_reaches_listing_router_without_relay_config() { +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(&[ @@ -1011,20 +830,10 @@ fn radrootsd_listing_publish_reaches_listing_router_without_relay_config() { .as_str() .expect("farm d tag"), ); - sandbox.write_app_config( - r#"[[capability_binding]] -capability = "signer.remote_nip46" -provider = "myc" -target_kind = "explicit_endpoint" -target = "http://myc.invalid" -signer_session_ref = "session_test" -"#, - ); - let server = OneShotJsonRpcServer::listing_publish(); let output = sandbox .command() - .env("RADROOTS_RPC_URL", &server.endpoint) + .env("RADROOTS_RPC_URL", "http://127.0.0.1:9") .env("RADROOTS_RPC_BEARER_TOKEN", "bridge_test") .args([ "--format", @@ -1042,53 +851,29 @@ signer_session_ref = "session_test" .output() .expect("run radrootsd listing publish"); let value: Value = serde_json::from_slice(&output.stdout).expect("json output"); - let request = server.take_request(); - assert!(output.status.success()); + 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["result"]["source"], - "radrootsd publish transport · signer session" - ); - assert_eq!(value["result"]["job_id"], "job_listing_publish_test"); - assert_eq!(value["result"]["job_status"], "published"); - assert_eq!(value["result"]["event_id"], "e".repeat(64)); - assert_eq!( - value["result"]["event_addr"], - value["result"]["listing_addr"] - ); - assert!(value["result"]["listing_id"].is_string()); - assert!(value["result"]["seller_pubkey"].is_string()); - assert_eq!(value["result"]["signer_mode"], "nip46"); - assert_eq!(value["result"]["signer_session_id"], "session_test"); - assert_eq!( - value["result"]["requested_signer_session_id"], - "session_test" + value["errors"][0]["detail"]["actions"][0], + "radroots --publish-mode nostr_relay --relay wss://relay.example.com listing publish <file>" ); - assert_eq!(value["result"]["idempotency_key"], "idem_listing"); - assert_eq!( - value["result"]["job"]["rpc_method"], - "bridge.listing.publish" - ); - assert_eq!(value["result"]["job"]["relay_count"], 2); - assert_eq!(value["result"]["job"]["acknowledged_relay_count"], 1); - assert_eq!(request.body["method"], "bridge.listing.publish"); - assert_eq!(request.body["params"]["kind"], 30402); - assert_eq!(request.body["params"]["signer_session_id"], "session_test"); - assert_eq!(request.body["params"]["idempotency_key"], "idem_listing"); - assert!( - request - .headers - .to_ascii_lowercase() - .contains("authorization: bearer bridge_test") + 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_submits_profile_and_farm_without_relay_config() { +fn radrootsd_farm_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(&[ + sandbox.json_success(&[ "--format", "json", "farm", @@ -1102,24 +887,10 @@ fn radrootsd_farm_publish_submits_profile_and_farm_without_relay_config() { "--delivery-method", "pickup", ]); - let farm_d_tag = farm["result"]["config"]["farm_d_tag"] - .as_str() - .expect("farm d tag") - .to_owned(); - sandbox.write_app_config( - r#"[[capability_binding]] -capability = "signer.remote_nip46" -provider = "myc" -target_kind = "explicit_endpoint" -target = "http://myc.invalid" -signer_session_ref = "session_test" -"#, - ); - let server = OneShotJsonRpcServer::farm_publish(); let output = sandbox .command() - .env("RADROOTS_RPC_URL", &server.endpoint) + .env("RADROOTS_RPC_URL", "http://127.0.0.1:9") .env("RADROOTS_RPC_BEARER_TOKEN", "bridge_test") .args([ "--format", @@ -1136,84 +907,36 @@ signer_session_ref = "session_test" .output() .expect("run radrootsd farm publish"); let value: Value = serde_json::from_slice(&output.stdout).expect("json output"); - let requests = server.take_requests(2); - let profile_request = &requests[0]; - let farm_request = &requests[1]; - assert!(output.status.success()); + assert!(!output.status.success()); + assert_eq!(output.status.code(), Some(3)); assert_eq!(value["operation_id"], "farm.publish"); - assert_eq!(value["result"]["state"], "published"); - assert_eq!( - value["result"]["source"], - "radrootsd publish transport · signer session" - ); - assert_eq!( - value["result"]["requested_signer_session_id"], - "session_test" - ); - assert_eq!( - value["result"]["profile"]["job_id"], - "job_profile_publish_test" - ); - assert_eq!(value["result"]["farm"]["job_id"], "job_farm_publish_test"); - assert_eq!(value["result"]["profile"]["event_kind"], KIND_PROFILE); - assert_eq!(value["result"]["farm"]["event_kind"], KIND_FARM); - assert_eq!(value["result"]["profile"]["event_id"], "a".repeat(64)); - assert_eq!(value["result"]["farm"]["event_id"], "b".repeat(64)); - assert_eq!( - value["result"]["profile"]["rpc_method"], - "bridge.profile.publish" - ); - assert_eq!(value["result"]["farm"]["rpc_method"], "bridge.farm.publish"); - assert_eq!(profile_request.body["method"], "bridge.profile.publish"); - assert_eq!(farm_request.body["method"], "bridge.farm.publish"); - assert_eq!(profile_request.body["params"]["profile_type"], "farm"); - assert_eq!( - profile_request.body["params"]["signer_session_id"], - "session_test" - ); - assert_eq!( - farm_request.body["params"]["signer_session_id"], - "session_test" - ); - assert_eq!( - profile_request.body["params"]["idempotency_key"], - "idem_farm:profile" - ); + 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!( - farm_request.body["params"]["idempotency_key"], - "idem_farm:farm" + value["errors"][0]["detail"]["actions"][0], + "radroots --publish-mode nostr_relay --relay wss://relay.example.com farm publish" ); - assert_eq!(farm_request.body["params"]["kind"], KIND_FARM); - assert_eq!(farm_request.body["params"]["farm"]["d_tag"], farm_d_tag); - assert!( - profile_request - .headers - .to_ascii_lowercase() - .contains("authorization: bearer bridge_test") + 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_state"], - "published" - ); - assert_eq!( - persisted["result"]["document"]["publication"]["farm_state"], - "published" - ); - assert_eq!( persisted["result"]["document"]["publication"]["profile_event_id"], - "a".repeat(64) + Value::Null ); assert_eq!( persisted["result"]["document"]["publication"]["farm_event_id"], - "b".repeat(64) + Value::Null ); } #[test] -fn radrootsd_farm_publish_missing_signer_binding_points_to_capability_binding() { +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(&[ @@ -1241,9 +964,11 @@ fn radrootsd_farm_publish_missing_signer_binding_points_to_capability_binding() 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"], "signer_unconfigured"); - assert_public_signer_session_binding_message(&dry_run); + 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() @@ -1261,13 +986,15 @@ fn radrootsd_farm_publish_missing_signer_binding_points_to_capability_binding() 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"], "signer_unconfigured"); - assert_public_signer_session_binding_message(&live); + 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_reject_missing_invocation_account() { +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); @@ -1312,15 +1039,17 @@ signer_session_ref = "session_test" 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"], "account_unresolved"); - assert_eq!(value["errors"][0]["detail"]["class"], "account"); + 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_reject_missing_invocation_account() { +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); @@ -1363,80 +1092,49 @@ signer_session_ref = "session_test" 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"], "account_unresolved"); - assert_eq!(value["errors"][0]["detail"]["class"], "account"); + 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_bridge_errors_are_classified() { - for (message, code, class) in [ - ( - "unauthorized bridge bearer token", - "auth_unauthorized", - "auth", - ), - ("signer session unavailable", "signer_unavailable", "signer"), - ( - "provider runtime unavailable", - "provider_unavailable", - "provider", - ), - ( - "bridge.listing.publish is disabled", - "operation_unavailable", - "operation", - ), - ] { - let sandbox = RadrootsCliSandbox::new(); - let listing_file = - create_listing_draft(&sandbox, format!("radrootsd-bridge-error-{class}").as_str()); - make_listing_publishable(&listing_file, "AAAAAAAAAAAAAAAAAAAAAw"); - 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" -"#, - ); - let server = OneShotJsonRpcServer::listing_publish_error(message); +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_RPC_URL", &server.endpoint) - .env("RADROOTS_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"); - let request = server.take_request(); + let mut command = sandbox.command(); + command + .env("RADROOTS_RPC_URL", "http://127.0.0.1:9") + .env("RADROOTS_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!(value["operation_id"], "listing.publish"); - assert_eq!(value["result"], Value::Null); - assert_eq!(value["errors"][0]["code"], code); - assert_eq!(value["errors"][0]["detail"]["class"], class); - assert_contains(&value["errors"][0]["message"], message); - assert_eq!(request.body["method"], "bridge.listing.publish"); - } + 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_bypasses_relay_signer_preflight() { +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(&[ @@ -1473,11 +1171,11 @@ fn radrootsd_listing_publish_bypasses_relay_signer_preflight() { ]); assert!(!output.status.success()); - assert_eq!(output.status.code(), Some(7)); + assert_eq!(output.status.code(), Some(3)); assert_eq!(value["operation_id"], "listing.publish"); - assert_eq!(value["errors"][0]["code"], "signer_unconfigured"); - assert_eq!(value["errors"][0]["detail"]["class"], "signer"); - assert_public_signer_session_binding_message(&value); + 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() @@ -1525,12 +1223,16 @@ fn radrootsd_publish_mode_routes_listing_update() { ]); assert!(!output.status.success()); - assert_eq!(output.status.code(), Some(7)); + 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"], "signer_unconfigured"); - assert_eq!(value["errors"][0]["detail"]["class"], "signer"); - assert_public_signer_session_binding_message(&value); + 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] @@ -2260,8 +1962,10 @@ fn next_actions_mirror_result_actions_for_json_and_ndjson() { let terminal = frames.last().expect("terminal ndjson frame"); assert!(output.status.success(), "{args:?}"); - assert_rpc_bearer_token_next_action(&terminal["payload"]["next_actions"]); - assert_signer_session_next_action(&terminal["payload"]["next_actions"]); + assert_direct_relay_next_action( + &terminal["payload"]["next_actions"], + "radroots --publish-mode nostr_relay --relay wss://relay.example.com config get", + ); } } @@ -3061,9 +2765,7 @@ fn radrootsd_sync_push_failure_exposes_nostr_relay_recovery_action() { assert!(stdout.contains("state: unavailable")); assert!(stdout.contains("publish_mode: radrootsd")); assert!(stdout.contains("publish_state: unavailable")); - assert!( - stdout.contains("reason: `radroots sync push` cannot run with publish mode `radrootsd`") - ); + 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()); }