cli

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

commit ba0ce9d3cb1ed6a768540f16016040fd78f4c041
parent 106516a900d4e97c500105ee16036cc0f91efe94
Author: triesap <tyson@radroots.org>
Date:   Mon, 27 Apr 2026 06:31:22 +0000

cli: add local signed listing publish

- sign listing publish events with the selected local account
- return signed event metadata without claiming relay delivery
- classify local account publish boundary failures precisely
- cover signed no-account and watch-only publish paths

Diffstat:
Msrc/domain/runtime.rs | 4++++
Msrc/operation_adapter.rs | 1+
Msrc/operation_listing.rs | 22+++++++++++++++++++++-
Msrc/runtime/accounts.rs | 27++++++++++++++++++++++++++-
Msrc/runtime/listing.rs | 145++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mtests/signer_runtime_modes.rs | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
6 files changed, 297 insertions(+), 5 deletions(-)

diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -1807,10 +1807,14 @@ pub struct ListingMutationJobView { pub struct ListingMutationEventView { pub kind: u32, pub author: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub created_at: Option<u32>, pub content: String, pub tags: Vec<Vec<String>>, #[serde(skip_serializing_if = "Option::is_none")] pub event_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub signature: Option<String>, pub event_addr: String, } diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs @@ -519,6 +519,7 @@ fn classify_runtime_failure( &lowered, &[ "no account", + "no local account", "account selector", "account selection", "did not match any local account", diff --git a/src/operation_listing.rs b/src/operation_listing.rs @@ -141,7 +141,8 @@ impl OperationService<ListingPublishRequest> for ListingOperationService<'_> { } require_approval(&request)?; let args = mutation_args(&request)?; - let view = map_runtime(crate::runtime::listing::publish(self.config, &args))?; + let view = crate::runtime::listing::publish(self.config, &args) + .map_err(|error| publish_runtime_error(request.operation_id(), error))?; mutation_result::<ListingPublishResult>(request.operation_id(), &view) } } @@ -272,6 +273,25 @@ fn map_runtime<T>(result: Result<T, RuntimeError>) -> Result<T, OperationAdapter result.map_err(|error| OperationAdapterError::Runtime(error.to_string())) } +fn publish_runtime_error(operation_id: &str, error: RuntimeError) -> OperationAdapterError { + let message = error.to_string(); + let lowered = message.to_ascii_lowercase(); + if lowered.contains("no local account") + || lowered.contains("watch_only") + || lowered.contains("not secret-backed") + || lowered.contains("selected local account") + { + return OperationAdapterError::unconfigured(operation_id, message); + } + if matches!(&error, RuntimeError::Config(_)) { + return OperationAdapterError::InvalidInput { + operation_id: operation_id.to_owned(), + message, + }; + } + OperationAdapterError::Runtime(message) +} + fn required_string<P>( request: &OperationRequest<P>, key: &str, diff --git a/src/runtime/accounts.rs b/src/runtime/accounts.rs @@ -1,6 +1,6 @@ use std::path::Path; -use radroots_identity::{IdentityError, load_identity_profile}; +use radroots_identity::{IdentityError, RadrootsIdentity, load_identity_profile}; use radroots_nostr_accounts::prelude::{ RadrootsNostrAccountRecord, RadrootsNostrAccountStatus, RadrootsNostrAccountsError, RadrootsNostrAccountsManager, @@ -91,6 +91,12 @@ pub struct AccountResolution { pub default_account: Option<AccountRecordView>, } +#[derive(Debug, Clone)] +pub struct AccountSigningIdentity { + pub account: AccountRecordView, + pub identity: RadrootsIdentity, +} + pub fn create_or_migrate_default_account( config: &RuntimeConfig, ) -> Result<AccountCreateResult, RuntimeError> { @@ -259,6 +265,25 @@ pub fn resolved_account_signing_status( ) } +pub fn resolve_local_signing_identity( + config: &RuntimeConfig, +) -> Result<AccountSigningIdentity, RuntimeError> { + let manager = account_manager(config)?; + let resolution = resolve_account_resolution(config)?; + let Some(account) = resolution.resolved_account else { + return Err(RuntimeError::Config( + "no local account is selected for signing".to_owned(), + )); + }; + let Some(identity) = manager.get_signing_identity(&account.record.account_id)? else { + return Err(RuntimeError::Config(format!( + "watch_only account {} is present but not secret-backed", + account.record.account_id + ))); + }; + Ok(AccountSigningIdentity { account, identity }) +} + pub fn account_summary_view(account: &AccountRecordView) -> AccountSummaryView { AccountSummaryView::from_account_record(&account.record, account.signer, account.is_default) } diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs @@ -18,6 +18,7 @@ use radroots_events::listing::{ use radroots_events::trade::RadrootsTradeListingValidationError; use radroots_events_codec::d_tag::is_d_tag_base64url; use radroots_events_codec::listing::encode::to_wire_parts_with_kind; +use radroots_nostr::prelude::radroots_nostr_build_event; use radroots_replica_db::ReplicaSql; use radroots_sql_core::SqliteExecutor; use radroots_trade::listing::publish::validate_listing_for_seller; @@ -36,7 +37,7 @@ use crate::domain::runtime::{ }; use crate::runtime::RuntimeError; use crate::runtime::accounts; -use crate::runtime::config::RuntimeConfig; +use crate::runtime::config::{RuntimeConfig, SignerBackend}; use crate::runtime::daemon; use crate::runtime::daemon::DaemonRpcError; use crate::runtime::farm_config; @@ -47,6 +48,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 LISTING_WRITE_SOURCE: &str = "daemon bridge · durable write plane"; +const LISTING_LOCAL_SIGNED_SOURCE: &str = "local account signer · signed event artifact"; static D_TAG_COUNTER: AtomicU64 = AtomicU64::new(0); @@ -1122,6 +1124,19 @@ fn mutate( }); } + if matches!(operation, ListingMutationOperation::Publish) + && matches!(config.signer.backend, SignerBackend::Local) + { + return local_signed_view( + config, + args, + operation, + &canonical, + listing_addr, + event_preview, + ); + } + let signer_authority = match resolve_actor_write_authority(config, "seller", canonical.seller_pubkey.as_str()) { Ok(authority) => authority, @@ -1562,9 +1577,11 @@ fn build_listing_event_preview( ListingMutationEventView { kind: KIND_LISTING, author: canonical.seller_pubkey.clone(), + created_at: None, content: parts.content, tags: parts.tags, event_id: None, + signature: None, event_addr: validated.listing_addr.clone(), }, validated.listing_addr, @@ -1687,6 +1704,132 @@ fn daemon_error_view( } } +fn local_signed_view( + config: &RuntimeConfig, + args: &ListingMutationArgs, + operation: ListingMutationOperation, + canonical: &CanonicalListingDraft, + listing_addr: String, + event_preview: ListingMutationEventView, +) -> Result<ListingMutationView, RuntimeError> { + let signed_event = match sign_listing_event(config, canonical) { + Ok(event) => event, + Err(error) => { + return Ok(ListingMutationView { + state: "unconfigured".to_owned(), + operation: operation.as_str().to_owned(), + source: LISTING_LOCAL_SIGNED_SOURCE.to_owned(), + file: args.file.display().to_string(), + listing_id: canonical.listing_id.clone(), + listing_addr, + seller_pubkey: canonical.seller_pubkey.clone(), + event_kind: KIND_LISTING, + dry_run: false, + deduplicated: false, + job_id: None, + job_status: None, + signer_mode: Some(config.signer.backend.as_str().to_owned()), + event_id: None, + event_addr: None, + idempotency_key: args.idempotency_key.clone(), + signer_session_id: None, + requested_signer_session_id: args.signer_session_id.clone(), + reason: Some(error.to_string()), + job: args.print_job.then(|| ListingMutationJobView { + rpc_method: "local.listing.sign".to_owned(), + state: "unconfigured".to_owned(), + job_id: None, + idempotency_key: args.idempotency_key.clone(), + requested_signer_session_id: args.signer_session_id.clone(), + signer_mode: Some(config.signer.backend.as_str().to_owned()), + signer_session_id: None, + }), + event: args.print_event.then_some(event_preview), + actions: vec!["radroots signer status get".to_owned()], + }); + } + }; + let event_view = signed_listing_event_view(&signed_event, listing_addr.as_str()); + Ok(ListingMutationView { + state: "signed".to_owned(), + operation: operation.as_str().to_owned(), + source: LISTING_LOCAL_SIGNED_SOURCE.to_owned(), + file: args.file.display().to_string(), + listing_id: canonical.listing_id.clone(), + listing_addr: listing_addr.clone(), + seller_pubkey: canonical.seller_pubkey.clone(), + event_kind: KIND_LISTING, + dry_run: false, + deduplicated: false, + job_id: None, + job_status: None, + signer_mode: Some(config.signer.backend.as_str().to_owned()), + event_id: event_view.event_id.clone(), + event_addr: Some(listing_addr), + idempotency_key: args.idempotency_key.clone(), + signer_session_id: None, + requested_signer_session_id: args.signer_session_id.clone(), + reason: Some("signed locally; relay delivery was not attempted".to_owned()), + job: args.print_job.then(|| ListingMutationJobView { + rpc_method: "local.listing.sign".to_owned(), + state: "not_submitted".to_owned(), + job_id: None, + idempotency_key: args.idempotency_key.clone(), + requested_signer_session_id: args.signer_session_id.clone(), + signer_mode: Some(config.signer.backend.as_str().to_owned()), + signer_session_id: None, + }), + event: Some(event_view), + actions: Vec::new(), + }) +} + +fn sign_listing_event( + config: &RuntimeConfig, + canonical: &CanonicalListingDraft, +) -> Result<radroots_nostr::prelude::RadrootsNostrEvent, RuntimeError> { + let signing = accounts::resolve_local_signing_identity(config)?; + let account_pubkey = signing + .account + .record + .public_identity + .public_key_hex + .as_str(); + if !account_pubkey.eq_ignore_ascii_case(canonical.seller_pubkey.as_str()) { + return Err(RuntimeError::Config(format!( + "selected local account pubkey `{account_pubkey}` cannot sign listing seller_pubkey `{}`", + canonical.seller_pubkey + ))); + } + let parts = to_wire_parts_with_kind(&canonical.listing, KIND_LISTING) + .map_err(|error| RuntimeError::Config(format!("invalid listing contract: {error}")))?; + let event = radroots_nostr_build_event(parts.kind, parts.content, parts.tags) + .map_err(|error| RuntimeError::Config(format!("build local listing event: {error}")))? + .sign_with_keys(signing.identity.keys()) + .map_err(|error| RuntimeError::Config(format!("sign local listing event: {error}")))?; + Ok(event) +} + +fn signed_listing_event_view( + event: &radroots_nostr::prelude::RadrootsNostrEvent, + listing_addr: &str, +) -> ListingMutationEventView { + ListingMutationEventView { + kind: event.kind.as_u16() as u32, + author: event.pubkey.to_string(), + created_at: Some(u32::try_from(event.created_at.as_secs()).unwrap_or(u32::MAX)), + content: event.content.clone(), + tags: event + .tags + .iter() + .map(|tag| tag.as_slice().to_vec()) + .collect(), + event_id: Some(event.id.to_string()), + signature: Some(event.sig.to_string()), + event_addr: listing_addr.to_owned(), + } +} + fn binding_error_view( config: &RuntimeConfig, args: &ListingMutationArgs, diff --git a/tests/signer_runtime_modes.rs b/tests/signer_runtime_modes.rs @@ -3,6 +3,7 @@ mod support; use std::fs; use std::path::{Path, PathBuf}; +use radroots_events::kinds::KIND_LISTING; use radroots_identity::{RadrootsIdentity, RadrootsIdentityPublic}; use serde_json::{Value, json}; use support::RadrootsCliSandbox; @@ -419,14 +420,106 @@ fn local_listing_publish_fails_without_local_account_authority() { assert!(!output.status.success()); assert_eq!(value["operation_id"], "listing.publish"); assert_eq!(value["result"], serde_json::Value::Null); - assert_eq!(value["errors"][0]["code"], "runtime_error"); - assert_eq!(value["errors"][0]["exit_code"], 1); + assert_eq!(value["errors"][0]["code"], "account_unresolved"); + assert_eq!(value["errors"][0]["exit_code"], 5); + assert_eq!(value["errors"][0]["detail"]["class"], "account"); assert_contains( &value["errors"][0]["message"], "no local account is selected", ); } +#[test] +fn local_listing_publish_signs_with_selected_account_without_remote_fallback() { + let sandbox = RadrootsCliSandbox::new(); + sandbox.json_success(&["--format", "json", "account", "create"]); + let listing_file = create_listing_draft(&sandbox, "local-signed"); + make_listing_publishable(&listing_file, "AAAAAAAAAAAAAAAAAAAAAw"); + + let (output, value) = sandbox.json_output(&[ + "--format", + "json", + "--approval-token", + "approve", + "listing", + "publish", + listing_file.to_string_lossy().as_ref(), + ]); + + assert!(output.status.success()); + assert_eq!(value["operation_id"], "listing.publish"); + assert_eq!(value["result"]["state"], "signed"); + assert_eq!(value["result"]["signer_mode"], "local"); + assert_eq!( + value["result"]["signer_session_id"], + serde_json::Value::Null + ); + assert_eq!(value["result"]["job_id"], serde_json::Value::Null); + assert_eq!(value["result"]["event"]["kind"], KIND_LISTING); + assert_eq!( + value["result"]["event"]["author"], + value["result"]["seller_pubkey"] + ); + assert_eq!( + value["result"]["event"]["event_id"], + value["result"]["event_id"] + ); + assert_hex_len(&value["result"]["event_id"], 64); + assert_hex_len(&value["result"]["event"]["signature"], 128); + assert_contains( + &value["result"]["reason"], + "relay delivery was not attempted", + ); + assert!( + value["result"]["event"]["tags"] + .as_array() + .expect("event tags") + .iter() + .any(|tag| tag + .as_array() + .is_some_and(|items| items.first() == Some(&json!("d")) + && items.get(1) == Some(&value["result"]["listing_id"]))) + ); +} + +#[test] +fn watch_only_listing_publish_fails_as_account_watch_only() { + let sandbox = RadrootsCliSandbox::new(); + let public_identity = identity_public(12); + let public_identity_file = + write_public_identity_profile(&sandbox, "watch-only-publish", &public_identity); + sandbox.json_success(&[ + "--format", + "json", + "--approval-token", + "approve", + "account", + "import", + "--default", + public_identity_file.to_string_lossy().as_ref(), + ]); + let listing_file = create_listing_draft(&sandbox, "watch-only-publish"); + make_listing_publishable(&listing_file, "AAAAAAAAAAAAAAAAAAAAAw"); + + let (output, value) = sandbox.json_output(&[ + "--format", + "json", + "--approval-token", + "approve", + "listing", + "publish", + listing_file.to_string_lossy().as_ref(), + ]); + + assert!(!output.status.success()); + assert_eq!(value["operation_id"], "listing.publish"); + assert_eq!(value["result"], serde_json::Value::Null); + assert_eq!(value["errors"][0]["code"], "account_watch_only"); + assert_eq!(value["errors"][0]["exit_code"], 7); + assert_eq!(value["errors"][0]["detail"]["class"], "account"); + assert_contains(&value["errors"][0]["message"], "watch_only account"); +} + #[cfg(unix)] #[test] fn myc_listing_publish_does_not_fallback_to_local_account() { @@ -573,6 +666,12 @@ fn assert_contains(value: &Value, needle: &str) { ); } +fn assert_hex_len(value: &Value, expected_len: usize) { + let value = value.as_str().expect("hex string"); + assert_eq!(value.len(), expected_len); + assert!(value.chars().all(|ch| ch.is_ascii_hexdigit())); +} + fn create_listing_draft(sandbox: &RadrootsCliSandbox, key: &str) -> PathBuf { let listing_file = sandbox.root().join(format!("{key}.toml")); let listing_file_arg = listing_file.to_string_lossy();