app

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

commit 4802b340e599152e8d22a23ffe4eeba8f00e6279
parent cdb4b18e136271bd37b058aed20a75281658e8c0
Author: triesap <tyson@radroots.org>
Date:   Wed,  3 Jun 2026 00:20:19 -0700

app: harden seller order decision preflight

- require usable signed request evidence before seller order export state
- keep stale relay decisions blocking app seller accept and decline actions
- assert app-published order decisions parse through canonical trade codecs
- cover shared local-events decision chain tags for accept and decline

Diffstat:
Mcrates/desktop/src/runtime.rs | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
1 file changed, 72 insertions(+), 12 deletions(-)

diff --git a/crates/desktop/src/runtime.rs b/crates/desktop/src/runtime.rs @@ -2331,18 +2331,6 @@ impl DesktopAppRuntimeState { reason: "seller order decision requires a selected seller public key", }, )?; - let Some(order_export) = - sqlite_store.load_seller_order_decision_export(farm_id, order_id)? - else { - return Err(AppSqliteError::InvalidProjection { - reason: "seller order decision requires a visible seller order", - }); - }; - if order_export.status != OrderStatus::NeedsAction { - return Err(AppSqliteError::InvalidProjection { - reason: "seller order decision requires an undecided order", - }); - } let request = self.resolve_seller_order_request_evidence(order_id)?; if request.payload.seller_pubkey.trim() != seller_pubkey.as_str() { return Err(AppSqliteError::InvalidProjection { @@ -2359,6 +2347,18 @@ impl DesktopAppRuntimeState { reason: "seller order decision listing address is outside seller authority", }); } + let Some(order_export) = + sqlite_store.load_seller_order_decision_export(farm_id, order_id)? + else { + return Err(AppSqliteError::InvalidProjection { + reason: "seller order decision requires a visible seller order", + }); + }; + if order_export.status != OrderStatus::NeedsAction { + return Err(AppSqliteError::InvalidProjection { + reason: "seller order decision requires an undecided order", + }); + } let decision = match command { AppSellerOrderDecisionCommand::Accept => AppOrderDecisionPayload::Accepted { @@ -8400,6 +8400,7 @@ mod tests { SdkDirectRelayAppSyncTransport, TokioRuntimeBuilder, default_sync_transport, direct_relay_event_source_runtime, farm_sync_payload, is_hex_64, order_decision_publish_payload_to_sdk_decision, pending_sync_upsert, + signed_event_from_local_record, }; use crate::pack_day_host_handoff::PackDayHostHandoffError; use crate::pack_day_print::{ @@ -13315,6 +13316,24 @@ mod tests { && record.event_kind == Some(3423) && record.event_pubkey.as_deref() == Some(seller_pubkey.as_str()) })); + let decision_event = shared_seller_order_decision_event(&paths, seller_pubkey.as_str()); + let envelope = radroots_sdk::trade::parse_order_decision(&decision_event) + .expect("app seller order accept should parse as canonical order decision"); + assert_eq!(envelope.payload.order_id, "seller-order-decision-1"); + assert!(matches!( + envelope.payload.decision, + RadrootsTradeOrderDecision::Accepted { .. } + )); + assert!(event_has_tag( + &decision_event, + "e_root", + "event-app:signed_event:order-request:seller-order-decision-1" + )); + assert!(event_has_tag( + &decision_event, + "e_prev", + "event-app:signed_event:order-request:seller-order-decision-1" + )); cleanup_bootstrapped_runtime_paths(&paths); } @@ -13339,6 +13358,23 @@ mod tests { && record.event_kind == Some(3423) && record.event_pubkey.as_deref() == Some(seller_pubkey.as_str()) })); + let decision_event = shared_seller_order_decision_event(&paths, seller_pubkey.as_str()); + let envelope = radroots_sdk::trade::parse_order_decision(&decision_event) + .expect("app seller order decline should parse as canonical order decision"); + let RadrootsTradeOrderDecision::Declined { reason } = envelope.payload.decision else { + panic!("expected declined decision"); + }; + assert_eq!(reason, "not available"); + assert!(event_has_tag( + &decision_event, + "e_root", + "event-app:signed_event:order-request:seller-order-decision-1" + )); + assert!(event_has_tag( + &decision_event, + "e_prev", + "event-app:signed_event:order-request:seller-order-decision-1" + )); cleanup_bootstrapped_runtime_paths(&paths); } @@ -17728,6 +17764,30 @@ mod tests { .expect("shared local records should list") } + fn shared_seller_order_decision_event( + paths: &AppDesktopRuntimePaths, + seller_pubkey: &str, + ) -> radroots_sdk::RadrootsNostrEvent { + let record = shared_local_event_records(paths) + .into_iter() + .find(|record| { + record.family == LocalRecordFamily::SignedEvent + && record.event_kind == Some(3423) + && record.event_pubkey.as_deref() == Some(seller_pubkey) + }) + .expect("shared seller order decision record should exist"); + signed_event_from_local_record(&record) + .expect("shared seller order decision record should decode") + .expect("shared seller order decision record should contain signed event") + } + + fn event_has_tag(event: &radroots_sdk::RadrootsNostrEvent, key: &str, value: &str) -> bool { + event.tags.iter().any(|tag| { + tag.first().map(String::as_str) == Some(key) + && tag.get(1).map(String::as_str) == Some(value) + }) + } + fn persisted_order_status(runtime: &DesktopAppRuntime, order_id: OrderId) -> String { runtime .lock_state()