app

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

commit a7dcb129b031356c9450accc425869cab7594bd8
parent 879051fb4de9f4964febb1eb9e763eededc48a47
Author: triesap <tyson@radroots.org>
Date:   Thu, 18 Jun 2026 13:45:38 -0700

app: add sdk boundary source guards

- guard migrated SDK runtime and audit paths
- record legacy direct publish allowlist metadata
- catch unallowlisted legacy publish and outbox usage
- verify app check script and source guard tests

Diffstat:
Mcrates/desktop/src/runtime.rs | 4+++-
Mcrates/desktop/src/source_guards.rs | 471+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 474 insertions(+), 1 deletion(-)

diff --git a/crates/desktop/src/runtime.rs b/crates/desktop/src/runtime.rs @@ -9956,7 +9956,9 @@ mod tests { RadrootsNostrMemoryAccountStore, RadrootsNostrSecretVaultMemory, RadrootsSecretVault, account_secret_slot, }; - use radroots_sdk::protocol::events::RadrootsNostrEventPtr; + use radroots_sdk::protocol::events::{ + RadrootsNostrEvent as SdkRadrootsNostrEvent, RadrootsNostrEventPtr, + }; use radroots_sdk::protocol::order::{ RadrootsOrderCancellation, RadrootsOrderDecision, RadrootsOrderDecisionOutcome, RadrootsOrderEconomicItem, RadrootsOrderEconomics, RadrootsOrderFulfillmentState, diff --git a/crates/desktop/src/source_guards.rs b/crates/desktop/src/source_guards.rs @@ -1163,8 +1163,355 @@ const FORBIDDEN_PRODUCTION_EVENT_KIND_LITERALS: &[(&str, &str)] = &[ ("3440", "KIND_TRADE_VALIDATION_RECEIPT"), ]; +struct SdkBoundaryForbiddenPattern { + pattern: &'static str, + reason: &'static str, +} + +struct LegacySdkBoundaryAllowlistEntry { + path: &'static str, + pattern: &'static str, + owner: &'static str, + reason: &'static str, + removal_condition: &'static str, +} + const TEST_MODULE_SENTINEL: &str = "\n#[cfg(test)]\nmod tests {"; +const SDK_MIGRATED_SOURCE_PATHS: &[&str] = &[ + "crates/runtime/src/sdk.rs", + "crates/store/src/migration_audit.rs", +]; + +const FORBIDDEN_SDK_MIGRATED_BOUNDARY_PATTERNS: &[SdkBoundaryForbiddenPattern] = &[ + SdkBoundaryForbiddenPattern { + pattern: "SdkDirectRelayAppSyncTransport", + reason: "migrated paths must use AppSdkRuntime instead of the legacy direct relay sync transport", + }, + SdkBoundaryForbiddenPattern { + pattern: "RadrootsSdkClient", + reason: "migrated paths must use the long-lived RadrootsSdk runtime boundary", + }, + SdkBoundaryForbiddenPattern { + pattern: "RadrootsSdkConfig", + reason: "migrated paths must use AppSdkConfig-derived runtime construction", + }, + SdkBoundaryForbiddenPattern { + pattern: "SdkTransportMode::RelayDirect", + reason: "migrated paths must not configure direct relay publish transport", + }, + SdkBoundaryForbiddenPattern { + pattern: "SignerConfig::LocalIdentity", + reason: "migrated paths must not configure local direct-publish signing", + }, + SdkBoundaryForbiddenPattern { + pattern: "PendingSyncOperation::from_publish_payload", + reason: "migrated paths must not enqueue legacy app publish payloads", + }, + SdkBoundaryForbiddenPattern { + pattern: ".enqueue_pending_operation(", + reason: "migrated paths must not mutate the legacy app outbox", + }, + SdkBoundaryForbiddenPattern { + pattern: "INSERT INTO local_outbox", + reason: "migrated paths must not write legacy local outbox rows", + }, + SdkBoundaryForbiddenPattern { + pattern: "UPDATE local_outbox", + reason: "migrated paths must not mutate legacy local outbox rows", + }, + SdkBoundaryForbiddenPattern { + pattern: "DELETE FROM local_outbox", + reason: "migrated paths must not delete legacy local outbox rows", + }, + SdkBoundaryForbiddenPattern { + pattern: "RadrootsOutbox", + reason: "canonical SDK outbox access belongs inside the SDK crate", + }, + SdkBoundaryForbiddenPattern { + pattern: "enqueue_signed_operation", + reason: "canonical SDK outbox writes must go through SDK APIs", + }, + SdkBoundaryForbiddenPattern { + pattern: "claim_ready_events", + reason: "canonical SDK outbox push claims must go through SDK APIs", + }, + SdkBoundaryForbiddenPattern { + pattern: "connected_client_from_identity", + reason: "migrated paths must not connect relay clients directly for publish", + }, + SdkBoundaryForbiddenPattern { + pattern: "publish_signed_event", + reason: "migrated paths must not publish signed events directly", + }, + SdkBoundaryForbiddenPattern { + pattern: "radroots_nostr_build_event", + reason: "migrated paths must not build protocol events outside SDK-owned publish APIs", + }, + SdkBoundaryForbiddenPattern { + pattern: "RadrootsIdentity::from_secret_key_str", + reason: "migrated paths must not parse direct signing keys", + }, + SdkBoundaryForbiddenPattern { + pattern: "RawSecretKey", + reason: "migrated paths must not import raw signing-key material", + }, + SdkBoundaryForbiddenPattern { + pattern: "EncryptedSecretKey", + reason: "migrated paths must not import encrypted signing-key material", + }, + SdkBoundaryForbiddenPattern { + pattern: "publish_with_identity", + reason: "migrated paths must not call legacy direct SDK publish APIs", + }, + SdkBoundaryForbiddenPattern { + pattern: "publish_draft_with_identity", + reason: "migrated paths must not encode legacy direct SDK publish targets", + }, + SdkBoundaryForbiddenPattern { + pattern: "publish_order_request_with_identity", + reason: "migrated paths must not call legacy direct SDK order publish APIs", + }, + SdkBoundaryForbiddenPattern { + pattern: "publish_order_decision_with_identity", + reason: "migrated paths must not call legacy direct SDK order publish APIs", + }, + SdkBoundaryForbiddenPattern { + pattern: "publish_order_revision_proposal_with_identity", + reason: "migrated paths must not call legacy direct SDK order publish APIs", + }, + SdkBoundaryForbiddenPattern { + pattern: "publish_order_revision_decision_with_identity", + reason: "migrated paths must not call legacy direct SDK order publish APIs", + }, + SdkBoundaryForbiddenPattern { + pattern: "publish_order_cancellation_with_identity", + reason: "migrated paths must not call legacy direct SDK order publish APIs", + }, + SdkBoundaryForbiddenPattern { + pattern: "publish_fulfillment_update_with_identity", + reason: "migrated paths must not call legacy direct SDK fulfillment publish APIs", + }, + SdkBoundaryForbiddenPattern { + pattern: "publish_buyer_receipt_with_identity", + reason: "migrated paths must not call legacy direct SDK receipt publish APIs", + }, +]; + +const LEGACY_SDK_BOUNDARY_PATTERNS: &[&str] = &[ + "SdkDirectRelayAppSyncTransport", + "RadrootsSdkClient", + "RadrootsSdkConfig", + "SdkTransportMode::RelayDirect", + "SignerConfig::LocalIdentity", + "PendingSyncOperation::from_publish_payload", + ".enqueue_pending_operation(", + "INSERT INTO local_outbox", + "UPDATE local_outbox", + "DELETE FROM local_outbox", + "publish_with_identity", + "publish_draft_with_identity", + "publish_order_request_with_identity", + "publish_order_decision_with_identity", + "publish_order_revision_proposal_with_identity", + "publish_order_revision_decision_with_identity", + "publish_order_cancellation_with_identity", + "publish_fulfillment_update_with_identity", + "publish_buyer_receipt_with_identity", +]; + +const LEGACY_SDK_BOUNDARY_ALLOWLIST: &[LegacySdkBoundaryAllowlistEntry] = &[ + LegacySdkBoundaryAllowlistEntry { + path: "crates/desktop/src/runtime.rs", + pattern: "SdkDirectRelayAppSyncTransport", + owner: "rpv1-app-sdk-refactor.07", + reason: "desktop runtime still owns deferred direct relay publish transport for unmigrated publish workflows", + removal_condition: "remove when farm, listing, order, fulfillment, and receipt publish workflows enqueue through AppSdkRuntime", + }, + LegacySdkBoundaryAllowlistEntry { + path: "crates/desktop/src/runtime.rs", + pattern: "RadrootsSdkClient", + owner: "rpv1-app-sdk-refactor.07", + reason: "desktop runtime still constructs the legacy direct publish client for unmigrated publish workflows", + removal_condition: "remove when direct publish workflows no longer construct SDK clients outside AppSdkRuntime", + }, + LegacySdkBoundaryAllowlistEntry { + path: "crates/desktop/src/runtime.rs", + pattern: "RadrootsSdkConfig", + owner: "rpv1-app-sdk-refactor.07", + reason: "desktop runtime still configures the legacy direct publish client for unmigrated publish workflows", + removal_condition: "remove when direct publish workflows no longer configure SDK clients outside AppSdkRuntime", + }, + LegacySdkBoundaryAllowlistEntry { + path: "crates/desktop/src/runtime.rs", + pattern: "SdkTransportMode::RelayDirect", + owner: "rpv1-app-sdk-refactor.07", + reason: "desktop runtime still uses relay direct publish transport for deferred workflow migration", + removal_condition: "remove when all publish workflows route through SDK canonical outbox and sync APIs", + }, + LegacySdkBoundaryAllowlistEntry { + path: "crates/desktop/src/runtime.rs", + pattern: "SignerConfig::LocalIdentity", + owner: "rpv1-app-sdk-refactor.07", + reason: "desktop runtime still configures direct local signing for deferred workflow migration", + removal_condition: "remove when publish signing is mediated by AppSdkRuntime and SDK signer adapters", + }, + LegacySdkBoundaryAllowlistEntry { + path: "crates/desktop/src/runtime.rs", + pattern: "PendingSyncOperation::from_publish_payload", + owner: "rpv1-app-sdk-refactor.07", + reason: "desktop runtime still creates legacy local outbox publish work for unmigrated workflows", + removal_condition: "remove when app publish workflows write SDK canonical outbox requests instead of app local_outbox operations", + }, + LegacySdkBoundaryAllowlistEntry { + path: "crates/desktop/src/runtime.rs", + pattern: "publish_with_identity", + owner: "rpv1-app-sdk-refactor.07", + reason: "desktop runtime still calls legacy direct SDK farm and listing publish APIs", + removal_condition: "remove when farm profile and listing publish workflows enqueue through AppSdkRuntime", + }, + LegacySdkBoundaryAllowlistEntry { + path: "crates/desktop/src/runtime.rs", + pattern: "publish_order_request_with_identity", + owner: "rpv1-app-sdk-refactor.07", + reason: "desktop runtime still calls legacy direct SDK order request publish APIs", + removal_condition: "remove when buyer order request publish workflow enqueues through AppSdkRuntime", + }, + LegacySdkBoundaryAllowlistEntry { + path: "crates/desktop/src/runtime.rs", + pattern: "publish_order_decision_with_identity", + owner: "rpv1-app-sdk-refactor.07", + reason: "desktop runtime still calls legacy direct SDK order decision publish APIs", + removal_condition: "remove when seller order decision publish workflow enqueues through AppSdkRuntime", + }, + LegacySdkBoundaryAllowlistEntry { + path: "crates/desktop/src/runtime.rs", + pattern: "publish_order_revision_proposal_with_identity", + owner: "rpv1-app-sdk-refactor.07", + reason: "desktop runtime still calls legacy direct SDK order revision proposal publish APIs", + removal_condition: "remove when seller order revision proposal workflow enqueues through AppSdkRuntime", + }, + LegacySdkBoundaryAllowlistEntry { + path: "crates/desktop/src/runtime.rs", + pattern: "publish_order_revision_decision_with_identity", + owner: "rpv1-app-sdk-refactor.07", + reason: "desktop runtime still calls legacy direct SDK order revision decision publish APIs", + removal_condition: "remove when buyer order revision decision workflow enqueues through AppSdkRuntime", + }, + LegacySdkBoundaryAllowlistEntry { + path: "crates/desktop/src/runtime.rs", + pattern: "publish_order_cancellation_with_identity", + owner: "rpv1-app-sdk-refactor.07", + reason: "desktop runtime still calls legacy direct SDK order cancellation publish APIs", + removal_condition: "remove when buyer order cancellation workflow enqueues through AppSdkRuntime", + }, + LegacySdkBoundaryAllowlistEntry { + path: "crates/desktop/src/runtime.rs", + pattern: "publish_fulfillment_update_with_identity", + owner: "rpv1-app-sdk-refactor.07", + reason: "desktop runtime still calls legacy direct SDK fulfillment publish APIs", + removal_condition: "remove when seller fulfillment workflow enqueues through AppSdkRuntime", + }, + LegacySdkBoundaryAllowlistEntry { + path: "crates/desktop/src/runtime.rs", + pattern: "publish_buyer_receipt_with_identity", + owner: "rpv1-app-sdk-refactor.07", + reason: "desktop runtime still calls legacy direct SDK receipt publish APIs", + removal_condition: "remove when buyer receipt workflow enqueues through AppSdkRuntime", + }, + LegacySdkBoundaryAllowlistEntry { + path: "crates/sync/src/publish.rs", + pattern: "SdkTransportMode::RelayDirect", + owner: "rpv1-app-sdk-refactor.07", + reason: "sync payload metadata still marks legacy app local outbox publish work as relay direct", + removal_condition: "remove when app sync publish payloads are replaced by SDK canonical outbox requests", + }, + LegacySdkBoundaryAllowlistEntry { + path: "crates/sync/src/publish.rs", + pattern: "publish_draft_with_identity", + owner: "rpv1-app-sdk-refactor.07", + reason: "sync payload metadata still names legacy farm and listing SDK publish operations", + removal_condition: "remove when farm and listing publish payload metadata is replaced by SDK canonical outbox requests", + }, + LegacySdkBoundaryAllowlistEntry { + path: "crates/sync/src/publish.rs", + pattern: "publish_order_request_with_identity", + owner: "rpv1-app-sdk-refactor.07", + reason: "sync payload metadata still names legacy order request SDK publish operations", + removal_condition: "remove when buyer order request publish payload metadata is replaced by SDK canonical outbox requests", + }, + LegacySdkBoundaryAllowlistEntry { + path: "crates/sync/src/publish.rs", + pattern: "publish_order_decision_with_identity", + owner: "rpv1-app-sdk-refactor.07", + reason: "sync payload metadata still names legacy order decision SDK publish operations", + removal_condition: "remove when seller order decision publish payload metadata is replaced by SDK canonical outbox requests", + }, + LegacySdkBoundaryAllowlistEntry { + path: "crates/sync/src/publish.rs", + pattern: "publish_order_revision_proposal_with_identity", + owner: "rpv1-app-sdk-refactor.07", + reason: "sync payload metadata still names legacy order revision proposal SDK publish operations", + removal_condition: "remove when order revision proposal payload metadata is replaced by SDK canonical outbox requests", + }, + LegacySdkBoundaryAllowlistEntry { + path: "crates/sync/src/publish.rs", + pattern: "publish_order_revision_decision_with_identity", + owner: "rpv1-app-sdk-refactor.07", + reason: "sync payload metadata still names legacy order revision decision SDK publish operations", + removal_condition: "remove when order revision decision payload metadata is replaced by SDK canonical outbox requests", + }, + LegacySdkBoundaryAllowlistEntry { + path: "crates/sync/src/publish.rs", + pattern: "publish_order_cancellation_with_identity", + owner: "rpv1-app-sdk-refactor.07", + reason: "sync payload metadata still names legacy order cancellation SDK publish operations", + removal_condition: "remove when order cancellation payload metadata is replaced by SDK canonical outbox requests", + }, + LegacySdkBoundaryAllowlistEntry { + path: "crates/sync/src/publish.rs", + pattern: "publish_fulfillment_update_with_identity", + owner: "rpv1-app-sdk-refactor.07", + reason: "sync payload metadata still names legacy fulfillment SDK publish operations", + removal_condition: "remove when fulfillment payload metadata is replaced by SDK canonical outbox requests", + }, + LegacySdkBoundaryAllowlistEntry { + path: "crates/sync/src/publish.rs", + pattern: "publish_buyer_receipt_with_identity", + owner: "rpv1-app-sdk-refactor.07", + reason: "sync payload metadata still names legacy receipt SDK publish operations", + removal_condition: "remove when receipt payload metadata is replaced by SDK canonical outbox requests", + }, + LegacySdkBoundaryAllowlistEntry { + path: "crates/store/src/lib.rs", + pattern: ".enqueue_pending_operation(", + owner: "rpv1-app-sdk-refactor.07", + reason: "store facade still accepts legacy app local_outbox publish operations for deferred workflows", + removal_condition: "remove when app local_outbox enqueue is replaced by SDK canonical outbox enqueue APIs", + }, + LegacySdkBoundaryAllowlistEntry { + path: "crates/store/src/sync.rs", + pattern: "INSERT INTO local_outbox", + owner: "rpv1-app-sdk-refactor.07", + reason: "store sync implementation still writes legacy app local_outbox rows for deferred workflows", + removal_condition: "remove when app local_outbox storage is retired after SDK canonical outbox migration", + }, + LegacySdkBoundaryAllowlistEntry { + path: "crates/store/src/sync.rs", + pattern: "UPDATE local_outbox", + owner: "rpv1-app-sdk-refactor.07", + reason: "store sync implementation still updates legacy app local_outbox rows for deferred workflows", + removal_condition: "remove when app local_outbox storage is retired after SDK canonical outbox migration", + }, + LegacySdkBoundaryAllowlistEntry { + path: "crates/store/src/sync.rs", + pattern: "DELETE FROM local_outbox", + owner: "rpv1-app-sdk-refactor.07", + reason: "store sync implementation still deletes legacy app local_outbox rows for deferred workflows", + removal_condition: "remove when app local_outbox storage is retired after SDK canonical outbox migration", + }, +]; + #[test] fn desktop_menu_source_uses_localized_copy_paths() { assert_eq!( @@ -1327,6 +1674,86 @@ fn app_production_trade_event_kinds_use_shared_constants() { ); } +#[test] +fn app_migrated_sdk_paths_do_not_use_direct_publish_boundaries() { + let app_root = app_root(); + + for relative_path in SDK_MIGRATED_SOURCE_PATHS { + let path = app_root.join(relative_path); + let source = read_source_path(path.as_path()); + let production_source = production_source_without_tests(&source); + + for forbidden in FORBIDDEN_SDK_MIGRATED_BOUNDARY_PATTERNS { + assert!( + !production_source.contains(forbidden.pattern), + "{relative_path} contains forbidden SDK boundary pattern `{}`: {}", + forbidden.pattern, + forbidden.reason + ); + } + } +} + +#[test] +fn app_legacy_sdk_boundary_allowlist_entries_are_complete_and_current() { + let app_root = app_root(); + let mut entries = BTreeSet::new(); + + for entry in LEGACY_SDK_BOUNDARY_ALLOWLIST { + assert!( + entries.insert((entry.path, entry.pattern)), + "duplicate legacy SDK boundary allowlist entry {} `{}`", + entry.path, + entry.pattern + ); + assert!( + !entry.owner.trim().is_empty(), + "{} `{}` is missing an owner", + entry.path, + entry.pattern + ); + assert!( + !entry.reason.trim().is_empty(), + "{} `{}` is missing a reason", + entry.path, + entry.pattern + ); + assert!( + !entry.removal_condition.trim().is_empty(), + "{} `{}` is missing a removal condition", + entry.path, + entry.pattern + ); + + let source_path = app_root.join(entry.path); + let source = read_source_path(source_path.as_path()); + let production_source = production_source_without_tests(&source); + assert!( + production_source.contains(entry.pattern), + "{} allowlists legacy SDK boundary pattern `{}` that is no longer present", + entry.path, + entry.pattern + ); + } +} + +#[test] +fn app_legacy_sdk_boundary_usage_is_allowlisted() { + for (relative_path, source) in app_rust_source_files() { + let production_source = production_source_without_tests(&source); + + for pattern in LEGACY_SDK_BOUNDARY_PATTERNS { + if production_source.contains(pattern) { + assert!( + legacy_sdk_boundary_allowlist_contains(relative_path.as_str(), pattern), + "{} contains unallowlisted legacy SDK boundary pattern `{pattern}`", + relative_path + ); + } + } + } +} + fn extract_string_literals(source: &str) -> BTreeSet<&str> { let mut literals = BTreeSet::new(); let bytes = source.as_bytes(); @@ -1365,6 +1792,50 @@ fn production_source_without_tests(source: &str) -> &str { .map_or(source, |(production_source, _)| production_source) } +fn legacy_sdk_boundary_allowlist_contains(path: &str, pattern: &str) -> bool { + LEGACY_SDK_BOUNDARY_ALLOWLIST + .iter() + .any(|entry| entry.path == path && entry.pattern == pattern) +} + +fn read_source_path(path: &Path) -> String { + fs::read_to_string(path) + .unwrap_or_else(|error| panic!("failed to read source {}: {error}", path.display())) +} + +fn app_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(|path| path.parent()) + .expect("desktop crate should live under app crates directory") + .to_path_buf() +} + +fn app_rust_source_files() -> Vec<(String, String)> { + let app_root = app_root(); + let mut paths = Vec::new(); + collect_rust_source_files(app_root.join("crates").as_path(), &mut paths); + paths.sort(); + paths + .into_iter() + .filter(|path| path.file_name().and_then(|name| name.to_str()) != Some("source_guards.rs")) + .map(|path| { + let relative_path = path + .strip_prefix(app_root.as_path()) + .unwrap_or_else(|error| { + panic!( + "failed to derive app-relative source path {}: {error}", + path.display() + ) + }) + .to_string_lossy() + .replace('\\', "/"); + let source = read_source_path(path.as_path()); + (relative_path, source) + }) + .collect() +} + fn contains_numeric_token(source: &str, literal: &str) -> bool { source.match_indices(literal).any(|(start, _)| { let end = start + literal.len();