sdk

Radroots SDK and bindings
git clone https://radroots.dev/git/sdk.git
Log | Files | Refs | README

commit 9fc2698c8ab6731b93e3fb747af4486f178252ac
parent 64dc049f7564df0346dfde99a64705ff383a600f
Author: triesap <tyson@radroots.org>
Date:   Sat, 13 Jun 2026 15:08:32 -0700

sdk: own Rust SDK and wasm packages

- move radroots_sdk into the SDK workspace as the first-class Rust SDK crate
- add SDK-owned wasm binding crates and tracked npm package manifests
- teach xtask to generate wasm packages with wasm-pack --no-pack
- document SDK workspace ownership across Rust, TypeScript, and wasm surfaces

Diffstat:
MCargo.lock | 2472++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
MCargo.toml | 33+++++++++++++++++++++++++++++++++
Acrates/events_codec_wasm/Cargo.toml | 34++++++++++++++++++++++++++++++++++
Acrates/events_codec_wasm/README | 24++++++++++++++++++++++++
Acrates/events_codec_wasm/src/lib.rs | 1149+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/replica_db_wasm/Cargo.toml | 36++++++++++++++++++++++++++++++++++++
Acrates/replica_db_wasm/README | 25+++++++++++++++++++++++++
Acrates/replica_db_wasm/src/lib.rs | 29+++++++++++++++++++++++++++++
Acrates/replica_db_wasm/src/utils.rs | 13+++++++++++++
Acrates/replica_db_wasm/src/wasm_impl.rs | 891+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/replica_sync_wasm/Cargo.toml | 35+++++++++++++++++++++++++++++++++++
Acrates/replica_sync_wasm/README | 25+++++++++++++++++++++++++
Acrates/replica_sync_wasm/src/lib.rs | 124+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/sdk/Cargo.toml | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/sdk/README | 23+++++++++++++++++++++++
Acrates/sdk/src/adapters/mod.rs | 8++++++++
Acrates/sdk/src/adapters/radrootsd.rs | 835+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/sdk/src/adapters/relay.rs | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/sdk/src/adapters/signer.rs | 24++++++++++++++++++++++++
Acrates/sdk/src/adapters/signing.rs | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/sdk/src/client.rs | 2711+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/sdk/src/config.rs | 388+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/sdk/src/farm.rs | 9+++++++++
Acrates/sdk/src/identity.rs | 33+++++++++++++++++++++++++++++++++
Acrates/sdk/src/lib.rs | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/sdk/src/listing.rs | 40++++++++++++++++++++++++++++++++++++++++
Acrates/sdk/src/order.rs | 281+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/sdk/src/profile.rs | 12++++++++++++
Acrates/sdk/tests/client.rs | 906+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/sdk/tests/config.rs | 562+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/sdk/tests/facade.rs | 269+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/sdk/tests/radrootsd.rs | 1952+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/sdk/tests/relay_direct.rs | 1406+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/sdk/tests/replica_ingest.rs | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/xtask/src/check.rs | 11++++++++++-
Mcrates/xtask/src/main.rs | 7++++++-
Mcrates/xtask/src/package_matrix.rs | 85++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Acrates/xtask/src/wasm.rs | 112+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackage.json | 2++
Apackages/events-codec-wasm/package.json | 23+++++++++++++++++++++++
Apackages/replica-db-wasm/package.json | 23+++++++++++++++++++++++
Apackages/replica-sync-wasm/package.json | 23+++++++++++++++++++++++
Mpnpm-lock.yaml | 6++++++
Mrust-toolchain.toml | 1+
44 files changed, 14948 insertions(+), 88 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -13,12 +13,125 @@ dependencies = [ ] [[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + +[[package]] name = "arrayvec" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-utility" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a34a3b57207a7a1007832416c3e4862378c8451b4e8e093e436f48c2d3d2c151" +dependencies = [ + "futures-util", + "gloo-timers", + "tokio", + "wasm-bindgen-futures", +] + +[[package]] +name = "async-wsocket" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c92385c7c8b3eb2de1b78aeca225212e4c9a69a78b802832759b108681a5069" +dependencies = [ + "async-utility", + "futures", + "futures-util", + "js-sys", + "tokio", + "tokio-rustls", + "tokio-socks", + "tokio-tungstenite", + "url", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "atomic-destructor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef49f5882e4b6afaac09ad239a4f8c70a24b8f2b0897edb1f706008efd109cf4" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] name = "autocfg" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -26,6 +139,12 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" @@ -71,6 +190,15 @@ dependencies = [ ] [[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +dependencies = [ + "serde_core", +] + +[[package]] name = "block-buffer" version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -95,6 +223,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] name = "cbc" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -120,6 +260,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] name = "chacha20" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -144,6 +290,19 @@ dependencies = [ ] [[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] name = "cipher" version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -155,6 +314,60 @@ dependencies = [ ] [[package]] +name = "config" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68578f196d2a33ff61b27fae256c3164f65e36382648e30666dde05b8cc9dfdf" +dependencies = [ + "async-trait", + "convert_case", + "json5", + "nom", + "pathdiff", + "ron", + "rust-ini", + "serde", + "serde_json", + "toml", + "yaml-rust2", +] + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] name = "cpufeatures" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -164,17 +377,50 @@ dependencies = [ ] [[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] name = "crypto-common" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "typenum", ] [[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -197,12 +443,76 @@ dependencies = [ ] [[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] name = "form_urlencoded" version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -212,12 +522,71 @@ dependencies = [ ] [[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] name = "futures-core" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] name = "futures-task" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -229,8 +598,13 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -259,39 +633,249 @@ dependencies = [ ] [[package]] -name = "hex" -version = "0.4.3" +name = "getrandom" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] [[package]] -name = "hex-conservative" -version = "0.2.2" +name = "getrandom" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ - "arrayvec", + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", ] [[package]] -name = "hmac" -version = "0.12.1" +name = "gloo-timers" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" dependencies = [ - "digest", + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", ] [[package]] -name = "icu_collections" -version = "2.2.0" +name = "hashbrown" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", + "ahash", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.7", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", "zerofrom", "zerovec", ] @@ -365,6 +949,12 @@ dependencies = [ ] [[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] name = "idna" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -386,6 +976,18 @@ dependencies = [ ] [[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] name = "inout" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -408,6 +1010,12 @@ dependencies = [ ] [[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -425,37 +1033,154 @@ dependencies = [ ] [[package]] +name = "json5" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" +dependencies = [ + "pest", + "pest_derive", + "serde", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] name = "libc" version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] name = "litemap" version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] name = "memchr" version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] +name = "minicov" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" +dependencies = [ + "cc", + "walkdir", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "negentropy" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0efe882e02d206d8d279c20eb40e03baf7cb5136a1476dc084a324fbc3ec42d" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] name = "nostr" version = "0.44.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d8f0fe13526800300a36bf3b7c5f752e62e32ab81c74a8e5caa2865708625a" dependencies = [ - "base64", + "aes", + "base64 0.22.1", "bech32", "bip39", "bitcoin_hashes", "cbc", "chacha20", "chacha20poly1305", - "getrandom", + "getrandom 0.2.17", "hex", "instant", "scrypt", @@ -467,12 +1192,81 @@ dependencies = [ ] [[package]] +name = "nostr-database" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7462c9d8ae5ef6a28d66a192d399ad2530f1f2130b13186296dbb11bdef5b3d1" +dependencies = [ + "lru", + "nostr", + "tokio", +] + +[[package]] +name = "nostr-gossip" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade30de16869618919c6b5efc8258f47b654a98b51541eb77f85e8ec5e3c83a6" +dependencies = [ + "nostr", +] + +[[package]] +name = "nostr-relay-pool" +version = "0.44.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91b2c039df4f96c4bf7dae52a74fd5516ad6dda83a11c0c69dea91b5255a4f37" +dependencies = [ + "async-utility", + "async-wsocket", + "atomic-destructor", + "hex", + "lru", + "negentropy", + "nostr", + "nostr-database", + "tokio", + "tracing", +] + +[[package]] +name = "nostr-sdk" +version = "0.44.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471732576710e779b64f04c55e3f8b5292f865fea228436daf19694f0bf70393" +dependencies = [ + "async-utility", + "nostr", + "nostr-database", + "nostr-gossip", + "nostr-relay-pool", + "tokio", + "tracing", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] name = "num-traits" version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -482,23 +1276,45 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + +[[package]] name = "opaque-debug" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + +[[package]] name = "password-hash" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" dependencies = [ "base64ct", - "rand_core", + "rand_core 0.6.4", "subtle", ] [[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] name = "pbkdf2" version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -515,18 +1331,67 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] -name = "pin-project-lite" -version = "0.2.17" +name = "pest" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] [[package]] -name = "poly1305" -version = "0.8.0" +name = "pest_derive" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" dependencies = [ - "cpufeatures", + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", "opaque-debug", "universal-hash", ] @@ -541,6 +1406,12 @@ dependencies = [ ] [[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] name = "ppv-lite86" version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -550,6 +1421,16 @@ dependencies = [ ] [[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] name = "proc-macro2" version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -559,6 +1440,61 @@ dependencies = [ ] [[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] name = "quote" version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -568,6 +1504,18 @@ dependencies = [ ] [[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] name = "radroots_core" version = "0.1.0-alpha.2" dependencies = [ @@ -588,8 +1536,11 @@ dependencies = [ name = "radroots_events" version = "0.1.0-alpha.2" dependencies = [ + "hex", "radroots_core", "serde", + "serde_json", + "sha2", ] [[package]] @@ -604,10 +1555,23 @@ dependencies = [ name = "radroots_events_codec" version = "0.1.0-alpha.2" dependencies = [ + "nostr", + "radroots_core", + "radroots_events", + "serde", + "serde_json", +] + +[[package]] +name = "radroots_events_codec_wasm" +version = "0.1.0-alpha.2" +dependencies = [ "radroots_core", "radroots_events", + "radroots_events_codec", "serde", "serde_json", + "wasm-bindgen", ] [[package]] @@ -627,12 +1591,14 @@ name = "radroots_identity" version = "0.1.0-alpha.2" dependencies = [ "nostr", + "radroots_events", "radroots_protected_store", + "radroots_runtime", "radroots_runtime_paths", "radroots_secret_vault", "serde", "serde_json", - "thiserror", + "thiserror 1.0.69", "tracing", ] @@ -645,11 +1611,63 @@ dependencies = [ ] [[package]] +name = "radroots_log" +version = "0.1.0-alpha.2" +dependencies = [ + "chrono", + "thiserror 1.0.69", + "tracing", + "tracing-appender", + "tracing-subscriber", +] + +[[package]] +name = "radroots_nostr" +version = "0.1.0-alpha.2" +dependencies = [ + "nostr", + "nostr-sdk", + "radroots_identity", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "radroots_nostr_connect" +version = "0.1.0-alpha.2" +dependencies = [ + "nostr", + "serde", + "serde_json", + "thiserror 1.0.69", + "url", +] + +[[package]] +name = "radroots_nostr_signer" +version = "0.1.0-alpha.2" +dependencies = [ + "hex", + "nostr", + "radroots_identity", + "radroots_nostr", + "radroots_nostr_connect", + "radroots_runtime", + "serde", + "serde_json", + "sha2", + "thiserror 1.0.69", + "url", + "uuid", +] + +[[package]] name = "radroots_protected_store" version = "0.1.0-alpha.2" dependencies = [ "chacha20poly1305", - "getrandom", + "getrandom 0.2.17", "radroots_secret_vault", "serde", "serde_json", @@ -657,6 +1675,19 @@ dependencies = [ ] [[package]] +name = "radroots_replica_db" +version = "0.1.0-alpha.2" +dependencies = [ + "hex", + "radroots_replica_db_schema", + "radroots_sql_core", + "radroots_types", + "serde", + "serde_json", + "sha2", +] + +[[package]] name = "radroots_replica_db_schema" version = "0.1.0-alpha.2" dependencies = [ @@ -674,11 +1705,111 @@ dependencies = [ ] [[package]] +name = "radroots_replica_db_wasm" +version = "0.1.0-alpha.2" +dependencies = [ + "js-sys", + "radroots_replica_db", + "radroots_replica_db_schema", + "radroots_replica_sync", + "radroots_sql_core", + "radroots_sql_wasm_core", + "serde", + "serde-wasm-bindgen", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-test", +] + +[[package]] +name = "radroots_replica_sync" +version = "0.1.0-alpha.2" +dependencies = [ + "base64 0.22.1", + "hex", + "radroots_core", + "radroots_events", + "radroots_events_codec", + "radroots_replica_db", + "radroots_replica_db_schema", + "radroots_sql_core", + "radroots_types", + "serde", + "serde_json", + "sha2", + "uuid", +] + +[[package]] +name = "radroots_replica_sync_wasm" +version = "0.1.0-alpha.2" +dependencies = [ + "base64 0.22.1", + "radroots_events", + "radroots_replica_sync", + "radroots_sql_core", + "radroots_sql_wasm_core", + "serde", + "serde-wasm-bindgen", + "serde_json", + "uuid", + "wasm-bindgen", +] + +[[package]] +name = "radroots_runtime" +version = "0.1.0-alpha.2" +dependencies = [ + "anyhow", + "chacha20poly1305", + "config", + "getrandom 0.2.17", + "radroots_log", + "radroots_protected_store", + "radroots_runtime_paths", + "radroots_secret_vault", + "serde", + "serde_json", + "tempfile", + "thiserror 1.0.69", + "tokio", + "toml", + "tracing", + "zeroize", +] + +[[package]] name = "radroots_runtime_paths" version = "0.1.0-alpha.2" dependencies = [ "serde", - "thiserror", + "thiserror 1.0.69", +] + +[[package]] +name = "radroots_sdk" +version = "0.1.0" +dependencies = [ + "futures", + "nostr", + "radroots_core", + "radroots_events", + "radroots_events_codec", + "radroots_identity", + "radroots_nostr", + "radroots_nostr_connect", + "radroots_nostr_signer", + "radroots_replica_db", + "radroots_replica_db_schema", + "radroots_replica_sync", + "radroots_sql_core", + "radroots_trade", + "reqwest", + "serde", + "serde_json", + "tempfile", + "tokio", + "tokio-tungstenite", ] [[package]] @@ -705,10 +1836,43 @@ name = "radroots_secret_vault" version = "0.1.0-alpha.2" [[package]] +name = "radroots_sql_core" +version = "0.1.0-alpha.2" +dependencies = [ + "chrono", + "radroots_sql_wasm_bridge", + "rusqlite", + "serde", + "serde-wasm-bindgen", + "serde_json", + "uuid", + "wasm-bindgen", +] + +[[package]] +name = "radroots_sql_wasm_bridge" +version = "0.1.0-alpha.2" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "radroots_sql_wasm_core" +version = "0.1.0-alpha.2" +dependencies = [ + "radroots_sql_core", + "radroots_sql_wasm_bridge", + "serde", + "serde-wasm-bindgen", + "wasm-bindgen", +] + +[[package]] name = "radroots_trade" version = "0.1.0-alpha.2" dependencies = [ - "base64", + "base64 0.22.1", "hex", "radroots_core", "radroots_events", @@ -716,7 +1880,7 @@ dependencies = [ "serde", "serde_json", "sha2", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -750,8 +1914,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -761,7 +1935,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -770,49 +1954,232 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.17", ] [[package]] -name = "rust_decimal" -version = "1.42.0" +name = "rand_core" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c5108e3d4d903e21aac27f12ba5377b6b34f9f44b325e4894c7924169d06995" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ - "arrayvec", - "num-traits", - "serde", - "wasm-bindgen", + "getrandom 0.3.4", ] [[package]] -name = "rust_decimal_macros" -version = "1.40.0" +name = "regex-automata" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74a5a6f027e892c7a035c6fddb50435a1fbf5a734ffc0c2a9fed4d0221440519" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ - "quote", - "syn", + "aho-corasick", + "memchr", + "regex-syntax", ] [[package]] -name = "rustversion" -version = "1.0.22" +name = "regex-syntax" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] -name = "salsa20" -version = "0.10.2" +name = "reqwest" +version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "cipher", -] - -[[package]] -name = "scrypt" -version = "0.11.0" + "base64 0.22.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.7", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "ron" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94" +dependencies = [ + "base64 0.21.7", + "bitflags", + "serde", + "serde_derive", +] + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink 0.9.1", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rust-ini" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0698206bcb8882bf2a9ecb4c1e7785db57ff052297085a6efd4fe42302068a" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + +[[package]] +name = "rust_decimal" +version = "1.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c5108e3d4d903e21aac27f12ba5377b6b34f9f44b325e4894c7924169d06995" +dependencies = [ + "arrayvec", + "num-traits", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "rust_decimal_macros" +version = "1.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74a5a6f027e892c7a035c6fddb50435a1fbf5a734ffc0c2a9fed4d0221440519" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scrypt" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" dependencies = [ @@ -828,7 +2195,7 @@ version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" dependencies = [ - "rand", + "rand 0.8.6", "secp256k1-sys", "serde", ] @@ -843,6 +2210,12 @@ dependencies = [ ] [[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] name = "serde" version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -853,6 +2226,17 @@ dependencies = [ ] [[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] name = "serde_core" version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -886,6 +2270,38 @@ dependencies = [ ] [[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] name = "sha2" version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -897,12 +2313,31 @@ dependencies = [ ] [[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] name = "shlex" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] name = "slab" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -915,6 +2350,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] name = "stable_deref_trait" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -927,6 +2372,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + +[[package]] name = "syn" version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -938,6 +2389,15 @@ dependencies = [ ] [[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] name = "synstructure" version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -949,12 +2409,34 @@ dependencies = [ ] [[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] name = "thiserror" version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", ] [[package]] @@ -969,6 +2451,65 @@ dependencies = [ ] [[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] name = "tinystr" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -994,53 +2535,299 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] -name = "tracing" -version = "0.1.44" +name = "tokio" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ + "bytes", + "libc", + "mio", "pin-project-lite", - "tracing-core", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", ] [[package]] -name = "tracing-core" -version = "0.1.36" +name = "tokio-macros" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] -name = "typenum" -version = "1.20.1" +name = "tokio-rustls" +version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] [[package]] -name = "unicode-ident" -version = "1.0.24" +name = "tokio-socks" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "a7e2948f60dbe26b35f2c7fb74ac2854c1fddded0fe9d7548fcc674a246f7615" +dependencies = [ + "either", + "futures-util", + "thiserror 1.0.69", + "tokio", +] [[package]] -name = "unicode-normalization" -version = "0.1.25" +name = "tokio-tungstenite" +version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" dependencies = [ - "tinyvec", + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", ] [[package]] -name = "universal-hash" -version = "0.5.1" +name = "toml" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ - "crypto-common", + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror 2.0.18", + "time", + "tracing-subscriber", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.4", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", "subtle", ] [[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] name = "url" version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1054,24 +2841,90 @@ dependencies = [ ] [[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] name = "utf8_iter" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] +name = "uuid" +version = "1.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] name = "wasm-bindgen" version = "0.2.123" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1086,6 +2939,16 @@ dependencies = [ ] [[package]] +name = "wasm-bindgen-futures" +version = "0.4.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54568702fabf5d4849ce2b90fadfa64168a097eaf4b351ce9df8b687a0086aaf" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] name = "wasm-bindgen-macro" version = "0.2.123" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1118,6 +2981,79 @@ dependencies = [ ] [[package]] +name = "wasm-bindgen-test" +version = "0.3.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e28a0782b173cf2e98f62aacb487c5c021b7c5925df46098675f28e1fa85e159" +dependencies = [ + "async-trait", + "cast", + "js-sys", + "libm", + "minicov", + "nu-ansi-term", + "num-traits", + "oorandom", + "serde", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", + "wasm-bindgen-test-shared", +] + +[[package]] +name = "wasm-bindgen-test-macro" +version = "0.3.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee997551ce1ad5adda03f7ce37ec34b4140fe9f547fd07b46d55901d1ba1a06b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "wasm-bindgen-test-shared" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae0f005eb61f765eff31a79ef71df7e21819731268a868df41192c1e41d6d3e5" + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] name = "web-sys" version = "0.3.100" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1128,12 +3064,378 @@ dependencies = [ ] [[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] name = "writeable" version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] +name = "yaml-rust2" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8902160c4e6f2fb145dbe9d6760a75e3c9522d8bf796ed7047c85919ac7115f8" +dependencies = [ + "arraydeque", + "encoding_rs", + "hashlink 0.8.4", +] + +[[package]] name = "yoke" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/Cargo.toml b/Cargo.toml @@ -2,10 +2,14 @@ members = [ "crates/binding_model", "crates/core_bindings", + "crates/events_codec_wasm", "crates/events_bindings", "crates/events_indexed_bindings", "crates/identity_bindings", + "crates/replica_db_wasm", "crates/replica_db_schema_bindings", + "crates/replica_sync_wasm", + "crates/sdk", "crates/trade_bindings", "crates/types_bindings", "crates/xtask", @@ -24,13 +28,42 @@ readme = "README" [workspace.dependencies] radroots_core = { path = "../lib/crates/core", version = "0.1.0-alpha.2", default-features = false } radroots_events = { path = "../lib/crates/events", version = "0.1.0-alpha.2", default-features = false } +radroots_events_codec = { path = "../lib/crates/events_codec", version = "0.1.0-alpha.2", default-features = false } radroots_events_indexed = { path = "../lib/crates/events_indexed", version = "0.1.0-alpha.2", default-features = false } radroots_identity = { path = "../lib/crates/identity", version = "0.1.0-alpha.2", default-features = false, features = [ "std", ] } +radroots_nostr = { path = "../lib/crates/nostr", version = "0.1.0-alpha.2", default-features = false } +radroots_nostr_connect = { path = "../lib/crates/nostr_connect", version = "0.1.0-alpha.2", default-features = false } +radroots_nostr_signer = { path = "../lib/crates/nostr_signer", version = "0.1.0-alpha.2", default-features = false } +radroots_replica_db = { path = "../lib/crates/replica_db", version = "0.1.0-alpha.2", default-features = false } radroots_replica_db_schema = { path = "../lib/crates/replica_db_schema", version = "0.1.0-alpha.2", default-features = false } +radroots_replica_sync = { path = "../lib/crates/replica_sync", version = "0.1.0-alpha.2", default-features = false } +radroots_sql_core = { path = "../lib/crates/sql_core", version = "0.1.0-alpha.2", default-features = false } +radroots_sql_wasm_core = { path = "../lib/crates/sql_wasm_core", version = "0.1.0-alpha.2", default-features = false } radroots_trade = { path = "../lib/crates/trade", version = "0.1.0-alpha.2", default-features = false, features = [ "serde_json", "std", ] } radroots_types = { path = "../lib/crates/types", version = "0.1.0-alpha.2", default-features = false } + +base64 = { version = "0.22", default-features = false, features = ["alloc"] } +futures = { version = "0.3" } +js-sys = { version = "0.3" } +nostr = { version = "0.44.2" } +reqwest = { version = "0.12", default-features = false, features = [ + "json", + "rustls-tls", +] } +serde = { version = "1", default-features = false, features = [ + "derive", + "alloc", +] } +serde_json = { version = "1", default-features = false, features = ["alloc"] } +serde-wasm-bindgen = { version = "0.6" } +tempfile = { version = "3" } +tokio = { version = "1" } +tokio-tungstenite = "0.26.2" +uuid = { version = "1.22.0", features = ["v4", "v7"] } +wasm-bindgen = { version = "0.2" } +wasm-bindgen-test = { version = "0.3" } diff --git a/crates/events_codec_wasm/Cargo.toml b/crates/events_codec_wasm/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "radroots_events_codec_wasm" +publish = false +version = "0.1.0-alpha.2" +edition.workspace = true +authors = ["Tyson Lupul <tyson@radroots.org>"] +rust-version.workspace = true +license.workspace = true +description = "WebAssembly bindings for radroots_events_codec" +repository.workspace = true +homepage.workspace = true +documentation = "https://docs.rs/radroots_events_codec_wasm" +readme = "README" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +radroots_events = { workspace = true, default-features = false, features = [ + "std", + "serde", +] } +radroots_events_codec = { workspace = true, default-features = false, features = [ + "std", + "serde_json", +] } +serde = { workspace = true } +serde_json = { workspace = true } +wasm-bindgen = { workspace = true } + +[dev-dependencies] +radroots_core = { workspace = true, default-features = false, features = [ + "std", +] } diff --git a/crates/events_codec_wasm/README b/crates/events_codec_wasm/README @@ -0,0 +1,24 @@ +# radroots_events_codec_wasm + +This is the README for `radroots_events_codec_wasm`, which provides WebAssembly +bindings for `radroots_events_codec` in the `radroots` core libraries. + +## Overview + + * wasm-bindgen entry points for event content and tag encoding and decoding; + * specialized tag helpers for farm, list, job, message, plot, and reaction + event families; + * JSON and `JsValue` boundaries built around `serde-wasm-bindgen` and base64 + helpers; + * built as `cdylib` and `rlib` artifacts for wasm consumers. + +## Copyright + +Except as otherwise noted, all files in the `radroots_events_codec_wasm` +distribution are + + Copyright (c) 2020-2026 Tyson Lupul + +For information on usage and redistribution, and for a DISCLAIMER OF ALL +WARRANTIES, see LICENSE included in the `radroots_events_codec_wasm` +distribution. diff --git a/crates/events_codec_wasm/src/lib.rs b/crates/events_codec_wasm/src/lib.rs @@ -0,0 +1,1149 @@ +#![forbid(unsafe_code)] + +use radroots_events::article::RadrootsArticle; +use radroots_events::calendar::{ + RadrootsCalendar, RadrootsCalendarDateEvent, RadrootsCalendarEventRsvp, + RadrootsCalendarTimeEvent, +}; +use radroots_events::comment::RadrootsComment; +use radroots_events::coop::RadrootsCoop; +use radroots_events::document::RadrootsDocument; +use radroots_events::farm::RadrootsFarm; +use radroots_events::farm_crdt::RadrootsFarmCrdtChange; +use radroots_events::farm_file::RadrootsFarmFileMetadata; +use radroots_events::farm_workspace::RadrootsFarmWorkspaceManifest; +use radroots_events::file_metadata::RadrootsFileMetadata; +use radroots_events::follow::RadrootsFollow; +use radroots_events::gift_wrap::RadrootsGiftWrap; +use radroots_events::group::{ + RadrootsGroupAdmins, RadrootsGroupCreateGroup, RadrootsGroupCreateInvite, + RadrootsGroupDeleteEvent, RadrootsGroupDeleteGroup, RadrootsGroupEditMetadata, + RadrootsGroupJoinRequest, RadrootsGroupLeaveRequest, RadrootsGroupMembers, + RadrootsGroupMetadata, RadrootsGroupPutUser, RadrootsGroupRemoveUser, RadrootsGroupRoles, +}; +use radroots_events::http_auth::RadrootsHttpAuth; +use radroots_events::job_feedback::RadrootsJobFeedback; +use radroots_events::job_request::RadrootsJobRequest; +use radroots_events::job_result::RadrootsJobResult; +use radroots_events::list::RadrootsList; +use radroots_events::list_set::RadrootsListSet; +use radroots_events::listing::RadrootsListing; +use radroots_events::message::RadrootsMessage; +use radroots_events::message_file::RadrootsMessageFile; +use radroots_events::plot::RadrootsPlot; +use radroots_events::post::RadrootsPost; +use radroots_events::reaction::RadrootsReaction; +use radroots_events::relay_auth::RadrootsRelayAuth; +use radroots_events::report::RadrootsReport; +use radroots_events::repost::{RadrootsGenericRepost, RadrootsRepost}; +use radroots_events::seal::RadrootsSeal; +use radroots_events_codec::article::encode::article_build_tags; +use radroots_events_codec::calendar::encode::{ + calendar_collection_build_tags, calendar_date_event_build_tags, calendar_time_event_build_tags, + rsvp_build_tags, +}; +use radroots_events_codec::comment::encode::comment_build_tags; +use radroots_events_codec::coop::encode::coop_build_tags; +use radroots_events_codec::document::encode::document_build_tags; +use radroots_events_codec::farm::encode::farm_build_tags; +use radroots_events_codec::farm_crdt::encode::farm_crdt_change_build_tags_with_author; +use radroots_events_codec::farm_file::encode::farm_file_metadata_build_tags; +use radroots_events_codec::farm_workspace::encode::farm_workspace_build_tags; +use radroots_events_codec::file_metadata::encode::file_metadata_build_tags; +use radroots_events_codec::follow::encode::follow_build_tags; +use radroots_events_codec::gift_wrap::encode::gift_wrap_build_tags; +use radroots_events_codec::group::encode::{ + group_admins_build_tags, group_create_group_build_tags, group_create_invite_build_tags, + group_delete_event_build_tags, group_delete_group_build_tags, group_edit_metadata_build_tags, + group_join_request_build_tags, group_leave_request_build_tags, group_members_build_tags, + group_metadata_build_tags, group_put_user_build_tags, group_remove_user_build_tags, + group_roles_build_tags, +}; +use radroots_events_codec::http_auth::encode::http_auth_build_tags; +use radroots_events_codec::job::feedback::encode::job_feedback_build_tags; +use radroots_events_codec::job::request::encode::job_request_build_tags; +use radroots_events_codec::job::result::encode::job_result_build_tags; +use radroots_events_codec::list::encode::list_build_tags; +use radroots_events_codec::list_set::encode::list_set_build_tags; +use radroots_events_codec::listing::tags::{ + listing_tags as listing_tags_impl, listing_tags_full as listing_tags_full_impl, +}; +use radroots_events_codec::message::encode::message_build_tags; +use radroots_events_codec::message_file::encode::message_file_build_tags; +use radroots_events_codec::plot::encode::plot_build_tags; +use radroots_events_codec::post::encode::post_build_tags; +use radroots_events_codec::reaction::encode::reaction_build_tags; +use radroots_events_codec::relay_auth::encode::relay_auth_build_tags; +use radroots_events_codec::report::encode::report_build_tags; +use radroots_events_codec::repost::encode::{generic_repost_build_tags, repost_build_tags}; +use radroots_events_codec::seal::encode::seal_build_tags; +use serde::de::DeserializeOwned; +#[cfg(target_arch = "wasm32")] +use wasm_bindgen::JsValue; +#[cfg(target_arch = "wasm32")] +use wasm_bindgen::prelude::*; + +#[cfg(target_arch = "wasm32")] +type RadrootsJsValue = JsValue; + +#[cfg(not(target_arch = "wasm32"))] +type RadrootsJsValue = String; + +fn err_js<E: ToString>(err: E) -> RadrootsJsValue { + #[cfg(target_arch = "wasm32")] + { + JsValue::from_str(&err.to_string()) + } + #[cfg(not(target_arch = "wasm32"))] + { + err.to_string() + } +} + +fn normalized_payload(input: &str) -> &str { + if input.is_empty() { "{}" } else { input } +} + +fn parse_json<T: DeserializeOwned>(input: &str) -> Result<T, RadrootsJsValue> { + serde_json::from_str(normalized_payload(input)).map_err(err_js) +} + +fn tags_to_json(tags: Vec<Vec<String>>) -> Result<String, RadrootsJsValue> { + serde_json::to_string(&tags).map_err(err_js) +} + +fn build_tags_json<T, E, F>(input: &str, build: F) -> Result<String, RadrootsJsValue> +where + T: DeserializeOwned, + E: ToString, + F: FnOnce(&T) -> Result<Vec<Vec<String>>, E>, +{ + let value = parse_json::<T>(input)?; + let tags = build(&value).map_err(err_js)?; + tags_to_json(tags) +} + +fn build_tags_json_infallible<T, F>(input: &str, build: F) -> Result<String, RadrootsJsValue> +where + T: DeserializeOwned, + F: FnOnce(&T) -> Vec<Vec<String>>, +{ + let value = parse_json::<T>(input)?; + let tags = build(&value); + tags_to_json(tags) +} + +#[derive(serde::Deserialize)] +struct FarmCrdtTagsInput { + change: RadrootsFarmCrdtChange, + author_pubkey: String, +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = listing_tags))] +pub fn listing_tags(listing_json: &str) -> Result<String, RadrootsJsValue> { + build_tags_json::<RadrootsListing, _, _>(listing_json, listing_tags_impl) +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = listing_tags_full))] +pub fn listing_tags_full(listing_json: &str) -> Result<String, RadrootsJsValue> { + build_tags_json::<RadrootsListing, _, _>(listing_json, listing_tags_full_impl) +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = post_tags))] +pub fn post_tags(post_json: &str) -> Result<String, RadrootsJsValue> { + build_tags_json::<RadrootsPost, _, _>(post_json, post_build_tags) +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = comment_tags))] +pub fn comment_tags(comment_json: &str) -> Result<String, RadrootsJsValue> { + build_tags_json::<RadrootsComment, _, _>(comment_json, comment_build_tags) +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = article_tags))] +pub fn article_tags(article_json: &str) -> Result<String, RadrootsJsValue> { + build_tags_json::<RadrootsArticle, _, _>(article_json, article_build_tags) +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = file_metadata_tags))] +pub fn file_metadata_tags(metadata_json: &str) -> Result<String, RadrootsJsValue> { + build_tags_json::<RadrootsFileMetadata, _, _>(metadata_json, file_metadata_build_tags) +} + +#[cfg_attr( + target_arch = "wasm32", + wasm_bindgen(js_name = calendar_date_event_tags) +)] +pub fn calendar_date_event_tags(event_json: &str) -> Result<String, RadrootsJsValue> { + build_tags_json::<RadrootsCalendarDateEvent, _, _>(event_json, calendar_date_event_build_tags) +} + +#[cfg_attr( + target_arch = "wasm32", + wasm_bindgen(js_name = calendar_time_event_tags) +)] +pub fn calendar_time_event_tags(event_json: &str) -> Result<String, RadrootsJsValue> { + build_tags_json::<RadrootsCalendarTimeEvent, _, _>(event_json, calendar_time_event_build_tags) +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = calendar_tags))] +pub fn calendar_tags(calendar_json: &str) -> Result<String, RadrootsJsValue> { + build_tags_json::<RadrootsCalendar, _, _>(calendar_json, calendar_collection_build_tags) +} + +#[cfg_attr( + target_arch = "wasm32", + wasm_bindgen(js_name = calendar_event_rsvp_tags) +)] +pub fn calendar_event_rsvp_tags(rsvp_json: &str) -> Result<String, RadrootsJsValue> { + build_tags_json::<RadrootsCalendarEventRsvp, _, _>(rsvp_json, rsvp_build_tags) +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = repost_tags))] +pub fn repost_tags(repost_json: &str) -> Result<String, RadrootsJsValue> { + build_tags_json::<RadrootsRepost, _, _>(repost_json, repost_build_tags) +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = generic_repost_tags))] +pub fn generic_repost_tags(repost_json: &str) -> Result<String, RadrootsJsValue> { + build_tags_json::<RadrootsGenericRepost, _, _>(repost_json, generic_repost_build_tags) +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = report_tags))] +pub fn report_tags(report_json: &str) -> Result<String, RadrootsJsValue> { + build_tags_json::<RadrootsReport, _, _>(report_json, report_build_tags) +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = follow_tags))] +pub fn follow_tags(follow_json: &str) -> Result<String, RadrootsJsValue> { + build_tags_json::<RadrootsFollow, _, _>(follow_json, follow_build_tags) +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = document_tags))] +pub fn document_tags(document_json: &str) -> Result<String, RadrootsJsValue> { + build_tags_json::<RadrootsDocument, _, _>(document_json, document_build_tags) +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = coop_tags))] +pub fn coop_tags(coop_json: &str) -> Result<String, RadrootsJsValue> { + build_tags_json::<RadrootsCoop, _, _>(coop_json, coop_build_tags) +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = farm_tags))] +pub fn farm_tags(farm_json: &str) -> Result<String, RadrootsJsValue> { + build_tags_json::<RadrootsFarm, _, _>(farm_json, farm_build_tags) +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = list_tags))] +pub fn list_tags(list_json: &str) -> Result<String, RadrootsJsValue> { + build_tags_json::<RadrootsList, _, _>(list_json, list_build_tags) +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = list_set_tags))] +pub fn list_set_tags(list_json: &str) -> Result<String, RadrootsJsValue> { + build_tags_json::<RadrootsListSet, _, _>(list_json, list_set_build_tags) +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = plot_tags))] +pub fn plot_tags(plot_json: &str) -> Result<String, RadrootsJsValue> { + build_tags_json::<RadrootsPlot, _, _>(plot_json, plot_build_tags) +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = job_request_tags))] +pub fn job_request_tags(job_json: &str) -> Result<String, RadrootsJsValue> { + build_tags_json_infallible::<RadrootsJobRequest, _>(job_json, job_request_build_tags) +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = job_result_tags))] +pub fn job_result_tags(job_json: &str) -> Result<String, RadrootsJsValue> { + build_tags_json_infallible::<RadrootsJobResult, _>(job_json, job_result_build_tags) +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = job_feedback_tags))] +pub fn job_feedback_tags(job_json: &str) -> Result<String, RadrootsJsValue> { + build_tags_json_infallible::<RadrootsJobFeedback, _>(job_json, job_feedback_build_tags) +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = reaction_tags))] +pub fn reaction_tags(reaction_json: &str) -> Result<String, RadrootsJsValue> { + build_tags_json::<RadrootsReaction, _, _>(reaction_json, reaction_build_tags) +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = message_tags))] +pub fn message_tags(message_json: &str) -> Result<String, RadrootsJsValue> { + build_tags_json::<RadrootsMessage, _, _>(message_json, message_build_tags) +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = message_file_tags))] +pub fn message_file_tags(message_json: &str) -> Result<String, RadrootsJsValue> { + build_tags_json::<RadrootsMessageFile, _, _>(message_json, message_file_build_tags) +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = seal_tags))] +pub fn seal_tags(seal_json: &str) -> Result<String, RadrootsJsValue> { + build_tags_json::<RadrootsSeal, _, _>(seal_json, seal_build_tags) +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = gift_wrap_tags))] +pub fn gift_wrap_tags(gift_wrap_json: &str) -> Result<String, RadrootsJsValue> { + build_tags_json::<RadrootsGiftWrap, _, _>(gift_wrap_json, gift_wrap_build_tags) +} + +#[cfg_attr( + target_arch = "wasm32", + wasm_bindgen(js_name = farm_workspace_manifest_tags) +)] +pub fn farm_workspace_manifest_tags(workspace_json: &str) -> Result<String, RadrootsJsValue> { + build_tags_json::<RadrootsFarmWorkspaceManifest, _, _>( + workspace_json, + farm_workspace_build_tags, + ) +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = farm_crdt_change_tags))] +pub fn farm_crdt_change_tags(input_json: &str) -> Result<String, RadrootsJsValue> { + let input = parse_json::<FarmCrdtTagsInput>(input_json)?; + let tags = farm_crdt_change_build_tags_with_author(&input.change, Some(&input.author_pubkey)) + .map_err(err_js)?; + tags_to_json(tags) +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = farm_file_metadata_tags))] +pub fn farm_file_metadata_tags(file_json: &str) -> Result<String, RadrootsJsValue> { + build_tags_json::<RadrootsFarmFileMetadata, _, _>(file_json, farm_file_metadata_build_tags) +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = relay_auth_tags))] +pub fn relay_auth_tags(auth_json: &str) -> Result<String, RadrootsJsValue> { + build_tags_json::<RadrootsRelayAuth, _, _>(auth_json, relay_auth_build_tags) +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = http_auth_tags))] +pub fn http_auth_tags(auth_json: &str) -> Result<String, RadrootsJsValue> { + build_tags_json::<RadrootsHttpAuth, _, _>(auth_json, http_auth_build_tags) +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = group_put_user_tags))] +pub fn group_put_user_tags(group_json: &str) -> Result<String, RadrootsJsValue> { + build_tags_json::<RadrootsGroupPutUser, _, _>(group_json, group_put_user_build_tags) +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = group_remove_user_tags))] +pub fn group_remove_user_tags(group_json: &str) -> Result<String, RadrootsJsValue> { + build_tags_json::<RadrootsGroupRemoveUser, _, _>(group_json, group_remove_user_build_tags) +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = group_create_group_tags))] +pub fn group_create_group_tags(group_json: &str) -> Result<String, RadrootsJsValue> { + build_tags_json::<RadrootsGroupCreateGroup, _, _>(group_json, group_create_group_build_tags) +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = group_edit_metadata_tags))] +pub fn group_edit_metadata_tags(group_json: &str) -> Result<String, RadrootsJsValue> { + build_tags_json::<RadrootsGroupEditMetadata, _, _>(group_json, group_edit_metadata_build_tags) +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = group_delete_group_tags))] +pub fn group_delete_group_tags(group_json: &str) -> Result<String, RadrootsJsValue> { + build_tags_json::<RadrootsGroupDeleteGroup, _, _>(group_json, group_delete_group_build_tags) +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = group_delete_event_tags))] +pub fn group_delete_event_tags(group_json: &str) -> Result<String, RadrootsJsValue> { + build_tags_json::<RadrootsGroupDeleteEvent, _, _>(group_json, group_delete_event_build_tags) +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = group_create_invite_tags))] +pub fn group_create_invite_tags(group_json: &str) -> Result<String, RadrootsJsValue> { + build_tags_json::<RadrootsGroupCreateInvite, _, _>(group_json, group_create_invite_build_tags) +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = group_join_request_tags))] +pub fn group_join_request_tags(group_json: &str) -> Result<String, RadrootsJsValue> { + build_tags_json::<RadrootsGroupJoinRequest, _, _>(group_json, group_join_request_build_tags) +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = group_leave_request_tags))] +pub fn group_leave_request_tags(group_json: &str) -> Result<String, RadrootsJsValue> { + build_tags_json::<RadrootsGroupLeaveRequest, _, _>(group_json, group_leave_request_build_tags) +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = group_metadata_tags))] +pub fn group_metadata_tags(group_json: &str) -> Result<String, RadrootsJsValue> { + build_tags_json::<RadrootsGroupMetadata, _, _>(group_json, group_metadata_build_tags) +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = group_admins_tags))] +pub fn group_admins_tags(group_json: &str) -> Result<String, RadrootsJsValue> { + build_tags_json::<RadrootsGroupAdmins, _, _>(group_json, group_admins_build_tags) +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = group_members_tags))] +pub fn group_members_tags(group_json: &str) -> Result<String, RadrootsJsValue> { + build_tags_json::<RadrootsGroupMembers, _, _>(group_json, group_members_build_tags) +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen(js_name = group_roles_tags))] +pub fn group_roles_tags(group_json: &str) -> Result<String, RadrootsJsValue> { + build_tags_json::<RadrootsGroupRoles, _, _>(group_json, group_roles_build_tags) +} + +#[cfg(test)] +mod tests { + use super::*; + use radroots_core::{ + RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, + RadrootsCoreQuantityPrice, RadrootsCoreUnit, + }; + use radroots_events::farm::RadrootsFarmRef; + use radroots_events::farm_crdt::{ + RADROOTS_FARM_CRDT_CHANGE_SCHEMA, RadrootsCrdtBackend, RadrootsFarmCrdtDocumentKind, + RadrootsFarmSemanticKind, + }; + use radroots_events::farm_file::{ + RadrootsFarmFileDimensions, RadrootsFarmFileMetadata, RadrootsFarmFileSource, + }; + use radroots_events::farm_workspace::{ + RADROOTS_FARM_WORKSPACE_PROTOCOL_VERSION, RADROOTS_FARM_WORKSPACE_SCHEMA, + RadrootsFarmWorkspaceManifest, RadrootsFarmWorkspaceMediaServer, RadrootsFarmWorkspaceRef, + RadrootsFarmWorkspaceRelay, RadrootsFarmWorkspaceRelayMode, + }; + use radroots_events::group::{ + RadrootsGroupAdmins, RadrootsGroupCreateGroup, RadrootsGroupCreateInvite, + RadrootsGroupDeleteEvent, RadrootsGroupDeleteGroup, RadrootsGroupEditMetadata, + RadrootsGroupEditableMetadata, RadrootsGroupJoinRequest, RadrootsGroupLeaveRequest, + RadrootsGroupMembers, RadrootsGroupMetadata, RadrootsGroupPutUser, RadrootsGroupRemoveUser, + RadrootsGroupRole, RadrootsGroupRoles, RadrootsGroupUserRef, + }; + use radroots_events::http_auth::RadrootsHttpAuth; + use radroots_events::job::JobInputType; + use radroots_events::job_request::{RadrootsJobInput, RadrootsJobParam}; + use radroots_events::kinds::KIND_FARM_FILE_METADATA; + use radroots_events::listing::{RadrootsListingBin, RadrootsListingProduct}; + use radroots_events::relay_auth::RadrootsRelayAuth; + use radroots_events::social::{ + RadrootsCalendarDateValue, RadrootsCalendarEventFreeBusy, RadrootsCalendarEventRsvpStatus, + RadrootsCalendarParticipant, RadrootsReportFileTarget, RadrootsReportType, + RadrootsSocialFarmAnchor, RadrootsSocialLocation, RadrootsSocialMediaDimensions, + RadrootsSocialMediaMetadata, RadrootsSocialTarget, + }; + + fn sample_listing() -> RadrootsListing { + let quantity = + RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::Each); + let price = RadrootsCoreQuantityPrice::new( + RadrootsCoreMoney::new(RadrootsCoreDecimal::from(10u32), RadrootsCoreCurrency::USD), + quantity.clone(), + ); + + RadrootsListing { + d_tag: "AAAAAAAAAAAAAAAAAAAAAg".parse().expect("listing d tag"), + published_at: None, + farm: RadrootsFarmRef { + pubkey: "farm_pubkey".to_string(), + d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(), + }, + product: RadrootsListingProduct { + key: "sku".to_string(), + title: "widget".to_string(), + category: "tools".to_string(), + summary: None, + process: None, + lot: None, + location: None, + profile: None, + year: None, + }, + primary_bin_id: "bin-1".parse().expect("primary bin id"), + bins: vec![RadrootsListingBin { + bin_id: "bin-1".parse().expect("bin id"), + quantity, + price_per_canonical_unit: price, + display_amount: None, + display_unit: None, + display_label: None, + display_price: None, + display_price_unit: None, + }], + resource_area: None, + plot: None, + discounts: None, + inventory_available: None, + availability: None, + delivery_method: None, + location: None, + images: None, + } + } + + fn synthetic_pubkey(seed: char) -> String { + seed.to_string().repeat(64) + } + + fn synthetic_event_id(seed: char) -> String { + seed.to_string().repeat(64) + } + + fn social_farm_anchor() -> RadrootsSocialFarmAnchor { + RadrootsSocialFarmAnchor { + farm: RadrootsFarmRef { + pubkey: synthetic_pubkey('a'), + d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(), + }, + relays: Some(vec!["wss://relay.example.test".to_string()]), + } + } + + fn event_target(kind: u32, seed: char) -> RadrootsSocialTarget { + RadrootsSocialTarget::Event { + id: synthetic_event_id(seed), + author: Some(synthetic_pubkey('b')), + event_kind: Some(kind), + relays: Some(vec!["wss://relay.example.test".to_string()]), + } + } + + fn address_target(kind: u32, d_tag: &str) -> RadrootsSocialTarget { + let author = synthetic_pubkey('c'); + RadrootsSocialTarget::Address { + address: format!("{kind}:{author}:{d_tag}"), + author: Some(author), + event_kind: Some(kind), + relays: Some(vec!["wss://relay2.example.test".to_string()]), + } + } + + fn social_location() -> RadrootsSocialLocation { + RadrootsSocialLocation { + name: Some("field edge".to_string()), + geohash: Some("c23nb62w20st".to_string()), + } + } + + fn sample_post() -> RadrootsPost { + RadrootsPost { + content: "field update".to_string(), + farm: Some(social_farm_anchor()), + address_refs: Some(vec![address_target(30023, "AAAAAAAAAAAAAAAAAAAAAQ")]), + location: Some(social_location()), + topics: Some(vec!["soil".to_string(), "market".to_string()]), + quote_refs: Some(vec![event_target(30023, 'd')]), + media: Some(vec![RadrootsSocialMediaMetadata { + url: Some("https://media.example.test/field.jpg".to_string()), + mime_type: Some("image/jpeg".to_string()), + sha256: Some( + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string(), + ), + original_sha256: None, + size: Some(4096), + dimensions: Some(RadrootsSocialMediaDimensions { + width: 1200, + height: 800, + }), + blurhash: None, + thumbnails: None, + image: None, + summary: Some("field photo".to_string()), + alt: Some("rows after harvest".to_string()), + fallback: None, + magnet: Some("magnet:?xt=urn:btih:abc".to_string()), + content_hashes: Some(vec!["sha256:field".to_string()]), + services: Some(vec!["https://media.example.test".to_string()]), + imeta: None, + }]), + } + } + + fn sample_article() -> RadrootsArticle { + RadrootsArticle { + d_tag: "AAAAAAAAAAAAAAAAAAAAAg".to_string(), + title: "soil notes".to_string(), + content: "# soil notes".to_string(), + summary: Some("cover crop observations".to_string()), + image: Some("https://media.example.test/article.jpg".to_string()), + published_at: Some(1_780_000_000), + farm: Some(social_farm_anchor()), + location: Some(social_location()), + topics: Some(vec!["soil".to_string(), "cover-crops".to_string()]), + } + } + + fn sample_public_file_metadata() -> RadrootsFileMetadata { + RadrootsFileMetadata { + url: "https://media.example.test/public.jpg".to_string(), + mime_type: "image/jpeg".to_string(), + sha256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string(), + original_sha256: Some( + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".to_string(), + ), + size: Some(4096), + dimensions: Some(RadrootsSocialMediaDimensions { + width: 1200, + height: 800, + }), + blurhash: None, + thumbnails: None, + summary: Some("public field photo".to_string()), + alt: Some("rows after harvest".to_string()), + fallback: Some("https://media.example.test/fallback.jpg".to_string()), + magnet: Some("magnet:?xt=urn:btih:abc".to_string()), + content_hashes: Some(vec!["sha256:field".to_string()]), + services: Some(vec!["https://media.example.test".to_string()]), + content: Some("caption".to_string()), + } + } + + fn sample_calendar_date_event() -> RadrootsCalendarDateEvent { + RadrootsCalendarDateEvent { + d_tag: "AAAAAAAAAAAAAAAAAAAAAw".to_string(), + title: "market day".to_string(), + start: "2026-06-20".to_string(), + description: Some("Farm stand pickup window.".to_string()), + end: Some("2026-06-21".to_string()), + days: Some(vec![RadrootsCalendarDateValue { + value: "2026-06-20".to_string(), + }]), + location: Some(social_location()), + summary: Some("weekly pickup".to_string()), + image: None, + participants: Some(vec![RadrootsCalendarParticipant { + pubkey: synthetic_pubkey('e'), + relay: Some("wss://relay.example.test".to_string()), + role: Some("host".to_string()), + }]), + } + } + + fn sample_calendar_time_event() -> RadrootsCalendarTimeEvent { + RadrootsCalendarTimeEvent { + d_tag: "AAAAAAAAAAAAAAAAAAAA-A".to_string(), + title: "wash pack shift".to_string(), + start: 1_781_895_600, + dates: vec![RadrootsCalendarDateValue { + value: "2026-06-20".to_string(), + }], + description: Some("Prepare CSA bins before pickup.".to_string()), + end: Some(1_781_899_200), + start_tzid: Some("America/Vancouver".to_string()), + end_tzid: Some("America/Vancouver".to_string()), + location: Some(social_location()), + summary: Some("field crew".to_string()), + image: None, + participants: None, + } + } + + fn sample_calendar() -> RadrootsCalendar { + RadrootsCalendar { + d_tag: "AAAAAAAAAAAAAAAAAAAA_A".to_string(), + title: "farm calendar".to_string(), + events: vec![address_target(31923, "AAAAAAAAAAAAAAAAAAAA-A")], + description: Some("Shared schedule for farm operations.".to_string()), + summary: Some("field schedule".to_string()), + image: None, + } + } + + fn sample_calendar_rsvp() -> RadrootsCalendarEventRsvp { + RadrootsCalendarEventRsvp { + d_tag: "AAAAAAAAAAAAAAAAAAAAAQ".to_string(), + event: address_target(31923, "AAAAAAAAAAAAAAAAAAAA-A"), + event_id: Some(synthetic_event_id('f')), + status: RadrootsCalendarEventRsvpStatus::Tentative, + free_busy: Some(RadrootsCalendarEventFreeBusy::Busy), + note: Some("depends on harvest".to_string()), + participants: None, + } + } + + fn sample_comment() -> RadrootsComment { + RadrootsComment { + root: event_target(30023, 'a'), + parent: address_target(30023, "AAAAAAAAAAAAAAAAAAAAAg"), + content: "great notes".to_string(), + } + } + + fn sample_reaction() -> RadrootsReaction { + RadrootsReaction { + target: address_target(30023, "AAAAAAAAAAAAAAAAAAAAAg"), + content: String::new(), + } + } + + fn sample_repost() -> RadrootsRepost { + RadrootsRepost { + target: event_target(1, 'b'), + content: Some("field update".to_string()), + } + } + + fn sample_generic_repost() -> RadrootsGenericRepost { + RadrootsGenericRepost { + target: address_target(30023, "AAAAAAAAAAAAAAAAAAAAAg"), + target_kind: 30023, + content: Some("article share".to_string()), + } + } + + fn sample_report() -> RadrootsReport { + RadrootsReport { + reported_pubkey: synthetic_pubkey('b'), + report_type: RadrootsReportType::Spam, + event: Some(event_target(1, 'c')), + file: Some(RadrootsReportFileTarget { + sha256: Some( + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string(), + ), + url: Some("https://media.example.test/bad.jpg".to_string()), + magnet: None, + }), + content: Some("spam report".to_string()), + } + } + + fn sample_job_request() -> RadrootsJobRequest { + RadrootsJobRequest { + kind: 5100, + inputs: vec![RadrootsJobInput { + data: "alpha".to_string(), + input_type: JobInputType::Text, + relay: None, + marker: None, + }], + output: None, + params: vec![RadrootsJobParam { + key: "mode".to_string(), + value: "fast".to_string(), + }], + bid_sat: Some(42), + relays: vec!["wss://relay.example.com".to_string()], + providers: vec!["provider-a".to_string()], + topics: vec!["topic-a".to_string()], + encrypted: false, + } + } + + fn sample_workspace_manifest() -> RadrootsFarmWorkspaceManifest { + RadrootsFarmWorkspaceManifest { + d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(), + schema: RADROOTS_FARM_WORKSPACE_SCHEMA.to_string(), + farm_group_id: "field-group".to_string(), + name: "Small Regen Farm".to_string(), + owner_pubkey: "workspace_owner_pubkey".to_string(), + farm: Some(RadrootsFarmRef { + pubkey: "farm_pubkey".to_string(), + d_tag: "AAAAAAAAAAAAAAAAAAAAAQ".to_string(), + }), + relays: vec![RadrootsFarmWorkspaceRelay { + url: "wss://relay.example.invalid/farm/field-group".to_string(), + mode: RadrootsFarmWorkspaceRelayMode::ReadWrite, + }], + media_servers: vec![RadrootsFarmWorkspaceMediaServer { + url: "https://media.example.invalid/farm/field-group".to_string(), + service: "RadrootsPrivateMedia".to_string(), + }], + supported_kinds: vec![78, 30078, KIND_FARM_FILE_METADATA], + protocol_version: RADROOTS_FARM_WORKSPACE_PROTOCOL_VERSION.to_string(), + created_at_ms: 1_780_000_000_000, + updated_at_ms: None, + } + } + + fn sample_crdt_change() -> RadrootsFarmCrdtChange { + RadrootsFarmCrdtChange { + schema: RADROOTS_FARM_CRDT_CHANGE_SCHEMA.to_string(), + workspace: RadrootsFarmWorkspaceRef { + pubkey: "workspace_pubkey".to_string(), + d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(), + }, + farm_group_id: "field-group".to_string(), + document_id: "AAAAAAAAAAAAAAAAAAAAAg".to_string(), + document_kind: RadrootsFarmCrdtDocumentKind::FarmTask, + crdt_backend: RadrootsCrdtBackend::Automerge, + crdt_backend_version: Some("0.x".to_string()), + actor_id: "actor_abc".to_string(), + change_hash: "crdt_hash_abc".to_string(), + dependencies: Vec::new(), + encoded_change: "abc-DEF_012".to_string(), + semantic_kind: RadrootsFarmSemanticKind::FarmTaskCreate, + business_time_ms: 1_780_000_000_000, + author_member_id: Some("member_abc".to_string()), + app_version: Some("0.1.0".to_string()), + } + } + + fn sample_file_metadata() -> RadrootsFarmFileMetadata { + RadrootsFarmFileMetadata { + d_tag: "AAAAAAAAAAAAAAAAAAAAAQ".to_string(), + workspace: RadrootsFarmWorkspaceRef { + pubkey: "workspace_pubkey".to_string(), + d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(), + }, + farm_group_id: "field-group".to_string(), + owner_document_id: "AAAAAAAAAAAAAAAAAAAAAg".to_string(), + owner_document_kind: RadrootsFarmCrdtDocumentKind::FarmTask, + caption: Some("Tomatoes harvested from Patch Y.".to_string()), + url: "https://media.example.invalid/blob/sha256".to_string(), + mime_type: "image/jpeg".to_string(), + sha256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string(), + original_sha256: None, + size_bytes: Some(123_456), + dimensions: Some(RadrootsFarmFileDimensions { w: 1600, h: 1200 }), + blurhash: None, + thumb: Some(RadrootsFarmFileSource { + url: "https://media.example.invalid/thumb/sha256".to_string(), + mime_type: Some("image/jpeg".to_string()), + dimensions: Some(RadrootsFarmFileDimensions { w: 320, h: 240 }), + }), + image: None, + alt: Some("Harvested tomatoes in a crate".to_string()), + fallbacks: Vec::new(), + } + } + + fn sample_group_metadata() -> RadrootsGroupEditableMetadata { + RadrootsGroupEditableMetadata { + name: Some("Small Regen Farm".to_string()), + about: Some("Field app group".to_string()), + picture: Some("https://media.example.invalid/group.png".to_string()), + is_private: false, + is_restricted: true, + is_closed: false, + is_hidden: false, + supported_kinds: Some(vec![78, 30078, KIND_FARM_FILE_METADATA]), + } + } + + fn sample_group_user(role: &str) -> RadrootsGroupUserRef { + RadrootsGroupUserRef { + pubkey: format!("{role}_pubkey"), + roles: vec![role.to_string()], + } + } + + fn sample_group_role() -> RadrootsGroupRole { + RadrootsGroupRole { + name: "member".to_string(), + description: Some("can read and write group events".to_string()), + permissions: vec!["read".to_string(), "write".to_string()], + } + } + + fn assert_tags_json(value: Result<String, RadrootsJsValue>) { + let tags = tags_json(value); + assert!(!tags.is_empty()); + } + + fn tags_json(value: Result<String, RadrootsJsValue>) -> Vec<Vec<String>> { + let json = value.expect("tags json"); + serde_json::from_str(&json).expect("tags") + } + + fn has_tag(tags: &[Vec<String>], key: &str, value: &str) -> bool { + tags.iter().any(|tag| { + tag.first().map(|entry| entry.as_str()) == Some(key) + && tag.get(1).map(|entry| entry.as_str()) == Some(value) + }) + } + + #[test] + fn bindings_reject_invalid_json() { + let bindings: [fn(&str) -> Result<String, RadrootsJsValue>; 46] = [ + listing_tags, + listing_tags_full, + post_tags, + comment_tags, + article_tags, + file_metadata_tags, + calendar_date_event_tags, + calendar_time_event_tags, + calendar_tags, + calendar_event_rsvp_tags, + repost_tags, + generic_repost_tags, + report_tags, + follow_tags, + document_tags, + coop_tags, + farm_tags, + list_tags, + list_set_tags, + plot_tags, + job_request_tags, + job_result_tags, + job_feedback_tags, + reaction_tags, + message_tags, + message_file_tags, + seal_tags, + gift_wrap_tags, + farm_workspace_manifest_tags, + farm_crdt_change_tags, + farm_file_metadata_tags, + relay_auth_tags, + http_auth_tags, + group_put_user_tags, + group_remove_user_tags, + group_create_group_tags, + group_edit_metadata_tags, + group_delete_group_tags, + group_delete_event_tags, + group_create_invite_tags, + group_join_request_tags, + group_leave_request_tags, + group_metadata_tags, + group_admins_tags, + group_members_tags, + group_roles_tags, + ]; + + for binding in bindings { + assert!(binding("{").is_err()); + } + assert!(listing_tags("").is_err()); + } + + #[test] + fn bindings_encode_to_json_when_input_is_valid() { + let listing_json = serde_json::to_string(&sample_listing()).expect("listing json"); + let listing_tags_json = listing_tags(&listing_json).expect("listing tags"); + let listing_tags: Vec<Vec<String>> = + serde_json::from_str(&listing_tags_json).expect("listing tags json"); + assert!(!listing_tags.is_empty()); + + let request_json = serde_json::to_string(&sample_job_request()).expect("request json"); + let request_tags_json = job_request_tags(&request_json).expect("request tags"); + let request_tags: Vec<Vec<String>> = + serde_json::from_str(&request_tags_json).expect("request tags json"); + assert!(!request_tags.is_empty()); + } + + #[test] + fn social_bindings_encode_to_json_when_input_is_valid() { + assert_tags_json(post_tags( + &serde_json::to_string(&sample_post()).expect("post json"), + )); + assert_tags_json(comment_tags( + &serde_json::to_string(&sample_comment()).expect("comment json"), + )); + assert_tags_json(article_tags( + &serde_json::to_string(&sample_article()).expect("article json"), + )); + assert_tags_json(file_metadata_tags( + &serde_json::to_string(&sample_public_file_metadata()).expect("file json"), + )); + assert_tags_json(calendar_date_event_tags( + &serde_json::to_string(&sample_calendar_date_event()).expect("date json"), + )); + let time_tags = tags_json(calendar_time_event_tags( + &serde_json::to_string(&sample_calendar_time_event()).expect("time json"), + )); + assert!(has_tag(&time_tags, "D", "2026-06-20")); + assert_tags_json(calendar_tags( + &serde_json::to_string(&sample_calendar()).expect("calendar json"), + )); + assert_tags_json(calendar_event_rsvp_tags( + &serde_json::to_string(&sample_calendar_rsvp()).expect("rsvp json"), + )); + assert_tags_json(reaction_tags( + &serde_json::to_string(&sample_reaction()).expect("reaction json"), + )); + assert_tags_json(repost_tags( + &serde_json::to_string(&sample_repost()).expect("repost json"), + )); + assert_tags_json(generic_repost_tags( + &serde_json::to_string(&sample_generic_repost()).expect("generic repost json"), + )); + assert_tags_json(report_tags( + &serde_json::to_string(&sample_report()).expect("report json"), + )); + } + + #[test] + fn social_bindings_surface_builder_errors() { + let mut article = sample_article(); + article.d_tag.clear(); + assert!(article_tags(&serde_json::to_string(&article).expect("article json")).is_err()); + + let mut comment = sample_comment(); + comment.root = event_target(1, 'a'); + assert!(comment_tags(&serde_json::to_string(&comment).expect("comment json")).is_err()); + + let mut reaction = sample_reaction(); + reaction.target = RadrootsSocialTarget::External { + id: "https://example.test/object".to_string(), + external_kind: "web".to_string(), + hint: None, + }; + assert!(reaction_tags(&serde_json::to_string(&reaction).expect("reaction json")).is_err()); + + let mut rsvp = sample_calendar_rsvp(); + rsvp.event = event_target(31923, 'f'); + assert!( + calendar_event_rsvp_tags(&serde_json::to_string(&rsvp).expect("rsvp json")).is_err() + ); + + let mut report = sample_report(); + report.reported_pubkey.clear(); + assert!(report_tags(&serde_json::to_string(&report).expect("report json")).is_err()); + } + + #[test] + fn field_bindings_encode_to_json_when_input_is_valid() { + let workspace_json = + serde_json::to_string(&sample_workspace_manifest()).expect("workspace json"); + assert_tags_json(farm_workspace_manifest_tags(&workspace_json)); + + let crdt_json = serde_json::json!({ + "change": sample_crdt_change(), + "author_pubkey": "author_pubkey" + }) + .to_string(); + assert_tags_json(farm_crdt_change_tags(&crdt_json)); + + let file_json = serde_json::to_string(&sample_file_metadata()).expect("file json"); + assert_tags_json(farm_file_metadata_tags(&file_json)); + + let relay_auth_json = serde_json::to_string(&RadrootsRelayAuth { + relay: "wss://relay.example.invalid/farm/field-group".to_string(), + challenge: "relay-provided-challenge".to_string(), + }) + .expect("relay auth json"); + assert_tags_json(relay_auth_tags(&relay_auth_json)); + + let http_auth_json = serde_json::to_string(&RadrootsHttpAuth { + url: "https://media.example.invalid/upload".to_string(), + method: "POST".to_string(), + payload_sha256: Some( + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string(), + ), + }) + .expect("http auth json"); + assert_tags_json(http_auth_tags(&http_auth_json)); + } + + #[test] + fn group_bindings_encode_to_json_when_input_is_valid() { + let metadata = sample_group_metadata(); + assert_tags_json(group_put_user_tags( + &serde_json::to_string(&RadrootsGroupPutUser { + group_id: "field-group".to_string(), + message: Some("add member".to_string()), + pubkey: "member_pubkey".to_string(), + roles: vec!["member".to_string()], + }) + .expect("put user json"), + )); + assert_tags_json(group_remove_user_tags( + &serde_json::to_string(&RadrootsGroupRemoveUser { + group_id: "field-group".to_string(), + message: Some("remove member".to_string()), + pubkey: "member_pubkey".to_string(), + }) + .expect("remove user json"), + )); + assert_tags_json(group_create_group_tags( + &serde_json::to_string(&RadrootsGroupCreateGroup { + group_id: "field-group".to_string(), + message: Some("create group".to_string()), + metadata: metadata.clone(), + }) + .expect("create group json"), + )); + assert_tags_json(group_edit_metadata_tags( + &serde_json::to_string(&RadrootsGroupEditMetadata { + group_id: "field-group".to_string(), + message: Some("edit metadata".to_string()), + metadata: metadata.clone(), + }) + .expect("edit metadata json"), + )); + assert_tags_json(group_delete_group_tags( + &serde_json::to_string(&RadrootsGroupDeleteGroup { + group_id: "field-group".to_string(), + message: Some("delete group".to_string()), + }) + .expect("delete group json"), + )); + assert_tags_json(group_delete_event_tags( + &serde_json::to_string(&RadrootsGroupDeleteEvent { + group_id: "field-group".to_string(), + message: Some("delete event".to_string()), + event_id: "event_id".to_string(), + }) + .expect("delete event json"), + )); + let invite_tags = tags_json(group_create_invite_tags( + &serde_json::to_string(&RadrootsGroupCreateInvite { + group_id: "field-group".to_string(), + message: Some("join the field group".to_string()), + code: "invite-code".to_string(), + }) + .expect("invite json"), + )); + assert!(invite_tags.contains(&vec!["code".to_string(), "invite-code".to_string()])); + assert_tags_json(group_join_request_tags( + &serde_json::to_string(&RadrootsGroupJoinRequest { + group_id: "field-group".to_string(), + message: Some("requesting access".to_string()), + code: Some("invite-code".to_string()), + }) + .expect("join json"), + )); + assert_tags_json(group_leave_request_tags( + &serde_json::to_string(&RadrootsGroupLeaveRequest { + group_id: "field-group".to_string(), + message: Some("leaving".to_string()), + }) + .expect("leave json"), + )); + let metadata_tags = tags_json(group_metadata_tags( + &serde_json::to_string(&RadrootsGroupMetadata { + d_tag: "field-group".to_string(), + metadata, + }) + .expect("metadata json"), + )); + assert!(metadata_tags.contains(&vec!["restricted".to_string()])); + assert!(metadata_tags.contains(&vec![ + "supported_kinds".to_string(), + "78".to_string(), + "30078".to_string(), + KIND_FARM_FILE_METADATA.to_string() + ])); + assert_tags_json(group_admins_tags( + &serde_json::to_string(&RadrootsGroupAdmins { + d_tag: "field-group".to_string(), + description: Some("group admins".to_string()), + admins: vec![sample_group_user("admin")], + }) + .expect("admins json"), + )); + assert_tags_json(group_members_tags( + &serde_json::to_string(&RadrootsGroupMembers { + d_tag: "field-group".to_string(), + description: Some("group members".to_string()), + members: vec![sample_group_user("member")], + }) + .expect("members json"), + )); + assert_tags_json(group_roles_tags( + &serde_json::to_string(&RadrootsGroupRoles { + d_tag: "field-group".to_string(), + description: Some("group roles".to_string()), + roles: vec![sample_group_role()], + }) + .expect("roles json"), + )); + } + + #[test] + fn listing_bindings_surface_builder_errors() { + let mut listing_json = serde_json::to_value(sample_listing()).expect("listing value"); + listing_json["bins"] = serde_json::Value::Array(Vec::new()); + let listing_json = serde_json::to_string(&listing_json).expect("listing json"); + + assert!(listing_tags(&listing_json).is_err()); + assert!(listing_tags_full(&listing_json).is_err()); + } +} diff --git a/crates/replica_db_wasm/Cargo.toml b/crates/replica_db_wasm/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "radroots_replica_db_wasm" +publish = false +version = "0.1.0-alpha.2" +edition.workspace = true +authors = ["Tyson Lupul <tyson@radroots.org>"] +rust-version.workspace = true +license.workspace = true +description = "WebAssembly bindings for radroots_replica_db" +repository.workspace = true +homepage.workspace = true +documentation = "https://docs.rs/radroots_replica_db_wasm" +readme = "README" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +radroots_sql_core = { workspace = true, features = ["bridge"] } +radroots_sql_wasm_core = { workspace = true, default-features = false, features = [ + "bridge", +] } +radroots_replica_db = { workspace = true } +radroots_replica_db_schema = { workspace = true } +radroots_replica_sync = { workspace = true, features = ["std"] } +js-sys = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +serde-wasm-bindgen = { workspace = true } +wasm-bindgen = { workspace = true } + +[dev-dependencies] +wasm-bindgen-test = { workspace = true } + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } diff --git a/crates/replica_db_wasm/README b/crates/replica_db_wasm/README @@ -0,0 +1,25 @@ +# radroots_replica_db_wasm + +This is the README for `radroots_replica_db_wasm`, which provides WebAssembly +bindings for `radroots_replica_db` in the `radroots` core libraries. + +## Overview + + * a wasm32-only wrapper around the replica database runtime surface; + * integration with `radroots_sql_wasm_core` for browser and worker SQL + execution; + * a small target-specific entry point layer rather than a separate database + implementation; + * intended for JavaScript-facing callers that need the replica database APIs + in wasm builds. + +## Copyright + +Except as otherwise noted, all files in the `radroots_replica_db_wasm` +distribution are + + Copyright (c) 2020-2026 Tyson Lupul + +For information on usage and redistribution, and for a DISCLAIMER OF ALL +WARRANTIES, see LICENSE included in the `radroots_replica_db_wasm` +distribution. diff --git a/crates/replica_db_wasm/src/lib.rs b/crates/replica_db_wasm/src/lib.rs @@ -0,0 +1,29 @@ +#![cfg(any(target_arch = "wasm32", coverage_nightly))] +#![forbid(unsafe_code)] + +#[cfg(target_arch = "wasm32")] +mod utils; +#[cfg(target_arch = "wasm32")] +mod wasm_impl; +#[cfg(target_arch = "wasm32")] +pub use wasm_impl::*; + +#[cfg(coverage_nightly)] +pub fn coverage_branch_probe(input: bool) -> &'static str { + if input { + "replica-db-wasm" + } else { + "replica-db-wasm" + } +} + +#[cfg(all(test, coverage_nightly))] +mod tests { + use super::coverage_branch_probe; + + #[test] + fn coverage_branch_probe_hits_both_paths() { + assert_eq!(coverage_branch_probe(true), "replica-db-wasm"); + assert_eq!(coverage_branch_probe(false), "replica-db-wasm"); + } +} diff --git a/crates/replica_db_wasm/src/utils.rs b/crates/replica_db_wasm/src/utils.rs @@ -0,0 +1,13 @@ +use serde::Serialize; +use wasm_bindgen::prelude::*; + +use radroots_sql_core::SqlError; + +pub fn value_to_js<T>(value: T) -> Result<JsValue, JsValue> +where + T: Serialize, +{ + let json = serde_json::to_string(&value) + .map_err(|err| radroots_sql_wasm_core::err_js(SqlError::from(err)))?; + Ok(JsValue::from_str(&json)) +} diff --git a/crates/replica_db_wasm/src/wasm_impl.rs b/crates/replica_db_wasm/src/wasm_impl.rs @@ -0,0 +1,891 @@ +use crate::utils::value_to_js; +use radroots_replica_db::migrations; +use radroots_replica_db::{ReplicaDbExportManifestRs, export_manifest}; +use radroots_replica_sync::radroots_replica_sync_status; +use radroots_sql_core::{ + WasmSqlExecutor, export_lock_begin, export_lock_end, with_export_lock_bypass, +}; +use radroots_sql_wasm_core::{err_js, parse_json}; +use wasm_bindgen::JsValue; +use wasm_bindgen::prelude::*; + +use radroots_replica_db_schema::farm::{ + IFarmCreate, IFarmDelete, IFarmFindMany, IFarmFindOne, IFarmUpdate, +}; + +use radroots_replica_db_schema::farm_gcs_location::{ + IFarmGcsLocationCreate, IFarmGcsLocationDelete, IFarmGcsLocationFindMany, + IFarmGcsLocationFindOne, IFarmGcsLocationUpdate, +}; + +use radroots_replica_db_schema::farm_member::{ + IFarmMemberCreate, IFarmMemberDelete, IFarmMemberFindMany, IFarmMemberFindOne, + IFarmMemberUpdate, +}; + +use radroots_replica_db_schema::farm_member_claim::{ + IFarmMemberClaimCreate, IFarmMemberClaimDelete, IFarmMemberClaimFindMany, + IFarmMemberClaimFindOne, IFarmMemberClaimUpdate, +}; + +use radroots_replica_db_schema::farm_tag::{ + IFarmTagCreate, IFarmTagDelete, IFarmTagFindMany, IFarmTagFindOne, IFarmTagUpdate, +}; + +use radroots_replica_db_schema::gcs_location::{ + IGcsLocationCreate, IGcsLocationDelete, IGcsLocationFindMany, IGcsLocationFindOne, + IGcsLocationUpdate, +}; + +use radroots_replica_db_schema::log_error::{ + ILogErrorCreate, ILogErrorDelete, ILogErrorFindMany, ILogErrorFindOne, ILogErrorUpdate, +}; + +use radroots_replica_db_schema::media_image::{ + IMediaImageCreate, IMediaImageDelete, IMediaImageFindMany, IMediaImageFindOne, + IMediaImageUpdate, +}; + +use radroots_replica_db_schema::nostr_profile::{ + INostrProfileCreate, INostrProfileDelete, INostrProfileFindMany, INostrProfileFindOne, + INostrProfileUpdate, +}; + +use radroots_replica_db_schema::nostr_event_head::{ + INostrEventHeadCreate, INostrEventHeadDelete, INostrEventHeadFindMany, INostrEventHeadFindOne, + INostrEventHeadUpdate, +}; + +use radroots_replica_db_schema::nostr_relay::{ + INostrRelayCreate, INostrRelayDelete, INostrRelayFindMany, INostrRelayFindOne, + INostrRelayUpdate, +}; + +use radroots_replica_db_schema::trade_product::{ + ITradeProductCreate, ITradeProductDelete, ITradeProductFindMany, ITradeProductFindOne, + ITradeProductUpdate, +}; + +use radroots_replica_db_schema::plot::{ + IPlotCreate, IPlotDelete, IPlotFindMany, IPlotFindOne, IPlotUpdate, +}; + +use radroots_replica_db_schema::plot_gcs_location::{ + IPlotGcsLocationCreate, IPlotGcsLocationDelete, IPlotGcsLocationFindMany, + IPlotGcsLocationFindOne, IPlotGcsLocationUpdate, +}; + +use radroots_replica_db_schema::plot_tag::{ + IPlotTagCreate, IPlotTagDelete, IPlotTagFindMany, IPlotTagFindOne, IPlotTagUpdate, +}; + +use radroots_replica_db_schema::nostr_profile_relay::INostrProfileRelayRelation; + +use radroots_replica_db_schema::trade_product_location::ITradeProductLocationRelation; + +use radroots_replica_db_schema::trade_product_media::ITradeProductMediaRelation; + +#[wasm_bindgen(js_name = replica_db_run_migrations)] +pub fn replica_db_run_migrations() -> Result<(), JsValue> { + let exec = WasmSqlExecutor::new(); + migrations::run_all_up(&exec).map_err(err_js) +} + +#[wasm_bindgen(js_name = replica_db_reset_database)] +pub fn replica_db_reset_database() -> Result<(), JsValue> { + let exec = WasmSqlExecutor::new(); + migrations::run_all_down(&exec).map_err(err_js) +} + +#[wasm_bindgen(js_name = replica_db_export_json)] +pub fn replica_db_export_json() -> Result<JsValue, JsValue> { + let exec = WasmSqlExecutor::new(); + let dump = radroots_replica_db::backup::export_database_backup(&exec).map_err(err_js)?; + value_to_js(dump) +} + +#[wasm_bindgen(js_name = replica_db_import_json)] +pub fn replica_db_import_json(dump_json: &str) -> Result<(), JsValue> { + let exec = WasmSqlExecutor::new(); + radroots_replica_db::backup::restore_database_backup_json(&exec, dump_json).map_err(err_js) +} + +#[wasm_bindgen(js_name = replica_db_export_begin)] +pub fn replica_db_export_begin() -> Result<JsValue, JsValue> { + export_lock_begin().map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let result = with_export_lock_bypass(|| export_snapshot(&exec)); + match result { + Ok(value) => Ok(value), + Err(err) => { + export_lock_end(); + Err(err) + } + } +} + +#[wasm_bindgen(js_name = replica_db_export_finish)] +pub fn replica_db_export_finish() -> Result<(), JsValue> { + export_lock_end(); + Ok(()) +} + +fn export_snapshot(exec: &WasmSqlExecutor) -> Result<JsValue, JsValue> { + let status = radroots_replica_sync_status(exec).map_err(|err| { + err_js(radroots_sql_core::SqlError::InvalidArgument( + err.to_string(), + )) + })?; + if status.pending_count > 0 { + return Err(err_js(radroots_sql_core::SqlError::InvalidArgument( + format!( + "replica db export requires synced state (pending {}/{})", + status.pending_count, status.expected_count + ), + ))); + } + let manifest = export_manifest(exec).map_err(err_js)?; + export_snapshot_value(manifest) +} + +fn export_snapshot_value(manifest: ReplicaDbExportManifestRs) -> Result<JsValue, JsValue> { + let bytes_js = radroots_sql_wasm_core::export_bytes(); + export_snapshot_value_with_bytes(manifest, bytes_js) +} + +fn export_snapshot_value_with_bytes( + manifest: ReplicaDbExportManifestRs, + bytes_js: JsValue, +) -> Result<JsValue, JsValue> { + let manifest_js = serde_wasm_bindgen::to_value(&manifest).map_err(|err| { + err_js(radroots_sql_core::SqlError::SerializationError( + err.to_string(), + )) + })?; + let obj = js_sys::Object::new(); + js_sys::Reflect::set(&obj, &JsValue::from_str("manifest_rs"), &manifest_js) + .map_err(|_| err_js(radroots_sql_core::SqlError::Internal))?; + js_sys::Reflect::set(&obj, &JsValue::from_str("db_bytes"), &bytes_js) + .map_err(|_| err_js(radroots_sql_core::SqlError::Internal))?; + Ok(JsValue::from(obj)) +} + +#[cfg(all(test, target_arch = "wasm32"))] +mod tests { + use super::export_snapshot_value_with_bytes; + use js_sys::{Reflect, Uint8Array}; + use wasm_bindgen::JsValue; + + #[wasm_bindgen_test::wasm_bindgen_test] + fn export_snapshot_value_includes_fields() { + let manifest = radroots_replica_db::ReplicaDbExportManifestRs { + export_version: "1".to_string(), + replica_db_version: "0.0.0".to_string(), + backup_format_version: "0.0.0".to_string(), + schema_hash: "hash".to_string(), + schema: Vec::new(), + migrations: Vec::new(), + table_counts: Vec::new(), + }; + let bytes = Uint8Array::new_with_length(2); + let js = + export_snapshot_value_with_bytes(manifest, JsValue::from(bytes)).expect("snapshot"); + let manifest_rs = + Reflect::get(&js, &JsValue::from_str("manifest_rs")).expect("manifest_rs"); + let db_bytes = Reflect::get(&js, &JsValue::from_str("db_bytes")).expect("db_bytes"); + assert!(manifest_rs.is_object()); + assert!(db_bytes.is_object()); + } +} + +#[wasm_bindgen(js_name = replica_db_farm_create)] +pub fn replica_db_farm_create(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: IFarmCreate = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = radroots_replica_db::farm::create(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_farm_find_one)] +pub fn replica_db_farm_find_one(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: IFarmFindOne = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = radroots_replica_db::farm::find_one(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_farm_find_many)] +pub fn replica_db_farm_find_many(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: IFarmFindMany = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = radroots_replica_db::farm::find_many(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_farm_update)] +pub fn replica_db_farm_update(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: IFarmUpdate = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = radroots_replica_db::farm::update(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_farm_delete)] +pub fn replica_db_farm_delete(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: IFarmDelete = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = radroots_replica_db::farm::delete(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_plot_create)] +pub fn replica_db_plot_create(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: IPlotCreate = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = radroots_replica_db::plot::create(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_plot_find_one)] +pub fn replica_db_plot_find_one(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: IPlotFindOne = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = radroots_replica_db::plot::find_one(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_plot_find_many)] +pub fn replica_db_plot_find_many(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: IPlotFindMany = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = radroots_replica_db::plot::find_many(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_plot_update)] +pub fn replica_db_plot_update(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: IPlotUpdate = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = radroots_replica_db::plot::update(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_plot_delete)] +pub fn replica_db_plot_delete(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: IPlotDelete = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = radroots_replica_db::plot::delete(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_gcs_location_create)] +pub fn replica_db_gcs_location_create(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: IGcsLocationCreate = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = radroots_replica_db::gcs_location::create(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_gcs_location_find_one)] +pub fn replica_db_gcs_location_find_one(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: IGcsLocationFindOne = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = + radroots_replica_db::gcs_location::find_one(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_gcs_location_find_many)] +pub fn replica_db_gcs_location_find_many(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: IGcsLocationFindMany = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = + radroots_replica_db::gcs_location::find_many(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_gcs_location_update)] +pub fn replica_db_gcs_location_update(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: IGcsLocationUpdate = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = radroots_replica_db::gcs_location::update(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_gcs_location_delete)] +pub fn replica_db_gcs_location_delete(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: IGcsLocationDelete = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = radroots_replica_db::gcs_location::delete(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_farm_gcs_location_create)] +pub fn replica_db_farm_gcs_location_create(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: IFarmGcsLocationCreate = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = + radroots_replica_db::farm_gcs_location::create(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_farm_gcs_location_find_one)] +pub fn replica_db_farm_gcs_location_find_one(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: IFarmGcsLocationFindOne = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = radroots_replica_db::farm_gcs_location::find_one(&exec, &opts) + .map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_farm_gcs_location_find_many)] +pub fn replica_db_farm_gcs_location_find_many(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: IFarmGcsLocationFindMany = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = radroots_replica_db::farm_gcs_location::find_many(&exec, &opts) + .map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_farm_gcs_location_update)] +pub fn replica_db_farm_gcs_location_update(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: IFarmGcsLocationUpdate = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = + radroots_replica_db::farm_gcs_location::update(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_farm_gcs_location_delete)] +pub fn replica_db_farm_gcs_location_delete(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: IFarmGcsLocationDelete = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = + radroots_replica_db::farm_gcs_location::delete(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_plot_gcs_location_create)] +pub fn replica_db_plot_gcs_location_create(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: IPlotGcsLocationCreate = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = + radroots_replica_db::plot_gcs_location::create(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_plot_gcs_location_find_one)] +pub fn replica_db_plot_gcs_location_find_one(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: IPlotGcsLocationFindOne = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = radroots_replica_db::plot_gcs_location::find_one(&exec, &opts) + .map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_plot_gcs_location_find_many)] +pub fn replica_db_plot_gcs_location_find_many(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: IPlotGcsLocationFindMany = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = radroots_replica_db::plot_gcs_location::find_many(&exec, &opts) + .map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_plot_gcs_location_update)] +pub fn replica_db_plot_gcs_location_update(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: IPlotGcsLocationUpdate = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = + radroots_replica_db::plot_gcs_location::update(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_plot_gcs_location_delete)] +pub fn replica_db_plot_gcs_location_delete(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: IPlotGcsLocationDelete = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = + radroots_replica_db::plot_gcs_location::delete(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_farm_tag_create)] +pub fn replica_db_farm_tag_create(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: IFarmTagCreate = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = radroots_replica_db::farm_tag::create(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_farm_tag_find_one)] +pub fn replica_db_farm_tag_find_one(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: IFarmTagFindOne = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = radroots_replica_db::farm_tag::find_one(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_farm_tag_find_many)] +pub fn replica_db_farm_tag_find_many(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: IFarmTagFindMany = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = radroots_replica_db::farm_tag::find_many(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_farm_tag_update)] +pub fn replica_db_farm_tag_update(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: IFarmTagUpdate = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = radroots_replica_db::farm_tag::update(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_farm_tag_delete)] +pub fn replica_db_farm_tag_delete(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: IFarmTagDelete = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = radroots_replica_db::farm_tag::delete(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_plot_tag_create)] +pub fn replica_db_plot_tag_create(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: IPlotTagCreate = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = radroots_replica_db::plot_tag::create(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_plot_tag_find_one)] +pub fn replica_db_plot_tag_find_one(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: IPlotTagFindOne = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = radroots_replica_db::plot_tag::find_one(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_plot_tag_find_many)] +pub fn replica_db_plot_tag_find_many(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: IPlotTagFindMany = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = radroots_replica_db::plot_tag::find_many(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_plot_tag_update)] +pub fn replica_db_plot_tag_update(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: IPlotTagUpdate = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = radroots_replica_db::plot_tag::update(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_plot_tag_delete)] +pub fn replica_db_plot_tag_delete(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: IPlotTagDelete = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = radroots_replica_db::plot_tag::delete(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_farm_member_create)] +pub fn replica_db_farm_member_create(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: IFarmMemberCreate = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = radroots_replica_db::farm_member::create(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_farm_member_find_one)] +pub fn replica_db_farm_member_find_one(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: IFarmMemberFindOne = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = + radroots_replica_db::farm_member::find_one(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_farm_member_find_many)] +pub fn replica_db_farm_member_find_many(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: IFarmMemberFindMany = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = + radroots_replica_db::farm_member::find_many(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_farm_member_update)] +pub fn replica_db_farm_member_update(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: IFarmMemberUpdate = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = radroots_replica_db::farm_member::update(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_farm_member_delete)] +pub fn replica_db_farm_member_delete(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: IFarmMemberDelete = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = radroots_replica_db::farm_member::delete(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_farm_member_claim_create)] +pub fn replica_db_farm_member_claim_create(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: IFarmMemberClaimCreate = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = + radroots_replica_db::farm_member_claim::create(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_farm_member_claim_find_one)] +pub fn replica_db_farm_member_claim_find_one(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: IFarmMemberClaimFindOne = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = radroots_replica_db::farm_member_claim::find_one(&exec, &opts) + .map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_farm_member_claim_find_many)] +pub fn replica_db_farm_member_claim_find_many(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: IFarmMemberClaimFindMany = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = radroots_replica_db::farm_member_claim::find_many(&exec, &opts) + .map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_farm_member_claim_update)] +pub fn replica_db_farm_member_claim_update(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: IFarmMemberClaimUpdate = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = + radroots_replica_db::farm_member_claim::update(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_farm_member_claim_delete)] +pub fn replica_db_farm_member_claim_delete(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: IFarmMemberClaimDelete = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = + radroots_replica_db::farm_member_claim::delete(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_log_error_create)] +pub fn replica_db_log_error_create(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: ILogErrorCreate = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = radroots_replica_db::log_error::create(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_log_error_find_one)] +pub fn replica_db_log_error_find_one(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: ILogErrorFindOne = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = radroots_replica_db::log_error::find_one(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_log_error_find_many)] +pub fn replica_db_log_error_find_many(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: ILogErrorFindMany = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = radroots_replica_db::log_error::find_many(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_log_error_update)] +pub fn replica_db_log_error_update(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: ILogErrorUpdate = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = radroots_replica_db::log_error::update(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_log_error_delete)] +pub fn replica_db_log_error_delete(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: ILogErrorDelete = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = radroots_replica_db::log_error::delete(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_media_image_create)] +pub fn replica_db_media_image_create(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: IMediaImageCreate = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = radroots_replica_db::media_image::create(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_media_image_find_one)] +pub fn replica_db_media_image_find_one(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: IMediaImageFindOne = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = + radroots_replica_db::media_image::find_one(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_media_image_find_many)] +pub fn replica_db_media_image_find_many(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: IMediaImageFindMany = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = + radroots_replica_db::media_image::find_many(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_media_image_update)] +pub fn replica_db_media_image_update(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: IMediaImageUpdate = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = radroots_replica_db::media_image::update(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_media_image_delete)] +pub fn replica_db_media_image_delete(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: IMediaImageDelete = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = radroots_replica_db::media_image::delete(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_nostr_profile_create)] +pub fn replica_db_nostr_profile_create(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: INostrProfileCreate = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = + radroots_replica_db::nostr_profile::create(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_nostr_profile_find_one)] +pub fn replica_db_nostr_profile_find_one(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: INostrProfileFindOne = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = + radroots_replica_db::nostr_profile::find_one(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_nostr_profile_find_many)] +pub fn replica_db_nostr_profile_find_many(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: INostrProfileFindMany = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = + radroots_replica_db::nostr_profile::find_many(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_nostr_profile_update)] +pub fn replica_db_nostr_profile_update(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: INostrProfileUpdate = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = + radroots_replica_db::nostr_profile::update(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_nostr_profile_delete)] +pub fn replica_db_nostr_profile_delete(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: INostrProfileDelete = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = + radroots_replica_db::nostr_profile::delete(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_nostr_event_head_create)] +pub fn replica_db_nostr_event_head_create(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: INostrEventHeadCreate = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = + radroots_replica_db::nostr_event_head::create(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_nostr_event_head_find_one)] +pub fn replica_db_nostr_event_head_find_one(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: INostrEventHeadFindOne = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = + radroots_replica_db::nostr_event_head::find_one(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_nostr_event_head_find_many)] +pub fn replica_db_nostr_event_head_find_many(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: INostrEventHeadFindMany = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = radroots_replica_db::nostr_event_head::find_many(&exec, &opts) + .map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_nostr_event_head_update)] +pub fn replica_db_nostr_event_head_update(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: INostrEventHeadUpdate = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = + radroots_replica_db::nostr_event_head::update(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_nostr_event_head_delete)] +pub fn replica_db_nostr_event_head_delete(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: INostrEventHeadDelete = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = + radroots_replica_db::nostr_event_head::delete(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_nostr_relay_create)] +pub fn replica_db_nostr_relay_create(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: INostrRelayCreate = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = radroots_replica_db::nostr_relay::create(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_nostr_relay_find_one)] +pub fn replica_db_nostr_relay_find_one(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: INostrRelayFindOne = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = + radroots_replica_db::nostr_relay::find_one(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_nostr_relay_find_many)] +pub fn replica_db_nostr_relay_find_many(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: INostrRelayFindMany = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = + radroots_replica_db::nostr_relay::find_many(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_nostr_relay_update)] +pub fn replica_db_nostr_relay_update(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: INostrRelayUpdate = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = radroots_replica_db::nostr_relay::update(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_nostr_relay_delete)] +pub fn replica_db_nostr_relay_delete(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: INostrRelayDelete = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = radroots_replica_db::nostr_relay::delete(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_trade_product_create)] +pub fn replica_db_trade_product_create(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: ITradeProductCreate = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = + radroots_replica_db::trade_product::create(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_trade_product_find_one)] +pub fn replica_db_trade_product_find_one(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: ITradeProductFindOne = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = + radroots_replica_db::trade_product::find_one(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_trade_product_find_many)] +pub fn replica_db_trade_product_find_many(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: ITradeProductFindMany = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = + radroots_replica_db::trade_product::find_many(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_trade_product_update)] +pub fn replica_db_trade_product_update(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: ITradeProductUpdate = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = + radroots_replica_db::trade_product::update(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_trade_product_delete)] +pub fn replica_db_trade_product_delete(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: ITradeProductDelete = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = + radroots_replica_db::trade_product::delete(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_nostr_profile_relay_set)] +pub fn replica_db_nostr_profile_relay_set(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: INostrProfileRelayRelation = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = + radroots_replica_db::nostr_profile_relay::set(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_nostr_profile_relay_unset)] +pub fn replica_db_nostr_profile_relay_unset(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: INostrProfileRelayRelation = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = + radroots_replica_db::nostr_profile_relay::unset(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_trade_product_location_set)] +pub fn replica_db_trade_product_location_set(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: ITradeProductLocationRelation = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = radroots_replica_db::trade_product_location::set(&exec, &opts) + .map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_trade_product_location_unset)] +pub fn replica_db_trade_product_location_unset(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: ITradeProductLocationRelation = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = radroots_replica_db::trade_product_location::unset(&exec, &opts) + .map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_trade_product_media_set)] +pub fn replica_db_trade_product_media_set(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: ITradeProductMediaRelation = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = + radroots_replica_db::trade_product_media::set(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} + +#[wasm_bindgen(js_name = replica_db_trade_product_media_unset)] +pub fn replica_db_trade_product_media_unset(opts_json: &str) -> Result<JsValue, JsValue> { + let opts: ITradeProductMediaRelation = parse_json(opts_json).map_err(err_js)?; + let exec = WasmSqlExecutor::new(); + let out = + radroots_replica_db::trade_product_media::unset(&exec, &opts).map_err(|e| err_js(e.err))?; + value_to_js(out) +} diff --git a/crates/replica_sync_wasm/Cargo.toml b/crates/replica_sync_wasm/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "radroots_replica_sync_wasm" +publish = false +version = "0.1.0-alpha.2" +edition.workspace = true +authors = ["Tyson Lupul <tyson@radroots.org>"] +rust-version.workspace = true +license.workspace = true +description = "WebAssembly bindings for radroots_replica_sync" +repository.workspace = true +homepage.workspace = true +documentation = "https://docs.rs/radroots_replica_sync_wasm" +readme = "README" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +base64 = { workspace = true } +radroots_events = { workspace = true, default-features = false, features = [ + "serde", +] } +radroots_sql_core = { workspace = true, features = ["bridge"] } +radroots_sql_wasm_core = { workspace = true, default-features = false, features = [ + "bridge", +] } +radroots_replica_sync = { workspace = true, features = ["std"] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +serde-wasm-bindgen = { workspace = true } +uuid = { workspace = true, features = ["js"] } +wasm-bindgen = { workspace = true } + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } diff --git a/crates/replica_sync_wasm/README b/crates/replica_sync_wasm/README @@ -0,0 +1,25 @@ +# radroots_replica_sync_wasm + +This is the README for `radroots_replica_sync_wasm`, which provides WebAssembly +bindings for `radroots_replica_sync` in the `radroots` core libraries. + +## Overview + + * wasm32 entry points for full sync and single-event ingest operations; + * integration with `WasmSqlExecutor` from `radroots_sql_core` for database + access; + * UUIDv7 id generation and JSON and base64 boundary handling for JavaScript + callers; + * a small target-specific wrapper around the Rust sync crate rather than a + separate sync engine. + +## Copyright + +Except as otherwise noted, all files in the `radroots_replica_sync_wasm` +distribution are + + Copyright (c) 2020-2026 Tyson Lupul + +For information on usage and redistribution, and for a DISCLAIMER OF ALL +WARRANTIES, see LICENSE included in the `radroots_replica_sync_wasm` +distribution. diff --git a/crates/replica_sync_wasm/src/lib.rs b/crates/replica_sync_wasm/src/lib.rs @@ -0,0 +1,124 @@ +#![cfg(any(target_arch = "wasm32", coverage_nightly))] +#![forbid(unsafe_code)] + +#[cfg(target_arch = "wasm32")] +use base64::Engine; +#[cfg(target_arch = "wasm32")] +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +#[cfg(target_arch = "wasm32")] +use radroots_events::RadrootsNostrEvent; +#[cfg(target_arch = "wasm32")] +use radroots_replica_sync::{ + RadrootsReplicaIdFactory, RadrootsReplicaIngestOutcome, RadrootsReplicaSyncRequest, + radroots_replica_ingest_event_with_factory, radroots_replica_sync_all, +}; +#[cfg(target_arch = "wasm32")] +use radroots_sql_core::WasmSqlExecutor; +#[cfg(target_arch = "wasm32")] +use serde::Deserialize; +#[cfg(target_arch = "wasm32")] +use uuid::Uuid; +#[cfg(target_arch = "wasm32")] +use wasm_bindgen::prelude::*; + +#[cfg(target_arch = "wasm32")] +fn err_js<E: ToString>(err: E) -> JsValue { + JsValue::from_str(&err.to_string()) +} + +#[cfg(target_arch = "wasm32")] +struct WasmIdFactory; + +#[cfg(target_arch = "wasm32")] +impl RadrootsReplicaIdFactory for WasmIdFactory { + fn new_d_tag(&self) -> String { + let uuid = Uuid::now_v7(); + URL_SAFE_NO_PAD.encode(uuid.as_bytes()) + } +} + +#[cfg(target_arch = "wasm32")] +#[derive(Deserialize)] +struct NostrEventEnvelope { + id: String, + #[serde(default)] + author: Option<String>, + #[serde(default)] + pubkey: Option<String>, + created_at: u32, + kind: u32, + tags: Vec<Vec<String>>, + content: String, + sig: String, +} + +#[cfg(target_arch = "wasm32")] +fn parse_request(request_json: &str) -> Result<RadrootsReplicaSyncRequest, JsValue> { + serde_json::from_str(request_json).map_err(err_js) +} + +#[cfg(target_arch = "wasm32")] +fn parse_event(event_json: &str) -> Result<RadrootsNostrEvent, JsValue> { + let envelope: NostrEventEnvelope = serde_json::from_str(event_json).map_err(err_js)?; + let author = match (envelope.author, envelope.pubkey) { + (Some(author), Some(pubkey)) if author != pubkey => { + return Err(JsValue::from_str("author/pubkey mismatch")); + } + (Some(author), _) => author, + (None, Some(pubkey)) => pubkey, + (None, None) => return Err(JsValue::from_str("missing author/pubkey")), + }; + Ok(RadrootsNostrEvent { + id: envelope.id, + author, + created_at: envelope.created_at, + kind: envelope.kind, + tags: envelope.tags, + content: envelope.content, + sig: envelope.sig, + }) +} + +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen(js_name = replica_sync_sync_all)] +pub fn replica_sync_sync_all(request_json: &str) -> Result<JsValue, JsValue> { + let request = parse_request(request_json)?; + let exec = WasmSqlExecutor::new(); + let bundle = radroots_replica_sync_all(&exec, &request).map_err(err_js)?; + serde_wasm_bindgen::to_value(&bundle).map_err(err_js) +} + +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen(js_name = replica_sync_ingest_event)] +pub fn replica_sync_ingest_event(event_json: &str) -> Result<JsValue, JsValue> { + let event = parse_event(event_json)?; + let exec = WasmSqlExecutor::new(); + let factory = WasmIdFactory; + let outcome = + radroots_replica_ingest_event_with_factory(&exec, &event, &factory).map_err(err_js)?; + let value = match outcome { + RadrootsReplicaIngestOutcome::Applied => "applied", + RadrootsReplicaIngestOutcome::Skipped => "skipped", + }; + Ok(JsValue::from_str(value)) +} + +#[cfg(coverage_nightly)] +pub fn coverage_branch_probe(input: bool) -> &'static str { + if input { + "replica-sync-wasm" + } else { + "replica-sync-wasm" + } +} + +#[cfg(all(test, coverage_nightly))] +mod tests { + use super::coverage_branch_probe; + + #[test] + fn coverage_branch_probe_hits_both_paths() { + assert_eq!(coverage_branch_probe(true), "replica-sync-wasm"); + assert_eq!(coverage_branch_probe(false), "replica-sync-wasm"); + } +} diff --git a/crates/sdk/Cargo.toml b/crates/sdk/Cargo.toml @@ -0,0 +1,78 @@ +[package] +name = "radroots_sdk" +publish = ["crates-io"] +version.workspace = true +edition.workspace = true +authors = ["Tyson Lupul <tyson@radroots.org>"] +rust-version.workspace = true +license.workspace = true +description = "Curated Radroots SDK for profile, farm, listing, and trade event workflows" +repository.workspace = true +homepage.workspace = true +documentation = "https://docs.rs/radroots_sdk" +readme = "README" + +[features] +default = ["std", "serde", "serde_json", "identity-models"] +std = ["radroots_events/std", "radroots_events_codec/std", "radroots_trade/std"] +serde = ["dep:serde", "radroots_events/serde", "radroots_trade/serde"] +serde_json = [ + "dep:serde_json", + "serde", + "nostr", + "radroots_events_codec/serde_json", + "radroots_trade/serde_json", +] +nostr = ["radroots_events_codec/nostr"] +identity-models = [ + "dep:radroots_identity", + "radroots_identity/profile", + "radroots_identity/std", +] +identity-storage = ["identity-models", "std", "radroots_identity/std"] +signing = ["dep:radroots_nostr", "nostr"] +relay-client = ["signing", "std", "serde_json", "radroots_nostr/client"] +radrootsd-client = ["std", "serde_json", "dep:reqwest"] +signer-adapters = [ + "identity-models", + "signing", + "std", + "dep:radroots_nostr_connect", + "dep:radroots_nostr_signer", +] + +[dependencies] +radroots_events = { workspace = true, default-features = false } +radroots_events_codec = { workspace = true, default-features = false } +radroots_trade = { workspace = true, default-features = false } +radroots_identity = { workspace = true, optional = true, default-features = false } +radroots_nostr = { workspace = true, optional = true, default-features = false } +radroots_nostr_connect = { workspace = true, optional = true } +radroots_nostr_signer = { workspace = true, optional = true, default-features = false } +reqwest = { workspace = true, optional = true, default-features = false, features = [ + "json", + "rustls-tls", +] } +serde = { workspace = true, optional = true, default-features = false, features = [ + "derive", + "alloc", +] } +serde_json = { workspace = true, optional = true, default-features = false, features = [ + "alloc", +] } + +[dev-dependencies] +futures = { workspace = true } +nostr = { workspace = true } +radroots_core = { workspace = true, default-features = false, features = [ + "std", +] } +radroots_replica_db = { workspace = true, default-features = false, features = [ + "native", +] } +radroots_replica_db_schema = { workspace = true } +radroots_replica_sync = { workspace = true, features = ["std"] } +radroots_sql_core = { workspace = true, features = ["native"] } +tempfile = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +tokio-tungstenite = "0.26.2" diff --git a/crates/sdk/README b/crates/sdk/README @@ -0,0 +1,23 @@ +# radroots_sdk + +Curated Rad Roots Rust SDK for the public marketplace event contract. + +This crate provides the recommended Rust entrypoint for building, parsing, and +validating Rad Roots profile, farm, listing, and trade events. It is a thin +facade over the underlying `rr-rs` substrate crates and does not duplicate the +core event or trade implementations. + +The deterministic event contract lives at the crate root: + +- `profile` +- `farm` +- `listing` +- `trade` + +Optional advanced substrate is explicitly feature-scoped: + +- `identity-models`: identity data types without local storage coupling +- `identity-storage`: encrypted identity-file helpers +- `signing`: Nostr builder and local signing adapters +- `relay-client`: relay client and publish adapters +- `signer-adapters`: NIP-46 and signer-session primitives diff --git a/crates/sdk/src/adapters/mod.rs b/crates/sdk/src/adapters/mod.rs @@ -0,0 +1,8 @@ +#[cfg(feature = "radrootsd-client")] +pub mod radrootsd; +#[cfg(feature = "relay-client")] +pub mod relay; +#[cfg(feature = "signer-adapters")] +pub mod signer; +#[cfg(feature = "signing")] +pub mod signing; diff --git a/crates/sdk/src/adapters/radrootsd.rs b/crates/sdk/src/adapters/radrootsd.rs @@ -0,0 +1,835 @@ +use core::fmt; +use core::time::Duration; + +use crate::config::RadrootsdAuth; +use crate::farm::RadrootsFarm; +use crate::listing; +use crate::listing::RadrootsListing; +use crate::order; +use crate::profile::{RadrootsProfile, RadrootsProfileType}; +use crate::{RadrootsNostrEvent, RadrootsNostrEventPtr}; +use radroots_events::kinds::KIND_LISTING; +use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue}; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use serde_json::{Value, json}; + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SdkRadrootsdSignerAuthority { + pub provider_runtime_id: String, + pub account_identity_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_signer_session_id: Option<String>, +} + +impl fmt::Debug for SdkRadrootsdSignerAuthority { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut debug = f.debug_struct("SdkRadrootsdSignerAuthority"); + debug.field("provider_runtime_id", &self.provider_runtime_id); + debug.field("account_identity_id", &self.account_identity_id); + debug.field( + "provider_signer_session_id", + &self + .provider_signer_session_id + .as_ref() + .map(|_| "<redacted>"), + ); + debug.finish() + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum SdkRadrootsdSignerSessionMode { + #[serde(alias = "bunker")] + Bunker, + #[serde(alias = "nostrconnect")] + Nostrconnect, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SdkRadrootsdSignerSessionRole { + InboundLocalSigner, + OutboundRemoteSigner, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SdkRadrootsdBridgeDeliveryPolicy { + Any, + Quorum, + All, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SdkRadrootsdBridgeJobStatus { + Accepted, + Published, + Failed, +} + +#[derive(Clone, PartialEq, Eq, Serialize)] +pub struct SdkRadrootsdSignerSessionConnectRequest { + pub url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_secret_key: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub signer_authority: Option<SdkRadrootsdSignerAuthority>, +} + +impl SdkRadrootsdSignerSessionConnectRequest { + pub fn bunker(url: impl Into<String>) -> Self { + Self { + url: url.into(), + client_secret_key: None, + signer_authority: None, + } + } + + pub fn nostrconnect(url: impl Into<String>, client_secret_key: impl Into<String>) -> Self { + Self { + url: url.into(), + client_secret_key: Some(client_secret_key.into()), + signer_authority: None, + } + } + + pub fn with_signer_authority(mut self, signer_authority: SdkRadrootsdSignerAuthority) -> Self { + self.signer_authority = Some(signer_authority); + self + } +} + +impl fmt::Debug for SdkRadrootsdSignerSessionConnectRequest { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut debug = f.debug_struct("SdkRadrootsdSignerSessionConnectRequest"); + debug.field("url", &self.url); + debug.field( + "client_secret_key", + &self.client_secret_key.as_ref().map(|_| "<redacted>"), + ); + debug.field("signer_authority", &self.signer_authority); + debug.finish() + } +} + +#[derive(Clone, Serialize)] +pub struct SdkRadrootsdProfilePublishRequest { + pub profile: RadrootsProfile, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile_type: Option<RadrootsProfileType>, + pub signer_session_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub signer_authority: Option<SdkRadrootsdSignerAuthority>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub idempotency_key: Option<String>, +} + +impl fmt::Debug for SdkRadrootsdProfilePublishRequest { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut debug = f.debug_struct("SdkRadrootsdProfilePublishRequest"); + debug.field("profile", &self.profile); + debug.field("profile_type", &self.profile_type); + debug.field("signer_session_id", &"<redacted>"); + debug.field("signer_authority", &self.signer_authority); + debug.field("idempotency_key", &self.idempotency_key); + debug.finish() + } +} + +#[derive(Clone, Serialize)] +pub struct SdkRadrootsdFarmPublishRequest { + pub farm: RadrootsFarm, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kind: Option<u32>, + pub signer_session_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub signer_authority: Option<SdkRadrootsdSignerAuthority>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub idempotency_key: Option<String>, +} + +impl fmt::Debug for SdkRadrootsdFarmPublishRequest { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut debug = f.debug_struct("SdkRadrootsdFarmPublishRequest"); + debug.field("farm", &self.farm); + debug.field("kind", &self.kind); + debug.field("signer_session_id", &"<redacted>"); + debug.field("signer_authority", &self.signer_authority); + debug.field("idempotency_key", &self.idempotency_key); + debug.finish() + } +} + +#[derive(Clone, Serialize)] +pub struct SdkRadrootsdListingPublishRequest { + pub listing: RadrootsListing, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kind: Option<u32>, + pub signer_session_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub signer_authority: Option<SdkRadrootsdSignerAuthority>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub idempotency_key: Option<String>, +} + +impl fmt::Debug for SdkRadrootsdListingPublishRequest { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut debug = f.debug_struct("SdkRadrootsdListingPublishRequest"); + debug.field("listing", &self.listing); + debug.field("kind", &self.kind); + debug.field("signer_session_id", &"<redacted>"); + debug.field("signer_authority", &self.signer_authority); + debug.field("idempotency_key", &self.idempotency_key); + debug.finish() + } +} + +#[derive(Clone, PartialEq, Eq, Serialize)] +pub(crate) struct SdkRadrootsdOrderRequestPublishRequest { + pub order: order::RadrootsOrderRequest, + pub listing_event: RadrootsNostrEventPtr, + pub signer_session_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub signer_authority: Option<SdkRadrootsdSignerAuthority>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub idempotency_key: Option<String>, +} + +impl fmt::Debug for SdkRadrootsdOrderRequestPublishRequest { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut debug = f.debug_struct("SdkRadrootsdOrderRequestPublishRequest"); + debug.field("order", &self.order); + debug.field("listing_event", &self.listing_event); + debug.field("signer_session_id", &"<redacted>"); + debug.field("signer_authority", &self.signer_authority); + debug.field("idempotency_key", &self.idempotency_key); + debug.finish() + } +} + +impl SdkRadrootsdListingPublishRequest { + pub fn from_event( + event: &RadrootsNostrEvent, + signer_session_id: impl Into<String>, + signer_authority: Option<SdkRadrootsdSignerAuthority>, + idempotency_key: Option<String>, + ) -> Result<Self, listing::RadrootsListingParseError> { + if event.kind != KIND_LISTING { + return Err(listing::RadrootsListingParseError::InvalidKind(event.kind)); + } + Ok(Self { + listing: listing::parse_event(event)?, + kind: Some(event.kind), + signer_session_id: signer_session_id.into(), + signer_authority, + idempotency_key, + }) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +pub(crate) struct SdkRadrootsdSignerSessionConnectResponse { + pub session_id: String, + pub mode: SdkRadrootsdSignerSessionMode, + pub remote_signer_pubkey: String, + pub client_pubkey: String, + pub relays: Vec<String>, +} + +#[derive(Clone, PartialEq, Eq, Deserialize)] +pub(crate) struct SdkRadrootsdSignerSessionViewResponse { + pub session_id: String, + pub role: SdkRadrootsdSignerSessionRole, + pub client_pubkey: String, + pub signer_pubkey: String, + #[serde(default)] + pub user_pubkey: Option<String>, + pub relays: Vec<String>, + pub permissions: Vec<String>, + #[serde(default)] + pub name: Option<String>, + #[serde(default)] + pub url: Option<String>, + #[serde(default)] + pub image: Option<String>, + pub auth_required: bool, + pub authorized: bool, + #[serde(default)] + pub auth_url: Option<String>, + #[serde(default)] + pub expires_in_secs: Option<u64>, + #[serde(default)] + pub signer_authority: Option<SdkRadrootsdSignerAuthority>, +} + +impl fmt::Debug for SdkRadrootsdSignerSessionViewResponse { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut debug = f.debug_struct("SdkRadrootsdSignerSessionViewResponse"); + debug.field("session_id", &"<redacted>"); + debug.field("role", &self.role); + debug.field("client_pubkey", &self.client_pubkey); + debug.field("signer_pubkey", &self.signer_pubkey); + debug.field("user_pubkey", &self.user_pubkey); + debug.field("relays", &self.relays); + debug.field("permissions", &self.permissions); + debug.field("name", &self.name); + debug.field("url", &self.url); + debug.field("image", &self.image); + debug.field("auth_required", &self.auth_required); + debug.field("authorized", &self.authorized); + debug.field("auth_url", &self.auth_url); + debug.field("expires_in_secs", &self.expires_in_secs); + debug.field("signer_authority", &self.signer_authority); + debug.finish() + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +pub(crate) struct SdkRadrootsdSignerSessionAuthorizeResponse { + pub authorized: bool, + pub replayed: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +pub(crate) struct SdkRadrootsdSignerSessionPublicKeyResponse { + pub pubkey: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +pub(crate) struct SdkRadrootsdSignerSessionRequireAuthResponse { + pub required: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +pub(crate) struct SdkRadrootsdSignerSessionCloseResponse { + pub closed: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct SdkRadrootsdBridgePublishResponse { + pub deduplicated: bool, + pub job: SdkRadrootsdBridgeJob, +} + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +pub struct SdkRadrootsdBridgeStatusResponse { + pub enabled: bool, + pub ready: bool, + pub auth_mode: String, + pub signer_mode: String, + pub default_signer_mode: String, + pub supported_signer_modes: Vec<String>, + pub available_nip46_signer_sessions: usize, + pub relay_count: usize, + pub delivery_policy: SdkRadrootsdBridgeDeliveryPolicy, + #[serde(default)] + pub delivery_quorum: Option<usize>, + pub publish_max_attempts: usize, + pub publish_initial_backoff_millis: u64, + pub publish_max_backoff_millis: u64, + pub job_status_retention: usize, + pub retained_jobs: usize, + pub retained_idempotency_keys: usize, + pub accepted_jobs: usize, + pub published_jobs: usize, + pub failed_jobs: usize, + pub recovered_failed_jobs: usize, + pub methods: Vec<String>, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SdkRadrootsdBridgeRelayPublishResult { + pub relay_url: String, + pub acknowledged: bool, + #[serde(default)] + pub detail: Option<String>, +} + +#[derive(Clone, PartialEq, Eq, Deserialize)] +pub struct SdkRadrootsdBridgeJob { + pub job_id: String, + pub command: String, + pub status: String, + pub terminal: bool, + pub recovered_after_restart: bool, + pub signer_mode: String, + #[serde(default)] + pub signer_session_id: Option<String>, + pub event_kind: u32, + #[serde(default)] + pub event_id: Option<String>, + #[serde(default)] + pub event_addr: Option<String>, + pub relay_count: usize, + pub acknowledged_relay_count: usize, +} + +impl fmt::Debug for SdkRadrootsdBridgeJob { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut debug = f.debug_struct("SdkRadrootsdBridgeJob"); + debug.field("job_id", &self.job_id); + debug.field("command", &self.command); + debug.field("status", &self.status); + debug.field("terminal", &self.terminal); + debug.field("recovered_after_restart", &self.recovered_after_restart); + debug.field("signer_mode", &"<redacted>"); + debug.field( + "signer_session_id", + &self.signer_session_id.as_ref().map(|_| "<redacted>"), + ); + debug.field("event_kind", &self.event_kind); + debug.field("event_id", &self.event_id); + debug.field("event_addr", &self.event_addr); + debug.field("relay_count", &self.relay_count); + debug.field("acknowledged_relay_count", &self.acknowledged_relay_count); + debug.finish() + } +} + +#[derive(Clone, PartialEq, Eq, Deserialize)] +pub struct SdkRadrootsdBridgeJobView { + pub job_id: String, + pub command: String, + #[serde(default)] + pub idempotency_key: Option<String>, + pub status: SdkRadrootsdBridgeJobStatus, + pub terminal: bool, + pub recovered_after_restart: bool, + pub requested_at_unix: u64, + #[serde(default)] + pub completed_at_unix: Option<u64>, + pub signer_mode: String, + #[serde(default)] + pub signer_session_id: Option<String>, + pub event_kind: u32, + #[serde(default)] + pub event_id: Option<String>, + #[serde(default)] + pub event_addr: Option<String>, + pub delivery_policy: SdkRadrootsdBridgeDeliveryPolicy, + #[serde(default)] + pub delivery_quorum: Option<usize>, + pub relay_count: usize, + pub acknowledged_relay_count: usize, + pub required_acknowledged_relay_count: usize, + pub attempt_count: usize, + #[serde(default)] + pub attempt_summaries: Vec<String>, + #[serde(default)] + pub relay_results: Vec<SdkRadrootsdBridgeRelayPublishResult>, + pub relay_outcome_summary: String, +} + +impl fmt::Debug for SdkRadrootsdBridgeJobView { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut debug = f.debug_struct("SdkRadrootsdBridgeJobView"); + debug.field("job_id", &self.job_id); + debug.field("command", &self.command); + debug.field("idempotency_key", &self.idempotency_key); + debug.field("status", &self.status); + debug.field("terminal", &self.terminal); + debug.field("recovered_after_restart", &self.recovered_after_restart); + debug.field("requested_at_unix", &self.requested_at_unix); + debug.field("completed_at_unix", &self.completed_at_unix); + debug.field("signer_mode", &self.signer_mode.as_str()); + debug.field( + "signer_session_id", + &self.signer_session_id.as_ref().map(|_| "<redacted>"), + ); + debug.field("event_kind", &self.event_kind); + debug.field("event_id", &self.event_id); + debug.field("event_addr", &self.event_addr); + debug.field("delivery_policy", &self.delivery_policy); + debug.field("delivery_quorum", &self.delivery_quorum); + debug.field("relay_count", &self.relay_count); + debug.field("acknowledged_relay_count", &self.acknowledged_relay_count); + debug.field( + "required_acknowledged_relay_count", + &self.required_acknowledged_relay_count, + ); + debug.field("attempt_count", &self.attempt_count); + debug.field("attempt_summaries", &self.attempt_summaries); + debug.field("relay_results", &self.relay_results); + debug.field("relay_outcome_summary", &self.relay_outcome_summary); + debug.finish() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RadrootsdError { + InvalidAuthHeader(String), + Http(String), + JsonRpc(String), + MalformedResponse(String), +} + +impl core::fmt::Display for RadrootsdError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::InvalidAuthHeader(value) => { + write!(f, "invalid radrootsd bearer token header: {value}") + } + Self::Http(value) => write!(f, "{value}"), + Self::JsonRpc(value) => write!(f, "{value}"), + Self::MalformedResponse(value) => write!(f, "{value}"), + } + } +} + +impl std::error::Error for RadrootsdError {} + +#[derive(Debug, Deserialize)] +struct JsonRpcEnvelope<T> { + result: Option<T>, + error: Option<JsonRpcError>, +} + +#[derive(Debug, Deserialize)] +struct JsonRpcError { + code: i64, + message: String, +} + +#[derive(Debug, Serialize)] +struct SdkRadrootsdSignerSessionParams<'a> { + session_id: &'a str, +} + +#[derive(Debug, Serialize)] +struct SdkRadrootsdSignerSessionRequireAuthParams<'a> { + session_id: &'a str, + auth_url: &'a str, +} + +#[derive(Debug, Serialize)] +struct SdkRadrootsdBridgeJobParams<'a> { + job_id: &'a str, +} + +pub async fn publish_listing( + endpoint: &str, + auth: &RadrootsdAuth, + request: &SdkRadrootsdListingPublishRequest, + timeout: Duration, +) -> Result<SdkRadrootsdBridgePublishResponse, RadrootsdError> { + jsonrpc_call( + endpoint, + auth, + "radroots-sdk-listing-publish", + "bridge.listing.publish", + request, + timeout, + ) + .await +} + +pub(crate) async fn publish_profile( + endpoint: &str, + auth: &RadrootsdAuth, + request: &SdkRadrootsdProfilePublishRequest, + timeout: Duration, +) -> Result<SdkRadrootsdBridgePublishResponse, RadrootsdError> { + jsonrpc_call( + endpoint, + auth, + "radroots-sdk-profile-publish", + "bridge.profile.publish", + request, + timeout, + ) + .await +} + +pub(crate) async fn publish_farm( + endpoint: &str, + auth: &RadrootsdAuth, + request: &SdkRadrootsdFarmPublishRequest, + timeout: Duration, +) -> Result<SdkRadrootsdBridgePublishResponse, RadrootsdError> { + jsonrpc_call( + endpoint, + auth, + "radroots-sdk-farm-publish", + "bridge.farm.publish", + request, + timeout, + ) + .await +} + +pub(crate) async fn publish_order_request( + endpoint: &str, + auth: &RadrootsdAuth, + request: &SdkRadrootsdOrderRequestPublishRequest, + timeout: Duration, +) -> Result<SdkRadrootsdBridgePublishResponse, RadrootsdError> { + jsonrpc_call( + endpoint, + auth, + "radroots-sdk-order-request-publish", + "bridge.order.request", + request, + timeout, + ) + .await +} + +pub(crate) async fn connect_signer_session( + endpoint: &str, + auth: &RadrootsdAuth, + request: &SdkRadrootsdSignerSessionConnectRequest, + timeout: Duration, +) -> Result<SdkRadrootsdSignerSessionConnectResponse, RadrootsdError> { + jsonrpc_call( + endpoint, + auth, + "radroots-sdk-nip46-connect", + "nip46.connect", + request, + timeout, + ) + .await +} + +pub(crate) async fn signer_session_status( + endpoint: &str, + auth: &RadrootsdAuth, + session_id: &str, + timeout: Duration, +) -> Result<SdkRadrootsdSignerSessionViewResponse, RadrootsdError> { + jsonrpc_call( + endpoint, + auth, + "radroots-sdk-nip46-session-status", + "nip46.session.status", + &SdkRadrootsdSignerSessionParams { session_id }, + timeout, + ) + .await +} + +pub(crate) async fn list_signer_sessions( + endpoint: &str, + auth: &RadrootsdAuth, + timeout: Duration, +) -> Result<Vec<SdkRadrootsdSignerSessionViewResponse>, RadrootsdError> { + jsonrpc_call( + endpoint, + auth, + "radroots-sdk-nip46-session-list", + "nip46.session.list", + &json!({}), + timeout, + ) + .await +} + +pub(crate) async fn authorize_signer_session( + endpoint: &str, + auth: &RadrootsdAuth, + session_id: &str, + timeout: Duration, +) -> Result<SdkRadrootsdSignerSessionAuthorizeResponse, RadrootsdError> { + jsonrpc_call( + endpoint, + auth, + "radroots-sdk-nip46-session-authorize", + "nip46.session.authorize", + &SdkRadrootsdSignerSessionParams { session_id }, + timeout, + ) + .await +} + +pub(crate) async fn get_signer_session_public_key( + endpoint: &str, + auth: &RadrootsdAuth, + session_id: &str, + timeout: Duration, +) -> Result<SdkRadrootsdSignerSessionPublicKeyResponse, RadrootsdError> { + jsonrpc_call( + endpoint, + auth, + "radroots-sdk-nip46-get-public-key", + "nip46.get_public_key", + &SdkRadrootsdSignerSessionParams { session_id }, + timeout, + ) + .await +} + +pub(crate) async fn require_signer_session_auth( + endpoint: &str, + auth: &RadrootsdAuth, + session_id: &str, + auth_url: &str, + timeout: Duration, +) -> Result<SdkRadrootsdSignerSessionRequireAuthResponse, RadrootsdError> { + jsonrpc_call( + endpoint, + auth, + "radroots-sdk-nip46-session-require-auth", + "nip46.session.require_auth", + &SdkRadrootsdSignerSessionRequireAuthParams { + session_id, + auth_url, + }, + timeout, + ) + .await +} + +pub(crate) async fn close_signer_session( + endpoint: &str, + auth: &RadrootsdAuth, + session_id: &str, + timeout: Duration, +) -> Result<SdkRadrootsdSignerSessionCloseResponse, RadrootsdError> { + jsonrpc_call( + endpoint, + auth, + "radroots-sdk-nip46-session-close", + "nip46.session.close", + &SdkRadrootsdSignerSessionParams { session_id }, + timeout, + ) + .await +} + +pub(crate) async fn bridge_status( + endpoint: &str, + auth: &RadrootsdAuth, + timeout: Duration, +) -> Result<SdkRadrootsdBridgeStatusResponse, RadrootsdError> { + jsonrpc_call( + endpoint, + auth, + "radroots-sdk-bridge-status", + "bridge.status", + &json!({}), + timeout, + ) + .await +} + +pub(crate) async fn bridge_job_status( + endpoint: &str, + auth: &RadrootsdAuth, + job_id: &str, + timeout: Duration, +) -> Result<SdkRadrootsdBridgeJobView, RadrootsdError> { + jsonrpc_call( + endpoint, + auth, + "radroots-sdk-bridge-job-status", + "bridge.job.status", + &SdkRadrootsdBridgeJobParams { job_id }, + timeout, + ) + .await +} + +pub(crate) async fn list_bridge_jobs( + endpoint: &str, + auth: &RadrootsdAuth, + timeout: Duration, +) -> Result<Vec<SdkRadrootsdBridgeJobView>, RadrootsdError> { + jsonrpc_call( + endpoint, + auth, + "radroots-sdk-bridge-job-list", + "bridge.job.list", + &json!({}), + timeout, + ) + .await +} + +fn auth_headers(auth: &RadrootsdAuth) -> Result<HeaderMap, RadrootsdError> { + let mut headers = HeaderMap::new(); + match auth { + RadrootsdAuth::None => Ok(headers), + RadrootsdAuth::BearerToken(token) => { + let value = HeaderValue::from_str(format!("Bearer {token}").as_str()) + .map_err(|err| RadrootsdError::InvalidAuthHeader(err.to_string()))?; + headers.insert(AUTHORIZATION, value); + Ok(headers) + } + } +} + +pub fn bridge_listing_publish_request_json( + request: &SdkRadrootsdListingPublishRequest, +) -> Result<Value, RadrootsdError> { + serde_json::to_value(request).map_err(|err| { + RadrootsdError::MalformedResponse(format!( + "serialize radrootsd listing publish request: {err}" + )) + }) +} + +async fn jsonrpc_call<P, R>( + endpoint: &str, + auth: &RadrootsdAuth, + request_id: &str, + method: &str, + params: &P, + timeout: Duration, +) -> Result<R, RadrootsdError> +where + P: Serialize + ?Sized, + R: DeserializeOwned, +{ + let client = reqwest::Client::builder() + .timeout(timeout) + .build() + .map_err(|err| RadrootsdError::Http(format!("build radrootsd client: {err}")))?; + let mut request_builder = client + .post(endpoint) + .headers(auth_headers(auth)?) + .json(&json!({ + "jsonrpc": "2.0", + "id": request_id, + "method": method, + "params": params, + })); + + request_builder = request_builder.header(CONTENT_TYPE, "application/json"); + + let response = request_builder + .send() + .await + .map_err(|err| RadrootsdError::Http(format!("send radrootsd {method} request: {err}")))?; + let status = response.status(); + let body = response + .text() + .await + .map_err(|err| RadrootsdError::Http(format!("read radrootsd response body: {err}")))?; + + if !status.is_success() { + return Err(RadrootsdError::Http(format!( + "radrootsd returned http {}: {}", + status.as_u16(), + body + ))); + } + + let envelope: JsonRpcEnvelope<R> = serde_json::from_str(body.as_str()).map_err(|err| { + RadrootsdError::MalformedResponse(format!("decode radrootsd {method} response: {err}")) + })?; + match (envelope.result, envelope.error) { + (Some(result), None) => Ok(result), + (None, Some(error)) => Err(RadrootsdError::JsonRpc(format!( + "radrootsd {method} failed {}: {}", + error.code, error.message + ))), + (Some(_), Some(error)) => Err(RadrootsdError::MalformedResponse(format!( + "radrootsd {method} returned result and error: {} {}", + error.code, error.message + ))), + (None, None) => Err(RadrootsdError::MalformedResponse(format!( + "radrootsd {method} returned neither result nor error" + ))), + } +} diff --git a/crates/sdk/src/adapters/relay.rs b/crates/sdk/src/adapters/relay.rs @@ -0,0 +1,96 @@ +use core::time::Duration; + +use crate::adapters::signing::SignedNostrEvent; +use crate::identity::RadrootsIdentity; +use radroots_nostr::prelude::{ + RadrootsNostrClient, RadrootsNostrClientOptions, RadrootsNostrError, RadrootsNostrEventId, + RadrootsNostrOutput, +}; + +pub type RelayClient = RadrootsNostrClient; +pub type RelayClientOptions = RadrootsNostrClientOptions; +pub type RelayError = RadrootsNostrError; +pub type RelayEventId = RadrootsNostrEventId; +pub type RelayOutput<T> = RadrootsNostrOutput<T>; + +pub fn signerless_client() -> RelayClient { + RelayClient::new_signerless() +} + +pub fn signerless_client_with_options( + options: RelayClientOptions, +) -> Result<RelayClient, RelayError> { + RelayClient::new_signerless_with_options(options) +} + +pub fn client_from_identity(identity: &RadrootsIdentity) -> RelayClient { + RelayClient::from_identity(identity) +} + +pub async fn configure_write_relays( + client: &RelayClient, + relay_urls: &[String], + connect_timeout: Duration, +) -> Result<(), RelayError> { + for relay_url in relay_urls { + client.add_write_relay(relay_url).await?; + } + client.connect().await; + client.wait_for_connection(connect_timeout).await; + Ok(()) +} + +pub async fn connected_client_from_identity( + identity: &RadrootsIdentity, + relay_urls: &[String], + connect_timeout: Duration, +) -> Result<RelayClient, RelayError> { + let client = client_from_identity(identity); + configure_write_relays(&client, relay_urls, connect_timeout).await?; + Ok(client) +} + +pub async fn connected_relay_urls(client: &RelayClient) -> Vec<String> { + let mut relay_urls = client + .relays() + .await + .into_values() + .filter(|relay| relay.is_connected()) + .map(|relay| relay.url().to_string()) + .collect::<Vec<_>>(); + relay_urls.sort(); + relay_urls +} + +pub async fn publish_signed_event( + client: &RelayClient, + event: &SignedNostrEvent, +) -> Result<RelayOutput<RelayEventId>, RelayError> { + client.send_event(event).await +} + +#[cfg(test)] +mod tests { + use super::{client_from_identity, signerless_client, signerless_client_with_options}; + use crate::identity::RadrootsIdentity; + use tokio::runtime::Runtime; + + #[test] + fn client_constructors_build_without_runtime_net() { + let identity = RadrootsIdentity::generate(); + let _client = client_from_identity(&identity); + let _signerless = signerless_client(); + let _signerless_with_options = + signerless_client_with_options(super::RelayClientOptions::new()) + .expect("signerless client with options"); + } + + #[test] + fn signerless_client_has_no_signer() { + let runtime = Runtime::new().expect("tokio runtime"); + runtime.block_on(async { + let client = signerless_client(); + assert!(!client.has_signer().await); + }); + } +} diff --git a/crates/sdk/src/adapters/signer.rs b/crates/sdk/src/adapters/signer.rs @@ -0,0 +1,24 @@ +pub use radroots_nostr_connect::prelude::{ + RADROOTS_NOSTR_CONNECT_PENDING_CONNECTION_ERROR, RADROOTS_NOSTR_CONNECT_RPC_KIND, + RadrootsNostrConnectBunkerUri, RadrootsNostrConnectClientMetadata, + RadrootsNostrConnectClientUri, RadrootsNostrConnectError, RadrootsNostrConnectMethod, + RadrootsNostrConnectPendingConnectionPollOutcome, RadrootsNostrConnectPermission, + RadrootsNostrConnectPermissions, RadrootsNostrConnectRemoteSessionCapability, + RadrootsNostrConnectRequest, RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse, + RadrootsNostrConnectResponseEnvelope, RadrootsNostrConnectUri, +}; +pub use radroots_nostr_signer::prelude::{ + RadrootsNostrEmbeddedSignerBackend, RadrootsNostrLocalSignerAvailability, + RadrootsNostrLocalSignerCapability, RadrootsNostrRemoteSessionSignerCapability, + RadrootsNostrSignerBackend, RadrootsNostrSignerBackendCapabilities, + RadrootsNostrSignerCapability, RadrootsNostrSignerConnectEvaluation, + RadrootsNostrSignerConnectProposal, RadrootsNostrSignerError, + RadrootsNostrSignerHandledRequest, RadrootsNostrSignerHandledRequestOutcome, + RadrootsNostrSignerManager, RadrootsNostrSignerNip46Codec, + RadrootsNostrSignerNip46ConnectDecision, RadrootsNostrSignerNip46Handler, + RadrootsNostrSignerNip46Policy, RadrootsNostrSignerNip46Signer, + RadrootsNostrSignerPublishTransition, RadrootsNostrSignerRequestAction, + RadrootsNostrSignerRequestEvaluation, RadrootsNostrSignerRequestResponseHint, + RadrootsNostrSignerSessionLookup, connect_response_outcome, handled_request_for_action, + response_from_hint, +}; diff --git a/crates/sdk/src/adapters/signing.rs b/crates/sdk/src/adapters/signing.rs @@ -0,0 +1,63 @@ +use crate::WireEventParts; +use crate::identity::RadrootsIdentity; +use radroots_nostr::prelude::{RadrootsNostrError, radroots_nostr_build_event}; + +pub type SignedNostrEvent = radroots_nostr::prelude::RadrootsNostrEvent; +pub type NostrEventBuilder = radroots_nostr::prelude::RadrootsNostrEventBuilder; +pub type SigningError = RadrootsNostrError; + +pub fn event_builder_from_parts(parts: WireEventParts) -> Result<NostrEventBuilder, SigningError> { + radroots_nostr_build_event(parts.kind, parts.content, parts.tags) +} + +pub fn sign_parts_with_identity( + identity: &RadrootsIdentity, + parts: WireEventParts, +) -> Result<SignedNostrEvent, SigningError> { + let builder = event_builder_from_parts(parts)?; + sign_builder_with_identity(identity, builder) +} + +pub fn sign_builder_with_identity( + identity: &RadrootsIdentity, + builder: NostrEventBuilder, +) -> Result<SignedNostrEvent, SigningError> { + builder.sign_with_keys(identity.keys()).map_err(Into::into) +} + +#[cfg(test)] +mod tests { + use super::{event_builder_from_parts, sign_parts_with_identity}; + use crate::{WireEventParts, identity::RadrootsIdentity}; + + #[test] + fn event_builder_from_parts_preserves_kind_and_content() { + let builder = event_builder_from_parts(WireEventParts { + kind: 30402, + content: "hello".into(), + tags: vec![vec!["x".into(), "y".into()]], + }) + .expect("builder"); + let identity = RadrootsIdentity::generate(); + let event = builder.build(identity.keys().public_key()); + + assert_eq!(u16::from(event.kind), 30402); + assert_eq!(event.content, "hello"); + } + + #[test] + fn sign_parts_with_identity_signs_event() { + let identity = RadrootsIdentity::generate(); + let event = sign_parts_with_identity( + &identity, + WireEventParts { + kind: 30402, + content: "hello".into(), + tags: vec![], + }, + ) + .expect("signed event"); + + assert_eq!(event.pubkey.to_hex(), identity.public_key_hex()); + } +} diff --git a/crates/sdk/src/client.rs b/crates/sdk/src/client.rs @@ -0,0 +1,2711 @@ +#[cfg(not(feature = "std"))] +use alloc::{string::String, vec::Vec}; +use core::fmt; +#[cfg(feature = "std")] +use std::{string::String, vec::Vec}; + +#[cfg(feature = "radrootsd-client")] +use crate::adapters::radrootsd; +#[cfg(all( + feature = "identity-models", + feature = "relay-client", + feature = "signing" +))] +use crate::adapters::{relay, signing}; +use crate::config::SignerConfig; +use crate::config::{RadrootsSdkConfig, SdkConfigError, SdkTransportMode}; +#[cfg(all( + feature = "identity-models", + feature = "relay-client", + feature = "signing" +))] +use crate::identity::RadrootsIdentity; +use crate::{ + NostrTags, RadrootsNostrEvent, RadrootsNostrEventPtr, RadrootsProfile, RadrootsProfileType, + TradeListingValidateResult, WireEventParts, farm, listing, order, profile, +}; +#[cfg(any( + feature = "radrootsd-client", + all( + feature = "identity-models", + feature = "relay-client", + feature = "signing" + ) +))] +use core::time::Duration; +use radroots_events::ids::RadrootsEventId; +#[cfg(feature = "radrootsd-client")] +use radroots_events::kinds::{KIND_FARM, KIND_LISTING}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SdkPublishReceipt { + pub transport: SdkTransportMode, + pub event_kind: Option<u32>, + pub event_id: Option<String>, + pub transport_receipt: SdkTransportReceipt, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SdkTransportReceipt { + RelayDirect(SdkRelayPublishReceipt), + Radrootsd(SdkRadrootsdPublishReceipt), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SdkRelayPublishReceipt { + pub event: RadrootsNostrEvent, + pub event_id: String, + pub event_kind: u32, + pub created_at: u32, + pub signature: String, + pub target_relays: Vec<String>, + pub connected_relays: Vec<String>, + pub acknowledged_relays: Vec<String>, + pub failed_relays: Vec<SdkRelayFailure>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SdkRelayFailure { + pub relay_url: String, + pub error: String, +} + +#[derive(Clone, PartialEq, Eq, Default)] +pub struct SdkRadrootsdPublishReceipt { + pub accepted: bool, + pub deduplicated: bool, + pub job_id: Option<String>, + pub status: Option<String>, + pub signer_mode: Option<String>, + pub signer_session_id: Option<String>, + pub event_addr: Option<String>, + pub relay_count: Option<usize>, + pub acknowledged_relay_count: Option<usize>, +} + +impl fmt::Debug for SdkRadrootsdPublishReceipt { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut debug = f.debug_struct("SdkRadrootsdPublishReceipt"); + debug.field("accepted", &self.accepted); + debug.field("deduplicated", &self.deduplicated); + debug.field("job_id", &self.job_id); + debug.field("status", &self.status); + debug.field( + "signer_mode", + &self.signer_mode.as_ref().map(|_| "<redacted>"), + ); + debug.field( + "signer_session_id", + &self.signer_session_id.as_ref().map(|_| "<redacted>"), + ); + debug.field("event_addr", &self.event_addr); + debug.field("relay_count", &self.relay_count); + debug.field("acknowledged_relay_count", &self.acknowledged_relay_count); + debug.finish() + } +} + +#[cfg(feature = "radrootsd-client")] +impl SdkRadrootsdPublishReceipt { + pub fn job(&self) -> Option<SdkRadrootsdBridgeJobRef> { + self.job_id + .as_ref() + .map(|job_id| SdkRadrootsdBridgeJobRef::new(job_id.clone())) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SdkPublishError { + Config(SdkConfigError), + Encode(String), + UnsupportedTransport { + transport: SdkTransportMode, + operation: &'static str, + }, + UnsupportedSignerMode { + transport: SdkTransportMode, + signer: SignerConfig, + required: SignerConfig, + operation: &'static str, + }, + Relay(String), + RelaySetup { + transport: SdkTransportMode, + operation: &'static str, + target_relays: Vec<String>, + error: String, + }, + RelayNotAcknowledged { + transport: SdkTransportMode, + failed_relays: Vec<SdkRelayFailure>, + }, + Radrootsd(String), +} + +impl From<SdkConfigError> for SdkPublishError { + fn from(value: SdkConfigError) -> Self { + Self::Config(value) + } +} + +impl core::fmt::Display for SdkPublishError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::Config(err) => write!(f, "{err}"), + Self::Encode(message) => write!(f, "{message}"), + Self::UnsupportedTransport { + transport, + operation, + } => { + write!( + f, + "{operation} requires a different sdk transport mode than {transport:?}" + ) + } + Self::UnsupportedSignerMode { + transport, + signer, + required, + operation, + } => write!( + f, + "{operation} requires signer mode `{required}` for {transport:?} transport, got `{signer}`" + ), + Self::Relay(message) => write!(f, "{message}"), + Self::RelaySetup { + transport, + operation, + target_relays, + error, + } => { + if target_relays.is_empty() { + write!( + f, + "{operation} failed to prepare {transport:?} relay publish: {error}" + ) + } else { + let relays = target_relays.join(", "); + write!( + f, + "{operation} failed to prepare {transport:?} relay publish for {relays}: {error}" + ) + } + } + Self::RelayNotAcknowledged { + transport, + failed_relays, + } => { + if failed_relays.is_empty() { + write!(f, "{transport:?} publish was not acknowledged by any relay") + } else { + let summary = failed_relays + .iter() + .map(|failure| format!("{}: {}", failure.relay_url, failure.error)) + .collect::<Vec<_>>() + .join(", "); + write!( + f, + "{transport:?} publish was not acknowledged by any relay: {summary}" + ) + } + } + Self::Radrootsd(message) => write!(f, "{message}"), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for SdkPublishError {} + +#[cfg(feature = "radrootsd-client")] +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SdkRadrootsdSessionError { + Config(SdkConfigError), + UnsupportedTransport { + transport: SdkTransportMode, + operation: &'static str, + }, + Radrootsd(String), +} + +#[cfg(feature = "radrootsd-client")] +impl From<SdkConfigError> for SdkRadrootsdSessionError { + fn from(value: SdkConfigError) -> Self { + Self::Config(value) + } +} + +#[cfg(feature = "radrootsd-client")] +impl fmt::Display for SdkRadrootsdSessionError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Config(err) => write!(f, "{err}"), + Self::UnsupportedTransport { + transport, + operation, + } => { + write!( + f, + "{operation} requires a different sdk transport mode than {transport:?}" + ) + } + Self::Radrootsd(message) => write!(f, "{message}"), + } + } +} + +#[cfg(all(feature = "radrootsd-client", feature = "std"))] +impl std::error::Error for SdkRadrootsdSessionError {} + +#[cfg(feature = "radrootsd-client")] +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SdkRadrootsdBridgeError { + Config(SdkConfigError), + UnsupportedTransport { + transport: SdkTransportMode, + operation: &'static str, + }, + Radrootsd(String), +} + +#[cfg(feature = "radrootsd-client")] +impl From<SdkConfigError> for SdkRadrootsdBridgeError { + fn from(value: SdkConfigError) -> Self { + Self::Config(value) + } +} + +#[cfg(feature = "radrootsd-client")] +impl fmt::Display for SdkRadrootsdBridgeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Config(err) => write!(f, "{err}"), + Self::UnsupportedTransport { + transport, + operation, + } => write!( + f, + "{operation} requires a different sdk transport mode than {transport:?}" + ), + Self::Radrootsd(message) => write!(f, "{message}"), + } + } +} + +#[cfg(all(feature = "radrootsd-client", feature = "std"))] +impl std::error::Error for SdkRadrootsdBridgeError {} + +#[cfg(feature = "radrootsd-client")] +#[derive(Clone, PartialEq, Eq)] +pub struct SdkRadrootsdSignerSessionRef { + session_id: String, +} + +#[cfg(feature = "radrootsd-client")] +impl fmt::Debug for SdkRadrootsdSignerSessionRef { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("SdkRadrootsdSignerSessionRef") + .field("session_id", &"<redacted>") + .finish() + } +} + +#[cfg(feature = "radrootsd-client")] +impl SdkRadrootsdSignerSessionRef { + pub fn from_session_id(session_id: impl Into<String>) -> Self { + Self { + session_id: session_id.into(), + } + } + + pub fn session_id(&self) -> &str { + self.session_id.as_str() + } +} + +#[cfg(feature = "radrootsd-client")] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SdkRadrootsdBridgeJobRef { + job_id: String, +} + +#[cfg(feature = "radrootsd-client")] +impl SdkRadrootsdBridgeJobRef { + pub fn new(job_id: impl Into<String>) -> Self { + Self { + job_id: job_id.into(), + } + } + + pub fn job_id(&self) -> &str { + self.job_id.as_str() + } +} + +#[cfg(feature = "radrootsd-client")] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SdkRadrootsdBridgeStatus { + pub enabled: bool, + pub ready: bool, + pub auth_mode: String, + pub signer_mode: String, + pub default_signer_mode: String, + pub supported_signer_modes: Vec<String>, + pub available_nip46_signer_sessions: usize, + pub relay_count: usize, + pub delivery_policy: radrootsd::SdkRadrootsdBridgeDeliveryPolicy, + pub delivery_quorum: Option<usize>, + pub publish_max_attempts: usize, + pub publish_initial_backoff_millis: u64, + pub publish_max_backoff_millis: u64, + pub job_status_retention: usize, + pub retained_jobs: usize, + pub retained_idempotency_keys: usize, + pub accepted_jobs: usize, + pub published_jobs: usize, + pub failed_jobs: usize, + pub recovered_failed_jobs: usize, + pub methods: Vec<String>, +} + +#[cfg(feature = "radrootsd-client")] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SdkRadrootsdBridgeJobView { + job: SdkRadrootsdBridgeJobRef, + pub command: String, + pub idempotency_key: Option<String>, + pub status: radrootsd::SdkRadrootsdBridgeJobStatus, + pub terminal: bool, + pub recovered_after_restart: bool, + pub requested_at_unix: u64, + pub completed_at_unix: Option<u64>, + pub signer_mode: String, + pub signer_session_id: Option<String>, + pub event_kind: u32, + pub event_id: Option<String>, + pub event_addr: Option<String>, + pub delivery_policy: radrootsd::SdkRadrootsdBridgeDeliveryPolicy, + pub delivery_quorum: Option<usize>, + pub relay_count: usize, + pub acknowledged_relay_count: usize, + pub required_acknowledged_relay_count: usize, + pub attempt_count: usize, + pub attempt_summaries: Vec<String>, + pub relay_results: Vec<radrootsd::SdkRadrootsdBridgeRelayPublishResult>, + pub relay_outcome_summary: String, +} + +#[cfg(feature = "radrootsd-client")] +impl SdkRadrootsdBridgeJobView { + pub fn job(&self) -> &SdkRadrootsdBridgeJobRef { + &self.job + } +} + +#[cfg(feature = "radrootsd-client")] +impl From<radrootsd::SdkRadrootsdBridgeStatusResponse> for SdkRadrootsdBridgeStatus { + fn from(value: radrootsd::SdkRadrootsdBridgeStatusResponse) -> Self { + Self { + enabled: value.enabled, + ready: value.ready, + auth_mode: value.auth_mode, + signer_mode: value.signer_mode, + default_signer_mode: value.default_signer_mode, + supported_signer_modes: value.supported_signer_modes, + available_nip46_signer_sessions: value.available_nip46_signer_sessions, + relay_count: value.relay_count, + delivery_policy: value.delivery_policy, + delivery_quorum: value.delivery_quorum, + publish_max_attempts: value.publish_max_attempts, + publish_initial_backoff_millis: value.publish_initial_backoff_millis, + publish_max_backoff_millis: value.publish_max_backoff_millis, + job_status_retention: value.job_status_retention, + retained_jobs: value.retained_jobs, + retained_idempotency_keys: value.retained_idempotency_keys, + accepted_jobs: value.accepted_jobs, + published_jobs: value.published_jobs, + failed_jobs: value.failed_jobs, + recovered_failed_jobs: value.recovered_failed_jobs, + methods: value.methods, + } + } +} + +#[cfg(feature = "radrootsd-client")] +impl From<radrootsd::SdkRadrootsdBridgeJobView> for SdkRadrootsdBridgeJobView { + fn from(value: radrootsd::SdkRadrootsdBridgeJobView) -> Self { + Self { + job: SdkRadrootsdBridgeJobRef::new(value.job_id), + command: value.command, + idempotency_key: value.idempotency_key, + status: value.status, + terminal: value.terminal, + recovered_after_restart: value.recovered_after_restart, + requested_at_unix: value.requested_at_unix, + completed_at_unix: value.completed_at_unix, + signer_mode: value.signer_mode, + signer_session_id: value.signer_session_id, + event_kind: value.event_kind, + event_id: value.event_id, + event_addr: value.event_addr, + delivery_policy: value.delivery_policy, + delivery_quorum: value.delivery_quorum, + relay_count: value.relay_count, + acknowledged_relay_count: value.acknowledged_relay_count, + required_acknowledged_relay_count: value.required_acknowledged_relay_count, + attempt_count: value.attempt_count, + attempt_summaries: value.attempt_summaries, + relay_results: value.relay_results, + relay_outcome_summary: value.relay_outcome_summary, + } + } +} + +#[cfg(feature = "radrootsd-client")] +#[derive(Clone, PartialEq, Eq)] +pub struct SdkRadrootsdSignerSessionHandle { + session: SdkRadrootsdSignerSessionRef, + mode: radrootsd::SdkRadrootsdSignerSessionMode, + remote_signer_pubkey: String, + client_pubkey: String, + relays: Vec<String>, +} + +#[cfg(feature = "radrootsd-client")] +#[derive(Clone, PartialEq, Eq)] +pub struct SdkRadrootsdSignerSessionView { + session: SdkRadrootsdSignerSessionRef, + pub role: radrootsd::SdkRadrootsdSignerSessionRole, + pub client_pubkey: String, + pub signer_pubkey: String, + pub user_pubkey: Option<String>, + pub relays: Vec<String>, + pub permissions: Vec<String>, + pub name: Option<String>, + pub url: Option<String>, + pub image: Option<String>, + pub auth_required: bool, + pub authorized: bool, + pub auth_url: Option<String>, + pub expires_in_secs: Option<u64>, + pub signer_authority: Option<radrootsd::SdkRadrootsdSignerAuthority>, +} + +#[cfg(feature = "radrootsd-client")] +impl fmt::Debug for SdkRadrootsdSignerSessionView { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut debug = f.debug_struct("SdkRadrootsdSignerSessionView"); + debug.field("session", &self.session); + debug.field("role", &self.role); + debug.field("client_pubkey", &self.client_pubkey); + debug.field("signer_pubkey", &self.signer_pubkey); + debug.field("user_pubkey", &self.user_pubkey); + debug.field("relays", &self.relays); + debug.field("permissions", &self.permissions); + debug.field("name", &self.name); + debug.field("url", &self.url); + debug.field("image", &self.image); + debug.field("auth_required", &self.auth_required); + debug.field("authorized", &self.authorized); + debug.field("auth_url", &self.auth_url); + debug.field("expires_in_secs", &self.expires_in_secs); + debug.field("signer_authority", &self.signer_authority); + debug.finish() + } +} + +#[cfg(feature = "radrootsd-client")] +impl SdkRadrootsdSignerSessionView { + pub fn session(&self) -> &SdkRadrootsdSignerSessionRef { + &self.session + } +} + +#[cfg(feature = "radrootsd-client")] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SdkRadrootsdSignerSessionAuthorizeResult { + pub authorized: bool, + pub replayed: bool, +} + +#[cfg(feature = "radrootsd-client")] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SdkRadrootsdSignerSessionPublicKeyResult { + pub pubkey: String, +} + +#[cfg(feature = "radrootsd-client")] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SdkRadrootsdSignerSessionRequireAuthResult { + pub required: bool, +} + +#[cfg(feature = "radrootsd-client")] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SdkRadrootsdSignerSessionCloseResult { + pub closed: bool, +} + +#[cfg(feature = "radrootsd-client")] +impl From<radrootsd::SdkRadrootsdSignerSessionViewResponse> for SdkRadrootsdSignerSessionView { + fn from(value: radrootsd::SdkRadrootsdSignerSessionViewResponse) -> Self { + Self { + session: SdkRadrootsdSignerSessionRef { + session_id: value.session_id, + }, + role: value.role, + client_pubkey: value.client_pubkey, + signer_pubkey: value.signer_pubkey, + user_pubkey: value.user_pubkey, + relays: value.relays, + permissions: value.permissions, + name: value.name, + url: value.url, + image: value.image, + auth_required: value.auth_required, + authorized: value.authorized, + auth_url: value.auth_url, + expires_in_secs: value.expires_in_secs, + signer_authority: value.signer_authority, + } + } +} + +#[cfg(feature = "radrootsd-client")] +impl From<radrootsd::SdkRadrootsdSignerSessionAuthorizeResponse> + for SdkRadrootsdSignerSessionAuthorizeResult +{ + fn from(value: radrootsd::SdkRadrootsdSignerSessionAuthorizeResponse) -> Self { + Self { + authorized: value.authorized, + replayed: value.replayed, + } + } +} + +#[cfg(feature = "radrootsd-client")] +impl From<radrootsd::SdkRadrootsdSignerSessionPublicKeyResponse> + for SdkRadrootsdSignerSessionPublicKeyResult +{ + fn from(value: radrootsd::SdkRadrootsdSignerSessionPublicKeyResponse) -> Self { + Self { + pubkey: value.pubkey, + } + } +} + +#[cfg(feature = "radrootsd-client")] +impl From<radrootsd::SdkRadrootsdSignerSessionRequireAuthResponse> + for SdkRadrootsdSignerSessionRequireAuthResult +{ + fn from(value: radrootsd::SdkRadrootsdSignerSessionRequireAuthResponse) -> Self { + Self { + required: value.required, + } + } +} + +#[cfg(feature = "radrootsd-client")] +impl From<radrootsd::SdkRadrootsdSignerSessionCloseResponse> + for SdkRadrootsdSignerSessionCloseResult +{ + fn from(value: radrootsd::SdkRadrootsdSignerSessionCloseResponse) -> Self { + Self { + closed: value.closed, + } + } +} + +#[cfg(feature = "radrootsd-client")] +impl fmt::Debug for SdkRadrootsdSignerSessionHandle { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut debug = f.debug_struct("SdkRadrootsdSignerSessionHandle"); + debug.field("session", &self.session); + debug.field("mode", &self.mode); + debug.field("remote_signer_pubkey", &self.remote_signer_pubkey); + debug.field("client_pubkey", &self.client_pubkey); + debug.field("relays", &self.relays); + debug.finish() + } +} + +#[cfg(feature = "radrootsd-client")] +impl SdkRadrootsdSignerSessionHandle { + pub fn session(&self) -> &SdkRadrootsdSignerSessionRef { + &self.session + } + + pub fn mode(&self) -> radrootsd::SdkRadrootsdSignerSessionMode { + self.mode + } + + pub fn remote_signer_pubkey(&self) -> &str { + self.remote_signer_pubkey.as_str() + } + + pub fn client_pubkey(&self) -> &str { + self.client_pubkey.as_str() + } + + pub fn relays(&self) -> &[String] { + self.relays.as_slice() + } +} + +#[cfg(feature = "radrootsd-client")] +impl From<radrootsd::SdkRadrootsdSignerSessionConnectResponse> for SdkRadrootsdSignerSessionHandle { + fn from(value: radrootsd::SdkRadrootsdSignerSessionConnectResponse) -> Self { + Self { + session: SdkRadrootsdSignerSessionRef { + session_id: value.session_id, + }, + mode: value.mode, + remote_signer_pubkey: value.remote_signer_pubkey, + client_pubkey: value.client_pubkey, + relays: value.relays, + } + } +} + +#[cfg(feature = "radrootsd-client")] +#[derive(Clone, PartialEq, Eq)] +pub struct SdkRadrootsdProfilePublishOptions { + session: SdkRadrootsdSignerSessionRef, + idempotency_key: Option<String>, + signer_authority: Option<radrootsd::SdkRadrootsdSignerAuthority>, +} + +#[cfg(feature = "radrootsd-client")] +impl SdkRadrootsdProfilePublishOptions { + pub fn from_signer_session(session: &SdkRadrootsdSignerSessionHandle) -> Self { + Self { + session: session.session().clone(), + idempotency_key: None, + signer_authority: None, + } + } + + pub fn from_signer_session_ref(session: &SdkRadrootsdSignerSessionRef) -> Self { + Self { + session: session.clone(), + idempotency_key: None, + signer_authority: None, + } + } + + pub fn with_idempotency_key(mut self, idempotency_key: impl Into<String>) -> Self { + self.idempotency_key = Some(idempotency_key.into()); + self + } + + pub fn with_signer_authority( + mut self, + signer_authority: radrootsd::SdkRadrootsdSignerAuthority, + ) -> Self { + self.signer_authority = Some(signer_authority); + self + } + + pub fn session(&self) -> &SdkRadrootsdSignerSessionRef { + &self.session + } + + pub fn idempotency_key(&self) -> Option<&str> { + self.idempotency_key.as_deref() + } + + pub fn signer_authority(&self) -> Option<&radrootsd::SdkRadrootsdSignerAuthority> { + self.signer_authority.as_ref() + } +} + +#[cfg(feature = "radrootsd-client")] +impl fmt::Debug for SdkRadrootsdProfilePublishOptions { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut debug = f.debug_struct("SdkRadrootsdProfilePublishOptions"); + debug.field("session", &self.session); + debug.field("idempotency_key", &self.idempotency_key); + debug.field("signer_authority", &self.signer_authority); + debug.finish() + } +} + +#[cfg(feature = "radrootsd-client")] +#[derive(Clone, PartialEq, Eq)] +pub struct SdkRadrootsdFarmPublishOptions { + session: SdkRadrootsdSignerSessionRef, + idempotency_key: Option<String>, + signer_authority: Option<radrootsd::SdkRadrootsdSignerAuthority>, +} + +#[cfg(feature = "radrootsd-client")] +impl SdkRadrootsdFarmPublishOptions { + pub fn from_signer_session(session: &SdkRadrootsdSignerSessionHandle) -> Self { + Self { + session: session.session().clone(), + idempotency_key: None, + signer_authority: None, + } + } + + pub fn from_signer_session_ref(session: &SdkRadrootsdSignerSessionRef) -> Self { + Self { + session: session.clone(), + idempotency_key: None, + signer_authority: None, + } + } + + pub fn with_idempotency_key(mut self, idempotency_key: impl Into<String>) -> Self { + self.idempotency_key = Some(idempotency_key.into()); + self + } + + pub fn with_signer_authority( + mut self, + signer_authority: radrootsd::SdkRadrootsdSignerAuthority, + ) -> Self { + self.signer_authority = Some(signer_authority); + self + } + + pub fn session(&self) -> &SdkRadrootsdSignerSessionRef { + &self.session + } + + pub fn idempotency_key(&self) -> Option<&str> { + self.idempotency_key.as_deref() + } + + pub fn signer_authority(&self) -> Option<&radrootsd::SdkRadrootsdSignerAuthority> { + self.signer_authority.as_ref() + } +} + +#[cfg(feature = "radrootsd-client")] +impl fmt::Debug for SdkRadrootsdFarmPublishOptions { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut debug = f.debug_struct("SdkRadrootsdFarmPublishOptions"); + debug.field("session", &self.session); + debug.field("idempotency_key", &self.idempotency_key); + debug.field("signer_authority", &self.signer_authority); + debug.finish() + } +} + +#[cfg(feature = "radrootsd-client")] +#[derive(Clone, PartialEq, Eq)] +pub struct SdkRadrootsdListingPublishOptions { + session: SdkRadrootsdSignerSessionRef, + idempotency_key: Option<String>, + signer_authority: Option<radrootsd::SdkRadrootsdSignerAuthority>, +} + +#[cfg(feature = "radrootsd-client")] +impl SdkRadrootsdListingPublishOptions { + pub fn from_signer_session(session: &SdkRadrootsdSignerSessionHandle) -> Self { + Self { + session: session.session().clone(), + idempotency_key: None, + signer_authority: None, + } + } + + pub fn from_signer_session_ref(session: &SdkRadrootsdSignerSessionRef) -> Self { + Self { + session: session.clone(), + idempotency_key: None, + signer_authority: None, + } + } + + pub fn with_idempotency_key(mut self, idempotency_key: impl Into<String>) -> Self { + self.idempotency_key = Some(idempotency_key.into()); + self + } + + pub fn with_signer_authority( + mut self, + signer_authority: radrootsd::SdkRadrootsdSignerAuthority, + ) -> Self { + self.signer_authority = Some(signer_authority); + self + } + + pub fn session(&self) -> &SdkRadrootsdSignerSessionRef { + &self.session + } + + pub fn idempotency_key(&self) -> Option<&str> { + self.idempotency_key.as_deref() + } + + pub fn signer_authority(&self) -> Option<&radrootsd::SdkRadrootsdSignerAuthority> { + self.signer_authority.as_ref() + } +} + +#[cfg(feature = "radrootsd-client")] +impl fmt::Debug for SdkRadrootsdListingPublishOptions { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut debug = f.debug_struct("SdkRadrootsdListingPublishOptions"); + debug.field("session", &self.session); + debug.field("idempotency_key", &self.idempotency_key); + debug.field("signer_authority", &self.signer_authority); + debug.finish() + } +} + +#[cfg(feature = "radrootsd-client")] +#[derive(Clone, PartialEq, Eq)] +pub struct SdkRadrootsdOrderRequestPublishOptions { + session: SdkRadrootsdSignerSessionRef, + idempotency_key: Option<String>, + signer_authority: Option<radrootsd::SdkRadrootsdSignerAuthority>, +} + +#[cfg(feature = "radrootsd-client")] +impl SdkRadrootsdOrderRequestPublishOptions { + pub fn from_signer_session(session: &SdkRadrootsdSignerSessionHandle) -> Self { + Self { + session: session.session().clone(), + idempotency_key: None, + signer_authority: None, + } + } + + pub fn from_signer_session_ref(session: &SdkRadrootsdSignerSessionRef) -> Self { + Self { + session: session.clone(), + idempotency_key: None, + signer_authority: None, + } + } + + pub fn with_idempotency_key(mut self, idempotency_key: impl Into<String>) -> Self { + self.idempotency_key = Some(idempotency_key.into()); + self + } + + pub fn with_signer_authority( + mut self, + signer_authority: radrootsd::SdkRadrootsdSignerAuthority, + ) -> Self { + self.signer_authority = Some(signer_authority); + self + } + + pub fn session(&self) -> &SdkRadrootsdSignerSessionRef { + &self.session + } + + pub fn idempotency_key(&self) -> Option<&str> { + self.idempotency_key.as_deref() + } + + pub fn signer_authority(&self) -> Option<&radrootsd::SdkRadrootsdSignerAuthority> { + self.signer_authority.as_ref() + } +} + +#[cfg(feature = "radrootsd-client")] +impl fmt::Debug for SdkRadrootsdOrderRequestPublishOptions { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut debug = f.debug_struct("SdkRadrootsdOrderRequestPublishOptions"); + debug.field("session", &self.session); + debug.field("idempotency_key", &self.idempotency_key); + debug.field("signer_authority", &self.signer_authority); + debug.finish() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RadrootsSdkClient { + config: RadrootsSdkConfig, + resolved_transport_target: SdkResolvedTransportTarget, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SdkResolvedTransportTarget { + RelayDirect { relay_urls: Vec<String> }, + Radrootsd { endpoint: String }, +} + +impl RadrootsSdkClient { + pub fn from_config(config: RadrootsSdkConfig) -> Result<Self, SdkConfigError> { + let resolved_transport_target = match config.transport { + SdkTransportMode::RelayDirect => SdkResolvedTransportTarget::RelayDirect { + relay_urls: config.resolved_relay_urls()?, + }, + SdkTransportMode::Radrootsd => SdkResolvedTransportTarget::Radrootsd { + endpoint: config.resolved_radrootsd_endpoint()?, + }, + }; + Ok(Self { + config, + resolved_transport_target, + }) + } + + pub fn config(&self) -> &RadrootsSdkConfig { + &self.config + } + + pub fn transport(&self) -> SdkTransportMode { + self.config.transport + } + + pub fn signer(&self) -> SignerConfig { + self.config.signer + } + + pub fn resolved_transport_target(&self) -> &SdkResolvedTransportTarget { + &self.resolved_transport_target + } + + pub fn profile(&self) -> ProfileClient<'_> { + ProfileClient { client: self } + } + + pub fn farm(&self) -> FarmClient<'_> { + FarmClient { client: self } + } + + pub fn listing(&self) -> ListingClient<'_> { + ListingClient { client: self } + } + + pub fn order(&self) -> TradeClient<'_> { + TradeClient { client: self } + } + + #[cfg(feature = "radrootsd-client")] + pub fn radrootsd(&self) -> RadrootsdClient<'_> { + RadrootsdClient { client: self } + } + + #[cfg(any( + feature = "radrootsd-client", + all( + feature = "identity-models", + feature = "relay-client", + feature = "signing" + ) + ))] + fn require_signer_mode( + &self, + required: SignerConfig, + operation: &'static str, + ) -> Result<(), SdkPublishError> { + let signer = self.signer(); + if signer == required { + return Ok(()); + } + Err(SdkPublishError::UnsupportedSignerMode { + transport: self.transport(), + signer, + required, + operation, + }) + } + + #[cfg(all( + feature = "identity-models", + feature = "relay-client", + feature = "signing" + ))] + async fn publish_parts_via_relay_with_identity( + &self, + identity: &RadrootsIdentity, + parts: WireEventParts, + operation: &'static str, + ) -> Result<SdkPublishReceipt, SdkPublishError> { + if self.transport() != SdkTransportMode::RelayDirect { + return Err(SdkPublishError::UnsupportedTransport { + transport: self.transport(), + operation, + }); + } + self.require_signer_mode(SignerConfig::LocalIdentity, operation)?; + + let relay_urls = match &self.resolved_transport_target { + SdkResolvedTransportTarget::RelayDirect { relay_urls } => relay_urls.clone(), + SdkResolvedTransportTarget::Radrootsd { .. } => { + return Err(SdkPublishError::UnsupportedTransport { + transport: self.transport(), + operation, + }); + } + }; + let client = relay::connected_client_from_identity( + identity, + &relay_urls, + Duration::from_millis(self.config.network.timeout_ms), + ) + .await + .map_err(|err| SdkPublishError::RelaySetup { + transport: SdkTransportMode::RelayDirect, + operation, + target_relays: relay_urls.clone(), + error: err.to_string(), + })?; + let connected_relays = relay::connected_relay_urls(&client).await; + if connected_relays.is_empty() { + return Err(SdkPublishError::RelaySetup { + transport: SdkTransportMode::RelayDirect, + operation, + target_relays: relay_urls, + error: "no relay connection was established".to_owned(), + }); + } + let signed_event = signing::sign_parts_with_identity(identity, parts) + .map_err(|err| SdkPublishError::Relay(err.to_string()))?; + let output = relay::publish_signed_event(&client, &signed_event) + .await + .map_err(|err| SdkPublishError::RelaySetup { + transport: SdkTransportMode::RelayDirect, + operation, + target_relays: relay_urls.clone(), + error: err.to_string(), + })?; + sdk_publish_receipt_from_relay_output(signed_event, relay_urls, connected_relays, output) + } + + #[cfg(feature = "radrootsd-client")] + async fn publish_listing_via_radrootsd( + &self, + request: &radrootsd::SdkRadrootsdListingPublishRequest, + ) -> Result<SdkPublishReceipt, SdkPublishError> { + if self.transport() != SdkTransportMode::Radrootsd { + return Err(SdkPublishError::UnsupportedTransport { + transport: self.transport(), + operation: "listing.publish_via_radrootsd", + }); + } + self.require_signer_mode(SignerConfig::Nip46, "listing.publish_via_radrootsd")?; + + let endpoint = match &self.resolved_transport_target { + SdkResolvedTransportTarget::Radrootsd { endpoint } => endpoint.as_str(), + SdkResolvedTransportTarget::RelayDirect { .. } => { + return Err(SdkPublishError::UnsupportedTransport { + transport: self.transport(), + operation: "listing.publish_via_radrootsd", + }); + } + }; + let response = radrootsd::publish_listing( + endpoint, + &self.config.radrootsd.auth, + request, + Duration::from_millis(self.config.network.timeout_ms), + ) + .await + .map_err(|err| SdkPublishError::Radrootsd(err.to_string()))?; + Ok(sdk_publish_receipt_from_radrootsd_bridge_response(response)) + } + + #[cfg(feature = "radrootsd-client")] + async fn publish_profile_via_radrootsd( + &self, + request: &radrootsd::SdkRadrootsdProfilePublishRequest, + ) -> Result<SdkPublishReceipt, SdkPublishError> { + if self.transport() != SdkTransportMode::Radrootsd { + return Err(SdkPublishError::UnsupportedTransport { + transport: self.transport(), + operation: "profile.publish_via_radrootsd", + }); + } + self.require_signer_mode(SignerConfig::Nip46, "profile.publish_via_radrootsd")?; + + let endpoint = match &self.resolved_transport_target { + SdkResolvedTransportTarget::Radrootsd { endpoint } => endpoint.as_str(), + SdkResolvedTransportTarget::RelayDirect { .. } => { + return Err(SdkPublishError::UnsupportedTransport { + transport: self.transport(), + operation: "profile.publish_via_radrootsd", + }); + } + }; + let response = radrootsd::publish_profile( + endpoint, + &self.config.radrootsd.auth, + request, + Duration::from_millis(self.config.network.timeout_ms), + ) + .await + .map_err(|err| SdkPublishError::Radrootsd(err.to_string()))?; + Ok(sdk_publish_receipt_from_radrootsd_bridge_response(response)) + } + + #[cfg(feature = "radrootsd-client")] + async fn publish_farm_via_radrootsd( + &self, + request: &radrootsd::SdkRadrootsdFarmPublishRequest, + ) -> Result<SdkPublishReceipt, SdkPublishError> { + if self.transport() != SdkTransportMode::Radrootsd { + return Err(SdkPublishError::UnsupportedTransport { + transport: self.transport(), + operation: "farm.publish_via_radrootsd", + }); + } + self.require_signer_mode(SignerConfig::Nip46, "farm.publish_via_radrootsd")?; + + let endpoint = match &self.resolved_transport_target { + SdkResolvedTransportTarget::Radrootsd { endpoint } => endpoint.as_str(), + SdkResolvedTransportTarget::RelayDirect { .. } => { + return Err(SdkPublishError::UnsupportedTransport { + transport: self.transport(), + operation: "farm.publish_via_radrootsd", + }); + } + }; + let response = radrootsd::publish_farm( + endpoint, + &self.config.radrootsd.auth, + request, + Duration::from_millis(self.config.network.timeout_ms), + ) + .await + .map_err(|err| SdkPublishError::Radrootsd(err.to_string()))?; + Ok(sdk_publish_receipt_from_radrootsd_bridge_response(response)) + } + + #[cfg(feature = "radrootsd-client")] + async fn publish_order_request_via_radrootsd( + &self, + request: &radrootsd::SdkRadrootsdOrderRequestPublishRequest, + ) -> Result<SdkPublishReceipt, SdkPublishError> { + if self.transport() != SdkTransportMode::Radrootsd { + return Err(SdkPublishError::UnsupportedTransport { + transport: self.transport(), + operation: "order.publish_order_request_via_radrootsd", + }); + } + self.require_signer_mode( + SignerConfig::Nip46, + "order.publish_order_request_via_radrootsd", + )?; + + let endpoint = match &self.resolved_transport_target { + SdkResolvedTransportTarget::Radrootsd { endpoint } => endpoint.as_str(), + SdkResolvedTransportTarget::RelayDirect { .. } => { + return Err(SdkPublishError::UnsupportedTransport { + transport: self.transport(), + operation: "order.publish_order_request_via_radrootsd", + }); + } + }; + let response = radrootsd::publish_order_request( + endpoint, + &self.config.radrootsd.auth, + request, + Duration::from_millis(self.config.network.timeout_ms), + ) + .await + .map_err(|err| SdkPublishError::Radrootsd(err.to_string()))?; + Ok(sdk_publish_receipt_from_radrootsd_bridge_response(response)) + } + + #[cfg(feature = "radrootsd-client")] + async fn connect_radrootsd_signer_session( + &self, + request: &radrootsd::SdkRadrootsdSignerSessionConnectRequest, + ) -> Result<SdkRadrootsdSignerSessionHandle, SdkRadrootsdSessionError> { + if self.transport() != SdkTransportMode::Radrootsd { + return Err(SdkRadrootsdSessionError::UnsupportedTransport { + transport: self.transport(), + operation: "radrootsd.signer_sessions.connect", + }); + } + + let endpoint = self.require_radrootsd_endpoint("radrootsd.signer_sessions.connect")?; + let response = radrootsd::connect_signer_session( + endpoint, + &self.config.radrootsd.auth, + request, + Duration::from_millis(self.config.network.timeout_ms), + ) + .await + .map_err(|err| SdkRadrootsdSessionError::Radrootsd(err.to_string()))?; + Ok(response.into()) + } + + #[cfg(feature = "radrootsd-client")] + fn require_radrootsd_endpoint( + &self, + operation: &'static str, + ) -> Result<&str, SdkRadrootsdSessionError> { + match &self.resolved_transport_target { + SdkResolvedTransportTarget::Radrootsd { endpoint } => Ok(endpoint.as_str()), + SdkResolvedTransportTarget::RelayDirect { .. } => { + Err(SdkRadrootsdSessionError::UnsupportedTransport { + transport: self.transport(), + operation, + }) + } + } + } + + #[cfg(feature = "radrootsd-client")] + async fn radrootsd_signer_session_status( + &self, + session: &SdkRadrootsdSignerSessionRef, + ) -> Result<SdkRadrootsdSignerSessionView, SdkRadrootsdSessionError> { + if self.transport() != SdkTransportMode::Radrootsd { + return Err(SdkRadrootsdSessionError::UnsupportedTransport { + transport: self.transport(), + operation: "radrootsd.signer_sessions.status", + }); + } + + let response = radrootsd::signer_session_status( + self.require_radrootsd_endpoint("radrootsd.signer_sessions.status")?, + &self.config.radrootsd.auth, + session.session_id(), + Duration::from_millis(self.config.network.timeout_ms), + ) + .await + .map_err(|err| SdkRadrootsdSessionError::Radrootsd(err.to_string()))?; + Ok(response.into()) + } + + #[cfg(feature = "radrootsd-client")] + async fn radrootsd_list_signer_sessions( + &self, + ) -> Result<Vec<SdkRadrootsdSignerSessionView>, SdkRadrootsdSessionError> { + if self.transport() != SdkTransportMode::Radrootsd { + return Err(SdkRadrootsdSessionError::UnsupportedTransport { + transport: self.transport(), + operation: "radrootsd.signer_sessions.list", + }); + } + + let response = radrootsd::list_signer_sessions( + self.require_radrootsd_endpoint("radrootsd.signer_sessions.list")?, + &self.config.radrootsd.auth, + Duration::from_millis(self.config.network.timeout_ms), + ) + .await + .map_err(|err| SdkRadrootsdSessionError::Radrootsd(err.to_string()))?; + Ok(response.into_iter().map(Into::into).collect()) + } + + #[cfg(feature = "radrootsd-client")] + async fn authorize_radrootsd_signer_session( + &self, + session: &SdkRadrootsdSignerSessionRef, + ) -> Result<SdkRadrootsdSignerSessionAuthorizeResult, SdkRadrootsdSessionError> { + if self.transport() != SdkTransportMode::Radrootsd { + return Err(SdkRadrootsdSessionError::UnsupportedTransport { + transport: self.transport(), + operation: "radrootsd.signer_sessions.authorize", + }); + } + + let response = radrootsd::authorize_signer_session( + self.require_radrootsd_endpoint("radrootsd.signer_sessions.authorize")?, + &self.config.radrootsd.auth, + session.session_id(), + Duration::from_millis(self.config.network.timeout_ms), + ) + .await + .map_err(|err| SdkRadrootsdSessionError::Radrootsd(err.to_string()))?; + Ok(response.into()) + } + + #[cfg(feature = "radrootsd-client")] + async fn get_radrootsd_signer_session_public_key( + &self, + session: &SdkRadrootsdSignerSessionRef, + ) -> Result<SdkRadrootsdSignerSessionPublicKeyResult, SdkRadrootsdSessionError> { + if self.transport() != SdkTransportMode::Radrootsd { + return Err(SdkRadrootsdSessionError::UnsupportedTransport { + transport: self.transport(), + operation: "radrootsd.signer_sessions.get_public_key", + }); + } + + let response = radrootsd::get_signer_session_public_key( + self.require_radrootsd_endpoint("radrootsd.signer_sessions.get_public_key")?, + &self.config.radrootsd.auth, + session.session_id(), + Duration::from_millis(self.config.network.timeout_ms), + ) + .await + .map_err(|err| SdkRadrootsdSessionError::Radrootsd(err.to_string()))?; + Ok(response.into()) + } + + #[cfg(feature = "radrootsd-client")] + async fn require_radrootsd_signer_session_auth( + &self, + session: &SdkRadrootsdSignerSessionRef, + auth_url: &str, + ) -> Result<SdkRadrootsdSignerSessionRequireAuthResult, SdkRadrootsdSessionError> { + if self.transport() != SdkTransportMode::Radrootsd { + return Err(SdkRadrootsdSessionError::UnsupportedTransport { + transport: self.transport(), + operation: "radrootsd.signer_sessions.require_auth", + }); + } + + let response = radrootsd::require_signer_session_auth( + self.require_radrootsd_endpoint("radrootsd.signer_sessions.require_auth")?, + &self.config.radrootsd.auth, + session.session_id(), + auth_url, + Duration::from_millis(self.config.network.timeout_ms), + ) + .await + .map_err(|err| SdkRadrootsdSessionError::Radrootsd(err.to_string()))?; + Ok(response.into()) + } + + #[cfg(feature = "radrootsd-client")] + async fn close_radrootsd_signer_session( + &self, + session: &SdkRadrootsdSignerSessionRef, + ) -> Result<SdkRadrootsdSignerSessionCloseResult, SdkRadrootsdSessionError> { + if self.transport() != SdkTransportMode::Radrootsd { + return Err(SdkRadrootsdSessionError::UnsupportedTransport { + transport: self.transport(), + operation: "radrootsd.signer_sessions.close", + }); + } + + let response = radrootsd::close_signer_session( + self.require_radrootsd_endpoint("radrootsd.signer_sessions.close")?, + &self.config.radrootsd.auth, + session.session_id(), + Duration::from_millis(self.config.network.timeout_ms), + ) + .await + .map_err(|err| SdkRadrootsdSessionError::Radrootsd(err.to_string()))?; + Ok(response.into()) + } + + #[cfg(feature = "radrootsd-client")] + fn require_radrootsd_bridge_endpoint( + &self, + operation: &'static str, + ) -> Result<&str, SdkRadrootsdBridgeError> { + match &self.resolved_transport_target { + SdkResolvedTransportTarget::Radrootsd { endpoint } => Ok(endpoint.as_str()), + SdkResolvedTransportTarget::RelayDirect { .. } => { + Err(SdkRadrootsdBridgeError::UnsupportedTransport { + transport: self.transport(), + operation, + }) + } + } + } + + #[cfg(feature = "radrootsd-client")] + async fn radrootsd_bridge_status( + &self, + ) -> Result<SdkRadrootsdBridgeStatus, SdkRadrootsdBridgeError> { + if self.transport() != SdkTransportMode::Radrootsd { + return Err(SdkRadrootsdBridgeError::UnsupportedTransport { + transport: self.transport(), + operation: "radrootsd.bridge.status", + }); + } + + let response = radrootsd::bridge_status( + self.require_radrootsd_bridge_endpoint("radrootsd.bridge.status")?, + &self.config.radrootsd.auth, + Duration::from_millis(self.config.network.timeout_ms), + ) + .await + .map_err(|err| SdkRadrootsdBridgeError::Radrootsd(err.to_string()))?; + Ok(response.into()) + } + + #[cfg(feature = "radrootsd-client")] + async fn radrootsd_bridge_job_status( + &self, + job: &SdkRadrootsdBridgeJobRef, + ) -> Result<SdkRadrootsdBridgeJobView, SdkRadrootsdBridgeError> { + if self.transport() != SdkTransportMode::Radrootsd { + return Err(SdkRadrootsdBridgeError::UnsupportedTransport { + transport: self.transport(), + operation: "radrootsd.bridge.job", + }); + } + + let response = radrootsd::bridge_job_status( + self.require_radrootsd_bridge_endpoint("radrootsd.bridge.job")?, + &self.config.radrootsd.auth, + job.job_id(), + Duration::from_millis(self.config.network.timeout_ms), + ) + .await + .map_err(|err| SdkRadrootsdBridgeError::Radrootsd(err.to_string()))?; + Ok(response.into()) + } + + #[cfg(feature = "radrootsd-client")] + async fn radrootsd_bridge_jobs( + &self, + ) -> Result<Vec<SdkRadrootsdBridgeJobView>, SdkRadrootsdBridgeError> { + if self.transport() != SdkTransportMode::Radrootsd { + return Err(SdkRadrootsdBridgeError::UnsupportedTransport { + transport: self.transport(), + operation: "radrootsd.bridge.jobs", + }); + } + + let response = radrootsd::list_bridge_jobs( + self.require_radrootsd_bridge_endpoint("radrootsd.bridge.jobs")?, + &self.config.radrootsd.auth, + Duration::from_millis(self.config.network.timeout_ms), + ) + .await + .map_err(|err| SdkRadrootsdBridgeError::Radrootsd(err.to_string()))?; + Ok(response.into_iter().map(Into::into).collect()) + } +} + +#[cfg(feature = "radrootsd-client")] +#[derive(Debug, Clone, Copy)] +pub struct RadrootsdClient<'a> { + client: &'a RadrootsSdkClient, +} + +#[cfg(feature = "radrootsd-client")] +impl<'a> RadrootsdClient<'a> { + pub fn sdk(&self) -> &'a RadrootsSdkClient { + self.client + } + + pub fn transport(&self) -> SdkTransportMode { + self.client.transport() + } + + pub fn signer(&self) -> SignerConfig { + self.client.signer() + } + + pub fn signer_sessions(&self) -> RadrootsdSignerSessionClient<'a> { + RadrootsdSignerSessionClient { + client: self.client, + } + } + + pub fn bridge(&self) -> RadrootsdBridgeClient<'a> { + RadrootsdBridgeClient { + client: self.client, + } + } +} + +#[cfg(feature = "radrootsd-client")] +#[derive(Debug, Clone, Copy)] +pub struct RadrootsdSignerSessionClient<'a> { + client: &'a RadrootsSdkClient, +} + +#[cfg(feature = "radrootsd-client")] +impl<'a> RadrootsdSignerSessionClient<'a> { + pub fn sdk(&self) -> &'a RadrootsSdkClient { + self.client + } + + pub fn transport(&self) -> SdkTransportMode { + self.client.transport() + } + + pub fn signer(&self) -> SignerConfig { + self.client.signer() + } + + pub async fn connect( + &self, + request: &radrootsd::SdkRadrootsdSignerSessionConnectRequest, + ) -> Result<SdkRadrootsdSignerSessionHandle, SdkRadrootsdSessionError> { + self.client.connect_radrootsd_signer_session(request).await + } + + pub async fn connect_bunker( + &self, + url: impl Into<String>, + ) -> Result<SdkRadrootsdSignerSessionHandle, SdkRadrootsdSessionError> { + let request = radrootsd::SdkRadrootsdSignerSessionConnectRequest::bunker(url); + self.connect(&request).await + } + + pub async fn connect_nostrconnect( + &self, + url: impl Into<String>, + client_secret_key: impl Into<String>, + ) -> Result<SdkRadrootsdSignerSessionHandle, SdkRadrootsdSessionError> { + let request = radrootsd::SdkRadrootsdSignerSessionConnectRequest::nostrconnect( + url, + client_secret_key, + ); + self.connect(&request).await + } + + pub async fn status( + &self, + session: &SdkRadrootsdSignerSessionRef, + ) -> Result<SdkRadrootsdSignerSessionView, SdkRadrootsdSessionError> { + self.client.radrootsd_signer_session_status(session).await + } + + pub async fn list( + &self, + ) -> Result<Vec<SdkRadrootsdSignerSessionView>, SdkRadrootsdSessionError> { + self.client.radrootsd_list_signer_sessions().await + } + + pub async fn authorize( + &self, + session: &SdkRadrootsdSignerSessionRef, + ) -> Result<SdkRadrootsdSignerSessionAuthorizeResult, SdkRadrootsdSessionError> { + self.client + .authorize_radrootsd_signer_session(session) + .await + } + + pub async fn get_public_key( + &self, + session: &SdkRadrootsdSignerSessionRef, + ) -> Result<SdkRadrootsdSignerSessionPublicKeyResult, SdkRadrootsdSessionError> { + self.client + .get_radrootsd_signer_session_public_key(session) + .await + } + + pub async fn require_auth( + &self, + session: &SdkRadrootsdSignerSessionRef, + auth_url: impl AsRef<str>, + ) -> Result<SdkRadrootsdSignerSessionRequireAuthResult, SdkRadrootsdSessionError> { + self.client + .require_radrootsd_signer_session_auth(session, auth_url.as_ref()) + .await + } + + pub async fn close( + &self, + session: &SdkRadrootsdSignerSessionRef, + ) -> Result<SdkRadrootsdSignerSessionCloseResult, SdkRadrootsdSessionError> { + self.client.close_radrootsd_signer_session(session).await + } +} + +#[cfg(feature = "radrootsd-client")] +#[derive(Debug, Clone, Copy)] +pub struct RadrootsdBridgeClient<'a> { + client: &'a RadrootsSdkClient, +} + +#[cfg(feature = "radrootsd-client")] +impl<'a> RadrootsdBridgeClient<'a> { + pub fn sdk(&self) -> &'a RadrootsSdkClient { + self.client + } + + pub fn transport(&self) -> SdkTransportMode { + self.client.transport() + } + + pub fn signer(&self) -> SignerConfig { + self.client.signer() + } + + pub async fn status(&self) -> Result<SdkRadrootsdBridgeStatus, SdkRadrootsdBridgeError> { + self.client.radrootsd_bridge_status().await + } + + pub async fn job( + &self, + job: &SdkRadrootsdBridgeJobRef, + ) -> Result<SdkRadrootsdBridgeJobView, SdkRadrootsdBridgeError> { + self.client.radrootsd_bridge_job_status(job).await + } + + pub async fn jobs(&self) -> Result<Vec<SdkRadrootsdBridgeJobView>, SdkRadrootsdBridgeError> { + self.client.radrootsd_bridge_jobs().await + } +} + +#[derive(Debug, Clone, Copy)] +pub struct ProfileClient<'a> { + client: &'a RadrootsSdkClient, +} + +impl<'a> ProfileClient<'a> { + pub fn sdk(&self) -> &'a RadrootsSdkClient { + self.client + } + + pub fn transport(&self) -> SdkTransportMode { + self.client.transport() + } + + pub fn signer(&self) -> SignerConfig { + self.client.signer() + } + + #[cfg(feature = "serde_json")] + pub fn build_draft( + &self, + profile_value: &RadrootsProfile, + profile_type: Option<RadrootsProfileType>, + ) -> Result<WireEventParts, profile::ProfileEncodeError> { + profile::build_draft(profile_value, profile_type) + } + + #[cfg(all( + feature = "identity-models", + feature = "relay-client", + feature = "signing" + ))] + pub async fn publish_with_identity( + &self, + identity: &RadrootsIdentity, + profile_value: &RadrootsProfile, + profile_type: Option<RadrootsProfileType>, + ) -> Result<SdkPublishReceipt, SdkPublishError> { + let parts = profile::build_draft(profile_value, profile_type) + .map_err(|err| SdkPublishError::Encode(err.to_string()))?; + self.client + .publish_parts_via_relay_with_identity(identity, parts, "profile.publish_with_identity") + .await + } + + #[cfg(all( + feature = "identity-models", + feature = "relay-client", + feature = "signing" + ))] + pub async fn publish_draft_with_identity( + &self, + identity: &RadrootsIdentity, + draft: WireEventParts, + ) -> Result<SdkPublishReceipt, SdkPublishError> { + self.client + .publish_parts_via_relay_with_identity( + identity, + draft, + "profile.publish_draft_with_identity", + ) + .await + } + + #[cfg(feature = "radrootsd-client")] + pub async fn publish_profile_via_radrootsd( + &self, + profile_value: &RadrootsProfile, + profile_type: Option<RadrootsProfileType>, + session: &SdkRadrootsdSignerSessionHandle, + ) -> Result<SdkPublishReceipt, SdkPublishError> { + self.publish_profile_via_radrootsd_with_options( + profile_value, + profile_type, + &SdkRadrootsdProfilePublishOptions::from_signer_session(session), + ) + .await + } + + #[cfg(feature = "radrootsd-client")] + pub async fn publish_profile_via_radrootsd_with_options( + &self, + profile_value: &RadrootsProfile, + profile_type: Option<RadrootsProfileType>, + options: &SdkRadrootsdProfilePublishOptions, + ) -> Result<SdkPublishReceipt, SdkPublishError> { + let request = radrootsd::SdkRadrootsdProfilePublishRequest { + profile: profile_value.clone(), + profile_type, + signer_session_id: options.session().session_id().to_owned(), + signer_authority: options.signer_authority().cloned(), + idempotency_key: options.idempotency_key().map(str::to_owned), + }; + self.client.publish_profile_via_radrootsd(&request).await + } +} + +#[derive(Debug, Clone, Copy)] +pub struct FarmClient<'a> { + client: &'a RadrootsSdkClient, +} + +impl<'a> FarmClient<'a> { + pub fn sdk(&self) -> &'a RadrootsSdkClient { + self.client + } + + pub fn transport(&self) -> SdkTransportMode { + self.client.transport() + } + + pub fn signer(&self) -> SignerConfig { + self.client.signer() + } + + #[cfg(feature = "serde_json")] + pub fn build_draft( + &self, + farm_value: &farm::RadrootsFarm, + ) -> Result<WireEventParts, farm::EventEncodeError> { + farm::build_draft(farm_value) + } + + #[cfg(all( + feature = "identity-models", + feature = "relay-client", + feature = "signing" + ))] + pub async fn publish_with_identity( + &self, + identity: &RadrootsIdentity, + farm_value: &farm::RadrootsFarm, + ) -> Result<SdkPublishReceipt, SdkPublishError> { + let parts = farm::build_draft(farm_value) + .map_err(|err| SdkPublishError::Encode(err.to_string()))?; + self.client + .publish_parts_via_relay_with_identity(identity, parts, "farm.publish_with_identity") + .await + } + + #[cfg(all( + feature = "identity-models", + feature = "relay-client", + feature = "signing" + ))] + pub async fn publish_draft_with_identity( + &self, + identity: &RadrootsIdentity, + draft: WireEventParts, + ) -> Result<SdkPublishReceipt, SdkPublishError> { + self.client + .publish_parts_via_relay_with_identity( + identity, + draft, + "farm.publish_draft_with_identity", + ) + .await + } + + #[cfg(feature = "radrootsd-client")] + pub async fn publish_farm_via_radrootsd( + &self, + farm_value: &farm::RadrootsFarm, + session: &SdkRadrootsdSignerSessionHandle, + ) -> Result<SdkPublishReceipt, SdkPublishError> { + self.publish_farm_via_radrootsd_with_options( + farm_value, + &SdkRadrootsdFarmPublishOptions::from_signer_session(session), + ) + .await + } + + #[cfg(feature = "radrootsd-client")] + pub async fn publish_farm_via_radrootsd_with_options( + &self, + farm_value: &farm::RadrootsFarm, + options: &SdkRadrootsdFarmPublishOptions, + ) -> Result<SdkPublishReceipt, SdkPublishError> { + let request = radrootsd::SdkRadrootsdFarmPublishRequest { + farm: farm_value.clone(), + kind: Some(KIND_FARM), + signer_session_id: options.session().session_id().to_owned(), + signer_authority: options.signer_authority().cloned(), + idempotency_key: options.idempotency_key().map(str::to_owned), + }; + self.client.publish_farm_via_radrootsd(&request).await + } +} + +#[derive(Debug, Clone, Copy)] +pub struct ListingClient<'a> { + client: &'a RadrootsSdkClient, +} + +impl<'a> ListingClient<'a> { + pub fn sdk(&self) -> &'a RadrootsSdkClient { + self.client + } + + pub fn transport(&self) -> SdkTransportMode { + self.client.transport() + } + + pub fn signer(&self) -> SignerConfig { + self.client.signer() + } + + pub fn build_tags( + &self, + listing_value: &listing::RadrootsListing, + ) -> Result<NostrTags, listing::EventEncodeError> { + listing::build_tags(listing_value) + } + + #[cfg(feature = "serde_json")] + pub fn build_draft( + &self, + listing_value: &listing::RadrootsListing, + ) -> Result<listing::RadrootsListingDraft, listing::EventEncodeError> { + listing::build_draft(listing_value) + } + + #[cfg(feature = "serde_json")] + pub fn parse_event( + &self, + event: &RadrootsNostrEvent, + ) -> Result<listing::RadrootsListing, listing::RadrootsListingParseError> { + listing::parse_event(event) + } + + #[cfg(all( + feature = "identity-models", + feature = "relay-client", + feature = "signing" + ))] + pub async fn publish_with_identity( + &self, + identity: &RadrootsIdentity, + listing_value: &listing::RadrootsListing, + ) -> Result<SdkPublishReceipt, SdkPublishError> { + let parts = listing::build_draft(listing_value) + .map_err(|err| SdkPublishError::Encode(err.to_string()))? + .into_wire_parts(); + self.client + .publish_parts_via_relay_with_identity(identity, parts, "listing.publish_with_identity") + .await + } + + #[cfg(all( + feature = "identity-models", + feature = "relay-client", + feature = "signing" + ))] + pub async fn publish_draft_with_identity( + &self, + identity: &RadrootsIdentity, + draft: listing::RadrootsListingDraft, + ) -> Result<SdkPublishReceipt, SdkPublishError> { + self.client + .publish_parts_via_relay_with_identity( + identity, + draft.into_wire_parts(), + "listing.publish_draft_with_identity", + ) + .await + } + + #[cfg(feature = "radrootsd-client")] + pub async fn publish_listing_via_radrootsd( + &self, + listing_value: &listing::RadrootsListing, + session: &SdkRadrootsdSignerSessionHandle, + ) -> Result<SdkPublishReceipt, SdkPublishError> { + self.publish_listing_via_radrootsd_with_options( + listing_value, + &SdkRadrootsdListingPublishOptions::from_signer_session(session), + ) + .await + } + + #[cfg(feature = "radrootsd-client")] + pub async fn publish_listing_via_radrootsd_with_options( + &self, + listing_value: &listing::RadrootsListing, + options: &SdkRadrootsdListingPublishOptions, + ) -> Result<SdkPublishReceipt, SdkPublishError> { + let request = radrootsd::SdkRadrootsdListingPublishRequest { + listing: listing_value.clone(), + kind: Some(KIND_LISTING), + signer_session_id: options.session().session_id().to_owned(), + signer_authority: options.signer_authority().cloned(), + idempotency_key: options.idempotency_key().map(str::to_owned), + }; + self.client.publish_listing_via_radrootsd(&request).await + } + + #[cfg(feature = "radrootsd-client")] + pub async fn publish_draft_via_radrootsd( + &self, + draft: listing::RadrootsListingDraft, + session: &SdkRadrootsdSignerSessionHandle, + ) -> Result<SdkPublishReceipt, SdkPublishError> { + self.publish_draft_via_radrootsd_with_options( + draft, + &SdkRadrootsdListingPublishOptions::from_signer_session(session), + ) + .await + } + + #[cfg(feature = "radrootsd-client")] + pub async fn publish_draft_via_radrootsd_with_options( + &self, + draft: listing::RadrootsListingDraft, + options: &SdkRadrootsdListingPublishOptions, + ) -> Result<SdkPublishReceipt, SdkPublishError> { + let parts = draft.into_wire_parts(); + let event = RadrootsNostrEvent { + id: String::new(), + author: String::new(), + created_at: 0, + kind: parts.kind, + tags: parts.tags, + content: parts.content, + sig: String::new(), + }; + let request = radrootsd::SdkRadrootsdListingPublishRequest::from_event( + &event, + options.session().session_id().to_owned(), + options.signer_authority().cloned(), + options.idempotency_key().map(str::to_owned), + ) + .map_err(|err| SdkPublishError::Encode(err.to_string()))?; + self.client.publish_listing_via_radrootsd(&request).await + } +} + +#[derive(Debug, Clone, Copy)] +pub struct TradeClient<'a> { + client: &'a RadrootsSdkClient, +} + +impl<'a> TradeClient<'a> { + pub fn sdk(&self) -> &'a RadrootsSdkClient { + self.client + } + + pub fn transport(&self) -> SdkTransportMode { + self.client.transport() + } + + pub fn signer(&self) -> SignerConfig { + self.client.signer() + } + + #[cfg(feature = "serde_json")] + pub fn parse_listing_address( + &self, + listing_addr: &str, + ) -> Result<order::RadrootsOrderListingAddress, order::RadrootsOrderListingAddressError> { + order::parse_listing_address(listing_addr) + } + + #[cfg(feature = "serde_json")] + pub fn validate_listing_event( + &self, + event: &RadrootsNostrEvent, + ) -> Result<TradeListingValidateResult, order::RadrootsTradeValidationListingError> { + order::validate_listing_event(event) + } + + #[cfg(feature = "serde_json")] + pub fn build_order_request_draft( + &self, + listing_event: &RadrootsNostrEventPtr, + payload: &order::RadrootsOrderRequest, + ) -> Result<order::RadrootsOrderRequestDraft, order::EventEncodeError> { + order::build_order_request_draft(listing_event, payload) + } + + #[cfg(feature = "serde_json")] + pub fn build_order_decision_draft( + &self, + root_event_id: &RadrootsEventId, + prev_event_id: &RadrootsEventId, + payload: &order::RadrootsOrderDecision, + ) -> Result<order::RadrootsOrderDecisionDraft, order::EventEncodeError> { + order::build_order_decision_draft(root_event_id, prev_event_id, payload) + } + + #[cfg(feature = "serde_json")] + pub fn build_order_revision_proposal_draft( + &self, + root_event_id: &RadrootsEventId, + prev_event_id: &RadrootsEventId, + payload: &order::RadrootsOrderRevisionProposal, + ) -> Result<order::RadrootsOrderRevisionProposalDraft, order::EventEncodeError> { + order::build_order_revision_proposal_draft(root_event_id, prev_event_id, payload) + } + + #[cfg(feature = "serde_json")] + pub fn build_order_revision_decision_draft( + &self, + root_event_id: &RadrootsEventId, + prev_event_id: &RadrootsEventId, + payload: &order::RadrootsOrderRevisionDecision, + ) -> Result<order::RadrootsOrderRevisionDecisionDraft, order::EventEncodeError> { + order::build_order_revision_decision_draft(root_event_id, prev_event_id, payload) + } + + #[cfg(feature = "serde_json")] + pub fn build_fulfillment_update_draft( + &self, + root_event_id: &RadrootsEventId, + prev_event_id: &RadrootsEventId, + payload: &order::RadrootsOrderFulfillmentUpdate, + ) -> Result<order::RadrootsOrderFulfillmentUpdateDraft, order::EventEncodeError> { + order::build_fulfillment_update_draft(root_event_id, prev_event_id, payload) + } + + #[cfg(feature = "serde_json")] + pub fn build_order_cancellation_draft( + &self, + root_event_id: &RadrootsEventId, + prev_event_id: &RadrootsEventId, + payload: &order::RadrootsOrderCancellation, + ) -> Result<order::RadrootsOrderCancellationDraft, order::EventEncodeError> { + order::build_order_cancellation_draft(root_event_id, prev_event_id, payload) + } + + #[cfg(feature = "serde_json")] + pub fn build_buyer_receipt_draft( + &self, + root_event_id: &RadrootsEventId, + prev_event_id: &RadrootsEventId, + payload: &order::RadrootsOrderReceipt, + ) -> Result<order::RadrootsOrderReceiptDraft, order::EventEncodeError> { + order::build_buyer_receipt_draft(root_event_id, prev_event_id, payload) + } + + #[cfg(feature = "serde_json")] + pub fn parse_order_request( + &self, + event: &RadrootsNostrEvent, + ) -> Result< + order::RadrootsOrderEnvelope<order::RadrootsOrderRequest>, + order::RadrootsOrderEnvelopeParseError, + > { + order::parse_order_request(event) + } + + #[cfg(feature = "serde_json")] + pub fn parse_order_decision( + &self, + event: &RadrootsNostrEvent, + ) -> Result< + order::RadrootsOrderEnvelope<order::RadrootsOrderDecision>, + order::RadrootsOrderEnvelopeParseError, + > { + order::parse_order_decision(event) + } + + #[cfg(feature = "serde_json")] + pub fn parse_order_revision_proposal( + &self, + event: &RadrootsNostrEvent, + ) -> Result< + order::RadrootsOrderEnvelope<order::RadrootsOrderRevisionProposal>, + order::RadrootsOrderEnvelopeParseError, + > { + order::parse_order_revision_proposal(event) + } + + #[cfg(feature = "serde_json")] + pub fn parse_order_revision_decision( + &self, + event: &RadrootsNostrEvent, + ) -> Result< + order::RadrootsOrderEnvelope<order::RadrootsOrderRevisionDecision>, + order::RadrootsOrderEnvelopeParseError, + > { + order::parse_order_revision_decision(event) + } + + #[cfg(feature = "serde_json")] + pub fn parse_fulfillment_update( + &self, + event: &RadrootsNostrEvent, + ) -> Result< + order::RadrootsOrderEnvelope<order::RadrootsOrderFulfillmentUpdate>, + order::RadrootsOrderEnvelopeParseError, + > { + order::parse_fulfillment_update(event) + } + + #[cfg(feature = "serde_json")] + pub fn parse_order_cancellation( + &self, + event: &RadrootsNostrEvent, + ) -> Result< + order::RadrootsOrderEnvelope<order::RadrootsOrderCancellation>, + order::RadrootsOrderEnvelopeParseError, + > { + order::parse_order_cancellation(event) + } + + #[cfg(feature = "serde_json")] + pub fn parse_buyer_receipt( + &self, + event: &RadrootsNostrEvent, + ) -> Result< + order::RadrootsOrderEnvelope<order::RadrootsOrderReceipt>, + order::RadrootsOrderEnvelopeParseError, + > { + order::parse_buyer_receipt(event) + } + + #[cfg(all( + feature = "identity-models", + feature = "relay-client", + feature = "signing" + ))] + pub async fn publish_order_request_with_identity( + &self, + identity: &RadrootsIdentity, + listing_event: &RadrootsNostrEventPtr, + payload: &order::RadrootsOrderRequest, + ) -> Result<SdkPublishReceipt, SdkPublishError> { + let draft = order::build_order_request_draft(listing_event, payload) + .map_err(|err| SdkPublishError::Encode(err.to_string()))?; + self.client + .publish_parts_via_relay_with_identity( + identity, + draft.into_wire_parts(), + "order.publish_order_request_with_identity", + ) + .await + } + + #[cfg(all( + feature = "identity-models", + feature = "relay-client", + feature = "signing" + ))] + pub async fn publish_order_revision_proposal_with_identity( + &self, + identity: &RadrootsIdentity, + root_event_id: &RadrootsEventId, + prev_event_id: &RadrootsEventId, + payload: &order::RadrootsOrderRevisionProposal, + ) -> Result<SdkPublishReceipt, SdkPublishError> { + let draft = + order::build_order_revision_proposal_draft(root_event_id, prev_event_id, payload) + .map_err(|err| SdkPublishError::Encode(err.to_string()))?; + self.client + .publish_parts_via_relay_with_identity( + identity, + draft.into_wire_parts(), + "order.publish_order_revision_proposal_with_identity", + ) + .await + } + + #[cfg(all( + feature = "identity-models", + feature = "relay-client", + feature = "signing" + ))] + pub async fn publish_order_revision_decision_with_identity( + &self, + identity: &RadrootsIdentity, + root_event_id: &RadrootsEventId, + prev_event_id: &RadrootsEventId, + payload: &order::RadrootsOrderRevisionDecision, + ) -> Result<SdkPublishReceipt, SdkPublishError> { + let draft = + order::build_order_revision_decision_draft(root_event_id, prev_event_id, payload) + .map_err(|err| SdkPublishError::Encode(err.to_string()))?; + self.client + .publish_parts_via_relay_with_identity( + identity, + draft.into_wire_parts(), + "order.publish_order_revision_decision_with_identity", + ) + .await + } + + #[cfg(all( + feature = "identity-models", + feature = "relay-client", + feature = "signing" + ))] + pub async fn publish_order_decision_with_identity( + &self, + identity: &RadrootsIdentity, + root_event_id: &RadrootsEventId, + prev_event_id: &RadrootsEventId, + payload: &order::RadrootsOrderDecision, + ) -> Result<SdkPublishReceipt, SdkPublishError> { + let draft = order::build_order_decision_draft(root_event_id, prev_event_id, payload) + .map_err(|err| SdkPublishError::Encode(err.to_string()))?; + self.client + .publish_parts_via_relay_with_identity( + identity, + draft.into_wire_parts(), + "order.publish_order_decision_with_identity", + ) + .await + } + + #[cfg(all( + feature = "identity-models", + feature = "relay-client", + feature = "signing" + ))] + pub async fn publish_fulfillment_update_with_identity( + &self, + identity: &RadrootsIdentity, + root_event_id: &RadrootsEventId, + prev_event_id: &RadrootsEventId, + payload: &order::RadrootsOrderFulfillmentUpdate, + ) -> Result<SdkPublishReceipt, SdkPublishError> { + let draft = order::build_fulfillment_update_draft(root_event_id, prev_event_id, payload) + .map_err(|err| SdkPublishError::Encode(err.to_string()))?; + self.client + .publish_parts_via_relay_with_identity( + identity, + draft.into_wire_parts(), + "order.publish_fulfillment_update_with_identity", + ) + .await + } + + #[cfg(all( + feature = "identity-models", + feature = "relay-client", + feature = "signing" + ))] + pub async fn publish_order_revision_proposal_draft_with_identity( + &self, + identity: &RadrootsIdentity, + draft: order::RadrootsOrderRevisionProposalDraft, + ) -> Result<SdkPublishReceipt, SdkPublishError> { + self.client + .publish_parts_via_relay_with_identity( + identity, + draft.into_wire_parts(), + "order.publish_order_revision_proposal_draft_with_identity", + ) + .await + } + + #[cfg(all( + feature = "identity-models", + feature = "relay-client", + feature = "signing" + ))] + pub async fn publish_order_revision_decision_draft_with_identity( + &self, + identity: &RadrootsIdentity, + draft: order::RadrootsOrderRevisionDecisionDraft, + ) -> Result<SdkPublishReceipt, SdkPublishError> { + self.client + .publish_parts_via_relay_with_identity( + identity, + draft.into_wire_parts(), + "order.publish_order_revision_decision_draft_with_identity", + ) + .await + } + + #[cfg(all( + feature = "identity-models", + feature = "relay-client", + feature = "signing" + ))] + pub async fn publish_order_cancellation_with_identity( + &self, + identity: &RadrootsIdentity, + root_event_id: &RadrootsEventId, + prev_event_id: &RadrootsEventId, + payload: &order::RadrootsOrderCancellation, + ) -> Result<SdkPublishReceipt, SdkPublishError> { + let draft = order::build_order_cancellation_draft(root_event_id, prev_event_id, payload) + .map_err(|err| SdkPublishError::Encode(err.to_string()))?; + self.client + .publish_parts_via_relay_with_identity( + identity, + draft.into_wire_parts(), + "order.publish_order_cancellation_with_identity", + ) + .await + } + + #[cfg(all( + feature = "identity-models", + feature = "relay-client", + feature = "signing" + ))] + pub async fn publish_buyer_receipt_with_identity( + &self, + identity: &RadrootsIdentity, + root_event_id: &RadrootsEventId, + prev_event_id: &RadrootsEventId, + payload: &order::RadrootsOrderReceipt, + ) -> Result<SdkPublishReceipt, SdkPublishError> { + let draft = order::build_buyer_receipt_draft(root_event_id, prev_event_id, payload) + .map_err(|err| SdkPublishError::Encode(err.to_string()))?; + self.client + .publish_parts_via_relay_with_identity( + identity, + draft.into_wire_parts(), + "order.publish_buyer_receipt_with_identity", + ) + .await + } + + #[cfg(all( + feature = "identity-models", + feature = "relay-client", + feature = "signing" + ))] + pub async fn publish_order_request_draft_with_identity( + &self, + identity: &RadrootsIdentity, + draft: order::RadrootsOrderRequestDraft, + ) -> Result<SdkPublishReceipt, SdkPublishError> { + self.client + .publish_parts_via_relay_with_identity( + identity, + draft.into_wire_parts(), + "order.publish_order_request_draft_with_identity", + ) + .await + } + + #[cfg(all( + feature = "identity-models", + feature = "relay-client", + feature = "signing" + ))] + pub async fn publish_order_decision_draft_with_identity( + &self, + identity: &RadrootsIdentity, + draft: order::RadrootsOrderDecisionDraft, + ) -> Result<SdkPublishReceipt, SdkPublishError> { + self.client + .publish_parts_via_relay_with_identity( + identity, + draft.into_wire_parts(), + "order.publish_order_decision_draft_with_identity", + ) + .await + } + + #[cfg(all( + feature = "identity-models", + feature = "relay-client", + feature = "signing" + ))] + pub async fn publish_fulfillment_update_draft_with_identity( + &self, + identity: &RadrootsIdentity, + draft: order::RadrootsOrderFulfillmentUpdateDraft, + ) -> Result<SdkPublishReceipt, SdkPublishError> { + self.client + .publish_parts_via_relay_with_identity( + identity, + draft.into_wire_parts(), + "order.publish_fulfillment_update_draft_with_identity", + ) + .await + } + + #[cfg(all( + feature = "identity-models", + feature = "relay-client", + feature = "signing" + ))] + pub async fn publish_order_cancellation_draft_with_identity( + &self, + identity: &RadrootsIdentity, + draft: order::RadrootsOrderCancellationDraft, + ) -> Result<SdkPublishReceipt, SdkPublishError> { + self.client + .publish_parts_via_relay_with_identity( + identity, + draft.into_wire_parts(), + "order.publish_order_cancellation_draft_with_identity", + ) + .await + } + + #[cfg(all( + feature = "identity-models", + feature = "relay-client", + feature = "signing" + ))] + pub async fn publish_buyer_receipt_draft_with_identity( + &self, + identity: &RadrootsIdentity, + draft: order::RadrootsOrderReceiptDraft, + ) -> Result<SdkPublishReceipt, SdkPublishError> { + self.client + .publish_parts_via_relay_with_identity( + identity, + draft.into_wire_parts(), + "order.publish_buyer_receipt_draft_with_identity", + ) + .await + } + + #[cfg(feature = "radrootsd-client")] + pub async fn publish_order_request_via_radrootsd( + &self, + order: &order::RadrootsOrderRequest, + listing_event: &RadrootsNostrEventPtr, + session: &SdkRadrootsdSignerSessionHandle, + ) -> Result<SdkPublishReceipt, SdkPublishError> { + self.publish_order_request_via_radrootsd_with_options( + order, + listing_event, + &SdkRadrootsdOrderRequestPublishOptions::from_signer_session(session), + ) + .await + } + + #[cfg(feature = "radrootsd-client")] + pub async fn publish_order_request_via_radrootsd_with_options( + &self, + order: &order::RadrootsOrderRequest, + listing_event: &RadrootsNostrEventPtr, + options: &SdkRadrootsdOrderRequestPublishOptions, + ) -> Result<SdkPublishReceipt, SdkPublishError> { + let request = radrootsd::SdkRadrootsdOrderRequestPublishRequest { + order: order.clone(), + listing_event: listing_event.clone(), + signer_session_id: options.session().session_id().to_owned(), + signer_authority: options.signer_authority().cloned(), + idempotency_key: options.idempotency_key().map(str::to_owned), + }; + self.client + .publish_order_request_via_radrootsd(&request) + .await + } +} + +#[cfg(all( + feature = "identity-models", + feature = "relay-client", + feature = "signing" +))] +fn sdk_publish_receipt_from_relay_output( + signed_event: signing::SignedNostrEvent, + target_relays: Vec<String>, + connected_relays: Vec<String>, + output: relay::RelayOutput<relay::RelayEventId>, +) -> Result<SdkPublishReceipt, SdkPublishError> { + let event = sdk_event_from_signed_event(&signed_event); + let event_id = event.id.clone(); + let event_kind = event.kind; + let created_at = event.created_at; + let signature = event.sig.clone(); + let target_relays = sorted_unique_strings(target_relays); + let connected_relays = sorted_unique_strings(connected_relays); + let mut acknowledged_relays = output + .success + .into_iter() + .map(|relay| relay.to_string()) + .collect::<Vec<_>>(); + acknowledged_relays = sorted_unique_strings(acknowledged_relays); + + let mut failed_relays = output + .failed + .into_iter() + .map(|(relay_url, error)| SdkRelayFailure { + relay_url: relay_url.to_string(), + error, + }) + .collect::<Vec<_>>(); + failed_relays.sort_by(|left, right| left.relay_url.cmp(&right.relay_url)); + + if acknowledged_relays.is_empty() { + return Err(SdkPublishError::RelayNotAcknowledged { + transport: SdkTransportMode::RelayDirect, + failed_relays, + }); + } + + Ok(SdkPublishReceipt { + transport: SdkTransportMode::RelayDirect, + event_kind: Some(event_kind), + event_id: Some(event_id.clone()), + transport_receipt: SdkTransportReceipt::RelayDirect(SdkRelayPublishReceipt { + event, + event_id, + event_kind, + created_at, + signature, + target_relays, + connected_relays, + acknowledged_relays, + failed_relays, + }), + }) +} + +#[cfg(all( + feature = "identity-models", + feature = "relay-client", + feature = "signing" +))] +fn sdk_event_from_signed_event(event: &signing::SignedNostrEvent) -> RadrootsNostrEvent { + RadrootsNostrEvent { + id: event.id.to_string(), + author: event.pubkey.to_string(), + created_at: u32::try_from(event.created_at.as_secs()).unwrap_or(u32::MAX), + kind: event.kind.as_u16() as u32, + tags: event + .tags + .iter() + .map(|tag| tag.as_slice().to_vec()) + .collect(), + content: event.content.clone(), + sig: event.sig.to_string(), + } +} + +#[cfg(all( + feature = "identity-models", + feature = "relay-client", + feature = "signing" +))] +fn sorted_unique_strings(mut values: Vec<String>) -> Vec<String> { + values.sort(); + values.dedup(); + values +} + +#[cfg(feature = "radrootsd-client")] +fn sdk_publish_receipt_from_radrootsd_bridge_response( + response: radrootsd::SdkRadrootsdBridgePublishResponse, +) -> SdkPublishReceipt { + let job = response.job; + SdkPublishReceipt { + transport: SdkTransportMode::Radrootsd, + event_kind: Some(job.event_kind), + event_id: job.event_id.clone(), + transport_receipt: SdkTransportReceipt::Radrootsd(SdkRadrootsdPublishReceipt { + accepted: true, + deduplicated: response.deduplicated, + job_id: Some(job.job_id), + status: Some(job.status), + signer_mode: Some(job.signer_mode), + signer_session_id: job.signer_session_id, + event_addr: job.event_addr, + relay_count: Some(job.relay_count), + acknowledged_relay_count: Some(job.acknowledged_relay_count), + }), + } +} + +#[cfg(all( + test, + feature = "identity-models", + feature = "relay-client", + feature = "signing" +))] +mod tests { + use super::{ + SdkPublishError, SdkRelayFailure, SdkTransportMode, sdk_publish_receipt_from_relay_output, + }; + use crate::WireEventParts; + use crate::adapters::relay::RelayOutput; + use crate::adapters::signing::sign_parts_with_identity; + use crate::identity::RadrootsIdentity; + use radroots_nostr::prelude::RadrootsNostrEventId; + use std::collections::{HashMap, HashSet}; + + #[test] + fn relay_output_maps_to_normalized_publish_receipt() { + let identity = RadrootsIdentity::generate(); + let signed_event = sign_parts_with_identity( + &identity, + WireEventParts { + kind: 30402, + content: "listing".to_owned(), + tags: vec![vec!["d".to_owned(), "AAAAAAAAAAAAAAAAAAAAAg".to_owned()]], + }, + ) + .expect("signed event"); + let event_id = signed_event.id.to_string(); + let event_created_at = u32::try_from(signed_event.created_at.as_secs()).unwrap(); + let event_signature = signed_event.sig.to_string(); + let output = RelayOutput { + val: RadrootsNostrEventId::parse(event_id.as_str()).expect("event id"), + success: HashSet::from([ + nostr::RelayUrl::parse("ws://127.0.0.1:8080").expect("relay a"), + nostr::RelayUrl::parse("ws://127.0.0.1:8081").expect("relay b"), + ]), + failed: HashMap::from([( + nostr::RelayUrl::parse("ws://127.0.0.1:8082").expect("relay c"), + "timeout".to_owned(), + )]), + }; + + let receipt = sdk_publish_receipt_from_relay_output( + signed_event, + vec![ + "ws://127.0.0.1:8081".to_owned(), + "ws://127.0.0.1:8080".to_owned(), + ], + vec!["ws://127.0.0.1:8080".to_owned()], + output, + ) + .expect("receipt"); + + assert_eq!(receipt.transport, SdkTransportMode::RelayDirect); + assert_eq!(receipt.event_kind, Some(30402)); + assert_eq!(receipt.event_id, Some(event_id.clone())); + let relay_receipt = match receipt.transport_receipt { + super::SdkTransportReceipt::RelayDirect(relay_receipt) => relay_receipt, + super::SdkTransportReceipt::Radrootsd(_) => panic!("unexpected radrootsd receipt"), + }; + assert_eq!(relay_receipt.event.id, event_id); + assert_eq!(relay_receipt.event_id, relay_receipt.event.id); + assert_eq!(relay_receipt.event_kind, 30402); + assert_eq!(relay_receipt.created_at, event_created_at); + assert_eq!(relay_receipt.signature, event_signature); + assert_eq!( + relay_receipt.target_relays, + vec![ + "ws://127.0.0.1:8080".to_owned(), + "ws://127.0.0.1:8081".to_owned(), + ] + ); + assert_eq!( + relay_receipt.connected_relays, + vec!["ws://127.0.0.1:8080".to_owned()] + ); + } + + #[test] + fn relay_output_without_acknowledgement_is_rejected() { + let identity = RadrootsIdentity::generate(); + let signed_event = sign_parts_with_identity( + &identity, + WireEventParts { + kind: 30402, + content: "listing".to_owned(), + tags: vec![], + }, + ) + .expect("signed event"); + let output = RelayOutput { + val: RadrootsNostrEventId::parse(signed_event.id.to_string().as_str()) + .expect("event id"), + success: HashSet::new(), + failed: HashMap::from([( + nostr::RelayUrl::parse("ws://127.0.0.1:8082").expect("relay c"), + "blocked".to_owned(), + )]), + }; + + let error = sdk_publish_receipt_from_relay_output(signed_event, vec![], vec![], output) + .expect_err("error"); + + assert_eq!( + error, + SdkPublishError::RelayNotAcknowledged { + transport: SdkTransportMode::RelayDirect, + failed_relays: vec![SdkRelayFailure { + relay_url: "ws://127.0.0.1:8082".to_owned(), + error: "blocked".to_owned(), + }], + } + ); + } +} diff --git a/crates/sdk/src/config.rs b/crates/sdk/src/config.rs @@ -0,0 +1,388 @@ +#[cfg(not(feature = "std"))] +use alloc::{string::String, vec::Vec}; +use core::fmt; +#[cfg(feature = "std")] +use std::{env, string::String, vec::Vec}; + +pub const RADROOTS_SDK_PRODUCTION_RELAY_URL: &str = "wss://radroots.org"; +pub const RADROOTS_SDK_STAGING_RELAY_URL: &str = "wss://staging.radroots.org"; +pub const RADROOTS_SDK_LOCAL_RELAY_URL: &str = "ws://127.0.0.1:8080"; + +pub const RADROOTS_SDK_PRODUCTION_RADROOTSD_ENDPOINT: &str = "https://rpc.radroots.org/jsonrpc"; +pub const RADROOTS_SDK_STAGING_RADROOTSD_ENDPOINT: &str = + "https://rpc.staging.radroots.org/jsonrpc"; +pub const RADROOTS_SDK_LOCAL_RADROOTSD_ENDPOINT: &str = "http://127.0.0.1:7070"; + +pub const RADROOTS_SDK_DEFAULT_TIMEOUT_MS: u64 = 10_000; + +#[cfg(feature = "std")] +const LOCAL_RELAY_SCHEME_ENV: &str = "NOSTR_RS_RELAY_PUBLIC_SCHEME"; +#[cfg(feature = "std")] +const LOCAL_RELAY_HOST_ENV: &str = "NOSTR_RS_RELAY_PUBLIC_HOST"; +#[cfg(feature = "std")] +const LOCAL_RELAY_PORT_ENV: &str = "NOSTR_RS_RELAY_PUBLIC_PORT"; +#[cfg(feature = "std")] +const LOCAL_RADROOTSD_ENDPOINT_ENV: &str = "RADROOTSD_RPC_URL"; +#[cfg(feature = "std")] +const LOCAL_RADROOTSD_HOST_ENV: &str = "RADROOTSD_RPC_HOST"; +#[cfg(feature = "std")] +const LOCAL_RADROOTSD_PORT_ENV: &str = "RADROOTSD_RPC_PORT"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RadrootsSdkConfig { + pub environment: SdkEnvironment, + pub transport: SdkTransportMode, + pub relay: RelayConfig, + pub radrootsd: RadrootsdConfig, + pub signer: SignerConfig, + pub network: NetworkConfig, +} + +impl RadrootsSdkConfig { + pub fn production() -> Self { + Self::for_environment(SdkEnvironment::Production) + } + + pub fn staging() -> Self { + Self::for_environment(SdkEnvironment::Staging) + } + + pub fn local() -> Self { + Self::for_environment(SdkEnvironment::Local) + } + + pub fn custom() -> Self { + Self::for_environment(SdkEnvironment::Custom) + } + + pub fn for_environment(environment: SdkEnvironment) -> Self { + Self { + environment, + transport: SdkTransportMode::RelayDirect, + relay: RelayConfig::default(), + radrootsd: RadrootsdConfig::default(), + signer: SignerConfig::default(), + network: NetworkConfig::default(), + } + } + + pub fn resolved_relay_urls(&self) -> Result<Vec<String>, SdkConfigError> { + self.relay.resolved_urls(self.environment) + } + + pub fn resolved_radrootsd_endpoint(&self) -> Result<String, SdkConfigError> { + self.radrootsd.resolved_endpoint(self.environment) + } +} + +impl Default for RadrootsSdkConfig { + fn default() -> Self { + Self::production() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SdkEnvironment { + Production, + Staging, + Local, + Custom, +} + +impl SdkEnvironment { + pub fn default_relay_urls(self) -> Option<Vec<String>> { + match self { + Self::Production => Some(vec![RADROOTS_SDK_PRODUCTION_RELAY_URL.to_owned()]), + Self::Staging => Some(vec![RADROOTS_SDK_STAGING_RELAY_URL.to_owned()]), + Self::Local => Some(vec![RADROOTS_SDK_LOCAL_RELAY_URL.to_owned()]), + Self::Custom => None, + } + } + + pub fn default_radrootsd_endpoint(self) -> Option<&'static str> { + match self { + Self::Production => Some(RADROOTS_SDK_PRODUCTION_RADROOTSD_ENDPOINT), + Self::Staging => Some(RADROOTS_SDK_STAGING_RADROOTSD_ENDPOINT), + Self::Local => Some(RADROOTS_SDK_LOCAL_RADROOTSD_ENDPOINT), + Self::Custom => None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SdkTransportMode { + RelayDirect, + Radrootsd, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct RelayConfig { + pub urls: Vec<String>, +} + +impl RelayConfig { + pub fn resolved_urls( + &self, + environment: SdkEnvironment, + ) -> Result<Vec<String>, SdkConfigError> { + if self.urls.is_empty() { + if environment == SdkEnvironment::Local { + #[cfg(feature = "std")] + if let Some(local_url) = resolve_local_relay_url_from_env() { + return Ok(vec![normalize_relay_url(local_url.as_str())?]); + } + } + return environment + .default_relay_urls() + .ok_or(SdkConfigError::MissingCustomRelayUrls); + } + + normalize_relay_urls(&self.urls) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RadrootsdConfig { + pub endpoint: Option<String>, + pub auth: RadrootsdAuth, +} + +impl RadrootsdConfig { + pub fn resolved_endpoint(&self, environment: SdkEnvironment) -> Result<String, SdkConfigError> { + match self.endpoint.as_deref() { + Some(endpoint) => normalize_radrootsd_endpoint(endpoint), + None => { + if environment == SdkEnvironment::Local { + #[cfg(feature = "std")] + if let Some(endpoint) = resolve_local_radrootsd_endpoint_from_env() { + return normalize_radrootsd_endpoint(endpoint.as_str()); + } + } + + environment + .default_radrootsd_endpoint() + .map(str::to_owned) + .ok_or(SdkConfigError::MissingCustomRadrootsdEndpoint) + } + } + } +} + +impl Default for RadrootsdConfig { + fn default() -> Self { + Self { + endpoint: None, + auth: RadrootsdAuth::default(), + } + } +} + +#[derive(Clone, PartialEq, Eq, Default)] +pub enum RadrootsdAuth { + #[default] + None, + BearerToken(String), +} + +impl fmt::Debug for RadrootsdAuth { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::None => f.write_str("None"), + Self::BearerToken(_) => f.write_str("BearerToken(\"<redacted>\")"), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum SignerConfig { + #[default] + DraftOnly, + LocalIdentity, + Nip46, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NetworkConfig { + pub timeout_ms: u64, +} + +impl Default for NetworkConfig { + fn default() -> Self { + Self { + timeout_ms: RADROOTS_SDK_DEFAULT_TIMEOUT_MS, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SdkConfigError { + MissingCustomRelayUrls, + MissingCustomRadrootsdEndpoint, + EmptyRelayUrl, + InvalidRelayUrl(String), + EmptyRadrootsdEndpoint, + InvalidRadrootsdEndpoint(String), +} + +impl fmt::Display for SdkConfigError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingCustomRelayUrls => { + f.write_str("custom sdk environment requires explicit relay urls") + } + Self::MissingCustomRadrootsdEndpoint => { + f.write_str("custom sdk environment requires an explicit radrootsd endpoint") + } + Self::EmptyRelayUrl => f.write_str("relay url must not be empty"), + Self::InvalidRelayUrl(value) => { + write!(f, "relay url must use ws or wss, got `{value}`") + } + Self::EmptyRadrootsdEndpoint => f.write_str("radrootsd endpoint must not be empty"), + Self::InvalidRadrootsdEndpoint(value) => { + write!( + f, + "radrootsd endpoint must use http or https, got `{value}`" + ) + } + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for SdkConfigError {} + +impl fmt::Display for SignerConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::DraftOnly => f.write_str("draft_only"), + Self::LocalIdentity => f.write_str("local_identity"), + Self::Nip46 => f.write_str("nip46"), + } + } +} + +fn normalize_relay_urls(values: &[String]) -> Result<Vec<String>, SdkConfigError> { + let mut normalized = Vec::new(); + for value in values { + let relay = normalize_relay_url(value.as_str())?; + if !normalized.iter().any(|existing| existing == &relay) { + normalized.push(relay); + } + } + Ok(normalized) +} + +fn normalize_relay_url(value: &str) -> Result<String, SdkConfigError> { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(SdkConfigError::EmptyRelayUrl); + } + + let rest = if let Some(rest) = trimmed.strip_prefix("ws://") { + rest + } else if let Some(rest) = trimmed.strip_prefix("wss://") { + rest + } else { + return Err(SdkConfigError::InvalidRelayUrl(trimmed.to_owned())); + }; + + if relay_authority_is_invalid(rest) { + return Err(SdkConfigError::InvalidRelayUrl(trimmed.to_owned())); + } + + Ok(trimmed.to_owned()) +} + +fn relay_authority_is_invalid(rest: &str) -> bool { + let authority_end = rest + .char_indices() + .find(|(_, ch)| matches!(ch, '/' | '?' | '#')) + .map(|(index, _)| index) + .unwrap_or(rest.len()); + let authority = &rest[..authority_end]; + + if authority.is_empty() || authority.chars().any(char::is_whitespace) { + return true; + } + if authority.contains('@') { + return true; + } + + if let Some(after_open) = authority.strip_prefix('[') { + let Some(close_index) = after_open.find(']') else { + return true; + }; + let host = &after_open[..close_index]; + let after_host = &after_open[close_index + 1..]; + if host.is_empty() { + return true; + } + return relay_port_suffix_is_invalid(after_host); + } + + let colon_count = authority.bytes().filter(|byte| *byte == b':').count(); + match colon_count { + 0 => false, + 1 => { + let (host, port) = authority + .split_once(':') + .expect("one colon in relay authority"); + host.is_empty() || relay_port_is_invalid(port) + } + _ => true, + } +} + +fn relay_port_suffix_is_invalid(after_host: &str) -> bool { + if after_host.is_empty() { + return false; + } + let Some(port) = after_host.strip_prefix(':') else { + return true; + }; + relay_port_is_invalid(port) +} + +fn relay_port_is_invalid(port: &str) -> bool { + port.is_empty() || !port.bytes().all(|byte| byte.is_ascii_digit()) +} + +fn normalize_radrootsd_endpoint(value: &str) -> Result<String, SdkConfigError> { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(SdkConfigError::EmptyRadrootsdEndpoint); + } + if !(trimmed.starts_with("http://") || trimmed.starts_with("https://")) { + return Err(SdkConfigError::InvalidRadrootsdEndpoint(trimmed.to_owned())); + } + Ok(trimmed.to_owned()) +} + +#[cfg(feature = "std")] +fn resolve_local_relay_url_from_env() -> Option<String> { + let scheme = read_trimmed_env(LOCAL_RELAY_SCHEME_ENV)?; + let host = read_trimmed_env(LOCAL_RELAY_HOST_ENV)?; + let port = read_trimmed_env(LOCAL_RELAY_PORT_ENV)?; + Some(format!("{scheme}://{host}:{port}")) +} + +#[cfg(feature = "std")] +fn resolve_local_radrootsd_endpoint_from_env() -> Option<String> { + if let Some(endpoint) = read_trimmed_env(LOCAL_RADROOTSD_ENDPOINT_ENV) { + return Some(endpoint); + } + + let host = read_trimmed_env(LOCAL_RADROOTSD_HOST_ENV)?; + let port = read_trimmed_env(LOCAL_RADROOTSD_PORT_ENV)?; + Some(format!("http://{host}:{port}")) +} + +#[cfg(feature = "std")] +fn read_trimmed_env(key: &str) -> Option<String> { + let value = env::var(key).ok()?; + let trimmed = value.trim(); + if trimmed.is_empty() { + return None; + } + Some(trimmed.to_owned()) +} diff --git a/crates/sdk/src/farm.rs b/crates/sdk/src/farm.rs @@ -0,0 +1,9 @@ +pub use radroots_events::farm::*; +pub use radroots_events_codec::error::EventEncodeError; + +use crate::WireEventParts; + +#[cfg(feature = "serde_json")] +pub fn build_draft(farm: &RadrootsFarm) -> Result<WireEventParts, EventEncodeError> { + radroots_events_codec::farm::encode::to_wire_parts(farm) +} diff --git a/crates/sdk/src/identity.rs b/crates/sdk/src/identity.rs @@ -0,0 +1,33 @@ +pub use radroots_identity::{ + DEFAULT_IDENTITY_PATH, IdentityError, RADROOTS_USERNAME_MAX_LEN, RADROOTS_USERNAME_MIN_LEN, + RADROOTS_USERNAME_REGEX, RadrootsIdentity, RadrootsIdentityFile, RadrootsIdentityId, + RadrootsIdentityProfile, RadrootsIdentityPublic, RadrootsIdentitySecretKeyFormat, + radroots_username_is_valid, radroots_username_normalize, +}; + +#[cfg(feature = "identity-storage")] +pub use radroots_identity::{ + RADROOTS_ENCRYPTED_IDENTITY_DEFAULT_KEY_SLOT, RADROOTS_ENCRYPTED_IDENTITY_KEY_SUFFIX, + RadrootsEncryptedIdentityFile, encrypted_identity_wrapping_key_path, load_encrypted_identity, + load_encrypted_identity_with_key_slot, load_identity_profile, rotate_encrypted_identity, + rotate_encrypted_identity_with_key_slot, store_encrypted_identity, + store_encrypted_identity_with_key_slot, store_identity_profile, +}; + +#[cfg(all(feature = "identity-models", feature = "identity-storage"))] +#[cfg(test)] +mod tests { + use super::{RadrootsEncryptedIdentityFile, RadrootsIdentity}; + + #[test] + fn encrypted_identity_file_round_trips() { + let temp = tempfile::tempdir().expect("tempdir"); + let file = RadrootsEncryptedIdentityFile::new(temp.path().join("identity.enc.json")); + let identity = RadrootsIdentity::generate(); + + file.store(&identity).expect("store identity"); + let loaded = file.load().expect("load identity"); + + assert_eq!(loaded.public_key_hex(), identity.public_key_hex()); + } +} diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs @@ -0,0 +1,72 @@ +#![cfg_attr(not(feature = "std"), no_std)] +#![forbid(unsafe_code)] + +#[cfg(not(feature = "std"))] +extern crate alloc; + +#[cfg(not(feature = "std"))] +use alloc::{string::String, vec::Vec}; +#[cfg(feature = "std")] +use std::{string::String, vec::Vec}; + +#[cfg(any( + feature = "radrootsd-client", + feature = "signing", + feature = "relay-client", + feature = "signer-adapters" +))] +pub mod adapters; +pub mod client; +pub mod config; +pub mod farm; +#[cfg(feature = "identity-models")] +pub mod identity; +pub mod listing; +pub mod order; +pub mod profile; + +#[cfg(feature = "radrootsd-client")] +pub use crate::adapters::radrootsd::{ + SdkRadrootsdBridgeDeliveryPolicy, SdkRadrootsdBridgeJobStatus, + SdkRadrootsdBridgeRelayPublishResult, SdkRadrootsdSignerAuthority, + SdkRadrootsdSignerSessionConnectRequest, SdkRadrootsdSignerSessionMode, + SdkRadrootsdSignerSessionRole, +}; +pub use crate::client::{ + FarmClient, ListingClient, ProfileClient, RadrootsSdkClient, SdkPublishError, + SdkPublishReceipt, SdkRadrootsdPublishReceipt, SdkRelayFailure, SdkRelayPublishReceipt, + SdkResolvedTransportTarget, SdkTransportReceipt, TradeClient, +}; +#[cfg(feature = "radrootsd-client")] +pub use crate::client::{ + RadrootsdBridgeClient, RadrootsdClient, RadrootsdSignerSessionClient, SdkRadrootsdBridgeError, + SdkRadrootsdBridgeJobRef, SdkRadrootsdBridgeJobView, SdkRadrootsdBridgeStatus, + SdkRadrootsdFarmPublishOptions, SdkRadrootsdListingPublishOptions, + SdkRadrootsdOrderRequestPublishOptions, SdkRadrootsdProfilePublishOptions, + SdkRadrootsdSessionError, SdkRadrootsdSignerSessionAuthorizeResult, + SdkRadrootsdSignerSessionCloseResult, SdkRadrootsdSignerSessionHandle, + SdkRadrootsdSignerSessionPublicKeyResult, SdkRadrootsdSignerSessionRef, + SdkRadrootsdSignerSessionRequireAuthResult, SdkRadrootsdSignerSessionView, +}; +pub use crate::config::{ + NetworkConfig, RADROOTS_SDK_LOCAL_RADROOTSD_ENDPOINT, RADROOTS_SDK_LOCAL_RELAY_URL, + RADROOTS_SDK_PRODUCTION_RADROOTSD_ENDPOINT, RADROOTS_SDK_PRODUCTION_RELAY_URL, + RADROOTS_SDK_STAGING_RADROOTSD_ENDPOINT, RADROOTS_SDK_STAGING_RELAY_URL, RadrootsSdkConfig, + RadrootsdAuth, RadrootsdConfig, RelayConfig, SdkConfigError, SdkEnvironment, SdkTransportMode, + SignerConfig, +}; +pub use radroots_events::{ + RadrootsNostrEvent, RadrootsNostrEventPtr, RadrootsNostrEventRef, + draft::{RadrootsFrozenEventDraft, RadrootsSignedNostrEvent}, + farm::RadrootsFarm, + listing::RadrootsListing, + profile::{RadrootsProfile, RadrootsProfileType}, +}; +#[cfg(feature = "serde_json")] +pub use radroots_events_codec::order::{ + RadrootsOrderEnvelopeParseError, RadrootsOrderListingAddress, RadrootsOrderListingAddressError, +}; +pub use radroots_events_codec::wire::WireEventParts; +pub use radroots_trade::listing::validation::RadrootsTradeListing as TradeListingValidateResult; + +pub type NostrTags = Vec<Vec<String>>; diff --git a/crates/sdk/src/listing.rs b/crates/sdk/src/listing.rs @@ -0,0 +1,40 @@ +pub use radroots_events::listing::*; +pub use radroots_events::order::RadrootsListingParseError; +pub use radroots_events::trade_validation::RadrootsTradeValidationListingError; +pub use radroots_events_codec::error::EventEncodeError; +pub use radroots_trade::listing::validation::RadrootsTradeListing as TradeListingValidateResult; + +use crate::{NostrTags, RadrootsNostrEvent, WireEventParts}; + +#[derive(Debug, Clone)] +pub struct RadrootsListingDraft { + parts: WireEventParts, +} + +impl RadrootsListingDraft { + pub fn as_wire_parts(&self) -> &WireEventParts { + &self.parts + } + + pub fn into_wire_parts(self) -> WireEventParts { + self.parts + } +} + +pub fn build_tags(listing: &RadrootsListing) -> Result<NostrTags, EventEncodeError> { + radroots_events_codec::listing::encode::listing_build_tags(listing) +} + +#[cfg(feature = "serde_json")] +pub fn build_draft(listing: &RadrootsListing) -> Result<RadrootsListingDraft, EventEncodeError> { + Ok(RadrootsListingDraft { + parts: radroots_events_codec::listing::encode::to_wire_parts(listing)?, + }) +} + +#[cfg(feature = "serde_json")] +pub fn parse_event( + event: &RadrootsNostrEvent, +) -> Result<RadrootsListing, RadrootsListingParseError> { + radroots_trade::listing::parse_listing_event(event) +} diff --git a/crates/sdk/src/order.rs b/crates/sdk/src/order.rs @@ -0,0 +1,281 @@ +pub use radroots_events::order::*; +pub use radroots_events::trade_validation::*; +pub use radroots_events_codec::error::EventEncodeError; +#[cfg(feature = "serde_json")] +pub use radroots_events_codec::order::{ + RadrootsOrderEnvelopeParseError, RadrootsOrderEventContext, RadrootsOrderListingAddress, + RadrootsOrderListingAddressError, +}; +pub use radroots_trade::listing::validation::RadrootsTradeListing as TradeListingValidateResult; + +use crate::{RadrootsNostrEvent, RadrootsNostrEventPtr, WireEventParts}; +use radroots_events::ids::RadrootsEventId; + +#[derive(Debug, Clone)] +pub struct RadrootsOrderRequestDraft { + parts: WireEventParts, +} + +#[derive(Debug, Clone)] +pub struct RadrootsOrderDecisionDraft { + parts: WireEventParts, +} + +#[derive(Debug, Clone)] +pub struct RadrootsOrderRevisionProposalDraft { + parts: WireEventParts, +} + +#[derive(Debug, Clone)] +pub struct RadrootsOrderRevisionDecisionDraft { + parts: WireEventParts, +} + +#[derive(Debug, Clone)] +pub struct RadrootsOrderFulfillmentUpdateDraft { + parts: WireEventParts, +} + +#[derive(Debug, Clone)] +pub struct RadrootsOrderCancellationDraft { + parts: WireEventParts, +} + +#[derive(Debug, Clone)] +pub struct RadrootsOrderReceiptDraft { + parts: WireEventParts, +} + +impl RadrootsOrderRequestDraft { + pub fn as_wire_parts(&self) -> &WireEventParts { + &self.parts + } + + pub fn into_wire_parts(self) -> WireEventParts { + self.parts + } +} + +impl RadrootsOrderDecisionDraft { + pub fn as_wire_parts(&self) -> &WireEventParts { + &self.parts + } + + pub fn into_wire_parts(self) -> WireEventParts { + self.parts + } +} + +impl RadrootsOrderRevisionProposalDraft { + pub fn as_wire_parts(&self) -> &WireEventParts { + &self.parts + } + + pub fn into_wire_parts(self) -> WireEventParts { + self.parts + } +} + +impl RadrootsOrderRevisionDecisionDraft { + pub fn as_wire_parts(&self) -> &WireEventParts { + &self.parts + } + + pub fn into_wire_parts(self) -> WireEventParts { + self.parts + } +} + +impl RadrootsOrderFulfillmentUpdateDraft { + pub fn as_wire_parts(&self) -> &WireEventParts { + &self.parts + } + + pub fn into_wire_parts(self) -> WireEventParts { + self.parts + } +} + +impl RadrootsOrderCancellationDraft { + pub fn as_wire_parts(&self) -> &WireEventParts { + &self.parts + } + + pub fn into_wire_parts(self) -> WireEventParts { + self.parts + } +} + +impl RadrootsOrderReceiptDraft { + pub fn as_wire_parts(&self) -> &WireEventParts { + &self.parts + } + + pub fn into_wire_parts(self) -> WireEventParts { + self.parts + } +} + +#[cfg(feature = "serde_json")] +pub fn build_order_request_draft( + listing_event: &RadrootsNostrEventPtr, + payload: &RadrootsOrderRequest, +) -> Result<RadrootsOrderRequestDraft, EventEncodeError> { + Ok(RadrootsOrderRequestDraft { + parts: radroots_events_codec::order::order_request_event_build(listing_event, payload)?, + }) +} + +#[cfg(feature = "serde_json")] +pub fn build_order_decision_draft( + root_event_id: &RadrootsEventId, + prev_event_id: &RadrootsEventId, + payload: &RadrootsOrderDecision, +) -> Result<RadrootsOrderDecisionDraft, EventEncodeError> { + Ok(RadrootsOrderDecisionDraft { + parts: radroots_events_codec::order::order_decision_event_build( + root_event_id, + prev_event_id, + payload, + )?, + }) +} + +#[cfg(feature = "serde_json")] +pub fn build_order_revision_proposal_draft( + root_event_id: &RadrootsEventId, + prev_event_id: &RadrootsEventId, + payload: &RadrootsOrderRevisionProposal, +) -> Result<RadrootsOrderRevisionProposalDraft, EventEncodeError> { + Ok(RadrootsOrderRevisionProposalDraft { + parts: radroots_events_codec::order::order_revision_proposal_event_build( + root_event_id, + prev_event_id, + payload, + )?, + }) +} + +#[cfg(feature = "serde_json")] +pub fn build_order_revision_decision_draft( + root_event_id: &RadrootsEventId, + prev_event_id: &RadrootsEventId, + payload: &RadrootsOrderRevisionDecision, +) -> Result<RadrootsOrderRevisionDecisionDraft, EventEncodeError> { + Ok(RadrootsOrderRevisionDecisionDraft { + parts: radroots_events_codec::order::order_revision_decision_event_build( + root_event_id, + prev_event_id, + payload, + )?, + }) +} + +#[cfg(feature = "serde_json")] +pub fn build_fulfillment_update_draft( + root_event_id: &RadrootsEventId, + prev_event_id: &RadrootsEventId, + payload: &RadrootsOrderFulfillmentUpdate, +) -> Result<RadrootsOrderFulfillmentUpdateDraft, EventEncodeError> { + Ok(RadrootsOrderFulfillmentUpdateDraft { + parts: radroots_events_codec::order::order_fulfillment_update_event_build( + root_event_id, + prev_event_id, + payload, + )?, + }) +} + +#[cfg(feature = "serde_json")] +pub fn build_order_cancellation_draft( + root_event_id: &RadrootsEventId, + prev_event_id: &RadrootsEventId, + payload: &RadrootsOrderCancellation, +) -> Result<RadrootsOrderCancellationDraft, EventEncodeError> { + Ok(RadrootsOrderCancellationDraft { + parts: radroots_events_codec::order::order_cancellation_event_build( + root_event_id, + prev_event_id, + payload, + )?, + }) +} + +#[cfg(feature = "serde_json")] +pub fn build_buyer_receipt_draft( + root_event_id: &RadrootsEventId, + prev_event_id: &RadrootsEventId, + payload: &RadrootsOrderReceipt, +) -> Result<RadrootsOrderReceiptDraft, EventEncodeError> { + Ok(RadrootsOrderReceiptDraft { + parts: radroots_events_codec::order::order_receipt_event_build( + root_event_id, + prev_event_id, + payload, + )?, + }) +} + +#[cfg(feature = "serde_json")] +pub fn parse_order_request( + event: &RadrootsNostrEvent, +) -> Result<RadrootsOrderEnvelope<RadrootsOrderRequest>, RadrootsOrderEnvelopeParseError> { + radroots_events_codec::order::order_request_from_event(event) +} + +#[cfg(feature = "serde_json")] +pub fn parse_order_decision( + event: &RadrootsNostrEvent, +) -> Result<RadrootsOrderEnvelope<RadrootsOrderDecision>, RadrootsOrderEnvelopeParseError> { + radroots_events_codec::order::order_decision_from_event(event) +} + +#[cfg(feature = "serde_json")] +pub fn parse_order_revision_proposal( + event: &RadrootsNostrEvent, +) -> Result<RadrootsOrderEnvelope<RadrootsOrderRevisionProposal>, RadrootsOrderEnvelopeParseError> { + radroots_events_codec::order::order_revision_proposal_from_event(event) +} + +#[cfg(feature = "serde_json")] +pub fn parse_order_revision_decision( + event: &RadrootsNostrEvent, +) -> Result<RadrootsOrderEnvelope<RadrootsOrderRevisionDecision>, RadrootsOrderEnvelopeParseError> { + radroots_events_codec::order::order_revision_decision_from_event(event) +} + +#[cfg(feature = "serde_json")] +pub fn parse_fulfillment_update( + event: &RadrootsNostrEvent, +) -> Result<RadrootsOrderEnvelope<RadrootsOrderFulfillmentUpdate>, RadrootsOrderEnvelopeParseError> +{ + radroots_events_codec::order::order_fulfillment_update_from_event(event) +} + +#[cfg(feature = "serde_json")] +pub fn parse_order_cancellation( + event: &RadrootsNostrEvent, +) -> Result<RadrootsOrderEnvelope<RadrootsOrderCancellation>, RadrootsOrderEnvelopeParseError> { + radroots_events_codec::order::order_cancellation_from_event(event) +} + +#[cfg(feature = "serde_json")] +pub fn parse_buyer_receipt( + event: &RadrootsNostrEvent, +) -> Result<RadrootsOrderEnvelope<RadrootsOrderReceipt>, RadrootsOrderEnvelopeParseError> { + radroots_events_codec::order::order_receipt_from_event(event) +} + +#[cfg(feature = "serde_json")] +pub fn parse_listing_address( + listing_addr: &str, +) -> Result<RadrootsOrderListingAddress, RadrootsOrderListingAddressError> { + RadrootsOrderListingAddress::parse(listing_addr) +} + +#[cfg(feature = "serde_json")] +pub fn validate_listing_event( + event: &RadrootsNostrEvent, +) -> Result<TradeListingValidateResult, RadrootsTradeValidationListingError> { + radroots_trade::listing::validation::validate_listing_event(event) +} diff --git a/crates/sdk/src/profile.rs b/crates/sdk/src/profile.rs @@ -0,0 +1,12 @@ +pub use radroots_events::profile::{RadrootsProfile, RadrootsProfileType}; +pub use radroots_events_codec::profile::error::ProfileEncodeError; + +use crate::WireEventParts; + +#[cfg(feature = "serde_json")] +pub fn build_draft( + profile: &RadrootsProfile, + profile_type: Option<RadrootsProfileType>, +) -> Result<WireEventParts, ProfileEncodeError> { + radroots_events_codec::profile::encode::to_wire_parts_with_profile_type(profile, profile_type) +} diff --git a/crates/sdk/tests/client.rs b/crates/sdk/tests/client.rs @@ -0,0 +1,906 @@ +use radroots_core::{ + RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, + RadrootsCoreQuantityPrice, RadrootsCoreUnit, +}; +use radroots_events::farm::{RadrootsFarm, RadrootsFarmRef}; +use radroots_events::ids::{RadrootsEventId, RadrootsPublicKey}; +use radroots_events::kinds::{ + KIND_FARM, KIND_LISTING, KIND_ORDER_CANCELLATION, KIND_ORDER_DECISION, + KIND_ORDER_FULFILLMENT_UPDATE, KIND_ORDER_RECEIPT, KIND_ORDER_REQUEST, + KIND_ORDER_REVISION_DECISION, KIND_ORDER_REVISION_PROPOSAL, KIND_PROFILE, +}; +use radroots_events::listing::{ + RadrootsListing, RadrootsListingAvailability, RadrootsListingBin, + RadrootsListingDeliveryMethod, RadrootsListingLocation, RadrootsListingProduct, + RadrootsListingStatus, +}; +use radroots_events::order::{ + RadrootsOrderCancellation, RadrootsOrderDecision, RadrootsOrderDecisionOutcome, + RadrootsOrderEconomicItem, RadrootsOrderEconomics, RadrootsOrderFulfillmentState, + RadrootsOrderFulfillmentUpdate, RadrootsOrderInventoryCommitment, RadrootsOrderItem, + RadrootsOrderPricingBasis, RadrootsOrderReceipt, RadrootsOrderRequest, + RadrootsOrderRevisionDecision, RadrootsOrderRevisionOutcome, RadrootsOrderRevisionProposal, +}; +use radroots_events::profile::{RadrootsProfile, RadrootsProfileType}; +use radroots_sdk::{ + RADROOTS_SDK_PRODUCTION_RELAY_URL, RadrootsNostrEvent, RadrootsNostrEventPtr, + RadrootsSdkClient, RadrootsSdkConfig, RelayConfig, SdkConfigError, SdkEnvironment, + SdkPublishError, SdkRadrootsdPublishReceipt, SdkRelayFailure, SdkResolvedTransportTarget, + SdkTransportMode, SignerConfig, WireEventParts, +}; + +fn sample_farm() -> RadrootsFarm { + RadrootsFarm { + d_tag: "AAAAAAAAAAAAAAAAAAAAAA".into(), + name: "North Farm".into(), + about: Some("Organic coffee".into()), + website: None, + picture: None, + banner: None, + location: None, + tags: Some(vec!["coffee".into()]), + } +} + +fn sample_listing() -> RadrootsListing { + RadrootsListing { + d_tag: "AAAAAAAAAAAAAAAAAAAAAg".parse().expect("listing d tag"), + published_at: None, + farm: RadrootsFarmRef { + pubkey: "seller".into(), + d_tag: "AAAAAAAAAAAAAAAAAAAAAA".into(), + }, + product: RadrootsListingProduct { + key: "coffee".into(), + title: "Coffee".into(), + category: "coffee".into(), + summary: Some("Single origin coffee".into()), + process: None, + lot: None, + location: None, + profile: None, + year: None, + }, + primary_bin_id: "bin-1".parse().expect("primary bin id"), + bins: vec![RadrootsListingBin { + bin_id: "bin-1".parse().expect("bin id"), + quantity: RadrootsCoreQuantity::new( + RadrootsCoreDecimal::from(1000u32), + RadrootsCoreUnit::MassG, + ), + price_per_canonical_unit: RadrootsCoreQuantityPrice { + amount: RadrootsCoreMoney::new( + RadrootsCoreDecimal::from(20u32), + RadrootsCoreCurrency::USD, + ), + quantity: RadrootsCoreQuantity::new( + RadrootsCoreDecimal::from(1u32), + RadrootsCoreUnit::MassG, + ), + }, + display_amount: None, + display_unit: None, + display_label: None, + display_price: None, + display_price_unit: None, + }], + resource_area: None, + plot: None, + discounts: None, + inventory_available: Some(RadrootsCoreDecimal::from(5u32)), + availability: Some(RadrootsListingAvailability::Status { + status: RadrootsListingStatus::Active, + }), + delivery_method: Some(RadrootsListingDeliveryMethod::Pickup), + location: Some(RadrootsListingLocation { + primary: "North Farm".into(), + city: None, + region: None, + country: None, + lat: None, + lng: None, + geohash: None, + }), + images: None, + } +} + +fn sample_profile() -> RadrootsProfile { + RadrootsProfile { + name: "north-farm".into(), + display_name: Some("North Farm".into()), + nip05: None, + about: Some("Farm profile".into()), + website: None, + picture: None, + banner: None, + lud06: None, + lud16: None, + bot: None, + } +} + +fn decimal(raw: &str) -> RadrootsCoreDecimal { + raw.parse().expect("decimal") +} + +fn usd(raw: &str) -> RadrootsCoreMoney { + RadrootsCoreMoney::new(decimal(raw), RadrootsCoreCurrency::USD) +} + +fn listing_event_ptr() -> RadrootsNostrEventPtr { + RadrootsNostrEventPtr { + id: event_id_wire('a'), + relays: Some("wss://listing.relay.example".into()), + } +} + +fn public_key(value: String) -> RadrootsPublicKey { + value.parse().expect("public key") +} + +fn event_id(character: char) -> RadrootsEventId { + core::iter::repeat_n(character, 64) + .collect::<String>() + .parse() + .expect("event id") +} + +fn event_id_wire(character: char) -> String { + event_id(character).into_string() +} + +fn sample_order_request(buyer_pubkey: String, seller_pubkey: String) -> RadrootsOrderRequest { + let buyer_pubkey = public_key(buyer_pubkey); + let seller_pubkey = public_key(seller_pubkey); + RadrootsOrderRequest { + order_id: "order-1".parse().expect("order id"), + listing_addr: format!("{KIND_LISTING}:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAg") + .parse() + .expect("listing address"), + buyer_pubkey, + seller_pubkey, + items: vec![RadrootsOrderItem { + bin_id: "bin-1".parse().expect("bin id"), + bin_count: 2, + }], + economics: RadrootsOrderEconomics { + quote_id: "quote-1".parse().expect("quote id"), + quote_version: 1, + pricing_basis: RadrootsOrderPricingBasis::ListingEvent, + currency: RadrootsCoreCurrency::USD, + items: vec![RadrootsOrderEconomicItem { + bin_id: "bin-1".parse().expect("bin id"), + bin_count: 2, + quantity_amount: decimal("1"), + quantity_unit: RadrootsCoreUnit::Each, + unit_price_amount: decimal("5"), + unit_price_currency: RadrootsCoreCurrency::USD, + line_subtotal: usd("10"), + }], + discounts: Vec::new(), + adjustments: Vec::new(), + subtotal: usd("10"), + discount_total: usd("0"), + adjustment_total: usd("0"), + total: usd("10"), + }, + } +} + +fn sample_order_decision(buyer_pubkey: String, seller_pubkey: String) -> RadrootsOrderDecision { + let buyer_pubkey = public_key(buyer_pubkey); + let seller_pubkey = public_key(seller_pubkey); + RadrootsOrderDecision { + order_id: "order-1".parse().expect("order id"), + listing_addr: format!("{KIND_LISTING}:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAg") + .parse() + .expect("listing address"), + buyer_pubkey, + seller_pubkey, + decision: RadrootsOrderDecisionOutcome::Accepted { + inventory_commitments: vec![RadrootsOrderInventoryCommitment { + bin_id: "bin-1".parse().expect("bin id"), + bin_count: 2, + }], + }, + } +} + +fn sample_order_revision_proposal( + buyer_pubkey: String, + seller_pubkey: String, + root_event_id: String, + prev_event_id: String, +) -> RadrootsOrderRevisionProposal { + let buyer_pubkey = public_key(buyer_pubkey); + let seller_pubkey = public_key(seller_pubkey); + RadrootsOrderRevisionProposal { + revision_id: "revision-1".parse().expect("revision id"), + order_id: "order-1".parse().expect("order id"), + listing_addr: format!("{KIND_LISTING}:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAg") + .parse() + .expect("listing address"), + buyer_pubkey, + seller_pubkey, + root_event_id: root_event_id.parse().expect("root event id"), + prev_event_id: prev_event_id.parse().expect("previous event id"), + items: vec![RadrootsOrderItem { + bin_id: "bin-1".parse().expect("bin id"), + bin_count: 3, + }], + economics: RadrootsOrderEconomics { + quote_id: "revision-quote-1".parse().expect("revision quote id"), + quote_version: 2, + pricing_basis: RadrootsOrderPricingBasis::ListingEvent, + currency: RadrootsCoreCurrency::USD, + items: vec![RadrootsOrderEconomicItem { + bin_id: "bin-1".parse().expect("bin id"), + bin_count: 3, + quantity_amount: decimal("1"), + quantity_unit: RadrootsCoreUnit::Each, + unit_price_amount: decimal("5"), + unit_price_currency: RadrootsCoreCurrency::USD, + line_subtotal: usd("15"), + }], + discounts: Vec::new(), + adjustments: Vec::new(), + subtotal: usd("15"), + discount_total: usd("0"), + adjustment_total: usd("0"), + total: usd("15"), + }, + reason: "update count".into(), + } +} + +fn sample_order_revision_decision( + proposal: &RadrootsOrderRevisionProposal, + decision: RadrootsOrderRevisionOutcome, +) -> RadrootsOrderRevisionDecision { + RadrootsOrderRevisionDecision { + revision_id: proposal.revision_id.clone(), + order_id: proposal.order_id.clone(), + listing_addr: proposal.listing_addr.clone(), + buyer_pubkey: proposal.buyer_pubkey.clone(), + seller_pubkey: proposal.seller_pubkey.clone(), + root_event_id: proposal.root_event_id.clone(), + prev_event_id: event_id('3'), + decision, + } +} + +fn sample_fulfillment_update( + buyer_pubkey: String, + seller_pubkey: String, +) -> RadrootsOrderFulfillmentUpdate { + let buyer_pubkey = public_key(buyer_pubkey); + let seller_pubkey = public_key(seller_pubkey); + RadrootsOrderFulfillmentUpdate { + order_id: "order-1".parse().expect("order id"), + listing_addr: format!("{KIND_LISTING}:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAg") + .parse() + .expect("listing address"), + buyer_pubkey, + seller_pubkey, + status: RadrootsOrderFulfillmentState::ReadyForPickup, + } +} + +fn sample_order_cancellation( + buyer_pubkey: String, + seller_pubkey: String, +) -> RadrootsOrderCancellation { + let buyer_pubkey = public_key(buyer_pubkey); + let seller_pubkey = public_key(seller_pubkey); + RadrootsOrderCancellation { + order_id: "order-1".parse().expect("order id"), + listing_addr: format!("{KIND_LISTING}:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAg") + .parse() + .expect("listing address"), + buyer_pubkey, + seller_pubkey, + reason: "schedule changed".into(), + } +} + +fn sample_buyer_receipt(buyer_pubkey: String, seller_pubkey: String) -> RadrootsOrderReceipt { + let buyer_pubkey = public_key(buyer_pubkey); + let seller_pubkey = public_key(seller_pubkey); + RadrootsOrderReceipt { + order_id: "order-1".parse().expect("order id"), + listing_addr: format!("{KIND_LISTING}:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAg") + .parse() + .expect("listing address"), + buyer_pubkey, + seller_pubkey, + received: true, + issue: None, + received_at: 1_785_000_000, + } +} + +fn event_from_parts( + id: &str, + author: &str, + created_at: u32, + parts: WireEventParts, +) -> RadrootsNostrEvent { + RadrootsNostrEvent { + id: id.into(), + author: author.into(), + created_at, + kind: parts.kind, + tags: parts.tags, + content: parts.content, + sig: String::new(), + } +} + +#[test] +fn client_default_config_uses_production_relay_direct() { + let client = RadrootsSdkClient::from_config(RadrootsSdkConfig::default()).expect("sdk client"); + + assert_eq!(client.transport(), SdkTransportMode::RelayDirect); + assert_eq!( + client.resolved_transport_target(), + &SdkResolvedTransportTarget::RelayDirect { + relay_urls: vec![RADROOTS_SDK_PRODUCTION_RELAY_URL.to_string()], + } + ); +} + +#[test] +fn client_rejects_invalid_config_on_construction() { + let mut config = RadrootsSdkConfig::custom(); + config.transport = SdkTransportMode::RelayDirect; + config.relay = RelayConfig { + urls: vec!["https://radroots.org".into()], + }; + + let error = RadrootsSdkClient::from_config(config).expect_err("invalid config"); + assert_eq!( + error, + SdkConfigError::InvalidRelayUrl("https://radroots.org".into()) + ); +} + +#[test] +fn client_rejects_invalid_radrootsd_config_on_construction() { + let mut missing = RadrootsSdkConfig::custom(); + missing.transport = SdkTransportMode::Radrootsd; + + assert_eq!( + RadrootsSdkClient::from_config(missing).expect_err("missing radrootsd endpoint"), + SdkConfigError::MissingCustomRadrootsdEndpoint + ); + + let mut invalid = RadrootsSdkConfig::custom(); + invalid.transport = SdkTransportMode::Radrootsd; + invalid.radrootsd.endpoint = Some("wss://rpc.bad".into()); + + assert_eq!( + RadrootsSdkClient::from_config(invalid).expect_err("invalid radrootsd endpoint"), + SdkConfigError::InvalidRadrootsdEndpoint("wss://rpc.bad".into()) + ); +} + +#[test] +fn client_allows_custom_relay_without_radrootsd_endpoint() { + let mut config = RadrootsSdkConfig::custom(); + config.transport = SdkTransportMode::RelayDirect; + config.relay = RelayConfig { + urls: vec!["wss://radroots.org".into()], + }; + + let client = RadrootsSdkClient::from_config(config).expect("relay-only sdk client"); + assert_eq!( + client.resolved_transport_target(), + &SdkResolvedTransportTarget::RelayDirect { + relay_urls: vec!["wss://radroots.org".to_string()], + } + ); +} + +#[test] +fn client_allows_custom_radrootsd_without_relay_urls() { + let endpoint = "https://custom.radroots.org/jsonrpc"; + let mut config = RadrootsSdkConfig::custom(); + config.transport = SdkTransportMode::Radrootsd; + config.radrootsd.endpoint = Some(endpoint.into()); + + let client = RadrootsSdkClient::from_config(config).expect("radrootsd-only sdk client"); + assert_eq!( + client.resolved_transport_target(), + &SdkResolvedTransportTarget::Radrootsd { + endpoint: endpoint.to_string(), + } + ); +} + +#[test] +fn namespace_clients_reflect_explicit_transport_mode() { + let mut config = RadrootsSdkConfig::for_environment(SdkEnvironment::Production); + config.transport = SdkTransportMode::Radrootsd; + config.signer = SignerConfig::LocalIdentity; + + let client = RadrootsSdkClient::from_config(config).expect("sdk client"); + + assert_eq!(client.transport(), SdkTransportMode::Radrootsd); + assert_eq!(client.profile().transport(), SdkTransportMode::Radrootsd); + assert_eq!(client.farm().transport(), SdkTransportMode::Radrootsd); + assert_eq!(client.listing().transport(), SdkTransportMode::Radrootsd); + assert_eq!(client.order().transport(), SdkTransportMode::Radrootsd); + #[cfg(feature = "radrootsd-client")] + assert_eq!(client.radrootsd().transport(), SdkTransportMode::Radrootsd); + assert_eq!(client.signer(), SignerConfig::LocalIdentity); + assert_eq!(client.profile().signer(), SignerConfig::LocalIdentity); + assert_eq!(client.farm().signer(), SignerConfig::LocalIdentity); + assert_eq!(client.listing().signer(), SignerConfig::LocalIdentity); + assert_eq!(client.order().signer(), SignerConfig::LocalIdentity); + #[cfg(feature = "radrootsd-client")] + assert_eq!(client.radrootsd().signer(), SignerConfig::LocalIdentity); +} + +#[test] +fn namespace_clients_expose_parent_sdk_and_draft_facades() { + let client = + RadrootsSdkClient::from_config(RadrootsSdkConfig::production()).expect("sdk client"); + let profile = client.profile(); + let farm = client.farm(); + let listing = client.listing(); + let order = client.order(); + + assert_eq!(client.config().environment, SdkEnvironment::Production); + assert!(std::ptr::eq(profile.sdk(), &client)); + assert!(std::ptr::eq(farm.sdk(), &client)); + assert!(std::ptr::eq(listing.sdk(), &client)); + assert!(std::ptr::eq(order.sdk(), &client)); + + let profile_draft = profile + .build_draft(&sample_profile(), Some(RadrootsProfileType::Farm)) + .expect("profile draft"); + assert_eq!(profile_draft.kind, KIND_PROFILE); + + let farm_draft = farm.build_draft(&sample_farm()).expect("farm draft"); + assert_eq!(farm_draft.kind, KIND_FARM); + + let listing_draft = listing + .build_draft(&sample_listing()) + .expect("listing draft"); + assert_eq!(listing_draft.as_wire_parts().kind, KIND_LISTING); + assert_eq!(listing_draft.into_wire_parts().kind, KIND_LISTING); + + let mut invalid_listing = sample_listing(); + invalid_listing.farm.pubkey.clear(); + assert!(listing.build_draft(&invalid_listing).is_err()); +} + +#[test] +fn listing_and_order_clients_wrap_existing_sdk_facades() { + let client = RadrootsSdkClient::from_config(RadrootsSdkConfig::local()).expect("sdk client"); + let listing_value = sample_listing(); + let buyer_pubkey = "b".repeat(64); + let seller_pubkey = "a".repeat(64); + + let tags = client + .listing() + .build_tags(&listing_value) + .expect("listing tags"); + assert!(!tags.is_empty()); + + let draft = client + .listing() + .build_draft(&listing_value) + .expect("listing draft"); + assert_eq!(draft.as_wire_parts().kind, KIND_LISTING); + + let event = RadrootsNostrEvent { + id: "listing-1".into(), + author: "seller".into(), + created_at: 1, + kind: draft.as_wire_parts().kind, + tags: draft.as_wire_parts().tags.clone(), + content: draft.as_wire_parts().content.clone(), + sig: String::new(), + }; + let parsed = client + .listing() + .parse_event(&event) + .expect("parsed listing"); + assert_eq!(parsed.d_tag, listing_value.d_tag); + + let validated = client + .order() + .validate_listing_event(&event) + .expect("validated listing"); + assert_eq!(validated.listing_id, listing_value.d_tag); + + let listing_addr = format!("{KIND_LISTING}:{seller_pubkey}:{}", listing_value.d_tag); + let payload = sample_order_request(buyer_pubkey.clone(), seller_pubkey.clone()); + let envelope = client + .order() + .build_order_request_draft(&listing_event_ptr(), &payload) + .expect("order draft"); + assert_eq!(envelope.as_wire_parts().kind, KIND_ORDER_REQUEST); + let envelope_event = RadrootsNostrEvent { + id: "order-event-1".into(), + author: buyer_pubkey, + created_at: 2, + kind: envelope.as_wire_parts().kind, + tags: envelope.as_wire_parts().tags.clone(), + content: envelope.as_wire_parts().content.clone(), + sig: String::new(), + }; + assert_eq!( + client + .order() + .parse_order_request(&envelope_event) + .expect("order envelope") + .payload + .order_id, + payload.order_id + ); + let parsed_addr = client + .order() + .parse_listing_address(&listing_addr) + .expect("listing address"); + assert_eq!(parsed_addr.listing_id, listing_value.d_tag); +} + +#[test] +fn order_facades_round_trip_all_draft_types() { + let client = + RadrootsSdkClient::from_config(RadrootsSdkConfig::production()).expect("sdk client"); + let order_client = client.order(); + let buyer_pubkey = "b".repeat(64); + let seller_pubkey = "a".repeat(64); + let root_event_id = event_id('1'); + let decision_event_id = event_id('2'); + let proposal_event_id = event_id('3'); + let fulfillment_event_id = event_id('4'); + + let order_request = sample_order_request(buyer_pubkey.clone(), seller_pubkey.clone()); + let order_draft = order_client + .build_order_request_draft(&listing_event_ptr(), &order_request) + .expect("order request draft"); + assert_eq!(order_draft.as_wire_parts().kind, KIND_ORDER_REQUEST); + let order_event = event_from_parts( + root_event_id.as_str(), + &buyer_pubkey, + 1, + order_draft.clone().into_wire_parts(), + ); + let order_envelope = order_client + .parse_order_request(&order_event) + .expect("order request envelope"); + assert_eq!(order_envelope.payload.economics.total, usd("10")); + + let decision = sample_order_decision(buyer_pubkey.clone(), seller_pubkey.clone()); + let decision_draft = order_client + .build_order_decision_draft(&root_event_id, &root_event_id, &decision) + .expect("order decision draft"); + assert_eq!(decision_draft.as_wire_parts().kind, KIND_ORDER_DECISION); + let decision_event = event_from_parts( + decision_event_id.as_str(), + &seller_pubkey, + 2, + decision_draft.clone().into_wire_parts(), + ); + assert_eq!( + order_client + .parse_order_decision(&decision_event) + .expect("order decision envelope") + .payload + .decision, + decision.decision + ); + + let proposal = sample_order_revision_proposal( + buyer_pubkey.clone(), + seller_pubkey.clone(), + root_event_id.to_string(), + decision_event_id.to_string(), + ); + let proposal_draft = order_client + .build_order_revision_proposal_draft(&root_event_id, &decision_event_id, &proposal) + .expect("revision proposal draft"); + assert_eq!( + proposal_draft.as_wire_parts().kind, + KIND_ORDER_REVISION_PROPOSAL + ); + let proposal_event = event_from_parts( + proposal_event_id.as_str(), + &seller_pubkey, + 3, + proposal_draft.clone().into_wire_parts(), + ); + assert_eq!( + order_client + .parse_order_revision_proposal(&proposal_event) + .expect("revision proposal envelope") + .payload + .economics + .total, + usd("15") + ); + + let revision_decision = + sample_order_revision_decision(&proposal, RadrootsOrderRevisionOutcome::Accepted); + let revision_decision_draft = order_client + .build_order_revision_decision_draft( + &root_event_id, + &revision_decision.prev_event_id, + &revision_decision, + ) + .expect("revision decision draft"); + assert_eq!( + revision_decision_draft.as_wire_parts().kind, + KIND_ORDER_REVISION_DECISION + ); + let revision_decision_event = event_from_parts( + "order-revision-decision-event-1", + &buyer_pubkey, + 4, + revision_decision_draft.clone().into_wire_parts(), + ); + assert_eq!( + order_client + .parse_order_revision_decision(&revision_decision_event) + .expect("revision decision envelope") + .payload + .revision_id, + revision_decision.revision_id + ); + + let fulfillment = sample_fulfillment_update(buyer_pubkey.clone(), seller_pubkey.clone()); + let fulfillment_draft = order_client + .build_fulfillment_update_draft(&root_event_id, &decision_event_id, &fulfillment) + .expect("fulfillment draft"); + assert_eq!( + fulfillment_draft.as_wire_parts().kind, + KIND_ORDER_FULFILLMENT_UPDATE + ); + let fulfillment_event = event_from_parts( + fulfillment_event_id.as_str(), + &seller_pubkey, + 5, + fulfillment_draft.clone().into_wire_parts(), + ); + assert_eq!( + order_client + .parse_fulfillment_update(&fulfillment_event) + .expect("fulfillment envelope") + .payload + .status, + fulfillment.status + ); + + let cancellation = sample_order_cancellation(buyer_pubkey.clone(), seller_pubkey.clone()); + let cancellation_draft = order_client + .build_order_cancellation_draft(&root_event_id, &decision_event_id, &cancellation) + .expect("cancellation draft"); + assert_eq!( + cancellation_draft.as_wire_parts().kind, + KIND_ORDER_CANCELLATION + ); + let cancellation_event = event_from_parts( + "order-cancellation-event-1", + &buyer_pubkey, + 6, + cancellation_draft.clone().into_wire_parts(), + ); + assert_eq!( + order_client + .parse_order_cancellation(&cancellation_event) + .expect("cancellation envelope") + .payload + .reason, + cancellation.reason + ); + + let receipt = sample_buyer_receipt(buyer_pubkey.clone(), seller_pubkey.clone()); + let receipt_draft = order_client + .build_buyer_receipt_draft(&root_event_id, &fulfillment_event_id, &receipt) + .expect("receipt draft"); + assert_eq!(receipt_draft.as_wire_parts().kind, KIND_ORDER_RECEIPT); + let receipt_event = event_from_parts( + "receipt-event-1", + &buyer_pubkey, + 7, + receipt_draft.clone().into_wire_parts(), + ); + assert!( + order_client + .parse_buyer_receipt(&receipt_event) + .expect("receipt envelope") + .payload + .received + ); +} + +#[test] +fn order_draft_facades_return_encoder_errors() { + let client = + RadrootsSdkClient::from_config(RadrootsSdkConfig::production()).expect("sdk client"); + let order = client.order(); + let buyer_pubkey = "b".repeat(64); + let seller_pubkey = "a".repeat(64); + let root_event_id = event_id('1'); + let decision_event_id = event_id('2'); + + let mut invalid_order = sample_order_request(buyer_pubkey.clone(), seller_pubkey.clone()); + invalid_order.items.clear(); + assert!( + order + .build_order_request_draft(&listing_event_ptr(), &invalid_order) + .is_err() + ); + + let mut invalid_decision = sample_order_decision(buyer_pubkey.clone(), seller_pubkey.clone()); + invalid_decision.decision = RadrootsOrderDecisionOutcome::Accepted { + inventory_commitments: Vec::new(), + }; + assert!( + order + .build_order_decision_draft(&root_event_id, &root_event_id, &invalid_decision) + .is_err() + ); + + let proposal = sample_order_revision_proposal( + buyer_pubkey.clone(), + seller_pubkey.clone(), + root_event_id.to_string(), + decision_event_id.to_string(), + ); + let different_root_event_id = event_id('d'); + assert!( + order + .build_order_revision_proposal_draft( + &different_root_event_id, + &decision_event_id, + &proposal, + ) + .is_err() + ); + + let revision_decision = + sample_order_revision_decision(&proposal, RadrootsOrderRevisionOutcome::Accepted); + let different_prev_event_id = event_id('e'); + assert!( + order + .build_order_revision_decision_draft( + &root_event_id, + &different_prev_event_id, + &revision_decision, + ) + .is_err() + ); + + let mut fulfillment = sample_fulfillment_update(buyer_pubkey.clone(), seller_pubkey.clone()); + fulfillment.status = RadrootsOrderFulfillmentState::AcceptedNotFulfilled; + assert!( + order + .build_fulfillment_update_draft(&root_event_id, &decision_event_id, &fulfillment) + .is_err() + ); + + let mut cancellation = sample_order_cancellation(buyer_pubkey.clone(), seller_pubkey.clone()); + cancellation.reason.clear(); + assert!( + order + .build_order_cancellation_draft(&root_event_id, &decision_event_id, &cancellation) + .is_err() + ); + + let mut receipt = sample_buyer_receipt(buyer_pubkey, seller_pubkey); + receipt.received = false; + assert!( + order + .build_buyer_receipt_draft(&root_event_id, &decision_event_id, &receipt) + .is_err() + ); +} + +#[test] +fn publish_receipts_and_errors_format_public_details() { + let receipt = SdkRadrootsdPublishReceipt { + accepted: true, + deduplicated: true, + job_id: Some("job-1".into()), + status: Some("accepted".into()), + signer_mode: Some("secret-mode".into()), + signer_session_id: Some("secret-session".into()), + event_addr: Some("3432:pubkey:order-1".into()), + relay_count: Some(2), + acknowledged_relay_count: Some(1), + }; + let debug = format!("{receipt:?}"); + + assert!(debug.contains("SdkRadrootsdPublishReceipt")); + assert!(debug.contains("<redacted>")); + assert!(!debug.contains("secret-mode")); + assert!(!debug.contains("secret-session")); + + let relay_failure = SdkRelayFailure { + relay_url: "wss://relay.example".into(), + error: "closed".into(), + }; + let formatted = [ + SdkPublishError::from(SdkConfigError::EmptyRelayUrl).to_string(), + SdkPublishError::Encode("encode failed".into()).to_string(), + SdkPublishError::UnsupportedTransport { + transport: SdkTransportMode::Radrootsd, + operation: "order.publish", + } + .to_string(), + SdkPublishError::UnsupportedSignerMode { + transport: SdkTransportMode::RelayDirect, + signer: SignerConfig::DraftOnly, + required: SignerConfig::LocalIdentity, + operation: "order.publish", + } + .to_string(), + SdkPublishError::Relay("relay failed".into()).to_string(), + SdkPublishError::RelaySetup { + transport: SdkTransportMode::RelayDirect, + operation: "order.publish", + target_relays: Vec::new(), + error: "setup failed".into(), + } + .to_string(), + SdkPublishError::RelaySetup { + transport: SdkTransportMode::RelayDirect, + operation: "order.publish", + target_relays: vec!["wss://relay.example".into()], + error: "setup failed".into(), + } + .to_string(), + SdkPublishError::RelayNotAcknowledged { + transport: SdkTransportMode::RelayDirect, + failed_relays: Vec::new(), + } + .to_string(), + SdkPublishError::RelayNotAcknowledged { + transport: SdkTransportMode::RelayDirect, + failed_relays: vec![relay_failure], + } + .to_string(), + SdkPublishError::Radrootsd("radrootsd failed".into()).to_string(), + ]; + + assert!( + formatted + .iter() + .any(|message| message == "relay url must not be empty") + ); + assert!(formatted.iter().any(|message| message == "encode failed")); + assert!( + formatted + .iter() + .any(|message| message.contains("requires signer mode `local_identity`")) + ); + assert!(formatted.iter().any(|message| { + message.contains("failed to prepare RelayDirect relay publish for wss://relay.example") + })); + assert!( + formatted + .iter() + .any(|message| message.contains("wss://relay.example: closed")) + ); + assert!( + formatted + .iter() + .any(|message| message == "radrootsd failed") + ); +} + +#[test] +fn farm_client_wraps_existing_farm_facade() { + let client = + RadrootsSdkClient::from_config(RadrootsSdkConfig::production()).expect("sdk client"); + let farm = sample_farm(); + + let draft = client.farm().build_draft(&farm).expect("farm draft"); + assert!(!draft.tags.is_empty()); +} diff --git a/crates/sdk/tests/config.rs b/crates/sdk/tests/config.rs @@ -0,0 +1,562 @@ +use radroots_sdk::{ + NetworkConfig, RADROOTS_SDK_LOCAL_RADROOTSD_ENDPOINT, RADROOTS_SDK_LOCAL_RELAY_URL, + RADROOTS_SDK_PRODUCTION_RADROOTSD_ENDPOINT, RADROOTS_SDK_PRODUCTION_RELAY_URL, + RADROOTS_SDK_STAGING_RADROOTSD_ENDPOINT, RADROOTS_SDK_STAGING_RELAY_URL, RadrootsSdkConfig, + RadrootsdAuth, RelayConfig, SdkConfigError, SdkEnvironment, SdkTransportMode, SignerConfig, +}; +use std::{ + ffi::OsString, + sync::{Mutex, OnceLock}, +}; + +const LOCAL_SDK_ENV_KEYS: &[&str] = &[ + "NOSTR_RS_RELAY_PUBLIC_SCHEME", + "NOSTR_RS_RELAY_PUBLIC_HOST", + "NOSTR_RS_RELAY_PUBLIC_PORT", + "RADROOTSD_RPC_URL", + "RADROOTSD_RPC_HOST", + "RADROOTSD_RPC_PORT", +]; + +fn sdk_env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock<Mutex<()>> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) +} + +struct LocalSdkEnvRestore { + saved: Vec<(&'static str, Option<OsString>)>, +} + +impl LocalSdkEnvRestore { + fn apply(pairs: &[(&str, &str)]) -> Self { + let saved = LOCAL_SDK_ENV_KEYS + .iter() + .map(|key| (*key, std::env::var_os(key))) + .collect::<Vec<_>>(); + + for key in LOCAL_SDK_ENV_KEYS { + unsafe { + std::env::remove_var(key); + } + } + for (key, value) in pairs { + assert!( + LOCAL_SDK_ENV_KEYS.contains(key), + "unexpected local sdk env key `{key}`" + ); + unsafe { + std::env::set_var(key, value); + } + } + + Self { saved } + } +} + +impl Drop for LocalSdkEnvRestore { + fn drop(&mut self) { + for (key, original) in self.saved.drain(..) { + match original { + Some(value) => unsafe { + std::env::set_var(key, value); + }, + None => unsafe { + std::env::remove_var(key); + }, + } + } + } +} + +struct EnvKeyRestore { + key: &'static str, + saved: Option<OsString>, +} + +impl EnvKeyRestore { + fn capture(key: &'static str) -> Self { + Self { + key, + saved: std::env::var_os(key), + } + } +} + +impl Drop for EnvKeyRestore { + fn drop(&mut self) { + match &self.saved { + Some(value) => unsafe { + std::env::set_var(self.key, value); + }, + None => unsafe { + std::env::remove_var(self.key); + }, + } + } +} + +fn with_local_sdk_env<F>(pairs: &[(&str, &str)], test: F) +where + F: FnOnce(), +{ + let _guard = sdk_env_lock().lock().expect("sdk env lock"); + let _env_restore = LocalSdkEnvRestore::apply(pairs); + + test(); +} + +#[test] +fn local_sdk_env_restore_preserves_original_os_string_values() { + let _guard = sdk_env_lock().lock().expect("sdk env lock"); + let key = "NOSTR_RS_RELAY_PUBLIC_HOST"; + let _restore_key = EnvKeyRestore::capture(key); + let original = OsString::from("relay.before.example"); + + unsafe { + std::env::set_var(key, &original); + } + + { + let _env_restore = LocalSdkEnvRestore::apply(&[("RADROOTSD_RPC_PORT", "18080")]); + + assert_eq!(std::env::var_os(key), None); + } + + assert_eq!(std::env::var_os(key), Some(original)); +} + +#[test] +fn env_key_restore_restores_existing_value() { + let _guard = sdk_env_lock().lock().expect("sdk env lock"); + let key = "NOSTR_RS_RELAY_PUBLIC_HOST"; + let _restore_outer = EnvKeyRestore::capture(key); + let original = OsString::from("relay.before.example"); + let changed = OsString::from("relay.changed.example"); + + unsafe { + std::env::set_var(key, &original); + } + + { + let _restore_inner = EnvKeyRestore::capture(key); + + unsafe { + std::env::set_var(key, &changed); + } + } + + assert_eq!(std::env::var_os(key), Some(original)); +} + +#[cfg(unix)] +#[test] +fn local_sdk_env_restore_preserves_non_unicode_original_values() { + use std::os::unix::ffi::OsStringExt; + + let _guard = sdk_env_lock().lock().expect("sdk env lock"); + let key = "NOSTR_RS_RELAY_PUBLIC_HOST"; + let _restore_key = EnvKeyRestore::capture(key); + let original = OsString::from_vec(vec![b'r', b'e', b'l', b'a', b'y', 0x80]); + + unsafe { + std::env::set_var(key, &original); + } + + { + let _env_restore = LocalSdkEnvRestore::apply(&[("RADROOTSD_RPC_PORT", "18080")]); + + assert_eq!(std::env::var_os(key), None); + } + + assert_eq!(std::env::var_os(key), Some(original)); +} + +#[test] +fn default_config_uses_production_relay_direct_draft_only() { + let config = RadrootsSdkConfig::default(); + + assert_eq!(config.environment, SdkEnvironment::Production); + assert_eq!(config.transport, SdkTransportMode::RelayDirect); + assert_eq!(config.signer, SignerConfig::DraftOnly); + assert_eq!(config.network, NetworkConfig::default()); + assert_eq!(config.radrootsd.auth, RadrootsdAuth::None); +} + +#[test] +fn production_environment_resolves_radroots_org_defaults() { + let config = RadrootsSdkConfig::production(); + + assert_eq!( + config.resolved_relay_urls().expect("relay defaults"), + vec![RADROOTS_SDK_PRODUCTION_RELAY_URL.to_owned()] + ); + assert_eq!( + config + .resolved_radrootsd_endpoint() + .expect("radrootsd endpoint"), + RADROOTS_SDK_PRODUCTION_RADROOTSD_ENDPOINT + ); +} + +#[test] +fn staging_environment_resolves_staging_defaults() { + let config = RadrootsSdkConfig::staging(); + + assert_eq!( + config.resolved_relay_urls().expect("relay defaults"), + vec![RADROOTS_SDK_STAGING_RELAY_URL.to_owned()] + ); + assert_eq!( + config + .resolved_radrootsd_endpoint() + .expect("radrootsd endpoint"), + RADROOTS_SDK_STAGING_RADROOTSD_ENDPOINT + ); +} + +#[test] +fn local_environment_resolves_localhost_defaults() { + with_local_sdk_env(&[], || { + let config = RadrootsSdkConfig::local(); + + assert_eq!( + config.resolved_relay_urls().expect("relay defaults"), + vec![RADROOTS_SDK_LOCAL_RELAY_URL.to_owned()] + ); + assert_eq!( + config + .resolved_radrootsd_endpoint() + .expect("radrootsd endpoint"), + RADROOTS_SDK_LOCAL_RADROOTSD_ENDPOINT + ); + }); +} + +#[test] +fn local_environment_prefers_root_env_contract_when_present() { + with_local_sdk_env( + &[ + ("NOSTR_RS_RELAY_PUBLIC_SCHEME", "ws"), + ("NOSTR_RS_RELAY_PUBLIC_HOST", "127.0.0.1"), + ("NOSTR_RS_RELAY_PUBLIC_PORT", "18080"), + ("RADROOTSD_RPC_URL", "http://127.0.0.1:17070/jsonrpc"), + ], + || { + let config = RadrootsSdkConfig::local(); + + assert_eq!( + config.resolved_relay_urls().expect("relay defaults"), + vec!["ws://127.0.0.1:18080".to_owned()] + ); + assert_eq!( + config + .resolved_radrootsd_endpoint() + .expect("radrootsd endpoint"), + "http://127.0.0.1:17070/jsonrpc" + ); + }, + ); +} + +#[test] +fn local_environment_ignores_partial_or_blank_env_contracts() { + with_local_sdk_env( + &[ + ("NOSTR_RS_RELAY_PUBLIC_SCHEME", "ws"), + ("NOSTR_RS_RELAY_PUBLIC_HOST", " "), + ("NOSTR_RS_RELAY_PUBLIC_PORT", "18080"), + ("RADROOTSD_RPC_HOST", "127.0.0.1"), + ], + || { + let config = RadrootsSdkConfig::local(); + + assert_eq!( + config.resolved_relay_urls().expect("relay defaults"), + vec![RADROOTS_SDK_LOCAL_RELAY_URL.to_owned()] + ); + assert_eq!( + config + .resolved_radrootsd_endpoint() + .expect("radrootsd endpoint"), + RADROOTS_SDK_LOCAL_RADROOTSD_ENDPOINT + ); + }, + ); +} + +#[test] +fn local_environment_handles_invalid_and_missing_relay_port_env() { + with_local_sdk_env( + &[ + ("NOSTR_RS_RELAY_PUBLIC_SCHEME", "http"), + ("NOSTR_RS_RELAY_PUBLIC_HOST", "127.0.0.1"), + ("NOSTR_RS_RELAY_PUBLIC_PORT", "18080"), + ], + || { + let config = RadrootsSdkConfig::local(); + + assert_eq!( + config.resolved_relay_urls().expect_err("invalid relay env"), + SdkConfigError::InvalidRelayUrl("http://127.0.0.1:18080".to_owned()) + ); + }, + ); + + with_local_sdk_env( + &[ + ("NOSTR_RS_RELAY_PUBLIC_SCHEME", "ws"), + ("NOSTR_RS_RELAY_PUBLIC_HOST", "127.0.0.1"), + ], + || { + let config = RadrootsSdkConfig::local(); + + assert_eq!( + config.resolved_relay_urls().expect("relay defaults"), + vec![RADROOTS_SDK_LOCAL_RELAY_URL.to_owned()] + ); + }, + ); +} + +#[test] +fn local_environment_builds_radrootsd_endpoint_from_host_port_env() { + with_local_sdk_env( + &[ + ("RADROOTSD_RPC_HOST", "127.0.0.1"), + ("RADROOTSD_RPC_PORT", "17070"), + ], + || { + let config = RadrootsSdkConfig::local(); + + assert_eq!( + config + .resolved_radrootsd_endpoint() + .expect("host port endpoint"), + "http://127.0.0.1:17070" + ); + }, + ); +} + +#[test] +fn explicit_coordinates_override_environment_defaults_exactly() { + let mut config = RadrootsSdkConfig::production(); + config.relay.urls = vec![ + " wss://relay.custom.one ".to_owned(), + "wss://relay.custom.one".to_owned(), + "ws://relay.custom.two".to_owned(), + ]; + config.radrootsd.endpoint = Some(" https://rpc.custom.radroots.org ".to_owned()); + + assert_eq!( + config.resolved_relay_urls().expect("relay overrides"), + vec![ + "wss://relay.custom.one".to_owned(), + "ws://relay.custom.two".to_owned(), + ] + ); + assert_eq!( + config + .resolved_radrootsd_endpoint() + .expect("endpoint override"), + "https://rpc.custom.radroots.org" + ); +} + +#[test] +fn custom_environment_requires_explicit_coordinates() { + let config = RadrootsSdkConfig::custom(); + + assert_eq!( + config + .resolved_relay_urls() + .expect_err("custom relay error"), + SdkConfigError::MissingCustomRelayUrls + ); + assert_eq!( + config + .resolved_radrootsd_endpoint() + .expect_err("custom radrootsd error"), + SdkConfigError::MissingCustomRadrootsdEndpoint + ); +} + +#[test] +fn custom_environment_accepts_explicit_coordinates() { + let mut config = RadrootsSdkConfig::custom(); + config.relay.urls = vec!["wss://relay.custom.radroots.org".to_owned()]; + config.radrootsd.endpoint = Some("https://rpc.custom.radroots.org".to_owned()); + + assert_eq!( + config.resolved_relay_urls().expect("custom relay"), + vec!["wss://relay.custom.radroots.org".to_owned()] + ); + assert_eq!( + config + .resolved_radrootsd_endpoint() + .expect("custom endpoint"), + "https://rpc.custom.radroots.org" + ); +} + +#[test] +fn empty_coordinate_values_fail_loudly() { + let mut config = RadrootsSdkConfig::production(); + config.relay = RelayConfig { + urls: vec![" ".to_owned()], + }; + config.radrootsd.endpoint = Some(" ".to_owned()); + + assert_eq!( + config.resolved_relay_urls().expect_err("empty relay"), + SdkConfigError::EmptyRelayUrl + ); + assert_eq!( + config + .resolved_radrootsd_endpoint() + .expect_err("empty radrootsd endpoint"), + SdkConfigError::EmptyRadrootsdEndpoint + ); +} + +#[test] +fn invalid_coordinate_schemes_fail_loudly() { + let mut config = RadrootsSdkConfig::production(); + config.relay.urls = vec!["https://relay.bad".to_owned()]; + config.radrootsd.endpoint = Some("wss://rpc.bad".to_owned()); + + assert_eq!( + config + .resolved_relay_urls() + .expect_err("relay scheme error"), + SdkConfigError::InvalidRelayUrl("https://relay.bad".to_owned()) + ); + assert_eq!( + config + .resolved_radrootsd_endpoint() + .expect_err("endpoint scheme error"), + SdkConfigError::InvalidRadrootsdEndpoint("wss://rpc.bad".to_owned()) + ); +} + +#[test] +fn invalid_relay_authorities_fail_loudly() { + let invalid_relays = [ + "wss://", + "wss:///relay", + "ws://:8080", + "wss://relay.example:", + "wss://relay example", + "wss://user@relay.example", + "wss://relay.example:abc", + "wss://2001:db8::1", + ]; + + for relay_url in invalid_relays { + let mut config = RadrootsSdkConfig::production(); + config.relay.urls = vec![relay_url.to_owned()]; + + assert_eq!( + config + .resolved_relay_urls() + .expect_err("relay authority error"), + SdkConfigError::InvalidRelayUrl(relay_url.to_owned()) + ); + } +} + +#[test] +fn invalid_bracketed_relay_authorities_fail_loudly() { + let invalid_relays = [ + "wss://[2001:db8::1", + "wss://[]:443", + "wss://[2001:db8::1]suffix", + "wss://[2001:db8::1]:abc", + ]; + + for relay_url in invalid_relays { + let mut config = RadrootsSdkConfig::production(); + config.relay.urls = vec![relay_url.to_owned()]; + + assert_eq!( + config + .resolved_relay_urls() + .expect_err("bracketed relay authority error"), + SdkConfigError::InvalidRelayUrl(relay_url.to_owned()) + ); + } +} + +#[test] +fn valid_relay_authorities_still_resolve() { + let mut config = RadrootsSdkConfig::production(); + config.relay.urls = vec![ + " wss://relay.example/nostr ".to_owned(), + "ws://127.0.0.1:8080".to_owned(), + "wss://[2001:db8::1]/relay".to_owned(), + "wss://[2001:db8::1]:443/relay".to_owned(), + ]; + + assert_eq!( + config.resolved_relay_urls().expect("valid relays"), + vec![ + "wss://relay.example/nostr".to_owned(), + "ws://127.0.0.1:8080".to_owned(), + "wss://[2001:db8::1]/relay".to_owned(), + "wss://[2001:db8::1]:443/relay".to_owned() + ] + ); +} + +#[test] +fn signer_modes_format_as_config_tokens() { + assert_eq!(SignerConfig::DraftOnly.to_string(), "draft_only"); + assert_eq!(SignerConfig::LocalIdentity.to_string(), "local_identity"); + assert_eq!(SignerConfig::Nip46.to_string(), "nip46"); +} + +#[test] +fn config_errors_format_operator_facing_messages() { + let formatted = [ + SdkConfigError::MissingCustomRelayUrls.to_string(), + SdkConfigError::MissingCustomRadrootsdEndpoint.to_string(), + SdkConfigError::EmptyRelayUrl.to_string(), + SdkConfigError::InvalidRelayUrl("http://relay.example".into()).to_string(), + SdkConfigError::EmptyRadrootsdEndpoint.to_string(), + SdkConfigError::InvalidRadrootsdEndpoint("ws://rpc.example".into()).to_string(), + ]; + + assert_eq!( + formatted, + [ + "custom sdk environment requires explicit relay urls", + "custom sdk environment requires an explicit radrootsd endpoint", + "relay url must not be empty", + "relay url must use ws or wss, got `http://relay.example`", + "radrootsd endpoint must not be empty", + "radrootsd endpoint must use http or https, got `ws://rpc.example`", + ] + ); +} + +#[test] +fn radrootsd_auth_debug_formats_none_and_redacts_bearer_tokens() { + assert_eq!(format!("{:?}", RadrootsdAuth::None), "None"); + + let bearer = RadrootsdAuth::BearerToken("sdk-secret-token".to_owned()); + let debug = format!("{bearer:?}"); + + assert!(!debug.contains("sdk-secret-token")); + assert_eq!(debug, "BearerToken(\"<redacted>\")"); +} + +#[test] +fn sdk_config_debug_redacts_bearer_tokens() { + let mut config = RadrootsSdkConfig::production(); + config.radrootsd.auth = RadrootsdAuth::BearerToken("sdk-secret-token".to_owned()); + + let debug = format!("{config:?}"); + + assert!(!debug.contains("sdk-secret-token")); + assert!(debug.contains("BearerToken(\"<redacted>\")")); +} diff --git a/crates/sdk/tests/facade.rs b/crates/sdk/tests/facade.rs @@ -0,0 +1,269 @@ +use radroots_core::{ + RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, + RadrootsCoreQuantityPrice, RadrootsCoreUnit, +}; +use radroots_events::farm::{RadrootsFarm, RadrootsFarmRef}; +use radroots_events::ids::RadrootsPublicKey; +use radroots_events::kinds::{KIND_FARM, KIND_LISTING, KIND_ORDER_REQUEST, KIND_PROFILE}; +use radroots_events::listing::{ + RadrootsListing, RadrootsListingAvailability, RadrootsListingBin, + RadrootsListingDeliveryMethod, RadrootsListingLocation, RadrootsListingProduct, + RadrootsListingStatus, +}; +use radroots_events::order::{ + RadrootsOrderEconomicItem, RadrootsOrderEconomics, RadrootsOrderItem, + RadrootsOrderPricingBasis, RadrootsOrderRequest, +}; +use radroots_events::profile::{RadrootsProfile, RadrootsProfileType}; +use radroots_sdk::{RadrootsNostrEvent, RadrootsNostrEventPtr, farm, listing, order, profile}; + +fn sample_profile() -> RadrootsProfile { + RadrootsProfile { + name: "North Farm".into(), + display_name: Some("North Farm".into()), + nip05: None, + about: Some("Organic coffee".into()), + website: Some("https://example.com".into()), + picture: None, + banner: None, + lud06: None, + lud16: None, + bot: None, + } +} + +fn sample_farm() -> RadrootsFarm { + RadrootsFarm { + d_tag: "AAAAAAAAAAAAAAAAAAAAAA".into(), + name: "North Farm".into(), + about: Some("Organic coffee".into()), + website: None, + picture: None, + banner: None, + location: None, + tags: Some(vec!["coffee".into()]), + } +} + +fn sample_listing() -> RadrootsListing { + RadrootsListing { + d_tag: "AAAAAAAAAAAAAAAAAAAAAg".parse().expect("listing d tag"), + published_at: None, + farm: RadrootsFarmRef { + pubkey: "seller".into(), + d_tag: "AAAAAAAAAAAAAAAAAAAAAA".into(), + }, + product: RadrootsListingProduct { + key: "coffee".into(), + title: "Coffee".into(), + category: "coffee".into(), + summary: Some("Single origin coffee".into()), + process: None, + lot: None, + location: None, + profile: None, + year: None, + }, + primary_bin_id: "bin-1".parse().expect("primary bin id"), + bins: vec![RadrootsListingBin { + bin_id: "bin-1".parse().expect("bin id"), + quantity: RadrootsCoreQuantity::new( + RadrootsCoreDecimal::from(1000u32), + RadrootsCoreUnit::MassG, + ), + price_per_canonical_unit: RadrootsCoreQuantityPrice { + amount: RadrootsCoreMoney::new( + RadrootsCoreDecimal::from(20u32), + RadrootsCoreCurrency::USD, + ), + quantity: RadrootsCoreQuantity::new( + RadrootsCoreDecimal::from(1u32), + RadrootsCoreUnit::MassG, + ), + }, + display_amount: None, + display_unit: None, + display_label: None, + display_price: None, + display_price_unit: None, + }], + resource_area: None, + plot: None, + discounts: None, + inventory_available: Some(RadrootsCoreDecimal::from(5u32)), + availability: Some(RadrootsListingAvailability::Status { + status: RadrootsListingStatus::Active, + }), + delivery_method: Some(RadrootsListingDeliveryMethod::Pickup), + location: Some(RadrootsListingLocation { + primary: "North Farm".into(), + city: None, + region: None, + country: None, + lat: None, + lng: None, + geohash: None, + }), + images: None, + } +} + +fn listing_event(listing_value: &RadrootsListing) -> RadrootsNostrEvent { + let parts = listing::build_draft(listing_value).expect("listing draft"); + RadrootsNostrEvent { + id: "event-1".into(), + author: "seller".into(), + created_at: 1, + kind: parts.as_wire_parts().kind, + tags: parts.as_wire_parts().tags.clone(), + content: parts.as_wire_parts().content.clone(), + sig: String::new(), + } +} + +fn listing_event_ptr() -> RadrootsNostrEventPtr { + RadrootsNostrEventPtr { + id: core::iter::repeat_n('a', 64).collect(), + relays: Some("wss://listing.relay.example".into()), + } +} + +fn public_key(character: char) -> RadrootsPublicKey { + core::iter::repeat_n(character, 64) + .collect::<String>() + .parse() + .expect("public key") +} + +fn sample_order_request() -> RadrootsOrderRequest { + let seller_pubkey = public_key('a'); + + RadrootsOrderRequest { + order_id: "order-1".parse().expect("order id"), + listing_addr: format!("{KIND_LISTING}:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAg") + .parse() + .expect("listing address"), + buyer_pubkey: public_key('b'), + seller_pubkey, + items: vec![RadrootsOrderItem { + bin_id: "bin-1".parse().expect("bin id"), + bin_count: 2, + }], + economics: RadrootsOrderEconomics { + quote_id: "quote-1".parse().expect("quote id"), + quote_version: 1, + pricing_basis: RadrootsOrderPricingBasis::ListingEvent, + currency: RadrootsCoreCurrency::USD, + items: vec![RadrootsOrderEconomicItem { + bin_id: "bin-1".parse().expect("bin id"), + bin_count: 2, + quantity_amount: RadrootsCoreDecimal::from(1u32), + quantity_unit: RadrootsCoreUnit::Each, + unit_price_amount: RadrootsCoreDecimal::from(5u32), + unit_price_currency: RadrootsCoreCurrency::USD, + line_subtotal: RadrootsCoreMoney::new( + RadrootsCoreDecimal::from(10u32), + RadrootsCoreCurrency::USD, + ), + }], + discounts: Vec::new(), + adjustments: Vec::new(), + subtotal: RadrootsCoreMoney::new( + RadrootsCoreDecimal::from(10u32), + RadrootsCoreCurrency::USD, + ), + discount_total: RadrootsCoreMoney::new( + RadrootsCoreDecimal::from(0u32), + RadrootsCoreCurrency::USD, + ), + adjustment_total: RadrootsCoreMoney::new( + RadrootsCoreDecimal::from(0u32), + RadrootsCoreCurrency::USD, + ), + total: RadrootsCoreMoney::new( + RadrootsCoreDecimal::from(10u32), + RadrootsCoreCurrency::USD, + ), + }, + } +} + +#[test] +fn profile_build_draft_wraps_profile_encoder() { + let parts = + profile::build_draft(&sample_profile(), Some(RadrootsProfileType::Farm)).expect("profile"); + + assert_eq!(parts.kind, KIND_PROFILE); + assert!(parts.tags.iter().any(|tag| { + tag.first().map(|value| value.as_str()) == Some("t") + && tag.get(1).map(|value| value.as_str()) == Some("radroots:type:farm") + })); +} + +#[test] +fn farm_build_draft_wraps_farm_encoder() { + let parts = farm::build_draft(&sample_farm()).expect("farm"); + + assert_eq!(parts.kind, KIND_FARM); + assert!( + parts + .tags + .iter() + .any(|tag| tag.first().map(|value| value.as_str()) == Some("d")) + ); +} + +#[test] +fn listing_facade_wraps_build_parse_and_validate() { + let listing_value = sample_listing(); + let tags = listing::build_tags(&listing_value).expect("listing tags"); + assert!(!tags.is_empty()); + + let event = listing_event(&listing_value); + let parsed = listing::parse_event(&event).expect("parsed listing"); + assert_eq!(parsed.d_tag, listing_value.d_tag); + + let validated = order::validate_listing_event(&event).expect("validated listing"); + assert_eq!(validated.listing_id, listing_value.d_tag); + assert_eq!(event.kind, KIND_LISTING); +} + +#[test] +fn listing_parse_rejects_non_listing_kind() { + let listing_value = sample_listing(); + let mut event = listing_event(&listing_value); + event.kind = KIND_PROFILE; + + assert_eq!( + listing::parse_event(&event).expect_err("listing kind error"), + listing::RadrootsListingParseError::InvalidKind(KIND_PROFILE) + ); +} + +#[test] +fn order_facade_wraps_build_parse_and_address_ops() { + let listing_value = sample_listing(); + let seller_pubkey = "a".repeat(64); + let listing_addr = format!("{KIND_LISTING}:{seller_pubkey}:{}", listing_value.d_tag); + let payload = sample_order_request(); + let parts = + order::build_order_request_draft(&listing_event_ptr(), &payload).expect("order draft"); + + assert_eq!(parts.as_wire_parts().kind, KIND_ORDER_REQUEST); + + let parsed_addr = order::parse_listing_address(&listing_addr).expect("listing address"); + assert_eq!(parsed_addr.listing_id, listing_value.d_tag); + + let event = RadrootsNostrEvent { + id: core::iter::repeat_n('b', 64).collect(), + author: payload.buyer_pubkey.to_string(), + created_at: 2, + kind: parts.as_wire_parts().kind, + tags: parts.as_wire_parts().tags.clone(), + content: parts.as_wire_parts().content.clone(), + sig: String::new(), + }; + let envelope = order::parse_order_request(&event).expect("order envelope"); + assert_eq!(envelope.payload.order_id, payload.order_id); + assert_eq!(envelope.payload.listing_addr, listing_addr); +} diff --git a/crates/sdk/tests/radrootsd.rs b/crates/sdk/tests/radrootsd.rs @@ -0,0 +1,1952 @@ +#![cfg(feature = "radrootsd-client")] + +use radroots_core::{ + RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, + RadrootsCoreQuantityPrice, RadrootsCoreUnit, +}; +use radroots_events::farm::{RadrootsFarm, RadrootsFarmLocation, RadrootsFarmRef}; +use radroots_events::ids::RadrootsPublicKey; +use radroots_events::kinds::{ + KIND_FARM, KIND_LISTING, KIND_LISTING_DRAFT, KIND_ORDER_REQUEST, KIND_PROFILE, +}; +use radroots_sdk::adapters::radrootsd::{ + SdkRadrootsdBridgeJob, SdkRadrootsdBridgePublishResponse, SdkRadrootsdListingPublishRequest, + SdkRadrootsdSignerAuthority, SdkRadrootsdSignerSessionConnectRequest, + SdkRadrootsdSignerSessionMode, +}; +use radroots_sdk::listing::{ + RadrootsListing, RadrootsListingAvailability, RadrootsListingBin, + RadrootsListingDeliveryMethod, RadrootsListingLocation, RadrootsListingParseError, + RadrootsListingProduct, RadrootsListingStatus, +}; +use radroots_sdk::order::{ + RadrootsOrderEconomicItem, RadrootsOrderEconomicLine, RadrootsOrderEconomics, + RadrootsOrderItem, RadrootsOrderPricingBasis, RadrootsOrderRequest, +}; +use radroots_sdk::{ + RadrootsNostrEvent, RadrootsNostrEventPtr, RadrootsProfile, RadrootsProfileType, + RadrootsSdkClient, RadrootsSdkConfig, RadrootsdAuth, RadrootsdConfig, SdkConfigError, + SdkEnvironment, SdkPublishError, SdkRadrootsdBridgeDeliveryPolicy, SdkRadrootsdBridgeError, + SdkRadrootsdBridgeJobStatus, SdkRadrootsdFarmPublishOptions, SdkRadrootsdListingPublishOptions, + SdkRadrootsdOrderRequestPublishOptions, SdkRadrootsdProfilePublishOptions, + SdkRadrootsdPublishReceipt, SdkRadrootsdSessionError, SdkRadrootsdSignerSessionHandle, + SdkRadrootsdSignerSessionRole, SdkRadrootsdSignerSessionView, SdkTransportMode, + SdkTransportReceipt, SignerConfig, +}; +use serde_json::{Value, json}; +use std::collections::VecDeque; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; +use tokio::sync::{mpsc, oneshot}; + +type TestResult<T> = Result<T, Box<dyn std::error::Error + Send + Sync>>; + +struct JsonRpcServer { + endpoint: String, + shutdown_tx: Option<oneshot::Sender<()>>, +} + +impl JsonRpcServer { + async fn spawn( + expected_auth: Option<&str>, + response_body: Value, + ) -> TestResult<(Self, oneshot::Receiver<Value>)> { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let endpoint = format!("http://{addr}/jsonrpc"); + let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); + let (request_tx, request_rx) = oneshot::channel(); + let expected_auth = expected_auth.map(str::to_owned); + let response_text = response_body.to_string(); + + tokio::spawn(async move { + loop { + tokio::select! { + _ = &mut shutdown_rx => break, + accept = listener.accept() => { + let Ok((mut stream, _)) = accept else { + break; + }; + let mut buffer = Vec::new(); + let mut chunk = [0_u8; 4096]; + let header_end = loop { + let Ok(read) = stream.read(&mut chunk).await else { + return; + }; + if read == 0 { + return; + } + buffer.extend_from_slice(&chunk[..read]); + if let Some(index) = find_headers_end(&buffer) { + break index; + } + }; + + let headers = String::from_utf8_lossy(&buffer[..header_end]).into_owned(); + let content_length = parse_content_length(headers.as_str()).unwrap_or(0); + let body_start = header_end + 4; + while buffer.len().saturating_sub(body_start) < content_length { + let Ok(read) = stream.read(&mut chunk).await else { + return; + }; + if read == 0 { + break; + } + buffer.extend_from_slice(&chunk[..read]); + } + + if let Some(expected_auth) = expected_auth.as_deref() { + let actual_auth = parse_authorization(headers.as_str()); + if actual_auth.as_deref() != Some(expected_auth) { + let _ = write_http_response( + &mut stream, + 401, + json!({ + "jsonrpc": "2.0", + "id": "sdk-test", + "error": { + "code": -32001, + "message": format!( + "unexpected authorization header: {:?}", + actual_auth + ), + } + }) + .to_string() + .as_str(), + ) + .await; + return; + } + } + + let body = &buffer[body_start..body_start + content_length]; + let Ok(request_json) = serde_json::from_slice::<Value>(body) else { + return; + }; + let _ = request_tx.send(request_json); + let _ = write_http_response(&mut stream, 200, response_text.as_str()).await; + break; + } + } + } + }); + + Ok(( + Self { + endpoint, + shutdown_tx: Some(shutdown_tx), + }, + request_rx, + )) + } + + fn endpoint(&self) -> &str { + self.endpoint.as_str() + } +} + +struct JsonRpcSequenceServer { + endpoint: String, + shutdown_tx: Option<oneshot::Sender<()>>, +} + +impl JsonRpcSequenceServer { + async fn spawn( + expected_auth: Option<&str>, + response_bodies: Vec<Value>, + ) -> TestResult<(Self, mpsc::UnboundedReceiver<Value>)> { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let endpoint = format!("http://{addr}/jsonrpc"); + let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); + let (request_tx, request_rx) = mpsc::unbounded_channel(); + let expected_auth = expected_auth.map(str::to_owned); + let mut response_texts = response_bodies + .into_iter() + .map(|value| value.to_string()) + .collect::<VecDeque<_>>(); + + tokio::spawn(async move { + loop { + if response_texts.is_empty() { + break; + } + + tokio::select! { + _ = &mut shutdown_rx => break, + accept = listener.accept() => { + let Ok((mut stream, _)) = accept else { + break; + }; + let mut buffer = Vec::new(); + let mut chunk = [0_u8; 4096]; + let header_end = loop { + let Ok(read) = stream.read(&mut chunk).await else { + return; + }; + if read == 0 { + return; + } + buffer.extend_from_slice(&chunk[..read]); + if let Some(index) = find_headers_end(&buffer) { + break index; + } + }; + + let headers = String::from_utf8_lossy(&buffer[..header_end]).into_owned(); + let content_length = parse_content_length(headers.as_str()).unwrap_or(0); + let body_start = header_end + 4; + while buffer.len().saturating_sub(body_start) < content_length { + let Ok(read) = stream.read(&mut chunk).await else { + return; + }; + if read == 0 { + break; + } + buffer.extend_from_slice(&chunk[..read]); + } + + if let Some(expected_auth) = expected_auth.as_deref() { + let actual_auth = parse_authorization(headers.as_str()); + if actual_auth.as_deref() != Some(expected_auth) { + let _ = write_http_response( + &mut stream, + 401, + json!({ + "jsonrpc": "2.0", + "id": "sdk-test", + "error": { + "code": -32001, + "message": format!( + "unexpected authorization header: {:?}", + actual_auth + ), + } + }) + .to_string() + .as_str(), + ) + .await; + return; + } + } + + let body = &buffer[body_start..body_start + content_length]; + let Ok(request_json) = serde_json::from_slice::<Value>(body) else { + return; + }; + let _ = request_tx.send(request_json); + let Some(response_text) = response_texts.pop_front() else { + return; + }; + let _ = write_http_response(&mut stream, 200, response_text.as_str()).await; + } + } + } + }); + + Ok(( + Self { + endpoint, + shutdown_tx: Some(shutdown_tx), + }, + request_rx, + )) + } + + fn endpoint(&self) -> &str { + self.endpoint.as_str() + } +} + +impl Drop for JsonRpcSequenceServer { + fn drop(&mut self) { + if let Some(shutdown_tx) = self.shutdown_tx.take() { + let _ = shutdown_tx.send(()); + } + } +} + +impl Drop for JsonRpcServer { + fn drop(&mut self) { + if let Some(shutdown_tx) = self.shutdown_tx.take() { + let _ = shutdown_tx.send(()); + } + } +} + +fn find_headers_end(buffer: &[u8]) -> Option<usize> { + buffer.windows(4).position(|window| window == b"\r\n\r\n") +} + +fn parse_content_length(headers: &str) -> Option<usize> { + headers.lines().find_map(|line| { + let (name, value) = line.split_once(':')?; + if !name.eq_ignore_ascii_case("content-length") { + return None; + } + value.trim().parse().ok() + }) +} + +fn parse_authorization(headers: &str) -> Option<String> { + headers.lines().find_map(|line| { + let (name, value) = line.split_once(':')?; + if !name.eq_ignore_ascii_case("authorization") { + return None; + } + Some(value.trim().to_owned()) + }) +} + +async fn write_http_response( + stream: &mut tokio::net::TcpStream, + status: u16, + body: &str, +) -> Result<(), std::io::Error> { + let status_text = match status { + 200 => "OK", + 401 => "Unauthorized", + _ => "Internal Server Error", + }; + let response = format!( + "HTTP/1.1 {status} {status_text}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + stream.write_all(response.as_bytes()).await +} + +fn sample_listing() -> RadrootsListing { + RadrootsListing { + d_tag: "AAAAAAAAAAAAAAAAAAAAAg".parse().expect("listing d tag"), + published_at: None, + farm: RadrootsFarmRef { + pubkey: "seller".into(), + d_tag: "AAAAAAAAAAAAAAAAAAAAAA".into(), + }, + product: RadrootsListingProduct { + key: "coffee".into(), + title: "Coffee".into(), + category: "coffee".into(), + summary: Some("Single origin coffee".into()), + process: None, + lot: None, + location: None, + profile: None, + year: None, + }, + primary_bin_id: "bin-1".parse().expect("primary bin id"), + bins: vec![RadrootsListingBin { + bin_id: "bin-1".parse().expect("bin id"), + quantity: RadrootsCoreQuantity::new( + RadrootsCoreDecimal::from(1000u32), + RadrootsCoreUnit::MassG, + ), + price_per_canonical_unit: RadrootsCoreQuantityPrice { + amount: RadrootsCoreMoney::new( + RadrootsCoreDecimal::from(20u32), + RadrootsCoreCurrency::USD, + ), + quantity: RadrootsCoreQuantity::new( + RadrootsCoreDecimal::from(1u32), + RadrootsCoreUnit::MassG, + ), + }, + display_amount: None, + display_unit: None, + display_label: None, + display_price: None, + display_price_unit: None, + }], + resource_area: None, + plot: None, + discounts: None, + inventory_available: Some(RadrootsCoreDecimal::from(5u32)), + availability: Some(RadrootsListingAvailability::Status { + status: RadrootsListingStatus::Active, + }), + delivery_method: Some(RadrootsListingDeliveryMethod::Pickup), + location: Some(RadrootsListingLocation { + primary: "North Farm".into(), + city: None, + region: None, + country: None, + lat: None, + lng: None, + geohash: None, + }), + images: None, + } +} + +fn sample_profile() -> RadrootsProfile { + RadrootsProfile { + name: "North Farm".into(), + display_name: Some("North Farm".into()), + nip05: None, + about: Some("Coffee farm".into()), + website: Some("https://example.invalid/north-farm".into()), + picture: None, + banner: None, + lud06: None, + lud16: None, + bot: None, + } +} + +fn sample_farm() -> RadrootsFarm { + RadrootsFarm { + d_tag: "AAAAAAAAAAAAAAAAAAAAAA".into(), + name: "North Farm".into(), + about: Some("Coffee farm".into()), + website: Some("https://example.invalid/north-farm".into()), + picture: None, + banner: None, + location: Some(RadrootsFarmLocation { + primary: Some("North Farm".into()), + city: Some("San Francisco".into()), + region: Some("CA".into()), + country: Some("US".into()), + gcs: None, + }), + tags: Some(vec!["coffee".into()]), + } +} + +fn sample_order_request_economics() -> RadrootsOrderEconomics { + RadrootsOrderEconomics { + quote_id: "quote-1".parse().expect("quote id"), + quote_version: 1, + pricing_basis: RadrootsOrderPricingBasis::ListingEvent, + currency: RadrootsCoreCurrency::USD, + items: vec![RadrootsOrderEconomicItem { + bin_id: "bin-1".parse().expect("bin id"), + bin_count: 2, + quantity_amount: RadrootsCoreDecimal::from(1u32), + quantity_unit: RadrootsCoreUnit::MassG, + unit_price_amount: RadrootsCoreDecimal::from(20u32), + unit_price_currency: RadrootsCoreCurrency::USD, + line_subtotal: RadrootsCoreMoney::new( + RadrootsCoreDecimal::from(40u32), + RadrootsCoreCurrency::USD, + ), + }], + discounts: Vec::<RadrootsOrderEconomicLine>::new(), + adjustments: Vec::<RadrootsOrderEconomicLine>::new(), + subtotal: RadrootsCoreMoney::new( + RadrootsCoreDecimal::from(40u32), + RadrootsCoreCurrency::USD, + ), + discount_total: RadrootsCoreMoney::new( + RadrootsCoreDecimal::from(0u32), + RadrootsCoreCurrency::USD, + ), + adjustment_total: RadrootsCoreMoney::new( + RadrootsCoreDecimal::from(0u32), + RadrootsCoreCurrency::USD, + ), + total: RadrootsCoreMoney::new(RadrootsCoreDecimal::from(40u32), RadrootsCoreCurrency::USD), + } +} + +fn sample_order_request() -> RadrootsOrderRequest { + let seller_pubkey: RadrootsPublicKey = "a".repeat(64).parse().expect("seller public key"); + + RadrootsOrderRequest { + order_id: "order-1".parse().expect("order id"), + listing_addr: format!("{KIND_LISTING}:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAg") + .parse() + .expect("listing address"), + buyer_pubkey: "b".repeat(64).parse().expect("buyer public key"), + seller_pubkey, + items: vec![RadrootsOrderItem { + bin_id: "bin-1".parse().expect("bin id"), + bin_count: 2, + }], + economics: sample_order_request_economics(), + } +} + +fn listing_event_ptr_with_relays(relays: Option<&str>) -> RadrootsNostrEventPtr { + RadrootsNostrEventPtr { + id: "a".repeat(64), + relays: relays.map(str::to_owned), + } +} + +fn sdk_event( + author: &str, + created_at: u32, + draft: radroots_sdk::listing::RadrootsListingDraft, +) -> RadrootsNostrEvent { + let parts = draft.into_wire_parts(); + RadrootsNostrEvent { + id: "event-1".to_owned(), + author: author.to_owned(), + created_at, + kind: parts.kind, + tags: parts.tags, + content: parts.content, + sig: "f".repeat(128), + } +} + +fn radrootsd_test_client(endpoint: &str) -> Result<RadrootsSdkClient, SdkConfigError> { + let mut config = RadrootsSdkConfig::for_environment(SdkEnvironment::Production); + config.transport = SdkTransportMode::Radrootsd; + config.signer = SignerConfig::Nip46; + config.radrootsd = RadrootsdConfig { + endpoint: Some(endpoint.to_owned()), + auth: RadrootsdAuth::BearerToken("sdk-secret".to_owned()), + }; + RadrootsSdkClient::from_config(config) +} + +fn sample_session_view_json(session_id: &str) -> Value { + json!({ + "session_id": session_id, + "role": "outbound_remote_signer", + "client_pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "signer_pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "user_pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "relays": ["wss://radroots.org"], + "permissions": ["sign_event:30402"], + "name": "Radroots Signer", + "url": "https://radroots.org/signers/demo", + "image": "https://radroots.org/signers/demo.png", + "auth_required": false, + "authorized": true, + "auth_url": null, + "expires_in_secs": 120, + "signer_authority": { + "provider_runtime_id": "runtime-1", + "account_identity_id": "identity-1", + "provider_signer_session_id": "provider-session-123" + } + }) +} + +fn sample_bridge_status_json() -> Value { + json!({ + "enabled": true, + "ready": true, + "auth_mode": "bearer_token", + "signer_mode": "selectable_per_request", + "default_signer_mode": "embedded_service_identity", + "supported_signer_modes": ["embedded_service_identity", "nip46_session"], + "available_nip46_signer_sessions": 2, + "relay_count": 1, + "delivery_policy": "quorum", + "delivery_quorum": 1, + "publish_max_attempts": 3, + "publish_initial_backoff_millis": 250, + "publish_max_backoff_millis": 4000, + "job_status_retention": 64, + "retained_jobs": 4, + "retained_idempotency_keys": 2, + "accepted_jobs": 1, + "published_jobs": 2, + "failed_jobs": 1, + "recovered_failed_jobs": 0, + "methods": ["bridge.status", "bridge.job.status", "bridge.job.list", "bridge.listing.publish"] + }) +} + +fn sample_bridge_job_json(job_id: &str) -> Value { + sample_bridge_job_json_for(job_id, "bridge.listing.publish", 30402) +} + +fn sample_bridge_job_json_for(job_id: &str, command: &str, event_kind: u32) -> Value { + json!({ + "job_id": job_id, + "command": command, + "idempotency_key": "idem-bridge-1", + "status": "published", + "terminal": true, + "recovered_after_restart": false, + "requested_at_unix": 1720000000u64, + "completed_at_unix": 1720000001u64, + "signer_mode": "nip46_session", + "signer_session_id": "session-123", + "event_kind": event_kind, + "event_id": "event-bridge-1", + "event_addr": "30402:seller:listing-bridge-1", + "delivery_policy": "quorum", + "delivery_quorum": 1, + "relay_count": 2, + "acknowledged_relay_count": 1, + "required_acknowledged_relay_count": 1, + "attempt_count": 1, + "attempt_summaries": ["attempt 1: 1/2 relays acknowledged"], + "relay_results": [ + { + "relay_url": "wss://radroots.org", + "acknowledged": true, + "detail": null + }, + { + "relay_url": "wss://backup.radroots.org", + "acknowledged": false, + "detail": "timeout" + } + ], + "relay_outcome_summary": "quorum satisfied with 1/2 relay acknowledgements" + }) +} + +async fn connected_bunker_session_handle( + session_id: &str, +) -> TestResult<SdkRadrootsdSignerSessionHandle> { + let (server, _) = JsonRpcServer::spawn( + Some("Bearer sdk-secret"), + json!({ + "jsonrpc": "2.0", + "id": "radroots-sdk-nip46-connect", + "result": { + "session_id": session_id, + "mode": "Bunker", + "remote_signer_pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "client_pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "relays": ["wss://radroots.org"] + } + }), + ) + .await?; + let client = radrootsd_test_client(server.endpoint())?; + client + .radrootsd() + .signer_sessions() + .connect_bunker( + "bunker://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa?relay=wss%3A%2F%2Fradroots.org&secret=shared-secret", + ) + .await + .map_err(Into::into) +} + +#[test] +fn radrootsd_debug_redacts_signer_session_values() { + let signer_authority = SdkRadrootsdSignerAuthority { + provider_runtime_id: "runtime-1".to_owned(), + account_identity_id: "identity-1".to_owned(), + provider_signer_session_id: Some("provider-session-123".to_owned()), + }; + let request = SdkRadrootsdListingPublishRequest { + listing: sample_listing(), + kind: Some(30402), + signer_session_id: "session-123".to_owned(), + signer_authority: Some(signer_authority), + idempotency_key: Some("idem-1".to_owned()), + }; + let job = SdkRadrootsdBridgeJob { + job_id: "job-1".to_owned(), + command: "bridge.listing.publish".to_owned(), + status: "published".to_owned(), + terminal: true, + recovered_after_restart: false, + signer_mode: "nip46_session:session-123".to_owned(), + signer_session_id: Some("session-123".to_owned()), + event_kind: 30402, + event_id: Some("event-1".to_owned()), + event_addr: Some("30402:seller:listing-1".to_owned()), + relay_count: 1, + acknowledged_relay_count: 1, + }; + let response = SdkRadrootsdBridgePublishResponse { + deduplicated: false, + job, + }; + let receipt = SdkRadrootsdPublishReceipt { + accepted: true, + deduplicated: false, + job_id: Some("job-1".to_owned()), + status: Some("published".to_owned()), + signer_mode: Some("nip46_session:session-123".to_owned()), + signer_session_id: Some("session-123".to_owned()), + event_addr: Some("30402:seller:listing-1".to_owned()), + relay_count: Some(1), + acknowledged_relay_count: Some(1), + }; + + let request_debug = format!("{request:?}"); + let response_debug = format!("{response:?}"); + let receipt_debug = format!("{receipt:?}"); + + assert!(!request_debug.contains("session-123")); + assert!(!request_debug.contains("provider-session-123")); + assert!(request_debug.contains("<redacted>")); + + assert!(!response_debug.contains("session-123")); + assert!(response_debug.contains("<redacted>")); + + assert!(!receipt_debug.contains("session-123")); + assert!(receipt_debug.contains("<redacted>")); + + let connect_request = SdkRadrootsdSignerSessionConnectRequest::nostrconnect( + "nostrconnect://bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb?relay=wss%3A%2F%2Fradroots.org&secret=shared-secret", + "client-secret-key", + ) + .with_signer_authority(SdkRadrootsdSignerAuthority { + provider_runtime_id: "runtime-1".to_owned(), + account_identity_id: "identity-1".to_owned(), + provider_signer_session_id: Some("provider-session-123".to_owned()), + }); + let connect_request_debug = format!("{connect_request:?}"); + assert!(!connect_request_debug.contains("client-secret-key")); + assert!(!connect_request_debug.contains("provider-session-123")); + assert!(connect_request_debug.contains("<redacted>")); +} + +#[tokio::test] +async fn radrootsd_signer_session_connect_returns_opaque_handle() -> TestResult<()> { + let (server, request_rx) = JsonRpcServer::spawn( + Some("Bearer sdk-secret"), + json!({ + "jsonrpc": "2.0", + "id": "radroots-sdk-nip46-connect", + "result": { + "session_id": "session-123", + "mode": "Nostrconnect", + "remote_signer_pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "client_pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "relays": ["wss://radroots.org"] + } + }), + ) + .await?; + + let mut config = RadrootsSdkConfig::for_environment(SdkEnvironment::Production); + config.transport = SdkTransportMode::Radrootsd; + config.signer = SignerConfig::Nip46; + config.radrootsd = RadrootsdConfig { + endpoint: Some(server.endpoint().to_owned()), + auth: RadrootsdAuth::BearerToken("sdk-secret".to_owned()), + }; + let client = RadrootsSdkClient::from_config(config)?; + let request = SdkRadrootsdSignerSessionConnectRequest::nostrconnect( + "nostrconnect://bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb?relay=wss%3A%2F%2Fradroots.org&secret=shared-secret", + "client-secret-key", + ); + + let handle: SdkRadrootsdSignerSessionHandle = client + .radrootsd() + .signer_sessions() + .connect(&request) + .await?; + let request_json = request_rx.await?; + + assert_eq!(request_json["method"], "nip46.connect"); + assert_eq!( + request_json["params"]["url"], + "nostrconnect://bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb?relay=wss%3A%2F%2Fradroots.org&secret=shared-secret" + ); + assert_eq!( + request_json["params"]["client_secret_key"], + "client-secret-key" + ); + assert_eq!(handle.mode(), SdkRadrootsdSignerSessionMode::Nostrconnect); + assert_eq!( + handle.remote_signer_pubkey(), + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ); + assert_eq!( + handle.client_pubkey(), + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ); + assert_eq!(handle.relays(), &["wss://radroots.org".to_owned()]); + + let handle_debug = format!("{handle:?}"); + assert!(!handle_debug.contains("session-123")); + assert!(handle_debug.contains("<redacted>")); + + let options = SdkRadrootsdListingPublishOptions::from_signer_session(&handle); + let options_debug = format!("{options:?}"); + assert!(!options_debug.contains("session-123")); + assert!(options_debug.contains("<redacted>")); + + Ok(()) +} + +#[tokio::test] +async fn radrootsd_signer_session_connect_bunker_supports_bunker_mode() -> TestResult<()> { + let (server, request_rx) = JsonRpcServer::spawn( + Some("Bearer sdk-secret"), + json!({ + "jsonrpc": "2.0", + "id": "radroots-sdk-nip46-connect", + "result": { + "session_id": "session-bunker", + "mode": "Bunker", + "remote_signer_pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "client_pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "relays": ["wss://radroots.org"] + } + }), + ) + .await?; + + let client = radrootsd_test_client(server.endpoint())?; + let handle: SdkRadrootsdSignerSessionHandle = client + .radrootsd() + .signer_sessions() + .connect_bunker( + "bunker://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa?relay=wss%3A%2F%2Fradroots.org&secret=shared-secret", + ) + .await?; + let request_json = request_rx.await?; + + assert_eq!(request_json["method"], "nip46.connect"); + assert_eq!( + request_json["params"]["url"], + "bunker://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa?relay=wss%3A%2F%2Fradroots.org&secret=shared-secret" + ); + assert!(request_json["params"]["client_secret_key"].is_null()); + assert_eq!(handle.mode(), SdkRadrootsdSignerSessionMode::Bunker); + + Ok(()) +} + +#[tokio::test] +async fn radrootsd_signer_session_status_returns_typed_view() -> TestResult<()> { + let (connect_server, _) = JsonRpcServer::spawn( + Some("Bearer sdk-secret"), + json!({ + "jsonrpc": "2.0", + "id": "radroots-sdk-nip46-connect", + "result": { + "session_id": "session-123", + "mode": "Nostrconnect", + "remote_signer_pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "client_pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "relays": ["wss://radroots.org"] + } + }), + ) + .await?; + let connect_client = radrootsd_test_client(connect_server.endpoint())?; + let handle: SdkRadrootsdSignerSessionHandle = connect_client + .radrootsd() + .signer_sessions() + .connect_nostrconnect( + "nostrconnect://bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb?relay=wss%3A%2F%2Fradroots.org&secret=shared-secret", + "client-secret-key", + ) + .await?; + + let (status_server, request_rx) = JsonRpcServer::spawn( + Some("Bearer sdk-secret"), + json!({ + "jsonrpc": "2.0", + "id": "radroots-sdk-nip46-session-status", + "result": sample_session_view_json("session-123") + }), + ) + .await?; + let status_client = radrootsd_test_client(status_server.endpoint())?; + let session: SdkRadrootsdSignerSessionView = status_client + .radrootsd() + .signer_sessions() + .status(handle.session()) + .await?; + let request_json = request_rx.await?; + + assert_eq!(request_json["method"], "nip46.session.status"); + assert_eq!(request_json["params"]["session_id"], "session-123"); + assert_eq!(session.session(), handle.session()); + assert_eq!( + session.role, + SdkRadrootsdSignerSessionRole::OutboundRemoteSigner + ); + assert_eq!( + session.client_pubkey, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ); + assert_eq!( + session.signer_pubkey, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ); + assert_eq!( + session.user_pubkey.as_deref(), + Some("cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc") + ); + assert_eq!(session.relays, vec!["wss://radroots.org".to_owned()]); + assert_eq!(session.permissions, vec!["sign_event:30402".to_owned()]); + assert_eq!(session.name.as_deref(), Some("Radroots Signer")); + assert_eq!( + session.url.as_deref(), + Some("https://radroots.org/signers/demo") + ); + assert_eq!( + session.image.as_deref(), + Some("https://radroots.org/signers/demo.png") + ); + assert!(session.authorized); + assert!(!session.auth_required); + assert_eq!(session.expires_in_secs, Some(120)); + assert_eq!( + session + .signer_authority + .as_ref() + .map(|value| value.provider_runtime_id.as_str()), + Some("runtime-1") + ); + + let debug = format!("{session:?}"); + assert!(!debug.contains("session-123")); + assert!(debug.contains("<redacted>")); + + Ok(()) +} + +#[tokio::test] +async fn radrootsd_signer_session_list_returns_typed_views() -> TestResult<()> { + let (server, request_rx) = JsonRpcServer::spawn( + Some("Bearer sdk-secret"), + json!({ + "jsonrpc": "2.0", + "id": "radroots-sdk-nip46-session-list", + "result": [ + sample_session_view_json("session-123"), + sample_session_view_json("session-456") + ] + }), + ) + .await?; + let client = radrootsd_test_client(server.endpoint())?; + let sessions: Vec<SdkRadrootsdSignerSessionView> = + client.radrootsd().signer_sessions().list().await?; + let request_json = request_rx.await?; + + assert_eq!(request_json["method"], "nip46.session.list"); + assert_eq!(sessions.len(), 2); + assert_eq!( + sessions[0].role, + SdkRadrootsdSignerSessionRole::OutboundRemoteSigner + ); + let debug = format!("{:?}", sessions[0].session()); + assert!(!debug.contains("session-123")); + assert!(debug.contains("<redacted>")); + + Ok(()) +} + +#[tokio::test] +async fn radrootsd_signer_session_authorize_returns_typed_result() -> TestResult<()> { + let (connect_server, _) = JsonRpcServer::spawn( + Some("Bearer sdk-secret"), + json!({ + "jsonrpc": "2.0", + "id": "radroots-sdk-nip46-connect", + "result": { + "session_id": "session-123", + "mode": "Bunker", + "remote_signer_pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "client_pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "relays": ["wss://radroots.org"] + } + }), + ) + .await?; + let connect_client = radrootsd_test_client(connect_server.endpoint())?; + let handle: SdkRadrootsdSignerSessionHandle = connect_client + .radrootsd() + .signer_sessions() + .connect_bunker( + "bunker://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa?relay=wss%3A%2F%2Fradroots.org&secret=shared-secret", + ) + .await?; + + let (server, request_rx) = JsonRpcServer::spawn( + Some("Bearer sdk-secret"), + json!({ + "jsonrpc": "2.0", + "id": "radroots-sdk-nip46-session-authorize", + "result": { + "authorized": true, + "replayed": true + } + }), + ) + .await?; + let client = radrootsd_test_client(server.endpoint())?; + let result = client + .radrootsd() + .signer_sessions() + .authorize(handle.session()) + .await?; + let request_json = request_rx.await?; + + assert_eq!(request_json["method"], "nip46.session.authorize"); + assert_eq!(request_json["params"]["session_id"], "session-123"); + assert!(result.authorized); + assert!(result.replayed); + + Ok(()) +} + +#[tokio::test] +async fn radrootsd_signer_session_get_public_key_returns_typed_result() -> TestResult<()> { + let (connect_server, _) = JsonRpcServer::spawn( + Some("Bearer sdk-secret"), + json!({ + "jsonrpc": "2.0", + "id": "radroots-sdk-nip46-connect", + "result": { + "session_id": "session-123", + "mode": "Bunker", + "remote_signer_pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "client_pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "relays": ["wss://radroots.org"] + } + }), + ) + .await?; + let connect_client = radrootsd_test_client(connect_server.endpoint())?; + let handle: SdkRadrootsdSignerSessionHandle = connect_client + .radrootsd() + .signer_sessions() + .connect_bunker( + "bunker://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa?relay=wss%3A%2F%2Fradroots.org&secret=shared-secret", + ) + .await?; + + let (server, request_rx) = JsonRpcServer::spawn( + Some("Bearer sdk-secret"), + json!({ + "jsonrpc": "2.0", + "id": "radroots-sdk-nip46-get-public-key", + "result": { + "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + } + }), + ) + .await?; + let client = radrootsd_test_client(server.endpoint())?; + let result = client + .radrootsd() + .signer_sessions() + .get_public_key(handle.session()) + .await?; + let request_json = request_rx.await?; + + assert_eq!(request_json["method"], "nip46.get_public_key"); + assert_eq!(request_json["params"]["session_id"], "session-123"); + assert_eq!( + result.pubkey, + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + ); + + Ok(()) +} + +#[tokio::test] +async fn radrootsd_signer_session_require_auth_returns_typed_result() -> TestResult<()> { + let (connect_server, _) = JsonRpcServer::spawn( + Some("Bearer sdk-secret"), + json!({ + "jsonrpc": "2.0", + "id": "radroots-sdk-nip46-connect", + "result": { + "session_id": "session-123", + "mode": "Bunker", + "remote_signer_pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "client_pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "relays": ["wss://radroots.org"] + } + }), + ) + .await?; + let connect_client = radrootsd_test_client(connect_server.endpoint())?; + let handle: SdkRadrootsdSignerSessionHandle = connect_client + .radrootsd() + .signer_sessions() + .connect_bunker( + "bunker://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa?relay=wss%3A%2F%2Fradroots.org&secret=shared-secret", + ) + .await?; + + let (server, request_rx) = JsonRpcServer::spawn( + Some("Bearer sdk-secret"), + json!({ + "jsonrpc": "2.0", + "id": "radroots-sdk-nip46-session-require-auth", + "result": { + "required": true + } + }), + ) + .await?; + let client = radrootsd_test_client(server.endpoint())?; + let result = client + .radrootsd() + .signer_sessions() + .require_auth(handle.session(), "https://radroots.org/auth") + .await?; + let request_json = request_rx.await?; + + assert_eq!(request_json["method"], "nip46.session.require_auth"); + assert_eq!(request_json["params"]["session_id"], "session-123"); + assert_eq!( + request_json["params"]["auth_url"], + "https://radroots.org/auth" + ); + assert!(result.required); + + Ok(()) +} + +#[tokio::test] +async fn radrootsd_signer_session_close_returns_typed_result() -> TestResult<()> { + let (connect_server, _) = JsonRpcServer::spawn( + Some("Bearer sdk-secret"), + json!({ + "jsonrpc": "2.0", + "id": "radroots-sdk-nip46-connect", + "result": { + "session_id": "session-123", + "mode": "Bunker", + "remote_signer_pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "client_pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "relays": ["wss://radroots.org"] + } + }), + ) + .await?; + let connect_client = radrootsd_test_client(connect_server.endpoint())?; + let handle: SdkRadrootsdSignerSessionHandle = connect_client + .radrootsd() + .signer_sessions() + .connect_bunker( + "bunker://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa?relay=wss%3A%2F%2Fradroots.org&secret=shared-secret", + ) + .await?; + + let (server, request_rx) = JsonRpcServer::spawn( + Some("Bearer sdk-secret"), + json!({ + "jsonrpc": "2.0", + "id": "radroots-sdk-nip46-session-close", + "result": { + "closed": true + } + }), + ) + .await?; + let client = radrootsd_test_client(server.endpoint())?; + let result = client + .radrootsd() + .signer_sessions() + .close(handle.session()) + .await?; + let request_json = request_rx.await?; + + assert_eq!(request_json["method"], "nip46.session.close"); + assert_eq!(request_json["params"]["session_id"], "session-123"); + assert!(result.closed); + + Ok(()) +} + +#[tokio::test] +async fn radrootsd_signer_session_connect_rejects_relay_transport_mode() -> TestResult<()> { + let client = RadrootsSdkClient::from_config(RadrootsSdkConfig::production())?; + let request = SdkRadrootsdSignerSessionConnectRequest::bunker( + "bunker://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa?relay=wss%3A%2F%2Fradroots.org&secret=shared-secret", + ); + + let error = client + .radrootsd() + .signer_sessions() + .connect(&request) + .await + .expect_err("unsupported transport"); + + assert!(matches!( + error, + SdkRadrootsdSessionError::UnsupportedTransport { + transport: SdkTransportMode::RelayDirect, + operation: "radrootsd.signer_sessions.connect", + } + )); + + Ok(()) +} + +#[tokio::test] +async fn radrootsd_listing_publish_accepts_sdk_built_draft() -> TestResult<()> { + let (server, request_rx) = JsonRpcServer::spawn( + Some("Bearer sdk-secret"), + json!({ + "jsonrpc": "2.0", + "id": "radroots-sdk-listing-publish", + "result": { + "deduplicated": false, + "job": { + "job_id": "job-1", + "command": "bridge.listing.publish", + "status": "published", + "terminal": true, + "recovered_after_restart": false, + "signer_mode": "nip46_session:session-123", + "signer_session_id": "session-123", + "event_kind": 30402, + "event_id": "event-1", + "event_addr": "30402:seller:listing-1", + "relay_count": 1, + "acknowledged_relay_count": 1 + } + } + }), + ) + .await?; + + let handle = connected_bunker_session_handle("session-123").await?; + let client = radrootsd_test_client(server.endpoint())?; + let draft = client.listing().build_draft(&sample_listing())?; + let options = SdkRadrootsdListingPublishOptions::from_signer_session(&handle) + .with_idempotency_key("idem-1"); + + let receipt = client + .listing() + .publish_draft_via_radrootsd_with_options(draft, &options) + .await?; + let request_json = request_rx.await?; + + assert_eq!(request_json["method"], "bridge.listing.publish"); + assert_eq!(request_json["params"]["signer_session_id"], "session-123"); + assert_eq!(request_json["params"]["idempotency_key"], "idem-1"); + assert_eq!(request_json["params"]["kind"], 30402); + assert_eq!( + request_json["params"]["listing"]["d_tag"], + "AAAAAAAAAAAAAAAAAAAAAg" + ); + + assert_eq!(receipt.transport, SdkTransportMode::Radrootsd); + assert_eq!(receipt.event_kind, Some(30402)); + assert_eq!(receipt.event_id, Some("event-1".to_owned())); + match receipt.transport_receipt { + SdkTransportReceipt::Radrootsd(rpc_receipt) => { + assert!(rpc_receipt.accepted); + assert!(!rpc_receipt.deduplicated); + assert_eq!(rpc_receipt.job_id.as_deref(), Some("job-1")); + assert_eq!(rpc_receipt.status.as_deref(), Some("published")); + assert_eq!( + rpc_receipt.signer_session_id.as_deref(), + Some("session-123") + ); + assert_eq!( + rpc_receipt.event_addr.as_deref(), + Some("30402:seller:listing-1") + ); + assert_eq!(rpc_receipt.relay_count, Some(1)); + assert_eq!(rpc_receipt.acknowledged_relay_count, Some(1)); + } + SdkTransportReceipt::RelayDirect(_) => panic!("unexpected relay receipt"), + } + + Ok(()) +} + +#[tokio::test] +async fn radrootsd_listing_publish_accepts_typed_listing_value() -> TestResult<()> { + let (server, request_rx) = JsonRpcServer::spawn( + Some("Bearer sdk-secret"), + json!({ + "jsonrpc": "2.0", + "id": "radroots-sdk-listing-publish", + "result": { + "deduplicated": false, + "job": { + "job_id": "job-2", + "command": "bridge.listing.publish", + "status": "published", + "terminal": true, + "recovered_after_restart": false, + "signer_mode": "nip46_session:session-456", + "signer_session_id": "session-456", + "event_kind": 30402, + "event_id": "event-2", + "event_addr": "30402:seller:listing-2", + "relay_count": 1, + "acknowledged_relay_count": 1 + } + } + }), + ) + .await?; + + let handle = connected_bunker_session_handle("session-456").await?; + let client = radrootsd_test_client(server.endpoint())?; + + let receipt = client + .listing() + .publish_listing_via_radrootsd(&sample_listing(), &handle) + .await?; + let request_json = request_rx.await?; + + assert_eq!(request_json["method"], "bridge.listing.publish"); + assert_eq!(request_json["params"]["signer_session_id"], "session-456"); + assert!(request_json["params"]["idempotency_key"].is_null()); + assert_eq!(request_json["params"]["kind"], 30402); + assert_eq!( + request_json["params"]["listing"]["d_tag"], + "AAAAAAAAAAAAAAAAAAAAAg" + ); + + assert_eq!(receipt.transport, SdkTransportMode::Radrootsd); + assert_eq!(receipt.event_kind, Some(30402)); + assert_eq!(receipt.event_id, Some("event-2".to_owned())); + + Ok(()) +} + +#[tokio::test] +async fn radrootsd_profile_publish_accepts_typed_profile_value() -> TestResult<()> { + let (server, request_rx) = JsonRpcServer::spawn( + Some("Bearer sdk-secret"), + json!({ + "jsonrpc": "2.0", + "id": "radroots-sdk-profile-publish", + "result": { + "deduplicated": false, + "job": { + "job_id": "job-profile-1", + "command": "bridge.profile.publish", + "status": "published", + "terminal": true, + "recovered_after_restart": false, + "signer_mode": "nip46_session:session-profile-1", + "signer_session_id": "session-profile-1", + "event_kind": 0, + "event_id": "event-profile-1", + "relay_count": 1, + "acknowledged_relay_count": 1 + } + } + }), + ) + .await?; + + let handle = connected_bunker_session_handle("session-profile-1").await?; + let client = radrootsd_test_client(server.endpoint())?; + let options = SdkRadrootsdProfilePublishOptions::from_signer_session(&handle) + .with_idempotency_key("profile-idem-1") + .with_signer_authority(SdkRadrootsdSignerAuthority { + provider_runtime_id: "runtime-profile".to_owned(), + account_identity_id: "identity-profile".to_owned(), + provider_signer_session_id: Some("provider-session-profile".to_owned()), + }); + + let receipt = client + .profile() + .publish_profile_via_radrootsd_with_options( + &sample_profile(), + Some(RadrootsProfileType::Farm), + &options, + ) + .await?; + let request_json = request_rx.await?; + + assert_eq!(request_json["method"], "bridge.profile.publish"); + assert_eq!( + request_json["params"]["signer_session_id"], + "session-profile-1" + ); + assert_eq!(request_json["params"]["profile_type"], "farm"); + assert_eq!(request_json["params"]["profile"]["name"], "North Farm"); + assert_eq!(request_json["params"]["idempotency_key"], "profile-idem-1"); + assert_eq!( + request_json["params"]["signer_authority"]["provider_runtime_id"], + "runtime-profile" + ); + assert_eq!(receipt.event_kind, Some(KIND_PROFILE)); + assert_eq!(receipt.event_id, Some("event-profile-1".to_owned())); + + Ok(()) +} + +#[tokio::test] +async fn radrootsd_farm_publish_accepts_typed_farm_value() -> TestResult<()> { + let (server, request_rx) = JsonRpcServer::spawn( + Some("Bearer sdk-secret"), + json!({ + "jsonrpc": "2.0", + "id": "radroots-sdk-farm-publish", + "result": { + "deduplicated": false, + "job": { + "job_id": "job-farm-1", + "command": "bridge.farm.publish", + "status": "published", + "terminal": true, + "recovered_after_restart": false, + "signer_mode": "nip46_session:session-farm-1", + "signer_session_id": "session-farm-1", + "event_kind": 30340, + "event_id": "event-farm-1", + "event_addr": "30340:seller:AAAAAAAAAAAAAAAAAAAAAA", + "relay_count": 1, + "acknowledged_relay_count": 1 + } + } + }), + ) + .await?; + + let handle = connected_bunker_session_handle("session-farm-1").await?; + let client = radrootsd_test_client(server.endpoint())?; + let options = SdkRadrootsdFarmPublishOptions::from_signer_session(&handle) + .with_idempotency_key("farm-idem-1"); + + let receipt = client + .farm() + .publish_farm_via_radrootsd_with_options(&sample_farm(), &options) + .await?; + let request_json = request_rx.await?; + + assert_eq!(request_json["method"], "bridge.farm.publish"); + assert_eq!( + request_json["params"]["signer_session_id"], + "session-farm-1" + ); + assert_eq!(request_json["params"]["kind"], KIND_FARM); + assert_eq!( + request_json["params"]["farm"]["d_tag"], + "AAAAAAAAAAAAAAAAAAAAAA" + ); + assert_eq!(request_json["params"]["idempotency_key"], "farm-idem-1"); + assert_eq!(receipt.event_kind, Some(KIND_FARM)); + assert_eq!(receipt.event_id, Some("event-farm-1".to_owned())); + match receipt.transport_receipt { + SdkTransportReceipt::Radrootsd(receipt) => { + assert_eq!( + receipt.event_addr, + Some("30340:seller:AAAAAAAAAAAAAAAAAAAAAA".to_owned()) + ); + } + SdkTransportReceipt::RelayDirect(_) => panic!("unexpected relay receipt"), + } + + Ok(()) +} + +#[tokio::test] +async fn radrootsd_listing_publish_with_options_forwards_typed_continuity_metadata() +-> TestResult<()> { + let (server, request_rx) = JsonRpcServer::spawn( + Some("Bearer sdk-secret"), + json!({ + "jsonrpc": "2.0", + "id": "radroots-sdk-listing-publish", + "result": { + "deduplicated": false, + "job": { + "job_id": "job-3", + "command": "bridge.listing.publish", + "status": "published", + "terminal": true, + "recovered_after_restart": false, + "signer_mode": "nip46_session:session-789", + "signer_session_id": "session-789", + "event_kind": 30402, + "event_id": "event-3", + "event_addr": "30402:seller:listing-3", + "relay_count": 1, + "acknowledged_relay_count": 1 + } + } + }), + ) + .await?; + + let handle = connected_bunker_session_handle("session-789").await?; + let client = radrootsd_test_client(server.endpoint())?; + let options = SdkRadrootsdListingPublishOptions::from_signer_session(&handle) + .with_idempotency_key("idem-3") + .with_signer_authority(SdkRadrootsdSignerAuthority { + provider_runtime_id: "runtime-1".to_owned(), + account_identity_id: "identity-1".to_owned(), + provider_signer_session_id: Some("provider-session-123".to_owned()), + }); + + let receipt = client + .listing() + .publish_listing_via_radrootsd_with_options(&sample_listing(), &options) + .await?; + let request_json = request_rx.await?; + + assert_eq!(request_json["method"], "bridge.listing.publish"); + assert_eq!(request_json["params"]["signer_session_id"], "session-789"); + assert_eq!(request_json["params"]["idempotency_key"], "idem-3"); + assert_eq!( + request_json["params"]["signer_authority"]["provider_runtime_id"], + "runtime-1" + ); + assert_eq!( + request_json["params"]["signer_authority"]["account_identity_id"], + "identity-1" + ); + assert_eq!( + request_json["params"]["signer_authority"]["provider_signer_session_id"], + "provider-session-123" + ); + assert_eq!(receipt.event_id, Some("event-3".to_owned())); + + Ok(()) +} + +#[tokio::test] +async fn radrootsd_listing_publish_rejects_draft_only_signer_mode() -> TestResult<()> { + let mut config = RadrootsSdkConfig::for_environment(SdkEnvironment::Production); + config.transport = SdkTransportMode::Radrootsd; + config.signer = SignerConfig::DraftOnly; + let client = RadrootsSdkClient::from_config(config)?; + let handle = connected_bunker_session_handle("session-123").await?; + + let error = client + .listing() + .publish_listing_via_radrootsd(&sample_listing(), &handle) + .await + .expect_err("unsupported signer mode"); + + assert!(matches!( + error, + SdkPublishError::UnsupportedSignerMode { + transport: SdkTransportMode::Radrootsd, + signer: SignerConfig::DraftOnly, + required: SignerConfig::Nip46, + operation: "listing.publish_via_radrootsd", + } + )); + + Ok(()) +} + +#[tokio::test] +async fn radrootsd_listing_publish_rejects_local_identity_signer_mode() -> TestResult<()> { + let mut config = RadrootsSdkConfig::for_environment(SdkEnvironment::Production); + config.transport = SdkTransportMode::Radrootsd; + config.signer = SignerConfig::LocalIdentity; + let client = RadrootsSdkClient::from_config(config)?; + let handle = connected_bunker_session_handle("session-123").await?; + + let error = client + .listing() + .publish_listing_via_radrootsd(&sample_listing(), &handle) + .await + .expect_err("unsupported signer mode"); + + assert!(matches!( + error, + SdkPublishError::UnsupportedSignerMode { + transport: SdkTransportMode::Radrootsd, + signer: SignerConfig::LocalIdentity, + required: SignerConfig::Nip46, + operation: "listing.publish_via_radrootsd", + } + )); + + Ok(()) +} + +#[tokio::test] +async fn radrootsd_listing_publish_rejects_relay_transport_mode() -> TestResult<()> { + let client = RadrootsSdkClient::from_config(RadrootsSdkConfig::production())?; + let handle = connected_bunker_session_handle("session-123").await?; + + let error = client + .listing() + .publish_listing_via_radrootsd(&sample_listing(), &handle) + .await + .expect_err("unsupported transport"); + + assert!(matches!( + error, + SdkPublishError::UnsupportedTransport { + transport: SdkTransportMode::RelayDirect, + operation: "listing.publish_via_radrootsd", + } + )); + + Ok(()) +} + +#[tokio::test] +async fn radrootsd_order_request_publish_accepts_session_handle() -> TestResult<()> { + let (server, request_rx) = JsonRpcServer::spawn( + Some("Bearer sdk-secret"), + json!({ + "jsonrpc": "2.0", + "id": "radroots-sdk-order-request-publish", + "result": { + "deduplicated": false, + "job": { + "job_id": "job-order-1", + "command": "bridge.order.request", + "status": "published", + "terminal": true, + "recovered_after_restart": false, + "signer_mode": "nip46_session:session-order-1", + "signer_session_id": "session-order-1", + "event_kind": KIND_ORDER_REQUEST, + "event_id": "event-order-1", + "event_addr": format!("{KIND_LISTING}:{}:AAAAAAAAAAAAAAAAAAAAAg", "a".repeat(64)), + "relay_count": 1, + "acknowledged_relay_count": 1 + } + } + }), + ) + .await?; + + let handle = connected_bunker_session_handle("session-order-1").await?; + let client = radrootsd_test_client(server.endpoint())?; + let options = SdkRadrootsdOrderRequestPublishOptions::from_signer_session(&handle) + .with_idempotency_key("idem-order-1") + .with_signer_authority(SdkRadrootsdSignerAuthority { + provider_runtime_id: "runtime-1".to_owned(), + account_identity_id: "identity-1".to_owned(), + provider_signer_session_id: Some("provider-session-order-1".to_owned()), + }); + + let receipt = client + .order() + .publish_order_request_via_radrootsd_with_options( + &sample_order_request(), + &listing_event_ptr_with_relays(Some("wss://radroots.org")), + &options, + ) + .await?; + let request_json = request_rx.await?; + + assert_eq!(request_json["method"], "bridge.order.request"); + assert_eq!( + request_json["params"]["signer_session_id"], + "session-order-1" + ); + assert_eq!(request_json["params"]["idempotency_key"], "idem-order-1"); + assert_eq!(request_json["params"]["order"]["order_id"], "order-1"); + assert_eq!( + request_json["params"]["listing_event"]["id"], + "a".repeat(64) + ); + assert_eq!( + request_json["params"]["listing_event"]["relays"], + "wss://radroots.org" + ); + assert_eq!( + request_json["params"]["signer_authority"]["provider_runtime_id"], + "runtime-1" + ); + assert_eq!( + request_json["params"]["signer_authority"]["provider_signer_session_id"], + "provider-session-order-1" + ); + assert_eq!(receipt.event_kind, Some(KIND_ORDER_REQUEST)); + assert_eq!(receipt.event_id, Some("event-order-1".to_owned())); + + Ok(()) +} + +#[tokio::test] +async fn radrootsd_sdk_workflow_chains_session_listing_order_and_bridge_job() -> TestResult<()> { + let (server, mut request_rx) = JsonRpcSequenceServer::spawn( + Some("Bearer sdk-secret"), + vec![ + json!({ + "jsonrpc": "2.0", + "id": "radroots-sdk-nip46-connect", + "result": { + "session_id": "session-workflow-1", + "mode": "Bunker", + "remote_signer_pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "client_pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "relays": ["wss://radroots.org"] + } + }), + json!({ + "jsonrpc": "2.0", + "id": "radroots-sdk-listing-publish", + "result": { + "deduplicated": false, + "job": { + "job_id": "job-workflow-listing", + "command": "bridge.listing.publish", + "status": "published", + "terminal": true, + "recovered_after_restart": false, + "signer_mode": "nip46_session:session-workflow-1", + "signer_session_id": "session-workflow-1", + "event_kind": 30402, + "event_id": "event-workflow-listing", + "event_addr": "30402:seller:listing-workflow-1", + "relay_count": 1, + "acknowledged_relay_count": 1 + } + } + }), + json!({ + "jsonrpc": "2.0", + "id": "radroots-sdk-order-request-publish", + "result": { + "deduplicated": false, + "job": { + "job_id": "job-workflow-order", + "command": "bridge.order.request", + "status": "published", + "terminal": true, + "recovered_after_restart": false, + "signer_mode": "nip46_session:session-workflow-1", + "signer_session_id": "session-workflow-1", + "event_kind": KIND_ORDER_REQUEST, + "event_id": "event-workflow-order", + "event_addr": format!("{KIND_LISTING}:{}:AAAAAAAAAAAAAAAAAAAAAg", "a".repeat(64)), + "relay_count": 1, + "acknowledged_relay_count": 1 + } + } + }), + json!({ + "jsonrpc": "2.0", + "id": "radroots-sdk-bridge-job-status", + "result": sample_bridge_job_json_for( + "job-workflow-order", + "bridge.order.request", + KIND_ORDER_REQUEST, + ) + }), + ], + ) + .await?; + + let client = radrootsd_test_client(server.endpoint())?; + let handle = client + .radrootsd() + .signer_sessions() + .connect_bunker( + "bunker://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa?relay=wss%3A%2F%2Fradroots.org&secret=shared-secret", + ) + .await?; + assert_eq!(handle.mode(), SdkRadrootsdSignerSessionMode::Bunker); + + let connect_request = request_rx.recv().await.expect("connect request"); + assert_eq!(connect_request["method"], "nip46.connect"); + + let listing_receipt = client + .listing() + .publish_listing_via_radrootsd(&sample_listing(), &handle) + .await?; + let listing_request = request_rx.recv().await.expect("listing publish request"); + assert_eq!(listing_request["method"], "bridge.listing.publish"); + assert_eq!( + listing_request["params"]["signer_session_id"], + "session-workflow-1" + ); + + let order_receipt = client + .order() + .publish_order_request_via_radrootsd( + &sample_order_request(), + &listing_event_ptr_with_relays(Some("wss://radroots.org")), + &handle, + ) + .await?; + let order_request = request_rx.recv().await.expect("order publish request"); + assert_eq!(order_request["method"], "bridge.order.request"); + assert_eq!( + order_request["params"]["signer_session_id"], + "session-workflow-1" + ); + assert_eq!(order_request["params"]["order"]["order_id"], "order-1"); + assert_eq!( + order_request["params"]["listing_event"]["id"], + "a".repeat(64) + ); + + let order_job = match &order_receipt.transport_receipt { + SdkTransportReceipt::Radrootsd(receipt) => receipt.job(), + SdkTransportReceipt::RelayDirect(_) => None, + } + .expect("order publish receipt should expose a bridge job ref"); + + let job_view = client.radrootsd().bridge().job(&order_job).await?; + let job_request = request_rx.recv().await.expect("bridge job request"); + assert_eq!(job_request["method"], "bridge.job.status"); + assert_eq!(job_request["params"]["job_id"], "job-workflow-order"); + + assert_eq!(listing_receipt.event_kind, Some(30402)); + assert_eq!(order_receipt.event_kind, Some(KIND_ORDER_REQUEST)); + assert_eq!(job_view.job().job_id(), "job-workflow-order"); + assert_eq!(job_view.command, "bridge.order.request"); + assert_eq!(job_view.status, SdkRadrootsdBridgeJobStatus::Published); + + Ok(()) +} + +#[tokio::test] +async fn radrootsd_bridge_status_returns_typed_status() -> TestResult<()> { + let (server, request_rx) = JsonRpcServer::spawn( + Some("Bearer sdk-secret"), + json!({ + "jsonrpc": "2.0", + "id": "radroots-sdk-bridge-status", + "result": sample_bridge_status_json() + }), + ) + .await?; + let client = radrootsd_test_client(server.endpoint())?; + let status = client.radrootsd().bridge().status().await?; + let request_json = request_rx.await?; + + assert_eq!(request_json["method"], "bridge.status"); + assert!(status.enabled); + assert!(status.ready); + assert_eq!( + status.delivery_policy, + SdkRadrootsdBridgeDeliveryPolicy::Quorum + ); + assert_eq!(status.delivery_quorum, Some(1)); + assert_eq!(status.available_nip46_signer_sessions, 2); + assert!( + status + .methods + .contains(&"bridge.listing.publish".to_owned()) + ); + + Ok(()) +} + +#[tokio::test] +async fn radrootsd_bridge_job_status_accepts_typed_job_ref_from_publish_receipt() -> TestResult<()> +{ + let (publish_server, publish_request_rx) = JsonRpcServer::spawn( + Some("Bearer sdk-secret"), + json!({ + "jsonrpc": "2.0", + "id": "radroots-sdk-listing-publish", + "result": { + "deduplicated": false, + "job": { + "job_id": "job-bridge-1", + "command": "bridge.listing.publish", + "status": "published", + "terminal": true, + "recovered_after_restart": false, + "signer_mode": "nip46_session:session-123", + "signer_session_id": "session-123", + "event_kind": 30402, + "event_id": "event-bridge-1", + "event_addr": "30402:seller:listing-bridge-1", + "relay_count": 1, + "acknowledged_relay_count": 1 + } + } + }), + ) + .await?; + let handle = connected_bunker_session_handle("session-123").await?; + let publish_client = radrootsd_test_client(publish_server.endpoint())?; + let publish_receipt = publish_client + .listing() + .publish_listing_via_radrootsd(&sample_listing(), &handle) + .await?; + let publish_request_json = publish_request_rx.await?; + assert_eq!(publish_request_json["method"], "bridge.listing.publish"); + + let job = match &publish_receipt.transport_receipt { + SdkTransportReceipt::Radrootsd(receipt) => receipt.job(), + SdkTransportReceipt::RelayDirect(_) => None, + } + .expect("publish receipt should expose a bridge job ref"); + + let (job_server, request_rx) = JsonRpcServer::spawn( + Some("Bearer sdk-secret"), + json!({ + "jsonrpc": "2.0", + "id": "radroots-sdk-bridge-job-status", + "result": sample_bridge_job_json("job-bridge-1") + }), + ) + .await?; + let job_client = radrootsd_test_client(job_server.endpoint())?; + let job_view = job_client.radrootsd().bridge().job(&job).await?; + let request_json = request_rx.await?; + + assert_eq!(request_json["method"], "bridge.job.status"); + assert_eq!(request_json["params"]["job_id"], "job-bridge-1"); + assert_eq!(job_view.job().job_id(), "job-bridge-1"); + assert_eq!(job_view.status, SdkRadrootsdBridgeJobStatus::Published); + assert_eq!( + job_view.delivery_policy, + SdkRadrootsdBridgeDeliveryPolicy::Quorum + ); + assert_eq!(job_view.attempt_count, 1); + assert_eq!(job_view.relay_results.len(), 2); + assert_eq!(job_view.relay_results[0].relay_url, "wss://radroots.org"); + assert!(job_view.relay_results[0].acknowledged); + + Ok(()) +} + +#[tokio::test] +async fn radrootsd_bridge_job_list_returns_typed_views() -> TestResult<()> { + let (server, request_rx) = JsonRpcServer::spawn( + Some("Bearer sdk-secret"), + json!({ + "jsonrpc": "2.0", + "id": "radroots-sdk-bridge-job-list", + "result": [ + sample_bridge_job_json("job-bridge-1"), + sample_bridge_job_json("job-bridge-2") + ] + }), + ) + .await?; + let client = radrootsd_test_client(server.endpoint())?; + let jobs = client.radrootsd().bridge().jobs().await?; + let request_json = request_rx.await?; + + assert_eq!(request_json["method"], "bridge.job.list"); + assert_eq!(jobs.len(), 2); + assert_eq!(jobs[0].job().job_id(), "job-bridge-1"); + assert_eq!(jobs[1].job().job_id(), "job-bridge-2"); + assert_eq!(jobs[0].status, SdkRadrootsdBridgeJobStatus::Published); + + Ok(()) +} + +#[tokio::test] +async fn radrootsd_bridge_status_rejects_relay_transport_mode() -> TestResult<()> { + let client = RadrootsSdkClient::from_config(RadrootsSdkConfig::production())?; + let error = client + .radrootsd() + .bridge() + .status() + .await + .expect_err("unsupported transport"); + + assert!(matches!( + error, + SdkRadrootsdBridgeError::UnsupportedTransport { + transport: SdkTransportMode::RelayDirect, + operation: "radrootsd.bridge.status", + } + )); + + Ok(()) +} + +#[test] +fn radrootsd_listing_request_from_event_rejects_listing_draft_kind() -> TestResult<()> { + let draft = radroots_sdk::listing::build_draft(&sample_listing())?; + let mut event = sdk_event("seller", 1_720_000_000, draft); + event.kind = KIND_LISTING_DRAFT; + + assert!(matches!( + SdkRadrootsdListingPublishRequest::from_event(&event, "session-123", None, None), + Err(RadrootsListingParseError::InvalidKind(KIND_LISTING_DRAFT)) + )); + + Ok(()) +} diff --git a/crates/sdk/tests/relay_direct.rs b/crates/sdk/tests/relay_direct.rs @@ -0,0 +1,1406 @@ +#![cfg(all( + feature = "identity-models", + feature = "relay-client", + feature = "signing" +))] + +use futures::{SinkExt, StreamExt}; +use nostr::{ClientMessage, JsonUtil, RelayMessage}; +use radroots_core::{ + RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, + RadrootsCoreQuantityPrice, RadrootsCoreUnit, +}; +use radroots_events::ids::{RadrootsEventId, RadrootsPublicKey}; +use radroots_sdk::farm::{RadrootsFarm, RadrootsFarmLocation, RadrootsFarmRef}; +use radroots_sdk::identity::RadrootsIdentity; +use radroots_sdk::listing::{ + RadrootsListing, RadrootsListingAvailability, RadrootsListingBin, + RadrootsListingDeliveryMethod, RadrootsListingLocation, RadrootsListingProduct, + RadrootsListingStatus, +}; +use radroots_sdk::order::{ + RadrootsOrderCancellation, RadrootsOrderDecision, RadrootsOrderDecisionOutcome, + RadrootsOrderEconomicItem, RadrootsOrderEconomics, RadrootsOrderFulfillmentState, + RadrootsOrderFulfillmentUpdate, RadrootsOrderInventoryCommitment, RadrootsOrderItem, + RadrootsOrderPricingBasis, RadrootsOrderReceipt, RadrootsOrderRequest, + RadrootsOrderRevisionDecision, RadrootsOrderRevisionOutcome, RadrootsOrderRevisionProposal, +}; +use radroots_sdk::profile::{RadrootsProfile, RadrootsProfileType}; +use radroots_sdk::{ + RadrootsNostrEventPtr, RadrootsSdkClient, RadrootsSdkConfig, RelayConfig, SdkEnvironment, + SdkPublishError, SdkTransportMode, SdkTransportReceipt, SignerConfig, +}; +use tokio::net::TcpListener; +use tokio::sync::oneshot; +use tokio_tungstenite::tungstenite::Message; + +type TestResult<T> = Result<T, Box<dyn std::error::Error + Send + Sync>>; + +struct AckRelay { + url: String, + shutdown_tx: Option<oneshot::Sender<()>>, +} + +impl AckRelay { + async fn spawn() -> TestResult<Self> { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let url = format!("ws://{addr}"); + let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); + + tokio::spawn(async move { + loop { + tokio::select! { + _ = &mut shutdown_rx => break, + accept = listener.accept() => { + let Ok((stream, _)) = accept else { + break; + }; + tokio::spawn(async move { + let Ok(websocket) = tokio_tungstenite::accept_async(stream).await else { + return; + }; + let (mut writer, mut reader) = websocket.split(); + while let Some(message) = reader.next().await { + let Ok(message) = message else { + break; + }; + let Message::Text(text) = message else { + continue; + }; + let Ok(client_message) = ClientMessage::from_json(text.as_str()) else { + continue; + }; + if let ClientMessage::Event(event) = client_message { + let relay_message = + RelayMessage::ok(event.id, true, "").as_json(); + if writer + .send(Message::Text(relay_message.into())) + .await + .is_err() + { + break; + } + } + } + }); + } + } + } + }); + + Ok(Self { + url, + shutdown_tx: Some(shutdown_tx), + }) + } + + fn url(&self) -> &str { + self.url.as_str() + } +} + +impl Drop for AckRelay { + fn drop(&mut self) { + if let Some(shutdown_tx) = self.shutdown_tx.take() { + let _ = shutdown_tx.send(()); + } + } +} + +fn sample_listing() -> RadrootsListing { + RadrootsListing { + d_tag: "AAAAAAAAAAAAAAAAAAAAAg".parse().expect("listing d tag"), + published_at: None, + farm: RadrootsFarmRef { + pubkey: "seller".into(), + d_tag: "AAAAAAAAAAAAAAAAAAAAAA".into(), + }, + product: RadrootsListingProduct { + key: "coffee".into(), + title: "Coffee".into(), + category: "coffee".into(), + summary: Some("Single origin coffee".into()), + process: None, + lot: None, + location: None, + profile: None, + year: None, + }, + primary_bin_id: "bin-1".parse().expect("primary bin id"), + bins: vec![RadrootsListingBin { + bin_id: "bin-1".parse().expect("bin id"), + quantity: RadrootsCoreQuantity::new( + RadrootsCoreDecimal::from(1000u32), + RadrootsCoreUnit::MassG, + ), + price_per_canonical_unit: RadrootsCoreQuantityPrice { + amount: RadrootsCoreMoney::new( + RadrootsCoreDecimal::from(20u32), + RadrootsCoreCurrency::USD, + ), + quantity: RadrootsCoreQuantity::new( + RadrootsCoreDecimal::from(1u32), + RadrootsCoreUnit::MassG, + ), + }, + display_amount: None, + display_unit: None, + display_label: None, + display_price: None, + display_price_unit: None, + }], + resource_area: None, + plot: None, + discounts: None, + inventory_available: Some(RadrootsCoreDecimal::from(5u32)), + availability: Some(RadrootsListingAvailability::Status { + status: RadrootsListingStatus::Active, + }), + delivery_method: Some(RadrootsListingDeliveryMethod::Pickup), + location: Some(RadrootsListingLocation { + primary: "North Farm".into(), + city: None, + region: None, + country: None, + lat: None, + lng: None, + geohash: None, + }), + images: None, + } +} + +fn sample_profile() -> RadrootsProfile { + RadrootsProfile { + name: "north-farm".into(), + display_name: Some("North Farm".into()), + nip05: None, + about: Some("Farm profile".into()), + website: None, + picture: None, + banner: None, + lud06: None, + lud16: None, + bot: None, + } +} + +fn sample_farm() -> RadrootsFarm { + RadrootsFarm { + d_tag: "AAAAAAAAAAAAAAAAAAAAAA".into(), + name: "North Farm".into(), + about: Some("Vegetable farm".into()), + website: None, + picture: None, + banner: None, + location: Some(RadrootsFarmLocation { + primary: Some("North Road".into()), + city: None, + region: None, + country: Some("US".into()), + gcs: None, + }), + tags: Some(vec!["vegetables".into()]), + } +} + +fn decimal(raw: &str) -> RadrootsCoreDecimal { + raw.parse().expect("decimal") +} + +fn usd(raw: &str) -> RadrootsCoreMoney { + RadrootsCoreMoney::new(decimal(raw), RadrootsCoreCurrency::USD) +} + +fn listing_event_ptr() -> RadrootsNostrEventPtr { + RadrootsNostrEventPtr { + id: event_id_wire('a'), + relays: Some("wss://listing.relay.example".into()), + } +} + +fn public_key(value: String) -> RadrootsPublicKey { + value.parse().expect("public key") +} + +fn event_id(character: char) -> RadrootsEventId { + core::iter::repeat_n(character, 64) + .collect::<String>() + .parse() + .expect("event id") +} + +fn event_id_wire(character: char) -> String { + event_id(character).into_string() +} + +fn sample_order_request(buyer_pubkey: String, seller_pubkey: String) -> RadrootsOrderRequest { + let buyer_pubkey = public_key(buyer_pubkey); + let seller_pubkey = public_key(seller_pubkey); + RadrootsOrderRequest { + order_id: "order-1".parse().expect("order id"), + listing_addr: format!("30402:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAg") + .parse() + .expect("listing address"), + buyer_pubkey, + seller_pubkey, + items: vec![RadrootsOrderItem { + bin_id: "bin-1".parse().expect("bin id"), + bin_count: 2, + }], + economics: RadrootsOrderEconomics { + quote_id: "quote-1".parse().expect("quote id"), + quote_version: 1, + pricing_basis: RadrootsOrderPricingBasis::ListingEvent, + currency: RadrootsCoreCurrency::USD, + items: vec![RadrootsOrderEconomicItem { + bin_id: "bin-1".parse().expect("bin id"), + bin_count: 2, + quantity_amount: decimal("1"), + quantity_unit: RadrootsCoreUnit::Each, + unit_price_amount: decimal("5"), + unit_price_currency: RadrootsCoreCurrency::USD, + line_subtotal: usd("10"), + }], + discounts: Vec::new(), + adjustments: Vec::new(), + subtotal: usd("10"), + discount_total: usd("0"), + adjustment_total: usd("0"), + total: usd("10"), + }, + } +} + +fn sample_order_decision(buyer_pubkey: String, seller_pubkey: String) -> RadrootsOrderDecision { + let buyer_pubkey = public_key(buyer_pubkey); + let seller_pubkey = public_key(seller_pubkey); + RadrootsOrderDecision { + order_id: "order-1".parse().expect("order id"), + listing_addr: format!("30402:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAg") + .parse() + .expect("listing address"), + buyer_pubkey, + seller_pubkey, + decision: RadrootsOrderDecisionOutcome::Accepted { + inventory_commitments: vec![RadrootsOrderInventoryCommitment { + bin_id: "bin-1".parse().expect("bin id"), + bin_count: 2, + }], + }, + } +} + +fn sample_order_revision_proposal( + buyer_pubkey: String, + seller_pubkey: String, + root_event_id: String, + prev_event_id: String, +) -> RadrootsOrderRevisionProposal { + let buyer_pubkey = public_key(buyer_pubkey); + let seller_pubkey = public_key(seller_pubkey); + RadrootsOrderRevisionProposal { + revision_id: "revision-1".parse().expect("revision id"), + order_id: "order-1".parse().expect("order id"), + listing_addr: format!("30402:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAg") + .parse() + .expect("listing address"), + buyer_pubkey, + seller_pubkey, + root_event_id: root_event_id.parse().expect("root event id"), + prev_event_id: prev_event_id.parse().expect("previous event id"), + items: vec![RadrootsOrderItem { + bin_id: "bin-1".parse().expect("bin id"), + bin_count: 3, + }], + economics: RadrootsOrderEconomics { + quote_id: "revision-quote-1".parse().expect("revision quote id"), + quote_version: 2, + pricing_basis: RadrootsOrderPricingBasis::ListingEvent, + currency: RadrootsCoreCurrency::USD, + items: vec![RadrootsOrderEconomicItem { + bin_id: "bin-1".parse().expect("bin id"), + bin_count: 3, + quantity_amount: decimal("1"), + quantity_unit: RadrootsCoreUnit::Each, + unit_price_amount: decimal("5"), + unit_price_currency: RadrootsCoreCurrency::USD, + line_subtotal: usd("15"), + }], + discounts: Vec::new(), + adjustments: Vec::new(), + subtotal: usd("15"), + discount_total: usd("0"), + adjustment_total: usd("0"), + total: usd("15"), + }, + reason: "update count".into(), + } +} + +fn sample_order_revision_decision( + proposal: &RadrootsOrderRevisionProposal, + decision: RadrootsOrderRevisionOutcome, +) -> RadrootsOrderRevisionDecision { + RadrootsOrderRevisionDecision { + revision_id: proposal.revision_id.clone(), + order_id: proposal.order_id.clone(), + listing_addr: proposal.listing_addr.clone(), + buyer_pubkey: proposal.buyer_pubkey.clone(), + seller_pubkey: proposal.seller_pubkey.clone(), + root_event_id: proposal.root_event_id.clone(), + prev_event_id: event_id('3'), + decision, + } +} + +fn sample_fulfillment_update( + buyer_pubkey: String, + seller_pubkey: String, +) -> RadrootsOrderFulfillmentUpdate { + let buyer_pubkey = public_key(buyer_pubkey); + let seller_pubkey = public_key(seller_pubkey); + RadrootsOrderFulfillmentUpdate { + order_id: "order-1".parse().expect("order id"), + listing_addr: format!("30402:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAg") + .parse() + .expect("listing address"), + buyer_pubkey, + seller_pubkey, + status: RadrootsOrderFulfillmentState::ReadyForPickup, + } +} + +fn sample_order_cancellation( + buyer_pubkey: String, + seller_pubkey: String, +) -> RadrootsOrderCancellation { + let buyer_pubkey = public_key(buyer_pubkey); + let seller_pubkey = public_key(seller_pubkey); + RadrootsOrderCancellation { + order_id: "order-1".parse().expect("order id"), + listing_addr: format!("30402:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAg") + .parse() + .expect("listing address"), + buyer_pubkey, + seller_pubkey, + reason: "schedule changed".into(), + } +} + +fn sample_buyer_receipt(buyer_pubkey: String, seller_pubkey: String) -> RadrootsOrderReceipt { + let buyer_pubkey = public_key(buyer_pubkey); + let seller_pubkey = public_key(seller_pubkey); + RadrootsOrderReceipt { + order_id: "order-1".parse().expect("order id"), + listing_addr: format!("30402:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAg") + .parse() + .expect("listing address"), + buyer_pubkey, + seller_pubkey, + received: true, + issue: None, + received_at: 1_785_000_000, + } +} + +#[tokio::test] +async fn relay_direct_farm_publish_accepts_sdk_built_draft() -> TestResult<()> { + let relay = AckRelay::spawn().await?; + let identity = RadrootsIdentity::generate(); + let mut config = RadrootsSdkConfig::for_environment(SdkEnvironment::Custom); + config.transport = SdkTransportMode::RelayDirect; + config.signer = SignerConfig::LocalIdentity; + config.relay = RelayConfig { + urls: vec![relay.url().to_owned()], + }; + let client = RadrootsSdkClient::from_config(config)?; + let draft = client.farm().build_draft(&sample_farm())?; + + let receipt = client + .farm() + .publish_draft_with_identity(&identity, draft) + .await?; + + assert_eq!(receipt.transport, SdkTransportMode::RelayDirect); + assert_eq!(receipt.event_kind, Some(30340)); + assert!(receipt.event_id.is_some()); + match receipt.transport_receipt { + SdkTransportReceipt::RelayDirect(relay_receipt) => { + assert_eq!( + receipt.event_id.as_deref(), + Some(relay_receipt.event_id.as_str()) + ); + assert_eq!(relay_receipt.event.kind, 30340); + assert_eq!(relay_receipt.event.author, identity.public_key_hex()); + assert_eq!( + relay_receipt.event.tags, + vec![ + vec!["d".to_owned(), "AAAAAAAAAAAAAAAAAAAAAA".to_owned()], + vec!["t".to_owned(), "vegetables".to_owned()] + ] + ); + assert_eq!(relay_receipt.target_relays, vec![relay.url().to_owned()]); + assert_eq!(relay_receipt.connected_relays, vec![relay.url().to_owned()]); + assert_eq!( + relay_receipt.acknowledged_relays, + vec![relay.url().to_owned()] + ); + assert!(relay_receipt.failed_relays.is_empty()); + } + SdkTransportReceipt::Radrootsd(_) => panic!("unexpected radrootsd receipt"), + } + + Ok(()) +} + +#[tokio::test] +async fn relay_direct_order_request_publish_accepts_sdk_built_draft() -> TestResult<()> { + let relay = AckRelay::spawn().await?; + let buyer_identity = RadrootsIdentity::generate(); + let seller_identity = RadrootsIdentity::generate(); + let listing_event = listing_event_ptr(); + let payload = sample_order_request( + buyer_identity.public_key_hex(), + seller_identity.public_key_hex(), + ); + let mut config = RadrootsSdkConfig::for_environment(SdkEnvironment::Custom); + config.transport = SdkTransportMode::RelayDirect; + config.signer = SignerConfig::LocalIdentity; + config.relay = RelayConfig { + urls: vec![relay.url().to_owned()], + }; + let client = RadrootsSdkClient::from_config(config)?; + let draft = client + .order() + .build_order_request_draft(&listing_event, &payload)?; + assert_eq!(draft.as_wire_parts().kind, 3422); + + let receipt = client + .order() + .publish_order_request_draft_with_identity(&buyer_identity, draft) + .await?; + + assert_eq!(receipt.transport, SdkTransportMode::RelayDirect); + assert_eq!(receipt.event_kind, Some(3422)); + assert!(receipt.event_id.is_some()); + match receipt.transport_receipt { + SdkTransportReceipt::RelayDirect(relay_receipt) => { + assert_eq!( + receipt.event_id.as_deref(), + Some(relay_receipt.event_id.as_str()) + ); + assert_eq!(receipt.event_kind, Some(relay_receipt.event_kind)); + assert_eq!(relay_receipt.event.kind, 3422); + assert_eq!(relay_receipt.event_id, relay_receipt.event.id); + assert_eq!(relay_receipt.signature, relay_receipt.event.sig); + assert_eq!(relay_receipt.created_at, relay_receipt.event.created_at); + assert_eq!(relay_receipt.event.author, buyer_identity.public_key_hex()); + assert!( + relay_receipt + .event + .tags + .contains(&vec!["p".to_owned(), seller_identity.public_key_hex()]) + ); + assert!( + relay_receipt + .event + .tags + .contains(&vec!["a".to_owned(), payload.listing_addr.to_string()]) + ); + assert!( + relay_receipt + .event + .tags + .contains(&vec!["d".to_owned(), payload.order_id.to_string()]) + ); + assert!(relay_receipt.event.tags.contains(&vec![ + "listing_event".to_owned(), + listing_event.id.clone(), + listing_event.relays.clone().expect("listing relay") + ])); + assert_eq!(relay_receipt.target_relays, vec![relay.url().to_owned()]); + assert_eq!(relay_receipt.connected_relays, vec![relay.url().to_owned()]); + assert_eq!( + relay_receipt.acknowledged_relays, + vec![relay.url().to_owned()] + ); + assert!(relay_receipt.failed_relays.is_empty()); + let envelope = client + .order() + .parse_order_request(&relay_receipt.event) + .expect("order request"); + assert_eq!(envelope.order_id, payload.order_id); + assert_eq!(envelope.listing_addr, payload.listing_addr); + assert_eq!(envelope.payload.economics.quote_id, "quote-1"); + assert_eq!(envelope.payload.economics.total, usd("10")); + } + SdkTransportReceipt::Radrootsd(_) => panic!("unexpected radrootsd receipt"), + } + + Ok(()) +} + +#[tokio::test] +async fn relay_direct_order_decision_publish_accepts_sdk_built_draft() -> TestResult<()> { + let relay = AckRelay::spawn().await?; + let buyer_identity = RadrootsIdentity::generate(); + let seller_identity = RadrootsIdentity::generate(); + let root_event_id = event_id('1'); + let payload = sample_order_decision( + buyer_identity.public_key_hex(), + seller_identity.public_key_hex(), + ); + let mut config = RadrootsSdkConfig::for_environment(SdkEnvironment::Custom); + config.transport = SdkTransportMode::RelayDirect; + config.signer = SignerConfig::LocalIdentity; + config.relay = RelayConfig { + urls: vec![relay.url().to_owned()], + }; + let client = RadrootsSdkClient::from_config(config)?; + let draft = + client + .order() + .build_order_decision_draft(&root_event_id, &root_event_id, &payload)?; + assert_eq!(draft.as_wire_parts().kind, 3423); + + let receipt = client + .order() + .publish_order_decision_draft_with_identity(&seller_identity, draft) + .await?; + + assert_eq!(receipt.transport, SdkTransportMode::RelayDirect); + assert_eq!(receipt.event_kind, Some(3423)); + assert!(receipt.event_id.is_some()); + match receipt.transport_receipt { + SdkTransportReceipt::RelayDirect(relay_receipt) => { + assert_eq!( + receipt.event_id.as_deref(), + Some(relay_receipt.event_id.as_str()) + ); + assert_eq!(receipt.event_kind, Some(relay_receipt.event_kind)); + assert_eq!(relay_receipt.event.kind, 3423); + assert_eq!(relay_receipt.event.author, seller_identity.public_key_hex()); + assert!( + relay_receipt + .event + .tags + .contains(&vec!["p".to_owned(), buyer_identity.public_key_hex()]) + ); + assert!( + relay_receipt + .event + .tags + .contains(&vec!["a".to_owned(), payload.listing_addr.to_string()]) + ); + assert!( + relay_receipt + .event + .tags + .contains(&vec!["d".to_owned(), payload.order_id.to_string()]) + ); + assert!( + relay_receipt + .event + .tags + .contains(&vec!["e_root".to_owned(), root_event_id.to_string()]) + ); + assert!( + relay_receipt + .event + .tags + .contains(&vec!["e_prev".to_owned(), root_event_id.to_string()]) + ); + assert_eq!(relay_receipt.target_relays, vec![relay.url().to_owned()]); + assert_eq!(relay_receipt.connected_relays, vec![relay.url().to_owned()]); + assert_eq!( + relay_receipt.acknowledged_relays, + vec![relay.url().to_owned()] + ); + assert!(relay_receipt.failed_relays.is_empty()); + let envelope = client + .order() + .parse_order_decision(&relay_receipt.event) + .expect("order decision"); + assert_eq!(envelope.order_id, payload.order_id); + assert_eq!(envelope.listing_addr, payload.listing_addr); + assert_eq!(envelope.payload.decision, payload.decision); + } + SdkTransportReceipt::Radrootsd(_) => panic!("unexpected radrootsd receipt"), + } + + Ok(()) +} + +#[tokio::test] +async fn relay_direct_order_revision_publish_accepts_sdk_built_payloads() -> TestResult<()> { + let relay = AckRelay::spawn().await?; + let buyer_identity = RadrootsIdentity::generate(); + let seller_identity = RadrootsIdentity::generate(); + let buyer_pubkey = buyer_identity.public_key_hex(); + let seller_pubkey = seller_identity.public_key_hex(); + let root_event_id = event_id('1'); + let decision_event_id = event_id('2'); + let proposal = sample_order_revision_proposal( + buyer_pubkey.clone(), + seller_pubkey.clone(), + root_event_id.to_string(), + decision_event_id.to_string(), + ); + let decision = + sample_order_revision_decision(&proposal, RadrootsOrderRevisionOutcome::Accepted); + let mut config = RadrootsSdkConfig::for_environment(SdkEnvironment::Custom); + config.transport = SdkTransportMode::RelayDirect; + config.signer = SignerConfig::LocalIdentity; + config.relay = RelayConfig { + urls: vec![relay.url().to_owned()], + }; + let client = RadrootsSdkClient::from_config(config)?; + + let proposal_receipt = client + .order() + .publish_order_revision_proposal_with_identity( + &seller_identity, + &root_event_id, + &decision_event_id, + &proposal, + ) + .await?; + let decision_receipt = client + .order() + .publish_order_revision_decision_with_identity( + &buyer_identity, + &root_event_id, + &decision.prev_event_id, + &decision, + ) + .await?; + + assert_eq!(proposal_receipt.event_kind, Some(3424)); + assert_eq!(decision_receipt.event_kind, Some(3425)); + + match proposal_receipt.transport_receipt { + SdkTransportReceipt::RelayDirect(relay_receipt) => { + assert_eq!(relay_receipt.event.kind, 3424); + assert_eq!(relay_receipt.event.author, seller_pubkey); + assert!( + relay_receipt + .event + .tags + .contains(&vec!["p".to_owned(), buyer_pubkey.clone()]) + ); + assert!( + relay_receipt + .event + .tags + .contains(&vec!["e_root".to_owned(), root_event_id.to_string()]) + ); + assert!( + relay_receipt + .event + .tags + .contains(&vec!["e_prev".to_owned(), decision_event_id.to_string()]) + ); + let envelope = client + .order() + .parse_order_revision_proposal(&relay_receipt.event) + .expect("order revision proposal"); + assert_eq!(envelope.order_id, proposal.order_id); + assert_eq!(envelope.listing_addr, proposal.listing_addr); + assert_eq!(envelope.payload.revision_id, "revision-1"); + assert_eq!(envelope.payload.economics.total, usd("15")); + assert_eq!(envelope.payload.reason, "update count"); + } + SdkTransportReceipt::Radrootsd(_) => panic!("unexpected radrootsd receipt"), + } + + match decision_receipt.transport_receipt { + SdkTransportReceipt::RelayDirect(relay_receipt) => { + assert_eq!(relay_receipt.event.kind, 3425); + assert_eq!(relay_receipt.event.author, buyer_pubkey); + assert!( + relay_receipt + .event + .tags + .contains(&vec!["p".to_owned(), seller_pubkey]) + ); + assert!( + relay_receipt + .event + .tags + .contains(&vec!["e_root".to_owned(), root_event_id.to_string()]) + ); + assert!( + relay_receipt + .event + .tags + .contains(&vec!["e_prev".to_owned(), event_id_wire('3')]) + ); + let envelope = client + .order() + .parse_order_revision_decision(&relay_receipt.event) + .expect("order revision decision"); + assert_eq!(envelope.order_id, decision.order_id); + assert_eq!(envelope.listing_addr, decision.listing_addr); + assert_eq!(envelope.payload.revision_id, decision.revision_id); + assert_eq!( + envelope.payload.decision, + RadrootsOrderRevisionOutcome::Accepted + ); + } + SdkTransportReceipt::Radrootsd(_) => panic!("unexpected radrootsd receipt"), + } + + Ok(()) +} + +#[tokio::test] +async fn relay_direct_order_lifecycle_publish_accepts_sdk_built_payloads() -> TestResult<()> { + let relay = AckRelay::spawn().await?; + let buyer_identity = RadrootsIdentity::generate(); + let seller_identity = RadrootsIdentity::generate(); + let buyer_pubkey = buyer_identity.public_key_hex(); + let seller_pubkey = seller_identity.public_key_hex(); + let root_event_id = event_id('1'); + let decision_event_id = event_id('2'); + let fulfillment_event_id = event_id('4'); + let fulfillment = sample_fulfillment_update(buyer_pubkey.clone(), seller_pubkey.clone()); + let cancellation = sample_order_cancellation(buyer_pubkey.clone(), seller_pubkey.clone()); + let receipt = sample_buyer_receipt(buyer_pubkey.clone(), seller_pubkey.clone()); + let mut config = RadrootsSdkConfig::for_environment(SdkEnvironment::Custom); + config.transport = SdkTransportMode::RelayDirect; + config.signer = SignerConfig::LocalIdentity; + config.relay = RelayConfig { + urls: vec![relay.url().to_owned()], + }; + let client = RadrootsSdkClient::from_config(config)?; + + let fulfillment_receipt = client + .order() + .publish_fulfillment_update_with_identity( + &seller_identity, + &root_event_id, + &decision_event_id, + &fulfillment, + ) + .await?; + let cancellation_receipt = client + .order() + .publish_order_cancellation_with_identity( + &buyer_identity, + &root_event_id, + &root_event_id, + &cancellation, + ) + .await?; + let buyer_receipt = client + .order() + .publish_buyer_receipt_with_identity( + &buyer_identity, + &root_event_id, + &fulfillment_event_id, + &receipt, + ) + .await?; + + assert_eq!(fulfillment_receipt.event_kind, Some(3433)); + assert_eq!(cancellation_receipt.event_kind, Some(3432)); + assert_eq!(buyer_receipt.event_kind, Some(3434)); + + match fulfillment_receipt.transport_receipt { + SdkTransportReceipt::RelayDirect(relay_receipt) => { + assert_eq!(relay_receipt.event.kind, 3433); + assert_eq!(relay_receipt.event.author, seller_pubkey); + assert!( + relay_receipt + .event + .tags + .contains(&vec!["p".to_owned(), buyer_pubkey.clone()]) + ); + assert!( + relay_receipt + .event + .tags + .contains(&vec!["e_root".to_owned(), root_event_id.to_string()]) + ); + assert!( + relay_receipt + .event + .tags + .contains(&vec!["e_prev".to_owned(), decision_event_id.to_string()]) + ); + let envelope = client + .order() + .parse_fulfillment_update(&relay_receipt.event) + .expect("active fulfillment update"); + assert_eq!(envelope.order_id, fulfillment.order_id); + assert_eq!(envelope.listing_addr, fulfillment.listing_addr); + assert_eq!(envelope.payload.status, fulfillment.status); + } + SdkTransportReceipt::Radrootsd(_) => panic!("unexpected radrootsd receipt"), + } + + match cancellation_receipt.transport_receipt { + SdkTransportReceipt::RelayDirect(relay_receipt) => { + assert_eq!(relay_receipt.event.kind, 3432); + assert_eq!(relay_receipt.event.author, buyer_pubkey); + assert!( + relay_receipt + .event + .tags + .contains(&vec!["p".to_owned(), seller_pubkey.clone()]) + ); + assert!( + relay_receipt + .event + .tags + .contains(&vec!["e_root".to_owned(), root_event_id.to_string()]) + ); + assert!( + relay_receipt + .event + .tags + .contains(&vec!["e_prev".to_owned(), root_event_id.to_string()]) + ); + let envelope = client + .order() + .parse_order_cancellation(&relay_receipt.event) + .expect("order cancellation"); + assert_eq!(envelope.order_id, cancellation.order_id); + assert_eq!(envelope.listing_addr, cancellation.listing_addr); + assert_eq!(envelope.payload.reason, cancellation.reason); + } + SdkTransportReceipt::Radrootsd(_) => panic!("unexpected radrootsd receipt"), + } + + match buyer_receipt.transport_receipt { + SdkTransportReceipt::RelayDirect(relay_receipt) => { + assert_eq!(relay_receipt.event.kind, 3434); + assert_eq!(relay_receipt.event.author, buyer_pubkey); + assert!( + relay_receipt + .event + .tags + .contains(&vec!["p".to_owned(), seller_pubkey]) + ); + assert!( + relay_receipt + .event + .tags + .contains(&vec!["e_root".to_owned(), root_event_id.to_string()]) + ); + assert!( + relay_receipt + .event + .tags + .contains(&vec!["e_prev".to_owned(), fulfillment_event_id.to_string()]) + ); + let envelope = client + .order() + .parse_buyer_receipt(&relay_receipt.event) + .expect("active buyer receipt"); + assert_eq!(envelope.order_id, receipt.order_id); + assert_eq!(envelope.listing_addr, receipt.listing_addr); + assert_eq!(envelope.payload.received, receipt.received); + } + SdkTransportReceipt::Radrootsd(_) => panic!("unexpected radrootsd receipt"), + } + + Ok(()) +} + +#[tokio::test] +async fn relay_direct_order_decision_publish_builds_and_publishes_payload() -> TestResult<()> { + let relay = AckRelay::spawn().await?; + let buyer_identity = RadrootsIdentity::generate(); + let seller_identity = RadrootsIdentity::generate(); + let payload = sample_order_decision( + buyer_identity.public_key_hex(), + seller_identity.public_key_hex(), + ); + let mut config = RadrootsSdkConfig::for_environment(SdkEnvironment::Custom); + config.transport = SdkTransportMode::RelayDirect; + config.signer = SignerConfig::LocalIdentity; + config.relay = RelayConfig { + urls: vec![relay.url().to_owned()], + }; + let client = RadrootsSdkClient::from_config(config)?; + let root_event_id = event_id('1'); + + let receipt = client + .order() + .publish_order_decision_with_identity( + &seller_identity, + &root_event_id, + &root_event_id, + &payload, + ) + .await?; + + assert_eq!(receipt.transport, SdkTransportMode::RelayDirect); + assert_eq!(receipt.event_kind, Some(3423)); + + Ok(()) +} + +#[tokio::test] +async fn relay_direct_order_request_publish_builds_and_publishes_payload() -> TestResult<()> { + let relay = AckRelay::spawn().await?; + let buyer_identity = RadrootsIdentity::generate(); + let seller_identity = RadrootsIdentity::generate(); + let payload = sample_order_request( + buyer_identity.public_key_hex(), + seller_identity.public_key_hex(), + ); + let mut config = RadrootsSdkConfig::for_environment(SdkEnvironment::Custom); + config.transport = SdkTransportMode::RelayDirect; + config.signer = SignerConfig::LocalIdentity; + config.relay = RelayConfig { + urls: vec![relay.url().to_owned()], + }; + let client = RadrootsSdkClient::from_config(config)?; + + let receipt = client + .order() + .publish_order_request_with_identity(&buyer_identity, &listing_event_ptr(), &payload) + .await?; + + assert_eq!(receipt.transport, SdkTransportMode::RelayDirect); + assert_eq!(receipt.event_kind, Some(3422)); + + Ok(()) +} + +#[tokio::test] +async fn relay_direct_order_request_publish_rejects_radrootsd_transport_mode() -> TestResult<()> { + let buyer_identity = RadrootsIdentity::generate(); + let seller_identity = RadrootsIdentity::generate(); + let payload = sample_order_request( + buyer_identity.public_key_hex(), + seller_identity.public_key_hex(), + ); + let mut config = RadrootsSdkConfig::production(); + config.transport = SdkTransportMode::Radrootsd; + config.signer = SignerConfig::LocalIdentity; + let client = RadrootsSdkClient::from_config(config)?; + + let error = client + .order() + .publish_order_request_with_identity(&buyer_identity, &listing_event_ptr(), &payload) + .await + .expect_err("unsupported transport"); + + assert!(matches!( + error, + SdkPublishError::UnsupportedTransport { + transport: SdkTransportMode::Radrootsd, + operation: "order.publish_order_request_with_identity", + } + )); + + Ok(()) +} + +#[tokio::test] +async fn relay_direct_order_request_publish_rejects_draft_only_signer_mode() -> TestResult<()> { + let relay = AckRelay::spawn().await?; + let buyer_identity = RadrootsIdentity::generate(); + let seller_identity = RadrootsIdentity::generate(); + let payload = sample_order_request( + buyer_identity.public_key_hex(), + seller_identity.public_key_hex(), + ); + let mut config = RadrootsSdkConfig::for_environment(SdkEnvironment::Custom); + config.transport = SdkTransportMode::RelayDirect; + config.signer = SignerConfig::DraftOnly; + config.relay = RelayConfig { + urls: vec![relay.url().to_owned()], + }; + let client = RadrootsSdkClient::from_config(config)?; + + let error = client + .order() + .publish_order_request_with_identity(&buyer_identity, &listing_event_ptr(), &payload) + .await + .expect_err("unsupported signer mode"); + + assert!(matches!( + error, + SdkPublishError::UnsupportedSignerMode { + transport: SdkTransportMode::RelayDirect, + signer: SignerConfig::DraftOnly, + required: SignerConfig::LocalIdentity, + operation: "order.publish_order_request_with_identity", + } + )); + + Ok(()) +} + +#[tokio::test] +async fn relay_direct_order_request_publish_rejects_invalid_economics() -> TestResult<()> { + let buyer_identity = RadrootsIdentity::generate(); + let seller_identity = RadrootsIdentity::generate(); + let mut payload = sample_order_request( + buyer_identity.public_key_hex(), + seller_identity.public_key_hex(), + ); + payload.economics.items[0].bin_count = 1; + let mut config = RadrootsSdkConfig::for_environment(SdkEnvironment::Custom); + config.transport = SdkTransportMode::RelayDirect; + config.signer = SignerConfig::LocalIdentity; + config.relay = RelayConfig { + urls: vec!["ws://127.0.0.1:9".to_owned()], + }; + let client = RadrootsSdkClient::from_config(config)?; + + let error = client + .order() + .publish_order_request_with_identity(&buyer_identity, &listing_event_ptr(), &payload) + .await + .expect_err("invalid economics"); + + assert!(matches!(error, SdkPublishError::Encode(_))); + + Ok(()) +} + +#[tokio::test] +async fn relay_direct_order_request_publish_reports_setup_error_detail() -> TestResult<()> { + let buyer_identity = RadrootsIdentity::generate(); + let seller_identity = RadrootsIdentity::generate(); + let payload = sample_order_request( + buyer_identity.public_key_hex(), + seller_identity.public_key_hex(), + ); + let mut config = RadrootsSdkConfig::for_environment(SdkEnvironment::Custom); + config.transport = SdkTransportMode::RelayDirect; + config.signer = SignerConfig::LocalIdentity; + config.network.timeout_ms = 10; + config.relay = RelayConfig { + urls: vec!["ws://127.0.0.1:9".to_owned()], + }; + let client = RadrootsSdkClient::from_config(config)?; + + let error = client + .order() + .publish_order_request_with_identity(&buyer_identity, &listing_event_ptr(), &payload) + .await + .expect_err("relay setup error"); + + assert!(matches!( + error, + SdkPublishError::RelaySetup { + transport: SdkTransportMode::RelayDirect, + operation: "order.publish_order_request_with_identity", + target_relays, + error: _, + } if target_relays == vec!["ws://127.0.0.1:9".to_owned()] + )); + + Ok(()) +} + +#[tokio::test] +async fn relay_direct_farm_publish_rejects_radrootsd_transport_mode() -> TestResult<()> { + let identity = RadrootsIdentity::generate(); + let mut config = RadrootsSdkConfig::production(); + config.transport = SdkTransportMode::Radrootsd; + config.signer = SignerConfig::LocalIdentity; + let client = RadrootsSdkClient::from_config(config)?; + + let error = client + .farm() + .publish_with_identity(&identity, &sample_farm()) + .await + .expect_err("unsupported transport"); + + assert!(matches!( + error, + SdkPublishError::UnsupportedTransport { + transport: SdkTransportMode::Radrootsd, + operation: "farm.publish_with_identity", + } + )); + + Ok(()) +} + +#[tokio::test] +async fn relay_direct_farm_publish_rejects_draft_only_signer_mode() -> TestResult<()> { + let relay = AckRelay::spawn().await?; + let identity = RadrootsIdentity::generate(); + let mut config = RadrootsSdkConfig::for_environment(SdkEnvironment::Custom); + config.transport = SdkTransportMode::RelayDirect; + config.signer = SignerConfig::DraftOnly; + config.relay = RelayConfig { + urls: vec![relay.url().to_owned()], + }; + let client = RadrootsSdkClient::from_config(config)?; + + let error = client + .farm() + .publish_with_identity(&identity, &sample_farm()) + .await + .expect_err("unsupported signer mode"); + + assert!(matches!( + error, + SdkPublishError::UnsupportedSignerMode { + transport: SdkTransportMode::RelayDirect, + signer: SignerConfig::DraftOnly, + required: SignerConfig::LocalIdentity, + operation: "farm.publish_with_identity", + } + )); + + Ok(()) +} + +#[tokio::test] +async fn relay_direct_profile_publish_accepts_sdk_built_draft() -> TestResult<()> { + let relay = AckRelay::spawn().await?; + let identity = RadrootsIdentity::generate(); + let mut config = RadrootsSdkConfig::for_environment(SdkEnvironment::Custom); + config.transport = SdkTransportMode::RelayDirect; + config.signer = SignerConfig::LocalIdentity; + config.relay = RelayConfig { + urls: vec![relay.url().to_owned()], + }; + let client = RadrootsSdkClient::from_config(config)?; + let draft = client + .profile() + .build_draft(&sample_profile(), Some(RadrootsProfileType::Farm))?; + + let receipt = client + .profile() + .publish_draft_with_identity(&identity, draft) + .await?; + + assert_eq!(receipt.transport, SdkTransportMode::RelayDirect); + assert_eq!(receipt.event_kind, Some(0)); + assert!(receipt.event_id.is_some()); + match receipt.transport_receipt { + SdkTransportReceipt::RelayDirect(relay_receipt) => { + assert_eq!( + receipt.event_id.as_deref(), + Some(relay_receipt.event_id.as_str()) + ); + assert_eq!(relay_receipt.event.kind, 0); + assert_eq!(relay_receipt.event.author, identity.public_key_hex()); + assert_eq!( + relay_receipt.event.tags, + vec![vec!["t".to_owned(), "radroots:type:farm".to_owned()]] + ); + assert_eq!(relay_receipt.target_relays, vec![relay.url().to_owned()]); + assert_eq!(relay_receipt.connected_relays, vec![relay.url().to_owned()]); + assert_eq!( + relay_receipt.acknowledged_relays, + vec![relay.url().to_owned()] + ); + assert!(relay_receipt.failed_relays.is_empty()); + } + SdkTransportReceipt::Radrootsd(_) => panic!("unexpected radrootsd receipt"), + } + + Ok(()) +} + +#[tokio::test] +async fn relay_direct_profile_publish_rejects_radrootsd_transport_mode() -> TestResult<()> { + let identity = RadrootsIdentity::generate(); + let mut config = RadrootsSdkConfig::production(); + config.transport = SdkTransportMode::Radrootsd; + config.signer = SignerConfig::LocalIdentity; + let client = RadrootsSdkClient::from_config(config)?; + + let error = client + .profile() + .publish_with_identity( + &identity, + &sample_profile(), + Some(RadrootsProfileType::Farm), + ) + .await + .expect_err("unsupported transport"); + + assert!(matches!( + error, + SdkPublishError::UnsupportedTransport { + transport: SdkTransportMode::Radrootsd, + operation: "profile.publish_with_identity", + } + )); + + Ok(()) +} + +#[tokio::test] +async fn relay_direct_profile_publish_rejects_draft_only_signer_mode() -> TestResult<()> { + let relay = AckRelay::spawn().await?; + let identity = RadrootsIdentity::generate(); + let mut config = RadrootsSdkConfig::for_environment(SdkEnvironment::Custom); + config.transport = SdkTransportMode::RelayDirect; + config.signer = SignerConfig::DraftOnly; + config.relay = RelayConfig { + urls: vec![relay.url().to_owned()], + }; + let client = RadrootsSdkClient::from_config(config)?; + + let error = client + .profile() + .publish_with_identity( + &identity, + &sample_profile(), + Some(RadrootsProfileType::Farm), + ) + .await + .expect_err("unsupported signer mode"); + + assert!(matches!( + error, + SdkPublishError::UnsupportedSignerMode { + transport: SdkTransportMode::RelayDirect, + signer: SignerConfig::DraftOnly, + required: SignerConfig::LocalIdentity, + operation: "profile.publish_with_identity", + } + )); + + Ok(()) +} + +#[tokio::test] +async fn relay_direct_listing_publish_accepts_sdk_built_draft() -> TestResult<()> { + let relay = AckRelay::spawn().await?; + let identity = RadrootsIdentity::generate(); + let mut config = RadrootsSdkConfig::for_environment(SdkEnvironment::Custom); + config.transport = SdkTransportMode::RelayDirect; + config.signer = SignerConfig::LocalIdentity; + config.relay = RelayConfig { + urls: vec![relay.url().to_owned()], + }; + let client = RadrootsSdkClient::from_config(config)?; + let draft = client.listing().build_draft(&sample_listing())?; + + let receipt = client + .listing() + .publish_draft_with_identity(&identity, draft) + .await?; + + assert_eq!(receipt.transport, SdkTransportMode::RelayDirect); + assert_eq!(receipt.event_kind, Some(30402)); + assert!(receipt.event_id.is_some()); + match receipt.transport_receipt { + SdkTransportReceipt::RelayDirect(relay_receipt) => { + assert_eq!( + receipt.event_id.as_deref(), + Some(relay_receipt.event_id.as_str()) + ); + assert_eq!(receipt.event_kind, Some(relay_receipt.event_kind)); + assert_eq!(relay_receipt.event.kind, 30402); + assert_eq!(relay_receipt.event_id, relay_receipt.event.id); + assert_eq!(relay_receipt.signature, relay_receipt.event.sig); + assert_eq!(relay_receipt.created_at, relay_receipt.event.created_at); + assert_eq!(relay_receipt.event.author, identity.public_key_hex()); + assert_eq!(relay_receipt.target_relays, vec![relay.url().to_owned()]); + assert_eq!(relay_receipt.connected_relays, vec![relay.url().to_owned()]); + assert_eq!( + relay_receipt.acknowledged_relays, + vec![relay.url().to_owned()] + ); + assert!(relay_receipt.failed_relays.is_empty()); + } + SdkTransportReceipt::Radrootsd(_) => panic!("unexpected radrootsd receipt"), + } + + Ok(()) +} + +#[tokio::test] +async fn relay_direct_publish_rejects_radrootsd_transport_mode() -> TestResult<()> { + let identity = RadrootsIdentity::generate(); + let mut config = RadrootsSdkConfig::production(); + config.transport = SdkTransportMode::Radrootsd; + config.signer = SignerConfig::LocalIdentity; + let client = RadrootsSdkClient::from_config(config)?; + + let error = client + .listing() + .publish_with_identity(&identity, &sample_listing()) + .await + .expect_err("unsupported transport"); + + assert!(matches!( + error, + SdkPublishError::UnsupportedTransport { + transport: SdkTransportMode::Radrootsd, + operation: "listing.publish_with_identity", + } + )); + + Ok(()) +} + +#[tokio::test] +async fn relay_direct_publish_rejects_draft_only_signer_mode() -> TestResult<()> { + let relay = AckRelay::spawn().await?; + let identity = RadrootsIdentity::generate(); + let mut config = RadrootsSdkConfig::for_environment(SdkEnvironment::Custom); + config.transport = SdkTransportMode::RelayDirect; + config.signer = SignerConfig::DraftOnly; + config.relay = RelayConfig { + urls: vec![relay.url().to_owned()], + }; + let client = RadrootsSdkClient::from_config(config)?; + + let error = client + .listing() + .publish_with_identity(&identity, &sample_listing()) + .await + .expect_err("unsupported signer mode"); + + assert!(matches!( + error, + SdkPublishError::UnsupportedSignerMode { + transport: SdkTransportMode::RelayDirect, + signer: SignerConfig::DraftOnly, + required: SignerConfig::LocalIdentity, + operation: "listing.publish_with_identity", + } + )); + + Ok(()) +} + +#[tokio::test] +async fn relay_direct_publish_rejects_nip46_signer_mode() -> TestResult<()> { + let relay = AckRelay::spawn().await?; + let identity = RadrootsIdentity::generate(); + let mut config = RadrootsSdkConfig::for_environment(SdkEnvironment::Custom); + config.transport = SdkTransportMode::RelayDirect; + config.signer = SignerConfig::Nip46; + config.relay = RelayConfig { + urls: vec![relay.url().to_owned()], + }; + let client = RadrootsSdkClient::from_config(config)?; + + let error = client + .listing() + .publish_with_identity(&identity, &sample_listing()) + .await + .expect_err("unsupported signer mode"); + + assert!(matches!( + error, + SdkPublishError::UnsupportedSignerMode { + transport: SdkTransportMode::RelayDirect, + signer: SignerConfig::Nip46, + required: SignerConfig::LocalIdentity, + operation: "listing.publish_with_identity", + } + )); + + Ok(()) +} diff --git a/crates/sdk/tests/replica_ingest.rs b/crates/sdk/tests/replica_ingest.rs @@ -0,0 +1,85 @@ +use radroots_replica_db::ReplicaSql; +use radroots_replica_db_schema::farm::IFarmFindMany; +use radroots_replica_sync::{RadrootsReplicaIngestOutcome, radroots_replica_ingest_event}; +use radroots_sdk::{RadrootsFarm, RadrootsNostrEvent, farm}; +use radroots_sql_core::{SqlExecutor, SqliteExecutor}; +use tempfile::{TempDir, tempdir}; + +fn seller_pubkey() -> String { + "a".repeat(64) +} + +fn sdk_event( + id: u64, + author: &str, + created_at: u32, + kind: u32, + content: String, + tags: Vec<Vec<String>>, +) -> RadrootsNostrEvent { + RadrootsNostrEvent { + id: format!("{id:064x}"), + author: author.to_owned(), + created_at, + kind, + tags, + content, + sig: "f".repeat(128), + } +} + +fn sample_farm() -> RadrootsFarm { + RadrootsFarm { + d_tag: "AAAAAAAAAAAAAAAAAAAAAA".into(), + name: "North Farm".into(), + about: Some("Organic coffee".into()), + website: None, + picture: None, + banner: None, + location: None, + tags: Some(vec!["coffee".into()]), + } +} + +fn open_replica() -> (TempDir, ReplicaSql<SqliteExecutor>) { + let dir = tempdir().expect("tempdir"); + let db_path = dir.path().join("replica.sqlite"); + let executor = SqliteExecutor::open(&db_path).expect("open sqlite"); + executor + .exec("PRAGMA foreign_keys = ON;", "[]") + .expect("enable foreign keys"); + let replica = ReplicaSql::new(executor); + replica.migrate_up().expect("migrate"); + (dir, replica) +} + +fn ingest_farm(replica: &ReplicaSql<SqliteExecutor>) -> RadrootsNostrEvent { + let farm_value = sample_farm(); + let author = seller_pubkey(); + let parts = farm::build_draft(&farm_value).expect("farm draft"); + let event = sdk_event( + 1, + &author, + 1_720_000_000, + parts.kind, + parts.content, + parts.tags, + ); + let outcome = radroots_replica_ingest_event(replica.executor(), &event).expect("ingest farm"); + assert_eq!(outcome, RadrootsReplicaIngestOutcome::Applied); + event +} + +#[test] +fn sdk_farm_draft_ingests_into_replica_projection() { + let (_dir, replica) = open_replica(); + let event = ingest_farm(&replica); + let farms = replica + .farm_find_many(&IFarmFindMany { filter: None }) + .expect("query farms") + .results; + assert_eq!(farms.len(), 1); + assert_eq!(farms[0].d_tag, sample_farm().d_tag); + assert_eq!(farms[0].name, sample_farm().name); + assert_eq!(farms[0].pubkey, event.author); +} diff --git a/crates/xtask/src/check.rs b/crates/xtask/src/check.rs @@ -3,7 +3,9 @@ use std::{fs, path::Path}; use crate::{ fs::workspace_root, output::package_outputs, - package_matrix::{FORBIDDEN_PACKAGE_NAMES, package_specs, validate_package_matrix}, + package_matrix::{ + FORBIDDEN_PACKAGE_NAMES, package_specs, validate_package_matrix, wasm_package_specs, + }, }; pub fn check() -> Result<(), String> { @@ -20,6 +22,10 @@ pub fn check() -> Result<(), String> { return Err(format!("missing package index: {}", index_path.display())); } } + for spec in wasm_package_specs() { + let package_dir = root.join(spec.package_dir); + check_package_json(&package_dir.join("package.json"), spec.package_name)?; + } for output in package_outputs() { for expected in output.files() { let path = root @@ -47,6 +53,9 @@ fn check_binding_crate_sources(root: &Path) -> Result<(), String> { } check_no_typescript_files(&crate_src_dir)?; } + for spec in wasm_package_specs() { + check_no_typescript_files(&root.join(spec.crate_dir).join("src"))?; + } Ok(()) } diff --git a/crates/xtask/src/main.rs b/crates/xtask/src/main.rs @@ -5,6 +5,7 @@ mod manifest; mod output; mod package_matrix; mod ts; +mod wasm; fn main() { if let Err(error) = run(std::env::args().skip(1)) { @@ -17,6 +18,9 @@ fn run(args: impl IntoIterator<Item = String>) -> Result<(), String> { let args = args.into_iter().collect::<Vec<_>>(); match args.as_slice() { [command, target] if command == "generate" && target == "ts" => generate::generate_ts(), + [command, target, rest @ ..] if command == "generate" && target == "wasm" => { + wasm::generate(rest) + } [command] if command == "check" => check::check(), [] => Err(usage()), _ => Err(usage()), @@ -24,7 +28,8 @@ fn run(args: impl IntoIterator<Item = String>) -> Result<(), String> { } fn usage() -> String { - "usage: cargo xtask generate ts | cargo xtask check".to_owned() + "usage: cargo xtask generate ts | cargo xtask generate wasm [--package <key>] | cargo xtask check" + .to_owned() } #[cfg(test)] diff --git a/crates/xtask/src/package_matrix.rs b/crates/xtask/src/package_matrix.rs @@ -9,6 +9,17 @@ pub struct PackageSpec { pub package_dir: &'static str, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct WasmPackageSpec { + pub key: &'static str, + pub crate_name: &'static str, + pub crate_dir: &'static str, + pub package_name: &'static str, + pub package_dir: &'static str, + pub out_name: &'static str, + pub out_dir: &'static str, +} + pub const PACKAGE_SPECS: [PackageSpec; 7] = [ PackageSpec { key: "core", @@ -61,6 +72,36 @@ pub const PACKAGE_SPECS: [PackageSpec; 7] = [ }, ]; +pub const WASM_PACKAGE_SPECS: [WasmPackageSpec; 3] = [ + WasmPackageSpec { + key: "events_codec", + crate_name: "radroots_events_codec_wasm", + crate_dir: "crates/events_codec_wasm", + package_name: "@radroots/events-codec-wasm", + package_dir: "packages/events-codec-wasm", + out_name: "radroots_events_codec_wasm", + out_dir: "../../packages/events-codec-wasm/dist", + }, + WasmPackageSpec { + key: "replica_db", + crate_name: "radroots_replica_db_wasm", + crate_dir: "crates/replica_db_wasm", + package_name: "@radroots/replica-db-wasm", + package_dir: "packages/replica-db-wasm", + out_name: "radroots_replica_db_wasm", + out_dir: "../../packages/replica-db-wasm/dist", + }, + WasmPackageSpec { + key: "replica_sync", + crate_name: "radroots_replica_sync_wasm", + crate_dir: "crates/replica_sync_wasm", + package_name: "@radroots/replica-sync-wasm", + package_dir: "packages/replica-sync-wasm", + out_name: "radroots_replica_sync_wasm", + out_dir: "../../packages/replica-sync-wasm/dist", + }, +]; + pub const FORBIDDEN_PACKAGE_NAMES: [&str; 2] = ["@radroots/tangle-db-schema-bindings", "@radroots/contracts"]; @@ -68,6 +109,10 @@ pub fn package_specs() -> &'static [PackageSpec] { &PACKAGE_SPECS } +pub fn wasm_package_specs() -> &'static [WasmPackageSpec] { + &WASM_PACKAGE_SPECS +} + pub fn validate_package_matrix() -> Result<(), String> { let mut crate_names = BTreeSet::new(); let mut package_names = BTreeSet::new(); @@ -95,12 +140,37 @@ pub fn validate_package_matrix() -> Result<(), String> { )); } } + for spec in wasm_package_specs() { + if FORBIDDEN_PACKAGE_NAMES.contains(&spec.package_name) { + return Err(format!( + "forbidden package in matrix: {}", + spec.package_name + )); + } + if !crate_names.insert(spec.crate_name) { + return Err(format!("duplicate crate in matrix: {}", spec.crate_name)); + } + if !package_names.insert(spec.package_name) { + return Err(format!( + "duplicate package in matrix: {}", + spec.package_name + )); + } + if !package_dirs.insert(spec.package_dir) { + return Err(format!( + "duplicate package directory in matrix: {}", + spec.package_dir + )); + } + } Ok(()) } #[cfg(test)] mod tests { - use super::{FORBIDDEN_PACKAGE_NAMES, package_specs, validate_package_matrix}; + use super::{ + FORBIDDEN_PACKAGE_NAMES, package_specs, validate_package_matrix, wasm_package_specs, + }; #[test] fn package_matrix_is_valid() { @@ -110,6 +180,7 @@ mod tests { #[test] fn approved_package_count_is_stable() { assert_eq!(package_specs().len(), 7); + assert_eq!(wasm_package_specs().len(), 3); } #[test] @@ -117,6 +188,9 @@ mod tests { for spec in package_specs() { assert!(!FORBIDDEN_PACKAGE_NAMES.contains(&spec.package_name)); } + for spec in wasm_package_specs() { + assert!(!FORBIDDEN_PACKAGE_NAMES.contains(&spec.package_name)); + } } #[test] @@ -127,4 +201,13 @@ mod tests { .any(|spec| spec.package_name == "@radroots/replica-db-schema-bindings") ); } + + #[test] + fn wasm_packages_use_sdk_package_names() { + assert!( + wasm_package_specs() + .iter() + .any(|spec| spec.package_name == "@radroots/replica-db-wasm") + ); + } } diff --git a/crates/xtask/src/wasm.rs b/crates/xtask/src/wasm.rs @@ -0,0 +1,112 @@ +use std::{env, fs, path::Path, process::Command}; + +use crate::{ + fs::workspace_root, + package_matrix::{WasmPackageSpec, validate_package_matrix, wasm_package_specs}, +}; + +pub fn generate(args: &[String]) -> Result<(), String> { + validate_package_matrix()?; + let specs = selected_specs(args)?; + let root = workspace_root()?; + for spec in specs { + let dist_dir = root.join(spec.package_dir).join("dist"); + if dist_dir.exists() { + fs::remove_dir_all(&dist_dir) + .map_err(|error| format!("failed to remove {}: {error}", dist_dir.display()))?; + } + let mut command = Command::new("wasm-pack"); + command + .current_dir(&root) + .arg("build") + .arg(spec.crate_dir) + .arg("--release") + .arg("--target") + .arg("web") + .arg("--out-dir") + .arg(spec.out_dir) + .arg("--out-name") + .arg(spec.out_name) + .arg("--no-pack"); + if let Some(rustc) = rustup_tool("rustc") { + if let Some(parent) = Path::new(&rustc).parent() { + prepend_path(&mut command, parent); + } + command.env("RUSTC", rustc); + } + if let Some(cargo) = rustup_tool("cargo") { + command.env("CARGO", cargo); + } + let status = command + .status() + .map_err(|error| format!("failed to start wasm-pack for {}: {error}", spec.key))?; + if !status.success() { + return Err(format!("wasm-pack failed for {}", spec.key)); + } + println!("generated wasm package {}", spec.package_name); + } + Ok(()) +} + +fn selected_specs(args: &[String]) -> Result<Vec<WasmPackageSpec>, String> { + match args { + [] => Ok(wasm_package_specs().to_vec()), + [flag, key] if flag == "--package" => wasm_package_specs() + .iter() + .copied() + .find(|spec| spec.key == key) + .map(|spec| vec![spec]) + .ok_or_else(|| format!("unknown wasm package: {key}")), + _ => Err("usage: cargo xtask generate wasm [--package <key>]".to_owned()), + } +} + +fn rustup_tool(name: &str) -> Option<String> { + let output = Command::new("rustup") + .arg("which") + .arg(name) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let path = String::from_utf8(output.stdout).ok()?; + let trimmed = path.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_owned()) +} + +fn prepend_path(command: &mut Command, prefix: &Path) { + let existing = env::var_os("PATH").unwrap_or_default(); + let mut paths = vec![prefix.to_path_buf()]; + paths.extend(env::split_paths(&existing)); + if let Ok(joined) = env::join_paths(paths) { + command.env("PATH", joined); + } +} + +#[cfg(test)] +mod tests { + use super::{rustup_tool, selected_specs}; + + #[test] + fn selects_all_specs_by_default() { + assert_eq!(selected_specs(&[]).expect("all specs").len(), 3); + } + + #[test] + fn selects_one_spec_by_key() { + let specs = selected_specs(&["--package".to_owned(), "replica_db".to_owned()]) + .expect("replica db spec"); + assert_eq!(specs[0].package_name, "@radroots/replica-db-wasm"); + } + + #[test] + fn rejects_unknown_spec_key() { + assert!(selected_specs(&["--package".to_owned(), "missing".to_owned()]).is_err()); + } + + #[test] + fn rustup_tool_resolution_is_non_panicking() { + let _ = rustup_tool("rustc"); + } +} diff --git a/package.json b/package.json @@ -3,6 +3,8 @@ "private": true, "packageManager": "pnpm@10.25.0", "scripts": { + "generate:ts": "cargo xtask generate ts", + "generate:wasm": "cargo xtask generate wasm", "build": "pnpm -r build", "typecheck": "pnpm -r typecheck" }, diff --git a/packages/events-codec-wasm/package.json b/packages/events-codec-wasm/package.json @@ -0,0 +1,23 @@ +{ + "name": "@radroots/events-codec-wasm", + "description": "WebAssembly bindings for radroots_events_codec", + "private": true, + "type": "module", + "files": [ + "dist" + ], + "main": "./dist/radroots_events_codec_wasm.js", + "types": "./dist/radroots_events_codec_wasm.d.ts", + "exports": { + ".": { + "types": "./dist/radroots_events_codec_wasm.d.ts", + "import": "./dist/radroots_events_codec_wasm.js", + "default": "./dist/radroots_events_codec_wasm.js" + } + }, + "scripts": { + "build": "cd ../.. && cargo xtask generate wasm --package events_codec", + "typecheck": "node -e \"require('fs').accessSync('dist/radroots_events_codec_wasm.d.ts')\"" + }, + "sideEffects": false +} diff --git a/packages/replica-db-wasm/package.json b/packages/replica-db-wasm/package.json @@ -0,0 +1,23 @@ +{ + "name": "@radroots/replica-db-wasm", + "description": "WebAssembly bindings for radroots_replica_db", + "private": true, + "type": "module", + "files": [ + "dist" + ], + "main": "./dist/radroots_replica_db_wasm.js", + "types": "./dist/radroots_replica_db_wasm.d.ts", + "exports": { + ".": { + "types": "./dist/radroots_replica_db_wasm.d.ts", + "import": "./dist/radroots_replica_db_wasm.js", + "default": "./dist/radroots_replica_db_wasm.js" + } + }, + "scripts": { + "build": "cd ../.. && cargo xtask generate wasm --package replica_db", + "typecheck": "node -e \"require('fs').accessSync('dist/radroots_replica_db_wasm.d.ts')\"" + }, + "sideEffects": false +} diff --git a/packages/replica-sync-wasm/package.json b/packages/replica-sync-wasm/package.json @@ -0,0 +1,23 @@ +{ + "name": "@radroots/replica-sync-wasm", + "description": "WebAssembly bindings for radroots_replica_sync", + "private": true, + "type": "module", + "files": [ + "dist" + ], + "main": "./dist/radroots_replica_sync_wasm.js", + "types": "./dist/radroots_replica_sync_wasm.d.ts", + "exports": { + ".": { + "types": "./dist/radroots_replica_sync_wasm.d.ts", + "import": "./dist/radroots_replica_sync_wasm.js", + "default": "./dist/radroots_replica_sync_wasm.js" + } + }, + "scripts": { + "build": "cd ../.. && cargo xtask generate wasm --package replica_sync", + "typecheck": "node -e \"require('fs').accessSync('dist/radroots_replica_sync_wasm.d.ts')\"" + }, + "sideEffects": false +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml @@ -20,6 +20,8 @@ importers: specifier: workspace:* version: link:../core-bindings + packages/events-codec-wasm: {} + packages/events-indexed-bindings: {} packages/identity-bindings: {} @@ -30,6 +32,10 @@ importers: specifier: workspace:* version: link:../types-bindings + packages/replica-db-wasm: {} + + packages/replica-sync-wasm: {} + packages/trade-bindings: dependencies: '@radroots/core-bindings': diff --git a/rust-toolchain.toml b/rust-toolchain.toml @@ -1,2 +1,3 @@ [toolchain] channel = "1.92.0" +targets = ["wasm32-unknown-unknown"]