cli

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

commit d5773aac7e5a270a0ddf8220c0b45730d9aee165
parent 168211291c366a966907d8dcca0080f141ad7900
Author: triesap <tyson@radroots.org>
Date:   Thu,  7 May 2026 20:33:23 +0000

farm: route publish by mode

Diffstat:
Msrc/domain/runtime.rs | 5+++++
Msrc/main.rs | 10+++++-----
Msrc/operation_core.rs | 6+++---
Msrc/operation_farm.rs | 13+++++++------
Msrc/operation_registry.rs | 4+---
Msrc/runtime/farm.rs | 836++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mtests/signer_runtime_modes.rs | 98++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mtests/target_cli.rs | 290++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
8 files changed, 1178 insertions(+), 84 deletions(-)

diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -752,6 +752,11 @@ pub struct FarmStatusView { pub config_valid: bool, pub account_state: String, pub listing_defaults_state: String, + pub publish_mode: String, + pub publish_state: String, + pub publish_executable: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub publish_reason: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub config: Option<FarmConfigSummaryView>, #[serde(default, skip_serializing_if = "Vec::is_empty")] diff --git a/src/main.rs b/src/main.rs @@ -454,7 +454,7 @@ 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_listing_publish_mode_routed_operation(operation_id) + && is_publish_mode_routed_operation(operation_id) { return false; } @@ -462,7 +462,7 @@ fn requires_local_signer_mode_for_publish_mode(operation_id: &str, config: &Runt } fn requires_pre_runtime_relay_target(operation_id: &str) -> bool { - !is_listing_publish_mode_routed_operation(operation_id) + !is_publish_mode_routed_operation(operation_id) } fn validate_publish_mode_contract( @@ -476,7 +476,7 @@ fn validate_publish_mode_contract( return Err(OperationAdapterError::operation_unavailable_with_detail( spec.operation_id, format!( - "`{}` cannot run with publish mode `radrootsd`; radrootsd publish transport is not implemented", + "`{}` cannot run with publish mode `radrootsd`; radrootsd publish transport is only implemented for farm and listing publish operations", spec.cli_path ), json!({ @@ -497,10 +497,10 @@ fn validate_publish_mode_contract( Ok(()) } -fn is_listing_publish_mode_routed_operation(operation_id: &str) -> bool { +fn is_publish_mode_routed_operation(operation_id: &str) -> bool { matches!( operation_id, - "listing.publish" | "listing.update" | "listing.archive" + "farm.publish" | "listing.publish" | "listing.update" | "listing.archive" ) } diff --git a/src/operation_core.rs b/src/operation_core.rs @@ -808,7 +808,7 @@ fn radrootsd_publish_readiness(config: &RuntimeConfig) -> (&'static str, bool, O "unconfigured", false, Some( - "radrootsd listing publish requires bridge bearer token configuration from RADROOTS_RPC_BEARER_TOKEN" + "radrootsd publish requires bridge bearer token configuration from RADROOTS_RPC_BEARER_TOKEN" .to_owned(), ), ); @@ -819,7 +819,7 @@ fn radrootsd_publish_readiness(config: &RuntimeConfig) -> (&'static str, bool, O "unconfigured", false, Some( - "radrootsd listing publish requires a signer.remote_nip46 capability binding with signer_session_ref for config and health readiness" + "radrootsd publish requires a signer.remote_nip46 capability binding with signer_session_ref for config and health readiness" .to_owned(), ), ); @@ -829,7 +829,7 @@ fn radrootsd_publish_readiness(config: &RuntimeConfig) -> (&'static str, bool, O "ready", true, Some( - "radrootsd bridge endpoint, bridge auth, and signer-session binding are configured; live daemon readiness is verified when listing publish runs" + "radrootsd bridge endpoint, bridge auth, and signer-session binding are configured; live bridge readiness is verified when publish runs" .to_owned(), ), ) diff --git a/src/operation_farm.rs b/src/operation_farm.rs @@ -11,7 +11,7 @@ use crate::operation_adapter::{ OperationService, }; use crate::runtime::RuntimeError; -use crate::runtime::config::RuntimeConfig; +use crate::runtime::config::{PublishMode, RuntimeConfig}; use crate::runtime_args::{ FarmCreateArgs, FarmFieldArg, FarmPublishArgs, FarmScopeArg, FarmScopedArgs, FarmUpdateArgs, }; @@ -143,7 +143,9 @@ impl OperationService<FarmPublishRequest> for FarmOperationService<'_> { request.operation_id(), )); } - require_relay_target(&request, self.config)?; + if matches!(self.config.publish.mode, PublishMode::NostrRelay) { + require_relay_target(&request, self.config)?; + } let view = crate::runtime::farm::publish(self.config, &args).map_err(|error| { OperationAdapterError::runtime_failure(request.operation_id(), error) @@ -247,10 +249,9 @@ fn farm_publish_result( } fn farm_publish_relay_unavailable(view: &FarmPublishView) -> bool { - view.source == "direct Nostr relay publish · local key" - && (!view.profile.failed_relays.is_empty() - || !view.farm.failed_relays.is_empty() - || view.state == "partial") + view.state == "partial" + || !view.profile.failed_relays.is_empty() + || !view.farm.failed_relays.is_empty() } fn require_relay_target<P>( diff --git a/src/operation_registry.rs b/src/operation_registry.rs @@ -1153,8 +1153,7 @@ pub fn requires_local_signer_mode(operation_id: &str) -> bool { pub fn requires_nostr_relay_publish_mode(operation_id: &str) -> bool { matches!( operation_id, - "farm.publish" - | "order.submit" + "order.submit" | "order.accept" | "order.decline" | "order.cancel" @@ -1509,7 +1508,6 @@ mod tests { .map(|operation| operation.operation_id) .collect::<BTreeSet<_>>(); let expected = [ - "farm.publish", "order.submit", "order.accept", "order.decline", diff --git a/src/runtime/farm.rs b/src/runtime/farm.rs @@ -9,15 +9,24 @@ use radroots_events_codec::d_tag::is_d_tag_base64url; use radroots_events_codec::farm::encode::to_wire_parts_with_kind; use radroots_events_codec::profile::encode::to_wire_parts_with_profile_type; use radroots_events_codec::wire::WireEventParts; +use radroots_sdk::{ + RadrootsSdkClient, RadrootsSdkConfig, RadrootsdAuth, SdkEnvironment, SdkPublishError, + SdkPublishReceipt, SdkRadrootsdFarmPublishOptions, SdkRadrootsdProfilePublishOptions, + SdkRadrootsdPublishReceipt, SdkRadrootsdSignerSessionRef, SdkTransportMode, + SdkTransportReceipt, SignerConfig as SdkSignerConfig, +}; use crate::domain::runtime::{ FarmConfigDocumentView, FarmConfigSummaryView, FarmGetView, FarmListingDefaultsView, - FarmPublicationView, FarmPublishComponentView, FarmPublishEventView, FarmPublishView, - FarmSelectionView, FarmSetView, FarmSetupView, FarmStatusView, RelayFailureView, + FarmPublicationView, FarmPublishComponentView, FarmPublishEventView, FarmPublishJobView, + FarmPublishView, FarmSelectionView, FarmSetView, FarmSetupView, FarmStatusView, + RelayFailureView, }; use crate::runtime::RuntimeError; use crate::runtime::accounts::{self, AccountRecordView}; -use crate::runtime::config::RuntimeConfig; +use crate::runtime::config::{ + PublishMode, RuntimeConfig, SIGNER_REMOTE_NIP46_CAPABILITY, SignerBackend, +}; use crate::runtime::direct_relay::{ DirectRelayFailure, DirectRelayPublishError, DirectRelayPublishReceipt, publish_parts_with_identity, @@ -32,7 +41,10 @@ use crate::runtime_args::{ }; const FARM_CONFIG_SOURCE: &str = "farm config · local first"; -const FARM_WRITE_SOURCE: &str = "direct Nostr relay publish · local key"; +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_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); @@ -50,7 +62,7 @@ pub fn init(config: &RuntimeConfig, args: &FarmCreateArgs) -> Result<FarmSetupVi &selected_account, &document, Some("The farm draft is local until you publish it.".to_owned()), - farm_setup_actions(&document), + farm_setup_actions(config, &document), config, ) } @@ -83,7 +95,7 @@ pub fn init_preflight( ), )), reason: Some("dry run requested; farm draft was not written".to_owned()), - actions: farm_setup_actions(&document), + actions: farm_setup_actions(config, &document), }) } @@ -124,7 +136,7 @@ pub fn set(config: &RuntimeConfig, args: &FarmUpdateArgs) -> Result<FarmSetView, account_pubkey, )), reason: None, - actions: vec!["radroots farm readiness check".to_owned()], + actions: farm_update_actions(config, &resolved.document), }) } @@ -167,7 +179,7 @@ pub fn set_preflight( account_pubkey, )), reason: Some("dry run requested; farm draft was not written".to_owned()), - actions: vec!["radroots farm readiness check".to_owned()], + actions: farm_update_actions(config, &resolved.document), }) } @@ -188,6 +200,10 @@ 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_state: "not_checked".to_owned(), + publish_executable: false, + publish_reason: None, config: None, missing: vec!["Farm draft".to_owned()], reason: Some(format!("no farm config found at {}", path.display())), @@ -207,7 +223,12 @@ pub fn status( } else { "ready" }; - let state = if account.is_some() && draft_missing.is_empty() { + let publish = account + .as_ref() + .filter(|_| draft_missing.is_empty()) + .map(|account| farm_publish_readiness(config, account)) + .unwrap_or_else(FarmPublishReadiness::not_checked); + let state = if account.is_some() && draft_missing.is_empty() && publish.executable { "ready" } else { "unconfigured" @@ -220,13 +241,13 @@ pub fn status( } else if !draft_missing.is_empty() { Some("farm draft is missing required fields".to_owned()) } else { - None + publish.reason.clone() }; let mut actions = Vec::new(); if account.is_none() { actions.push("radroots account create".to_owned()); } else if draft_missing.is_empty() { - actions.push("radroots farm publish".to_owned()); + actions.extend(publish.actions.clone()); } else { actions.extend(missing_field_actions(draft_missing.as_slice())); } @@ -243,6 +264,10 @@ 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_state: publish.state.to_owned(), + publish_executable: publish.executable, + publish_reason: publish.reason, config: Some(summary_view( resolved.scope, resolved.path.display().to_string(), @@ -252,7 +277,9 @@ pub fn status( missing: if account.is_none() { vec!["Selected account".to_owned()] } else { - missing_field_labels(draft_missing.as_slice()) + let mut missing = missing_field_labels(draft_missing.as_slice()); + missing.extend(publish.missing); + missing }, reason, actions, @@ -288,6 +315,126 @@ pub fn get(config: &RuntimeConfig, args: &FarmScopedArgs) -> Result<FarmGetView, }) } +#[derive(Debug, Clone)] +struct FarmPublishReadiness { + state: &'static str, + executable: bool, + reason: Option<String>, + missing: Vec<String>, + actions: Vec<String>, +} + +impl FarmPublishReadiness { + fn not_checked() -> Self { + Self { + state: "not_checked", + executable: false, + reason: None, + missing: Vec::new(), + actions: Vec::new(), + } + } +} + +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), + } +} + +fn relay_farm_publish_readiness( + config: &RuntimeConfig, + account: &AccountRecordView, +) -> FarmPublishReadiness { + if 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(), + ), + missing: vec!["Configured relay".to_owned()], + actions: vec!["radroots --relay wss://relay.example.com farm publish".to_owned()], + }; + } + + if matches!(config.signer.backend, SignerBackend::Myc) { + return FarmPublishReadiness { + state: "unavailable", + executable: false, + reason: Some( + "nostr_relay farm publish requires signer mode `local`; signer mode `myc` is deferred" + .to_owned(), + ), + missing: vec!["Local signer mode".to_owned()], + actions: vec!["radroots signer status get".to_owned()], + }; + } + + if !account.write_capable { + return FarmPublishReadiness { + state: "unconfigured", + executable: false, + reason: Some( + accounts::AccountRuntimeFailure::watch_only(&account.record.account_id).to_string(), + ), + missing: vec!["Write-capable account".to_owned()], + actions: vec!["radroots account attach-secret".to_owned()], + }; + } + + FarmPublishReadiness { + state: "ready", + executable: true, + reason: None, + missing: Vec::new(), + actions: vec!["radroots farm publish".to_owned()], + } +} + +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()], + }; + } + + 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" + .to_owned(), + ), + missing: Vec::new(), + actions: vec!["radroots farm publish".to_owned()], + } +} + pub fn publish( config: &RuntimeConfig, args: &FarmPublishArgs, @@ -297,6 +444,7 @@ pub fn publish( let path = farm_config::config_path(&config.paths, resolved_scope)?; let Some(resolved) = farm_config::load(config, Some(resolved_scope))? else { return Ok(missing_publish_view( + config, resolved_scope, path.display().to_string(), args, @@ -313,6 +461,7 @@ pub fn publish( let Some(account) = configured_account(config, &resolved.document.selection.account)? else { return Ok(missing_publish_view( + config, resolved.scope, resolved.path.display().to_string(), args, @@ -332,6 +481,7 @@ pub fn publish( let draft_missing = farm_config::missing_fields(&resolved.document); if !draft_missing.is_empty() { return Ok(missing_publish_view( + config, resolved.scope, resolved.path.display().to_string(), args, @@ -350,6 +500,156 @@ 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 dry_run_publish_view( + config, + args, + &resolved, + &account_pubkey, + previews, + profile_idempotency_key, + farm_idempotency_key, + ); + } + + match config.publish.mode { + PublishMode::NostrRelay => publish_via_direct_relay( + config, + args, + resolved, + account_pubkey, + previews, + profile_idempotency_key, + farm_idempotency_key, + ), + PublishMode::Radrootsd => publish_via_radrootsd( + config, + args, + resolved, + account_pubkey, + previews, + profile_idempotency_key, + farm_idempotency_key, + ), + } +} + +fn dry_run_publish_view( + config: &RuntimeConfig, + args: &FarmPublishArgs, + resolved: &ResolvedFarmConfig, + account_pubkey: &str, + previews: FarmPublishPreviews, + profile_idempotency_key: Option<String>, + farm_idempotency_key: Option<String>, +) -> Result<FarmPublishView, RuntimeError> { + match config.publish.mode { + PublishMode::NostrRelay => { + if let Err(error) = resolve_farm_signing_identity(config, account_pubkey) { + return match error { + ActorWriteBindingError::Account(failure) => Err(failure.into()), + error => Ok(binding_error_publish_view( + config, + args, + resolved, + account_pubkey, + previews, + profile_idempotency_key, + farm_idempotency_key, + error, + )), + }; + } + + Ok(base_publish_view( + "dry_run", + config, + args, + resolved, + account_pubkey, + preview_component( + "relay.profile.publish", + KIND_PROFILE, + profile_idempotency_key, + args, + Some(previews.profile.event), + ), + preview_component( + "relay.farm.publish", + KIND_FARM, + farm_idempotency_key, + args, + Some(previews.farm.event), + ), + Some("dry run requested; relay publish skipped".to_owned()), + 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 `signer_session_id` input or 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))) + } + } +} + +fn publish_via_direct_relay( + 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 signing = match resolve_farm_signing_identity(config, account_pubkey.as_str()) { Ok(signing) => signing, Err(ActorWriteBindingError::Account(failure)) => return Err(failure.into()), @@ -367,40 +667,14 @@ pub fn publish( } }; - if config.output.dry_run { - return Ok(base_publish_view( - "dry_run", - config, - args, - &resolved, - &account_pubkey, - preview_component( - "relay.profile.publish", - KIND_PROFILE, - profile_idempotency_key, - args, - Some(previews.profile.event), - ), - preview_component( - "relay.farm.publish", - KIND_FARM, - farm_idempotency_key, - args, - Some(previews.farm.event), - ), - Some("dry run requested; relay publish skipped".to_owned()), - vec![format!( - "radroots farm publish --scope {}", - resolved.scope.as_str() - )], - )); - } let profile_receipt = publish_parts_with_identity( &signing.identity, &config.relay.urls, previews.profile.parts.clone(), ) .map_err(|error| RuntimeError::Network(error.to_string()))?; + persist_profile_publication(config, &mut resolved, profile_receipt.event_id.clone())?; + let farm_receipt = match publish_parts_with_identity( &signing.identity, &config.relay.urls, @@ -421,6 +695,7 @@ pub fn publish( )); } }; + persist_farm_publication(config, &mut resolved, farm_receipt.event_id.clone())?; Ok(base_publish_view( "published", @@ -449,6 +724,136 @@ pub fn publish( )) } +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 `signer_session_id` input or 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, @@ -461,7 +866,15 @@ struct FarmPublishEventDraft { parts: WireEventParts, } +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, path: String, args: &FarmPublishArgs, @@ -476,7 +889,7 @@ fn missing_publish_view( ) -> FarmPublishView { FarmPublishView { state: "unconfigured".to_owned(), - source: FARM_WRITE_SOURCE.to_owned(), + source: farm_write_source(config).to_owned(), scope: scope.as_str().to_owned(), path, config_present, @@ -485,8 +898,14 @@ fn missing_publish_view( selected_account_pubkey, farm_d_tag, requested_signer_session_id: args.signer_session_id.clone(), - profile: not_submitted_component("relay.profile.publish", KIND_PROFILE, args, None, None), - farm: not_submitted_component("relay.farm.publish", KIND_FARM, args, None, None), + profile: not_submitted_component( + profile_publish_rpc_method(config), + KIND_PROFILE, + args, + None, + None, + ), + farm: not_submitted_component(farm_publish_rpc_method(config), KIND_FARM, args, None, None), missing, reason: Some(reason), actions, @@ -538,7 +957,7 @@ fn base_publish_view( ) -> FarmPublishView { FarmPublishView { state: state.to_owned(), - source: FARM_WRITE_SOURCE.to_owned(), + source: farm_write_source(config).to_owned(), scope: resolved.scope.as_str().to_owned(), path: resolved.path.display().to_string(), config_present: true, @@ -732,10 +1151,7 @@ fn partial_publish_view( farm_error, ), Some(reason), - vec![format!( - "radroots farm publish --scope {}", - resolved.scope.as_str() - )], + vec!["radroots farm publish".to_owned()], ) } @@ -803,6 +1219,302 @@ fn failed_component( } } +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 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()] + }; + base_publish_view( + state, + config, + args, + resolved, + account_pubkey, + FarmPublishComponentView { + state: state.to_owned(), + signer_mode: Some("nip46".to_owned()), + signer_session_id: signer_session_id.clone(), + reason: Some(reason.to_owned()), + ..radrootsd_preview_component( + RADROOTSD_BRIDGE_PROFILE_PUBLISH_METHOD, + KIND_PROFILE, + profile_idempotency_key, + args, + Some(previews.profile.event), + ) + }, + FarmPublishComponentView { + state: state.to_owned(), + signer_mode: Some("nip46".to_owned()), + signer_session_id: signer_session_id.clone(), + reason: Some(reason.to_owned()), + ..radrootsd_preview_component( + RADROOTSD_BRIDGE_FARM_PUBLISH_METHOD, + KIND_FARM, + farm_idempotency_key, + args, + Some(previews.farm.event), + ) + }, + Some(reason.to_owned()), + actions, + ) + .with_requested_signer_session_id(signer_session_id) +} + +fn radrootsd_preview_component( + rpc_method: &str, + event_kind: u32, + idempotency_key: Option<String>, + args: &FarmPublishArgs, + event: Option<FarmPublishEventView>, +) -> FarmPublishComponentView { + FarmPublishComponentView { + signer_mode: Some("nip46".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::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, + event_id: String, +) -> Result<(), RuntimeError> { + persist_publication(config, resolved, Some(event_id), None) +} + +fn persist_farm_publication( + config: &RuntimeConfig, + resolved: &mut ResolvedFarmConfig, + event_id: String, +) -> Result<(), RuntimeError> { + persist_publication(config, resolved, None, Some(event_id)) +} + +fn persist_publication( + config: &RuntimeConfig, + resolved: &mut ResolvedFarmConfig, + profile_event_id: Option<String>, + farm_event_id: Option<String>, +) -> Result<(), RuntimeError> { + let published_at = now_unix(); + if let Some(event_id) = profile_event_id.and_then(|value| non_empty(value.as_str())) { + resolved.document.publication.profile_event_id = Some(event_id); + resolved.document.publication.profile_published_at = Some(published_at); + } + if let Some(event_id) = farm_event_id.and_then(|value| non_empty(value.as_str())) { + resolved.document.publication.farm_event_id = Some(event_id); + resolved.document.publication.farm_published_at = Some(published_at); + } + farm_config::write(&config.paths, resolved.scope, &resolved.document)?; + Ok(()) +} + +fn farm_write_source(config: &RuntimeConfig) -> &'static str { + match config.publish.mode { + PublishMode::NostrRelay => RELAY_FARM_WRITE_SOURCE, + PublishMode::Radrootsd => RADROOTSD_FARM_WRITE_SOURCE, + } +} + +fn profile_publish_rpc_method(config: &RuntimeConfig) -> &'static str { + match config.publish.mode { + PublishMode::NostrRelay => "relay.profile.publish", + PublishMode::Radrootsd => RADROOTSD_BRIDGE_PROFILE_PUBLISH_METHOD, + } +} + +fn farm_publish_rpc_method(config: &RuntimeConfig) -> &'static str { + match config.publish.mode { + PublishMode::NostrRelay => "relay.farm.publish", + PublishMode::Radrootsd => RADROOTSD_BRIDGE_FARM_PUBLISH_METHOD, + } +} + #[derive(Debug, Clone)] struct FarmPublishFailureDetails { event_id: Option<String>, @@ -1015,14 +1727,31 @@ fn save_draft_view( }) } -fn farm_setup_actions(document: &FarmConfigDocument) -> Vec<String> { +fn farm_update_actions(config: &RuntimeConfig, document: &FarmConfigDocument) -> Vec<String> { + farm_setup_actions(config, document) +} + +fn farm_setup_actions(config: &RuntimeConfig, document: &FarmConfigDocument) -> Vec<String> { let mut actions = vec!["radroots farm readiness check".to_owned()]; - if farm_config::missing_fields(document).is_empty() { + if farm_config::missing_fields(document).is_empty() && farm_publish_action_available(config) { actions.push("radroots farm publish".to_owned()); } actions } +fn farm_publish_action_available(config: &RuntimeConfig) -> bool { + match config.publish.mode { + PublishMode::NostrRelay => { + !config.relay.urls.is_empty() && matches!(config.signer.backend, SignerBackend::Local) + } + PublishMode::Radrootsd => { + config.rpc.bridge_bearer_token.is_some() + && resolve_radrootsd_signer_session_id(config, &FarmPublishArgs::default()) + .is_some() + } + } +} + fn missing_blocks_listing_defaults(missing: &[FarmMissingField]) -> bool { missing.iter().any(|field| { matches!( @@ -1401,6 +2130,13 @@ fn non_empty(value: &str) -> Option<String> { } } +fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default() +} + fn generate_d_tag() -> String { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/tests/signer_runtime_modes.rs b/tests/signer_runtime_modes.rs @@ -27,11 +27,19 @@ struct FarmPartialRelayServer { impl FarmPartialRelayServer { fn profile_accept_farm_reject() -> Self { + Self::with_publish_outcomes([(true, ""), (false, "farm rejected by test relay")]) + } + + fn profile_and_farm_accept() -> Self { + Self::with_publish_outcomes([(true, ""), (true, "")]) + } + + fn with_publish_outcomes(outcomes: [(bool, &'static str); 2]) -> Self { let listener = TcpListener::bind("127.0.0.1:0").expect("bind relay"); let endpoint = format!("ws://{}", listener.local_addr().expect("relay addr")); let (tx, requests) = mpsc::channel(); let handle = thread::spawn(move || { - for (accepted, reason) in [(true, ""), (false, "farm rejected by test relay")] { + for (accepted, reason) in outcomes { let (stream, _) = listener.accept().expect("accept relay connection"); handle_publish_connection(stream, accepted, reason, &tx); } @@ -1135,6 +1143,7 @@ fn local_farm_publish_reports_partial_when_farm_event_fails_after_profile_publis assert_eq!(detail["farm"]["event_id"], requests[1]["id"]); assert_eq!(detail["profile"]["idempotency_key"], "farm_partial:profile"); assert_eq!(detail["farm"]["idempotency_key"], "farm_partial:farm"); + assert_eq!(detail["actions"][0], "radroots farm publish"); assert_eq!(detail["profile"]["target_relays"][0], relay_url.as_str()); assert_eq!(detail["farm"]["target_relays"][0], relay_url.as_str()); assert_relay_url( @@ -1154,6 +1163,93 @@ fn local_farm_publish_reports_partial_when_farm_event_fails_after_profile_publis assert_eq!(requests[1]["kind"], KIND_FARM); assert_no_removed_command_reference(&value, &["farm", "publish"]); assert_no_daemon_runtime_reference(&value, &["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"], + "not_published" + ); + assert_eq!( + persisted["result"]["document"]["publication"]["profile_event_id"], + requests[0]["id"] + ); +} + +#[test] +fn local_farm_publish_persists_publication_after_profile_and_farm_publish() { + let sandbox = RadrootsCliSandbox::new(); + sandbox.json_success(&["--format", "json", "account", "create"]); + sandbox.json_success(&[ + "--format", + "json", + "farm", + "create", + "--name", + "Green Farm", + "--location", + "farmstand", + "--country", + "US", + "--delivery-method", + "pickup", + ]); + let relay = FarmPartialRelayServer::profile_and_farm_accept(); + let relay_url = relay.endpoint().to_owned(); + + let value = sandbox.json_success(&[ + "--format", + "json", + "--relay", + relay_url.as_str(), + "--approval-token", + "approve", + "--idempotency-key", + "farm_success", + "farm", + "publish", + ]); + let requests = relay.take_requests(); + + assert_eq!(value["operation_id"], "farm.publish"); + assert_eq!(value["result"]["state"], "published"); + assert_eq!(value["result"]["profile"]["state"], "published"); + assert_eq!(value["result"]["farm"]["state"], "published"); + assert_eq!(value["result"]["profile"]["event_id"], requests[0]["id"]); + assert_eq!(value["result"]["farm"]["event_id"], requests[1]["id"]); + assert_eq!( + value["result"]["profile"]["idempotency_key"], + "farm_success:profile" + ); + assert_eq!( + value["result"]["farm"]["idempotency_key"], + "farm_success:farm" + ); + assert_eq!(requests[0]["kind"], KIND_PROFILE); + assert_eq!(requests[1]["kind"], KIND_FARM); + assert_no_removed_command_reference(&value, &["farm", "publish"]); + assert_no_daemon_runtime_reference(&value, &["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"], + requests[0]["id"] + ); + assert_eq!( + persisted["result"]["document"]["publication"]["farm_event_id"], + requests[1]["id"] + ); } #[test] diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -8,6 +8,7 @@ use std::sync::mpsc::{self, Receiver}; use std::thread::{self, JoinHandle}; use std::time::Duration; +use radroots_events::kinds::{KIND_FARM, KIND_PROFILE}; use serde_json::Value; use serde_json::json; @@ -58,6 +59,53 @@ impl OneShotJsonRpcServer { })) } + 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", @@ -70,6 +118,10 @@ impl OneShotJsonRpcServer { } 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", @@ -77,17 +129,19 @@ impl OneShotJsonRpcServer { ); let (tx, requests) = mpsc::channel(); let handle = thread::spawn(move || { - 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"); + 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, @@ -97,10 +151,20 @@ impl OneShotJsonRpcServer { } fn take_request(self) -> JsonRpcRequest { - let request = self - .requests - .recv_timeout(Duration::from_secs(5)) - .expect("fake radrootsd request"); + 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 } @@ -265,7 +329,7 @@ signer_session_ref = "session_ready" assert_eq!(value["result"]["publish"]["executable"], true); assert_contains( &value["result"]["publish"]["reason"], - "live daemon readiness is verified when listing publish runs", + "live bridge readiness is verified when publish runs", ); assert_eq!(value["result"]["publish"]["provider"]["state"], "ready"); assert_eq!(value["result"]["rpc"]["bridge_auth_configured"], true); @@ -475,6 +539,72 @@ fn health_check_marks_relay_publish_ready_with_secret_backed_local_account() { } #[test] +fn farm_readiness_check_reports_mode_specific_publish_gates() { + let sandbox = RadrootsCliSandbox::new(); + sandbox.json_success(&["--format", "json", "account", "create"]); + sandbox.json_success(&[ + "--format", + "json", + "farm", + "create", + "--name", + "Ready Farm", + "--location", + "farmstand", + "--country", + "US", + "--delivery-method", + "pickup", + ]); + + let (_, relay_value) = sandbox.json_output(&["--format", "json", "farm", "readiness", "check"]); + let relay_detail = if relay_value["result"].is_null() { + &relay_value["errors"][0]["detail"] + } else { + &relay_value["result"] + }; + assert_eq!(relay_detail["publish_mode"], "nostr_relay"); + assert_eq!(relay_detail["publish_state"], "unconfigured"); + assert_eq!(relay_detail["publish_executable"], false); + assert_eq!(relay_detail["missing"][0], "Configured relay"); + + 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 output = sandbox + .command() + .env("RADROOTS_RPC_BEARER_TOKEN", "bridge_test") + .args([ + "--format", + "json", + "--publish-mode", + "radrootsd", + "farm", + "readiness", + "check", + ]) + .output() + .expect("run radrootsd 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"], "ready"); + assert_eq!(radrootsd_value["result"]["publish_executable"], true); + assert_eq!( + radrootsd_value["result"]["actions"][0], + "radroots farm publish" + ); +} + +#[test] fn radrootsd_listing_publish_reaches_listing_router_without_relay_config() { let sandbox = RadrootsCliSandbox::new(); sandbox.json_success(&["--format", "json", "account", "create"]); @@ -577,6 +707,134 @@ signer_session_ref = "session_test" } #[test] +fn radrootsd_farm_publish_submits_profile_and_farm_without_relay_config() { + 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 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_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"); + let requests = server.take_requests(2); + let profile_request = &requests[0]; + let farm_request = &requests[1]; + + assert!(output.status.success()); + 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!( + farm_request.body["params"]["idempotency_key"], + "idem_farm:farm" + ); + 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") + ); + + 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) + ); + assert_eq!( + persisted["result"]["document"]["publication"]["farm_event_id"], + "b".repeat(64) + ); +} + +#[test] fn radrootsd_listing_writes_dry_run_use_draft_identity_without_local_account() { for operation in ["publish", "update", "archive"] { let sandbox = RadrootsCliSandbox::new();