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:
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());
}