cli

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

commit d734e0fb82ddaa87d37d8ba1f1106dfbd761b81d
parent ce5be2dc7e548e6a44eda80acc19ee1adac29002
Author: triesap <tyson@radroots.org>
Date:   Wed, 24 Jun 2026 00:57:25 +0000

cli: enable sdk-backed myc signing

- Add CLI signer provider wiring for local key and Myc NIP-46 SDK signing modes.
- Route farm and listing writes through configured SDK signer providers without local fallback.
- Replace deferred Myc readiness with binding and session validation for config, health, and signer status surfaces.
- Cover direct relay and radrootsd proxy Myc listing publishes with deterministic integration tests.
- Validate with cargo fmt --all --check, cargo check --all-targets, and cargo test --all-targets.

Diffstat:
MCargo.lock | 4++++
MCargo.toml | 6++++--
Msrc/ops/error.rs | 6++++++
Msrc/ops/exec/core.rs | 45+++++++++++++++++++++++----------------------
Msrc/ops/exec/listing.rs | 2+-
Msrc/registry/mod.rs | 12+-----------
Msrc/runtime/account.rs | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/runtime/config.rs | 2--
Msrc/runtime/farm.rs | 98++++++++++++++++++++++++++-----------------------------------------------------
Msrc/runtime/listing.rs | 126+++++++++++++++++--------------------------------------------------------------
Msrc/runtime/order.rs | 20+++++++++++++++-----
Msrc/runtime/sdk.rs | 424+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Msrc/runtime/signer.rs | 281+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Mtests/signer_runtime_modes.rs | 38++++++++++++++++++++++++--------------
Mtests/support/mod.rs | 10++++++++++
Mtests/target_cli.rs | 641++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
16 files changed, 1444 insertions(+), 326 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -3617,6 +3617,7 @@ dependencies = [ "clap", "flate2", "getrandom 0.2.17", + "nostr", "radroots_authority", "radroots_core", "radroots_events", @@ -3626,6 +3627,7 @@ dependencies = [ "radroots_log", "radroots_nostr", "radroots_nostr_accounts", + "radroots_nostr_connect", "radroots_nostr_signer", "radroots_protected_store", "radroots_replica_db", @@ -3920,6 +3922,8 @@ dependencies = [ "radroots_events_codec", "radroots_identity", "radroots_nostr", + "radroots_nostr_connect", + "radroots_nostr_signer", "radroots_outbox", "radroots_publish_proxy_protocol", "radroots_relay_transport", diff --git a/Cargo.toml b/Cargo.toml @@ -35,7 +35,9 @@ radroots_local_events = { path = "../lib/crates/local_events" } radroots_log = { path = "../lib/crates/log" } radroots_nostr_accounts = { path = "../lib/crates/nostr_accounts", features = ["os-keyring"] } radroots_nostr = { path = "../lib/crates/nostr", features = ["client", "events"] } +radroots_nostr_connect = { path = "../lib/crates/nostr_connect" } radroots_nostr_signer = { path = "../lib/crates/nostr_signer" } +radroots_protected_store = { path = "../lib/crates/protected_store", features = ["std"] } radroots_replica_db = { path = "../lib/crates/replica_db" } radroots_replica_db_schema = { path = "../lib/crates/replica_db_schema" } radroots_replica_sync = { path = "../lib/crates/replica_sync" } @@ -49,7 +51,7 @@ radroots_trade = { path = "../lib/crates/trade" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" thiserror = "2.0" -tokio = { version = "1", features = ["rt-multi-thread", "time"] } +tokio = { version = "1", features = ["rt-multi-thread", "sync", "time"] } toml = "0.8" url = "2.5" zeroize = "1.8" @@ -57,7 +59,7 @@ zeroize = "1.8" [dev-dependencies] assert_cmd = "2.0" flate2 = "1" -radroots_protected_store = { path = "../lib/crates/protected_store", features = ["std"] } +nostr = { version = "0.44.2", features = ["nip44"] } tar = "0.4" tempfile = "3.17" tungstenite = "0.26.2" diff --git a/src/ops/error.rs b/src/ops/error.rs @@ -281,6 +281,12 @@ impl OperationAdapterError { RuntimeFailureAvailability::Unconfigured, ) } + RuntimeError::Config(_) if looks_like_signer_failure(&lowered) => { + Self::SignerUnconfigured { + operation_id: operation_id.to_owned(), + message, + } + } RuntimeError::Config(_) if looks_like_validation_failure(&lowered) => { Self::ValidationFailed { operation_id: operation_id.to_owned(), diff --git a/src/ops/exec/core.rs b/src/ops/exec/core.rs @@ -30,6 +30,7 @@ use crate::runtime::account::{ use crate::runtime::config::{PublishTransport, RuntimeConfig, SignerBackend}; use crate::runtime::logging::LoggingState; use crate::runtime::sdk::CliSdkAdapterError; +use crate::runtime::signer::resolve_signer_status; use crate::view::runtime::{ CommandDisposition, LocalBackupView, LocalRestoreView, PublishProviderRuntimeView, PublishRelayRuntimeView, PublishRuntimeView, @@ -874,14 +875,12 @@ fn direct_nostr_relay_publish_readiness( } if matches!(config.signer.backend, SignerBackend::Myc) { - return ( - "unavailable", - false, - Some( - "direct_nostr_relay publish transport requires signer mode `local` for signed writes; signer mode `myc` is deferred" - .to_owned(), - ), - ); + let signer = resolve_signer_status(config); + return if signer.state == "ready" { + ("ready", true, None) + } else { + ("unconfigured", false, signer.reason) + }; } let Some(resolved_account) = account.resolved_account.as_ref() else { @@ -920,14 +919,12 @@ fn radrootsd_publish_readiness(config: &RuntimeConfig) -> (&'static str, bool, O } if matches!(config.signer.backend, SignerBackend::Myc) { - return ( - "unavailable", - false, - Some( - "radrootsd_proxy publish transport requires signer mode `local` for signed writes; signer mode `myc` is deferred" - .to_owned(), - ), - ); + let signer = resolve_signer_status(config); + return if signer.state == "ready" { + ("ready", true, None) + } else { + ("unconfigured", false, signer.reason) + }; } ("ready", true, None) @@ -952,12 +949,16 @@ fn signer_health_view(config: &RuntimeConfig, account: &AccountResolution) -> Va }, }) } - SignerBackend::Myc => json!({ - "state": "unavailable", - "backend": config.signer.backend.as_str(), - "write_capable_account": false, - "reason": "signer mode `myc` is deferred for CLI writes", - }), + SignerBackend::Myc => { + let signer = resolve_signer_status(config); + json!({ + "state": signer.state, + "backend": config.signer.backend.as_str(), + "write_capable_account": signer.reason.is_none(), + "reason": signer.reason, + "binding_state": signer.binding.state, + }) + } } } diff --git a/src/ops/exec/listing.rs b/src/ops/exec/listing.rs @@ -335,7 +335,7 @@ where fn listing_relay_unavailable(view: &ListingMutationView) -> bool { matches!( view.source.as_str(), - "direct Nostr relay publish · local key" | "SDK listing publish · local key" + "direct Nostr relay publish · local key" | "SDK listing publish · configured signer" ) && (view.reason.as_deref().is_some_and(|reason| { reason.contains("configured relay") || reason.contains("direct relay connection failed") diff --git a/src/registry/mod.rs b/src/registry/mod.rs @@ -211,12 +211,7 @@ pub fn network_requirement(operation_id: &str) -> NetworkRequirement { pub fn requires_local_signer_mode(operation_id: &str) -> bool { matches!( operation_id, - "signer.status.get" - | "farm.publish" - | "sync.push" - | "listing.publish" - | "listing.update" - | "listing.archive" + "sync.push" | "order.submit" | "order.accept" | "order.decline" @@ -577,12 +572,7 @@ mod tests { .map(|operation| operation.operation_id) .collect::<BTreeSet<_>>(); let expected = [ - "signer.status.get", "sync.push", - "farm.publish", - "listing.publish", - "listing.update", - "listing.archive", "order.submit", "order.accept", "order.decline", diff --git a/src/runtime/account.rs b/src/runtime/account.rs @@ -1,4 +1,4 @@ -use std::{fmt, path::Path}; +use std::{fmt, path::Path, sync::Arc}; use radroots_identity::{ IdentityError, RadrootsIdentity, RadrootsIdentityPublic, load_identity_profile, @@ -7,8 +7,9 @@ use radroots_nostr_accounts::prelude::{ RadrootsNostrAccountRecord, RadrootsNostrAccountStatus, RadrootsNostrAccountsError, RadrootsNostrAccountsManager, }; +use radroots_protected_store::RadrootsProtectedFileSecretVault; use radroots_secret_vault::{ - RadrootsHostVaultCapabilities, RadrootsResolvedSecretBackend, + RadrootsHostVaultCapabilities, RadrootsResolvedSecretBackend, RadrootsSecretBackend, RadrootsSecretBackendAvailability, RadrootsSecretBackendSelection, RadrootsSecretVault, RadrootsSecretVaultError, RadrootsSecretVaultOsKeyring, }; @@ -594,6 +595,26 @@ pub fn secret_backend_status(config: &RuntimeConfig) -> AccountSecretBackendStat } } +pub fn load_secret_backend_secret( + config: &RuntimeConfig, + slot: &str, + service_name: &str, +) -> Result<Option<String>, RuntimeError> { + if slot.trim().is_empty() { + return Err(RuntimeError::Config( + "secret backend slot must not be empty".to_owned(), + )); + } + let resolved = resolve_secret_backend(config).map_err(secret_backend_resolution_error)?; + let vault = secret_vault_for_backend(config, resolved.backend, service_name)?; + vault.load_secret(slot).map_err(|error| { + RuntimeError::Config(format!( + "failed to load secret `{slot}` from account secret backend `{}`: {error}", + resolved.backend.kind() + )) + }) +} + fn snapshot_from_manager( manager: &RadrootsNostrAccountsManager, ) -> Result<AccountSnapshot, RuntimeError> { @@ -773,6 +794,36 @@ fn resolve_secret_backend( }) } +fn secret_backend_resolution_error(error: SecretBackendResolutionError) -> RuntimeError { + match error { + SecretBackendResolutionError::Unavailable(reason) + | SecretBackendResolutionError::Invalid(reason) => RuntimeError::Config(reason), + } +} + +fn secret_vault_for_backend( + config: &RuntimeConfig, + backend: RadrootsSecretBackend, + service_name: &str, +) -> Result<Arc<dyn RadrootsSecretVault>, RuntimeError> { + match backend { + RadrootsSecretBackend::HostVault(_) => { + Ok(Arc::new(RadrootsSecretVaultOsKeyring::new(service_name))) + } + RadrootsSecretBackend::EncryptedFile => Ok(Arc::new( + RadrootsProtectedFileSecretVault::new(config.account.secrets_dir.as_path()), + )), + RadrootsSecretBackend::ExternalCommand => Err(RuntimeError::Config( + "external_command account secret backend is not supported for CLI signer sessions" + .to_owned(), + )), + RadrootsSecretBackend::Memory => Err(RuntimeError::Config( + "memory account secret backend is not supported for persisted CLI signer sessions" + .to_owned(), + )), + } +} + fn account_secret_backend_selection(config: &RuntimeConfig) -> RadrootsSecretBackendSelection { RadrootsSecretBackendSelection { primary: config.account.secret_backend, diff --git a/src/runtime/config.rs b/src/runtime/config.rs @@ -317,7 +317,6 @@ pub enum CapabilityBindingTargetKind { ExplicitEndpoint, } -#[cfg(test)] impl CapabilityBindingTargetKind { pub fn as_str(self) -> &'static str { match self { @@ -333,7 +332,6 @@ pub enum CapabilityBindingSource { WorkspaceConfig, } -#[cfg(test)] impl CapabilityBindingSource { pub fn as_str(self) -> &'static str { match self { diff --git a/src/runtime/farm.rs b/src/runtime/farm.rs @@ -1,7 +1,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; -use radroots_authority::{RadrootsActorContext, RadrootsLocalEventSigner}; +use radroots_authority::RadrootsActorContext; use radroots_events::contract::RadrootsActorRole; use radroots_events::farm::{RadrootsFarm, RadrootsFarmLocation}; use radroots_events::kinds::{KIND_FARM, KIND_PROFILE}; @@ -9,7 +9,6 @@ use radroots_events::listing::RadrootsListingLocation; use radroots_events::profile::{RadrootsProfile, RadrootsProfileType}; use radroots_events_codec::d_tag::is_d_tag_base64url; use radroots_events_codec::profile::encode::to_wire_parts_with_profile_type; -use radroots_nostr::prelude::RadrootsNostrKeys; use radroots_sdk::{ FarmEnqueuePublishRequest, FarmEnqueueReceipt, FarmPreparePublishRequest, FarmPublishPlan, PushOutboxEventReceipt, PushOutboxEventState, PushOutboxReceipt, PushOutboxRelayOutcomeKind, @@ -31,8 +30,9 @@ use crate::runtime::farm_config::{ use crate::runtime::local_events::append_local_work; use crate::runtime::sdk::{ CliSdkAdapterError, CliSdkSession, sdk_relay_target_policy, sdk_relay_url_policy, + validate_configured_signer_for_actor, }; -use crate::runtime::signer::{ActorWriteBindingError, resolve_actor_write_authority}; +use crate::runtime::signer::ActorWriteBindingError; use crate::view::runtime::{ FarmConfigDocumentView, FarmConfigSummaryView, FarmGetView, FarmListingDefaultsView, FarmPublicationView, FarmPublishComponentView, FarmPublishEventView, FarmPublishView, @@ -42,7 +42,7 @@ use crate::view::runtime::{ const FARM_CONFIG_SOURCE: &str = "farm config · local first"; const FARM_SELLER_ACTOR_SOURCE: &str = "farm_config"; -const SDK_FARM_WRITE_SOURCE: &str = "SDK farm publish · local key"; +const SDK_FARM_WRITE_SOURCE: &str = "SDK farm publish · configured signer"; const SDK_PROFILE_NOT_SUBMITTED_METHOD: &str = "sdk.farm.profile.not_submitted"; const SDK_FARM_PUBLISH_METHOD: &str = "sdk.farm.publish.v1"; const SDK_PROFILE_NOT_SUBMITTED_REASON: &str = @@ -518,15 +518,26 @@ fn relay_farm_publish_readiness( } if matches!(config.signer.backend, SignerBackend::Myc) { + if let Err(error) = validate_configured_signer_for_actor( + config, + Some(account.record.account_id.as_str()), + account.record.public_identity.public_key_hex.as_str(), + "farm seller", + ) { + return FarmPublishReadiness { + state: "unconfigured", + executable: false, + reason: Some(error.to_string()), + missing: vec!["Remote signer binding".to_owned()], + actions: vec!["radroots signer status get".to_owned()], + }; + } return FarmPublishReadiness { - state: "unavailable", - executable: false, - reason: Some( - "direct_nostr_relay farm publish requires signer mode `local`; signer mode `myc` is deferred" - .to_owned(), - ), - missing: vec!["Local signer mode".to_owned()], - actions: vec!["radroots signer status get".to_owned()], + state: "ready", + executable: true, + reason: None, + missing: Vec::new(), + actions: Vec::new(), }; } @@ -644,12 +655,14 @@ fn publish_via_sdk( ) -> Result<FarmPublishView, CliSdkAdapterError> { let input = sdk_farm_publish_input(&resolved, account_pubkey.as_str())?; if config.output.dry_run { - if let Err(error) = resolve_farm_signing_identity( + if let Err(error) = validate_configured_signer_for_actor( config, - resolved.document.selection.account.as_str(), + Some(resolved.document.selection.account.as_str()), account_pubkey.as_str(), + "farm seller", ) { - return match error { + let binding_error = ActorWriteBindingError::from_runtime(error); + return match binding_error { ActorWriteBindingError::Account(failure) => Err(RuntimeError::from(failure).into()), error => Ok(binding_error_publish_view( config, @@ -681,18 +694,18 @@ fn publish_via_sdk( )); } - let session = CliSdkSession::connect(config)?; - let signer = sdk_farm_signer( + let session = CliSdkSession::connect_for_actor( config, - resolved.document.selection.account.as_str(), + Some(resolved.document.selection.account.as_str()), account_pubkey.as_str(), + "farm seller", )?; let mut request = FarmEnqueuePublishRequest::new(input.actor, input.farm, sdk_relay_target_policy(config)); if let Some(idempotency_key) = farm_idempotency_key.as_deref() { request = request.try_with_idempotency_key(idempotency_key)?; } - let enqueue = session.block_on(session.sdk().farms().enqueue_publish(request, &signer))?; + let enqueue = session.block_on(session.sdk().farms().enqueue_publish(request))?; let push = session.block_on( session.sdk().sync().push_outbox( PushOutboxRequest::new() @@ -777,39 +790,6 @@ fn missing_publish_view( } } -fn resolve_farm_signing_identity( - config: &RuntimeConfig, - account_id: &str, - account_pubkey: &str, -) -> Result<account::AccountSigningIdentity, ActorWriteBindingError> { - if !matches!( - config.signer.backend, - crate::runtime::config::SignerBackend::Local - ) { - return resolve_actor_write_authority(config, "farm", account_pubkey).and_then(|_| { - Err(ActorWriteBindingError::Unconfigured( - "farm publish requires signer mode `local`".to_owned(), - )) - }); - } - let signing = account::resolve_local_signing_identity_for_account(config, account_id) - .map_err(ActorWriteBindingError::from_runtime)?; - let selected_pubkey = signing - .account - .record - .public_identity - .public_key_hex - .as_str(); - if !selected_pubkey.eq_ignore_ascii_case(account_pubkey) { - return Err(ActorWriteBindingError::Account( - account::AccountRuntimeFailure::mismatch(format!( - "account mismatch: resolved account pubkey `{selected_pubkey}` cannot sign farm-bound seller pubkey `{account_pubkey}`" - )), - )); - } - Ok(signing) -} - fn base_publish_view( state: &str, config: &RuntimeConfig, @@ -977,20 +957,6 @@ fn sdk_farm_publish_input( }) } -fn sdk_farm_signer( - config: &RuntimeConfig, - account_id: &str, - account_pubkey: &str, -) -> Result<RadrootsLocalEventSigner, RuntimeError> { - let signing = match resolve_farm_signing_identity(config, account_id, account_pubkey) { - Ok(signing) => signing, - Err(ActorWriteBindingError::Account(failure)) => return Err(failure.into()), - Err(error) => return Err(RuntimeError::Config(error.reason())), - }; - let keys: RadrootsNostrKeys = signing.identity.into_keys(); - RadrootsLocalEventSigner::new(keys).map_err(|error| RuntimeError::Config(error.to_string())) -} - fn sdk_prepared_publish_view( config: &RuntimeConfig, args: &FarmPublishArgs, diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs @@ -4,7 +4,7 @@ use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; -use radroots_authority::{RadrootsActorContext, RadrootsLocalEventSigner}; +use radroots_authority::RadrootsActorContext; use radroots_core::{ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreDiscount, RadrootsCoreDiscountScope, RadrootsCoreDiscountThreshold, RadrootsCoreDiscountValue, RadrootsCoreMoney, @@ -24,7 +24,6 @@ use radroots_events::trade_validation::RadrootsTradeValidationListingError; use radroots_events_codec::d_tag::is_d_tag_base64url; use radroots_events_codec::listing::encode::to_wire_parts_with_kind; use radroots_local_events::{LocalEventRecord, LocalRecordFamily, SourceRuntime}; -use radroots_nostr::prelude::RadrootsNostrKeys; use radroots_replica_db::ReplicaSql; use radroots_sdk::{ ListingEnqueuePublishRequest, ListingEnqueueReceipt, ListingPreparePublishRequest, @@ -42,7 +41,7 @@ use crate::cli::global::{ }; use crate::runtime::RuntimeError; use crate::runtime::account; -use crate::runtime::config::{RuntimeConfig, SignerBackend}; +use crate::runtime::config::RuntimeConfig; use crate::runtime::farm_config; use crate::runtime::local_events::{ append_local_work, get_shared_record, list_shared_records_before, list_shared_records_latest, @@ -50,6 +49,7 @@ use crate::runtime::local_events::{ }; use crate::runtime::sdk::{ CliSdkAdapterError, CliSdkSession, sdk_relay_target_policy, sdk_relay_url_policy, + validate_configured_signer_for_actor, }; use crate::runtime::sync::{ RelayIngestScope, freshness_for_scope_from_executor, market_refresh, missing_freshness, @@ -66,7 +66,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_APP_RECORD_SOURCE: &str = "shared local events · app"; -const SDK_LISTING_WRITE_SOURCE: &str = "SDK listing publish · local key"; +const SDK_LISTING_WRITE_SOURCE: &str = "SDK listing publish · configured signer"; 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"; @@ -1741,7 +1741,7 @@ pub fn publish_via_sdk( ) -> Result<ListingMutationView, CliSdkAdapterError> { let input = sdk_listing_publish_input(config, args)?; if config.output.dry_run { - validate_local_listing_signer(config, &input.canonical)?; + validate_configured_listing_signer(config, &input.canonical)?; let session = CliSdkSession::connect_memory(config)?; let plan = session.sdk().listings().prepare_publish( ListingPreparePublishRequest::from_document( @@ -1758,8 +1758,12 @@ pub fn publish_via_sdk( )); } - let session = CliSdkSession::connect(config)?; - let signer = sdk_listing_signer(config, &input.canonical)?; + let session = CliSdkSession::connect_for_actor( + config, + Some(input.canonical.seller_account_id.as_str()), + input.canonical.seller_pubkey.as_str(), + "listing seller", + )?; let mut request = ListingEnqueuePublishRequest::from_document( input.actor, input.document, @@ -1768,8 +1772,7 @@ pub fn publish_via_sdk( if let Some(idempotency_key) = args.idempotency_key.as_deref() { request = request.try_with_idempotency_key(idempotency_key)?; } - let enqueue_receipt = - session.block_on(session.sdk().listings().enqueue_publish(request, &signer))?; + let enqueue_receipt = session.block_on(session.sdk().listings().enqueue_publish(request))?; let push_receipt = if args.offline { None } else { @@ -1842,15 +1845,6 @@ fn sdk_listing_publish_input( }) } -fn sdk_listing_signer( - config: &RuntimeConfig, - canonical: &CanonicalListingDraft, -) -> Result<RadrootsLocalEventSigner, RuntimeError> { - let signing = resolve_listing_signing_identity(config, canonical)?; - let keys: RadrootsNostrKeys = signing.identity.into_keys(); - RadrootsLocalEventSigner::new(keys).map_err(|error| RuntimeError::Config(error.to_string())) -} - fn sdk_prepared_publish_view( config: &RuntimeConfig, args: &ListingMutationArgs, @@ -2152,8 +2146,8 @@ fn mutate( }); } - if config.output.dry_run && matches!(config.signer.backend, SignerBackend::Local) { - validate_local_listing_signer(config, &canonical)?; + if config.output.dry_run { + validate_configured_listing_signer(config, &canonical)?; } mutate_via_sdk_from_canonical(config, args, operation, canonical) @@ -2183,8 +2177,12 @@ fn mutate_via_sdk_from_canonical( )); } - let session = CliSdkSession::connect(config)?; - let signer = sdk_listing_signer(config, &canonical)?; + let session = CliSdkSession::connect_for_actor( + config, + Some(canonical.seller_account_id.as_str()), + canonical.seller_pubkey.as_str(), + "listing seller", + )?; let mut request = ListingEnqueuePublishRequest::from_document( actor, document, @@ -2193,8 +2191,7 @@ fn mutate_via_sdk_from_canonical( if let Some(idempotency_key) = args.idempotency_key.as_deref() { request = request.try_with_idempotency_key(idempotency_key)?; } - let enqueue_receipt = - session.block_on(session.sdk().listings().enqueue_publish(request, &signer))?; + let enqueue_receipt = session.block_on(session.sdk().listings().enqueue_publish(request))?; let push_receipt = if args.offline { None } else { @@ -2794,87 +2791,16 @@ fn invalid_validation_view( } } -fn validate_local_listing_signer( +fn validate_configured_listing_signer( config: &RuntimeConfig, canonical: &CanonicalListingDraft, ) -> Result<(), RuntimeError> { - resolve_listing_signing_identity(config, canonical).map(|_| ()) -} - -fn resolve_listing_signing_identity( - config: &RuntimeConfig, - canonical: &CanonicalListingDraft, -) -> Result<account::AccountSigningIdentity, RuntimeError> { - let signing = account::resolve_local_signing_identity_for_account( + validate_configured_signer_for_actor( config, - canonical.seller_account_id.as_str(), + Some(canonical.seller_account_id.as_str()), + canonical.seller_pubkey.as_str(), + "listing seller", ) - .map_err(|error| listing_bound_signing_error(error, canonical))?; - 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(account::AccountRuntimeFailure::mismatch_with_detail( - format!( - "account mismatch: listing-bound seller account `{}` pubkey `{account_pubkey}` cannot sign listing seller_pubkey `{}`", - canonical.seller_account_id, canonical.seller_pubkey - ), - json!({ - "seller_actor_source": canonical.seller_actor_source, - "listing_seller_account_id": canonical.seller_account_id, - "listing_seller_pubkey": canonical.seller_pubkey, - "account_pubkey": account_pubkey, - "actions": [ - "radroots account import <path>", - "radroots account attach-secret <account-id> <path>", - ], - }), - ) - .into()); - } - Ok(signing) -} - -fn listing_bound_signing_error( - error: RuntimeError, - canonical: &CanonicalListingDraft, -) -> RuntimeError { - match error { - RuntimeError::Account(account::AccountRuntimeFailure::Unresolved(issue)) => { - account::AccountRuntimeFailure::unresolved_with_detail( - issue.message().to_owned(), - json!({ - "seller_actor_source": canonical.seller_actor_source, - "listing_seller_account_id": canonical.seller_account_id, - "listing_seller_pubkey": canonical.seller_pubkey, - "actions": [ - "radroots account import <path>", - format!("radroots listing rebind <file> {}", canonical.seller_account_id), - ], - }), - ) - .into() - } - RuntimeError::Account(account::AccountRuntimeFailure::WatchOnly(issue)) => { - account::AccountRuntimeFailure::watch_only_with_detail( - &canonical.seller_account_id, - json!({ - "seller_actor_source": canonical.seller_actor_source, - "listing_seller_account_id": canonical.seller_account_id, - "listing_seller_pubkey": canonical.seller_pubkey, - "reason": issue.message(), - "actions": [ - format!("radroots account attach-secret {} <path>", canonical.seller_account_id), - ], - }), - ) - .into() - } - other => other, - } } fn issue_from_trade_validation( diff --git a/src/runtime/order.rs b/src/runtime/order.rs @@ -5422,7 +5422,7 @@ fn enqueue_order_revision_proposal_via_sdk( session .sdk() .orders() - .enqueue_revision_proposal(request, &signer), + .enqueue_revision_proposal_with_explicit_signer(request, &signer), )?; let push = push_one_sdk_outbox_event(&session, policy)?; Ok(sdk_enqueued_order_revision_view( @@ -5467,7 +5467,7 @@ fn enqueue_order_revision_decision_via_sdk( session .sdk() .orders() - .enqueue_revision_decision(request, &signer), + .enqueue_revision_decision_with_explicit_signer(request, &signer), )?; let push = push_one_sdk_outbox_event(&session, policy)?; Ok(sdk_enqueued_order_revision_decision_view( @@ -5526,7 +5526,7 @@ fn enqueue_order_cancellation_via_sdk( session .sdk() .orders() - .enqueue_cancellation(request, &signer), + .enqueue_cancellation_with_explicit_signer(request, &signer), )?; let push = push_one_sdk_outbox_event(&session, policy)?; Ok(sdk_enqueued_order_cancellation_view( @@ -6122,7 +6122,12 @@ fn enqueue_order_decision_via_sdk( let keys: RadrootsNostrKeys = signing.identity.into_keys(); let signer = RadrootsLocalEventSigner::new(keys) .map_err(|error| RuntimeError::Config(error.to_string()))?; - let enqueue = session.block_on(session.sdk().orders().enqueue_decision(request, &signer))?; + let enqueue = session.block_on( + session + .sdk() + .orders() + .enqueue_decision_with_explicit_signer(request, &signer), + )?; let push = session.block_on( session.sdk().sync().push_outbox( PushOutboxRequest::new() @@ -9465,7 +9470,12 @@ fn submit_via_sdk( let keys: RadrootsNostrKeys = signing.identity.into_keys(); let signer = RadrootsLocalEventSigner::new(keys) .map_err(|error| RuntimeError::Config(error.to_string()))?; - let enqueue = session.block_on(session.sdk().orders().enqueue_submit(request, &signer))?; + let enqueue = session.block_on( + session + .sdk() + .orders() + .enqueue_submit_with_explicit_signer(request, &signer), + )?; let push = session.block_on( session.sdk().sync().push_outbox( PushOutboxRequest::new() diff --git a/src/runtime/sdk.rs b/src/runtime/sdk.rs @@ -3,23 +3,42 @@ use std::fs; use std::future::Future; use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; use radroots_authority::RadrootsLocalEventSigner; -use radroots_nostr::prelude::RadrootsNostrKeys; +use radroots_identity::RadrootsIdentity; +use radroots_nostr::prelude::{ + RadrootsNostrClient, RadrootsNostrEvent, RadrootsNostrFilter, RadrootsNostrKeys, + RadrootsNostrKind, RadrootsNostrRelayPoolNotification, RadrootsNostrTimestamp, + radroots_nostr_filter_tag, +}; +use radroots_nostr_connect::prelude::{ + RADROOTS_NOSTR_CONNECT_RPC_KIND, RadrootsNostrConnectBunkerUri, + RadrootsNostrConnectClientTarget, RadrootsNostrConnectError, RadrootsNostrConnectUri, +}; use radroots_sdk::{ - RadrootsSdk, RadrootsSdkBuilder, RadrootsSdkError, RadrootsSdkStorageConfig, - SdkPublishTransport, SdkRelayUrlPolicy, + RadrootsSdk, RadrootsSdkBuilder, RadrootsSdkError, RadrootsSdkLocalKeySigner, + RadrootsSdkMycNip46Signer, RadrootsSdkNip46Transport, RadrootsSdkNip46TransportFuture, + RadrootsSdkSignerProvider, RadrootsSdkStorageConfig, SdkPublishTransport, SdkRelayUrlPolicy, adapters::radrootsd::{RadrootsdAuth, RadrootsdProxyConfig as SdkRadrootsdProxyConfig}, }; use radroots_secret_vault::{RadrootsSecretVault, RadrootsSecretVaultOsKeyring}; use tokio::runtime::{Builder as TokioRuntimeBuilder, Runtime}; +use tokio::sync::{Mutex, broadcast}; +use tokio::time::{Instant, timeout}; +use url::Url; use crate::runtime::RuntimeError; use crate::runtime::account; -use crate::runtime::config::{PublishTransport, RuntimeConfig}; +use crate::runtime::config::{ + CapabilityBindingTargetKind, PublishTransport, RuntimeConfig, SIGNER_REMOTE_NIP46_CAPABILITY, + SignerBackend, +}; const SDK_STORAGE_DIR_NAME: &str = "sdk"; const RADROOTSD_PROXY_SECRET_SERVICE: &str = "org.radroots.cli.radrootsd-proxy"; +pub(crate) const MYC_NIP46_SESSION_SECRET_SERVICE: &str = "org.radroots.cli.myc-nip46-session"; #[derive(Debug, thiserror::Error)] pub enum CliSdkAdapterError { @@ -89,6 +108,53 @@ impl CliSdkSession { }) } + pub fn connect_for_actor( + config: &RuntimeConfig, + actor_account_id: Option<&str>, + actor_pubkey: &str, + actor_label: &str, + ) -> Result<Self, CliSdkAdapterError> { + let sdk_config = CliSdkConfig::from_runtime_config(config)?; + let signer_input = + configured_signer_input(config, actor_account_id, actor_pubkey, actor_label)?; + let runtime = sdk_runtime()?; + let signer_provider = runtime.block_on(signer_provider(config, signer_input))?; + let sdk = runtime.block_on( + sdk_config + .builder() + .signer_provider(signer_provider) + .build(), + )?; + Ok(Self { + runtime, + sdk, + config: sdk_config, + }) + } + + pub fn connect_memory_for_actor( + config: &RuntimeConfig, + actor_account_id: Option<&str>, + actor_pubkey: &str, + actor_label: &str, + ) -> Result<Self, CliSdkAdapterError> { + let sdk_config = CliSdkConfig::from_runtime_config(config)?; + let signer_input = + configured_signer_input(config, actor_account_id, actor_pubkey, actor_label)?; + let runtime = sdk_runtime()?; + let signer_provider = runtime.block_on(signer_provider(config, signer_input))?; + let sdk = runtime.block_on( + memory_builder(&sdk_config) + .signer_provider(signer_provider) + .build(), + )?; + Ok(Self { + runtime, + sdk, + config: sdk_config, + }) + } + pub fn sdk(&self) -> &RadrootsSdk { &self.sdk } @@ -105,6 +171,15 @@ impl CliSdkSession { } } +pub fn validate_configured_signer_for_actor( + config: &RuntimeConfig, + actor_account_id: Option<&str>, + actor_pubkey: &str, + actor_label: &str, +) -> Result<(), RuntimeError> { + configured_signer_input(config, actor_account_id, actor_pubkey, actor_label).map(|_| ()) +} + pub struct CliSdkLocalSigner { account_id: String, public_key_hex: String, @@ -144,6 +219,314 @@ impl CliSdkLocalSigner { } } +enum CliSdkSignerInput { + LocalKey(RadrootsNostrKeys), + MycNip46 { + client_keys: RadrootsNostrKeys, + target: RadrootsNostrConnectClientTarget, + actor_pubkey: String, + }, +} + +fn configured_signer_input( + config: &RuntimeConfig, + actor_account_id: Option<&str>, + actor_pubkey: &str, + actor_label: &str, +) -> Result<CliSdkSignerInput, RuntimeError> { + match config.signer.backend { + SignerBackend::Local => { + let keys = local_key_signer_input(config, actor_account_id, actor_pubkey, actor_label)?; + Ok(CliSdkSignerInput::LocalKey(keys)) + } + SignerBackend::Myc => myc_nip46_signer_input(config, actor_account_id, actor_pubkey), + } +} + +fn local_key_signer_input( + config: &RuntimeConfig, + actor_account_id: Option<&str>, + actor_pubkey: &str, + actor_label: &str, +) -> Result<RadrootsNostrKeys, RuntimeError> { + let signing = match actor_account_id { + Some(account_id) => { + account::resolve_local_signing_identity_for_account(config, account_id)? + } + None => account::resolve_local_signing_identity(config)?, + }; + let signer_pubkey = signing + .account + .record + .public_identity + .public_key_hex + .as_str(); + if !signer_pubkey.eq_ignore_ascii_case(actor_pubkey) { + return Err(account::AccountRuntimeFailure::mismatch(format!( + "{actor_label} public key `{actor_pubkey}` does not match local signer account `{}` public key `{signer_pubkey}`", + signing.account.record.account_id + )) + .into()); + } + Ok(signing.identity.into_keys()) +} + +fn myc_nip46_signer_input( + config: &RuntimeConfig, + actor_account_id: Option<&str>, + actor_pubkey: &str, +) -> Result<CliSdkSignerInput, RuntimeError> { + let binding = config + .capability_binding(SIGNER_REMOTE_NIP46_CAPABILITY) + .ok_or_else(|| RuntimeError::Config("signer.remote_nip46 binding is missing".to_owned()))?; + if binding.target_kind != CapabilityBindingTargetKind::ExplicitEndpoint { + return Err(RuntimeError::Config(format!( + "signer.remote_nip46 binding target_kind `{}` is not supported for CLI Myc signing; use `explicit_endpoint`", + binding.target_kind.as_str() + ))); + } + if let Some(managed_account_ref) = binding.managed_account_ref.as_deref() { + let matched_account = actor_account_id + .is_some_and(|account_id| managed_account_ref == account_id) + || managed_account_ref == actor_pubkey; + if !matched_account { + return Err(RuntimeError::Config(format!( + "signer.remote_nip46 managed_account_ref `{managed_account_ref}` does not match actor account or pubkey" + ))); + } + } + let signer_session_ref = binding.signer_session_ref.as_deref().ok_or_else(|| { + RuntimeError::Config("signer.remote_nip46 signer_session_ref is missing".to_owned()) + })?; + let secret = + account::load_secret_backend_secret(config, signer_session_ref, MYC_NIP46_SESSION_SECRET_SERVICE)? + .ok_or_else(|| { + RuntimeError::Config(format!( + "signer.remote_nip46 signer_session_ref `{signer_session_ref}` was not found in the account secret backend" + )) + })?; + let client_keys = RadrootsIdentity::from_secret_key_str(secret.trim()) + .map_err(|error| { + RuntimeError::Config(format!( + "signer.remote_nip46 signer_session_ref `{signer_session_ref}` contains invalid client secret key material: {error}" + )) + })? + .into_keys(); + let bunker = parse_myc_nip46_target(binding.target.as_str())?; + let target = + RadrootsNostrConnectClientTarget::new(bunker.remote_signer_public_key, bunker.relays); + Ok(CliSdkSignerInput::MycNip46 { + client_keys, + target, + actor_pubkey: actor_pubkey.to_owned(), + }) +} + +async fn signer_provider( + config: &RuntimeConfig, + signer_input: CliSdkSignerInput, +) -> Result<RadrootsSdkSignerProvider, RuntimeError> { + match signer_input { + CliSdkSignerInput::LocalKey(keys) => { + let signer = RadrootsSdkLocalKeySigner::new(keys) + .map_err(|error| RuntimeError::Config(error.to_string()))?; + Ok(RadrootsSdkSignerProvider::LocalKey(signer)) + } + CliSdkSignerInput::MycNip46 { + client_keys, + target, + actor_pubkey, + } => { + let transport = Arc::new( + CliSdkNip46RelayTransport::connect( + &client_keys, + &target, + Duration::from_millis(config.myc.status_timeout_ms), + ) + .await?, + ); + let signer = + RadrootsSdkMycNip46Signer::new(client_keys, target, actor_pubkey, transport) + .map_err(|error| RuntimeError::Config(error.to_string()))?; + Ok(RadrootsSdkSignerProvider::MycNip46(signer)) + } + } +} + +fn parse_myc_nip46_target(value: &str) -> Result<RadrootsNostrConnectBunkerUri, RuntimeError> { + let trimmed = value.trim(); + if trimmed.starts_with("nostrconnect://") { + return Err(RuntimeError::Config( + "signer.remote_nip46 target must be a bunker URI or discovery URL; raw nostrconnect client URIs are signer-side only" + .to_owned(), + )); + } + let bunker_uri = if trimmed.starts_with("bunker://") { + trimmed.to_owned() + } else { + let url = Url::parse(trimmed).map_err(|error| { + RuntimeError::Config(format!("signer.remote_nip46 target is invalid: {error}")) + })?; + url.query_pairs() + .find(|(key, _)| key == "uri") + .map(|(_, uri)| uri.into_owned()) + .ok_or_else(|| { + RuntimeError::Config( + "signer.remote_nip46 discovery target is missing `uri` query parameter" + .to_owned(), + ) + })? + }; + match RadrootsNostrConnectUri::parse(bunker_uri.as_str()).map_err(|error| { + RuntimeError::Config(format!("signer.remote_nip46 target is invalid: {error}")) + })? { + RadrootsNostrConnectUri::Bunker(bunker) => Ok(bunker), + RadrootsNostrConnectUri::Client(_) => Err(RuntimeError::Config( + "signer.remote_nip46 target must resolve to a bunker URI; raw nostrconnect client URIs are signer-side only" + .to_owned(), + )), + } +} + +struct CliSdkNip46RelayTransport { + client: RadrootsNostrClient, + notifications: Mutex<broadcast::Receiver<RadrootsNostrRelayPoolNotification>>, + request_timeout: Duration, + deadline: Mutex<Option<Instant>>, +} + +impl CliSdkNip46RelayTransport { + async fn connect( + client_keys: &RadrootsNostrKeys, + target: &RadrootsNostrConnectClientTarget, + request_timeout: Duration, + ) -> Result<Self, RuntimeError> { + if request_timeout.is_zero() { + return Err(RuntimeError::Config( + "RADROOTS_CLI_MYC_STATUS_TIMEOUT_MS must be greater than zero".to_owned(), + )); + } + let client = RadrootsNostrClient::new_signerless(); + for relay in &target.relays { + client.add_relay(relay.as_str()).await.map_err(|error| { + RuntimeError::Network(format!( + "failed to add signer.remote_nip46 relay `{relay}`: {error}" + )) + })?; + } + let connect_output = client.try_connect(request_timeout).await; + if connect_output.success.is_empty() { + let failures = connect_output + .failed + .iter() + .map(|(relay, error)| format!("{relay}: {error}")) + .collect::<Vec<_>>() + .join("; "); + return Err(RuntimeError::Network(if failures.is_empty() { + "failed to connect to signer.remote_nip46 relays".to_owned() + } else { + format!("failed to connect to signer.remote_nip46 relays: {failures}") + })); + } + let filter = radroots_nostr_filter_tag( + RadrootsNostrFilter::new() + .kind(RadrootsNostrKind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND)) + .since(RadrootsNostrTimestamp::now()), + "p", + vec![client_keys.public_key().to_hex()], + ) + .map_err(|error| { + RuntimeError::Config(format!( + "failed to build signer.remote_nip46 filter: {error}" + )) + })?; + let notifications = client.notifications(); + client.subscribe(filter, None).await.map_err(|error| { + RuntimeError::Network(format!( + "failed to subscribe to signer.remote_nip46 response relays: {error}" + )) + })?; + Ok(Self { + client, + notifications: Mutex::new(notifications), + request_timeout, + deadline: Mutex::new(None), + }) + } +} + +impl RadrootsSdkNip46Transport for CliSdkNip46RelayTransport { + fn publish_request_event<'a>( + &'a self, + event: RadrootsNostrEvent, + ) -> RadrootsSdkNip46TransportFuture<'a, ()> { + Box::pin(async move { + *self.deadline.lock().await = Some(Instant::now() + self.request_timeout); + let output = self.client.send_event(&event).await.map_err(|error| { + RadrootsNostrConnectError::Transport { + reason: error.to_string(), + } + })?; + if output.success.is_empty() { + let failures = output + .failed + .iter() + .map(|(relay, error)| format!("{relay}: {error}")) + .collect::<Vec<_>>() + .join("; "); + return Err(RadrootsNostrConnectError::Transport { + reason: if failures.is_empty() { + "signer.remote_nip46 request event was not accepted by any relay".to_owned() + } else { + format!( + "signer.remote_nip46 request event was not accepted by any relay: {failures}" + ) + }, + }); + } + Ok(()) + }) + } + + fn next_response_event<'a>( + &'a self, + ) -> RadrootsSdkNip46TransportFuture<'a, RadrootsNostrEvent> { + Box::pin(async move { + loop { + let Some(deadline) = *self.deadline.lock().await else { + return Err(RadrootsNostrConnectError::Transport { + reason: "signer.remote_nip46 request deadline is not initialized" + .to_owned(), + }); + }; + let now = Instant::now(); + if now >= deadline { + return Err(RadrootsNostrConnectError::RequestTimedOut); + } + let remaining = deadline - now; + let mut notifications = self.notifications.lock().await; + let received = timeout(remaining, notifications.recv()).await; + drop(notifications); + let notification = match received { + Ok(Ok(notification)) => notification, + Ok(Err(broadcast::error::RecvError::Lagged(_))) => continue, + Ok(Err(broadcast::error::RecvError::Closed)) => { + return Err(RadrootsNostrConnectError::Transport { + reason: "signer.remote_nip46 relay notification stream closed" + .to_owned(), + }); + } + Err(_) => return Err(RadrootsNostrConnectError::RequestTimedOut), + }; + let RadrootsNostrRelayPoolNotification::Event { event, .. } = notification else { + continue; + }; + return Ok((*event).clone()); + } + }) + } +} + pub fn sdk_storage_root(config: &RuntimeConfig) -> PathBuf { config.local.root.join(SDK_STORAGE_DIR_NAME) } @@ -343,6 +726,13 @@ mod tests { }, DirectRrRsDependency { section: "dependencies", + name: "radroots_nostr_connect", + owner: "sdk-myc-nip46-transport", + reason: "CLI Myc signer target parsing and NIP-46 relay transport bridge for SDK signing", + lifecycle: "retain while CLI owns signer backend wiring", + }, + DirectRrRsDependency { + section: "dependencies", name: "radroots_nostr_accounts", owner: "cli-account-store", reason: "CLI account selection, import, local signer status, and account persistence", @@ -399,6 +789,13 @@ mod tests { }, DirectRrRsDependency { section: "dependencies", + name: "radroots_protected_store", + owner: "cli-account-store", + reason: "protected file secret vault selection for local account and Myc session material", + lifecycle: "retain while CLI owns account and signer session custody UX", + }, + DirectRrRsDependency { + section: "dependencies", name: "radroots_sp1_host_trade", owner: "validation-receipts", reason: "validation receipt SP1 proof inspection and verification", @@ -418,13 +815,6 @@ mod tests { reason: "listing draft validation, order economics, order reducer helpers, and validation receipt parsing", lifecycle: "retain until remaining trade validation and draft behavior migrates", }, - DirectRrRsDependency { - section: "dev-dependencies", - name: "radroots_protected_store", - owner: "account-tests", - reason: "unit coverage for protected file secret vault behavior", - lifecycle: "test-only", - }, ]; const LEGACY_DIRECT_RELAY_CONSUMERS: &[LegacyDirectRelayConsumer] = &[ @@ -473,7 +863,7 @@ mod tests { end: "#[derive(Debug, Clone)]\nstruct SdkFarmPublishInput", required_tokens: &[ "prepare_publish(FarmPreparePublishRequest::new", - "enqueue_publish(request, &signer)", + "enqueue_publish(request)", "session.sdk().sync().push_outbox", ], }, @@ -518,7 +908,7 @@ mod tests { required_tokens: &[ "prepare_submit(OrderSubmitPrepareRequest::new", "OrderSubmitEnqueueRequest::new", - "enqueue_submit(request, &signer)", + "enqueue_submit_with_explicit_signer(request, &signer)", "push_outbox(", ], }, @@ -530,7 +920,7 @@ mod tests { required_tokens: &[ "OrderDecisionEnqueueRequest::new", "ingest_request_evidence(OrderRequestEvidenceIngestRequest::new", - "enqueue_decision(request, &signer)", + "enqueue_decision_with_explicit_signer(request, &signer)", "push_outbox(", ], }, @@ -544,9 +934,9 @@ mod tests { "prepare_revision_decision(OrderRevisionDecisionPrepareRequest::new", "prepare_cancellation(OrderCancellationPrepareRequest::new", "ingest_order_evidence_events(&session, evidence_events)?", - "enqueue_revision_proposal(request, &signer)", - "enqueue_revision_decision(request, &signer)", - "enqueue_cancellation(request, &signer)", + "enqueue_revision_proposal_with_explicit_signer(request, &signer)", + "enqueue_revision_decision_with_explicit_signer(request, &signer)", + "enqueue_cancellation_with_explicit_signer(request, &signer)", "push_one_sdk_outbox_event(&session, policy)?", ], }, diff --git a/src/runtime/signer.rs b/src/runtime/signer.rs @@ -1,10 +1,13 @@ use crate::runtime::RuntimeError; use crate::runtime::account::AccountRuntimeFailure; use crate::runtime::account::{SHARED_ACCOUNT_STORE_SOURCE, empty_account_resolution_view}; -use crate::runtime::config::{RuntimeConfig, SIGNER_REMOTE_NIP46_CAPABILITY, SignerBackend}; +use crate::runtime::config::{ + CapabilityBindingTargetKind, RuntimeConfig, SIGNER_REMOTE_NIP46_CAPABILITY, SignerBackend, +}; +use crate::runtime::sdk::MYC_NIP46_SESSION_SECRET_SERVICE; use crate::view::runtime::{ - IdentityPublicView, LocalSignerStatusView, SignerBindingStatusView, SignerStatusView, - SignerWriteKindReadinessView, + IdentityPublicView, LocalSignerStatusView, MycStatusView, SignerBindingStatusView, + SignerStatusView, SignerWriteKindReadinessView, }; use radroots_events::kinds::{ KIND_FARM, KIND_LISTING, KIND_ORDER_CANCELLATION, KIND_ORDER_DECISION, KIND_ORDER_REQUEST, @@ -15,11 +18,11 @@ use radroots_nostr_signer::prelude::{ RadrootsNostrLocalSignerAvailability, RadrootsNostrLocalSignerCapability, RadrootsNostrSignerCapability, }; -use serde::{Deserialize, Serialize}; +use radroots_sdk::radroots_sdk_myc_nip46_product_permission_strings; +use url::Url; const SIGNER_BINDING_PROVIDER_RUNTIME_ID: &str = "myc"; const SIGNER_BINDING_MODEL: &str = "session_authorized_remote_signer"; -const MYC_DEFERRED_REASON: &str = "signer mode `myc` is deferred; use signer mode `local`"; #[derive(Debug, Clone, Copy)] struct CliWriteKind { @@ -49,14 +52,6 @@ impl ActorWriteBindingError { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ActorWriteSignerAuthority { - pub provider_runtime_id: String, - pub account_identity_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub provider_session_ref: Option<String>, -} - pub fn resolve_signer_status(config: &RuntimeConfig) -> SignerStatusView { match config.signer.backend { SignerBackend::Local => resolve_local_signer_status(config), @@ -64,20 +59,6 @@ pub fn resolve_signer_status(config: &RuntimeConfig) -> SignerStatusView { } } -pub fn resolve_actor_write_authority( - config: &RuntimeConfig, - _actor_role: &str, - _actor_pubkey: &str, -) -> Result<Option<ActorWriteSignerAuthority>, ActorWriteBindingError> { - if !matches!(config.signer.backend, SignerBackend::Myc) { - return Ok(None); - } - - Err(ActorWriteBindingError::Unconfigured( - MYC_DEFERRED_REASON.to_owned(), - )) -} - fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView { let (account_resolution, resolved_account_id) = match crate::runtime::account::resolve_account_resolution(config) { @@ -242,17 +223,40 @@ fn resolve_myc_signer_status(config: &RuntimeConfig) -> SignerStatusView { Ok(resolution) => crate::runtime::account::account_resolution_view(&resolution), Err(_) => empty_account_resolution_view(), }; + let readiness = myc_binding_readiness(config, None); SignerStatusView { mode: config.signer.backend.as_str().to_owned(), - state: "unconfigured".to_owned(), - source: "target cli signer mode contract".to_owned(), + state: if readiness.ready { + "ready" + } else { + "unconfigured" + } + .to_owned(), + source: readiness.source.clone(), signer_account_id: None, account_resolution, - reason: Some(MYC_DEFERRED_REASON.to_owned()), - binding: deferred_myc_binding_status(), - write_kinds: deferred_write_kind_readiness(), + reason: readiness.reason.clone(), + binding: readiness.binding, + write_kinds: myc_write_kind_readiness(readiness.ready, readiness.reason.clone()), local: None, - myc: None, + myc: Some(MycStatusView { + executable: config.myc.executable.display().to_string(), + state: if readiness.ready { + "ready" + } else { + "unconfigured" + } + .to_owned(), + source: readiness.source, + service_status: None, + ready: readiness.ready, + reason: readiness.reason, + reasons: readiness.reasons, + remote_session_count: usize::from(readiness.signer_session_ref.is_some()), + local_signer: None, + remote_sessions: Vec::new(), + custody: None, + }), } } @@ -275,23 +279,6 @@ fn disabled_binding_status() -> SignerBindingStatusView { } } -fn deferred_myc_binding_status() -> SignerBindingStatusView { - SignerBindingStatusView { - capability_id: SIGNER_REMOTE_NIP46_CAPABILITY.to_owned(), - provider_runtime_id: SIGNER_BINDING_PROVIDER_RUNTIME_ID.to_owned(), - binding_model: SIGNER_BINDING_MODEL.to_owned(), - state: "deferred".to_owned(), - source: "target cli signer mode contract".to_owned(), - target_kind: None, - target: None, - managed_account_ref: None, - signer_session_ref: None, - resolved_session_ref: None, - matched_session_count: None, - reason: Some(MYC_DEFERRED_REASON.to_owned()), - } -} - fn cli_write_kinds() -> [CliWriteKind; 12] { [ CliWriteKind { @@ -361,19 +348,6 @@ fn local_write_kind_readiness( .collect() } -fn deferred_write_kind_readiness() -> Vec<SignerWriteKindReadinessView> { - cli_write_kinds() - .iter() - .map(|kind| SignerWriteKindReadinessView { - command: kind.command.to_owned(), - event_kind: kind.event_kind, - permission: "signer_mode_local_required".to_owned(), - ready: false, - reason: Some(MYC_DEFERRED_REASON.to_owned()), - }) - .collect() -} - fn local_availability(value: RadrootsNostrLocalSignerAvailability) -> &'static str { match value { RadrootsNostrLocalSignerAvailability::PublicOnly => "public_only", @@ -381,6 +355,187 @@ fn local_availability(value: RadrootsNostrLocalSignerAvailability) -> &'static s } } +#[derive(Debug, Clone)] +struct MycBindingReadiness { + binding: SignerBindingStatusView, + ready: bool, + source: String, + reason: Option<String>, + reasons: Vec<String>, + signer_session_ref: Option<String>, +} + +fn myc_binding_readiness( + config: &RuntimeConfig, + actor_pubkey: Option<&str>, +) -> MycBindingReadiness { + let Some(binding) = config.capability_binding(SIGNER_REMOTE_NIP46_CAPABILITY) else { + let reason = "signer.remote_nip46 binding is missing".to_owned(); + return MycBindingReadiness { + binding: missing_myc_binding_status(reason.clone()), + ready: false, + source: "no explicit capability binding".to_owned(), + reason: Some(reason.clone()), + reasons: vec![reason], + signer_session_ref: None, + }; + }; + + let mut reasons = Vec::new(); + if binding.target_kind != CapabilityBindingTargetKind::ExplicitEndpoint { + reasons.push(format!( + "signer.remote_nip46 binding target_kind `{}` is not supported for CLI Myc signing; use `explicit_endpoint`", + binding.target_kind.as_str() + )); + } + if let Err(reason) = validate_myc_target(binding.target.as_str()) { + reasons.push(reason); + } + if let (Some(managed_account_ref), Some(actor_pubkey)) = + (binding.managed_account_ref.as_deref(), actor_pubkey) + { + if managed_account_ref != actor_pubkey { + reasons.push(format!( + "signer.remote_nip46 managed_account_ref `{managed_account_ref}` does not match actor pubkey `{actor_pubkey}`" + )); + } + } + let signer_session_ref = binding.signer_session_ref.clone(); + if let Some(session_ref) = signer_session_ref.as_deref() { + match crate::runtime::account::load_secret_backend_secret( + config, + session_ref, + MYC_NIP46_SESSION_SECRET_SERVICE, + ) { + Ok(Some(secret)) if secret.trim().is_empty() => { + reasons.push(format!( + "signer.remote_nip46 signer_session_ref `{session_ref}` resolved to an empty client secret" + )); + } + Ok(Some(_)) => {} + Ok(None) => { + reasons.push(format!( + "signer.remote_nip46 signer_session_ref `{session_ref}` was not found in the account secret backend" + )); + } + Err(error) => reasons.push(error.to_string()), + } + } else { + reasons.push("signer.remote_nip46 signer_session_ref is missing".to_owned()); + } + + let ready = reasons.is_empty(); + let reason = reasons.first().cloned(); + let source = binding.source.as_str().to_owned(); + MycBindingReadiness { + binding: SignerBindingStatusView { + capability_id: binding.capability_id.clone(), + provider_runtime_id: binding.provider_runtime_id.clone(), + binding_model: binding.binding_model.clone(), + state: if ready { "ready" } else { "unconfigured" }.to_owned(), + source: source.clone(), + target_kind: Some(binding.target_kind.as_str().to_owned()), + target: Some(binding.target.clone()), + managed_account_ref: binding.managed_account_ref.clone(), + signer_session_ref: binding.signer_session_ref.clone(), + resolved_session_ref: binding.signer_session_ref.clone().filter(|_| ready), + matched_session_count: Some(usize::from(ready)), + reason: reason.clone(), + }, + ready, + source, + reason, + reasons, + signer_session_ref, + } +} + +fn missing_myc_binding_status(reason: String) -> SignerBindingStatusView { + SignerBindingStatusView { + capability_id: SIGNER_REMOTE_NIP46_CAPABILITY.to_owned(), + provider_runtime_id: SIGNER_BINDING_PROVIDER_RUNTIME_ID.to_owned(), + binding_model: SIGNER_BINDING_MODEL.to_owned(), + state: "unconfigured".to_owned(), + source: "no explicit capability binding".to_owned(), + target_kind: None, + target: None, + managed_account_ref: None, + signer_session_ref: None, + resolved_session_ref: None, + matched_session_count: Some(0), + reason: Some(reason), + } +} + +fn validate_myc_target(value: &str) -> Result<(), String> { + let trimmed = value.trim(); + if trimmed.starts_with("nostrconnect://") { + return Err( + "signer.remote_nip46 target must be a bunker URI or discovery URL; raw nostrconnect client URIs are signer-side only" + .to_owned(), + ); + } + let bunker_uri = if trimmed.starts_with("bunker://") { + trimmed.to_owned() + } else { + let url = Url::parse(trimmed) + .map_err(|error| format!("signer.remote_nip46 target is invalid: {error}"))?; + url.query_pairs() + .find(|(key, _)| key == "uri") + .map(|(_, uri)| uri.into_owned()) + .ok_or_else(|| { + "signer.remote_nip46 discovery target is missing `uri` query parameter".to_owned() + })? + }; + match radroots_nostr_connect::prelude::RadrootsNostrConnectUri::parse(bunker_uri.as_str()) + .map_err(|error| format!("signer.remote_nip46 target is invalid: {error}"))? + { + radroots_nostr_connect::prelude::RadrootsNostrConnectUri::Bunker(_) => Ok(()), + radroots_nostr_connect::prelude::RadrootsNostrConnectUri::Client(_) => Err( + "signer.remote_nip46 target must resolve to a bunker URI; raw nostrconnect client URIs are signer-side only" + .to_owned(), + ), + } +} + +fn myc_write_kind_readiness( + ready: bool, + reason: Option<String>, +) -> Vec<SignerWriteKindReadinessView> { + let permissions = radroots_sdk_myc_nip46_product_permission_strings(); + cli_write_kinds() + .iter() + .map(|kind| { + let event_kind = kind.event_kind.to_string(); + let permission = permissions + .iter() + .find(|permission| permission.contains(event_kind.as_str())) + .cloned() + .unwrap_or_else(|| format!("sign_event:{event_kind}")); + let permission_ready = ready + && permissions + .iter() + .any(|permission| permission.contains(event_kind.as_str())); + SignerWriteKindReadinessView { + command: kind.command.to_owned(), + event_kind: kind.event_kind, + permission, + ready: permission_ready, + reason: if permission_ready { + None + } else { + reason.clone().or_else(|| { + Some( + "SDK Myc signer permission is not configured for this event kind" + .to_owned(), + ) + }) + }, + } + }) + .collect() +} + #[cfg(test)] mod tests { use super::{ diff --git a/tests/signer_runtime_modes.rs b/tests/signer_runtime_modes.rs @@ -640,20 +640,25 @@ fn watch_only_import_reports_unconfigured_local_signer() { } #[test] -fn myc_signer_status_returns_deferred_signer_error() { +fn myc_signer_status_reports_missing_binding() { let sandbox = RadrootsCliSandbox::new(); let missing_myc = sandbox.root().join("bin/missing-myc"); configure_myc_mode(&sandbox, &missing_myc); let (output, value) = sandbox.json_output(&["--format", "json", "signer", "status", "get"]); - assert!(!output.status.success()); + assert!(output.status.success()); assert_eq!(value["operation_id"], "signer.status.get"); - assert_eq!(value["result"], serde_json::Value::Null); - assert_eq!(value["errors"][0]["code"], "signer_mode_deferred"); - assert_eq!(value["errors"][0]["exit_code"], 7); - assert_eq!(value["errors"][0]["detail"]["class"], "signer"); - assert_contains(&value["errors"][0]["message"], "signer mode `myc`"); + assert!(value["errors"].as_array().expect("errors").is_empty()); + assert_eq!(value["result"]["mode"], "myc"); + assert_eq!(value["result"]["state"], "unconfigured"); + assert_eq!(value["result"]["binding"]["state"], "unconfigured"); + assert_eq!(value["result"]["myc"]["state"], "unconfigured"); + assert_eq!(value["result"]["myc"]["ready"], false); + assert_contains( + &value["result"]["reason"], + "signer.remote_nip46 binding is missing", + ); assert_no_removed_command_reference(&value, &["signer", "status", "get"]); } @@ -674,9 +679,11 @@ fn myc_signer_status_does_not_invoke_configured_executable() { let (output, value) = sandbox.json_output(&["--format", "json", "signer", "status", "get"]); - assert!(!output.status.success()); + assert!(output.status.success()); assert_eq!(value["operation_id"], "signer.status.get"); - assert_eq!(value["errors"][0]["code"], "signer_mode_deferred"); + assert!(value["errors"].as_array().expect("errors").is_empty()); + assert_eq!(value["result"]["state"], "unconfigured"); + assert_eq!(value["result"]["myc"]["ready"], false); assert!(!invoked.exists(), "target CLI must not execute MYC"); } @@ -1233,7 +1240,7 @@ fn local_farm_publish_reports_sdk_push_failure_without_profile_publish() { "SDK relay publish did not reach accepted quorum", ); let detail = &value["errors"][0]["detail"]; - assert_eq!(detail["source"], "SDK farm publish · local key"); + assert_eq!(detail["source"], "SDK farm publish · configured signer"); assert_eq!(detail["state"], "unavailable"); assert_eq!(detail["profile"]["state"], "not_submitted"); assert_eq!(detail["farm"]["state"], "unavailable"); @@ -1311,7 +1318,7 @@ fn local_farm_publish_does_not_persist_publication_until_sdk_push_publishes() { assert_eq!(value["errors"][0]["code"], "network_unavailable"); assert_eq!(value["errors"][0]["detail"]["class"], "network"); let detail = &value["errors"][0]["detail"]; - assert_eq!(detail["source"], "SDK farm publish · local key"); + assert_eq!(detail["source"], "SDK farm publish · configured signer"); assert_eq!(detail["profile"]["state"], "not_submitted"); assert_eq!(detail["farm"]["state"], "unavailable"); assert_eq!(detail["profile"]["event_id"], serde_json::Value::Null); @@ -1913,7 +1920,7 @@ fn local_seller_publish_commands_attempt_configured_relay() { ); assert_eq!( farm_value["errors"][0]["detail"]["source"], - "SDK farm publish · local key" + "SDK farm publish · configured signer" ); assert_eq!( farm_value["errors"][0]["detail"]["farm"]["target_relays"][0], @@ -2399,10 +2406,13 @@ fn myc_listing_publish_does_not_fallback_to_local_account() { 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"], "signer_mode_deferred"); + assert_eq!(value["errors"][0]["code"], "signer_unconfigured"); assert_eq!(value["errors"][0]["exit_code"], 7); assert_eq!(value["errors"][0]["detail"]["class"], "signer"); - assert_contains(&value["errors"][0]["message"], "signer mode `myc`"); + assert_contains( + &value["errors"][0]["message"], + "signer.remote_nip46 binding is missing", + ); assert!(!invoked.exists(), "target CLI must not execute MYC"); } diff --git a/tests/support/mod.rs b/tests/support/mod.rs @@ -15,7 +15,9 @@ use radroots_local_events::{ LocalRecordStatus, PublishOutboxStatus, RelayDeliveryEvidence, SourceRuntime, canonical_relay_set_fingerprint, }; +use radroots_protected_store::RadrootsProtectedFileSecretVault; use radroots_replica_sync::{RadrootsReplicaIngestOutcome, radroots_replica_ingest_event}; +use radroots_secret_vault::RadrootsSecretVault; use radroots_sql_core::{SqlExecutor, SqliteExecutor}; use serde_json::{Value, json}; use tempfile::TempDir; @@ -480,6 +482,14 @@ pub fn identity_secret(seed: u8) -> RadrootsIdentity { RadrootsIdentity::from_secret_key_bytes(&secret).expect("fixture identity") } +pub fn store_test_session_secret(sandbox: &RadrootsCliSandbox, slot: &str, secret: &str) { + let vault = + RadrootsProtectedFileSecretVault::new(sandbox.root().join("secrets/shared/accounts")); + vault + .store_secret(slot, secret) + .expect("store test session secret"); +} + pub fn make_listing_publishable(path: &Path, farm_d_tag: &str) { let raw = fs::read_to_string(path).expect("listing draft"); let mut seller_pubkey_present = false; diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -4,22 +4,33 @@ use std::fs; use std::io::{Read, Write}; use std::net::{TcpListener, TcpStream}; use std::path::{Path, PathBuf}; +use std::sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, +}; use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant}; +use nostr::nips::nip44::{self, Version}; +use nostr::{EventBuilder, Keys, Kind, PublicKey, SecretKey, Tag}; use radroots_events::RadrootsNostrEventPtr; use radroots_events::ids::{ RadrootsInventoryBinId, RadrootsListingAddress, RadrootsOrderId, RadrootsPublicKey, }; -use radroots_events::kinds::KIND_ORDER_REQUEST; +use radroots_events::kinds::{KIND_LISTING, KIND_ORDER_REQUEST}; use radroots_events::order::{RadrootsOrderEconomics, RadrootsOrderItem, RadrootsOrderRequest}; use radroots_events_codec::order::order_request_event_build; +use radroots_identity::RadrootsIdentity; use radroots_local_events::{ BUYER_ORDER_REQUEST_LOCAL_WORK_RECORD_KIND, LocalEventRecordInput, LocalEventsStore, LocalRecordFamily, LocalRecordStatus, PublishOutboxStatus, RelayDeliveryEvidence, SourceRuntime, canonical_relay_set_fingerprint, }; use radroots_nostr::prelude::{RadrootsNostrEvent, radroots_nostr_build_event}; +use radroots_nostr_connect::prelude::{ + RADROOTS_NOSTR_CONNECT_RPC_KIND, RadrootsNostrConnectRequest, + RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse, +}; use radroots_replica_db::{farm, migrations}; use radroots_replica_db_schema::farm::IFarmFields; use radroots_replica_sync::radroots_replica_pending_publish_batch; @@ -32,9 +43,10 @@ use support::{ assert_no_daemon_runtime_reference, assert_no_removed_command_reference, create_listing_draft, duplicate_orderable_listing_row, identity_public, identity_secret, json_from_stdout, make_listing_publishable, ndjson_from_stdout, radroots, remove_orderable_listing, - replace_latest_listing_event_id, seed_orderable_listing, toml_string, - update_orderable_listing_available_amount, update_orderable_listing_primary_bin_id, - write_public_identity_profile, write_secret_identity_profile, + replace_latest_listing_event_id, seed_orderable_listing, store_test_session_secret, + toml_string, update_orderable_listing_available_amount, + update_orderable_listing_primary_bin_id, write_public_identity_profile, + write_secret_identity_profile, }; const LISTING_ADDR: &str = @@ -130,6 +142,345 @@ impl RadrootsdProxyJsonRpcServer { } } +#[derive(Clone, Copy)] +enum Nip46RelayFinish { + SignResponse, + ProductPublish, +} + +struct Nip46RelayReport { + connection_count: usize, + req_count: usize, + sign_request_count: usize, + published_events: Vec<RadrootsNostrEvent>, +} + +struct Nip46RelayState { + connection_count: Mutex<usize>, + req_count: Mutex<usize>, + sign_request_count: Mutex<usize>, + published_events: Mutex<Vec<RadrootsNostrEvent>>, + pending_responses: Mutex<Vec<RadrootsNostrEvent>>, + done: AtomicBool, + finish: Nip46RelayFinish, +} + +struct Nip46RelayServer { + endpoint: String, + state: Arc<Nip46RelayState>, + handle: JoinHandle<()>, +} + +impl Nip46RelayServer { + fn new(remote_keys: Keys, user_keys: Keys, finish: Nip46RelayFinish) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind nip46 relay"); + listener.set_nonblocking(true).expect("nip46 nonblocking"); + let endpoint = format!("ws://{}", listener.local_addr().expect("nip46 addr")); + let state = Arc::new(Nip46RelayState { + connection_count: Mutex::new(0), + req_count: Mutex::new(0), + sign_request_count: Mutex::new(0), + published_events: Mutex::new(Vec::new()), + pending_responses: Mutex::new(Vec::new()), + done: AtomicBool::new(false), + finish, + }); + let thread_state = Arc::clone(&state); + let remote_keys = Arc::new(remote_keys); + let user_keys = Arc::new(user_keys); + let handle = thread::spawn(move || { + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline && !thread_state.done.load(Ordering::SeqCst) { + match listener.accept() { + Ok((stream, _)) => { + *thread_state + .connection_count + .lock() + .expect("connection count lock") += 1; + let connection_state = Arc::clone(&thread_state); + let remote_keys = Arc::clone(&remote_keys); + let user_keys = Arc::clone(&user_keys); + thread::spawn(move || { + handle_nip46_relay_connection( + stream, + (*remote_keys).clone(), + (*user_keys).clone(), + connection_state, + ); + }); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(10)); + } + Err(error) => panic!("accept nip46 relay connection: {error}"), + } + } + assert!( + thread_state.done.load(Ordering::SeqCst), + "timed out waiting for NIP-46 relay proof; connections {}; req {}; sign requests {}; published events {}", + *thread_state + .connection_count + .lock() + .expect("connection count lock"), + *thread_state.req_count.lock().expect("req count lock"), + *thread_state + .sign_request_count + .lock() + .expect("sign count lock"), + thread_state + .published_events + .lock() + .expect("published events lock") + .len(), + ); + }); + Self { + endpoint, + state, + handle, + } + } + + fn endpoint(&self) -> &str { + self.endpoint.as_str() + } + + fn join(self) -> Nip46RelayReport { + self.handle.join().expect("nip46 relay server join"); + Nip46RelayReport { + connection_count: *self + .state + .connection_count + .lock() + .expect("connection count lock"), + req_count: *self.state.req_count.lock().expect("req count lock"), + sign_request_count: *self + .state + .sign_request_count + .lock() + .expect("sign count lock"), + published_events: self + .state + .published_events + .lock() + .expect("published events lock") + .clone(), + } + } +} + +fn handle_nip46_relay_connection( + stream: TcpStream, + remote_keys: Keys, + user_keys: Keys, + state: Arc<Nip46RelayState>, +) { + stream + .set_nonblocking(false) + .expect("nip46 blocking stream"); + let mut websocket = tungstenite::accept(stream).expect("accept nip46 websocket"); + let mut subscriptions = Vec::<String>::new(); + loop { + let message = match websocket.read() { + Ok(message) => message, + Err(_) => return, + }; + if !message.is_text() { + continue; + } + let value: Value = + serde_json::from_str(message.to_text().expect("nip46 text")).expect("nip46 json"); + match value.get(0).and_then(Value::as_str) { + Some("REQ") => { + *state.req_count.lock().expect("req count lock") += 1; + let subscription_id = value + .get(1) + .and_then(Value::as_str) + .expect("nip46 subscription id") + .to_owned(); + subscriptions.push(subscription_id.clone()); + websocket + .send(tungstenite::Message::Text( + json!(["EOSE", subscription_id]).to_string().into(), + )) + .expect("send nip46 eose"); + send_pending_nip46_responses(&mut websocket, subscription_id.as_str(), &state); + } + Some("CLOSE") => {} + Some("EVENT") => { + let event: RadrootsNostrEvent = + serde_json::from_value(value.get(1).cloned().expect("nip46 relay event")) + .expect("parse nip46 relay event"); + if event.kind == Kind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND) { + handle_nip46_sign_request( + &mut websocket, + &subscriptions, + event, + &remote_keys, + &user_keys, + &state, + ); + } else { + handle_nip46_product_publish(&mut websocket, event, &state); + } + } + _ => {} + } + } +} + +fn handle_nip46_sign_request( + websocket: &mut tungstenite::WebSocket<TcpStream>, + subscriptions: &[String], + event: RadrootsNostrEvent, + remote_keys: &Keys, + user_keys: &Keys, + state: &Nip46RelayState, +) { + send_relay_ok(websocket, &event); + let decrypted = nip44::decrypt(remote_keys.secret_key(), &event.pubkey, &event.content) + .expect("decrypt nip46 request"); + let request: RadrootsNostrConnectRequestMessage = + serde_json::from_str(&decrypted).expect("decode nip46 request"); + let response = match request.request { + RadrootsNostrConnectRequest::SignEvent(unsigned_event) => { + let signed = unsigned_event + .sign_with_keys(user_keys) + .expect("sign nip46 request"); + RadrootsNostrConnectResponse::SignedEvent(signed) + } + other => RadrootsNostrConnectResponse::Error { + result: None, + error: format!("unexpected test NIP-46 method `{}`", other.method()), + }, + }; + let response_event = + nip46_response_event(remote_keys, event.pubkey, request.id.as_str(), response); + state + .pending_responses + .lock() + .expect("pending response lock") + .push(response_event.clone()); + for subscription_id in subscriptions { + send_nip46_response(websocket, subscription_id.as_str(), &response_event); + } + *state + .sign_request_count + .lock() + .expect("sign request count lock") += 1; + if matches!(state.finish, Nip46RelayFinish::SignResponse) { + state.done.store(true, Ordering::SeqCst); + } +} + +fn send_pending_nip46_responses( + websocket: &mut tungstenite::WebSocket<TcpStream>, + subscription_id: &str, + state: &Nip46RelayState, +) { + let responses = state + .pending_responses + .lock() + .expect("pending response lock") + .clone(); + for response in responses { + send_nip46_response(websocket, subscription_id, &response); + } +} + +fn send_nip46_response( + websocket: &mut tungstenite::WebSocket<TcpStream>, + subscription_id: &str, + response_event: &RadrootsNostrEvent, +) { + websocket + .send(tungstenite::Message::Text( + json!(["EVENT", subscription_id, response_event]) + .to_string() + .into(), + )) + .expect("send nip46 response event"); +} + +fn handle_nip46_product_publish( + websocket: &mut tungstenite::WebSocket<TcpStream>, + event: RadrootsNostrEvent, + state: &Nip46RelayState, +) { + send_relay_ok(websocket, &event); + state + .published_events + .lock() + .expect("published events lock") + .push(event); + if matches!(state.finish, Nip46RelayFinish::ProductPublish) { + state.done.store(true, Ordering::SeqCst); + } +} + +fn nip46_response_event( + remote_keys: &Keys, + client_public_key: PublicKey, + request_id: &str, + response: RadrootsNostrConnectResponse, +) -> RadrootsNostrEvent { + let envelope = response + .into_envelope(request_id) + .expect("nip46 response envelope"); + let payload = serde_json::to_string(&envelope).expect("nip46 response payload"); + let ciphertext = nip44::encrypt( + remote_keys.secret_key(), + &client_public_key, + payload, + Version::V2, + ) + .expect("nip46 response ciphertext"); + EventBuilder::new(Kind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND), ciphertext) + .tag(Tag::public_key(client_public_key)) + .sign_with_keys(remote_keys) + .expect("nip46 response event") +} + +fn send_relay_ok(websocket: &mut tungstenite::WebSocket<TcpStream>, event: &RadrootsNostrEvent) { + websocket + .send(tungstenite::Message::Text( + json!(["OK", event.id.to_hex(), true, ""]) + .to_string() + .into(), + )) + .expect("send relay ok"); +} + +fn nostr_keys_from_identity(identity: &RadrootsIdentity) -> Keys { + let secret_key_hex = identity.secret_key_hex(); + Keys::new(SecretKey::from_hex(secret_key_hex.as_str()).expect("secret key")) +} + +fn myc_nip46_config( + remote_signer_pubkey: &str, + relay_endpoint: &str, + managed_account_ref: &str, + session_ref: &str, +) -> String { + format!( + r#"[signer] +backend = "myc" + +[[capability_binding]] +capability = "signer.remote_nip46" +provider = "myc" +target_kind = "explicit_endpoint" +target = "bunker://{}?relay={}" +managed_account_ref = "{}" +signer_session_ref = "{}" +"#, + toml_string(remote_signer_pubkey), + toml_string(relay_endpoint), + toml_string(managed_account_ref), + toml_string(session_ref), + ) +} + fn handle_radrootsd_proxy_connection(mut stream: TcpStream, expected_token: &str) -> Value { let mut bytes = Vec::new(); let mut buffer = [0_u8; 1024]; @@ -1055,7 +1406,7 @@ fn config_get_radrootsd_proxy_with_token_file_reports_ready_transport() { } #[test] -fn config_get_marks_radrootsd_proxy_unavailable_with_myc_signer() { +fn config_get_marks_radrootsd_proxy_unconfigured_with_incomplete_myc_signer() { let sandbox = RadrootsCliSandbox::new(); sandbox.write_app_config( r#"[publish] @@ -1084,12 +1435,12 @@ signer_session_ref = "session_ready" assert!(output.status.success()); assert_eq!(value["operation_id"], "config.get"); assert_eq!(value["result"]["publish"]["transport"], "radrootsd_proxy"); - assert_eq!(value["result"]["publish"]["state"], "unavailable"); + assert_eq!(value["result"]["publish"]["state"], "unconfigured"); assert_eq!(value["result"]["publish"]["executable"], false); - assert_contains(&value["result"]["publish"]["reason"], "signer mode `local`"); + assert_contains(&value["result"]["publish"]["reason"], "signer.remote_nip46"); assert_eq!( value["result"]["publish"]["provider"]["state"], - "unavailable" + "unconfigured" ); assert_eq!(value["result"]["actions"][0], "radroots signer status get"); } @@ -1183,7 +1534,7 @@ fn config_get_marks_relay_publish_ready_with_secret_backed_local_account() { } #[test] -fn config_get_marks_relay_publish_unavailable_with_deferred_signer_mode() { +fn config_get_marks_relay_publish_unconfigured_with_missing_myc_binding() { let sandbox = RadrootsCliSandbox::new(); sandbox.json_success(&["--format", "json", "account", "create"]); sandbox.write_app_config("[signer]\nbackend = \"myc\"\n"); @@ -1203,12 +1554,15 @@ fn config_get_marks_relay_publish_unavailable_with_deferred_signer_mode() { ); assert_eq!(value["result"]["publish"]["relay"]["ready"], true); assert_eq!(value["result"]["publish"]["signed_write_required"], true); - assert_eq!(value["result"]["publish"]["state"], "unavailable"); + assert_eq!(value["result"]["publish"]["state"], "unconfigured"); assert_eq!(value["result"]["publish"]["executable"], false); - assert_contains(&value["result"]["publish"]["reason"], "signer mode `local`"); + assert_contains( + &value["result"]["publish"]["reason"], + "signer.remote_nip46 binding is missing", + ); assert_eq!( value["result"]["publish"]["provider"]["state"], - "unavailable" + "unconfigured" ); } @@ -1246,7 +1600,7 @@ fn config_get_marks_relay_publish_unconfigured_with_watch_only_account() { } #[test] -fn health_surfaces_publish_state_under_deferred_signer_mode() { +fn health_surfaces_publish_state_under_missing_myc_binding() { let sandbox = RadrootsCliSandbox::new(); let missing_myc = sandbox.root().join("bin/missing-myc"); let token_file = radrootsd_proxy_token_file(&sandbox); @@ -1264,16 +1618,19 @@ fn health_surfaces_publish_state_under_deferred_signer_mode() { assert_eq!(value["result"]["publish"]["executable"], false); assert_eq!( value["result"]["publish"]["provider"]["state"], - "unavailable" + "unconfigured" + ); + assert_contains( + &value["result"]["publish"]["reason"], + "signer.remote_nip46 binding is missing", ); - assert_contains(&value["result"]["publish"]["reason"], "signer mode `local`"); assert_eq!(value["result"]["store"]["state"], "ready"); assert_eq!( value["result"]["store"]["source"], "SDK canonical event store and outbox" ); assert_eq!(value["result"]["store"]["canonical_store"], "sdk"); - assert_eq!(value["result"]["signer"]["state"], "unavailable"); + assert_eq!(value["result"]["signer"]["state"], "unconfigured"); assert_eq!(value["result"]["actions"][0], "radroots account create"); assert_eq!(value["result"]["actions"][1], "radroots signer status get"); assert_eq!( @@ -1546,7 +1903,10 @@ fn radrootsd_proxy_listing_publish_update_and_archive_dry_run_without_direct_rel assert!(output.status.success()); assert_eq!(value["operation_id"], format!("listing.{operation}")); assert_eq!(value["result"]["state"], "dry_run"); - assert_eq!(value["result"]["source"], "SDK listing publish · local key"); + assert_eq!( + value["result"]["source"], + "SDK listing publish · configured signer" + ); assert_eq!(value["result"]["dry_run"], true); assert_eq!( value["result"]["target_relays"] @@ -1628,7 +1988,10 @@ token_file = "{}" ); assert_eq!(value["operation_id"], "listing.publish"); assert_eq!(value["result"]["state"], "published"); - assert_eq!(value["result"]["source"], "SDK listing publish · local key"); + assert_eq!( + value["result"]["source"], + "SDK listing publish · configured signer" + ); assert_eq!(value["result"]["dry_run"], false); assert_eq!( request["params"]["event"]["id"], @@ -1661,6 +2024,242 @@ token_file = "{}" } #[test] +fn direct_relay_listing_publish_uses_myc_nip46_sdk_signer() { + let sandbox = RadrootsCliSandbox::new(); + let user_identity = identity_secret(90); + let client_identity = identity_secret(91); + let remote_identity = identity_secret(92); + let user_public = user_identity.to_public(); + let user_keys = nostr_keys_from_identity(&user_identity); + let remote_keys = nostr_keys_from_identity(&remote_identity); + let remote_pubkey = remote_keys.public_key().to_hex(); + let relay = Nip46RelayServer::new( + remote_keys.clone(), + user_keys, + Nip46RelayFinish::ProductPublish, + ); + let relay_endpoint = relay.endpoint().to_owned(); + let public_identity_file = + write_public_identity_profile(&sandbox, "myc-direct-user", &user_public); + let imported = sandbox.json_success(&[ + "--format", + "json", + "--approval-token", + "approve", + "account", + "import", + "--default", + public_identity_file.to_string_lossy().as_ref(), + ]); + let account_id = imported["result"]["account"]["id"] + .as_str() + .expect("account id"); + store_test_session_secret( + &sandbox, + "session_ready", + client_identity.secret_key_hex().as_str(), + ); + sandbox.write_app_config( + myc_nip46_config( + remote_pubkey.as_str(), + relay_endpoint.as_str(), + account_id, + "session_ready", + ) + .as_str(), + ); + let signer = sandbox.json_success(&["--format", "json", "signer", "status", "get"]); + assert_eq!(signer["result"]["state"], "ready"); + assert_eq!(signer["result"]["binding"]["state"], "ready"); + let farm = sandbox.json_success(&[ + "--format", + "json", + "farm", + "create", + "--name", + "Myc Relay Farm", + "--location", + "farmstand", + "--country", + "US", + "--delivery-method", + "pickup", + ]); + let listing_file = create_listing_draft(&sandbox, "myc-direct-relay-live"); + make_listing_publishable( + &listing_file, + farm["result"]["config"]["farm_d_tag"] + .as_str() + .expect("farm d tag"), + ); + + let output = sandbox + .command() + .args([ + "--format", + "json", + "--approval-token", + "approve", + "--relay", + relay_endpoint.as_str(), + "listing", + "publish", + listing_file.to_string_lossy().as_ref(), + ]) + .output() + .expect("run myc direct listing publish"); + let value: Value = serde_json::from_slice(&output.stdout).expect("json output"); + + assert!( + output.status.success(), + "stderr `{}` stdout `{}`", + String::from_utf8_lossy(&output.stderr), + String::from_utf8_lossy(&output.stdout) + ); + let report = relay.join(); + assert_eq!(value["operation_id"], "listing.publish"); + assert_eq!(value["result"]["state"], "published"); + assert_eq!( + value["result"]["source"], + "SDK listing publish · configured signer" + ); + assert_eq!(value["result"]["target_relays"][0], relay_endpoint); + assert!(report.connection_count >= 1); + assert!(report.req_count >= 1); + assert_eq!(report.sign_request_count, 1); + assert_eq!(report.published_events.len(), 1); + let published = &report.published_events[0]; + assert_eq!(published.kind, Kind::Custom(KIND_LISTING as u16)); + assert_eq!(published.pubkey.to_hex(), user_public.public_key_hex); + assert_eq!( + published.id.to_hex(), + value["result"]["event_id"].as_str().expect("event id") + ); +} + +#[test] +fn radrootsd_proxy_listing_publish_uses_myc_nip46_sdk_signer() { + let sandbox = RadrootsCliSandbox::new(); + let user_identity = identity_secret(93); + let client_identity = identity_secret(94); + let remote_identity = identity_secret(95); + let user_public = user_identity.to_public(); + let user_keys = nostr_keys_from_identity(&user_identity); + let remote_keys = nostr_keys_from_identity(&remote_identity); + let remote_pubkey = remote_keys.public_key().to_hex(); + let relay = Nip46RelayServer::new( + remote_keys.clone(), + user_keys, + Nip46RelayFinish::SignResponse, + ); + let proxy = RadrootsdProxyJsonRpcServer::once("proxy_test_token"); + let token_file = radrootsd_proxy_token_file(&sandbox); + let public_identity_file = + write_public_identity_profile(&sandbox, "myc-proxy-user", &user_public); + let imported = sandbox.json_success(&[ + "--format", + "json", + "--approval-token", + "approve", + "account", + "import", + "--default", + public_identity_file.to_string_lossy().as_ref(), + ]); + let account_id = imported["result"]["account"]["id"] + .as_str() + .expect("account id"); + store_test_session_secret( + &sandbox, + "session_ready", + client_identity.secret_key_hex().as_str(), + ); + let config = format!( + r#"[publish] +transport = "radrootsd_proxy" + +[publish.radrootsd_proxy] +url = "{}" +token_file = "{}" + +{}"#, + toml_string(proxy.endpoint()), + toml_string(token_file.display().to_string().as_str()), + myc_nip46_config( + remote_pubkey.as_str(), + relay.endpoint(), + account_id, + "session_ready", + ), + ); + sandbox.write_app_config(config.as_str()); + let farm = sandbox.json_success(&[ + "--format", + "json", + "farm", + "create", + "--name", + "Myc Proxy Farm", + "--location", + "farmstand", + "--country", + "US", + "--delivery-method", + "pickup", + ]); + let listing_file = create_listing_draft(&sandbox, "myc-radrootsd-proxy-live"); + make_listing_publishable( + &listing_file, + farm["result"]["config"]["farm_d_tag"] + .as_str() + .expect("farm d tag"), + ); + + let output = sandbox + .command() + .args([ + "--format", + "json", + "--approval-token", + "approve", + "listing", + "publish", + listing_file.to_string_lossy().as_ref(), + ]) + .output() + .expect("run myc proxy listing publish"); + let value: Value = serde_json::from_slice(&output.stdout).expect("json output"); + + assert!( + output.status.success(), + "stderr `{}` stdout `{}`", + String::from_utf8_lossy(&output.stderr), + String::from_utf8_lossy(&output.stdout) + ); + let report = relay.join(); + let request = proxy.join(); + assert_eq!(value["operation_id"], "listing.publish"); + assert_eq!(value["result"]["state"], "published"); + assert_eq!( + value["result"]["source"], + "SDK listing publish · configured signer" + ); + assert!(report.connection_count >= 1); + assert!(report.req_count >= 1); + assert_eq!(report.sign_request_count, 1); + assert_eq!(report.published_events.len(), 0); + assert_eq!(request["params"]["event"]["kind"], KIND_LISTING); + assert_eq!( + request["params"]["event"]["pubkey"], + user_public.public_key_hex + ); + assert_eq!( + request["params"]["event"]["id"], + value["result"]["event_id"] + ); +} + +#[test] fn listing_update_publish_attempts_direct_relay_with_approval() { let sandbox = RadrootsCliSandbox::new(); sandbox.json_success(&["--format", "json", "account", "create"]); @@ -2581,7 +3180,7 @@ fn offline_listing_publish_enqueues_sdk_outbox_without_direct_relay_push() { assert_eq!(publish["result"]["state"], "queued"); assert_eq!( publish["result"]["source"], - "SDK listing publish · local key" + "SDK listing publish · configured signer" ); assert_eq!(publish["result"]["target_relays"][0], relay); assert_eq!(publish["result"]["actions"][0], "radroots sync push"); @@ -5022,7 +5621,7 @@ fn farm_publish_uses_sdk_outbox_without_legacy_signed_event_records() { assert_eq!(publish["result"], serde_json::Value::Null); assert_eq!(publish["errors"][0]["code"], "network_unavailable"); let detail = &publish["errors"][0]["detail"]; - assert_eq!(detail["source"], "SDK farm publish · local key"); + assert_eq!(detail["source"], "SDK farm publish · configured signer"); assert_eq!(detail["state"], "unavailable"); assert_eq!(detail["profile"]["state"], "not_submitted"); assert_eq!(detail["profile"]["event_id"], serde_json::Value::Null); @@ -5089,7 +5688,7 @@ fn listing_publish_failure_uses_sdk_outbox_without_legacy_local_event_record() { assert_eq!(publish["errors"][0]["code"], "network_unavailable"); assert_eq!( publish["errors"][0]["detail"]["source"], - "SDK listing publish · local key" + "SDK listing publish · configured signer" ); assert_eq!(publish["errors"][0]["detail"]["state"], "unavailable"); assert_eq!(