app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 93ef5e2db2f3fdb13d34195cd87941aa6098c029
parent 740a5278449c514b96a3bb19436cbbda8b5334ea
Author: triesap <tyson@radroots.org>
Date:   Mon, 25 May 2026 02:53:01 +0000

sync: bind publish signing to payload account

- resolve direct relay publish custody from each AppPublishContext account id
- reject missing watch-only and mismatched local signer state before publish
- prove ambient default account drift cannot retarget signed events
- keep direct relay partial result behavior intact for failed publish work

Diffstat:
Mcrates/launchers/desktop/src/runtime.rs | 213++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
1 file changed, 196 insertions(+), 17 deletions(-)

diff --git a/crates/launchers/desktop/src/runtime.rs b/crates/launchers/desktop/src/runtime.rs @@ -59,7 +59,7 @@ use radroots_core::{ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit, }; -use radroots_identity::RadrootsIdentity; +use radroots_identity::{RadrootsIdentity, RadrootsIdentityId}; use radroots_local_events::{ BUYER_ORDER_REQUEST_ACTOR_SOURCE_RESOLVED_ACCOUNT, BUYER_ORDER_REQUEST_ACTOR_SOURCE_UNRESOLVED_APP, BUYER_ORDER_REQUEST_DOCUMENT_KIND, @@ -159,21 +159,17 @@ impl SdkDirectRelayAppSyncTransport { request: AppSyncRequest, ) -> Result<AppSyncResult, AppSyncTransportError> { let run_started_at = current_utc_timestamp(); - let identity = self - .accounts_manager - .default_signing_identity() - .map_err(|error| AppSyncTransportError::failed(error.to_string()))? - .ok_or_else(|| { - AppSyncTransportError::unavailable( - "selected account is not backed by a local signing key", - ) - })?; let relay_urls = normalized_app_sync_relay_urls(&self.relay_urls)?; let client = direct_relay_sdk_client(relay_urls.clone(), self.timeout_ms)?; let mut published_receipts = Vec::new(); for operation in &request.pending_operations { - match publish_pending_sync_operation(&client, &identity, operation, &relay_urls) { + match publish_pending_sync_operation( + &client, + &self.accounts_manager, + operation, + &relay_urls, + ) { Ok(receipt) => published_receipts.push(receipt), Err(error) => { if published_receipts.is_empty() { @@ -206,7 +202,7 @@ impl SdkDirectRelayAppSyncTransport { fn publish_pending_sync_operation( client: &RadrootsSdkClient, - identity: &RadrootsIdentity, + accounts_manager: &RadrootsNostrAccountsManager, operation: &PendingSyncOperation, relay_urls: &[String], ) -> Result<AppPublishedOperationReceipt, AppSyncTransportError> { @@ -231,10 +227,55 @@ fn publish_pending_sync_operation( "pending app publish work is blocked: {reason_codes}" )) })?; - let receipt = publish_app_payload_sync(client, identity, &publish_payload, relay_urls)?; + let identity = signing_identity_for_publish_payload(accounts_manager, &publish_payload)?; + let receipt = publish_app_payload_sync(client, &identity, &publish_payload, relay_urls)?; published_operation_receipt(operation.operation_key.as_str(), &publish_payload, receipt) } +fn signing_identity_for_publish_payload( + accounts_manager: &RadrootsNostrAccountsManager, + publish_payload: &AppPublishPayload, +) -> Result<RadrootsIdentity, AppSyncTransportError> { + let context = publish_payload_context(publish_payload); + let account_id = RadrootsIdentityId::parse(context.account_id.trim()).map_err(|error| { + AppSyncTransportError::failed(format!( + "pending app publish work has invalid account context: {error}" + )) + })?; + let record = accounts_manager + .list_accounts() + .map_err(|error| AppSyncTransportError::failed(error.to_string()))? + .into_iter() + .find(|record| record.account_id == account_id) + .ok_or_else(|| { + AppSyncTransportError::unavailable(format!( + "publish account is not configured locally: {account_id}" + )) + })?; + let identity = accounts_manager + .get_signing_identity(&account_id) + .map_err(|error| AppSyncTransportError::failed(error.to_string()))? + .ok_or_else(|| { + AppSyncTransportError::unavailable(format!( + "publish account is not backed by a local signing key: {account_id}" + )) + })?; + if identity.public_key_hex() != record.public_identity.public_key_hex { + return Err(AppSyncTransportError::failed( + "publish account signing key does not match account context", + )); + } + Ok(identity) +} + +fn publish_payload_context(publish_payload: &AppPublishPayload) -> &AppPublishContext { + match publish_payload { + AppPublishPayload::FarmProfile(payload) => &payload.context, + AppPublishPayload::Listing(payload) => &payload.context, + AppPublishPayload::OrderRequest(payload) => &payload.context, + } +} + fn partial_failed_sync_result( request: &AppSyncRequest, published_receipts: Vec<AppPublishedOperationReceipt>, @@ -7019,7 +7060,8 @@ mod tests { }; use radroots_nostr_accounts::prelude::{ RadrootsNostrAccountsManager, RadrootsNostrFileAccountStore, - RadrootsNostrMemoryAccountStore, RadrootsNostrSecretVaultMemory, + RadrootsNostrMemoryAccountStore, RadrootsNostrSecretVaultMemory, RadrootsSecretVault, + account_secret_slot, }; use radroots_sql_core::SqliteExecutor; use serde_json::json; @@ -7287,12 +7329,63 @@ mod tests { } #[test] - fn runtime_direct_relay_transport_requires_local_signing_custody() { + fn runtime_direct_relay_transport_signs_with_payload_account_context() { + let relay = ThreadedAckRelay::spawn(); + let manager = RadrootsNostrAccountsManager::new_in_memory(); + let first_account_id = manager + .generate_identity(Some("First".to_owned()), true) + .expect("first account"); + let second_account_id = manager + .generate_identity(Some("Second".to_owned()), true) + .expect("second account"); + let first_identity = manager + .get_signing_identity(&first_account_id) + .expect("first signer") + .expect("first local signer"); + let second_identity = manager + .get_signing_identity(&second_account_id) + .expect("second signer") + .expect("second local signer"); + let payload = AppPublishPayload::FarmProfile(AppFarmProfilePublishPayload { + context: AppPublishContext::new(first_account_id.to_string(), "farm_setup"), + farm_id: FarmId::new(), + display_name: "North field farm".to_owned(), + readiness: Some(FarmReadiness::Ready), + }); + let operation = PendingSyncOperation::from_publish_payload(payload, "2026-05-24T12:00:00Z") + .expect("typed farm publish work should serialize"); + let mut transport = + SdkDirectRelayAppSyncTransport::with_relay_urls(manager, vec![relay.url().to_owned()]); + + let result = transport + .sync(AppSyncRequest { + trigger: SyncTrigger::ManualRefresh, + checkpoint: SyncCheckpointStatus::never_synced(), + pending_operations: vec![operation], + known_conflicts: Vec::new(), + }) + .expect("payload account signer should publish"); + + assert_eq!(result.run_status, AppSyncRunStatus::Succeeded); + assert_eq!(result.published_receipts.len(), 1); + assert_eq!( + result.published_receipts[0].event_pubkey, + first_identity.public_key_hex() + ); + assert_ne!( + result.published_receipts[0].event_pubkey, + second_identity.public_key_hex() + ); + } + + #[test] + fn runtime_direct_relay_transport_rejects_missing_account_context() { let relay = ThreadedAckRelay::spawn(); let manager = RadrootsNostrAccountsManager::new_in_memory(); let farm_id = FarmId::new(); + let missing_account_id = RadrootsIdentity::generate().id(); let payload = AppPublishPayload::FarmProfile(AppFarmProfilePublishPayload { - context: AppPublishContext::new("missing", "farm_setup"), + context: AppPublishContext::new(missing_account_id.to_string(), "farm_setup"), farm_id, display_name: "North field farm".to_owned(), readiness: Some(FarmReadiness::Ready), @@ -7315,7 +7408,93 @@ mod tests { assert!( error .to_string() - .contains("selected account is not backed by a local signing key") + .contains("publish account is not configured locally") + ); + } + + #[test] + fn runtime_direct_relay_transport_rejects_watch_only_account_context() { + let relay = ThreadedAckRelay::spawn(); + let manager = RadrootsNostrAccountsManager::new_in_memory(); + let identity = RadrootsIdentity::generate(); + let account_id = manager + .upsert_public_identity(identity.to_public(), Some("Watch".to_owned()), true) + .expect("watch-only account"); + let payload = AppPublishPayload::FarmProfile(AppFarmProfilePublishPayload { + context: AppPublishContext::new(account_id.to_string(), "farm_setup"), + farm_id: FarmId::new(), + display_name: "North field farm".to_owned(), + readiness: Some(FarmReadiness::Ready), + }); + let operation = PendingSyncOperation::from_publish_payload(payload, "2026-05-24T12:00:00Z") + .expect("typed farm publish work should serialize"); + let mut transport = + SdkDirectRelayAppSyncTransport::with_relay_urls(manager, vec![relay.url().to_owned()]); + + let error = transport + .sync(AppSyncRequest { + trigger: SyncTrigger::ManualRefresh, + checkpoint: SyncCheckpointStatus::never_synced(), + pending_operations: vec![operation], + known_conflicts: Vec::new(), + }) + .expect_err("watch-only account should not publish"); + + assert!(matches!(error, AppSyncTransportError::Unavailable { .. })); + assert!( + error + .to_string() + .contains("publish account is not backed by a local signing key") + ); + } + + #[test] + fn runtime_direct_relay_transport_rejects_mismatched_local_signing_custody() { + let relay = ThreadedAckRelay::spawn(); + let store = Arc::new(RadrootsNostrMemoryAccountStore::new()); + let vault = Arc::new(RadrootsNostrSecretVaultMemory::new()); + let manager = + RadrootsNostrAccountsManager::new(store, vault.clone()).expect("accounts manager"); + let account_identity = RadrootsIdentity::generate(); + let secret_identity = RadrootsIdentity::generate(); + let account_id = manager + .upsert_public_identity( + account_identity.to_public(), + Some("Mismatched".to_owned()), + true, + ) + .expect("public account"); + vault + .store_secret( + account_secret_slot(&account_id).as_str(), + secret_identity.secret_key_hex().as_str(), + ) + .expect("mismatched secret"); + let payload = AppPublishPayload::FarmProfile(AppFarmProfilePublishPayload { + context: AppPublishContext::new(account_id.to_string(), "farm_setup"), + farm_id: FarmId::new(), + display_name: "North field farm".to_owned(), + readiness: Some(FarmReadiness::Ready), + }); + let operation = PendingSyncOperation::from_publish_payload(payload, "2026-05-24T12:00:00Z") + .expect("typed farm publish work should serialize"); + let mut transport = + SdkDirectRelayAppSyncTransport::with_relay_urls(manager, vec![relay.url().to_owned()]); + + let error = transport + .sync(AppSyncRequest { + trigger: SyncTrigger::ManualRefresh, + checkpoint: SyncCheckpointStatus::never_synced(), + pending_operations: vec![operation], + known_conflicts: Vec::new(), + }) + .expect_err("mismatched custody should not publish"); + + assert!(matches!(error, AppSyncTransportError::Failed { .. })); + assert!( + error + .to_string() + .contains("public key does not match secret key") ); }