tangle


git clone https://radroots.dev/git/tangle.git
Log | Files | Refs | README | LICENSE

commit 89b1bad231d4017a53aec2d33a8fe008e56f2ef9
parent 3303026a759d61933d9c4ab3bc90885def8abae4
Author: triesap <tyson@radroots.org>
Date:   Sat, 13 Jun 2026 20:09:03 -0700

refactor: remove obsolete relay surfaces

- delete pre-v2 runtime crates, fixtures, tests, and local configs
- reduce tangle runtime and CLI to the v2 BaseRelay/Pocket config path
- keep AUTH parsing and deterministic v2 fixtures inside active crates
- update reference surfaces and validation scripts around the current v2 surface

Diffstat:
MCargo.lock | 5718++++++++++++-------------------------------------------------------------------
MCargo.toml | 4----
Mcrates/tangle/Cargo.toml | 7-------
Mcrates/tangle/src/lib.rs | 904+++++--------------------------------------------------------------------------
Mcrates/tangle/src/main.rs | 183++-----------------------------------------------------------------------------
Dcrates/tangle/tests/abuse_conformance.rs | 379-------------------------------------------------------------------------------
Dcrates/tangle/tests/commerce_privacy_conformance.rs | 168-------------------------------------------------------------------------------
Dcrates/tangle/tests/discussion_conformance.rs | 231-------------------------------------------------------------------------------
Dcrates/tangle/tests/moderation_conformance.rs | 168-------------------------------------------------------------------------------
Dcrates/tangle/tests/nip01_conformance.rs | 62--------------------------------------------------------------
Dcrates/tangle/tests/nip09_conformance.rs | 70----------------------------------------------------------------------
Dcrates/tangle/tests/nip42_conformance.rs | 71-----------------------------------------------------------------------
Dcrates/tangle/tests/nip50_conformance.rs | 77-----------------------------------------------------------------------------
Dcrates/tangle/tests/nip99_conformance.rs | 97-------------------------------------------------------------------------------
Mcrates/tangle/tests/release_acceptance.rs | 29++++++++++++++++++-----------
Dcrates/tangle/tests/run_integration.rs | 1364-------------------------------------------------------------------------------
Dcrates/tangle/tests/support/mod.rs | 347-------------------------------------------------------------------------------
Mcrates/tangle/tests/version.rs | 423++++++++++++++-----------------------------------------------------------------
Dcrates/tangle_core/Cargo.toml | 21---------------------
Dcrates/tangle_core/src/lib.rs | 6177-------------------------------------------------------------------------------
Dcrates/tangle_nips/Cargo.toml | 15---------------
Dcrates/tangle_nips/src/lib.rs | 4760-------------------------------------------------------------------------------
Mcrates/tangle_runtime/Cargo.toml | 8--------
Mcrates/tangle_runtime/src/base_relay.rs | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/tangle_runtime/src/lib.rs | 9704+------------------------------------------------------------------------------
Dcrates/tangle_store/Cargo.toml | 15---------------
Dcrates/tangle_store/src/lib.rs | 237-------------------------------------------------------------------------------
Dcrates/tangle_store_surreal/Cargo.toml | 23-----------------------
Dcrates/tangle_store_surreal/src/lib.rs | 10526-------------------------------------------------------------------------------
Mcrates/tangle_test_support/Cargo.toml | 3---
Mcrates/tangle_test_support/src/lib.rs | 441++++++-------------------------------------------------------------------------
Dscripts/local-surrealdb-down.sh | 4----
Dscripts/local-surrealdb-up.sh | 4----
Mscripts/release_acceptance.sh | 14+++-----------
Dtesting/fixtures/canonical/nostr/auth_event.json | 11-----------
Dtesting/fixtures/canonical/nostr/deletion_event.json | 10----------
Dtesting/fixtures/canonical/nostr/projection_ineligible_listing.json | 13-------------
Dtesting/fixtures/canonical/nostr/valid_public_listing.json | 19-------------------
38 files changed, 1119 insertions(+), 41249 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -3,248 +3,18 @@ version = 4 [[package]] -name = "addr" -version = "0.15.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a93b8a41dbe230ad5087cc721f8d41611de654542180586b315d9f4cf6b72bef" -dependencies = [ - "psl-types", -] - -[[package]] -name = "affinitypool" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4a46f56d354df11b6bcd8ca4f84fa03ac816cc41c72568a1b337d8f7e5af90e" -dependencies = [ - "arc-swap", - "async-task", - "crossbeam-deque", - "crossbeam-utils", - "libc", - "num_cpus", - "parking_lot", - "thiserror", - "winapi", -] - -[[package]] -name = "ahash" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" -dependencies = [ - "getrandom 0.2.17", - "once_cell", - "version_check", -] - -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "getrandom 0.3.4", - "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 = "ammonia" -version = "4.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6" -dependencies = [ - "cssparser", - "html5ever", - "maplit", - "tendril", - "url", -] - -[[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 = "approx" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" -dependencies = [ - "num-traits", -] - -[[package]] -name = "arc-swap" -version = "1.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" -dependencies = [ - "rustversion", -] - -[[package]] -name = "argon2" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" -dependencies = [ - "base64ct", - "blake2", - "cpufeatures 0.2.17", - "password-hash", -] - -[[package]] -name = "arrayref" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" - -[[package]] name = "arrayvec" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] -name = "as-slice" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45403b49e3954a4b8428a0ac21a4b7afadccf92bfd96273f1a58cd4812496ae0" -dependencies = [ - "generic-array 0.12.4", - "generic-array 0.13.3", - "generic-array 0.14.7", - "stable_deref_trait", -] - -[[package]] -name = "async-channel" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" -dependencies = [ - "concurrent-queue", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-stream" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" -dependencies = [ - "async-stream-impl", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-stream-impl" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "async-task" -version = "4.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" - -[[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 2.0.117", -] - -[[package]] -name = "atomic-polyfill" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" -dependencies = [ - "critical-section", -] - -[[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" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - -[[package]] -name = "aws-lc-rs" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" -dependencies = [ - "aws-lc-sys", - "untrusted 0.7.1", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", -] - -[[package]] name = "axum" version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -273,7 +43,7 @@ dependencies = [ "sha1", "sync_wrapper", "tokio", - "tokio-tungstenite 0.29.0", + "tokio-tungstenite", "tower", "tower-layer", "tower-service", @@ -318,19 +88,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] -name = "bcrypt" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a0f5948f30df5f43ac29d310b7476793be97c50787e6ef4a63d960a0d0be827" -dependencies = [ - "base64", - "blowfish", - "getrandom 0.3.4", - "subtle", - "zeroize", -] - -[[package]] name = "bincode" version = "1.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -340,44 +97,6 @@ dependencies = [ ] [[package]] -name = "bincode" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" -dependencies = [ - "bincode_derive", - "serde", - "unty", -] - -[[package]] -name = "bincode_derive" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" -dependencies = [ - "virtue", -] - -[[package]] -name = "bindgen" -version = "0.72.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" -dependencies = [ - "bitflags", - "cexpr", - "clang-sys", - "itertools 0.13.0", - "proc-macro2", - "quote", - "regex", - "rustc-hash", - "shlex 1.3.0", - "syn 2.0.117", -] - -[[package]] name = "bitcoin-io" version = "0.1.100" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -403,5182 +122,1410 @@ dependencies = [ ] [[package]] -name = "bitvec" -version = "1.0.1" +name = "block-buffer" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "funty", - "radium", - "tap", - "wyz", + "generic-array", ] [[package]] -name = "blake2" -version = "0.10.6" +name = "byteorder" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" -dependencies = [ - "digest", -] +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] -name = "blake3" -version = "1.8.5" +name = "bytes" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" -dependencies = [ - "arrayref", - "arrayvec", - "cc", - "cfg-if", - "constant_time_eq", - "cpufeatures 0.3.0", -] +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] -name = "block-buffer" -version = "0.10.4" +name = "cc" +version = "1.2.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ - "generic-array 0.14.7", + "find-msvc-tools", + "shlex", ] [[package]] -name = "blowfish" -version = "0.9.1" +name = "cfg-if" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" -dependencies = [ - "byteorder", - "cipher", -] +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] -name = "bnum" -version = "0.12.1" +name = "const-oid" +version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f781dba93de3a5ef6dc5b17c9958b208f6f3f021623b360fb605ea51ce443f10" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" [[package]] -name = "borsh" -version = "1.6.1" +name = "convert_case" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" -dependencies = [ - "borsh-derive", - "bytes", - "cfg_aliases", -] +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" [[package]] -name = "borsh-derive" -version = "1.6.1" +name = "cpufeatures" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" dependencies = [ - "once_cell", - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 2.0.117", + "libc", ] [[package]] -name = "boxcar" -version = "0.2.14" +name = "crossbeam-queue" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36f64beae40a84da1b4b26ff2761a5b895c12adc41dc25aaee1c4f2bbfe97a6e" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] [[package]] -name = "bumpalo" -version = "3.20.3" +name = "crossbeam-utils" +version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] -name = "bytecheck" -version = "0.6.12" +name = "crypto-bigint" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ - "bytecheck_derive", - "ptr_meta", - "simdutf8", + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", ] [[package]] -name = "bytecheck_derive" -version = "0.6.12" +name = "crypto-common" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", + "generic-array", + "typenum", ] [[package]] -name = "bytemuck" -version = "1.25.0" +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "der" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "bytemuck_derive", + "const-oid", + "zeroize", ] [[package]] -name = "bytemuck_derive" -version = "1.10.2" +name = "derive_more" +version = "0.99.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" dependencies = [ + "convert_case", "proc-macro2", "quote", - "syn 2.0.117", + "rustc_version", + "syn", ] [[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "bytes" -version = "1.11.1" +name = "digest" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "serde", + "block-buffer", + "const-oid", + "crypto-common", + "subtle", ] [[package]] -name = "bzip2-sys" -version = "0.1.13+1.0.8" +name = "displaydoc" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ - "cc", - "pkg-config", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "castaway" -version = "0.2.4" +name = "doxygen-rs" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +checksum = "415b6ec780d34dcf624666747194393603d0373b7141eef01d12ee58881507d9" dependencies = [ - "rustversion", + "phf", ] [[package]] -name = "cc" -version = "1.2.63" +name = "ecdsa" +version = "0.16.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ - "find-msvc-tools", - "jobserver", - "libc", - "shlex 2.0.1", + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", ] [[package]] -name = "cexpr" -version = "0.6.0" +name = "elliptic-curve" +version = "0.13.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ - "nom", + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", ] [[package]] -name = "cfg-if" -version = "1.0.4" +name = "ff" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] [[package]] -name = "cfg_aliases" -version = "0.2.1" +name = "find-msvc-tools" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] -name = "chrono" -version = "0.4.45" +name = "form_urlencoded" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link 0.2.1", + "percent-encoding", ] [[package]] -name = "ciborium" -version = "0.2.2" +name = "futures-channel" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ - "ciborium-io", - "ciborium-ll", - "serde", + "futures-core", ] [[package]] -name = "ciborium-io" -version = "0.2.2" +name = "futures-core" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] -name = "ciborium-ll" -version = "0.2.2" +name = "futures-sink" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" -dependencies = [ - "ciborium-io", - "half", -] +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] -name = "cipher" -version = "0.4.4" +name = "futures-task" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common", - "inout", -] +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] -name = "clang-sys" -version = "1.8.1" +name = "futures-util" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ - "glob", - "libc", - "libloading", + "futures-core", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", ] [[package]] -name = "cmake" -version = "0.1.58" +name = "generic-array" +version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ - "cc", + "typenum", + "version_check", + "zeroize", ] [[package]] -name = "concurrent-queue" -version = "2.5.0" +name = "getrandom" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ - "crossbeam-utils", + "cfg-if", + "libc", + "wasi", ] [[package]] -name = "const-oid" -version = "0.9.6" +name = "getrandom" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] [[package]] -name = "constant_time_eq" -version = "0.4.2" +name = "group" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] [[package]] -name = "convert_case" -version = "0.4.0" +name = "heed" +version = "0.20.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" +checksum = "7d4f449bab7320c56003d37732a917e18798e2f1709d80263face2b4f9436ddb" +dependencies = [ + "bitflags", + "byteorder", + "heed-traits", + "heed-types", + "libc", + "lmdb-master-sys", + "once_cell", + "page_size", + "serde", + "synchronoise", + "url", +] [[package]] -name = "core-foundation-sys" -version = "0.8.7" +name = "heed-traits" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +checksum = "eb3130048d404c57ce5a1ac61a903696e8fcde7e8c2991e9fcfc1f27c3ef74ff" [[package]] -name = "cpufeatures" -version = "0.2.17" +name = "heed-types" +version = "0.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +checksum = "9d3f528b053a6d700b2734eabcd0fd49cb8230647aa72958467527b0b7917114" dependencies = [ - "libc", + "bincode", + "byteorder", + "heed-traits", + "serde", + "serde_json", ] [[package]] -name = "cpufeatures" -version = "0.3.0" +name = "hex-conservative" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" dependencies = [ - "libc", + "arrayvec", ] [[package]] -name = "critical-section" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" - -[[package]] -name = "crossbeam-deque" -version = "0.8.6" +name = "hmac" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", + "digest", ] [[package]] -name = "crossbeam-epoch" -version = "0.9.18" +name = "http" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" dependencies = [ - "crossbeam-utils", + "bytes", + "itoa", ] [[package]] -name = "crossbeam-queue" -version = "0.3.12" +name = "http-body" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ - "crossbeam-utils", + "bytes", + "http", ] [[package]] -name = "crossbeam-skiplist" +name = "http-body-util" version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df29de440c58ca2cc6e587ec3d22347551a32435fbde9d2bff64e78a9ffa151b" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", ] [[package]] -name = "crossbeam-utils" -version = "0.8.21" +name = "httparse" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] -name = "crunchy" -version = "0.2.4" +name = "httpdate" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] -name = "crypto-bigint" -version = "0.5.5" +name = "hyper" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ - "generic-array 0.14.7", - "rand_core 0.6.4", - "subtle", - "zeroize", + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", ] [[package]] -name = "crypto-common" -version = "0.1.7" +name = "hyper-util" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "generic-array 0.14.7", - "typenum", + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", ] [[package]] -name = "cssparser" -version = "0.35.0" +name = "icu_collections" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ - "cssparser-macros", - "dtoa-short", - "itoa", - "phf 0.11.3", - "smallvec", + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", ] [[package]] -name = "cssparser-macros" -version = "0.6.1" +name = "icu_locale_core" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ - "quote", - "syn 2.0.117", + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", ] [[package]] -name = "curve25519-dalek" -version = "4.1.3" +name = "icu_normalizer" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "curve25519-dalek-derive", - "digest", - "fiat-crypto", - "rustc_version", - "subtle", - "zeroize", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", ] [[package]] -name = "curve25519-dalek-derive" -version = "0.1.1" +name = "icu_normalizer_data" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] -name = "dashmap" -version = "6.2.1" +name = "icu_properties" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ - "cfg-if", - "crossbeam-utils", - "hashbrown 0.14.5", - "lock_api", - "once_cell", - "parking_lot_core", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", ] [[package]] -name = "data-encoding" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" - -[[package]] -name = "der" -version = "0.7.10" +name = "icu_properties_data" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid", - "pem-rfc7468", - "zeroize", -] +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] -name = "deranged" -version = "0.5.8" +name = "icu_provider" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ - "powerfmt", + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", ] [[package]] -name = "derive_more" -version = "0.99.20" +name = "idna" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.117", + "idna_adapter", + "smallvec", + "utf8_iter", ] [[package]] -name = "deunicode" -version = "1.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" - -[[package]] -name = "digest" -version = "0.10.7" +name = "idna_adapter" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", - "subtle", + "icu_normalizer", + "icu_properties", ] [[package]] -name = "diskann" -version = "0.53.0" +name = "itoa" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "376186e025eb294c22f06236b23417608f1867def159c3a61a5c57788a3e889e" -dependencies = [ - "anyhow", - "bytemuck", - "diskann-utils", - "diskann-vector", - "diskann-wide", - "futures-util", - "half", - "hashbrown 0.16.1", - "num-traits", - "rand 0.9.4", - "thiserror", - "tokio", -] +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] -name = "diskann-utils" -version = "0.53.0" +name = "k256" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b70289db1b66826fa1ef2b4113bf2f9d0dedc8df983b2b804c38dc1e519e15e" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" dependencies = [ - "bytemuck", "cfg-if", - "diskann-vector", - "diskann-wide", - "half", - "rand 0.9.4", - "rand_distr", - "rayon", - "thiserror", + "ecdsa", + "elliptic-curve", + "once_cell", + "sha2", + "signature", ] [[package]] -name = "diskann-vector" -version = "0.53.0" +name = "libc" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f62c9d81aad6e3df6a026b1bb693dbbcfbee5ea93d9e7a5ff15c31576263bc29" -dependencies = [ - "cfg-if", - "diskann-wide", - "half", -] +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] -name = "diskann-wide" -version = "0.53.0" +name = "litemap" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46fcacef8ea9274969f98499456718f3dcaa5d3d7392b3171079653370fa0b20" -dependencies = [ - "cfg-if", - "half", -] +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] -name = "displaydoc" +name = "lmdb-master-sys" version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "aaeb9bd22e73bd1babffff614994b341e9b2008de7bb73bf1f7e9154f1978f8b" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "cc", + "doxygen-rs", + "libc", ] [[package]] -name = "dmp" -version = "0.2.3" +name = "log" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb2dfc7a18dffd3ef60a442b72a827126f1557d914620f8fc4d1049916da43c1" -dependencies = [ - "trice", - "urlencoding", -] +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" [[package]] -name = "doxygen-rs" -version = "0.4.2" +name = "matchit" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "415b6ec780d34dcf624666747194393603d0373b7141eef01d12ee58881507d9" -dependencies = [ - "phf 0.11.3", -] +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] -name = "dtoa" -version = "1.0.11" +name = "memchr" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] -name = "dtoa-short" -version = "0.3.5" +name = "memmap2" +version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" dependencies = [ - "dtoa", + "libc", ] [[package]] -name = "dunce" -version = "1.0.5" +name = "mime" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] -name = "earcutr" -version = "0.4.3" +name = "mio" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79127ed59a85d7687c409e9978547cffb7dc79675355ed22da6b66fd5f6ead01" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ - "itertools 0.11.0", - "num-traits", + "libc", + "wasi", + "windows-sys", ] [[package]] -name = "ecdsa" -version = "0.16.9" +name = "mmap-append" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +checksum = "df2ef74bd4eba425972bd31da530dbe0c0e62d15f7c06e1c6ebcc3b6d5446237" dependencies = [ - "der", - "digest", - "elliptic-curve", - "rfc6979", - "signature", - "spki", + "libc", + "memmap2", ] [[package]] -name = "ed25519" -version = "2.2.3" +name = "once_cell" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" -dependencies = [ - "pkcs8", - "signature", -] +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] -name = "ed25519-dalek" -version = "2.2.0" +name = "page_size" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" dependencies = [ - "curve25519-dalek", - "ed25519", - "serde", - "sha2", - "subtle", - "zeroize", + "libc", + "winapi", ] [[package]] -name = "either" -version = "1.16.0" +name = "percent-encoding" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] -name = "elliptic-curve" -version = "0.13.8" +name = "phf" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ - "base16ct", - "crypto-bigint", - "digest", - "ff", - "generic-array 0.14.7", - "group", - "hkdf", - "pem-rfc7468", - "pkcs8", - "rand_core 0.6.4", - "sec1", - "subtle", - "zeroize", + "phf_macros", + "phf_shared", ] [[package]] -name = "endian-type" -version = "0.2.0" +name = "phf_generator" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "869b0adbda23651a9c5c0c3d270aac9fcb52e8622a8f2b17e57802d7791962f2" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.6", +] [[package]] -name = "equivalent" -version = "1.0.2" +name = "phf_macros" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn", +] [[package]] -name = "errno" -version = "0.3.14" +name = "phf_shared" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" dependencies = [ - "libc", - "windows-sys 0.61.2", + "siphasher", ] [[package]] -name = "event-listener" -version = "5.4.1" +name = "pin-project-lite" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" -dependencies = [ - "concurrent-queue", - "parking", - "pin-project-lite", -] +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] -name = "event-listener-strategy" -version = "0.5.4" +name = "pkcs8" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "event-listener", - "pin-project-lite", + "der", + "spki", ] [[package]] -name = "ext-sort" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf5d3b056bcc471d38082b8c453acb6670f7327fd44219b3c411e40834883569" -dependencies = [ - "log", - "rayon", - "rmp-serde", - "serde", - "tempfile", -] - -[[package]] -name = "fastnum" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4089ab2dfd45d8ddc92febb5ca80644389d5ebb954f40231274a3f18341762e2" -dependencies = [ - "bnum", - "num-integer", - "num-traits", -] - -[[package]] -name = "fastrand" -version = "2.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" - -[[package]] -name = "ff" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" -dependencies = [ - "rand_core 0.6.4", - "subtle", -] - -[[package]] -name = "fiat-crypto" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "flatbuffers" -version = "25.12.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3" -dependencies = [ - "bitflags", - "rustc_version", - "serde", -] - -[[package]] -name = "float_next_after" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - -[[package]] -name = "fst" -version = "0.4.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ab85b9b05e3978cc9a9cf8fea7f01b494e1a09ed3037e16ba39edc7a29eb61a" - -[[package]] -name = "funty" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" - -[[package]] -name = "futf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" -dependencies = [ - "mac", - "new_debug_unreachable", -] - -[[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 2.0.117", -] - -[[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" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -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", -] - -[[package]] -name = "fuzzy-matcher" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" -dependencies = [ - "thread_local", -] - -[[package]] -name = "generic-array" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffdf9f34f1447443d37393cc6c2b8313aebddcd96906caf34e54c68d8e57d7bd" -dependencies = [ - "typenum", -] - -[[package]] -name = "generic-array" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f797e67af32588215eaaab8327027ee8e71b9dd0b2b26996aedf20c030fce309" -dependencies = [ - "typenum", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", - "zeroize", -] - -[[package]] -name = "geo" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f3901269ec6d4f6068d3f09e5f02f995bd076398dcd1dfec407cd230b02d11b" -dependencies = [ - "earcutr", - "float_next_after", - "geo-types", - "geographiclib-rs", - "i_overlay", - "log", - "num-traits", - "rand 0.8.6", - "robust", - "rstar 0.12.2", - "serde", - "sif-itree", - "spade", -] - -[[package]] -name = "geo-types" -version = "0.7.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94776032c45f950d30a13af6113c2ad5625316c9abfbccee4dd5a6695f8fe0f5" -dependencies = [ - "approx", - "num-traits", - "rayon", - "rstar 0.10.0", - "rstar 0.11.0", - "rstar 0.12.2", - "rstar 0.8.4", - "rstar 0.9.3", - "serde", -] - -[[package]] -name = "geographiclib-rs" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5a7f08910fd98737a6eda7568e7c5e645093e073328eeef49758cfe8b0489c7" -dependencies = [ - "libm", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +name = "pocket-db" +version = "0.1.0" +source = "git+https://github.com/triesap/pocket?rev=329334f20948c796c6016b673b92551ac4855ad7#329334f20948c796c6016b673b92551ac4855ad7" dependencies = [ - "cfg-if", - "js-sys", + "heed", "libc", - "wasi", - "wasm-bindgen", + "mmap-append", + "pocket-types", ] [[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +name = "pocket-types" +version = "0.1.0" +source = "git+https://github.com/triesap/pocket?rev=329334f20948c796c6016b673b92551ac4855ad7#329334f20948c796c6016b673b92551ac4855ad7" dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi 5.3.0", - "wasip2", - "wasm-bindgen", + "derive_more", + "rand 0.9.4", + "secp256k1", ] [[package]] -name = "getrandom" -version = "0.4.2" +name = "potential_utf" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ - "cfg-if", - "libc", - "r-efi 6.0.0", - "wasip2", - "wasip3", + "zerovec", ] [[package]] -name = "glob" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" - -[[package]] -name = "group" -version = "0.13.0" +name = "ppv-lite86" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ - "ff", - "rand_core 0.6.4", - "subtle", + "zerocopy", ] [[package]] -name = "h2" -version = "0.4.14" +name = "proc-macro2" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "half" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" -dependencies = [ - "bytemuck", - "cfg-if", - "crunchy", - "num-traits", - "rand 0.9.4", - "rand_distr", - "zerocopy", -] - -[[package]] -name = "hash32" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4041af86e63ac4298ce40e5cca669066e75b6f1aa3390fe2561ffa5e1d9f4cc" -dependencies = [ - "byteorder", -] - -[[package]] -name = "hash32" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" -dependencies = [ - "byteorder", -] - -[[package]] -name = "hash32" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" -dependencies = [ - "byteorder", -] - -[[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" -dependencies = [ - "ahash 0.7.8", -] - -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash 0.1.5", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.2.0", -] - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - -[[package]] -name = "headers" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" -dependencies = [ - "base64", - "bytes", - "headers-core", - "http", - "httpdate", - "mime", - "sha1", -] - -[[package]] -name = "headers-core" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" -dependencies = [ - "http", -] - -[[package]] -name = "heapless" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "634bd4d29cbf24424d0a4bfcbf80c6960129dc24424752a7d1d1390607023422" -dependencies = [ - "as-slice", - "generic-array 0.14.7", - "hash32 0.1.1", - "stable_deref_trait", -] - -[[package]] -name = "heapless" -version = "0.7.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" -dependencies = [ - "atomic-polyfill", - "hash32 0.2.1", - "rustc_version", - "spin", - "stable_deref_trait", -] - -[[package]] -name = "heapless" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" -dependencies = [ - "hash32 0.3.1", - "stable_deref_trait", -] - -[[package]] -name = "heck" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "heed" -version = "0.20.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d4f449bab7320c56003d37732a917e18798e2f1709d80263face2b4f9436ddb" -dependencies = [ - "bitflags", - "byteorder", - "heed-traits", - "heed-types", - "libc", - "lmdb-master-sys", - "once_cell", - "page_size", - "serde", - "synchronoise", - "url", -] - -[[package]] -name = "heed-traits" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb3130048d404c57ce5a1ac61a903696e8fcde7e8c2991e9fcfc1f27c3ef74ff" - -[[package]] -name = "heed-types" -version = "0.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d3f528b053a6d700b2734eabcd0fd49cb8230647aa72958467527b0b7917114" -dependencies = [ - "bincode 1.3.3", - "byteorder", - "heed-traits", - "serde", - "serde_json", -] - -[[package]] -name = "hermit-abi" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" - -[[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 = "hkdf" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" -dependencies = [ - "hmac", -] - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest", -] - -[[package]] -name = "html5ever" -version = "0.35.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4" -dependencies = [ - "log", - "markup5ever", - "match_token", -] - -[[package]] -name = "http" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" -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 = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "humantime" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" - -[[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", - "h2", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-timeout" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" -dependencies = [ - "hyper", - "hyper-util", - "pin-project-lite", - "tokio", - "tower-service", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "base64", - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "hyper", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2", - "tokio", - "tower-service", - "tracing", -] - -[[package]] -name = "i_float" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "010025c2c532c8d82e42d0b8bb5184afa449fa6f06c709ea9adcb16c49ae405b" -dependencies = [ - "libm", -] - -[[package]] -name = "i_key_sort" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9190f86706ca38ac8add223b2aed8b1330002b5cdbbce28fb58b10914d38fc27" - -[[package]] -name = "i_overlay" -version = "4.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413183068e6e0289e18d7d0a1f661b81546e6918d5453a44570b9ab30cbed1b3" -dependencies = [ - "i_float", - "i_key_sort", - "i_shape", - "i_tree", - "rayon", -] - -[[package]] -name = "i_shape" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ea154b742f7d43dae2897fcd5ead86bc7b5eefcedd305a7ebf9f69d44d61082" -dependencies = [ - "i_float", -] - -[[package]] -name = "i_tree" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35e6d558e6d4c7b82bc51d9c771e7a927862a161a7d87bf2b0541450e0e20915" - -[[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 0.62.2", -] - -[[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", -] - -[[package]] -name = "icu_locale_core" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" - -[[package]] -name = "icu_properties" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" - -[[package]] -name = "icu_provider" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[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" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[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" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "generic-array 0.14.7", -] - -[[package]] -name = "ipnet" -version = "2.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" - -[[package]] -name = "itertools" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" -dependencies = [ - "either", -] - -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - -[[package]] -name = "js-sys" -version = "0.3.99" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" -dependencies = [ - "cfg-if", - "futures-util", - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "jsonwebtoken" -version = "10.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" -dependencies = [ - "aws-lc-rs", - "base64", - "ed25519-dalek", - "getrandom 0.2.17", - "hmac", - "js-sys", - "p256", - "p384", - "pem", - "rand 0.8.6", - "rsa", - "serde", - "serde_json", - "sha2", - "signature", - "simple_asn1", - "zeroize", -] - -[[package]] -name = "k256" -version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" -dependencies = [ - "cfg-if", - "ecdsa", - "elliptic-curve", - "once_cell", - "sha2", - "signature", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -dependencies = [ - "spin", -] - -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - -[[package]] -name = "lexicmp" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e8f89da8fd95c4eb6274e914694bea90c7826523b26f2a2fd863d44b9d42c43" -dependencies = [ - "deunicode", -] - -[[package]] -name = "libc" -version = "0.2.186" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" - -[[package]] -name = "libloading" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" -dependencies = [ - "cfg-if", - "windows-link 0.2.1", -] - -[[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" - -[[package]] -name = "libz-sys" -version = "1.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" -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 = "lmdb-master-sys" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aaeb9bd22e73bd1babffff614994b341e9b2008de7bb73bf1f7e9154f1978f8b" -dependencies = [ - "cc", - "doxygen-rs", - "libc", -] - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" - -[[package]] -name = "lz4" -version = "1.28.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a20b523e860d03443e98350ceaac5e71c6ba89aea7d960769ec3ce37f4de5af4" -dependencies = [ - "lz4-sys", -] - -[[package]] -name = "lz4-sys" -version = "1.11.1+lz4-1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bd8c0d6c6ed0cd30b3652886bb8711dc4bb01d637a68105a3d5158039b418e6" -dependencies = [ - "cc", - "libc", -] - -[[package]] -name = "mac" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" - -[[package]] -name = "maplit" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" - -[[package]] -name = "markup5ever" -version = "0.35.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3" -dependencies = [ - "log", - "tendril", - "web_atoms", -] - -[[package]] -name = "match_token" -version = "0.35.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "matchers" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" -dependencies = [ - "regex-automata", -] - -[[package]] -name = "matchit" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" - -[[package]] -name = "matrixmultiply" -version = "0.3.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" -dependencies = [ - "autocfg", - "rawpointer", -] - -[[package]] -name = "md-5" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" -dependencies = [ - "cfg-if", - "digest", -] - -[[package]] -name = "memchr" -version = "2.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" - -[[package]] -name = "memmap2" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" -dependencies = [ - "libc", -] - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "mime_guess" -version = "2.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" -dependencies = [ - "mime", - "unicase", -] - -[[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 = "mmap-append" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df2ef74bd4eba425972bd31da530dbe0c0e62d15f7c06e1c6ebcc3b6d5446237" -dependencies = [ - "libc", - "memmap2", -] - -[[package]] -name = "ndarray" -version = "0.17.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" -dependencies = [ - "matrixmultiply", - "num-complex", - "num-integer", - "num-traits", - "portable-atomic", - "portable-atomic-util", - "rawpointer", -] - -[[package]] -name = "ndarray-stats" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b6e54a8b65764f71827a90ca1d56965ec0c67f069f996477bd493402a901d1f" -dependencies = [ - "indexmap", - "itertools 0.13.0", - "ndarray", - "noisy_float", - "num-integer", - "num-traits", - "rand 0.8.6", -] - -[[package]] -name = "new_debug_unreachable" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" - -[[package]] -name = "nibble_vec" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" -dependencies = [ - "smallvec", -] - -[[package]] -name = "noisy_float" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c16843be85dd410c6a12251c4eca0dd1d3ee8c5725f746c4d5e0fdcec0a864b2" -dependencies = [ - "num-traits", -] - -[[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 = "ntapi" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" -dependencies = [ - "winapi", -] - -[[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-bigint" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-bigint-dig" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" -dependencies = [ - "lazy_static", - "libm", - "num-integer", - "num-iter", - "num-traits", - "rand 0.8.6", - "smallvec", - "zeroize", -] - -[[package]] -name = "num-complex" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-conv" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" - -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-iter" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" -dependencies = [ - "autocfg", - "num-integer", - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", - "libm", -] - -[[package]] -name = "num_cpus" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" -dependencies = [ - "hermit-abi", - "libc", -] - -[[package]] -name = "objc2-core-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" -dependencies = [ - "bitflags", -] - -[[package]] -name = "objc2-io-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" -dependencies = [ - "libc", - "objc2-core-foundation", -] - -[[package]] -name = "object_store" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "622acbc9100d3c10e2ee15804b0caa40e55c933d5aa53814cd520805b7958a49" -dependencies = [ - "async-trait", - "bytes", - "chrono", - "futures-channel", - "futures-core", - "futures-util", - "http", - "humantime", - "itertools 0.14.0", - "parking_lot", - "percent-encoding", - "thiserror", - "tokio", - "tracing", - "url", - "walkdir", - "wasm-bindgen-futures", - "web-time", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "p256" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" -dependencies = [ - "ecdsa", - "elliptic-curve", - "primeorder", - "sha2", -] - -[[package]] -name = "p384" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" -dependencies = [ - "ecdsa", - "elliptic-curve", - "primeorder", - "sha2", -] - -[[package]] -name = "page_size" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" -dependencies = [ - "libc", - "winapi", -] - -[[package]] -name = "papaya" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "997ee03cd38c01469a7046643714f0ad28880bcb9e6679ff0666e24817ca19b7" -dependencies = [ - "equivalent", - "seize", -] - -[[package]] -name = "parking" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link 0.2.1", -] - -[[package]] -name = "password-hash" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" -dependencies = [ - "base64ct", - "rand_core 0.6.4", - "subtle", -] - -[[package]] -name = "path-clean" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17359afc20d7ab31fdb42bb844c8b3bb1dabd7dcf7e68428492da7f16966fcef" - -[[package]] -name = "pbkdf2" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" -dependencies = [ - "digest", - "hmac", - "password-hash", - "sha2", -] - -[[package]] -name = "pdqselect" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ec91767ecc0a0bbe558ce8c9da33c068066c57ecc8bb8477ef8c1ad3ef77c27" - -[[package]] -name = "pem" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" -dependencies = [ - "base64", - "serde_core", -] - -[[package]] -name = "pem-rfc7468" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" -dependencies = [ - "base64ct", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_macros 0.11.3", - "phf_shared 0.11.3", -] - -[[package]] -name = "phf" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" -dependencies = [ - "phf_macros 0.13.1", - "phf_shared 0.13.1", - "serde", -] - -[[package]] -name = "phf_codegen" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared 0.11.3", - "rand 0.8.6", -] - -[[package]] -name = "phf_generator" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" -dependencies = [ - "fastrand", - "phf_shared 0.13.1", -] - -[[package]] -name = "phf_macros" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "phf_macros" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" -dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", - "proc-macro2", - "quote", - "syn 2.0.117", - "unicase", -] - -[[package]] -name = "phf_shared" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher", -] - -[[package]] -name = "phf_shared" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" -dependencies = [ - "siphasher", - "unicase", -] - -[[package]] -name = "pin-project" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "pkcs1" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" -dependencies = [ - "der", - "pkcs8", - "spki", -] - -[[package]] -name = "pkcs8" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" -dependencies = [ - "der", - "spki", -] - -[[package]] -name = "pkg-config" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" - -[[package]] -name = "pocket-db" -version = "0.1.0" -source = "git+https://github.com/triesap/pocket?rev=329334f20948c796c6016b673b92551ac4855ad7#329334f20948c796c6016b673b92551ac4855ad7" -dependencies = [ - "heed", - "libc", - "mmap-append", - "pocket-types", -] - -[[package]] -name = "pocket-types" -version = "0.1.0" -source = "git+https://github.com/triesap/pocket?rev=329334f20948c796c6016b673b92551ac4855ad7#329334f20948c796c6016b673b92551ac4855ad7" -dependencies = [ - "derive_more", - "rand 0.9.4", - "secp256k1", -] - -[[package]] -name = "portable-atomic" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" - -[[package]] -name = "portable-atomic-util" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" -dependencies = [ - "portable-atomic", -] - -[[package]] -name = "potential_utf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" -dependencies = [ - "zerovec", -] - -[[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" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "precomputed-hash" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" - -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.117", -] - -[[package]] -name = "primeorder" -version = "0.13.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" -dependencies = [ - "elliptic-curve", -] - -[[package]] -name = "proc-macro-crate" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" -dependencies = [ - "toml_edit", -] - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "prost" -version = "0.14.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" -dependencies = [ - "bytes", - "prost-derive", -] - -[[package]] -name = "prost-derive" -version = "0.14.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" -dependencies = [ - "anyhow", - "itertools 0.14.0", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "prost-types" -version = "0.14.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" -dependencies = [ - "prost", -] - -[[package]] -name = "psl-types" -version = "2.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" - -[[package]] -name = "ptr_meta" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" -dependencies = [ - "ptr_meta_derive", -] - -[[package]] -name = "ptr_meta_derive" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "quick_cache" -version = "0.6.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a3db184a8b66cfe87f0263a1de147a6b554c864d1767c6f7fa4eb0e5497b565" -dependencies = [ - "ahash 0.8.12", - "equivalent", - "hashbrown 0.16.1", - "parking_lot", -] - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2", -] - -[[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 = "radium" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" - -[[package]] -name = "radix_trie" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b4431027dcd37fc2a73ef740b5f233aa805897935b8bce0195e41bbf9a3289a" -dependencies = [ - "endian-type", - "nibble_vec", - "serde", -] - -[[package]] -name = "rand" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" -dependencies = [ - "libc", - "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]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "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]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - -[[package]] -name = "rand_distr" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" -dependencies = [ - "num-traits", - "rand 0.9.4", -] - -[[package]] -name = "rawpointer" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" - -[[package]] -name = "rayon" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - -[[package]] -name = "reblessive" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbc4a4ea2a66a41a1152c4b3d86e8954dc087bdf33af35446e6e176db4e73c8c" - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - -[[package]] -name = "regex" -version = "1.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" - -[[package]] -name = "rend" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" -dependencies = [ - "bytecheck", -] - -[[package]] -name = "reqwest" -version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" -dependencies = [ - "base64", - "bytes", - "futures-core", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "js-sys", - "log", - "mime_guess", - "percent-encoding", - "pin-project-lite", - "serde", - "serde_json", - "sync_wrapper", - "tokio", - "tokio-util", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams", - "web-sys", -] - -[[package]] -name = "revision" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e735a8c2864f0b0fd48a55d0a71c081c7cbef8c8958a4665d8de423f20f2d0cf" -dependencies = [ - "bytes", - "chrono", - "geo", - "regex", - "revision-derive", - "roaring", - "rust_decimal", - "uuid", -] - -[[package]] -name = "revision-derive" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f446f8c55ba240992330b09f69fe9e5ec8a2e1ba266843cb9f59d7bc6037c821" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "rfc6979" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" -dependencies = [ - "hmac", - "subtle", -] - -[[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 0.9.0", - "windows-sys 0.52.0", -] - -[[package]] -name = "rkyv" -version = "0.7.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" -dependencies = [ - "bitvec", - "bytecheck", - "bytes", - "hashbrown 0.12.3", - "ptr_meta", - "rend", - "rkyv_derive", - "seahash", - "tinyvec", - "uuid", -] - -[[package]] -name = "rkyv_derive" -version = "0.7.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "rmp" -version = "0.8.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" -dependencies = [ - "num-traits", -] - -[[package]] -name = "rmp-serde" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" -dependencies = [ - "rmp", - "serde", -] - -[[package]] -name = "roaring" -version = "0.11.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dedc5658c6ecb3bdb5ef5f3295bb9253f42dcf3fd1402c03f6b1f7659c3c4a9" -dependencies = [ - "bytemuck", - "byteorder", - "serde", -] - -[[package]] -name = "robust" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e27ee8bb91ca0adcf0ecb116293afa12d393f9c2b9b9cd54d33e8078fe19839" - -[[package]] -name = "rsa" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" -dependencies = [ - "const-oid", - "digest", - "num-bigint-dig", - "num-integer", - "num-traits", - "pkcs1", - "pkcs8", - "rand_core 0.6.4", - "signature", - "spki", - "subtle", - "zeroize", -] - -[[package]] -name = "rstar" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a45c0e8804d37e4d97e55c6f258bc9ad9c5ee7b07437009dd152d764949a27c" -dependencies = [ - "heapless 0.6.1", - "num-traits", - "pdqselect", - "serde", - "smallvec", -] - -[[package]] -name = "rstar" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b40f1bfe5acdab44bc63e6699c28b74f75ec43afb59f3eda01e145aff86a25fa" -dependencies = [ - "heapless 0.7.17", - "num-traits", - "serde", - "smallvec", -] - -[[package]] -name = "rstar" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f39465655a1e3d8ae79c6d9e007f4953bfc5d55297602df9dc38f9ae9f1359a" -dependencies = [ - "heapless 0.7.17", - "num-traits", - "serde", - "smallvec", -] - -[[package]] -name = "rstar" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73111312eb7a2287d229f06c00ff35b51ddee180f017ab6dec1f69d62ac098d6" -dependencies = [ - "heapless 0.7.17", - "num-traits", - "serde", - "smallvec", -] - -[[package]] -name = "rstar" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "421400d13ccfd26dfa5858199c30a5d76f9c54e0dba7575273025b43c5175dbb" -dependencies = [ - "heapless 0.8.0", - "num-traits", - "serde", - "smallvec", -] - -[[package]] -name = "rust-stemmers" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e46a2036019fdb888131db7a4c847a1063a7493f971ed94ea82c67eada63ca54" -dependencies = [ - "serde", - "serde_derive", -] - -[[package]] -name = "rust_decimal" -version = "1.42.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c5108e3d4d903e21aac27f12ba5377b6b34f9f44b325e4894c7924169d06995" -dependencies = [ - "arrayvec", - "borsh", - "bytes", - "num-traits", - "rand 0.8.6", - "rkyv", - "serde", - "serde_json", - "wasm-bindgen", -] - -[[package]] -name = "rustc-hash" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[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-pki-types" -version = "1.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" -dependencies = [ - "web-time", - "zeroize", -] - -[[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 = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "scrypt" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" -dependencies = [ - "password-hash", - "pbkdf2", - "salsa20", - "sha2", -] - -[[package]] -name = "seahash" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" - -[[package]] -name = "sec1" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" -dependencies = [ - "base16ct", - "der", - "generic-array 0.14.7", - "pkcs8", - "subtle", - "zeroize", -] - -[[package]] -name = "secp256k1" -version = "0.31.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c3c81b43dc2d8877c216a3fccf76677ee1ebccd429566d3e67447290d0c42b2" -dependencies = [ - "bitcoin_hashes", - "rand 0.9.4", - "secp256k1-sys", -] - -[[package]] -name = "secp256k1-sys" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcb913707158fadaf0d8702c2db0e857de66eb003ccfdda5924b5f5ac98efb38" -dependencies = [ - "cc", -] - -[[package]] -name = "seize" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" -dependencies = [ - "serde", - "serde_core", -] - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "serde_json" -version = "1.0.150" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_path_to_error" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" -dependencies = [ - "itoa", - "serde", - "serde_core", -] - -[[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 0.2.17", - "digest", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest", -] - -[[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 = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - -[[package]] -name = "sif-itree" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7f45b8998ced5134fb1d75732c77842a3e888f19c1ff98481822e8fbfbf930b" - -[[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 = "signature" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" -dependencies = [ - "digest", - "rand_core 0.6.4", -] - -[[package]] -name = "simdutf8" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" - -[[package]] -name = "simple_asn1" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" -dependencies = [ - "num-bigint", - "num-traits", - "thiserror", - "time", -] - -[[package]] -name = "siphasher" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -dependencies = [ - "serde", -] - -[[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 = "spade" -version = "2.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9699399fd9349b00b184f5635b074f9ec93afffef30c853f8c875b32c0f8c7fa" -dependencies = [ - "hashbrown 0.16.1", - "num-traits", - "robust", - "smallvec", -] - -[[package]] -name = "spin" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" -dependencies = [ - "lock_api", -] - -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "storekey" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd9a94571bde7369ecaac47cec2e6844642d99166bd452fbd8def74b5b917b2f" -dependencies = [ - "bytes", - "storekey-derive", - "uuid", -] - -[[package]] -name = "storekey-derive" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6079d53242246522ec982de613c5c952cc7b1380ef2f8622fcdab9bfe73c0098" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "string_cache" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" -dependencies = [ - "new_debug_unreachable", - "parking_lot", - "phf_shared 0.11.3", - "precomputed-hash", - "serde", -] - -[[package]] -name = "string_cache_codegen" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", - "proc-macro2", - "quote", -] - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "surrealdb" -version = "3.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddeca7bd0efc07675ddc3ec7b8e5eba2e8268b4d6c8bbcd31fcdfeab6f0484a" -dependencies = [ - "anyhow", - "async-channel", - "boxcar", - "chrono", - "futures", - "getrandom 0.3.4", - "indexmap", - "js-sys", - "path-clean", - "reqwest", - "ring", - "rustls-pki-types", - "semver", - "serde", - "serde_json", - "surrealdb-core", - "surrealdb-types", - "surrealdb-types-derive", - "tokio", - "tokio-tungstenite 0.28.0", - "tokio-tungstenite-wasm", - "tokio-util", - "tracing", - "url", - "uuid", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasmtimer", - "web-sys", -] - -[[package]] -name = "surrealdb-collections" -version = "3.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "430d104b7ecdcfec487cfb6eb227475ec648f03c2bf991e26641bc309607b7b8" -dependencies = [ - "revision", - "storekey", -] - -[[package]] -name = "surrealdb-core" -version = "3.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68e4926bc2f0cd6d1eb6a95da2024432510cbf37571133c22c62ddce3d98e22a" -dependencies = [ - "addr", - "affinitypool", - "ahash 0.8.12", - "ammonia", - "anyhow", - "argon2", - "async-channel", - "async-stream", - "base64", - "bcrypt", - "blake3", - "bytes", - "chrono", - "ciborium", - "dashmap", - "deunicode", - "diskann", - "diskann-utils", - "diskann-vector", - "dmp", - "ext-sort", - "fastnum", - "fst", - "futures", - "fuzzy-matcher", - "geo", - "geo-types", - "getrandom 0.3.4", - "half", - "headers", - "hex", - "http", - "humantime", - "ipnet", - "jsonwebtoken", - "lexicmp", - "md-5", - "memchr", - "mime", - "ndarray", - "ndarray-stats", - "num-traits", - "num_cpus", - "object_store", - "parking_lot", - "path-clean", - "pbkdf2", - "phf 0.13.1", - "pin-project-lite", - "quick_cache", - "radix_trie", - "rand 0.9.4", - "rand_core 0.6.4", - "rayon", - "reblessive", - "regex", - "revision", - "ring", - "roaring", - "rust-stemmers", - "rust_decimal", - "scrypt", - "semver", - "serde", - "serde_json", - "sha1", - "sha2", - "storekey", - "strsim", - "subtle", - "surrealdb-collections", - "surrealdb-protocol", - "surrealdb-rocksdb", - "surrealdb-strand", - "surrealdb-types", - "surrealmx", - "sysinfo", - "tempfile", - "thiserror", - "tokio", - "tokio-util", - "tracing", - "ulid", - "unicase", - "url", - "uuid", - "vart", - "wasm-bindgen-futures", - "wasmtimer", - "web-time", -] - -[[package]] -name = "surrealdb-librocksdb-sys" -version = "0.18.3+11.0.0-4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25b82a18c7fa4b57206784a1a31e7b942ae1d3e24493e0c733019a409b2b4bea" -dependencies = [ - "bindgen", - "bzip2-sys", - "cc", - "libc", - "libz-sys", - "lz4-sys", - "zstd-sys", -] - -[[package]] -name = "surrealdb-protocol" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f4e06f586c9179a02349b88b0c18e3a0850c55431aa513e0cd66529c00da1af" -dependencies = [ - "anyhow", - "async-trait", - "bytes", - "chrono", - "flatbuffers", - "futures", - "geo", - "prost", - "prost-types", - "rust_decimal", - "semver", - "serde", - "serde_json", - "tonic", - "tonic-prost", - "uuid", -] - -[[package]] -name = "surrealdb-rocksdb" -version = "0.24.0-surreal.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02e8c3a004982458af159bcbf369e41663d538cd4a291a49c0d4a2fb373cbb7e" -dependencies = [ - "libc", - "surrealdb-librocksdb-sys", -] - -[[package]] -name = "surrealdb-strand" -version = "3.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95440b332afe817529f0c6dc7440eb512cd43dd71325af0aeade47deaa843d5e" -dependencies = [ - "revision", - "serde", - "storekey", -] - -[[package]] -name = "surrealdb-types" -version = "3.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dcc4329473a0d81be33a39b38e576e252d037d38ac4dd057283f1035826d0ae" -dependencies = [ - "anyhow", - "async-channel", - "bytes", - "castaway", - "chrono", - "flatbuffers", - "geo", - "hex", - "http", - "papaya", - "rand 0.9.4", - "regex", - "reqwest", - "rust_decimal", - "semver", - "serde", - "serde_json", - "surrealdb-protocol", - "surrealdb-types-derive", - "tracing", - "ulid", - "url", - "uuid", -] - -[[package]] -name = "surrealdb-types-derive" -version = "3.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7de5361377f98a936bb6416daa769811bc66bb94e5804abc6c26ac18c7f01870" -dependencies = [ - "heck 0.4.1", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "surrealmx" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e8a87f050a4860832ccf4a53e43ea264e98d27618983602b5c575e6df296054" -dependencies = [ - "arc-swap", - "bincode 2.0.1", - "bytes", - "crossbeam-deque", - "crossbeam-queue", - "crossbeam-skiplist", - "lz4", - "papaya", - "parking_lot", - "serde", - "smallvec", - "thiserror", - "tracing", - "web-time", -] - -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "2.0.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[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 = "synchronoise" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3dbc01390fc626ce8d1cffe3376ded2b72a11bb70e1c75f404a210e4daa4def2" -dependencies = [ - "crossbeam-queue", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "sysinfo" -version = "0.37.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16607d5caffd1c07ce073528f9ed972d88db15dd44023fa57142963be3feb11f" -dependencies = [ - "libc", - "memchr", - "ntapi", - "objc2-core-foundation", - "objc2-io-kit", - "windows", -] - -[[package]] -name = "tangle" -version = "0.1.0" -dependencies = [ - "futures-util", - "serde_json", - "tangle_protocol", - "tangle_runtime", - "tangle_store_surreal", - "tangle_test_support", - "tokio", - "tokio-tungstenite 0.29.0", - "tracing", - "tracing-subscriber", -] - -[[package]] -name = "tangle_bench" -version = "0.1.0" -dependencies = [ - "serde_json", - "sha2", - "tangle_groups", - "tangle_protocol", - "tangle_runtime", - "tangle_store_pocket", - "tangle_test_support", -] - -[[package]] -name = "tangle_core" -version = "0.1.0" -dependencies = [ - "serde_json", - "tangle_crypto", - "tangle_nips", - "tangle_protocol", - "tangle_store", - "tangle_test_support", -] - -[[package]] -name = "tangle_crypto" -version = "0.1.0" -dependencies = [ - "k256", - "sha2", - "tangle_protocol", - "tokio", -] - -[[package]] -name = "tangle_groups" -version = "0.1.0" -dependencies = [ - "serde", - "serde_json", - "tangle_crypto", - "tangle_protocol", -] - -[[package]] -name = "tangle_nips" -version = "0.1.0" -dependencies = [ - "serde_json", - "tangle_protocol", -] - -[[package]] -name = "tangle_protocol" -version = "0.1.0" -dependencies = [ - "serde_json", -] - -[[package]] -name = "tangle_runtime" -version = "0.1.0" -dependencies = [ - "axum", - "futures-util", - "http", - "serde", - "serde_json", - "sha2", - "tangle_core", - "tangle_crypto", - "tangle_groups", - "tangle_nips", - "tangle_protocol", - "tangle_store", - "tangle_store_pocket", - "tangle_store_surreal", - "tangle_test_support", - "tokio", - "tokio-tungstenite 0.28.0", - "tower", - "tracing", - "url", -] - -[[package]] -name = "tangle_store" -version = "0.1.0" -dependencies = [ - "tangle_nips", - "tangle_protocol", -] - -[[package]] -name = "tangle_store_pocket" -version = "0.1.0" -dependencies = [ - "pocket-db", - "pocket-types", -] - -[[package]] -name = "tangle_store_surreal" -version = "0.1.0" -dependencies = [ - "serde_json", - "sha2", - "surrealdb", - "tangle_nips", - "tangle_protocol", - "tangle_store", - "tangle_test_support", - "tokio", -] - -[[package]] -name = "tangle_test_support" -version = "0.1.0" -dependencies = [ - "k256", - "serde", - "serde_json", - "tangle_crypto", - "tangle_groups", - "tangle_nips", - "tangle_protocol", - "tangle_store", -] - -[[package]] -name = "tap" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" - -[[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 = "tendril" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" -dependencies = [ - "futf", - "mac", - "utf-8", -] - -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl", -] - -[[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 2.0.117", -] - -[[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.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" -dependencies = [ - "deranged", - "itoa", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" - -[[package]] -name = "time-macros" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tinystr" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tinyvec" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - -[[package]] -name = "tokio" -version = "1.52.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" -dependencies = [ - "bytes", - "libc", - "mio", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-macros" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "tokio-stream" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tokio-tungstenite" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" -dependencies = [ - "futures-util", - "log", - "tokio", - "tungstenite 0.28.0", -] - -[[package]] -name = "tokio-tungstenite" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" -dependencies = [ - "futures-util", - "log", - "tokio", - "tungstenite 0.29.0", -] - -[[package]] -name = "tokio-tungstenite-wasm" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccecee909c02b8863f9bda87253127eb4da0e7e1342330b2583fbc4d1795c2f8" -dependencies = [ - "bytes", - "futures-channel", - "futures-util", - "http", - "httparse", - "js-sys", - "thiserror", - "tokio", - "tokio-tungstenite 0.28.0", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "tokio-util" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" -dependencies = [ - "bytes", - "futures-core", - "futures-io", - "futures-sink", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "toml_datetime" -version = "1.1.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_edit" -version = "0.25.12+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" -dependencies = [ - "indexmap", - "toml_datetime", - "toml_parser", - "winnow", -] - -[[package]] -name = "toml_parser" -version = "1.1.2+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" -dependencies = [ - "winnow", -] - -[[package]] -name = "tonic" -version = "0.14.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" -dependencies = [ - "async-trait", - "axum", - "base64", - "bytes", - "h2", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-timeout", - "hyper-util", - "percent-encoding", - "pin-project", - "socket2", - "sync_wrapper", - "tokio", - "tokio-stream", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tonic-prost" -version = "0.14.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" -dependencies = [ - "bytes", - "prost", - "tonic", -] - -[[package]] -name = "tower" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" -dependencies = [ - "futures-core", - "futures-util", - "indexmap", - "pin-project-lite", - "slab", - "sync_wrapper", - "tokio", - "tokio-util", - "tower-layer", - "tower-service", - "tracing", -] - -[[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 = [ - "log", - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[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-serde" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" -dependencies = [ - "serde", - "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", - "serde", - "serde_json", - "sharded-slab", - "smallvec", - "thread_local", - "tracing", - "tracing-core", - "tracing-log", - "tracing-serde", -] - -[[package]] -name = "trice" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3aaab10ae9fac0b10f392752bf56f0fd20845f39037fec931e8537b105b515a" -dependencies = [ - "js-sys", - "wasm-bindgen", - "web-sys", -] - -[[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.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" -dependencies = [ - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand 0.9.4", - "sha1", - "thiserror", - "url", - "utf-8", -] - -[[package]] -name = "tungstenite" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" -dependencies = [ - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand 0.9.4", - "sha1", - "thiserror", -] - -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - -[[package]] -name = "ulid" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" -dependencies = [ - "rand 0.9.4", - "serde", - "web-time", + "unicode-ident", ] [[package]] -name = "unicase" -version = "2.9.0" +name = "quote" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] [[package]] -name = "unicode-ident" -version = "1.0.24" +name = "r-efi" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] -name = "unicode-xid" -version = "0.2.6" +name = "rand" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "rand_core 0.6.4", +] [[package]] -name = "untrusted" -version = "0.7.1" +name = "rand" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] [[package]] -name = "untrusted" +name = "rand_chacha" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] [[package]] -name = "unty" -version = "0.0.4" +name = "rand_core" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] [[package]] -name = "url" -version = "2.5.8" +name = "rand_core" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", + "getrandom 0.3.4", ] [[package]] -name = "urlencoding" -version = "2.1.3" +name = "rfc6979" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] [[package]] -name = "utf-8" -version = "0.7.6" +name = "rustc_version" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] [[package]] -name = "utf8_iter" -version = "1.0.4" +name = "ryu" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] -name = "uuid" -version = "1.23.2" +name = "sec1" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ - "getrandom 0.4.2", - "js-sys", - "serde_core", - "wasm-bindgen", + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", ] [[package]] -name = "valuable" -version = "0.1.1" +name = "secp256k1" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +checksum = "2c3c81b43dc2d8877c216a3fccf76677ee1ebccd429566d3e67447290d0c42b2" +dependencies = [ + "bitcoin_hashes", + "rand 0.9.4", + "secp256k1-sys", +] [[package]] -name = "vart" -version = "0.9.3" +name = "secp256k1-sys" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1982d899e57d646498709735f16e9224cf1e8680676ad687f930cf8b5b555ae" +checksum = "dcb913707158fadaf0d8702c2db0e857de66eb003ccfdda5924b5f5ac98efb38" +dependencies = [ + "cc", +] [[package]] -name = "vcpkg" -version = "0.2.15" +name = "semver" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] -name = "version_check" -version = "0.9.5" +name = "serde" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] [[package]] -name = "virtue" -version = "0.0.18" +name = "serde_core" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] [[package]] -name = "walkdir" -version = "2.5.0" +name = "serde_derive" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ - "same-file", - "winapi-util", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "want" -version = "0.3.1" +name = "serde_json" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ - "try-lock", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", ] [[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" +name = "serde_path_to_error" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] [[package]] -name = "wasip2" -version = "1.0.3+wasi-0.2.9" +name = "serde_urlencoded" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" dependencies = [ - "wit-bindgen 0.57.1", + "form_urlencoded", + "itoa", + "ryu", + "serde", ] [[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +name = "sha1" +version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ - "wit-bindgen 0.51.0", + "cfg-if", + "cpufeatures", + "digest", ] [[package]] -name = "wasm-bindgen" -version = "0.2.122" +name = "sha2" +version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "once_cell", - "rustversion", - "serde", - "wasm-bindgen-macro", - "wasm-bindgen-shared", + "cpufeatures", + "digest", ] [[package]] -name = "wasm-bindgen-futures" -version = "0.4.72" +name = "shlex" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" -dependencies = [ - "js-sys", - "wasm-bindgen", -] +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] -name = "wasm-bindgen-macro" -version = "0.2.122" +name = "signature" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "quote", - "wasm-bindgen-macro-support", + "digest", + "rand_core 0.6.4", ] [[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.122" +name = "siphasher" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 2.0.117", - "wasm-bindgen-shared", -] +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] -name = "wasm-bindgen-shared" -version = "0.2.122" +name = "slab" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" -dependencies = [ - "unicode-ident", -] +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] -name = "wasm-encoder" -version = "0.244.0" +name = "smallvec" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] -name = "wasm-metadata" -version = "0.244.0" +name = "socket2" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", + "libc", + "windows-sys", ] [[package]] -name = "wasm-streams" -version = "0.5.0" +name = "spki" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", + "base64ct", + "der", ] [[package]] -name = "wasmparser" -version = "0.244.0" +name = "stable_deref_trait" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", -] +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] -name = "wasmtimer" -version = "0.4.3" +name = "subtle" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c598d6b99ea013e35844697fc4670d08339d5cda15588f193c6beedd12f644b" -dependencies = [ - "futures", - "js-sys", - "parking_lot", - "pin-utils", - "wasm-bindgen", -] +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] -name = "web-sys" -version = "0.3.99" +name = "syn" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ - "js-sys", - "wasm-bindgen", + "proc-macro2", + "quote", + "unicode-ident", ] [[package]] -name = "web-time" -version = "1.1.0" +name = "sync_wrapper" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" [[package]] -name = "web_atoms" -version = "0.1.3" +name = "synchronoise" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414" +checksum = "3dbc01390fc626ce8d1cffe3376ded2b72a11bb70e1c75f404a210e4daa4def2" dependencies = [ - "phf 0.11.3", - "phf_codegen", - "string_cache", - "string_cache_codegen", + "crossbeam-queue", ] [[package]] -name = "winapi" -version = "0.3.9" +name = "synstructure" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +name = "tangle" +version = "0.1.0" +dependencies = [ + "serde_json", + "tangle_protocol", + "tangle_runtime", + "tangle_test_support", +] [[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +name = "tangle_bench" +version = "0.1.0" dependencies = [ - "windows-sys 0.61.2", + "serde_json", + "sha2", + "tangle_groups", + "tangle_protocol", + "tangle_runtime", + "tangle_store_pocket", + "tangle_test_support", ] [[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +name = "tangle_crypto" +version = "0.1.0" +dependencies = [ + "k256", + "sha2", + "tangle_protocol", + "tokio", +] [[package]] -name = "windows" -version = "0.61.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +name = "tangle_groups" +version = "0.1.0" dependencies = [ - "windows-collections", - "windows-core 0.61.2", - "windows-future", - "windows-link 0.1.3", - "windows-numerics", + "serde", + "serde_json", + "tangle_crypto", + "tangle_protocol", ] [[package]] -name = "windows-collections" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +name = "tangle_protocol" +version = "0.1.0" dependencies = [ - "windows-core 0.61.2", + "serde_json", ] [[package]] -name = "windows-core" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +name = "tangle_runtime" +version = "0.1.0" dependencies = [ - "windows-implement", - "windows-interface", - "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.4.2", + "axum", + "http", + "serde", + "serde_json", + "tangle_crypto", + "tangle_groups", + "tangle_protocol", + "tangle_store_pocket", + "tangle_test_support", + "tokio", + "tower", + "tracing", ] [[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +name = "tangle_store_pocket" +version = "0.1.0" dependencies = [ - "windows-implement", - "windows-interface", - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", + "pocket-db", + "pocket-types", ] [[package]] -name = "windows-future" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +name = "tangle_test_support" +version = "0.1.0" dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", - "windows-threading", + "k256", + "serde_json", + "tangle_crypto", + "tangle_groups", + "tangle_protocol", ] [[package]] -name = "windows-implement" -version = "0.60.2" +name = "thiserror" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "thiserror-impl", ] [[package]] -name = "windows-interface" -version = "0.59.3" +name = "thiserror-impl" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] -name = "windows-link" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-numerics" -version = "0.2.0" +name = "tinystr" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", + "displaydoc", + "zerovec", ] [[package]] -name = "windows-result" -version = "0.3.4" +name = "tokio" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ - "windows-link 0.1.3", + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys", ] [[package]] -name = "windows-result" -version = "0.4.1" +name = "tokio-macros" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ - "windows-link 0.2.1", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "windows-strings" -version = "0.4.2" +name = "tokio-tungstenite" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" dependencies = [ - "windows-link 0.1.3", + "futures-util", + "log", + "tokio", + "tungstenite", ] [[package]] -name = "windows-strings" -version = "0.5.1" +name = "tower" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ - "windows-link 0.2.1", + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", ] [[package]] -name = "windows-sys" -version = "0.52.0" +name = "tower-layer" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets", -] +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" [[package]] -name = "windows-sys" -version = "0.61.2" +name = "tower-service" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link 0.2.1", -] +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] -name = "windows-targets" -version = "0.52.6" +name = "tracing" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", ] [[package]] -name = "windows-threading" -version = "0.1.0" +name = "tracing-attributes" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ - "windows-link 0.1.3", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" +name = "tracing-core" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] [[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" +name = "tungstenite" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.4", + "sha1", + "thiserror", +] [[package]] -name = "windows_i686_gnu" -version = "0.52.6" +name = "typenum" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" +name = "unicode-ident" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] -name = "windows_i686_msvc" -version = "0.52.6" +name = "url" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] [[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" +name = "utf8_iter" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" +name = "version_check" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "winnow" -version = "1.0.3" +name = "wasip2" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "memchr", + "wit-bindgen", ] [[package]] -name = "wit-bindgen" -version = "0.51.0" +name = "winapi" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" dependencies = [ - "wit-bindgen-rust-macro", + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", ] [[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" +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck 0.5.0", - "wit-parser", -] +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] -name = "wit-bindgen-rust" -version = "0.51.0" +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck 0.5.0", - "indexmap", - "prettyplease", - "syn 2.0.117", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" +name = "windows-link" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.117", - "wit-bindgen-core", - "wit-bindgen-rust", -] +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] -name = "wit-component" -version = "0.244.0" +name = "windows-sys" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "anyhow", - "bitflags", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", + "windows-link", ] [[package]] -name = "wit-parser" -version = "0.244.0" +name = "wit-bindgen" +version = "0.57.1" 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", -] +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "writeable" @@ -5587,15 +1534,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] -name = "wyz" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" -dependencies = [ - "tap", -] - -[[package]] name = "yoke" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -5614,7 +1552,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", "synstructure", ] @@ -5635,7 +1573,7 @@ checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -5655,7 +1593,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", "synstructure", ] @@ -5664,20 +1602,6 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] [[package]] name = "zerotrie" @@ -5709,7 +1633,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -5717,13 +1641,3 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" - -[[package]] -name = "zstd-sys" -version = "2.0.16+zstd.1.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" -dependencies = [ - "cc", - "pkg-config", -] diff --git a/Cargo.toml b/Cargo.toml @@ -2,15 +2,11 @@ members = [ "crates/tangle", "crates/tangle_bench", - "crates/tangle_core", "crates/tangle_crypto", "crates/tangle_groups", - "crates/tangle_nips", "crates/tangle_protocol", "crates/tangle_runtime", - "crates/tangle_store", "crates/tangle_store_pocket", - "crates/tangle_store_surreal", "crates/tangle_test_support", ] resolver = "2" diff --git a/crates/tangle/Cargo.toml b/crates/tangle/Cargo.toml @@ -10,18 +10,11 @@ readme = "../../README" [dependencies] tangle_runtime = { path = "../tangle_runtime" } -tokio = { version = "1", features = ["rt", "signal"] } -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json"] } [dev-dependencies] -futures-util = "0.3" serde_json = "1" tangle_protocol = { path = "../tangle_protocol" } -tangle_store_surreal = { path = "../tangle_store_surreal" } tangle_test_support = { path = "../tangle_test_support" } -tokio = { version = "1", features = ["macros", "rt", "time"] } -tokio-tungstenite = "0.29" [lints] workspace = true diff --git a/crates/tangle/src/lib.rs b/crates/tangle/src/lib.rs @@ -7,25 +7,13 @@ pub const PACKAGE_VERSION: &str = env!("CARGO_PKG_VERSION"); pub const USAGE: &str = "\ usage: tangle [--version] - tangle migrate --config PATH - tangle run --config PATH - tangle event import --config PATH --input PATH - tangle event export --config PATH --output PATH - tangle projection rebuild --config PATH - tangle ops backup --config PATH --output DIR - tangle ops restore --config PATH --input DIR"; + tangle run --config PATH"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TangleCommand { Version, Help, - Migrate, Run, - EventImport, - EventExport, - ProjectionRebuild, - OpsBackup, - OpsRestore, } impl TangleCommand { @@ -33,27 +21,15 @@ impl TangleCommand { match self { Self::Version => "version", Self::Help => "help", - Self::Migrate => "migrate", Self::Run => "run", - Self::EventImport => "event import", - Self::EventExport => "event export", - Self::ProjectionRebuild => "projection rebuild", - Self::OpsBackup => "ops backup", - Self::OpsRestore => "ops restore", } } - - pub fn implemented(self) -> bool { - true - } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct TangleInvocation { command: TangleCommand, config_path: Option<String>, - input_path: Option<String>, - output_path: Option<String>, } impl TangleInvocation { @@ -61,21 +37,9 @@ impl TangleInvocation { Self { command, config_path, - input_path: None, - output_path: None, } } - pub fn with_input_path(mut self, input_path: Option<String>) -> Self { - self.input_path = input_path; - self - } - - pub fn with_output_path(mut self, output_path: Option<String>) -> Self { - self.output_path = output_path; - self - } - pub fn command(&self) -> TangleCommand { self.command } @@ -83,20 +47,11 @@ impl TangleInvocation { pub fn config_path(&self) -> Option<&str> { self.config_path.as_deref() } - - pub fn input_path(&self) -> Option<&str> { - self.input_path.as_deref() - } - - pub fn output_path(&self) -> Option<&str> { - self.output_path.as_deref() - } } #[derive(Debug, Clone, PartialEq, Eq)] pub enum TangleCliError { UnknownCommand(String), - MissingNestedCommand(&'static str), MissingOptionValue(&'static str), RepeatedOption(&'static str), UnexpectedArgument { command: String, argument: String }, @@ -106,9 +61,6 @@ impl fmt::Display for TangleCliError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::UnknownCommand(command) => write!(formatter, "unknown command: {command}"), - Self::MissingNestedCommand(command) => { - write!(formatter, "{command} command requires a nested command") - } Self::MissingOptionValue(option) => { write!(formatter, "{option} requires a value") } @@ -155,46 +107,10 @@ where let command = match first.as_str() { "--version" | "-V" => TangleCommand::Version, "--help" | "-h" | "help" => TangleCommand::Help, - "migrate" => TangleCommand::Migrate, "run" => TangleCommand::Run, - "event" => { - let Some(nested) = args.next() else { - return Err(TangleCliError::MissingNestedCommand("event")); - }; - match nested.as_str() { - "import" => TangleCommand::EventImport, - "export" => TangleCommand::EventExport, - _ => return Err(TangleCliError::UnknownCommand(format!("event {nested}"))), - } - } - "projection" => { - let Some(nested) = args.next() else { - return Err(TangleCliError::MissingNestedCommand("projection")); - }; - match nested.as_str() { - "rebuild" => TangleCommand::ProjectionRebuild, - _ => { - return Err(TangleCliError::UnknownCommand(format!( - "projection {nested}" - ))); - } - } - } - "ops" => { - let Some(nested) = args.next() else { - return Err(TangleCliError::MissingNestedCommand("ops")); - }; - match nested.as_str() { - "backup" => TangleCommand::OpsBackup, - "restore" => TangleCommand::OpsRestore, - _ => return Err(TangleCliError::UnknownCommand(format!("ops {nested}"))), - } - } _ => return Err(TangleCliError::UnknownCommand(first)), }; let mut config_path = None; - let mut input_path = None; - let mut output_path = None; while let Some(argument) = args.next() { match argument.as_str() { "--config" => { @@ -206,24 +122,6 @@ where }; config_path = Some(path); } - "--input" => { - if input_path.is_some() { - return Err(TangleCliError::RepeatedOption("--input")); - } - let Some(path) = args.next() else { - return Err(TangleCliError::MissingOptionValue("--input")); - }; - input_path = Some(path); - } - "--output" => { - if output_path.is_some() { - return Err(TangleCliError::RepeatedOption("--output")); - } - let Some(path) = args.next() else { - return Err(TangleCliError::MissingOptionValue("--output")); - }; - output_path = Some(path); - } _ => { return Err(TangleCliError::UnexpectedArgument { command: command.as_str().to_owned(), @@ -232,31 +130,13 @@ where } } } - if input_path.is_some() - && !matches!( - command, - TangleCommand::EventImport | TangleCommand::OpsRestore - ) - { - return Err(TangleCliError::UnexpectedArgument { - command: command.as_str().to_owned(), - argument: "--input".to_owned(), - }); - } - if output_path.is_some() - && !matches!( - command, - TangleCommand::EventExport | TangleCommand::OpsBackup - ) - { + if config_path.is_some() && command != TangleCommand::Run { return Err(TangleCliError::UnexpectedArgument { command: command.as_str().to_owned(), - argument: "--output".to_owned(), + argument: "--config".to_owned(), }); } - Ok(TangleInvocation::new(command, config_path) - .with_input_path(input_path) - .with_output_path(output_path)) + Ok(TangleInvocation::new(command, config_path)) } pub fn require_config_path(invocation: &TangleInvocation) -> Result<&str, TangleCliError> { @@ -265,782 +145,102 @@ pub fn require_config_path(invocation: &TangleInvocation) -> Result<&str, Tangle .ok_or(TangleCliError::MissingOptionValue("--config")) } -pub fn require_input_path(invocation: &TangleInvocation) -> Result<&str, TangleCliError> { - invocation - .input_path() - .ok_or(TangleCliError::MissingOptionValue("--input")) -} - -pub fn require_output_path(invocation: &TangleInvocation) -> Result<&str, TangleCliError> { - invocation - .output_path() - .ok_or(TangleCliError::MissingOptionValue("--output")) -} - -pub fn migrate_output(report: tangle_runtime::RuntimeMigrationReport) -> String { - format!( - "migrations applied: {}\nmigrations already applied: {}\nmigrations total: {}", - report.applied(), - report.already_applied(), - report.total() - ) -} - -pub fn event_import_output(report: tangle_runtime::RuntimeEventImportReport) -> String { - format!( - "events total: {}\nevents inserted: {}\nevents duplicate: {}\nevents projected: {}\nevents skipped: {}", - report.total(), - report.inserted(), - report.duplicate(), - report.projected(), - report.skipped() - ) -} - -pub fn event_export_output(report: tangle_runtime::RuntimeEventExportReport) -> String { - format!("events exported: {}", report.exported()) -} - -pub fn projection_rebuild_output(report: tangle_runtime::RuntimeProjectionRebuildReport) -> String { - format!( - "events scanned: {}\nevents rebuilt: {}\nlistings projected: {}\nevents skipped: {}", - report.scanned(), - report.rebuilt(), - report.projected(), - report.skipped() - ) -} - -pub fn ops_backup_output(report: &tangle_runtime::RuntimeBackupReport) -> String { - format!( - "backup directory: {}\nraw events: {}\nraw events sha256: {}\nsurrealdb export available: {}\nmanifest: {}\nmanifest sha256: {}", - report.output_dir().display(), - report.raw_event_count(), - report.raw_events_sha256(), - report.surrealdb_export_available(), - report.manifest_path().display(), - report.manifest_sha256() - ) -} - -pub fn ops_restore_output(report: &tangle_runtime::RuntimeRestoreReport) -> String { - format!( - "restore directory: {}\nraw events: {}\nraw events sha256: {}\nevents inserted: {}\nevents duplicate: {}\nevents rebuilt: {}\nlistings projected: {}\nevents skipped: {}", - report.input_dir().display(), - report.raw_event_count(), - report.raw_events_sha256(), - report.import_report().inserted(), - report.import_report().duplicate(), - report.rebuild_report().rebuilt(), - report.rebuild_report().projected(), - report.import_report().skipped() + report.rebuild_report().skipped() - ) -} - -pub async fn migrate_with_config(path: &str) -> Result<String, String> { - let config = tangle_runtime::load_runtime_config(path).map_err(|error| error.to_string())?; - initialize_tracing(config.tracing_config())?; - let report = tangle_runtime::migrate_runtime_database(&config) - .await - .map_err(|error| error.to_string())?; - Ok(migrate_output(report)) -} - -pub async fn event_import_with_config( - config_path: &str, - input_path: &str, -) -> Result<String, String> { - let config = - tangle_runtime::load_runtime_config(config_path).map_err(|error| error.to_string())?; - initialize_tracing(config.tracing_config())?; - let report = tangle_runtime::import_events_from_path(&config, input_path) - .await +pub fn run_with_config(config_path: &str) -> Result<String, String> { + let report = tangle_runtime::open_base_relay_from_config_path(config_path) .map_err(|error| error.to_string())?; - Ok(event_import_output(report)) -} - -pub async fn event_export_with_config( - config_path: &str, - output_path: &str, -) -> Result<String, String> { - let config = - tangle_runtime::load_runtime_config(config_path).map_err(|error| error.to_string())?; - initialize_tracing(config.tracing_config())?; - let report = tangle_runtime::export_events_to_path(&config, output_path) - .await - .map_err(|error| error.to_string())?; - Ok(event_export_output(report)) -} - -pub async fn projection_rebuild_with_config(config_path: &str) -> Result<String, String> { - let config = - tangle_runtime::load_runtime_config(config_path).map_err(|error| error.to_string())?; - initialize_tracing(config.tracing_config())?; - let report = tangle_runtime::rebuild_projections(&config) - .await - .map_err(|error| error.to_string())?; - Ok(projection_rebuild_output(report)) -} - -pub async fn ops_backup_with_config(config_path: &str, output_dir: &str) -> Result<String, String> { - let config = - tangle_runtime::load_runtime_config(config_path).map_err(|error| error.to_string())?; - initialize_tracing(config.tracing_config())?; - let report = tangle_runtime::backup_runtime_database(&config, output_dir) - .await - .map_err(|error| error.to_string())?; - Ok(ops_backup_output(&report)) -} - -pub async fn ops_restore_with_config(config_path: &str, input_dir: &str) -> Result<String, String> { - let config = - tangle_runtime::load_runtime_config(config_path).map_err(|error| error.to_string())?; - initialize_tracing(config.tracing_config())?; - let report = tangle_runtime::restore_runtime_database(&config, input_dir) - .await - .map_err(|error| error.to_string())?; - Ok(ops_restore_output(&report)) -} - -pub async fn run_with_config(path: &str) -> Result<(), String> { - let config = tangle_runtime::load_runtime_config(path).map_err(|error| error.to_string())?; - initialize_tracing(config.tracing_config())?; - let (shutdown, _) = tangle_runtime::GracefulShutdownSignal::new(); - let signal = shutdown.clone(); - #[rustfmt::skip] - tokio::spawn(async move { if tokio::signal::ctrl_c().await.is_ok() { signal.request_shutdown(); } }); - tangle_runtime::run_runtime_server(config, shutdown) - .await - .map(|_| ()) - .map_err(|error| error.to_string()) -} - -fn initialize_tracing(config: &tangle_runtime::RuntimeTracingConfig) -> Result<(), String> { - if !config.enabled() { - return Ok(()); - } - let filter = tracing_subscriber::EnvFilter::try_new(config.filter()) - .map_err(|error| format!("tracing filter is invalid: {error}"))?; - match config.format() { - tangle_runtime::RuntimeTracingFormat::Compact => { - let _ = tracing_subscriber::fmt() - .with_env_filter(filter) - .with_writer(std::io::stderr) - .try_init(); - } - tangle_runtime::RuntimeTracingFormat::Json => { - let _ = tracing_subscriber::fmt() - .with_env_filter(filter) - .with_writer(std::io::stderr) - .json() - .try_init(); - } - } - #[rustfmt::skip] - tracing::info!(filter = config.filter(), format = config.format().as_str(), "tracing initialized"); - Ok(()) + Ok(format!( + "relay url: {}\npocket data directory: {}\ngroups enabled: {}\nreadiness: {}", + report.relay_url(), + report.data_directory().display(), + report.groups_enabled(), + report.readiness().response().status + )) } #[cfg(test)] mod tests { use super::{ PACKAGE_NAME, PACKAGE_VERSION, TangleCliError, TangleCommand, TangleInvocation, - event_export_output, event_export_with_config, event_import_output, - event_import_with_config, initialize_tracing, migrate_output, migrate_with_config, - ops_backup_output, ops_backup_with_config, ops_restore_output, ops_restore_with_config, - parse_tangle_command, parse_tangle_invocation, projection_rebuild_output, - projection_rebuild_with_config, require_config_path, require_input_path, - require_output_path, run_with_config, usage_output, version_output, - }; - use std::{ - fs, - path::{Path, PathBuf}, - }; - use tangle_runtime::{ - RuntimeBackupReport, RuntimeEventExportReport, RuntimeEventImportReport, - RuntimeMigrationReport, RuntimeProjectionRebuildReport, RuntimeRestoreReport, - RuntimeTracingConfig, RuntimeTracingFormat, + parse_tangle_invocation, require_config_path, usage_output, version_output, }; #[test] - fn package_name_is_tangle() { + fn package_constants_track_cargo_metadata() { assert_eq!(PACKAGE_NAME, "tangle"); - } - - #[test] - fn package_version_matches_manifest() { assert_eq!(PACKAGE_VERSION, "0.1.0"); - } - - #[test] - fn version_output_contains_package_and_version() { assert_eq!(version_output(), "tangle 0.1.0"); } #[test] - fn tracing_setup_ignores_disabled_config_and_rejects_bad_filters() { - assert_eq!( - initialize_tracing(&RuntimeTracingConfig::disabled()), - Ok(()) - ); - let invalid = - RuntimeTracingConfig::new(true, "bad[", RuntimeTracingFormat::Compact).expect("config"); - assert!( - initialize_tracing(&invalid) - .expect_err("invalid filter") - .starts_with("tracing filter is invalid:") - ); - } - - #[test] - fn tracing_setup_accepts_compact_format() { - let config = - RuntimeTracingConfig::new(true, "info,tangle=info", RuntimeTracingFormat::Compact) - .expect("compact tracing config"); - - assert_eq!(initialize_tracing(&config), Ok(())); - } - - #[tokio::test] - async fn runtime_command_wrappers_report_config_load_errors() { - let missing = "missing-runtime-config.json"; - - for error in [ - migrate_with_config(missing).await.expect_err("migrate"), - event_import_with_config(missing, "events.jsonl") - .await - .expect_err("import"), - event_export_with_config(missing, "events.jsonl") - .await - .expect_err("export"), - projection_rebuild_with_config(missing) - .await - .expect_err("rebuild"), - ops_backup_with_config(missing, "backup") - .await - .expect_err("backup"), - ops_restore_with_config(missing, "backup") - .await - .expect_err("restore"), - run_with_config(missing).await.expect_err("run"), - ] { - assert!(error.contains("failed to read runtime config")); - } - } - - #[tokio::test] - async fn runtime_command_wrappers_report_tracing_errors() { - let root = temp_root("tracing-errors"); - let config_path = root.join("runtime.json"); - fs::create_dir_all(&root).expect("runtime root"); - write_memory_config(&config_path, "tangle_cli_tracing", Some("bad[")); - - for error in [ - migrate_with_config(path_str(&config_path)) - .await - .expect_err("migrate"), - event_import_with_config(path_str(&config_path), "events.jsonl") - .await - .expect_err("import"), - event_export_with_config(path_str(&config_path), "events.jsonl") - .await - .expect_err("export"), - projection_rebuild_with_config(path_str(&config_path)) - .await - .expect_err("rebuild"), - ops_backup_with_config(path_str(&config_path), path_str(&root.join("backup"))) - .await - .expect_err("backup"), - ops_restore_with_config(path_str(&config_path), path_str(&root.join("backup"))) - .await - .expect_err("restore"), - run_with_config(path_str(&config_path)) - .await - .expect_err("run"), - ] { - assert!(error.starts_with("tracing filter is invalid:")); - } - - fs::remove_dir_all(&root).expect("remove runtime root"); - } - - #[tokio::test] - async fn runtime_command_wrappers_report_operation_errors() { - let root = temp_root("operation-errors"); - let memory_config_path = root.join("memory-runtime.json"); - let store_error_config_path = root.join("store-error-runtime.json"); - let db_file = root.join("not-a-database-dir"); - let backup_file = root.join("backup-file"); - fs::create_dir_all(&root).expect("runtime root"); - fs::write(&db_file, "db").expect("db file"); - fs::write(&backup_file, "backup").expect("backup file"); - write_memory_config(&memory_config_path, "tangle_cli_operation", None); - write_rocksdb_config( - &store_error_config_path, - &db_file, - "tangle_cli_operation_error", - ); - - let missing_events = root.join("missing-events.jsonl"); - let missing_restore = root.join("missing-restore"); - let output_events = root.join("events.jsonl"); - let file_errors = [ - event_import_with_config(path_str(&memory_config_path), path_str(&missing_events)) - .await - .expect_err("import"), - ops_backup_with_config(path_str(&memory_config_path), path_str(&backup_file)) - .await - .expect_err("backup"), - ops_restore_with_config(path_str(&memory_config_path), path_str(&missing_restore)) - .await - .expect_err("restore"), - ]; - let store_errors = [ - migrate_with_config(path_str(&store_error_config_path)) - .await - .expect_err("migrate"), - event_export_with_config(path_str(&store_error_config_path), path_str(&output_events)) - .await - .expect_err("export"), - projection_rebuild_with_config(path_str(&store_error_config_path)) - .await - .expect_err("rebuild"), - run_with_config(path_str(&store_error_config_path)) - .await - .expect_err("run"), - ]; - - assert!( - file_errors - .iter() - .any(|error| error.contains("failed to read event import file")) - ); - assert!( - file_errors - .iter() - .any(|error| error.contains("failed to read backup manifest file")) - ); - assert!( - file_errors - .iter() - .any(|error| error.contains("failed to create backup directory")) - ); - assert!(!store_errors.is_empty()); - - fs::remove_dir_all(&root).expect("remove runtime root"); - } - - #[test] - fn usage_output_lists_supported_command_model() { + fn usage_lists_only_v2_command_surface() { assert_eq!( usage_output(), - "usage:\n tangle [--version]\n tangle migrate --config PATH\n tangle run --config PATH\n tangle event import --config PATH --input PATH\n tangle event export --config PATH --output PATH\n tangle projection rebuild --config PATH\n tangle ops backup --config PATH --output DIR\n tangle ops restore --config PATH --input DIR" + "usage:\n tangle [--version]\n tangle run --config PATH" ); } #[test] - fn command_model_parses_known_commands() { - let cases = [ - (Vec::<&str>::new(), TangleCommand::Help, "help"), - (vec!["--version"], TangleCommand::Version, "version"), - (vec!["-V"], TangleCommand::Version, "version"), - (vec!["--help"], TangleCommand::Help, "help"), - (vec!["help"], TangleCommand::Help, "help"), - (vec!["migrate"], TangleCommand::Migrate, "migrate"), - (vec!["run"], TangleCommand::Run, "run"), - ( - vec!["event", "import"], - TangleCommand::EventImport, - "event import", - ), - ( - vec!["event", "export"], - TangleCommand::EventExport, - "event export", - ), - ( - vec!["projection", "rebuild"], - TangleCommand::ProjectionRebuild, - "projection rebuild", - ), - ( - vec!["ops", "backup"], - TangleCommand::OpsBackup, - "ops backup", - ), - ( - vec!["ops", "restore"], - TangleCommand::OpsRestore, - "ops restore", - ), - ]; - - for (args, expected, label) in cases { - assert_eq!(parse_tangle_command(args).expect("command"), expected); - assert_eq!(expected.as_str(), label); - assert!(expected.implemented()); - } - } - - #[test] - fn command_model_parses_ops_backup_output_option() { - let invocation = parse_tangle_invocation([ - "ops", - "backup", - "--config", - "runtime.json", - "--output", - "backup-dir", - ]) - .expect("invocation"); - assert_eq!(invocation.command(), TangleCommand::OpsBackup); + fn parse_tangle_invocation_accepts_help_version_and_run() { assert_eq!( - require_config_path(&invocation).expect("config"), - "runtime.json" + parse_tangle_invocation(Vec::<&str>::new()).expect("empty"), + TangleInvocation::new(TangleCommand::Help, None) ); assert_eq!( - require_output_path(&invocation).expect("output"), - "backup-dir" + parse_tangle_invocation(["--version"]).expect("version"), + TangleInvocation::new(TangleCommand::Version, None) ); - } - - #[test] - fn command_model_parses_ops_restore_input_option() { - let invocation = parse_tangle_invocation([ - "ops", - "restore", - "--config", - "runtime.json", - "--input", - "backup-dir", - ]) - .expect("invocation"); - assert_eq!(invocation.command(), TangleCommand::OpsRestore); assert_eq!( - require_config_path(&invocation).expect("config"), - "runtime.json" - ); - assert_eq!( - require_input_path(&invocation).expect("input"), - "backup-dir" - ); - } - - #[test] - fn command_model_parses_export_output_option() { - let invocation = parse_tangle_invocation([ - "event", - "export", - "--config", - "runtime.json", - "--output", - "events.jsonl", - ]) - .expect("invocation"); - assert_eq!(invocation.command(), TangleCommand::EventExport); - assert_eq!( - require_config_path(&invocation).expect("config"), - "runtime.json" - ); - assert_eq!( - require_output_path(&invocation).expect("output"), - "events.jsonl" - ); - assert_eq!( - require_output_path(&TangleInvocation::new(TangleCommand::EventExport, None)) - .expect_err("output"), - TangleCliError::MissingOptionValue("--output") - ); - } - - #[test] - fn command_model_parses_common_config_option() { - assert_eq!( - parse_tangle_invocation(["migrate", "--config", "runtime.json"]).expect("invocation"), - TangleInvocation::new(TangleCommand::Migrate, Some("runtime.json".to_owned())) - ); - assert_eq!( - require_config_path(&TangleInvocation::new( - TangleCommand::Migrate, - Some("runtime.json".to_owned()) - )) - .expect("config"), - "runtime.json" - ); - assert_eq!( - require_config_path(&TangleInvocation::new(TangleCommand::Migrate, None)) - .expect_err("config"), - TangleCliError::MissingOptionValue("--config") + parse_tangle_invocation(["run", "--config", "ops/production/tangle-v2.example.json"]) + .expect("run"), + TangleInvocation::new( + TangleCommand::Run, + Some("ops/production/tangle-v2.example.json".to_owned()) + ) ); } #[test] - fn command_model_parses_import_input_option() { - let invocation = parse_tangle_invocation([ - "event", - "import", - "--config", - "runtime.json", - "--input", - "events.jsonl", - ]) - .expect("invocation"); - assert_eq!(invocation.command(), TangleCommand::EventImport); - assert_eq!( - require_config_path(&invocation).expect("config"), - "runtime.json" - ); - assert_eq!( - require_input_path(&invocation).expect("input"), - "events.jsonl" - ); - assert_eq!( - require_input_path(&TangleInvocation::new(TangleCommand::EventImport, None)) - .expect_err("input"), - TangleCliError::MissingOptionValue("--input") - ); + fn parse_tangle_invocation_rejects_removed_command_surface() { + for args in [ + vec!["migrate"], + vec!["event", "import"], + vec!["event", "export"], + vec!["projection", "rebuild"], + vec!["ops", "backup"], + vec!["ops", "restore"], + ] { + assert!(matches!( + parse_tangle_invocation(args).expect_err("removed"), + TangleCliError::UnknownCommand(_) + )); + } } #[test] - fn command_model_rejects_unknown_or_extra_arguments() { - assert_eq!( - parse_tangle_command(["unknown"]).expect_err("unknown"), - TangleCliError::UnknownCommand("unknown".to_owned()) - ); - assert_eq!( - parse_tangle_command(["event"]).expect_err("nested"), - TangleCliError::MissingNestedCommand("event") - ); - assert_eq!( - parse_tangle_command(["event", "bad"]).expect_err("event bad"), - TangleCliError::UnknownCommand("event bad".to_owned()) - ); - assert_eq!( - parse_tangle_command(["projection"]).expect_err("projection nested"), - TangleCliError::MissingNestedCommand("projection") - ); - assert_eq!( - parse_tangle_command(["projection", "bad"]).expect_err("projection"), - TangleCliError::UnknownCommand("projection bad".to_owned()) - ); - assert_eq!( - parse_tangle_command(["ops"]).expect_err("ops nested"), - TangleCliError::MissingNestedCommand("ops") - ); - assert_eq!( - parse_tangle_command(["ops", "bad"]).expect_err("ops bad"), - TangleCliError::UnknownCommand("ops bad".to_owned()) - ); + fn parse_tangle_invocation_validates_config_option() { assert_eq!( - parse_tangle_command(["run", "--extra"]).expect_err("extra"), - TangleCliError::UnexpectedArgument { - command: "run".to_owned(), - argument: "--extra".to_owned() - } - ); - assert_eq!( - parse_tangle_invocation(["migrate", "--config"]).expect_err("missing config"), + parse_tangle_invocation(["run", "--config"]).expect_err("missing"), TangleCliError::MissingOptionValue("--config") ); assert_eq!( - parse_tangle_invocation(["migrate", "--config", "a", "--config", "b"]) - .expect_err("repeated config"), + parse_tangle_invocation(["run", "--config", "a", "--config", "b"]).expect_err("repeat"), TangleCliError::RepeatedOption("--config") ); assert_eq!( - parse_tangle_invocation(["migrate", "--input", "events.jsonl"]).expect_err("input"), - TangleCliError::UnexpectedArgument { - command: "migrate".to_owned(), - argument: "--input".to_owned() - } - ); - assert_eq!( - parse_tangle_invocation(["event", "import", "--input"]).expect_err("missing input"), - TangleCliError::MissingOptionValue("--input") - ); - assert_eq!( - parse_tangle_invocation(["event", "import", "--input", "a", "--input", "b"]) - .expect_err("repeated input"), - TangleCliError::RepeatedOption("--input") - ); - assert_eq!( - parse_tangle_invocation(["run", "--output", "events.jsonl"]).expect_err("output"), - TangleCliError::UnexpectedArgument { - command: "run".to_owned(), - argument: "--output".to_owned() - } - ); - assert_eq!( - parse_tangle_invocation(["event", "export", "--output"]).expect_err("missing output"), - TangleCliError::MissingOptionValue("--output") - ); - assert_eq!( - parse_tangle_invocation(["event", "export", "--output", "a", "--output", "b"]) - .expect_err("repeated output"), - TangleCliError::RepeatedOption("--output") - ); - assert_eq!( - TangleCliError::MissingNestedCommand("event").to_string(), - "event command requires a nested command" - ); - assert_eq!( - TangleCliError::RepeatedOption("--config").to_string(), - "--config must not be repeated" - ); - assert_eq!( + parse_tangle_invocation(["--version", "--config", "runtime.json"]).expect_err("config"), TangleCliError::UnexpectedArgument { - command: "run".to_owned(), - argument: "--extra".to_owned() + command: "version".to_owned(), + argument: "--config".to_owned(), } - .to_string(), - "run command does not accept argument: --extra" - ); - } - - #[test] - fn migrate_output_reports_outcome_counts() { - assert_eq!( - migrate_output(RuntimeMigrationReport::new(8, 2, 10)), - "migrations applied: 8\nmigrations already applied: 2\nmigrations total: 10" ); } #[test] - fn event_import_output_reports_outcome_counts() { + fn require_config_path_reports_missing_value() { assert_eq!( - event_import_output(RuntimeEventImportReport::new(5, 2, 1, 2, 2)), - "events total: 5\nevents inserted: 2\nevents duplicate: 1\nevents projected: 2\nevents skipped: 2" - ); - } - - #[test] - fn event_export_output_reports_outcome_counts() { - assert_eq!( - event_export_output(RuntimeEventExportReport::new(3)), - "events exported: 3" - ); - } - - #[test] - fn projection_rebuild_output_reports_outcome_counts() { - assert_eq!( - projection_rebuild_output(RuntimeProjectionRebuildReport::new(4, 3, 2, 1)), - "events scanned: 4\nevents rebuilt: 3\nlistings projected: 2\nevents skipped: 1" - ); - } - - #[test] - fn ops_backup_output_reports_paths_counts_and_checksums() { - let report = RuntimeBackupReport::new( - PathBuf::from("backup"), - PathBuf::from("backup/raw-events.jsonl"), - 3, - "a".repeat(64), - PathBuf::from("backup/manifest.json"), - "b".repeat(64), - false, - ); - - assert_eq!( - ops_backup_output(&report), - format!( - "backup directory: backup\nraw events: 3\nraw events sha256: {}\nsurrealdb export available: false\nmanifest: backup/manifest.json\nmanifest sha256: {}", - "a".repeat(64), - "b".repeat(64) - ) - ); - } - - #[test] - fn ops_restore_output_reports_import_and_rebuild_counts() { - let report = RuntimeRestoreReport::new( - PathBuf::from("backup"), - 3, - "c".repeat(64), - RuntimeEventImportReport::new(3, 2, 1, 2, 0), - RuntimeProjectionRebuildReport::new(3, 3, 2, 0), - ); - - assert_eq!( - ops_restore_output(&report), - format!( - "restore directory: backup\nraw events: 3\nraw events sha256: {}\nevents inserted: 2\nevents duplicate: 1\nevents rebuilt: 3\nlistings projected: 2\nevents skipped: 0", - "c".repeat(64) - ) + require_config_path(&TangleInvocation::new(TangleCommand::Run, None)) + .expect_err("config"), + TangleCliError::MissingOptionValue("--config") ); } - - fn temp_root(label: &str) -> PathBuf { - std::env::temp_dir().join(format!("tangle-lib-{label}-{}", std::process::id())) - } - - fn path_str(path: &Path) -> &str { - path.to_str().expect("utf8 path") - } - - fn write_memory_config(path: &Path, namespace: &str, tracing_filter: Option<&str>) { - let mut config = serde_json::json!({ - "server": { - "listen_addr": "127.0.0.1:0", - "relay_url": "wss://relay.radroots.test" - }, - "database": { - "mode": "memory", - "namespace": namespace, - "database": "relay" - }, - "auth": { - "challenge_ttl_seconds": 300 - }, - "limits": { - "message_rate_limit": { - "limit": 120, - "window_seconds": 60 - } - } - }); - if let Some(filter) = tracing_filter { - config["observability"] = serde_json::json!({ - "tracing": { - "enabled": true, - "filter": filter, - "format": "compact" - } - }); - } - fs::write( - path, - serde_json::to_string_pretty(&config).expect("runtime config JSON"), - ) - .expect("write runtime config"); - } - - fn write_rocksdb_config(path: &Path, db_path: &Path, namespace: &str) { - let config = serde_json::json!({ - "server": { - "listen_addr": "127.0.0.1:0", - "relay_url": "wss://relay.radroots.test" - }, - "database": { - "mode": "rocks_db", - "path": path_str(db_path), - "namespace": namespace, - "database": "relay" - }, - "auth": { - "challenge_ttl_seconds": 300 - }, - "limits": { - "message_rate_limit": { - "limit": 120, - "window_seconds": 60 - } - } - }); - fs::write( - path, - serde_json::to_string_pretty(&config).expect("runtime config JSON"), - ) - .expect("write runtime config"); - } } diff --git a/crates/tangle/src/main.rs b/crates/tangle/src/main.rs @@ -21,64 +21,7 @@ fn main() -> ExitCode { println!("{}", tangle::usage_output()); ExitCode::SUCCESS } - tangle::TangleCommand::Migrate => match run_migrate(&invocation) { - Ok(output) => { - println!("{output}"); - ExitCode::SUCCESS - } - Err(error) => { - eprintln!("{error}"); - ExitCode::from(2) - } - }, tangle::TangleCommand::Run => match run_server(&invocation) { - Ok(()) => ExitCode::SUCCESS, - Err(error) => { - eprintln!("{error}"); - ExitCode::from(2) - } - }, - tangle::TangleCommand::EventImport => match run_event_import(&invocation) { - Ok(output) => { - println!("{output}"); - ExitCode::SUCCESS - } - Err(error) => { - eprintln!("{error}"); - ExitCode::from(2) - } - }, - tangle::TangleCommand::EventExport => match run_event_export(&invocation) { - Ok(output) => { - println!("{output}"); - ExitCode::SUCCESS - } - Err(error) => { - eprintln!("{error}"); - ExitCode::from(2) - } - }, - tangle::TangleCommand::ProjectionRebuild => match run_projection_rebuild(&invocation) { - Ok(output) => { - println!("{output}"); - ExitCode::SUCCESS - } - Err(error) => { - eprintln!("{error}"); - ExitCode::from(2) - } - }, - tangle::TangleCommand::OpsBackup => match run_ops_backup(&invocation) { - Ok(output) => { - println!("{output}"); - ExitCode::SUCCESS - } - Err(error) => { - eprintln!("{error}"); - ExitCode::from(2) - } - }, - tangle::TangleCommand::OpsRestore => match run_ops_restore(&invocation) { Ok(output) => { println!("{output}"); ExitCode::SUCCESS @@ -91,139 +34,21 @@ fn main() -> ExitCode { } } -fn run_migrate(invocation: &tangle::TangleInvocation) -> Result<String, String> { +fn run_server(invocation: &tangle::TangleInvocation) -> Result<String, String> { let config_path = tangle::require_config_path(invocation).map_err(|error| error.to_string())?; - let runtime = tangle_runtime(); - runtime.block_on(tangle::migrate_with_config(config_path)) -} - -fn run_server(invocation: &tangle::TangleInvocation) -> Result<(), String> { - let config_path = tangle::require_config_path(invocation).map_err(|error| error.to_string())?; - let runtime = tangle_runtime(); - runtime.block_on(tangle::run_with_config(config_path)) -} - -fn run_event_import(invocation: &tangle::TangleInvocation) -> Result<String, String> { - let config_path = tangle::require_config_path(invocation).map_err(|error| error.to_string())?; - let input_path = tangle::require_input_path(invocation).map_err(|error| error.to_string())?; - let runtime = tangle_runtime(); - runtime.block_on(tangle::event_import_with_config(config_path, input_path)) -} - -fn run_event_export(invocation: &tangle::TangleInvocation) -> Result<String, String> { - let config_path = tangle::require_config_path(invocation).map_err(|error| error.to_string())?; - let output_path = tangle::require_output_path(invocation).map_err(|error| error.to_string())?; - let runtime = tangle_runtime(); - runtime.block_on(tangle::event_export_with_config(config_path, output_path)) -} - -fn run_projection_rebuild(invocation: &tangle::TangleInvocation) -> Result<String, String> { - let config_path = tangle::require_config_path(invocation).map_err(|error| error.to_string())?; - let runtime = tangle_runtime(); - runtime.block_on(tangle::projection_rebuild_with_config(config_path)) -} - -fn run_ops_backup(invocation: &tangle::TangleInvocation) -> Result<String, String> { - let config_path = tangle::require_config_path(invocation).map_err(|error| error.to_string())?; - let output_path = tangle::require_output_path(invocation).map_err(|error| error.to_string())?; - let runtime = tangle_runtime(); - runtime.block_on(tangle::ops_backup_with_config(config_path, output_path)) -} - -fn run_ops_restore(invocation: &tangle::TangleInvocation) -> Result<String, String> { - let config_path = tangle::require_config_path(invocation).map_err(|error| error.to_string())?; - let input_path = tangle::require_input_path(invocation).map_err(|error| error.to_string())?; - let runtime = tangle_runtime(); - runtime.block_on(tangle::ops_restore_with_config(config_path, input_path)) -} - -fn tangle_runtime() -> tokio::runtime::Runtime { - tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("failed to start tangle Tokio runtime") + tangle::run_with_config(config_path) } #[cfg(test)] mod tests { - use super::{ - run_event_export, run_event_import, run_ops_backup, run_ops_restore, run_server, - tangle_runtime, - }; + use super::run_server; #[test] - fn command_runners_report_missing_options_in_process() { + fn command_runner_reports_missing_config_in_process() { let run = tangle::TangleInvocation::new(tangle::TangleCommand::Run, None); assert_eq!( run_server(&run).expect_err("run config"), "--config requires a value" ); - - let import_missing_config = - tangle::TangleInvocation::new(tangle::TangleCommand::EventImport, None); - assert_eq!( - run_event_import(&import_missing_config).expect_err("import config"), - "--config requires a value" - ); - let import_missing_input = tangle::TangleInvocation::new( - tangle::TangleCommand::EventImport, - Some("runtime.json".to_owned()), - ); - assert_eq!( - run_event_import(&import_missing_input).expect_err("import input"), - "--input requires a value" - ); - - let export_missing_config = - tangle::TangleInvocation::new(tangle::TangleCommand::EventExport, None); - assert_eq!( - run_event_export(&export_missing_config).expect_err("export config"), - "--config requires a value" - ); - let export_missing_output = tangle::TangleInvocation::new( - tangle::TangleCommand::EventExport, - Some("runtime.json".to_owned()), - ); - assert_eq!( - run_event_export(&export_missing_output).expect_err("export output"), - "--output requires a value" - ); - - let backup_missing_config = - tangle::TangleInvocation::new(tangle::TangleCommand::OpsBackup, None); - assert_eq!( - run_ops_backup(&backup_missing_config).expect_err("backup config"), - "--config requires a value" - ); - let backup_missing_output = tangle::TangleInvocation::new( - tangle::TangleCommand::OpsBackup, - Some("runtime.json".to_owned()), - ); - assert_eq!( - run_ops_backup(&backup_missing_output).expect_err("backup output"), - "--output requires a value" - ); - - let restore_missing_config = - tangle::TangleInvocation::new(tangle::TangleCommand::OpsRestore, None); - assert_eq!( - run_ops_restore(&restore_missing_config).expect_err("restore config"), - "--config requires a value" - ); - let restore_missing_input = tangle::TangleInvocation::new( - tangle::TangleCommand::OpsRestore, - Some("runtime.json".to_owned()), - ); - assert_eq!( - run_ops_restore(&restore_missing_input).expect_err("restore input"), - "--input requires a value" - ); - } - - #[test] - fn command_runner_runtime_builds_current_thread_executor() { - let runtime = tangle_runtime(); - - assert_eq!(runtime.block_on(async { 42 }), 42); } } diff --git a/crates/tangle/tests/abuse_conformance.rs b/crates/tangle/tests/abuse_conformance.rs @@ -1,379 +0,0 @@ -#![forbid(unsafe_code)] - -mod support; - -use std::fs; -use support::{ - RelayHarness, assert_ok, connect_client, http_get, http_post_json, reopen_store, send_auth, - send_event, send_req, send_text, -}; -use tangle_protocol::{Event, event_to_value}; -use tangle_test_support::{ - FixtureKey, auth_event_spec, build_fixture_event, build_fixture_event_from_parts, - valid_public_listing_spec, -}; - -#[tokio::test] -async fn abuse_conformance_rejects_malformed_invalid_and_replayed_messages() { - let seller = FixtureKey::Seller.public_key(); - let harness = RelayHarness::start( - "abuse_invalid_messages", - serde_json::json!({ - "approved_sellers": [seller.as_str()] - }), - ); - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let auth = build_fixture_event(&auth_event_spec()).expect("auth"); - let mut client = connect_client(harness.port).await; - - let malformed = send_text(&mut client, "not json").await; - assert_eq!(malformed[0], "NOTICE"); - assert!( - malformed[1] - .as_str() - .expect("notice") - .contains("client message JSON is invalid") - ); - let bad_id = send_text(&mut client, r#"["REQ","bad-id",{"ids":["bad"]}]"#).await; - assert_eq!(bad_id[0], "NOTICE"); - assert!(bad_id[1].as_str().expect("notice").contains("event id")); - - assert_ok(&send_auth(&mut client, &auth).await, true); - let replay = send_auth(&mut client, &auth).await; - assert_ok(&replay, false); - assert!( - replay[3] - .as_str() - .expect("replay rejection") - .contains("auth challenge is missing") - ); - - let mut bad_sig = event_to_value(&listing); - bad_sig["sig"] = serde_json::Value::String("f".repeat(128)); - let rejected_sig = send_text( - &mut client, - &serde_json::json!(["EVENT", bad_sig]).to_string(), - ) - .await; - assert_ok(&rejected_sig, false); - assert!( - rejected_sig[3] - .as_str() - .expect("signature rejection") - .contains("crypto") - ); - let mut bad_event_id = event_to_value(&listing); - bad_event_id["id"] = serde_json::Value::String("f".repeat(64)); - let rejected_id = send_text( - &mut client, - &serde_json::json!(["EVENT", bad_event_id]).to_string(), - ) - .await; - assert_ok(&rejected_id, false); - - let store_config = harness.store_config(); - let root = harness.root.clone(); - drop(client); - harness.stop(); - let store = reopen_store(&store_config).await; - assert!( - store - .raw_event_row(listing.id()) - .await - .expect("raw listing") - .is_none() - ); - drop(store); - fs::remove_dir_all(root).expect("remove runtime root"); -} - -#[tokio::test] -async fn abuse_conformance_enforces_runtime_limits_for_events_subscriptions_and_search() { - let seller = FixtureKey::Seller.public_key(); - let harness = RelayHarness::start_with_runtime_limits( - "abuse_runtime_limits", - serde_json::json!({ - "approved_sellers": [seller.as_str()] - }), - serde_json::json!({ - "max_tags_per_event": 1, - "max_filters_per_subscription": 1, - "max_subscriptions_per_connection": 1, - "max_search_tokens": 1 - }), - ); - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let auth = build_fixture_event(&auth_event_spec()).expect("auth"); - - let mut writer = connect_client(harness.port).await; - assert_ok(&send_auth(&mut writer, &auth).await, true); - let too_many_tags = send_event(&mut writer, &listing).await; - assert_ok(&too_many_tags, false); - assert!( - too_many_tags[3] - .as_str() - .expect("tag rejection") - .contains("tags per event") - ); - drop(writer); - - let mut reader = connect_client(harness.port).await; - let too_many_filters = send_text( - &mut reader, - r#"["REQ","too-many-filters",{"ids":["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]},{"ids":["bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"]}]"#, - ) - .await; - assert_eq!(too_many_filters[0], "CLOSED"); - assert!( - too_many_filters[2] - .as_str() - .expect("filter rejection") - .contains("filters per subscription") - ); - let first_subscription = send_req( - &mut reader, - "sub-one", - serde_json::json!({ - "ids": ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"] - }), - ) - .await; - assert_eq!(first_subscription[0], "EOSE"); - let too_many_subscriptions = send_req( - &mut reader, - "sub-two", - serde_json::json!({ - "ids": ["bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"] - }), - ) - .await; - assert_eq!(too_many_subscriptions[0], "CLOSED"); - assert!( - too_many_subscriptions[2] - .as_str() - .expect("subscription rejection") - .contains("subscriptions per connection") - ); - drop(reader); - - let mut searcher = connect_client(harness.port).await; - let abusive_search = send_req( - &mut searcher, - "search-abuse", - serde_json::json!({ - "search": "fresh carrots", - "limit": 5 - }), - ) - .await; - assert_eq!(abusive_search[0], "CLOSED"); - assert!( - abusive_search[2] - .as_str() - .expect("search rejection") - .contains("search tokens") - ); - - let store_config = harness.store_config(); - let root = harness.root.clone(); - drop(searcher); - harness.stop(); - let store = reopen_store(&store_config).await; - assert!( - store - .raw_event_row(listing.id()) - .await - .expect("raw listing") - .is_none() - ); - drop(store); - fs::remove_dir_all(root).expect("remove runtime root"); -} - -#[tokio::test] -async fn abuse_conformance_excludes_blocked_pubkey_writes_from_public_surfaces() { - let seller = FixtureKey::Seller.public_key(); - let harness = RelayHarness::start( - "abuse_blocked_pubkey", - serde_json::json!({ - "approved_sellers": [seller.as_str()], - "blocked_pubkeys": [seller.as_str()] - }), - ); - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let auth = build_fixture_event(&auth_event_spec()).expect("auth"); - let mut client = connect_client(harness.port).await; - - assert_ok(&send_auth(&mut client, &auth).await, true); - assert_ok(&send_event(&mut client, &listing).await, true); - assert!(!http_get(harness.port, "/api/listings?limit=5").contains(listing.id().as_str())); - - let store_config = harness.store_config(); - let root = harness.root.clone(); - drop(client); - harness.stop(); - let store = reopen_store(&store_config).await; - assert!( - store - .raw_event_row(listing.id()) - .await - .expect("raw listing") - .is_some() - ); - let listing_key = format!("30402:{}:listing-a", seller.as_str()); - assert!( - store - .listing_current_row(&listing_key) - .await - .expect("listing row") - .is_none() - ); - assert!( - store - .search_document_row(&listing_key) - .await - .expect("search row") - .is_none() - ); - drop(store); - fs::remove_dir_all(root).expect("remove runtime root"); -} - -#[tokio::test] -async fn abuse_conformance_excludes_hidden_and_deleted_listings_from_public_surfaces() { - let hidden = publish_listing_for_visibility("abuse_hidden_listing").await; - let hide = http_post_json( - hidden.harness.port, - &format!("/api/admin/events/{}/hide", hidden.listing.id().as_str()), - Some(hidden.admin.as_str()), - serde_json::json!({ - "reason": "abuse visibility test" - }), - ); - assert!(hide.contains("200 OK")); - assert!(hide.contains("\"status\":\"hidden\"")); - assert!( - !http_get(hidden.harness.port, "/api/listings?limit=5") - .contains(hidden.listing.id().as_str()) - ); - let hidden_store_config = hidden.harness.store_config(); - let hidden_root = hidden.harness.root.clone(); - drop(hidden.client); - hidden.harness.stop(); - let store = reopen_store(&hidden_store_config).await; - let listing_key = format!("30402:{}:listing-a", hidden.seller.as_str()); - assert_eq!( - store - .raw_event_row(hidden.listing.id()) - .await - .expect("raw row") - .expect("raw row exists")["hidden"], - true - ); - assert_eq!( - store - .listing_current_row(&listing_key) - .await - .expect("listing row") - .expect("listing row exists")["hidden"], - true - ); - assert_eq!( - store - .search_document_row(&listing_key) - .await - .expect("search row") - .expect("search row exists")["visible"], - false - ); - drop(store); - fs::remove_dir_all(hidden_root).expect("remove hidden runtime root"); - - let mut deleted = publish_listing_for_visibility("abuse_deleted_listing").await; - let listing_key = format!("30402:{}:listing-a", deleted.seller.as_str()); - let deletion = build_fixture_event_from_parts( - FixtureKey::Seller, - 1_714_124_460, - 5, - vec![vec!["a".to_owned(), listing_key.clone()]], - "delete listing", - ) - .expect("deletion"); - assert_ok(&send_event(&mut deleted.client, &deletion).await, true); - let deleted_lookup = send_req( - &mut deleted.client, - "deleted-listing", - serde_json::json!({ - "ids": [deleted.listing.id().as_str()] - }), - ) - .await; - assert_eq!(deleted_lookup[0], "EOSE"); - assert!( - !http_get(deleted.harness.port, "/api/listings?limit=5") - .contains(deleted.listing.id().as_str()) - ); - let deleted_store_config = deleted.harness.store_config(); - let deleted_root = deleted.harness.root.clone(); - drop(deleted.client); - deleted.harness.stop(); - let store = reopen_store(&deleted_store_config).await; - assert_eq!( - store - .raw_event_row(deleted.listing.id()) - .await - .expect("raw row") - .expect("raw row exists")["deleted"], - true - ); - assert_eq!( - store - .search_document_row(&listing_key) - .await - .expect("search row") - .expect("search row exists")["visible"], - false - ); - assert_eq!( - store - .deletion_marker_rows(deletion.id()) - .await - .expect("markers")[0]["target_type"], - "address" - ); - drop(store); - fs::remove_dir_all(deleted_root).expect("remove deleted runtime root"); -} - -struct VisibilityHarness { - harness: RelayHarness, - client: support::RelayClient, - listing: Event, - seller: tangle_protocol::PublicKeyHex, - admin: tangle_protocol::PublicKeyHex, -} - -async fn publish_listing_for_visibility(namespace: &str) -> VisibilityHarness { - let seller = FixtureKey::Seller.public_key(); - let admin = FixtureKey::Relay.public_key(); - let harness = RelayHarness::start( - namespace, - serde_json::json!({ - "admin_pubkeys": [admin.as_str()], - "approved_sellers": [seller.as_str()] - }), - ); - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let auth = build_fixture_event(&auth_event_spec()).expect("auth"); - let mut client = connect_client(harness.port).await; - assert_ok(&send_auth(&mut client, &auth).await, true); - assert_ok(&send_event(&mut client, &listing).await, true); - assert!(http_get(harness.port, "/api/listings?limit=5").contains(listing.id().as_str())); - VisibilityHarness { - harness, - client, - listing, - seller, - admin, - } -} diff --git a/crates/tangle/tests/commerce_privacy_conformance.rs b/crates/tangle/tests/commerce_privacy_conformance.rs @@ -1,168 +0,0 @@ -#![forbid(unsafe_code)] - -mod support; - -use std::fs; -use support::{ - RelayHarness, assert_ok, connect_client, http_get, next_label, reopen_store, - request_event_by_id, send_auth, send_event, -}; -use tangle_protocol::Event; -use tangle_test_support::{ - FixtureKey, auth_event_spec, build_fixture_event, build_fixture_event_from_parts, - valid_public_listing_spec, -}; - -const PRIVATE_VALUES: &[&str] = &[ - "fixture-order-001", - "buyer.contact@privacy.test", - "100 Privacy Fixture Way", - "fixture-payment-token", - "fixture-refund-token", - "fixture-dispute-evidence", - "private order note fixture", - "5550100", -]; - -#[tokio::test] -async fn commerce_privacy_conformance_rejects_private_order_plaintext() { - let seller = FixtureKey::Seller.public_key(); - let harness = RelayHarness::start( - "commerce_privacy_conformance", - serde_json::json!({ - "approved_sellers": [seller.as_str()] - }), - ); - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let auth = build_fixture_event(&auth_event_spec()).expect("auth"); - let private_events = private_commerce_events(); - let mut client = connect_client(harness.port).await; - - assert_ok(&send_auth(&mut client, &auth).await, true); - for event in &private_events { - let rejection = send_event(&mut client, event).await; - assert_ok(&rejection, false); - assert_eq!(rejection[1], event.id().as_str()); - assert!( - rejection[3] - .as_str() - .expect("privacy rejection") - .contains("privacy: private commerce plaintext field") - ); - } - assert_ok(&send_event(&mut client, &listing).await, true); - - let rejected_lookup = - request_event_by_id(&mut client, "private-order-rejected", &private_events[0]).await; - assert_eq!(rejected_lookup[0], "EOSE"); - assert_eq!(rejected_lookup[1], "private-order-rejected"); - let accepted_lookup = - request_event_by_id(&mut client, "public-listing-accepted", &listing).await; - assert_eq!(accepted_lookup[0], "EVENT"); - assert_eq!(accepted_lookup[1], "public-listing-accepted"); - assert_eq!(accepted_lookup[2]["id"], listing.id().as_str()); - assert_eq!(next_label(&mut client).await, "EOSE"); - - let listings = http_get(harness.port, "/api/listings?limit=5"); - assert!(listings.contains("200 OK")); - assert!(listings.contains(listing.id().as_str())); - for value in PRIVATE_VALUES { - assert!(!listings.contains(value)); - } - let detail = http_get( - harness.port, - &format!("/api/listings/{}/listing-a", seller.as_str()), - ); - assert!(detail.contains("200 OK")); - assert!(detail.contains(listing.id().as_str())); - for value in PRIVATE_VALUES { - assert!(!detail.contains(value)); - } - - let store_config = harness.store_config(); - let root = harness.root.clone(); - drop(client); - harness.stop(); - let store = reopen_store(&store_config).await; - for event in &private_events { - assert!( - store - .raw_event_row(event.id()) - .await - .expect("private raw row") - .is_none() - ); - } - assert!( - store - .raw_event_row(listing.id()) - .await - .expect("listing raw row") - .is_some() - ); - let listing_key = format!("30402:{}:listing-a", seller.as_str()); - assert!( - store - .listing_current_row(&listing_key) - .await - .expect("listing row") - .is_some() - ); - assert!( - store - .search_document_row(&listing_key) - .await - .expect("search row") - .is_some() - ); - drop(store); - fs::remove_dir_all(root).expect("remove runtime root"); -} - -fn private_commerce_events() -> Vec<Event> { - let mut events = PRIVATE_FIELDS - .iter() - .enumerate() - .map(|(index, (field, value))| { - let mut private = serde_json::Map::new(); - private.insert( - (*field).to_owned(), - serde_json::Value::String((*value).to_owned()), - ); - let mut root = serde_json::Map::new(); - root.insert( - "private_commerce".to_owned(), - serde_json::Value::Object(private), - ); - build_fixture_event_from_parts( - FixtureKey::Seller, - 1_714_124_450 + index as u64, - 1, - vec![vec!["t".to_owned(), "commerce-privacy".to_owned()]], - &serde_json::Value::Object(root).to_string(), - ) - .expect("private commerce event") - }) - .collect::<Vec<_>>(); - events.push( - build_fixture_event_from_parts( - FixtureKey::Seller, - 1_714_124_470, - 1, - vec![vec!["phone".to_owned(), "5550100".to_owned()]], - "private phone detail", - ) - .expect("phone tag event"), - ); - events -} - -const PRIVATE_FIELDS: &[(&str, &str)] = &[ - ("order_id", "fixture-order-001"), - ("buyer_contact", "buyer.contact@privacy.test"), - ("delivery_address", "100 Privacy Fixture Way"), - ("payment_details", "fixture-payment-token"), - ("refund_details", "fixture-refund-token"), - ("dispute_evidence", "fixture-dispute-evidence"), - ("private_note", "private order note fixture"), -]; diff --git a/crates/tangle/tests/discussion_conformance.rs b/crates/tangle/tests/discussion_conformance.rs @@ -1,231 +0,0 @@ -#![forbid(unsafe_code)] - -mod support; - -use std::fs; -use support::{ - RelayHarness, assert_ok, connect_client, http_get, next_label, reopen_store, - request_event_by_id, send_auth, send_event, -}; -use tangle_protocol::{Event, EventId}; -use tangle_test_support::{ - FixtureKey, auth_event_spec, build_fixture_event, build_fixture_event_from_parts, - valid_public_listing_spec, -}; - -#[tokio::test] -async fn discussion_conformance_projects_listing_comments_and_forum_threads() { - let seller = FixtureKey::Seller.public_key(); - let harness = RelayHarness::start( - "discussion_conformance", - serde_json::json!({ - "approved_sellers": [seller.as_str()] - }), - ); - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let listing_comment = listing_comment(&listing, 1_714_124_436, "Can I pickup Saturday?"); - let thread = forum_thread(1_714_124_438, Some("Market day thread"), &["market", "csa"]); - let thread_comment = forum_thread_comment(&thread, 1_714_124_439, "I can bring greens."); - let auth = build_fixture_event(&auth_event_spec()).expect("auth"); - - let mut client = connect_client(harness.port).await; - assert_ok(&send_auth(&mut client, &auth).await, true); - for event in [&listing, &listing_comment, &thread, &thread_comment] { - assert_ok(&send_event(&mut client, event).await, true); - } - - let fetched_comment = - request_event_by_id(&mut client, "discussion-comment", &listing_comment).await; - assert_eq!(fetched_comment[0], "EVENT"); - assert_eq!(fetched_comment[1], "discussion-comment"); - assert_eq!(fetched_comment[2]["id"], listing_comment.id().as_str()); - assert_eq!(next_label(&mut client).await, "EOSE"); - let fetched_thread = request_event_by_id(&mut client, "discussion-thread", &thread).await; - assert_eq!(fetched_thread[0], "EVENT"); - assert_eq!(fetched_thread[1], "discussion-thread"); - assert_eq!(fetched_thread[2]["id"], thread.id().as_str()); - assert_eq!(next_label(&mut client).await, "EOSE"); - - let listing_comments = http_get( - harness.port, - &format!( - "/api/listings/{}/listing-a/comments?limit=5", - seller.as_str() - ), - ); - assert!(listing_comments.contains("200 OK")); - assert!(listing_comments.contains(listing_comment.id().as_str())); - assert!(listing_comments.contains("Can I pickup Saturday?")); - let forum_threads = http_get(harness.port, "/api/forum/threads?topic=market&limit=5"); - assert!(forum_threads.contains("200 OK")); - assert!(forum_threads.contains(thread.id().as_str())); - assert!(forum_threads.contains("Market day thread")); - let forum_detail = http_get( - harness.port, - &format!("/api/forum/threads/{}", thread.id().as_str()), - ); - assert!(forum_detail.contains("200 OK")); - assert!(forum_detail.contains(thread.id().as_str())); - assert!(forum_detail.contains("Market day thread")); - let forum_comments = http_get( - harness.port, - &format!( - "/api/forum/threads/{}/comments?limit=5", - thread.id().as_str() - ), - ); - assert!(forum_comments.contains("200 OK")); - assert!(forum_comments.contains(thread_comment.id().as_str())); - assert!(forum_comments.contains("I can bring greens.")); - - let store_config = harness.store_config(); - let root = harness.root.clone(); - drop(client); - harness.stop(); - let store = reopen_store(&store_config).await; - for event in [&listing, &listing_comment, &thread, &thread_comment] { - assert!( - store - .raw_event_row(event.id()) - .await - .expect("raw row") - .is_some() - ); - } - let listing_key = format!("30402:{}:listing-a", seller.as_str()); - let listing_comment_row = store - .comment_projection_row(listing_comment.id()) - .await - .expect("listing comment row") - .expect("listing comment row exists"); - assert_eq!( - listing_comment_row["event_id"], - listing_comment.id().as_str() - ); - assert_eq!(listing_comment_row["root_target_type"], "address"); - assert_eq!(listing_comment_row["root_ref"], listing_key); - assert_eq!(listing_comment_row["root_kind"], "30402"); - assert_eq!(listing_comment_row["content"], "Can I pickup Saturday?"); - let thread_row = store - .forum_thread_row(thread.id()) - .await - .expect("thread row") - .expect("thread row exists"); - assert_eq!(thread_row["event_id"], thread.id().as_str()); - assert_eq!(thread_row["title"], "Market day thread"); - assert_eq!( - thread_row["content"], - "What is everyone bringing this weekend?" - ); - let topics = store - .forum_thread_topic_rows(thread.id()) - .await - .expect("topic rows"); - assert_eq!(topics.len(), 2); - assert_eq!(topics[0]["topic"], "csa"); - assert_eq!(topics[1]["topic"], "market"); - let thread_comment_row = store - .comment_projection_row(thread_comment.id()) - .await - .expect("thread comment row") - .expect("thread comment row exists"); - assert_eq!(thread_comment_row["event_id"], thread_comment.id().as_str()); - assert_eq!(thread_comment_row["root_target_type"], "event"); - assert_eq!(thread_comment_row["root_ref"], thread.id().as_str()); - assert_eq!(thread_comment_row["root_kind"], "11"); - assert_eq!(thread_comment_row["content"], "I can bring greens."); - assert!( - store - .search_document_row(thread.id().as_str()) - .await - .expect("thread search row") - .is_some() - ); - drop(store); - fs::remove_dir_all(root).expect("remove runtime root"); -} - -fn listing_comment(listing: &Event, created_at: u64, content: &str) -> Event { - let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str()); - build_fixture_event_from_parts( - FixtureKey::Seller, - created_at, - 1_111, - vec![ - vec!["A".to_owned(), listing_key.clone()], - vec!["K".to_owned(), "30402".to_owned()], - vec![ - "P".to_owned(), - listing.unsigned().pubkey().as_str().to_owned(), - ], - vec!["a".to_owned(), listing_key], - vec!["k".to_owned(), "30402".to_owned()], - vec![ - "p".to_owned(), - listing.unsigned().pubkey().as_str().to_owned(), - ], - ], - content, - ) - .expect("comment event") -} - -fn forum_thread(created_at: u64, title: Option<&str>, topics: &[&str]) -> Event { - let mut tags = vec![ - vec!["e".to_owned(), "5".repeat(EventId::HEX_LENGTH)], - vec![ - "p".to_owned(), - FixtureKey::Buyer.public_key().as_str().to_owned(), - ], - ]; - if let Some(title) = title { - tags.push(vec!["title".to_owned(), title.to_owned()]); - } - tags.extend( - topics - .iter() - .map(|topic| vec!["t".to_owned(), (*topic).to_owned()]), - ); - build_fixture_event_from_parts( - FixtureKey::Seller, - created_at, - 11, - tags, - "What is everyone bringing this weekend?", - ) - .expect("forum thread") -} - -fn forum_thread_comment(thread: &Event, created_at: u64, content: &str) -> Event { - build_fixture_event_from_parts( - FixtureKey::Seller, - created_at, - 1_111, - vec![ - vec![ - "E".to_owned(), - thread.id().as_str().to_owned(), - "wss://relay.radroots.test".to_owned(), - thread.unsigned().pubkey().as_str().to_owned(), - ], - vec!["K".to_owned(), "11".to_owned()], - vec![ - "P".to_owned(), - thread.unsigned().pubkey().as_str().to_owned(), - ], - vec![ - "e".to_owned(), - thread.id().as_str().to_owned(), - "wss://relay.radroots.test".to_owned(), - thread.unsigned().pubkey().as_str().to_owned(), - ], - vec!["k".to_owned(), "11".to_owned()], - vec![ - "p".to_owned(), - thread.unsigned().pubkey().as_str().to_owned(), - ], - ], - content, - ) - .expect("forum comment event") -} diff --git a/crates/tangle/tests/moderation_conformance.rs b/crates/tangle/tests/moderation_conformance.rs @@ -1,168 +0,0 @@ -#![forbid(unsafe_code)] - -mod support; - -use std::fs; -use support::{ - RelayHarness, assert_ok, connect_client, http_get, http_get_admin, next_label, reopen_store, - request_event_by_id, send_auth, send_event, -}; -use tangle_protocol::Event; -use tangle_test_support::{ - FixtureKey, auth_event_spec, build_fixture_event, build_fixture_event_from_parts, - valid_public_listing_spec, -}; - -#[tokio::test] -async fn moderation_conformance_projects_labels_reports_and_admin_queries() { - let seller = FixtureKey::Seller.public_key(); - let admin = FixtureKey::Relay.public_key(); - let harness = RelayHarness::start( - "moderation_conformance", - serde_json::json!({ - "admin_pubkeys": [admin.as_str()], - "approved_sellers": [seller.as_str()] - }), - ); - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let label = listing_label(&listing, 1_714_124_440, "reviewed"); - let report = listing_report(&listing, 1_714_124_441, "spam"); - let auth = build_fixture_event(&auth_event_spec()).expect("auth"); - - let mut client = connect_client(harness.port).await; - assert_ok(&send_auth(&mut client, &auth).await, true); - for event in [&listing, &label, &report] { - assert_ok(&send_event(&mut client, event).await, true); - } - let fetched_label = request_event_by_id(&mut client, "moderation-label", &label).await; - assert_eq!(fetched_label[0], "EVENT"); - assert_eq!(fetched_label[1], "moderation-label"); - assert_eq!(fetched_label[2]["id"], label.id().as_str()); - assert_eq!(next_label(&mut client).await, "EOSE"); - let fetched_report = request_event_by_id(&mut client, "moderation-report", &report).await; - assert_eq!(fetched_report[0], "EVENT"); - assert_eq!(fetched_report[1], "moderation-report"); - assert_eq!(fetched_report[2]["id"], report.id().as_str()); - assert_eq!(next_label(&mut client).await, "EOSE"); - - let unauthenticated_labels = http_get( - harness.port, - &format!( - "/api/admin/moderation/labels?target_type=event&target_ref={}&namespace=com.radroots.moderation&label=reviewed&limit=5", - listing.id().as_str() - ), - ); - assert!(unauthenticated_labels.contains("401 Unauthorized")); - let labels = http_get_admin( - harness.port, - &format!( - "/api/admin/moderation/labels?target_type=event&target_ref={}&namespace=com.radroots.moderation&label=reviewed&limit=5", - listing.id().as_str() - ), - admin.as_str(), - ); - assert!(labels.contains("200 OK")); - assert!(labels.contains(label.id().as_str())); - assert!(labels.contains("\"label\":\"reviewed\"")); - let reports = http_get_admin( - harness.port, - &format!( - "/api/admin/moderation/reports?target_type=event&target_ref={}&report_type=spam&limit=5", - listing.id().as_str() - ), - admin.as_str(), - ); - assert!(reports.contains("200 OK")); - assert!(reports.contains(report.id().as_str())); - assert!(reports.contains("\"report_type\":\"spam\"")); - - let store_config = harness.store_config(); - let root = harness.root.clone(); - drop(client); - harness.stop(); - let store = reopen_store(&store_config).await; - for event in [&listing, &label, &report] { - assert!( - store - .raw_event_row(event.id()) - .await - .expect("raw row") - .is_some() - ); - } - let label_rows = store - .label_projection_rows(label.id()) - .await - .expect("label rows"); - assert_eq!(label_rows.len(), 2); - let event_label = label_rows - .iter() - .find(|row| row["target_type"] == "event") - .expect("event label"); - assert_eq!(event_label["event_id"], label.id().as_str()); - assert_eq!(event_label["target_ref"], listing.id().as_str()); - assert_eq!(event_label["namespace"], "com.radroots.moderation"); - assert_eq!(event_label["label"], "reviewed"); - let address_label = label_rows - .iter() - .find(|row| row["target_type"] == "address") - .expect("address label"); - assert_eq!( - address_label["target_ref"], - format!("30402:{}:listing-a", seller.as_str()) - ); - let report_rows = store - .report_projection_rows(report.id()) - .await - .expect("report rows"); - let event_report = report_rows - .iter() - .find(|row| { - row["target_type"] == "event" - && row["target_ref"] == listing.id().as_str() - && row["report_type"] == "spam" - }) - .expect("event report"); - assert_eq!(event_report["reported_pubkeys"][0], seller.as_str()); - drop(store); - fs::remove_dir_all(root).expect("remove runtime root"); -} - -fn listing_label(listing: &Event, created_at: u64, label: &str) -> Event { - let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str()); - let namespace = "com.radroots.moderation"; - build_fixture_event_from_parts( - FixtureKey::Seller, - created_at, - 1_985, - vec![ - vec!["L".to_owned(), namespace.to_owned()], - vec!["l".to_owned(), label.to_owned(), namespace.to_owned()], - vec!["e".to_owned(), listing.id().as_str().to_owned()], - vec!["a".to_owned(), listing_key], - ], - "moderator label", - ) - .expect("label event") -} - -fn listing_report(listing: &Event, created_at: u64, report_type: &str) -> Event { - build_fixture_event_from_parts( - FixtureKey::Seller, - created_at, - 1_984, - vec![ - vec![ - "p".to_owned(), - listing.unsigned().pubkey().as_str().to_owned(), - ], - vec![ - "e".to_owned(), - listing.id().as_str().to_owned(), - report_type.to_owned(), - ], - ], - "moderator report", - ) - .expect("report event") -} diff --git a/crates/tangle/tests/nip01_conformance.rs b/crates/tangle/tests/nip01_conformance.rs @@ -1,62 +0,0 @@ -#![forbid(unsafe_code)] - -mod support; - -use std::fs; -use support::{ - RelayHarness, assert_ok, close_subscription, connect_client, http_get, next_label, - reopen_store, request_event_by_id, send_auth, send_event, send_text, -}; -use tangle_test_support::{ - FixtureKey, auth_event_spec, build_fixture_event, valid_public_listing_spec, -}; - -#[tokio::test] -async fn nip01_conformance_event_req_eose_and_close_round_trip() { - let seller = FixtureKey::Seller.public_key(); - let harness = RelayHarness::start( - "nip01_conformance", - serde_json::json!({ - "approved_sellers": [seller.as_str()] - }), - ); - let readiness = http_get(harness.port, "/readyz"); - assert!(readiness.contains("200 OK")); - assert!(readiness.contains("\"status\":\"ready\"")); - - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let auth = build_fixture_event(&auth_event_spec()).expect("auth"); - let mut client = connect_client(harness.port).await; - let notice = send_text(&mut client, "not json").await; - assert_eq!(notice[0], "NOTICE"); - assert!( - notice[1] - .as_str() - .expect("notice") - .starts_with("invalid: client message JSON is invalid:") - ); - - assert_ok(&send_auth(&mut client, &auth).await, true); - assert_ok(&send_event(&mut client, &listing).await, true); - let fetched = request_event_by_id(&mut client, "nip01-fetch", &listing).await; - assert_eq!(fetched[0], "EVENT"); - assert_eq!(fetched[1], "nip01-fetch"); - assert_eq!(fetched[2]["id"], listing.id().as_str()); - assert_eq!(next_label(&mut client).await, "EOSE"); - close_subscription(&mut client, "nip01-fetch").await; - - let store_config = harness.store_config(); - let root = harness.root.clone(); - drop(client); - harness.stop(); - let store = reopen_store(&store_config).await; - assert!( - store - .raw_event_row(listing.id()) - .await - .expect("raw row") - .is_some() - ); - drop(store); - fs::remove_dir_all(root).expect("remove runtime root"); -} diff --git a/crates/tangle/tests/nip09_conformance.rs b/crates/tangle/tests/nip09_conformance.rs @@ -1,70 +0,0 @@ -#![forbid(unsafe_code)] - -mod support; - -use std::fs; -use support::{ - RelayHarness, assert_ok, connect_client, reopen_store, request_event_by_id, send_auth, - send_event, -}; -use tangle_test_support::{ - FixtureKey, auth_event_spec, build_fixture_event, build_fixture_event_from_parts, - valid_public_listing_spec, -}; - -#[tokio::test] -async fn nip09_conformance_deletes_event_and_hides_it_from_req_results() { - let seller = FixtureKey::Seller.public_key(); - let harness = RelayHarness::start( - "nip09_conformance", - serde_json::json!({ - "approved_sellers": [seller.as_str()] - }), - ); - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let deletion = build_fixture_event_from_parts( - FixtureKey::Seller, - 1_714_124_460, - 5, - vec![vec!["e".to_owned(), listing.id().as_str().to_owned()]], - "delete listing", - ) - .expect("deletion"); - let auth = build_fixture_event(&auth_event_spec()).expect("auth"); - - let mut client = connect_client(harness.port).await; - assert_ok(&send_auth(&mut client, &auth).await, true); - assert_ok(&send_event(&mut client, &listing).await, true); - assert_ok(&send_event(&mut client, &deletion).await, true); - let deleted_lookup = request_event_by_id(&mut client, "nip09-deleted", &listing).await; - assert_eq!(deleted_lookup[0], "EOSE"); - assert_eq!(deleted_lookup[1], "nip09-deleted"); - - let store_config = harness.store_config(); - let root = harness.root.clone(); - drop(client); - harness.stop(); - let store = reopen_store(&store_config).await; - let raw_listing = store - .raw_event_row(listing.id()) - .await - .expect("listing row") - .expect("listing exists"); - assert_eq!(raw_listing["deleted"], true); - assert!( - store - .raw_event_row(deletion.id()) - .await - .expect("deletion row") - .is_some() - ); - let markers = store - .deletion_marker_rows(deletion.id()) - .await - .expect("markers"); - assert_eq!(markers.len(), 1); - assert_eq!(markers[0]["target_type"], "event"); - assert_eq!(markers[0]["target_ref"], listing.id().as_str()); - drop(store); - fs::remove_dir_all(root).expect("remove runtime root"); -} diff --git a/crates/tangle/tests/nip42_conformance.rs b/crates/tangle/tests/nip42_conformance.rs @@ -1,71 +0,0 @@ -#![forbid(unsafe_code)] - -mod support; - -use std::fs; -use support::{ - RelayHarness, assert_ok, connect_client_with_challenge, reopen_store, send_auth, send_event, -}; -use tangle_test_support::{ - FixtureKey, auth_event_spec, build_fixture_event, valid_public_listing_spec, -}; - -#[tokio::test] -async fn nip42_conformance_challenges_authenticates_and_gates_writes() { - let seller = FixtureKey::Seller.public_key(); - let harness = RelayHarness::start( - "nip42_conformance", - serde_json::json!({ - "require_write_auth": true, - "approved_sellers": [seller.as_str()] - }), - ); - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let auth = build_fixture_event(&auth_event_spec()).expect("auth"); - - let (mut client, challenge) = connect_client_with_challenge(harness.port).await; - assert_eq!(challenge[0], "AUTH"); - assert_eq!(challenge[1], "challenge-001"); - - let auth_as_event = send_event(&mut client, &auth).await; - assert_ok(&auth_as_event, false); - assert!( - auth_as_event[3] - .as_str() - .expect("auth rejection") - .contains("auth events must use AUTH") - ); - let unauthenticated_write = send_event(&mut client, &listing).await; - assert_ok(&unauthenticated_write, false); - assert!( - unauthenticated_write[3] - .as_str() - .expect("write rejection") - .contains("write authentication required") - ); - - assert_ok(&send_auth(&mut client, &auth).await, true); - assert_ok(&send_event(&mut client, &listing).await, true); - - let store_config = harness.store_config(); - let root = harness.root.clone(); - drop(client); - harness.stop(); - let store = reopen_store(&store_config).await; - assert!( - store - .raw_event_row(listing.id()) - .await - .expect("raw row") - .is_some() - ); - assert!( - store - .raw_event_row(auth.id()) - .await - .expect("auth raw row") - .is_none() - ); - drop(store); - fs::remove_dir_all(root).expect("remove runtime root"); -} diff --git a/crates/tangle/tests/nip50_conformance.rs b/crates/tangle/tests/nip50_conformance.rs @@ -1,77 +0,0 @@ -#![forbid(unsafe_code)] - -mod support; - -use std::fs; -use support::{ - RelayHarness, assert_ok, connect_client, next_label, reopen_store, send_auth, send_event, - send_req, -}; -use tangle_test_support::{ - FixtureKey, auth_event_spec, build_fixture_event, valid_public_listing_spec, -}; - -#[tokio::test] -async fn nip50_conformance_searches_persisted_listing_documents() { - let seller = FixtureKey::Seller.public_key(); - let harness = RelayHarness::start( - "nip50_conformance", - serde_json::json!({ - "approved_sellers": [seller.as_str()] - }), - ); - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let auth = build_fixture_event(&auth_event_spec()).expect("auth"); - - let mut client = connect_client(harness.port).await; - assert_ok(&send_auth(&mut client, &auth).await, true); - assert_ok(&send_event(&mut client, &listing).await, true); - - let found = send_req( - &mut client, - "nip50-search", - serde_json::json!({ - "search": "carrot", - "kinds": [30402], - "authors": [seller.as_str()], - "limit": 5 - }), - ) - .await; - assert_eq!(found[0], "EVENT"); - assert_eq!(found[1], "nip50-search"); - assert_eq!(found[2]["id"], listing.id().as_str()); - assert_eq!(next_label(&mut client).await, "EOSE"); - - let miss = send_req( - &mut client, - "nip50-miss", - serde_json::json!({ - "search": "rutabaga", - "kinds": [30402], - "authors": [seller.as_str()], - "limit": 5 - }), - ) - .await; - assert_eq!(miss[0], "EOSE"); - assert_eq!(miss[1], "nip50-miss"); - - let store_config = harness.store_config(); - let root = harness.root.clone(); - drop(client); - harness.stop(); - let store = reopen_store(&store_config).await; - let listing_key = format!("30402:{}:listing-a", seller.as_str()); - let search_row = store - .search_document_row(&listing_key) - .await - .expect("search row") - .expect("search row exists"); - assert_eq!(search_row["event_id"], listing.id().as_str()); - assert_eq!(search_row["doc_type"], "listing"); - assert_eq!(search_row["title"], "Carrot bunches"); - assert_eq!(search_row["visible"], true); - drop(store); - fs::remove_dir_all(root).expect("remove runtime root"); -} diff --git a/crates/tangle/tests/nip99_conformance.rs b/crates/tangle/tests/nip99_conformance.rs @@ -1,97 +0,0 @@ -#![forbid(unsafe_code)] - -mod support; - -use std::fs; -use support::{ - RelayHarness, assert_ok, connect_client, http_get, next_label, reopen_store, send_auth, - send_event, send_req, -}; -use tangle_test_support::{ - FixtureKey, auth_event_spec, build_fixture_event, valid_public_listing_spec, -}; - -#[tokio::test] -async fn nip99_conformance_projects_and_serves_public_listings() { - let seller = FixtureKey::Seller.public_key(); - let harness = RelayHarness::start( - "nip99_conformance", - serde_json::json!({ - "approved_sellers": [seller.as_str()] - }), - ); - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let auth = build_fixture_event(&auth_event_spec()).expect("auth"); - - let mut client = connect_client(harness.port).await; - assert_ok(&send_auth(&mut client, &auth).await, true); - assert_ok(&send_event(&mut client, &listing).await, true); - let by_address = send_req( - &mut client, - "nip99-address", - serde_json::json!({ - "kinds": [30402], - "authors": [seller.as_str()], - "#d": ["listing-a"], - "limit": 5 - }), - ) - .await; - assert_eq!(by_address[0], "EVENT"); - assert_eq!(by_address[1], "nip99-address"); - assert_eq!(by_address[2]["id"], listing.id().as_str()); - assert_eq!(by_address[2]["kind"], 30402); - assert_eq!(next_label(&mut client).await, "EOSE"); - - let list_response = http_get( - harness.port, - &format!( - "/api/listings?status=active&seller={}&unit=lb&currency=usd&limit=5", - seller.as_str() - ), - ); - assert!(list_response.contains("200 OK")); - assert!(list_response.contains(listing.id().as_str())); - assert!(list_response.contains("\"title\":\"Carrot bunches\"")); - assert!(list_response.contains("\"unit\":\"lb\"")); - let detail_response = http_get( - harness.port, - &format!("/api/listings/{}/listing-a", seller.as_str()), - ); - assert!(detail_response.contains("200 OK")); - assert!(detail_response.contains(listing.id().as_str())); - assert!(detail_response.contains("\"content\":\"Sweet storage carrots.\"")); - - let store_config = harness.store_config(); - let root = harness.root.clone(); - drop(client); - harness.stop(); - let store = reopen_store(&store_config).await; - let listing_key = format!("30402:{}:listing-a", seller.as_str()); - assert!( - store - .raw_event_row(listing.id()) - .await - .expect("raw row") - .is_some() - ); - let row = store - .listing_current_row(&listing_key) - .await - .expect("listing row") - .expect("listing row exists"); - assert_eq!(row["listing_key"], listing_key); - assert_eq!(row["event_id"], listing.id().as_str()); - assert_eq!(row["seller_pubkey"], seller.as_str()); - assert_eq!(row["d"], "listing-a"); - assert_eq!(row["title"], "Carrot bunches"); - assert_eq!(row["content"], "Sweet storage carrots."); - assert_eq!(row["price_decimal"], "12.50"); - assert_eq!(row["price_minor"], 1_250_u64); - assert_eq!(row["currency_norm"], "USD"); - assert_eq!(row["unit"], "lb"); - assert_eq!(row["effective_status"], "active"); - assert_eq!(row["hidden"], false); - drop(store); - fs::remove_dir_all(root).expect("remove runtime root"); -} diff --git a/crates/tangle/tests/release_acceptance.rs b/crates/tangle/tests/release_acceptance.rs @@ -14,17 +14,9 @@ fn release_acceptance_script_covers_release_candidate_validation_ladder() { "scripts/check.sh", "scripts/test.sh", "cargo nextest run --workspace", - "cargo test -p tangle --test nip01_conformance", - "cargo test -p tangle --test nip09_conformance", - "cargo test -p tangle --test nip42_conformance", - "cargo test -p tangle --test nip50_conformance", - "cargo test -p tangle --test nip99_conformance", - "cargo test -p tangle --test discussion_conformance", - "cargo test -p tangle --test moderation_conformance", - "cargo test -p tangle --test commerce_privacy_conformance", - "cargo test -p tangle --test abuse_conformance", - "cargo test -p tangle --test run_integration", - "cargo test -p tangle_runtime runtime_restore_command_imports_backup_and_rebuilds_projection_state", + "cargo test -p tangle_runtime --test base_relay_v2", + "cargo test -p tangle_groups", + "cargo test -p tangle_store_pocket", "cargo test -p tangle_bench", "scripts/benchmark_report.sh", "cargo test -p tangle --test source_comments", @@ -40,6 +32,21 @@ fn release_acceptance_script_covers_release_candidate_validation_ladder() { !script.contains("scripts/coverage.sh"), "release acceptance must not depend on strict line coverage" ); + for removed in [ + "nip50_conformance", + "nip99_conformance", + &["discuss", "ion_conformance"].concat(), + "moderation_conformance", + &["comm", "erce_privacy_conformance"].concat(), + "abuse_conformance", + "run_integration", + "runtime_restore_command_imports_backup_and_rebuilds_projection_state", + ] { + assert!( + !script.contains(removed), + "release acceptance still references `{removed}`" + ); + } #[cfg(unix)] { diff --git a/crates/tangle/tests/run_integration.rs b/crates/tangle/tests/run_integration.rs @@ -1,1364 +0,0 @@ -#![forbid(unsafe_code)] - -use futures_util::{SinkExt, StreamExt}; -use serde_json::Value; -use std::fs; -use std::io::{Read, Write}; -use std::net::{TcpListener, TcpStream}; -use std::path::Path; -use std::process::{Child, Command, Stdio}; -use std::time::{Duration, Instant}; -use tangle_protocol::{EventId, event_to_value}; -use tangle_store_surreal::{SurrealConnectionConfig, SurrealStore}; -use tangle_test_support::{ - FixtureKey, auth_event_spec, build_fixture_event, build_fixture_event_from_parts, - valid_public_listing_spec, -}; -use tokio_tungstenite::connect_async; -use tokio_tungstenite::tungstenite::Message; - -#[tokio::test] -async fn tangle_run_serves_relay_clients_and_persists_surreal_state() { - let port = free_port(); - let root = std::env::temp_dir().join(format!( - "tangle-run-integration-{}-{port}", - std::process::id() - )); - let db_path = root.join("surrealdb"); - let config_path = root.join("runtime.json"); - fs::create_dir_all(&root).expect("runtime root"); - let admin = FixtureKey::Relay.public_key(); - write_runtime_config( - &config_path, - &db_path, - port, - "tangle_it", - serde_json::json!({ - "admin_pubkeys": [admin.as_str()], - "approved_sellers": [FixtureKey::Seller.public_key().as_str()], - "write_rate_limit": { - "limit": 10, - "window_seconds": 60 - } - }), - Some(serde_json::json!({ - "tracing": { - "enabled": true, - "filter": "info,tangle=info,tangle_runtime=info", - "format": "json" - } - })), - ); - - let mut relay = Command::new(env!("CARGO_BIN_EXE_tangle")) - .args(["run", "--config"]) - .arg(&config_path) - .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .spawn() - .expect("spawn tangle run"); - - wait_for_http(port, &mut relay); - let nip11 = http_get(port, "/"); - assert!(nip11.contains("200 OK")); - assert!(nip11.contains("application/nostr+json")); - assert!(nip11.contains("\"supported_nips\"")); - - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let comment = listing_comment(&listing, 1_714_124_436, "Can I pickup Saturday?"); - let reaction = listing_reaction(&listing, 1_714_124_437, "+"); - let thread = forum_thread(1_714_124_438, Some("Market day thread"), &["market", "csa"]); - let thread_comment = forum_thread_comment(&thread, 1_714_124_439, "I can bring greens."); - let label = listing_label(&listing, 1_714_124_440, "reviewed"); - let report = listing_report(&listing, 1_714_124_441, "spam"); - let profile = seller_profile(1_714_124_442); - let auth = build_fixture_event(&auth_event_spec()).expect("auth"); - let seller = FixtureKey::Seller.public_key(); - - let (mut subscriber, _) = connect_async(format!("ws://127.0.0.1:{port}/ws")) - .await - .expect("subscriber connect"); - assert_eq!(next_label(&mut subscriber).await, "AUTH"); - subscriber - .send(Message::Text( - serde_json::json!([ - "REQ", - "sub-live", - { - "kinds": [30402], - "authors": [seller.as_str()] - } - ]) - .to_string() - .into(), - )) - .await - .expect("subscribe"); - assert_eq!(next_label(&mut subscriber).await, "EOSE"); - - let (mut unauthenticated, _) = connect_async(format!("ws://127.0.0.1:{port}/ws")) - .await - .expect("unauthenticated connect"); - assert_eq!(next_label(&mut unauthenticated).await, "AUTH"); - unauthenticated - .send(Message::Text( - serde_json::json!(["EVENT", event_to_value(&listing)]) - .to_string() - .into(), - )) - .await - .expect("unauthenticated event send"); - let unauthenticated_rejection = next_json(&mut unauthenticated).await; - assert_ok(&unauthenticated_rejection, false); - assert!( - unauthenticated_rejection[3] - .as_str() - .expect("rejection message") - .contains("write authentication required") - ); - - let (mut publisher, _) = connect_async(format!("ws://127.0.0.1:{port}/ws")) - .await - .expect("publisher connect"); - assert_eq!(next_label(&mut publisher).await, "AUTH"); - publisher - .send(Message::Text( - serde_json::json!(["AUTH", event_to_value(&auth)]) - .to_string() - .into(), - )) - .await - .expect("auth send"); - assert_ok(&next_json(&mut publisher).await, true); - - publisher - .send(Message::Text( - serde_json::json!(["EVENT", event_to_value(&profile)]) - .to_string() - .into(), - )) - .await - .expect("profile send"); - assert_ok(&next_json(&mut publisher).await, true); - publisher - .send(Message::Text( - serde_json::json!(["REQ", "sub-profile", { "ids": [profile.id().as_str()] }]) - .to_string() - .into(), - )) - .await - .expect("profile fetch send"); - let fetched_profile = next_json(&mut publisher).await; - assert_eq!(fetched_profile[0], "EVENT"); - assert_eq!(fetched_profile[1], "sub-profile"); - assert_eq!(fetched_profile[2]["id"], profile.id().as_str()); - assert_eq!(next_label(&mut publisher).await, "EOSE"); - - publisher - .send(Message::Text( - serde_json::json!(["EVENT", event_to_value(&listing)]) - .to_string() - .into(), - )) - .await - .expect("event send"); - assert_ok(&next_json(&mut publisher).await, true); - let live = next_json(&mut subscriber).await; - assert_eq!(live[0], "EVENT"); - assert_eq!(live[1], "sub-live"); - assert_eq!(live[2]["id"], listing.id().as_str()); - - publisher - .send(Message::Text( - serde_json::json!(["REQ", "sub-fetch", { "ids": [listing.id().as_str()] }]) - .to_string() - .into(), - )) - .await - .expect("fetch send"); - let fetched = next_json(&mut publisher).await; - assert_eq!(fetched[0], "EVENT"); - assert_eq!(fetched[1], "sub-fetch"); - assert_eq!(fetched[2]["id"], listing.id().as_str()); - assert_eq!(next_label(&mut publisher).await, "EOSE"); - - publisher - .send(Message::Text( - serde_json::json!(["EVENT", event_to_value(&comment)]) - .to_string() - .into(), - )) - .await - .expect("comment send"); - assert_ok(&next_json(&mut publisher).await, true); - publisher - .send(Message::Text( - serde_json::json!(["REQ", "sub-comment", { "ids": [comment.id().as_str()] }]) - .to_string() - .into(), - )) - .await - .expect("comment fetch send"); - let fetched_comment = next_json(&mut publisher).await; - assert_eq!(fetched_comment[0], "EVENT"); - assert_eq!(fetched_comment[1], "sub-comment"); - assert_eq!(fetched_comment[2]["id"], comment.id().as_str()); - assert_eq!(next_label(&mut publisher).await, "EOSE"); - - publisher - .send(Message::Text( - serde_json::json!(["EVENT", event_to_value(&reaction)]) - .to_string() - .into(), - )) - .await - .expect("reaction send"); - assert_ok(&next_json(&mut publisher).await, true); - publisher - .send(Message::Text( - serde_json::json!(["REQ", "sub-reaction", { "ids": [reaction.id().as_str()] }]) - .to_string() - .into(), - )) - .await - .expect("reaction fetch send"); - let fetched_reaction = next_json(&mut publisher).await; - assert_eq!(fetched_reaction[0], "EVENT"); - assert_eq!(fetched_reaction[1], "sub-reaction"); - assert_eq!(fetched_reaction[2]["id"], reaction.id().as_str()); - assert_eq!(next_label(&mut publisher).await, "EOSE"); - - publisher - .send(Message::Text( - serde_json::json!(["EVENT", event_to_value(&thread)]) - .to_string() - .into(), - )) - .await - .expect("thread send"); - assert_ok(&next_json(&mut publisher).await, true); - publisher - .send(Message::Text( - serde_json::json!(["REQ", "sub-thread", { "ids": [thread.id().as_str()] }]) - .to_string() - .into(), - )) - .await - .expect("thread fetch send"); - let fetched_thread = next_json(&mut publisher).await; - assert_eq!(fetched_thread[0], "EVENT"); - assert_eq!(fetched_thread[1], "sub-thread"); - assert_eq!(fetched_thread[2]["id"], thread.id().as_str()); - assert_eq!(next_label(&mut publisher).await, "EOSE"); - - publisher - .send(Message::Text( - serde_json::json!(["EVENT", event_to_value(&thread_comment)]) - .to_string() - .into(), - )) - .await - .expect("thread comment send"); - assert_ok(&next_json(&mut publisher).await, true); - publisher - .send(Message::Text( - serde_json::json!(["REQ", "sub-thread-comment", { "ids": [thread_comment.id().as_str()] }]) - .to_string() - .into(), - )) - .await - .expect("thread comment fetch send"); - let fetched_thread_comment = next_json(&mut publisher).await; - assert_eq!(fetched_thread_comment[0], "EVENT"); - assert_eq!(fetched_thread_comment[1], "sub-thread-comment"); - assert_eq!( - fetched_thread_comment[2]["id"], - thread_comment.id().as_str() - ); - assert_eq!(next_label(&mut publisher).await, "EOSE"); - - publisher - .send(Message::Text( - serde_json::json!(["EVENT", event_to_value(&label)]) - .to_string() - .into(), - )) - .await - .expect("label send"); - assert_ok(&next_json(&mut publisher).await, true); - publisher - .send(Message::Text( - serde_json::json!(["REQ", "sub-label", { "ids": [label.id().as_str()] }]) - .to_string() - .into(), - )) - .await - .expect("label fetch send"); - let fetched_label = next_json(&mut publisher).await; - assert_eq!(fetched_label[0], "EVENT"); - assert_eq!(fetched_label[1], "sub-label"); - assert_eq!(fetched_label[2]["id"], label.id().as_str()); - assert_eq!(next_label(&mut publisher).await, "EOSE"); - - publisher - .send(Message::Text( - serde_json::json!(["EVENT", event_to_value(&report)]) - .to_string() - .into(), - )) - .await - .expect("report send"); - assert_ok(&next_json(&mut publisher).await, true); - publisher - .send(Message::Text( - serde_json::json!(["REQ", "sub-report", { "ids": [report.id().as_str()] }]) - .to_string() - .into(), - )) - .await - .expect("report fetch send"); - let fetched_report = next_json(&mut publisher).await; - assert_eq!(fetched_report[0], "EVENT"); - assert_eq!(fetched_report[1], "sub-report"); - assert_eq!(fetched_report[2]["id"], report.id().as_str()); - assert_eq!(next_label(&mut publisher).await, "EOSE"); - - subscriber - .send(Message::Text( - serde_json::json!(["CLOSE", "sub-live"]).to_string().into(), - )) - .await - .expect("close send"); - - let listings = http_get(port, "/api/listings?limit=5"); - assert!(listings.contains("200 OK")); - assert!(listings.contains("Carrot bunches")); - assert!(listings.contains(listing.id().as_str())); - let detail = http_get( - port, - &format!("/api/listings/{}/listing-a", seller.as_str()), - ); - assert!(detail.contains("200 OK")); - assert!(detail.contains("listing-a")); - assert!(detail.contains("Carrot bunches")); - let comments = http_get( - port, - &format!( - "/api/listings/{}/listing-a/comments?limit=5", - seller.as_str() - ), - ); - assert!(comments.contains("200 OK")); - assert!(comments.contains(comment.id().as_str())); - assert!(comments.contains("Can I pickup Saturday?")); - let reactions = http_get( - port, - &format!("/api/listings/{}/listing-a/reactions", seller.as_str()), - ); - assert!(reactions.contains("200 OK")); - assert!(reactions.contains("\"like_count\":1")); - assert!(reactions.contains("\"total_count\":1")); - let forum_threads = http_get(port, "/api/forum/threads?topic=market&limit=5"); - assert!(forum_threads.contains("200 OK")); - assert!(forum_threads.contains(thread.id().as_str())); - assert!(forum_threads.contains("Market day thread")); - let forum_detail = http_get( - port, - &format!("/api/forum/threads/{}", thread.id().as_str()), - ); - assert!(forum_detail.contains("200 OK")); - assert!(forum_detail.contains(thread.id().as_str())); - assert!(forum_detail.contains("Market day thread")); - let forum_comments = http_get( - port, - &format!( - "/api/forum/threads/{}/comments?limit=5", - thread.id().as_str() - ), - ); - assert!(forum_comments.contains("200 OK")); - assert!(forum_comments.contains(thread_comment.id().as_str())); - assert!(forum_comments.contains("I can bring greens.")); - let moderation_labels = http_get_admin( - port, - &format!( - "/api/admin/moderation/labels?target_type=event&target_ref={}&namespace=com.radroots.moderation&label=reviewed&limit=5", - listing.id().as_str() - ), - Some(admin.as_str()), - ); - assert!(moderation_labels.contains("200 OK")); - assert!(moderation_labels.contains(label.id().as_str())); - assert!(moderation_labels.contains("\"label\":\"reviewed\"")); - let moderation_reports = http_get_admin( - port, - &format!( - "/api/admin/moderation/reports?target_type=event&target_ref={}&report_type=spam&limit=5", - listing.id().as_str() - ), - Some(admin.as_str()), - ); - assert!(moderation_reports.contains("200 OK")); - assert!(moderation_reports.contains(report.id().as_str())); - assert!(moderation_reports.contains("\"report_type\":\"spam\"")); - let search = http_get(port, "/api/search?q=carrots&limit=5"); - assert!(search.contains("200 OK")); - assert!(search.contains(listing.id().as_str())); - let seller_detail = http_get(port, &format!("/api/sellers/{}", seller.as_str())); - assert!(seller_detail.contains("200 OK")); - assert!(seller_detail.contains(seller.as_str())); - assert!(seller_detail.contains(profile.id().as_str())); - assert!(seller_detail.contains("\"display_name\":\"Radroots Market\"")); - assert!(seller_detail.contains("\"regions\":[\"cascadia\",\"pnw\"]")); - let readiness = http_get(port, "/readyz"); - assert!(readiness.contains("200 OK")); - assert!(readiness.contains("\"status\":\"ready\"")); - assert!(readiness.contains("\"database\":\"ready\"")); - assert!(readiness.contains("\"migrations\":\"ready\"")); - assert!(readiness.contains("\"repository\":\"ready\"")); - let metrics = http_get(port, "/metrics"); - assert!(metrics.contains("200 OK")); - assert!(metrics.contains("text/plain; version=0.0.4")); - assert!(metrics.contains("tangle_info{")); - assert!(metrics.contains("tangle_relay_ready 1")); - assert!(metrics.contains("tangle_store_events{state=\"stored\"} 8")); - assert!(metrics.contains("tangle_store_events{state=\"visible\"} 8")); - assert!(metrics.contains("tangle_store_listings{state=\"active\"} 1")); - assert!(metrics.contains("tangle_store_seller_profiles{state=\"visible\"} 1")); - assert!(metrics.contains("tangle_store_sellers{state=\"approved\"} 0")); - - let trace_output = stop_relay_with_stderr(relay); - assert!(trace_output.contains("tracing initialized")); - assert!(trace_output.contains("starting runtime server")); - assert!(trace_output.contains("\"format\":\"json\"")); - - let store_config = - SurrealConnectionConfig::rocksdb(db_path.to_str().expect("db path"), "tangle_it", "relay") - .expect("store config"); - let store = reopen_store(&store_config).await; - let listing_key = format!("30402:{}:listing-a", seller.as_str()); - assert!( - store - .raw_event_row(listing.id()) - .await - .expect("raw row") - .is_some() - ); - assert!( - store - .raw_event_row(auth.id()) - .await - .expect("auth raw row") - .is_none() - ); - assert!( - store - .raw_event_row(profile.id()) - .await - .expect("profile raw row") - .is_some() - ); - let metrics_snapshot = store.metrics_snapshot().await.expect("metrics snapshot"); - assert_eq!(metrics_snapshot.stored_events(), 8); - assert_eq!(metrics_snapshot.visible_events(), 8); - assert_eq!(metrics_snapshot.current_listings(), 1); - assert_eq!(metrics_snapshot.active_listings(), 1); - assert_eq!(metrics_snapshot.seller_profiles(), 1); - assert_eq!(metrics_snapshot.visible_seller_profiles(), 1); - assert_eq!(metrics_snapshot.approved_sellers(), 0); - let profile_row = store - .seller_profile_row(seller.as_str()) - .await - .expect("profile row") - .expect("profile row exists"); - assert_eq!(profile_row["event_id"], profile.id().as_str()); - assert_eq!(profile_row["name"], "radroots-market"); - assert_eq!(profile_row["display_name"], "Radroots Market"); - assert_eq!( - profile_row["regions"], - serde_json::json!(["cascadia", "pnw"]) - ); - assert_eq!(profile_row["categories"], serde_json::json!(["produce"])); - assert_eq!( - profile_row["trust_markers"], - serde_json::json!(["csa", "regenerative"]) - ); - assert!( - store - .listing_current_row(&listing_key) - .await - .expect("listing row") - .is_some() - ); - let comment_row = store - .comment_projection_row(comment.id()) - .await - .expect("comment row") - .expect("comment row exists"); - assert_eq!(comment_row["event_id"], comment.id().as_str()); - assert_eq!(comment_row["root_ref"], listing_key); - assert_eq!(comment_row["content"], "Can I pickup Saturday?"); - let reaction_count = store - .reaction_count_row(listing.id()) - .await - .expect("reaction count") - .expect("reaction count exists"); - assert_eq!(reaction_count["target_event_id"], listing.id().as_str()); - assert_eq!(reaction_count["like_count"], 1_i64); - assert_eq!(reaction_count["total_count"], 1_i64); - let thread_row = store - .forum_thread_row(thread.id()) - .await - .expect("thread row") - .expect("thread row exists"); - assert_eq!(thread_row["event_id"], thread.id().as_str()); - assert_eq!(thread_row["title"], "Market day thread"); - let thread_comment_row = store - .comment_projection_row(thread_comment.id()) - .await - .expect("thread comment row") - .expect("thread comment row exists"); - assert_eq!(thread_comment_row["root_ref"], thread.id().as_str()); - assert_eq!(thread_comment_row["content"], "I can bring greens."); - assert!( - store - .search_document_row(thread.id().as_str()) - .await - .expect("thread search row") - .is_some() - ); - let label_rows = store - .label_projection_rows(label.id()) - .await - .expect("label rows"); - assert_eq!(label_rows.len(), 2); - let event_label = label_rows - .iter() - .find(|row| row["target_type"] == "event") - .expect("event label row"); - assert_eq!(event_label["event_id"], label.id().as_str()); - assert_eq!(event_label["target_ref"], listing.id().as_str()); - assert_eq!(event_label["namespace"], "com.radroots.moderation"); - assert_eq!(event_label["label"], "reviewed"); - let report_rows = store - .report_projection_rows(report.id()) - .await - .expect("report rows"); - assert_eq!(report_rows.len(), 1); - assert_eq!(report_rows[0]["event_id"], report.id().as_str()); - assert_eq!(report_rows[0]["target_type"], "event"); - assert_eq!(report_rows[0]["target_ref"], listing.id().as_str()); - assert_eq!(report_rows[0]["report_type"], "spam"); - assert_eq!(report_rows[0]["reported_pubkeys"][0], seller.as_str()); - assert!( - store - .search_document_row(&listing_key) - .await - .expect("search row") - .is_some() - ); - - drop(store); - fs::remove_dir_all(&root).expect("remove runtime root"); -} - -#[tokio::test] -async fn tangle_run_enforces_seller_projection_policy() { - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let auth = build_fixture_event(&auth_event_spec()).expect("auth"); - let seller = FixtureKey::Seller.public_key(); - let listing_key = format!("30402:{}:listing-a", seller.as_str()); - - let raw_only = run_policy_write_scenario( - "raw-only", - "tangle_policy_raw_only", - serde_json::json!({}), - &listing, - &auth, - ) - .await; - assert_ok(&raw_only.event_response, true); - assert!(raw_only.listing_response.contains("200 OK")); - assert!(!raw_only.listing_response.contains(listing.id().as_str())); - let raw_only_store = reopen_store(&raw_only.store_config).await; - assert!( - raw_only_store - .raw_event_row(listing.id()) - .await - .expect("raw row") - .is_some() - ); - assert!( - raw_only_store - .listing_current_row(&listing_key) - .await - .expect("listing row") - .is_none() - ); - assert!( - raw_only_store - .search_document_row(&listing_key) - .await - .expect("search row") - .is_none() - ); - drop(raw_only_store); - fs::remove_dir_all(&raw_only.root).expect("remove raw-only root"); - - let reject_write = run_policy_write_scenario( - "reject-write", - "tangle_policy_reject_write", - serde_json::json!({ - "unapproved_seller_action": "reject_write" - }), - &listing, - &auth, - ) - .await; - assert_ok(&reject_write.event_response, false); - assert!( - reject_write.event_response[3] - .as_str() - .expect("rejection message") - .contains("seller is not approved") - ); - assert!(reject_write.listing_response.contains("200 OK")); - assert!( - !reject_write - .listing_response - .contains(listing.id().as_str()) - ); - let reject_store = reopen_store(&reject_write.store_config).await; - assert!( - reject_store - .raw_event_row(listing.id()) - .await - .expect("raw row") - .is_none() - ); - assert!( - reject_store - .listing_current_row(&listing_key) - .await - .expect("listing row") - .is_none() - ); - drop(reject_store); - fs::remove_dir_all(&reject_write.root).expect("remove reject root"); -} - -#[tokio::test] -async fn tangle_run_persists_durable_write_rate_limits() { - let port = free_port(); - let root = std::env::temp_dir().join(format!( - "tangle-rate-limit-integration-{}-{port}", - std::process::id() - )); - let db_path = root.join("surrealdb"); - let config_path = root.join("runtime.json"); - fs::create_dir_all(&root).expect("runtime root"); - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let auth = build_fixture_event(&auth_event_spec()).expect("auth"); - let seller = FixtureKey::Seller.public_key(); - write_runtime_config( - &config_path, - &db_path, - port, - "tangle_rate_limit", - serde_json::json!({ - "approved_sellers": [seller.as_str()], - "write_rate_limit": { - "limit": 1, - "window_seconds": 60 - } - }), - None, - ); - let mut relay = spawn_relay(&config_path); - wait_for_http(port, &mut relay); - let (mut client, _) = connect_async(format!("ws://127.0.0.1:{port}/ws")) - .await - .expect("client connect"); - assert_eq!(next_label(&mut client).await, "AUTH"); - client - .send(Message::Text( - serde_json::json!(["AUTH", event_to_value(&auth)]) - .to_string() - .into(), - )) - .await - .expect("auth send"); - assert_ok(&next_json(&mut client).await, true); - - client - .send(Message::Text( - serde_json::json!(["EVENT", event_to_value(&listing)]) - .to_string() - .into(), - )) - .await - .expect("first event send"); - assert_ok(&next_json(&mut client).await, true); - client - .send(Message::Text( - serde_json::json!(["EVENT", event_to_value(&listing)]) - .to_string() - .into(), - )) - .await - .expect("second event send"); - let rejected = next_json(&mut client).await; - assert_ok(&rejected, false); - assert!( - rejected[3] - .as_str() - .expect("rate rejection") - .contains("rate-limited: retry after") - ); - stop_relay(relay); - - let store_config = SurrealConnectionConfig::rocksdb( - db_path.to_str().expect("db path"), - "tangle_rate_limit", - "relay", - ) - .expect("store config"); - let store = reopen_store(&store_config).await; - let key = format!("event_write:{}", seller.as_str()); - let row = store - .rate_limit_state_row(&key) - .await - .expect("rate row") - .expect("rate row exists"); - assert_eq!(row["key"], key); - assert_eq!( - serde_json::from_str::<serde_json::Value>(row["state"].as_str().expect("state")) - .expect("state json")["used"], - 1_u64 - ); - assert!( - store - .raw_event_row(listing.id()) - .await - .expect("raw row") - .is_some() - ); - drop(store); - fs::remove_dir_all(&root).expect("remove runtime root"); -} - -#[tokio::test] -async fn tangle_run_serves_admin_policy_api() { - let port = free_port(); - let root = std::env::temp_dir().join(format!( - "tangle-admin-policy-integration-{}-{port}", - std::process::id() - )); - let db_path = root.join("surrealdb"); - let config_path = root.join("runtime.json"); - fs::create_dir_all(&root).expect("runtime root"); - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let auth = build_fixture_event(&auth_event_spec()).expect("auth"); - let seller = FixtureKey::Seller.public_key(); - let admin = FixtureKey::Relay.public_key(); - let listing_key = format!("30402:{}:listing-a", seller.as_str()); - write_runtime_config( - &config_path, - &db_path, - port, - "tangle_admin_policy", - serde_json::json!({ - "admin_pubkeys": [admin.as_str()] - }), - None, - ); - let mut relay = spawn_relay(&config_path); - wait_for_http(port, &mut relay); - - let unauthorized = http_post_json( - port, - &format!("/api/admin/sellers/{}/approve", seller.as_str()), - None, - serde_json::json!({}), - ); - assert!(unauthorized.contains("401 Unauthorized")); - let approve = http_post_json( - port, - &format!("/api/admin/sellers/{}/approve", seller.as_str()), - Some(admin.as_str()), - serde_json::json!({}), - ); - assert!(approve.contains("200 OK")); - assert!(approve.contains("\"status\":\"approved\"")); - let seller_detail = http_get(port, &format!("/api/sellers/{}", seller.as_str())); - assert!(seller_detail.contains("\"approved\":true")); - - let (mut client, _) = connect_async(format!("ws://127.0.0.1:{port}/ws")) - .await - .expect("client connect"); - assert_eq!(next_label(&mut client).await, "AUTH"); - client - .send(Message::Text( - serde_json::json!(["AUTH", event_to_value(&auth)]) - .to_string() - .into(), - )) - .await - .expect("auth send"); - assert_ok(&next_json(&mut client).await, true); - client - .send(Message::Text( - serde_json::json!(["EVENT", event_to_value(&listing)]) - .to_string() - .into(), - )) - .await - .expect("event send"); - assert_ok(&next_json(&mut client).await, true); - assert!(http_get(port, "/api/listings?limit=5").contains(listing.id().as_str())); - - let hide = http_post_json( - port, - &format!("/api/admin/events/{}/hide", listing.id().as_str()), - Some(admin.as_str()), - serde_json::json!({ - "reason": "admin policy integration" - }), - ); - assert!(hide.contains("200 OK")); - assert!(hide.contains("\"status\":\"hidden\"")); - assert!(!http_get(port, "/api/listings?limit=5").contains(listing.id().as_str())); - let unhide = http_post_json( - port, - &format!("/api/admin/events/{}/unhide", listing.id().as_str()), - Some(admin.as_str()), - serde_json::json!({ - "reason": "admin policy integration complete" - }), - ); - assert!(unhide.contains("200 OK")); - assert!(unhide.contains("\"status\":\"unhidden\"")); - assert!(http_get(port, "/api/listings?limit=5").contains(listing.id().as_str())); - let block = http_post_json( - port, - &format!("/api/admin/pubkeys/{}/block", seller.as_str()), - Some(admin.as_str()), - serde_json::json!({}), - ); - assert!(block.contains("200 OK")); - assert!(block.contains("\"status\":\"blocked\"")); - stop_relay(relay); - - let store_config = SurrealConnectionConfig::rocksdb( - db_path.to_str().expect("db path"), - "tangle_admin_policy", - "relay", - ) - .expect("store config"); - let store = reopen_store(&store_config).await; - let user = store - .relay_user_row(seller.as_str()) - .await - .expect("relay user") - .expect("relay user exists"); - assert_eq!(user["seller_approved"], true); - assert_eq!(user["blocked"], true); - assert!( - store - .hidden_event_row(listing.id()) - .await - .expect("hidden row") - .is_none() - ); - assert_eq!( - store - .raw_event_row(listing.id()) - .await - .expect("raw row") - .expect("raw row exists")["hidden"], - false - ); - assert_eq!( - store - .listing_current_row(&listing_key) - .await - .expect("listing row") - .expect("listing row exists")["hidden"], - false - ); - let actions = store - .moderation_action_rows("event", listing.id().as_str()) - .await - .expect("moderation actions"); - assert_eq!(actions.len(), 2); - let action_labels = actions - .iter() - .map(|action| action["action"].as_str().expect("action label")) - .collect::<std::collections::BTreeSet<_>>(); - assert!(action_labels.contains("hide")); - assert!(action_labels.contains("unhide")); - drop(store); - fs::remove_dir_all(&root).expect("remove runtime root"); -} - -struct PolicyWriteScenario { - root: std::path::PathBuf, - store_config: SurrealConnectionConfig, - event_response: Value, - listing_response: String, -} - -async fn run_policy_write_scenario( - name: &str, - namespace: &str, - policy: Value, - listing: &tangle_protocol::Event, - auth: &tangle_protocol::Event, -) -> PolicyWriteScenario { - let port = free_port(); - let root = std::env::temp_dir().join(format!( - "tangle-policy-{name}-{}-{port}", - std::process::id() - )); - let db_path = root.join("surrealdb"); - let config_path = root.join("runtime.json"); - fs::create_dir_all(&root).expect("runtime root"); - write_runtime_config(&config_path, &db_path, port, namespace, policy, None); - let mut relay = spawn_relay(&config_path); - wait_for_http(port, &mut relay); - let (mut client, _) = connect_async(format!("ws://127.0.0.1:{port}/ws")) - .await - .expect("client connect"); - assert_eq!(next_label(&mut client).await, "AUTH"); - client - .send(Message::Text( - serde_json::json!(["AUTH", event_to_value(auth)]) - .to_string() - .into(), - )) - .await - .expect("auth send"); - assert_ok(&next_json(&mut client).await, true); - client - .send(Message::Text( - serde_json::json!(["EVENT", event_to_value(listing)]) - .to_string() - .into(), - )) - .await - .expect("event send"); - let event_response = next_json(&mut client).await; - let listing_response = http_get(port, "/api/listings?limit=5"); - stop_relay(relay); - let store_config = - SurrealConnectionConfig::rocksdb(db_path.to_str().expect("db path"), namespace, "relay") - .expect("store config"); - PolicyWriteScenario { - root, - store_config, - event_response, - listing_response, - } -} - -fn spawn_relay(config_path: &Path) -> Child { - Command::new(env!("CARGO_BIN_EXE_tangle")) - .args(["run", "--config"]) - .arg(config_path) - .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .spawn() - .expect("spawn tangle run") -} - -fn write_runtime_config( - path: &Path, - db_path: &Path, - port: u16, - namespace: &str, - policy: Value, - observability: Option<Value>, -) { - let mut config = serde_json::json!({ - "server": { - "listen_addr": format!("127.0.0.1:{port}"), - "relay_url": "wss://relay.radroots.test" - }, - "database": { - "mode": "rocks_db", - "path": db_path.to_str().expect("db path"), - "namespace": namespace, - "database": "relay" - }, - "auth": { - "challenge_ttl_seconds": 300 - }, - "limits": { - "message_rate_limit": { - "limit": 120, - "window_seconds": 60 - } - }, - "policy": policy - }); - if let Some(observability) = observability { - config["observability"] = observability; - } - fs::write( - path, - serde_json::to_string_pretty(&config).expect("config JSON"), - ) - .expect("write config"); -} - -fn free_port() -> u16 { - TcpListener::bind("127.0.0.1:0") - .expect("bind port") - .local_addr() - .expect("local addr") - .port() -} - -fn wait_for_http(port: u16, child: &mut Child) { - let started = Instant::now(); - loop { - if let Ok(response) = try_http_get(port, "/healthz") - && response.contains("200 OK") - { - return; - } - if let Some(status) = child.try_wait().expect("child status") { - panic!("relay exited before readiness: {status}"); - } - assert!( - started.elapsed() < Duration::from_secs(10), - "relay did not open port {port}" - ); - std::thread::sleep(Duration::from_millis(50)); - } -} - -fn http_get(port: u16, path: &str) -> String { - try_http_get(port, path).expect("http get") -} - -fn http_get_admin(port: u16, path: &str, admin_pubkey: Option<&str>) -> String { - let mut stream = TcpStream::connect(("127.0.0.1", port)).expect("http connect"); - stream - .set_read_timeout(Some(Duration::from_secs(2))) - .expect("read timeout"); - stream - .set_write_timeout(Some(Duration::from_secs(2))) - .expect("write timeout"); - let admin_header = admin_pubkey - .map(|pubkey| format!("x-tangle-admin-pubkey: {pubkey}\r\n")) - .unwrap_or_default(); - write!( - stream, - "GET {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nAccept: application/json\r\n{admin_header}Connection: close\r\n\r\n" - ) - .expect("http get"); - let mut response = String::new(); - stream.read_to_string(&mut response).expect("http read"); - response -} - -fn http_post_json(port: u16, path: &str, admin_pubkey: Option<&str>, body: Value) -> String { - let body = body.to_string(); - let mut stream = TcpStream::connect(("127.0.0.1", port)).expect("http connect"); - stream - .set_read_timeout(Some(Duration::from_secs(2))) - .expect("read timeout"); - stream - .set_write_timeout(Some(Duration::from_secs(2))) - .expect("write timeout"); - let admin_header = admin_pubkey - .map(|pubkey| format!("x-tangle-admin-pubkey: {pubkey}\r\n")) - .unwrap_or_default(); - write!( - stream, - "POST {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nAccept: application/json\r\nContent-Type: application/json\r\nContent-Length: {}\r\n{admin_header}Connection: close\r\n\r\n{body}", - body.len() - ) - .expect("http post"); - let mut response = String::new(); - stream.read_to_string(&mut response).expect("http read"); - response -} - -fn try_http_get(port: u16, path: &str) -> Result<String, std::io::Error> { - let mut stream = TcpStream::connect(("127.0.0.1", port))?; - stream.set_read_timeout(Some(Duration::from_secs(2)))?; - stream.set_write_timeout(Some(Duration::from_secs(2)))?; - write!( - stream, - "GET {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nAccept: application/nostr+json\r\nConnection: close\r\n\r\n" - )?; - let mut response = String::new(); - stream.read_to_string(&mut response)?; - Ok(response) -} - -async fn reopen_store(config: &SurrealConnectionConfig) -> SurrealStore { - let started = Instant::now(); - loop { - match SurrealStore::connect_local(config).await { - Ok(store) => return store, - Err(error) if started.elapsed() < Duration::from_secs(5) => { - let _ = error; - tokio::time::sleep(Duration::from_millis(50)).await; - } - Err(error) => panic!("store reopen failed: {error}"), - } - } -} - -async fn next_json( - socket: &mut tokio_tungstenite::WebSocketStream< - tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>, - >, -) -> Value { - let message = socket - .next() - .await - .expect("websocket message") - .expect("websocket frame"); - let text = message.into_text().expect("text frame"); - serde_json::from_str(&text).expect("relay JSON") -} - -async fn next_label( - socket: &mut tokio_tungstenite::WebSocketStream< - tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>, - >, -) -> String { - next_json(socket).await[0] - .as_str() - .expect("label") - .to_owned() -} - -fn assert_ok(message: &Value, accepted: bool) { - assert_eq!(message[0], "OK"); - assert_eq!(message[2], accepted, "relay OK frame: {message}"); -} - -fn seller_profile(created_at: u64) -> tangle_protocol::Event { - let content = serde_json::json!({ - "name": "radroots-market", - "display_name": "Radroots Market", - "about": "Local food seller profile", - "picture": "https://fixtures.radroots.test/seller.png", - "website": "https://seller.radroots.test", - "nip05": "seller@radroots.test", - "lud16": "seller@pay.radroots.test" - }); - build_fixture_event_from_parts( - FixtureKey::Seller, - created_at, - 0, - vec![ - vec!["region".to_owned(), "PNW".to_owned()], - vec!["region".to_owned(), "Cascadia".to_owned()], - vec!["category".to_owned(), "Produce".to_owned()], - vec!["trust".to_owned(), "CSA".to_owned()], - vec!["trust".to_owned(), "regenerative".to_owned()], - ], - &content.to_string(), - ) - .expect("seller profile") -} - -fn listing_comment( - listing: &tangle_protocol::Event, - created_at: u64, - content: &str, -) -> tangle_protocol::Event { - let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str()); - build_fixture_event_from_parts( - FixtureKey::Seller, - created_at, - 1_111, - vec![ - vec!["A".to_owned(), listing_key.clone()], - vec!["K".to_owned(), "30402".to_owned()], - vec![ - "P".to_owned(), - listing.unsigned().pubkey().as_str().to_owned(), - ], - vec!["a".to_owned(), listing_key], - vec!["k".to_owned(), "30402".to_owned()], - vec![ - "p".to_owned(), - listing.unsigned().pubkey().as_str().to_owned(), - ], - ], - content, - ) - .expect("comment event") -} - -fn listing_reaction( - listing: &tangle_protocol::Event, - created_at: u64, - content: &str, -) -> tangle_protocol::Event { - let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str()); - build_fixture_event_from_parts( - FixtureKey::Seller, - created_at, - 7, - vec![ - vec![ - "e".to_owned(), - listing.id().as_str().to_owned(), - "wss://relay.radroots.test".to_owned(), - listing.unsigned().pubkey().as_str().to_owned(), - ], - vec![ - "p".to_owned(), - listing.unsigned().pubkey().as_str().to_owned(), - ], - vec!["a".to_owned(), listing_key], - vec!["k".to_owned(), "30402".to_owned()], - ], - content, - ) - .expect("reaction event") -} - -fn listing_label( - listing: &tangle_protocol::Event, - created_at: u64, - label: &str, -) -> tangle_protocol::Event { - let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str()); - let namespace = "com.radroots.moderation"; - build_fixture_event_from_parts( - FixtureKey::Seller, - created_at, - 1_985, - vec![ - vec!["L".to_owned(), namespace.to_owned()], - vec!["l".to_owned(), label.to_owned(), namespace.to_owned()], - vec!["e".to_owned(), listing.id().as_str().to_owned()], - vec!["a".to_owned(), listing_key], - ], - "moderator label", - ) - .expect("label event") -} - -fn listing_report( - listing: &tangle_protocol::Event, - created_at: u64, - report_type: &str, -) -> tangle_protocol::Event { - build_fixture_event_from_parts( - FixtureKey::Seller, - created_at, - 1_984, - vec![ - vec![ - "p".to_owned(), - listing.unsigned().pubkey().as_str().to_owned(), - ], - vec![ - "e".to_owned(), - listing.id().as_str().to_owned(), - report_type.to_owned(), - ], - ], - "moderator report", - ) - .expect("report event") -} - -fn forum_thread(created_at: u64, title: Option<&str>, topics: &[&str]) -> tangle_protocol::Event { - let mut tags = vec![ - vec!["e".to_owned(), "5".repeat(EventId::HEX_LENGTH)], - vec![ - "p".to_owned(), - FixtureKey::Buyer.public_key().as_str().to_owned(), - ], - ]; - if let Some(title) = title { - tags.push(vec!["title".to_owned(), title.to_owned()]); - } - tags.extend( - topics - .iter() - .map(|topic| vec!["t".to_owned(), (*topic).to_owned()]), - ); - build_fixture_event_from_parts( - FixtureKey::Seller, - created_at, - 11, - tags, - "What is everyone bringing this weekend?", - ) - .expect("forum thread") -} - -fn forum_thread_comment( - thread: &tangle_protocol::Event, - created_at: u64, - content: &str, -) -> tangle_protocol::Event { - build_fixture_event_from_parts( - FixtureKey::Seller, - created_at, - 1_111, - vec![ - vec![ - "E".to_owned(), - thread.id().as_str().to_owned(), - "wss://relay.radroots.test".to_owned(), - thread.unsigned().pubkey().as_str().to_owned(), - ], - vec!["K".to_owned(), "11".to_owned()], - vec![ - "P".to_owned(), - thread.unsigned().pubkey().as_str().to_owned(), - ], - vec![ - "e".to_owned(), - thread.id().as_str().to_owned(), - "wss://relay.radroots.test".to_owned(), - thread.unsigned().pubkey().as_str().to_owned(), - ], - vec!["k".to_owned(), "11".to_owned()], - vec![ - "p".to_owned(), - thread.unsigned().pubkey().as_str().to_owned(), - ], - ], - content, - ) - .expect("forum comment event") -} - -fn stop_relay(relay: Child) { - let _ = stop_relay_with_stderr(relay); -} - -fn stop_relay_with_stderr(mut relay: Child) -> String { - stop_child(&mut relay); - let output = relay.wait_with_output().expect("relay exit"); - assert!(output.status.success()); - String::from_utf8_lossy(&output.stderr).to_string() -} - -#[cfg(unix)] -fn stop_child(relay: &mut Child) { - let status = Command::new("kill") - .args(["-INT", &relay.id().to_string()]) - .status() - .expect("send interrupt"); - assert!(status.success()); -} - -#[cfg(not(unix))] -fn stop_child(relay: &mut Child) { - relay.kill().expect("kill relay"); -} diff --git a/crates/tangle/tests/support/mod.rs b/crates/tangle/tests/support/mod.rs @@ -1,347 +0,0 @@ -#![allow(dead_code)] - -use futures_util::{SinkExt, StreamExt}; -use serde_json::Value; -use std::fs; -use std::io::{Read, Write}; -use std::net::{TcpListener, TcpStream}; -use std::path::{Path, PathBuf}; -use std::process::{Child, Command, Stdio}; -use std::time::{Duration, Instant}; -use tangle_protocol::{Event, event_to_value}; -use tangle_store_surreal::{SurrealConnectionConfig, SurrealStore}; -use tokio_tungstenite::tungstenite::Message; - -pub type RelayClient = - tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>; - -pub struct RelayHarness { - pub port: u16, - pub root: PathBuf, - pub db_path: PathBuf, - pub namespace: String, - child: Child, -} - -impl RelayHarness { - pub fn start(namespace: &str, policy: Value) -> Self { - Self::start_with_runtime_limits(namespace, policy, serde_json::json!({})) - } - - pub fn start_with_runtime_limits( - namespace: &str, - policy: Value, - runtime_limits: Value, - ) -> Self { - let port = free_port(); - let root = std::env::temp_dir().join(format!( - "tangle-conformance-{namespace}-{}-{port}", - std::process::id() - )); - let db_path = root.join("surrealdb"); - let config_path = root.join("runtime.json"); - fs::create_dir_all(&root).expect("runtime root"); - write_runtime_config( - &config_path, - &db_path, - port, - namespace, - policy, - runtime_limits, - ); - let mut child = Command::new(env!("CARGO_BIN_EXE_tangle")) - .args(["run", "--config"]) - .arg(&config_path) - .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .spawn() - .expect("spawn tangle run"); - wait_for_http(port, &mut child); - Self { - port, - root, - db_path, - namespace: namespace.to_owned(), - child, - } - } - - pub fn store_config(&self) -> SurrealConnectionConfig { - SurrealConnectionConfig::rocksdb( - self.db_path.to_str().expect("db path"), - &self.namespace, - "relay", - ) - .expect("store config") - } - - pub fn stop(self) { - stop_relay(self.child); - } -} - -pub async fn connect_client(port: u16) -> RelayClient { - let (client, challenge) = connect_client_with_challenge(port).await; - assert_eq!(challenge[0], "AUTH"); - client -} - -pub async fn connect_client_with_challenge(port: u16) -> (RelayClient, Value) { - let (mut client, _) = tokio_tungstenite::connect_async(format!("ws://127.0.0.1:{port}/ws")) - .await - .expect("client connect"); - let challenge = next_json(&mut client).await; - (client, challenge) -} - -pub async fn send_event(client: &mut RelayClient, event: &Event) -> Value { - client - .send(Message::Text( - serde_json::json!(["EVENT", event_to_value(event)]) - .to_string() - .into(), - )) - .await - .expect("event send"); - next_json(client).await -} - -pub async fn send_auth(client: &mut RelayClient, event: &Event) -> Value { - client - .send(Message::Text( - serde_json::json!(["AUTH", event_to_value(event)]) - .to_string() - .into(), - )) - .await - .expect("auth send"); - next_json(client).await -} - -pub async fn request_event_by_id( - client: &mut RelayClient, - subscription: &str, - event: &Event, -) -> Value { - send_req( - client, - subscription, - serde_json::json!({ "ids": [event.id().as_str()] }), - ) - .await -} - -pub async fn send_req(client: &mut RelayClient, subscription: &str, filter: Value) -> Value { - client - .send(Message::Text( - serde_json::json!(["REQ", subscription, filter]) - .to_string() - .into(), - )) - .await - .expect("req send"); - next_json(client).await -} - -pub async fn close_subscription(client: &mut RelayClient, subscription: &str) { - client - .send(Message::Text( - serde_json::json!(["CLOSE", subscription]) - .to_string() - .into(), - )) - .await - .expect("close send"); -} - -pub async fn send_text(client: &mut RelayClient, text: &str) -> Value { - client - .send(Message::Text(text.to_owned().into())) - .await - .expect("text send"); - next_json(client).await -} - -pub async fn next_json(client: &mut RelayClient) -> Value { - let message = client - .next() - .await - .expect("websocket message") - .expect("websocket frame"); - let text = message.into_text().expect("text frame"); - serde_json::from_str(&text).expect("relay JSON") -} - -pub async fn next_label(client: &mut RelayClient) -> String { - next_json(client).await[0] - .as_str() - .expect("label") - .to_owned() -} - -pub fn assert_ok(message: &Value, accepted: bool) { - assert_eq!(message[0], "OK"); - assert_eq!(message[2], accepted, "relay OK frame: {message}"); -} - -pub fn http_get(port: u16, path: &str) -> String { - try_http_get(port, path).expect("http get") -} - -pub fn http_get_admin(port: u16, path: &str, admin_pubkey: &str) -> String { - let mut stream = TcpStream::connect(("127.0.0.1", port)).expect("http connect"); - stream - .set_read_timeout(Some(Duration::from_secs(2))) - .expect("read timeout"); - stream - .set_write_timeout(Some(Duration::from_secs(2))) - .expect("write timeout"); - write!( - stream, - "GET {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nAccept: application/json\r\nx-tangle-admin-pubkey: {admin_pubkey}\r\nConnection: close\r\n\r\n" - ) - .expect("http get"); - let mut response = String::new(); - stream.read_to_string(&mut response).expect("http read"); - response -} - -pub fn http_post_json(port: u16, path: &str, admin_pubkey: Option<&str>, body: Value) -> String { - let body = body.to_string(); - let mut stream = TcpStream::connect(("127.0.0.1", port)).expect("http connect"); - stream - .set_read_timeout(Some(Duration::from_secs(2))) - .expect("read timeout"); - stream - .set_write_timeout(Some(Duration::from_secs(2))) - .expect("write timeout"); - let admin_header = admin_pubkey - .map(|pubkey| format!("x-tangle-admin-pubkey: {pubkey}\r\n")) - .unwrap_or_default(); - write!( - stream, - "POST {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nAccept: application/json\r\nContent-Type: application/json\r\nContent-Length: {}\r\n{admin_header}Connection: close\r\n\r\n{body}", - body.len() - ) - .expect("http post"); - let mut response = String::new(); - stream.read_to_string(&mut response).expect("http read"); - response -} - -pub async fn reopen_store(config: &SurrealConnectionConfig) -> SurrealStore { - let started = Instant::now(); - loop { - match SurrealStore::connect_local(config).await { - Ok(store) => return store, - Err(error) if started.elapsed() < Duration::from_secs(5) => { - let _ = error; - tokio::time::sleep(Duration::from_millis(50)).await; - } - Err(error) => panic!("store reopen failed: {error}"), - } - } -} - -fn write_runtime_config( - path: &Path, - db_path: &Path, - port: u16, - namespace: &str, - policy: Value, - runtime_limits: Value, -) { - let config = serde_json::json!({ - "server": { - "listen_addr": format!("127.0.0.1:{port}"), - "relay_url": "wss://relay.radroots.test" - }, - "database": { - "mode": "rocks_db", - "path": db_path.to_str().expect("db path"), - "namespace": namespace, - "database": "relay" - }, - "auth": { - "challenge_ttl_seconds": 300 - }, - "limits": { - "message_rate_limit": { - "limit": 120, - "window_seconds": 60 - }, - "runtime": runtime_limits - }, - "policy": policy - }); - fs::write( - path, - serde_json::to_string_pretty(&config).expect("config JSON"), - ) - .expect("write config"); -} - -fn free_port() -> u16 { - TcpListener::bind("127.0.0.1:0") - .expect("bind port") - .local_addr() - .expect("local addr") - .port() -} - -fn wait_for_http(port: u16, child: &mut Child) { - let started = Instant::now(); - loop { - if let Ok(response) = try_http_get(port, "/healthz") - && response.contains("200 OK") - { - return; - } - if let Some(status) = child.try_wait().expect("child status") { - panic!("relay exited before readiness: {status}"); - } - assert!( - started.elapsed() < Duration::from_secs(10), - "relay did not open port {port}" - ); - std::thread::sleep(Duration::from_millis(50)); - } -} - -fn try_http_get(port: u16, path: &str) -> Result<String, std::io::Error> { - let mut stream = TcpStream::connect(("127.0.0.1", port))?; - stream.set_read_timeout(Some(Duration::from_secs(2)))?; - stream.set_write_timeout(Some(Duration::from_secs(2)))?; - write!( - stream, - "GET {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nAccept: application/nostr+json\r\nConnection: close\r\n\r\n" - )?; - let mut response = String::new(); - stream.read_to_string(&mut response)?; - Ok(response) -} - -fn stop_relay(relay: Child) { - let _ = stop_relay_with_stderr(relay); -} - -fn stop_relay_with_stderr(mut relay: Child) -> String { - stop_child(&mut relay); - let output = relay.wait_with_output().expect("relay exit"); - assert!(output.status.success()); - String::from_utf8_lossy(&output.stderr).to_string() -} - -#[cfg(unix)] -fn stop_child(relay: &mut Child) { - let status = Command::new("kill") - .args(["-INT", &relay.id().to_string()]) - .status() - .expect("send interrupt"); - assert!(status.success()); -} - -#[cfg(not(unix))] -fn stop_child(relay: &mut Child) { - relay.kill().expect("kill relay"); -} diff --git a/crates/tangle/tests/version.rs b/crates/tangle/tests/version.rs @@ -1,10 +1,7 @@ #![forbid(unsafe_code)] use std::process::Command; -use std::time::{Duration, Instant}; -use tangle_protocol::event_to_value; -use tangle_store_surreal::{SurrealConnectionConfig, SurrealStore, base_migration_plan}; -use tangle_test_support::{FixtureKey, build_fixture_event, valid_public_listing_spec}; +use tangle_test_support::{FixtureKey, TANGLE_V2_RELAY_SECRET_HEX}; #[test] fn tangle_version_command_reports_package_version() { @@ -27,7 +24,7 @@ fn tangle_without_args_reports_usage() { assert!(output.status.success()); assert_eq!( String::from_utf8_lossy(&output.stdout), - "usage:\n tangle [--version]\n tangle migrate --config PATH\n tangle run --config PATH\n tangle event import --config PATH --input PATH\n tangle event export --config PATH --output PATH\n tangle projection rebuild --config PATH\n tangle ops backup --config PATH --output DIR\n tangle ops restore --config PATH --input DIR\n" + "usage:\n tangle [--version]\n tangle run --config PATH\n" ); assert!(output.stderr.is_empty()); } @@ -43,386 +40,110 @@ fn tangle_unknown_arg_reports_usage_error() { assert!(output.stdout.is_empty()); assert_eq!( String::from_utf8_lossy(&output.stderr), - "unknown command: --unknown\nusage:\n tangle [--version]\n tangle migrate --config PATH\n tangle run --config PATH\n tangle event import --config PATH --input PATH\n tangle event export --config PATH --output PATH\n tangle projection rebuild --config PATH\n tangle ops backup --config PATH --output DIR\n tangle ops restore --config PATH --input DIR\n" + "unknown command: --unknown\nusage:\n tangle [--version]\n tangle run --config PATH\n" ); } #[test] -fn tangle_runtime_commands_report_config_load_failures() { - let root = - std::env::temp_dir().join(format!("tangle-cli-config-errors-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&root); - std::fs::create_dir_all(&root).expect("runtime root"); - let config_path = root.join("missing-runtime.json"); - let output_path = root.join("output"); - let cases: Vec<(Vec<&str>, Option<&str>)> = vec![ - (vec!["run", "--config"], None), - (vec!["event", "import", "--config"], Some("--input")), - (vec!["event", "export", "--config"], Some("--output")), - (vec!["ops", "backup", "--config"], Some("--output")), - (vec!["ops", "restore", "--config"], Some("--input")), - ]; - - for (args, path_option) in cases { - let mut command = Command::new(env!("CARGO_BIN_EXE_tangle")); - command.args(&args).arg(&config_path); - if let Some(path_option) = path_option { - command.arg(path_option).arg(&output_path); - } - let output = command.output().expect("run tangle command"); +fn tangle_removed_commands_are_not_accepted() { + for args in [ + vec!["migrate"], + vec!["event", "import"], + vec!["event", "export"], + vec!["projection", "rebuild"], + vec!["ops", "backup"], + vec!["ops", "restore"], + ] { + let output = Command::new(env!("CARGO_BIN_EXE_tangle")) + .args(args) + .output() + .expect("run tangle removed command"); assert_eq!(output.status.code(), Some(2)); assert!(output.stdout.is_empty()); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.starts_with("Read: failed to read runtime config `")); - assert!(stderr.contains("missing-runtime.json")); + assert!(String::from_utf8_lossy(&output.stderr).contains("unknown command")); } +} - std::fs::remove_dir_all(&root).expect("remove runtime root"); +#[test] +fn tangle_run_reports_missing_config() { + let output = Command::new(env!("CARGO_BIN_EXE_tangle")) + .args(["run"]) + .output() + .expect("run tangle without config"); + + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + assert_eq!( + String::from_utf8_lossy(&output.stderr), + "--config requires a value\n" + ); } #[test] -fn tangle_migrate_command_applies_configured_migrations() { - let path = std::env::temp_dir().join(format!("tangle-cli-migrate-{}.json", std::process::id())); +fn tangle_run_smoke_opens_v2_config() { + let root = std::env::temp_dir().join(format!("tangle-cli-run-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).expect("runtime root"); + let data_dir = root.join("pocket"); + let config_path = root.join("runtime.json"); std::fs::write( - &path, - r#"{ + &config_path, + serde_json::json!({ "server": { - "listen_addr": "127.0.0.1:7400", - "relay_url": "ws://127.0.0.1:7400" + "listen_addr": "127.0.0.1:0", + "relay_url": "wss://relay.radroots.test" }, - "database": { - "mode": "memory", - "namespace": "tangle_cli_migrate", - "database": "relay" + "pocket": { + "data_directory": data_dir, + "map_size_bytes": 10485760, + "reader_slots": 32, + "sync_policy": "flush_on_shutdown" + }, + "groups": { + "enabled": true, + "canonical_relay_url": "wss://relay.radroots.test", + "relay_secret": TANGLE_V2_RELAY_SECRET_HEX, + "owner_pubkeys": [FixtureKey::Owner.public_key().as_str()], + "admin_pubkeys": [FixtureKey::Admin.public_key().as_str()], + "redaction": { + "redact_private_tags": true, + "redact_invite_codes": true + }, + "limits": { + "max_group_id_bytes": 128, + "max_group_tags_per_event": 8, + "max_supported_kinds": 512, + "max_member_list_pubkeys": 100000, + "max_outbox_replay_batch": 1000 + } }, "auth": { "challenge_ttl_seconds": 300 }, "limits": { - "message_rate_limit": { - "limit": 120, - "window_seconds": 60 - } + "max_pending_events": 1024 } - }"#, + }) + .to_string(), ) .expect("write config"); let output = Command::new(env!("CARGO_BIN_EXE_tangle")) - .args(["migrate", "--config"]) - .arg(&path) + .args(["run", "--config"]) + .arg(&config_path) .output() - .expect("run tangle migrate"); - std::fs::remove_file(&path).expect("remove config"); + .expect("run tangle"); assert!(output.status.success()); - let migration_count = base_migration_plan().migrations().len(); assert_eq!( String::from_utf8_lossy(&output.stdout), format!( - "migrations applied: {migration_count}\nmigrations already applied: 0\nmigrations total: {migration_count}\n" + "relay url: wss://relay.radroots.test\npocket data directory: {}\ngroups enabled: true\nreadiness: ready\n", + data_dir.display() ) ); assert!(output.stderr.is_empty()); -} - -#[tokio::test] -async fn tangle_event_import_command_imports_canonical_jsonl() { - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let root = std::env::temp_dir().join(format!( - "tangle-cli-import-{}-{}", - std::process::id(), - &listing.id().as_str()[..8] - )); - let _ = std::fs::remove_dir_all(&root); - let db_path = root.join("db"); - let config_path = root.join("runtime.json"); - let input_path = root.join("events.jsonl"); - let output_path = root.join("exported.jsonl"); - let backup_path = root.join("backup"); - let restore_db_path = root.join("restore-db"); - let restore_config_path = root.join("restore-runtime.json"); - std::fs::create_dir_all(&root).expect("runtime root"); - write_rocksdb_config(&config_path, &db_path, "tangle_cli_import"); - std::fs::write(&input_path, format!("{}\n", event_to_value(&listing))) - .expect("write import file"); - - let first = Command::new(env!("CARGO_BIN_EXE_tangle")) - .args(["event", "import", "--config"]) - .arg(&config_path) - .args(["--input"]) - .arg(&input_path) - .output() - .expect("run tangle event import"); - - assert!(first.status.success()); - assert_eq!( - String::from_utf8_lossy(&first.stdout), - "events total: 1\nevents inserted: 1\nevents duplicate: 0\nevents projected: 1\nevents skipped: 0\n" - ); - assert!(first.stderr.is_empty()); - - let second = Command::new(env!("CARGO_BIN_EXE_tangle")) - .args(["event", "import", "--config"]) - .arg(&config_path) - .args(["--input"]) - .arg(&input_path) - .output() - .expect("rerun tangle event import"); - - assert!(second.status.success()); - assert_eq!( - String::from_utf8_lossy(&second.stdout), - "events total: 1\nevents inserted: 0\nevents duplicate: 1\nevents projected: 0\nevents skipped: 0\n" - ); - assert!(second.stderr.is_empty()); - - let export = Command::new(env!("CARGO_BIN_EXE_tangle")) - .args(["event", "export", "--config"]) - .arg(&config_path) - .args(["--output"]) - .arg(&output_path) - .output() - .expect("run tangle event export"); - - assert!(export.status.success()); - assert_eq!( - String::from_utf8_lossy(&export.stdout), - "events exported: 1\n" - ); - assert!(export.stderr.is_empty()); - assert_eq!( - std::fs::read_to_string(&output_path).expect("export file"), - format!("{}\n", event_to_value(&listing)) - ); - - let backup = Command::new(env!("CARGO_BIN_EXE_tangle")) - .args(["ops", "backup", "--config"]) - .arg(&config_path) - .args(["--output"]) - .arg(&backup_path) - .output() - .expect("run tangle ops backup"); - - assert!(backup.status.success()); - assert!(backup.stderr.is_empty()); - let backup_stdout = String::from_utf8_lossy(&backup.stdout); - assert!(backup_stdout.starts_with(&format!( - "backup directory: {}\nraw events: 1\nraw events sha256: ", - backup_path.display() - ))); - assert!(backup_stdout.contains(&format!( - "\nsurrealdb export available: false\nmanifest: {}\nmanifest sha256: ", - backup_path.join("manifest.json").display() - ))); - assert_eq!( - std::fs::read_to_string(backup_path.join("raw-events.jsonl")).expect("backup raw events"), - format!("{}\n", event_to_value(&listing)) - ); - let manifest: serde_json::Value = serde_json::from_str( - &std::fs::read_to_string(backup_path.join("manifest.json")).expect("backup manifest"), - ) - .expect("manifest JSON"); - assert_eq!(manifest["format"], "tangle-backup-v1"); - assert_eq!(manifest["database"]["namespace"], "tangle_cli_import"); - assert_eq!(manifest["database"]["database"], "relay"); - assert_eq!(manifest["raw_events"]["path"], "raw-events.jsonl"); - assert_eq!(manifest["raw_events"]["count"], 1); - assert_eq!( - manifest["raw_events"]["sha256"] - .as_str() - .expect("raw sha") - .len(), - 64 - ); - assert_eq!(manifest["surrealdb_export"]["available"], false); - assert!(manifest["surrealdb_export"]["path"].is_null()); - assert!(manifest["surrealdb_export"]["sha256"].is_null()); - - write_rocksdb_config(&restore_config_path, &restore_db_path, "tangle_cli_restore"); - let restore = Command::new(env!("CARGO_BIN_EXE_tangle")) - .args(["ops", "restore", "--config"]) - .arg(&restore_config_path) - .args(["--input"]) - .arg(&backup_path) - .output() - .expect("run tangle ops restore"); - assert!(restore.status.success()); - assert!(restore.stderr.is_empty()); - let restore_stdout = String::from_utf8_lossy(&restore.stdout); - assert!(restore_stdout.starts_with(&format!( - "restore directory: {}\nraw events: 1\nraw events sha256: ", - backup_path.display() - ))); - assert!(restore_stdout.contains( - "\nevents inserted: 1\nevents duplicate: 0\nevents rebuilt: 1\nlistings projected: 1\nevents skipped: 0\n" - )); - - let rebuild = Command::new(env!("CARGO_BIN_EXE_tangle")) - .args(["projection", "rebuild", "--config"]) - .arg(&config_path) - .output() - .expect("run tangle projection rebuild"); - - assert!(rebuild.status.success()); - assert_eq!( - String::from_utf8_lossy(&rebuild.stdout), - "events scanned: 1\nevents rebuilt: 1\nlistings projected: 1\nevents skipped: 0\n" - ); - assert!(rebuild.stderr.is_empty()); - - let second_rebuild = Command::new(env!("CARGO_BIN_EXE_tangle")) - .args(["projection", "rebuild", "--config"]) - .arg(&config_path) - .output() - .expect("rerun tangle projection rebuild"); - - assert!(second_rebuild.status.success()); - assert_eq!( - String::from_utf8_lossy(&second_rebuild.stdout), - "events scanned: 1\nevents rebuilt: 1\nlistings projected: 1\nevents skipped: 0\n" - ); - assert!(second_rebuild.stderr.is_empty()); - - let seller = FixtureKey::Seller.public_key(); - let listing_key = format!("30402:{}:listing-a", seller.as_str()); - let restore_store_config = SurrealConnectionConfig::rocksdb( - restore_db_path.to_str().expect("restore db path"), - "tangle_cli_restore", - "relay", - ) - .expect("restore store config"); - let restore_store = reopen_store(&restore_store_config).await; - assert!( - restore_store - .raw_event_row(listing.id()) - .await - .expect("restore raw row") - .is_some() - ); - assert!( - restore_store - .listing_current_row(&listing_key) - .await - .expect("restore listing row") - .is_some() - ); - assert!( - restore_store - .search_document_row(&listing_key) - .await - .expect("restore search row") - .is_some() - ); - drop(restore_store); - - let store_config = SurrealConnectionConfig::rocksdb( - db_path.to_str().expect("db path"), - "tangle_cli_import", - "relay", - ) - .expect("store config"); - let store = reopen_store(&store_config).await; - assert!( - store - .raw_event_row(listing.id()) - .await - .expect("raw row") - .is_some() - ); - assert!( - store - .listing_current_row(&listing_key) - .await - .expect("listing row") - .is_some() - ); - assert!( - store - .search_document_row(&listing_key) - .await - .expect("search row") - .is_some() - ); - - drop(store); std::fs::remove_dir_all(&root).expect("remove runtime root"); } - -#[test] -fn tangle_migrate_requires_config_path() { - let output = Command::new(env!("CARGO_BIN_EXE_tangle")) - .arg("migrate") - .output() - .expect("run tangle migrate without config"); - - assert_eq!(output.status.code(), Some(2)); - assert!(output.stdout.is_empty()); - assert_eq!( - String::from_utf8_lossy(&output.stderr), - "--config requires a value\n" - ); -} - -#[test] -fn tangle_projection_rebuild_requires_config_path() { - let output = Command::new(env!("CARGO_BIN_EXE_tangle")) - .args(["projection", "rebuild"]) - .output() - .expect("run tangle projection rebuild without config"); - - assert_eq!(output.status.code(), Some(2)); - assert!(output.stdout.is_empty()); - assert_eq!( - String::from_utf8_lossy(&output.stderr), - "--config requires a value\n" - ); -} - -fn write_rocksdb_config(path: &std::path::Path, db_path: &std::path::Path, namespace: &str) { - let config = serde_json::json!({ - "server": { - "listen_addr": "127.0.0.1:0", - "relay_url": "wss://relay.radroots.test" - }, - "database": { - "mode": "rocks_db", - "path": db_path.to_str().expect("db path"), - "namespace": namespace, - "database": "relay" - }, - "auth": { - "challenge_ttl_seconds": 300 - }, - "limits": { - "message_rate_limit": { - "limit": 120, - "window_seconds": 60 - } - }, - "policy": { - "approved_sellers": [FixtureKey::Seller.public_key().as_str()] - } - }); - std::fs::write( - path, - serde_json::to_string_pretty(&config).expect("config JSON"), - ) - .expect("write config"); -} - -async fn reopen_store(config: &SurrealConnectionConfig) -> SurrealStore { - let started = Instant::now(); - loop { - match SurrealStore::connect_local(config).await { - Ok(store) => return store, - Err(error) if started.elapsed() < Duration::from_secs(5) => { - let _ = error; - tokio::time::sleep(Duration::from_millis(50)).await; - } - Err(error) => panic!("store reopen failed: {error}"), - } - } -} diff --git a/crates/tangle_core/Cargo.toml b/crates/tangle_core/Cargo.toml @@ -1,21 +0,0 @@ -[package] -name = "tangle_core" -version.workspace = true -edition.workspace = true -authors.workspace = true -rust-version.workspace = true -license.workspace = true -description = "Transport-independent relay core policy for tangle" - -[dependencies] -serde_json = "1" -tangle_crypto = { path = "../tangle_crypto" } -tangle_nips = { path = "../tangle_nips" } -tangle_protocol = { path = "../tangle_protocol" } -tangle_store = { path = "../tangle_store" } - -[dev-dependencies] -tangle_test_support = { path = "../tangle_test_support" } - -[lints] -workspace = true diff --git a/crates/tangle_core/src/lib.rs b/crates/tangle_core/src/lib.rs @@ -1,6177 +0,0 @@ -#![forbid(unsafe_code)] - -use core::fmt; -use std::collections::{BTreeMap, BTreeSet}; -use tangle_crypto::verify_event_signature; -use tangle_nips::{ - DeletionRequest, FulfillmentMethod, ListingProjectionEvaluation, ListingUnit, RelayAuthEvent, - evaluate_listing_projection, parse_deletion_request, parse_nip50_filter_search, - parse_relay_auth_event, -}; -use tangle_protocol::{ - Event, EventId, Filter, PublicKeyHex, SubscriptionId, UnixTimestamp, event_to_value, -}; -use tangle_store::{ - DeletionMarker, DeletionMarkerRepository, ListingProjectionRepository, RawEventRepository, - RepositoryError, StoreEventOutcome, StoreProjectionOutcome, StoredEvent, -}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct RuntimeLimitValues { - pub max_event_bytes: u64, - pub max_content_bytes: u64, - pub max_tags_per_event: u64, - pub max_tag_values_per_tag: u64, - pub max_tag_value_bytes: u64, - pub max_filters_per_subscription: u64, - pub max_subscriptions_per_connection: u64, - pub max_search_query_bytes: u64, - pub max_search_tokens: u64, - pub max_filter_complexity: u64, - pub max_future_seconds: u64, - pub live_event_buffer: u64, - pub pending_store_events: u64, -} - -impl Default for RuntimeLimitValues { - fn default() -> Self { - Self { - max_event_bytes: 131_072, - max_content_bytes: 65_536, - max_tags_per_event: 128, - max_tag_values_per_tag: 16, - max_tag_value_bytes: 1_024, - max_filters_per_subscription: 16, - max_subscriptions_per_connection: 64, - max_search_query_bytes: 256, - max_search_tokens: 16, - max_filter_complexity: 512, - max_future_seconds: 900, - live_event_buffer: 1_024, - pending_store_events: 4_096, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct RuntimeLimits { - values: RuntimeLimitValues, -} - -impl Default for RuntimeLimits { - fn default() -> Self { - Self::from_values(RuntimeLimitValues::default()).expect("default runtime limits are valid") - } -} - -impl RuntimeLimits { - pub fn from_values(values: RuntimeLimitValues) -> Result<Self, RuntimeLimitConfigError> { - require_positive("max_event_bytes", values.max_event_bytes)?; - require_positive("max_content_bytes", values.max_content_bytes)?; - require_positive("max_tags_per_event", values.max_tags_per_event)?; - require_positive("max_tag_values_per_tag", values.max_tag_values_per_tag)?; - require_positive("max_tag_value_bytes", values.max_tag_value_bytes)?; - require_positive( - "max_filters_per_subscription", - values.max_filters_per_subscription, - )?; - require_positive( - "max_subscriptions_per_connection", - values.max_subscriptions_per_connection, - )?; - require_positive("max_search_query_bytes", values.max_search_query_bytes)?; - require_positive("max_search_tokens", values.max_search_tokens)?; - require_positive("max_filter_complexity", values.max_filter_complexity)?; - require_positive("live_event_buffer", values.live_event_buffer)?; - require_positive("pending_store_events", values.pending_store_events)?; - if values.max_content_bytes > values.max_event_bytes { - return Err(RuntimeLimitConfigError::Inconsistent { - field: "max_content_bytes", - maximum_field: "max_event_bytes", - value: values.max_content_bytes, - maximum: values.max_event_bytes, - }); - } - Ok(Self { values }) - } - - pub fn values(self) -> RuntimeLimitValues { - self.values - } - - pub fn max_event_bytes(self) -> u64 { - self.values.max_event_bytes - } - - pub fn max_content_bytes(self) -> u64 { - self.values.max_content_bytes - } - - pub fn max_tags_per_event(self) -> u64 { - self.values.max_tags_per_event - } - - pub fn max_tag_values_per_tag(self) -> u64 { - self.values.max_tag_values_per_tag - } - - pub fn max_tag_value_bytes(self) -> u64 { - self.values.max_tag_value_bytes - } - - pub fn max_filters_per_subscription(self) -> u64 { - self.values.max_filters_per_subscription - } - - pub fn max_subscriptions_per_connection(self) -> u64 { - self.values.max_subscriptions_per_connection - } - - pub fn max_search_query_bytes(self) -> u64 { - self.values.max_search_query_bytes - } - - pub fn max_search_tokens(self) -> u64 { - self.values.max_search_tokens - } - - pub fn max_filter_complexity(self) -> u64 { - self.values.max_filter_complexity - } - - pub fn max_future_seconds(self) -> u64 { - self.values.max_future_seconds - } - - pub fn live_event_buffer(self) -> u64 { - self.values.live_event_buffer - } - - pub fn pending_store_events(self) -> u64 { - self.values.pending_store_events - } - - pub fn validate_event(&self, event: &Event) -> Result<(), RuntimeLimitViolation> { - let event_bytes = event_to_value(event).to_string().len() as u64; - require_within( - RuntimeLimitKind::EventBytes, - event_bytes, - self.values.max_event_bytes, - )?; - let content_bytes = event.unsigned().content().len() as u64; - require_within( - RuntimeLimitKind::ContentBytes, - content_bytes, - self.values.max_content_bytes, - )?; - let tag_count = event.unsigned().tags().len() as u64; - require_within( - RuntimeLimitKind::TagsPerEvent, - tag_count, - self.values.max_tags_per_event, - )?; - for tag in event.unsigned().tags() { - let value_count = tag.values().len() as u64; - require_within( - RuntimeLimitKind::TagValuesPerTag, - value_count, - self.values.max_tag_values_per_tag, - )?; - for value in tag.values() { - require_within( - RuntimeLimitKind::TagValueBytes, - value.len() as u64, - self.values.max_tag_value_bytes, - )?; - } - } - Ok(()) - } - - pub fn validate_filters( - &self, - filter_count: u64, - complexity: u64, - ) -> Result<(), RuntimeLimitViolation> { - require_within( - RuntimeLimitKind::FiltersPerSubscription, - filter_count, - self.values.max_filters_per_subscription, - )?; - require_within( - RuntimeLimitKind::FilterComplexity, - complexity, - self.values.max_filter_complexity, - ) - } - - pub fn validate_search_query(&self, query: &str) -> Result<(), RuntimeLimitViolation> { - require_within( - RuntimeLimitKind::SearchQueryBytes, - query.len() as u64, - self.values.max_search_query_bytes, - )?; - require_within( - RuntimeLimitKind::SearchTokens, - query.split_whitespace().count() as u64, - self.values.max_search_tokens, - ) - } - - pub fn validate_subscription_count( - &self, - active_subscriptions: u64, - ) -> Result<(), RuntimeLimitViolation> { - require_within( - RuntimeLimitKind::SubscriptionsPerConnection, - active_subscriptions, - self.values.max_subscriptions_per_connection, - ) - } - - pub fn validate_event_timestamp( - &self, - event: &Event, - now: UnixTimestamp, - ) -> Result<(), RuntimeLimitViolation> { - let created_at = event.unsigned().created_at().as_u64(); - let now = now.as_u64(); - if created_at <= now { - return Ok(()); - } - require_within( - RuntimeLimitKind::FutureSeconds, - created_at - now, - self.values.max_future_seconds, - ) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RuntimeLimitConfigError { - Zero { - field: &'static str, - }, - Inconsistent { - field: &'static str, - maximum_field: &'static str, - value: u64, - maximum: u64, - }, -} - -impl fmt::Display for RuntimeLimitConfigError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Zero { field } => write!(formatter, "`{field}` must be greater than zero"), - Self::Inconsistent { - field, - maximum_field, - value, - maximum, - } => write!( - formatter, - "`{field}` must not exceed `{maximum_field}` ({value} > {maximum})" - ), - } - } -} - -impl std::error::Error for RuntimeLimitConfigError {} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct RuntimeLimitViolation { - kind: RuntimeLimitKind, - actual: u64, - maximum: u64, -} - -impl RuntimeLimitViolation { - pub fn new(kind: RuntimeLimitKind, actual: u64, maximum: u64) -> Self { - Self { - kind, - actual, - maximum, - } - } - - pub fn kind(self) -> RuntimeLimitKind { - self.kind - } - - pub fn actual(self) -> u64 { - self.actual - } - - pub fn maximum(self) -> u64 { - self.maximum - } -} - -impl fmt::Display for RuntimeLimitViolation { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - formatter, - "{} exceeded: {} > {}", - self.kind.as_str(), - self.actual, - self.maximum - ) - } -} - -impl std::error::Error for RuntimeLimitViolation {} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RuntimeLimitKind { - EventBytes, - ContentBytes, - TagsPerEvent, - TagValuesPerTag, - TagValueBytes, - FiltersPerSubscription, - SubscriptionsPerConnection, - SearchQueryBytes, - SearchTokens, - FilterComplexity, - FutureSeconds, -} - -impl RuntimeLimitKind { - pub fn as_str(self) -> &'static str { - match self { - Self::EventBytes => "event bytes", - Self::ContentBytes => "content bytes", - Self::TagsPerEvent => "tags per event", - Self::TagValuesPerTag => "tag values per tag", - Self::TagValueBytes => "tag value bytes", - Self::FiltersPerSubscription => "filters per subscription", - Self::SubscriptionsPerConnection => "subscriptions per connection", - Self::SearchQueryBytes => "search query bytes", - Self::SearchTokens => "search tokens", - Self::FilterComplexity => "filter complexity", - Self::FutureSeconds => "future seconds", - } - } -} - -impl fmt::Display for RuntimeLimitKind { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(self.as_str()) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AdmissionPolicy { - require_write_auth: bool, - unapproved_seller_action: UnapprovedSellerAction, - approved_sellers: BTreeSet<PublicKeyHex>, - blocked_pubkeys: BTreeSet<PublicKeyHex>, -} - -impl Default for AdmissionPolicy { - fn default() -> Self { - Self { - require_write_auth: true, - unapproved_seller_action: UnapprovedSellerAction::StoreRawOnly, - approved_sellers: BTreeSet::new(), - blocked_pubkeys: BTreeSet::new(), - } - } -} - -impl AdmissionPolicy { - pub fn new() -> Self { - Self::default() - } - - pub fn require_write_auth(&self) -> bool { - self.require_write_auth - } - - pub fn unapproved_seller_action(&self) -> UnapprovedSellerAction { - self.unapproved_seller_action - } - - pub fn approved_sellers(&self) -> &BTreeSet<PublicKeyHex> { - &self.approved_sellers - } - - pub fn blocked_pubkeys(&self) -> &BTreeSet<PublicKeyHex> { - &self.blocked_pubkeys - } - - pub fn with_write_auth_required(mut self, required: bool) -> Self { - self.require_write_auth = required; - self - } - - pub fn with_unapproved_seller_action(mut self, action: UnapprovedSellerAction) -> Self { - self.unapproved_seller_action = action; - self - } - - pub fn approve_seller(mut self, pubkey: PublicKeyHex) -> Self { - self.approved_sellers.insert(pubkey); - self - } - - pub fn block_pubkey(mut self, pubkey: PublicKeyHex) -> Self { - self.blocked_pubkeys.insert(pubkey); - self - } - - pub fn is_seller_approved(&self, pubkey: &PublicKeyHex) -> bool { - self.approved_sellers.contains(pubkey) - } - - pub fn is_pubkey_blocked(&self, pubkey: &PublicKeyHex) -> bool { - self.blocked_pubkeys.contains(pubkey) - } - - pub fn admit(&self, event: &AdmissionEvent, context: &AdmissionContext) -> AdmissionDecision { - if event.kind() == AdmissionEventKind::RelayAuth { - return AdmissionDecision::Accepted(AdmissionAcceptance::new( - AdmissionEffect::AuthenticateOnly, - None, - )); - } - if let Some(rejection) = self.write_auth_rejection(event.author_pubkey(), context) { - return AdmissionDecision::Rejected(rejection); - } - if self.is_pubkey_blocked(event.author_pubkey()) { - if event.kind() == AdmissionEventKind::PublicListing { - return AdmissionDecision::Accepted(AdmissionAcceptance::new( - AdmissionEffect::StoreRawWithoutPublicListingProjection, - Some(ProjectionExclusionReason::BlockedSeller), - )); - } - return AdmissionDecision::Rejected(AdmissionRejection::new( - AdmissionRejectionKind::BlockedPubkey, - "blocked pubkey", - )); - } - if event.kind() == AdmissionEventKind::PublicListing { - return self.admit_public_listing(event.author_pubkey()); - } - AdmissionDecision::Accepted(AdmissionAcceptance::new(AdmissionEffect::StoreRaw, None)) - } - - fn write_auth_rejection( - &self, - author_pubkey: &PublicKeyHex, - context: &AdmissionContext, - ) -> Option<AdmissionRejection> { - if !self.require_write_auth { - return None; - } - match context.authenticated_pubkey() { - Some(authenticated_pubkey) if authenticated_pubkey == author_pubkey => None, - Some(_) => Some(AdmissionRejection::new( - AdmissionRejectionKind::AuthenticatedPubkeyMismatch, - "authenticated pubkey does not match event author", - )), - None => Some(AdmissionRejection::new( - AdmissionRejectionKind::AuthenticationRequired, - "write authentication required", - )), - } - } - - fn admit_public_listing(&self, seller_pubkey: &PublicKeyHex) -> AdmissionDecision { - if self.is_seller_approved(seller_pubkey) { - return AdmissionDecision::Accepted(AdmissionAcceptance::new( - AdmissionEffect::StoreRawAndProjectPublicListing, - None, - )); - } - match self.unapproved_seller_action { - UnapprovedSellerAction::StoreRawOnly => { - AdmissionDecision::Accepted(AdmissionAcceptance::new( - AdmissionEffect::StoreRawWithoutPublicListingProjection, - Some(ProjectionExclusionReason::UnapprovedSeller), - )) - } - UnapprovedSellerAction::RejectWrite => { - AdmissionDecision::Rejected(AdmissionRejection::new( - AdmissionRejectionKind::UnapprovedSeller, - "seller is not approved", - )) - } - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AdmissionContext { - authenticated_pubkey: Option<PublicKeyHex>, -} - -impl AdmissionContext { - pub fn unauthenticated() -> Self { - Self { - authenticated_pubkey: None, - } - } - - pub fn authenticated(pubkey: PublicKeyHex) -> Self { - Self { - authenticated_pubkey: Some(pubkey), - } - } - - pub fn authenticated_pubkey(&self) -> Option<&PublicKeyHex> { - self.authenticated_pubkey.as_ref() - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AdmissionEvent { - author_pubkey: PublicKeyHex, - kind: AdmissionEventKind, -} - -impl AdmissionEvent { - pub fn new(author_pubkey: PublicKeyHex, kind: AdmissionEventKind) -> Self { - Self { - author_pubkey, - kind, - } - } - - pub fn author_pubkey(&self) -> &PublicKeyHex { - &self.author_pubkey - } - - pub fn kind(&self) -> AdmissionEventKind { - self.kind - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AdmissionEventKind { - RelayAuth, - Write, - PublicListing, - DraftListing, -} - -impl AdmissionEventKind { - pub fn as_str(self) -> &'static str { - match self { - Self::RelayAuth => "relay auth", - Self::Write => "write", - Self::PublicListing => "public listing", - Self::DraftListing => "draft listing", - } - } -} - -impl fmt::Display for AdmissionEventKind { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(self.as_str()) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum UnapprovedSellerAction { - StoreRawOnly, - RejectWrite, -} - -impl UnapprovedSellerAction { - pub fn as_str(self) -> &'static str { - match self { - Self::StoreRawOnly => "store raw only", - Self::RejectWrite => "reject write", - } - } -} - -impl fmt::Display for UnapprovedSellerAction { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(self.as_str()) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum AdmissionDecision { - Accepted(AdmissionAcceptance), - Rejected(AdmissionRejection), -} - -impl AdmissionDecision { - pub fn accepted(&self) -> Option<&AdmissionAcceptance> { - match self { - Self::Accepted(acceptance) => Some(acceptance), - Self::Rejected(_) => None, - } - } - - pub fn rejection(&self) -> Option<&AdmissionRejection> { - match self { - Self::Accepted(_) => None, - Self::Rejected(rejection) => Some(rejection), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AdmissionAcceptance { - effect: AdmissionEffect, - projection_exclusion: Option<ProjectionExclusionReason>, -} - -impl AdmissionAcceptance { - pub fn new( - effect: AdmissionEffect, - projection_exclusion: Option<ProjectionExclusionReason>, - ) -> Self { - Self { - effect, - projection_exclusion, - } - } - - pub fn effect(&self) -> AdmissionEffect { - self.effect - } - - pub fn projection_exclusion(&self) -> Option<ProjectionExclusionReason> { - self.projection_exclusion - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AdmissionEffect { - AuthenticateOnly, - StoreRaw, - StoreRawAndProjectPublicListing, - StoreRawWithoutPublicListingProjection, -} - -impl AdmissionEffect { - pub fn as_str(self) -> &'static str { - match self { - Self::AuthenticateOnly => "authenticate only", - Self::StoreRaw => "store raw", - Self::StoreRawAndProjectPublicListing => "store raw and project public listing", - Self::StoreRawWithoutPublicListingProjection => { - "store raw without public listing projection" - } - } - } -} - -impl fmt::Display for AdmissionEffect { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(self.as_str()) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ProjectionExclusionReason { - UnapprovedSeller, - BlockedSeller, -} - -impl ProjectionExclusionReason { - pub fn as_str(self) -> &'static str { - match self { - Self::UnapprovedSeller => "unapproved seller", - Self::BlockedSeller => "blocked seller", - } - } -} - -impl fmt::Display for ProjectionExclusionReason { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(self.as_str()) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AdmissionRejection { - kind: AdmissionRejectionKind, - message: String, -} - -impl AdmissionRejection { - pub fn new(kind: AdmissionRejectionKind, message: &str) -> Self { - Self { - kind, - message: message.to_owned(), - } - } - - pub fn kind(&self) -> AdmissionRejectionKind { - self.kind - } - - pub fn message(&self) -> &str { - &self.message - } -} - -impl fmt::Display for AdmissionRejection { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(formatter, "{}: {}", self.kind, self.message) - } -} - -impl std::error::Error for AdmissionRejection {} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AdmissionRejectionKind { - AuthenticationRequired, - AuthenticatedPubkeyMismatch, - BlockedPubkey, - UnapprovedSeller, -} - -impl AdmissionRejectionKind { - pub fn as_str(self) -> &'static str { - match self { - Self::AuthenticationRequired => "authentication required", - Self::AuthenticatedPubkeyMismatch => "authenticated pubkey mismatch", - Self::BlockedPubkey => "blocked pubkey", - Self::UnapprovedSeller => "unapproved seller", - } - } -} - -impl fmt::Display for AdmissionRejectionKind { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(self.as_str()) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct EventValidator { - limits: RuntimeLimits, - admission_policy: AdmissionPolicy, -} - -impl EventValidator { - pub fn new(limits: RuntimeLimits, admission_policy: AdmissionPolicy) -> Self { - Self { - limits, - admission_policy, - } - } - - pub fn limits(&self) -> RuntimeLimits { - self.limits - } - - pub fn admission_policy(&self) -> &AdmissionPolicy { - &self.admission_policy - } - - pub fn validate( - &self, - event: &Event, - context: &AdmissionContext, - now: UnixTimestamp, - ) -> Result<ValidatedEvent, EventValidationRejection> { - self.limits - .validate_event(event) - .map_err(EventValidationRejection::RuntimeLimit)?; - self.limits - .validate_event_timestamp(event, now) - .map_err(EventValidationRejection::RuntimeLimit)?; - verify_event_signature(event).map_err(EventValidationRejection::Crypto)?; - validate_private_commerce_plaintext(event)?; - let payload = validation_payload(event)?; - let admission_event = - AdmissionEvent::new(event.unsigned().pubkey().clone(), payload.admission_kind()); - let admission = match self.admission_policy.admit(&admission_event, context) { - AdmissionDecision::Accepted(acceptance) => acceptance, - AdmissionDecision::Rejected(rejection) => { - return Err(EventValidationRejection::Admission(rejection)); - } - }; - Ok(ValidatedEvent { - event_id: event.id().clone(), - author_pubkey: event.unsigned().pubkey().clone(), - admission_kind: admission_event.kind(), - admission, - payload, - }) - } -} - -impl Default for EventValidator { - fn default() -> Self { - Self::new(RuntimeLimits::default(), AdmissionPolicy::default()) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ValidatedEvent { - event_id: EventId, - author_pubkey: PublicKeyHex, - admission_kind: AdmissionEventKind, - admission: AdmissionAcceptance, - payload: ValidatedEventPayload, -} - -impl ValidatedEvent { - pub fn event_id(&self) -> &EventId { - &self.event_id - } - - pub fn author_pubkey(&self) -> &PublicKeyHex { - &self.author_pubkey - } - - pub fn admission_kind(&self) -> AdmissionEventKind { - self.admission_kind - } - - pub fn admission(&self) -> &AdmissionAcceptance { - &self.admission - } - - pub fn payload(&self) -> &ValidatedEventPayload { - &self.payload - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ValidatedEventPayload { - RelayAuth(Box<RelayAuthEvent>), - Deletion(Box<DeletionRequest>), - Listing { - admission_kind: AdmissionEventKind, - evaluation: Box<ListingProjectionEvaluation>, - }, - Other, -} - -impl ValidatedEventPayload { - pub fn admission_kind(&self) -> AdmissionEventKind { - match self { - Self::RelayAuth(_) => AdmissionEventKind::RelayAuth, - Self::Deletion(_) | Self::Other => AdmissionEventKind::Write, - Self::Listing { admission_kind, .. } => *admission_kind, - } - } - - pub fn relay_auth(&self) -> Option<&RelayAuthEvent> { - match self { - Self::RelayAuth(event) => Some(event), - Self::Deletion(_) | Self::Listing { .. } | Self::Other => None, - } - } - - pub fn deletion_request(&self) -> Option<&DeletionRequest> { - match self { - Self::Deletion(request) => Some(request), - Self::RelayAuth(_) | Self::Listing { .. } | Self::Other => None, - } - } - - pub fn listing_evaluation(&self) -> Option<&ListingProjectionEvaluation> { - match self { - Self::Listing { evaluation, .. } => Some(evaluation), - Self::RelayAuth(_) | Self::Deletion(_) | Self::Other => None, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum EventValidationRejection { - RuntimeLimit(RuntimeLimitViolation), - Crypto(String), - Privacy(String), - Parser(EventParserRejection), - Admission(AdmissionRejection), -} - -impl EventValidationRejection { - pub fn kind(&self) -> EventValidationRejectionKind { - match self { - Self::RuntimeLimit(_) => EventValidationRejectionKind::RuntimeLimit, - Self::Crypto(_) => EventValidationRejectionKind::Crypto, - Self::Privacy(_) => EventValidationRejectionKind::Privacy, - Self::Parser(_) => EventValidationRejectionKind::Parser, - Self::Admission(_) => EventValidationRejectionKind::Admission, - } - } -} - -impl fmt::Display for EventValidationRejection { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::RuntimeLimit(violation) => write!(formatter, "runtime limit: {violation}"), - Self::Crypto(message) => write!(formatter, "crypto: {message}"), - Self::Privacy(field) => write!( - formatter, - "privacy: private commerce plaintext field `{field}` is not allowed" - ), - Self::Parser(rejection) => write!(formatter, "parser: {rejection}"), - Self::Admission(rejection) => write!(formatter, "admission: {rejection}"), - } - } -} - -impl std::error::Error for EventValidationRejection {} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum EventValidationRejectionKind { - RuntimeLimit, - Crypto, - Privacy, - Parser, - Admission, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct EventParserRejection { - parser: EventParser, - message: String, -} - -impl EventParserRejection { - pub fn new(parser: EventParser, message: String) -> Self { - Self { parser, message } - } - - pub fn parser(&self) -> EventParser { - self.parser - } - - pub fn message(&self) -> &str { - &self.message - } -} - -impl fmt::Display for EventParserRejection { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(formatter, "{}: {}", self.parser, self.message) - } -} - -impl std::error::Error for EventParserRejection {} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum EventParser { - RelayAuth, - Deletion, -} - -impl EventParser { - pub fn as_str(self) -> &'static str { - match self { - Self::RelayAuth => "relay auth", - Self::Deletion => "deletion", - } - } -} - -impl fmt::Display for EventParser { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(self.as_str()) - } -} - -fn validate_private_commerce_plaintext(event: &Event) -> Result<(), EventValidationRejection> { - for tag in event.unsigned().tags() { - let field = tag.name().as_str().to_owned(); - if private_commerce_plaintext_field(&field) { - return Err(EventValidationRejection::Privacy(field)); - } - } - if let Ok(content) = serde_json::from_str::<serde_json::Value>(event.unsigned().content()) - && let Some(field) = private_commerce_plaintext_json_field(&content) - { - return Err(EventValidationRejection::Privacy(field)); - } - Ok(()) -} - -fn private_commerce_plaintext_json_field(value: &serde_json::Value) -> Option<String> { - match value { - serde_json::Value::Object(fields) => { - for (field, value) in fields { - if private_commerce_plaintext_field(field) { - return Some(field.clone()); - } - if let Some(field) = private_commerce_plaintext_json_field(value) { - return Some(field); - } - } - None - } - serde_json::Value::Array(values) => values - .iter() - .find_map(private_commerce_plaintext_json_field), - _ => None, - } -} - -fn private_commerce_plaintext_field(field: &str) -> bool { - matches!( - normalized_privacy_field(field).as_str(), - "buyercontact" - | "contact" - | "deliveryaddress" - | "dispute" - | "disputeevidence" - | "order" - | "orderid" - | "ordernote" - | "payment" - | "paymentdetails" - | "phone" - | "privatenote" - | "refund" - | "refunddetails" - ) -} - -fn normalized_privacy_field(field: &str) -> String { - field - .chars() - .filter(|character| character.is_ascii_alphanumeric()) - .flat_map(char::to_lowercase) - .collect() -} - -fn validation_payload(event: &Event) -> Result<ValidatedEventPayload, EventValidationRejection> { - if event.unsigned().kind().as_u32() == 22_242 { - let auth = parse_relay_auth_event(event) - .map_err(|message| { - EventValidationRejection::Parser(EventParserRejection::new( - EventParser::RelayAuth, - message, - )) - })? - .expect("relay auth kind must parse as relay auth"); - return Ok(ValidatedEventPayload::RelayAuth(Box::new(auth))); - } - if event.unsigned().kind().as_u32() == 5 { - let deletion = parse_deletion_request(event) - .map_err(|message| { - EventValidationRejection::Parser(EventParserRejection::new( - EventParser::Deletion, - message, - )) - })? - .expect("deletion kind must parse as deletion request"); - return Ok(ValidatedEventPayload::Deletion(Box::new(deletion))); - } - match event.unsigned().kind().as_u32() { - 30_402 => Ok(ValidatedEventPayload::Listing { - admission_kind: AdmissionEventKind::PublicListing, - evaluation: Box::new(evaluate_listing_projection(event)), - }), - 30_403 => Ok(ValidatedEventPayload::Listing { - admission_kind: AdmissionEventKind::DraftListing, - evaluation: Box::new(evaluate_listing_projection(event)), - }), - _ => Ok(ValidatedEventPayload::Other), - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct EventIngestor { - validator: EventValidator, -} - -impl EventIngestor { - pub fn new(validator: EventValidator) -> Self { - Self { validator } - } - - pub fn validator(&self) -> &EventValidator { - &self.validator - } - - pub fn ingest<R>( - &self, - repository: &mut R, - event: Event, - context: &AdmissionContext, - received_at: UnixTimestamp, - now: UnixTimestamp, - ) -> Result<EventIngestion, EventIngestionRejection> - where - R: RawEventRepository + ListingProjectionRepository + DeletionMarkerRepository, - { - let validated = self - .validator - .validate(&event, context, now) - .map_err(EventIngestionRejection::Validation)?; - if validated.admission().effect() == AdmissionEffect::AuthenticateOnly { - return Ok(EventIngestion::new( - validated.event_id().clone(), - EventIngestionEffect::Authenticated, - None, - None, - 0, - )); - } - if event.unsigned().kind().is_ephemeral() { - return Ok(EventIngestion::new( - validated.event_id().clone(), - EventIngestionEffect::EphemeralAccepted, - None, - None, - 0, - )); - } - let raw_outcome = repository - .put_event(StoredEvent::new(event.clone(), received_at)) - .map_err(EventIngestionRejection::Repository)?; - if raw_outcome == StoreEventOutcome::Duplicate { - return Ok(EventIngestion::new( - validated.event_id().clone(), - EventIngestionEffect::Duplicate, - Some(raw_outcome), - None, - 0, - )); - } - let projection_outcome = ingest_projection(repository, &validated)?; - let deletion_marker_count = ingest_deletion_markers(repository, &validated, &event)?; - Ok(EventIngestion::new( - validated.event_id().clone(), - EventIngestionEffect::Stored, - Some(raw_outcome), - projection_outcome, - deletion_marker_count, - )) - } -} - -impl Default for EventIngestor { - fn default() -> Self { - Self::new(EventValidator::default()) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct EventIngestion { - event_id: EventId, - effect: EventIngestionEffect, - raw_event_outcome: Option<StoreEventOutcome>, - projection_outcome: Option<StoreProjectionOutcome>, - deletion_marker_count: usize, -} - -impl EventIngestion { - pub fn new( - event_id: EventId, - effect: EventIngestionEffect, - raw_event_outcome: Option<StoreEventOutcome>, - projection_outcome: Option<StoreProjectionOutcome>, - deletion_marker_count: usize, - ) -> Self { - Self { - event_id, - effect, - raw_event_outcome, - projection_outcome, - deletion_marker_count, - } - } - - pub fn event_id(&self) -> &EventId { - &self.event_id - } - - pub fn effect(&self) -> EventIngestionEffect { - self.effect - } - - pub fn raw_event_outcome(&self) -> Option<StoreEventOutcome> { - self.raw_event_outcome - } - - pub fn projection_outcome(&self) -> Option<StoreProjectionOutcome> { - self.projection_outcome - } - - pub fn deletion_marker_count(&self) -> usize { - self.deletion_marker_count - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum EventIngestionEffect { - Authenticated, - EphemeralAccepted, - Stored, - Duplicate, -} - -impl EventIngestionEffect { - pub fn as_str(self) -> &'static str { - match self { - Self::Authenticated => "authenticated", - Self::EphemeralAccepted => "ephemeral accepted", - Self::Stored => "stored", - Self::Duplicate => "duplicate", - } - } -} - -impl fmt::Display for EventIngestionEffect { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(self.as_str()) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum EventIngestionRejection { - Validation(EventValidationRejection), - Repository(RepositoryError), -} - -impl EventIngestionRejection { - pub fn kind(&self) -> EventIngestionRejectionKind { - match self { - Self::Validation(_) => EventIngestionRejectionKind::Validation, - Self::Repository(_) => EventIngestionRejectionKind::Repository, - } - } -} - -impl fmt::Display for EventIngestionRejection { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Validation(rejection) => write!(formatter, "validation: {rejection}"), - Self::Repository(rejection) => write!(formatter, "repository: {rejection}"), - } - } -} - -impl std::error::Error for EventIngestionRejection {} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum EventIngestionRejectionKind { - Validation, - Repository, -} - -fn ingest_projection<R>( - repository: &mut R, - validated: &ValidatedEvent, -) -> Result<Option<StoreProjectionOutcome>, EventIngestionRejection> -where - R: ListingProjectionRepository, -{ - if validated.admission().effect() != AdmissionEffect::StoreRawAndProjectPublicListing { - return Ok(None); - } - let Some(ListingProjectionEvaluation::Eligible(projection)) = - validated.payload().listing_evaluation() - else { - return Ok(None); - }; - repository - .put_listing_projection(projection.as_ref().clone()) - .map(Some) - .map_err(EventIngestionRejection::Repository) -} - -fn ingest_deletion_markers<R>( - repository: &mut R, - validated: &ValidatedEvent, - event: &Event, -) -> Result<usize, EventIngestionRejection> -where - R: DeletionMarkerRepository, -{ - let Some(request) = validated.payload().deletion_request() else { - return Ok(0); - }; - for target in request.targets() { - repository - .put_deletion_marker(DeletionMarker::new( - request.event_id().clone(), - event.unsigned().pubkey().clone(), - target.clone(), - event.unsigned().created_at(), - )) - .map_err(EventIngestionRejection::Repository)?; - } - Ok(request.targets().len()) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct QueryPlan { - source: QuerySource, - mode: QueryExecutionMode, - sort: QuerySort, - branches: Vec<QueryPlanBranch>, -} - -impl QueryPlan { - pub fn new( - source: QuerySource, - mode: QueryExecutionMode, - sort: QuerySort, - branches: Vec<QueryPlanBranch>, - ) -> Result<Self, QueryPlanError> { - if branches.is_empty() { - return Err(QueryPlanError::EmptyBranches); - } - Ok(Self { - source, - mode, - sort, - branches, - }) - } - - pub fn source(&self) -> QuerySource { - self.source - } - - pub fn mode(&self) -> QueryExecutionMode { - self.mode - } - - pub fn sort(&self) -> QuerySort { - self.sort - } - - pub fn branches(&self) -> &[QueryPlanBranch] { - &self.branches - } - - pub fn requires_historical_query(&self) -> bool { - self.mode != QueryExecutionMode::Live - && self.branches.iter().any(|branch| branch.limit() != Some(0)) - } - - pub fn subscribes_to_live_events(&self) -> bool { - self.mode != QueryExecutionMode::Historical - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct QueryPlanBranch { - ids: Vec<EventId>, - authors: Vec<PublicKeyHex>, - kinds: Vec<tangle_protocol::Kind>, - tag_filters: BTreeMap<char, Vec<String>>, - since: Option<UnixTimestamp>, - until: Option<UnixTimestamp>, - limit: Option<u64>, - search: Option<QuerySearch>, -} - -impl QueryPlanBranch { - pub fn from_spec(spec: QueryPlanBranchSpec) -> Result<Self, QueryPlanError> { - if let (Some(since), Some(until)) = (spec.since, spec.until) - && since > until - { - return Err(QueryPlanError::InvalidTimeRange { since, until }); - } - let mut tag_filters = BTreeMap::new(); - for filter in spec.tag_filters { - tag_filters - .entry(filter.name()) - .or_insert_with(Vec::new) - .extend(filter.values().iter().cloned()); - } - for values in tag_filters.values_mut() { - let unique = values.drain(..).collect::<BTreeSet<_>>(); - values.extend(unique); - } - Ok(Self { - ids: unique_sorted(spec.ids), - authors: unique_sorted(spec.authors), - kinds: unique_sorted(spec.kinds), - tag_filters, - since: spec.since, - until: spec.until, - limit: spec.limit, - search: spec.search, - }) - } - - pub fn ids(&self) -> &[EventId] { - &self.ids - } - - pub fn authors(&self) -> &[PublicKeyHex] { - &self.authors - } - - pub fn kinds(&self) -> &[tangle_protocol::Kind] { - &self.kinds - } - - pub fn tag_filters(&self) -> &BTreeMap<char, Vec<String>> { - &self.tag_filters - } - - pub fn since(&self) -> Option<UnixTimestamp> { - self.since - } - - pub fn until(&self) -> Option<UnixTimestamp> { - self.until - } - - pub fn limit(&self) -> Option<u64> { - self.limit - } - - pub fn search(&self) -> Option<&QuerySearch> { - self.search.as_ref() - } -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct QueryPlanBranchSpec { - pub ids: Vec<EventId>, - pub authors: Vec<PublicKeyHex>, - pub kinds: Vec<tangle_protocol::Kind>, - pub tag_filters: Vec<QueryTagFilter>, - pub since: Option<UnixTimestamp>, - pub until: Option<UnixTimestamp>, - pub limit: Option<u64>, - pub search: Option<QuerySearch>, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct QueryTagFilter { - name: char, - values: Vec<String>, -} - -impl QueryTagFilter { - pub fn new(name: char, values: Vec<String>) -> Result<Self, QueryPlanError> { - if !name.is_ascii_alphabetic() { - return Err(QueryPlanError::InvalidTagName { name }); - } - if values.is_empty() { - return Err(QueryPlanError::EmptyTagValues { name }); - } - if values.iter().any(String::is_empty) { - return Err(QueryPlanError::EmptyTagValue { name }); - } - Ok(Self { - name, - values: unique_sorted(values), - }) - } - - pub fn name(&self) -> char { - self.name - } - - pub fn values(&self) -> &[String] { - &self.values - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct QuerySearch { - raw: String, - terms: Vec<String>, -} - -impl QuerySearch { - pub fn new(raw: &str, terms: Vec<String>) -> Result<Self, QueryPlanError> { - let raw = raw.trim(); - if raw.is_empty() || terms.is_empty() || terms.iter().any(String::is_empty) { - return Err(QueryPlanError::EmptySearch); - } - Ok(Self { - raw: raw.to_owned(), - terms: unique_sorted(terms), - }) - } - - pub fn raw(&self) -> &str { - &self.raw - } - - pub fn terms(&self) -> &[String] { - &self.terms - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum QuerySource { - RawEvents, - ListingProjections, - SearchDocuments, -} - -impl QuerySource { - pub fn as_str(self) -> &'static str { - match self { - Self::RawEvents => "raw events", - Self::ListingProjections => "listing projections", - Self::SearchDocuments => "search documents", - } - } -} - -impl fmt::Display for QuerySource { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(self.as_str()) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum QueryExecutionMode { - Historical, - Live, - HistoricalThenLive, -} - -impl QueryExecutionMode { - pub fn as_str(self) -> &'static str { - match self { - Self::Historical => "historical", - Self::Live => "live", - Self::HistoricalThenLive => "historical then live", - } - } -} - -impl fmt::Display for QueryExecutionMode { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(self.as_str()) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum QuerySort { - CreatedAtDescEventIdAsc, - ScoreDescCreatedAtDescEventIdAsc, -} - -impl QuerySort { - pub fn as_str(self) -> &'static str { - match self { - Self::CreatedAtDescEventIdAsc => "created_at desc event_id asc", - Self::ScoreDescCreatedAtDescEventIdAsc => "score desc created_at desc event_id asc", - } - } -} - -impl fmt::Display for QuerySort { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(self.as_str()) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum QueryPlanError { - EmptyBranches, - InvalidTimeRange { - since: UnixTimestamp, - until: UnixTimestamp, - }, - InvalidTagName { - name: char, - }, - EmptyTagValues { - name: char, - }, - EmptyTagValue { - name: char, - }, - EmptySearch, -} - -impl fmt::Display for QueryPlanError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::EmptyBranches => { - formatter.write_str("query plan must include at least one branch") - } - Self::InvalidTimeRange { since, until } => { - write!( - formatter, - "query time range is invalid: since {since} > until {until}" - ) - } - Self::InvalidTagName { name } => { - write!( - formatter, - "tag filter name must be ASCII alphabetic, got `{name}`" - ) - } - Self::EmptyTagValues { name } => { - write!( - formatter, - "tag filter `{name}` must include at least one value" - ) - } - Self::EmptyTagValue { name } => { - write!(formatter, "tag filter `{name}` values must not be empty") - } - Self::EmptySearch => formatter.write_str("search query must include terms"), - } - } -} - -impl std::error::Error for QueryPlanError {} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct MarketplaceQuerySpec { - pub q: Option<String>, - pub categories: Vec<String>, - pub seller: Option<PublicKeyHex>, - pub statuses: Vec<MarketplaceListingStatus>, - pub currencies: Vec<String>, - pub units: Vec<ListingUnit>, - pub min_price: Option<String>, - pub max_price: Option<String>, - pub fulfillment: Vec<FulfillmentMethod>, - pub delivery_only: Option<bool>, - pub pickup: Option<bool>, - pub latitude_microdegrees: Option<i32>, - pub longitude_microdegrees: Option<i32>, - pub radius_meters: Option<u64>, - pub near: Option<String>, - pub sort: MarketplaceSort, - pub limit: Option<u64>, - pub cursor: Option<MarketplaceCursor>, -} - -impl Default for MarketplaceQuerySpec { - fn default() -> Self { - Self { - q: None, - categories: Vec::new(), - seller: None, - statuses: Vec::new(), - currencies: Vec::new(), - units: Vec::new(), - min_price: None, - max_price: None, - fulfillment: Vec::new(), - delivery_only: None, - pickup: None, - latitude_microdegrees: None, - longitude_microdegrees: None, - radius_meters: None, - near: None, - sort: MarketplaceSort::Relevance, - limit: None, - cursor: None, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct MarketplaceQuery { - pub text: Option<MarketplaceSearchText>, - pub categories: Vec<String>, - pub seller: Option<PublicKeyHex>, - pub statuses: Vec<MarketplaceListingStatus>, - pub currencies: Vec<String>, - pub units: Vec<ListingUnit>, - pub min_price: Option<MarketplaceDecimal>, - pub max_price: Option<MarketplaceDecimal>, - pub fulfillment: Vec<FulfillmentMethod>, - pub delivery_only: Option<bool>, - pub pickup: Option<bool>, - pub location: MarketplaceLocationFilter, - pub sort: MarketplaceSort, - pub limit: u64, - pub cursor: Option<MarketplaceCursor>, -} - -impl MarketplaceQuery { - pub const DEFAULT_LIMIT: u64 = 50; - pub const MAX_LIMIT: u64 = 100; - - pub fn from_spec( - spec: MarketplaceQuerySpec, - limits: RuntimeLimits, - ) -> Result<Self, MarketplaceQueryError> { - let text = marketplace_search_text(spec.q, limits)?; - let min_price = spec - .min_price - .as_deref() - .map(|value| MarketplaceDecimal::new("min_price", value)) - .transpose()?; - let max_price = spec - .max_price - .as_deref() - .map(|value| MarketplaceDecimal::new("max_price", value)) - .transpose()?; - if let (Some(min_price), Some(max_price)) = (&min_price, &max_price) - && decimal_greater_than(min_price, max_price) - { - return Err(MarketplaceQueryError::new( - MarketplaceQueryErrorKind::InvalidPriceRange, - "min_price must not exceed max_price", - )); - } - let location = MarketplaceLocationFilter::from_spec( - spec.latitude_microdegrees, - spec.longitude_microdegrees, - spec.radius_meters, - spec.near, - )?; - if spec.sort == MarketplaceSort::Distance && !location.has_distance_reference() { - return Err(MarketplaceQueryError::new( - MarketplaceQueryErrorKind::MissingDistanceReference, - "distance sort requires a point or near filter", - )); - } - let limit = spec.limit.unwrap_or(Self::DEFAULT_LIMIT); - if limit == 0 || limit > Self::MAX_LIMIT { - return Err(MarketplaceQueryError::new( - MarketplaceQueryErrorKind::LimitOutOfRange, - format!("limit must be between 1 and {}", Self::MAX_LIMIT), - )); - } - if spec - .cursor - .as_ref() - .is_some_and(|cursor| cursor.sort != spec.sort) - { - return Err(MarketplaceQueryError::new( - MarketplaceQueryErrorKind::CursorSortMismatch, - "cursor sort must match query sort", - )); - } - Ok(Self { - text, - categories: normalized_text_filters("category", spec.categories)?, - seller: spec.seller, - statuses: unique_sorted(spec.statuses), - currencies: normalized_currencies(spec.currencies)?, - units: unique_listing_units(spec.units), - min_price, - max_price, - fulfillment: unique_sorted(spec.fulfillment), - delivery_only: spec.delivery_only, - pickup: spec.pickup, - location, - sort: spec.sort, - limit, - cursor: spec.cursor, - }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct MarketplaceSearchText { - pub raw: String, - pub terms: Vec<String>, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct MarketplaceDecimal { - pub raw: String, - whole: String, - fraction: String, -} - -impl MarketplaceDecimal { - pub fn new(field: &'static str, value: &str) -> Result<Self, MarketplaceQueryError> { - let raw = value.trim(); - let Some((whole, fraction)) = normalized_decimal_parts(raw) else { - return Err(MarketplaceQueryError::new( - MarketplaceQueryErrorKind::InvalidDecimal, - format!("{field} must be an exact unsigned decimal"), - )); - }; - Ok(Self { - raw: raw.to_owned(), - whole, - fraction, - }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct MarketplaceLocationFilter { - pub point: Option<MarketplaceGeoPoint>, - pub radius_meters: Option<u64>, - pub near: Option<String>, -} - -impl MarketplaceLocationFilter { - pub fn from_spec( - latitude_microdegrees: Option<i32>, - longitude_microdegrees: Option<i32>, - radius_meters: Option<u64>, - near: Option<String>, - ) -> Result<Self, MarketplaceQueryError> { - let point = match (latitude_microdegrees, longitude_microdegrees) { - (Some(latitude_microdegrees), Some(longitude_microdegrees)) => Some( - MarketplaceGeoPoint::new(latitude_microdegrees, longitude_microdegrees)?, - ), - (None, None) => None, - _ => { - return Err(MarketplaceQueryError::new( - MarketplaceQueryErrorKind::InvalidLocation, - "lat and lon must be provided together", - )); - } - }; - let radius_meters = match radius_meters { - Some(0) => { - return Err(MarketplaceQueryError::new( - MarketplaceQueryErrorKind::InvalidLocation, - "radius_meters must be greater than zero", - )); - } - Some(_) if point.is_none() => { - return Err(MarketplaceQueryError::new( - MarketplaceQueryErrorKind::InvalidLocation, - "radius_meters requires lat and lon", - )); - } - value => value, - }; - let near = normalize_optional_text("near", near)?; - Ok(Self { - point, - radius_meters, - near, - }) - } - - pub fn has_distance_reference(&self) -> bool { - self.point.is_some() || self.near.is_some() - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct MarketplaceGeoPoint { - pub latitude_microdegrees: i32, - pub longitude_microdegrees: i32, -} - -impl MarketplaceGeoPoint { - pub fn new( - latitude_microdegrees: i32, - longitude_microdegrees: i32, - ) -> Result<Self, MarketplaceQueryError> { - if !(-90_000_000..=90_000_000).contains(&latitude_microdegrees) { - return Err(MarketplaceQueryError::new( - MarketplaceQueryErrorKind::InvalidLocation, - "lat must be between -90 and 90 degrees", - )); - } - if !(-180_000_000..=180_000_000).contains(&longitude_microdegrees) { - return Err(MarketplaceQueryError::new( - MarketplaceQueryErrorKind::InvalidLocation, - "lon must be between -180 and 180 degrees", - )); - } - Ok(Self { - latitude_microdegrees, - longitude_microdegrees, - }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct MarketplaceCursorSpec { - pub version: u16, - pub sort: MarketplaceSort, - pub score: Option<i64>, - pub distance_meters: Option<u64>, - pub price: Option<String>, - pub updated_at: UnixTimestamp, - pub event_id: EventId, - pub filter_hash: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct MarketplaceCursor { - pub version: u16, - pub sort: MarketplaceSort, - pub score: Option<i64>, - pub distance_meters: Option<u64>, - pub price: Option<MarketplaceDecimal>, - pub updated_at: UnixTimestamp, - pub event_id: EventId, - pub filter_hash: String, -} - -impl MarketplaceCursor { - pub fn from_spec(spec: MarketplaceCursorSpec) -> Result<Self, MarketplaceQueryError> { - if spec.version == 0 { - return Err(invalid_cursor("cursor version must be greater than zero")); - } - let filter_hash = spec.filter_hash.trim(); - if filter_hash.is_empty() { - return Err(invalid_cursor("cursor filter_hash must not be empty")); - } - let price = spec - .price - .as_deref() - .map(|value| MarketplaceDecimal::new("cursor price", value)) - .transpose()?; - match spec.sort { - MarketplaceSort::Relevance if spec.score.is_none() => { - return Err(invalid_cursor("relevance cursor requires score")); - } - MarketplaceSort::Distance if spec.distance_meters.is_none() => { - return Err(invalid_cursor("distance cursor requires distance")); - } - MarketplaceSort::PriceAsc | MarketplaceSort::PriceDesc if price.is_none() => { - return Err(invalid_cursor("price cursor requires price")); - } - _ => {} - } - Ok(Self { - version: spec.version, - sort: spec.sort, - score: spec.score, - distance_meters: spec.distance_meters, - price, - updated_at: spec.updated_at, - event_id: spec.event_id, - filter_hash: filter_hash.to_owned(), - }) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -pub enum MarketplaceListingStatus { - Active, - Sold, - Draft, - Inactive, - Expired, - Deleted, - Hidden, - Rejected, -} - -impl MarketplaceListingStatus { - pub fn as_str(self) -> &'static str { - match self { - Self::Active => "active", - Self::Sold => "sold", - Self::Draft => "draft", - Self::Inactive => "inactive", - Self::Expired => "expired", - Self::Deleted => "deleted", - Self::Hidden => "hidden", - Self::Rejected => "rejected", - } - } -} - -impl fmt::Display for MarketplaceListingStatus { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(self.as_str()) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum MarketplaceSort { - Relevance, - Freshness, - PriceAsc, - PriceDesc, - Distance, - SellerTrust, -} - -impl MarketplaceSort { - pub fn as_str(self) -> &'static str { - match self { - Self::Relevance => "relevance", - Self::Freshness => "freshness", - Self::PriceAsc => "price_asc", - Self::PriceDesc => "price_desc", - Self::Distance => "distance", - Self::SellerTrust => "seller_trust", - } - } -} - -impl fmt::Display for MarketplaceSort { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(self.as_str()) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct MarketplaceQueryError { - kind: MarketplaceQueryErrorKind, - message: String, -} - -impl MarketplaceQueryError { - pub fn new(kind: MarketplaceQueryErrorKind, message: impl Into<String>) -> Self { - Self { - kind, - message: message.into(), - } - } - - pub fn runtime_limit(violation: RuntimeLimitViolation) -> Self { - Self::new( - MarketplaceQueryErrorKind::RuntimeLimit, - format!("runtime limit: {violation}"), - ) - } - - pub fn kind(&self) -> MarketplaceQueryErrorKind { - self.kind - } - - pub fn message(&self) -> &str { - &self.message - } -} - -impl fmt::Display for MarketplaceQueryError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(&self.message) - } -} - -impl std::error::Error for MarketplaceQueryError {} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum MarketplaceQueryErrorKind { - RuntimeLimit, - EmptyFilterValue, - InvalidDecimal, - InvalidPriceRange, - InvalidLocation, - LimitOutOfRange, - MissingDistanceReference, - InvalidCursor, - CursorSortMismatch, -} - -fn marketplace_search_text( - q: Option<String>, - limits: RuntimeLimits, -) -> Result<Option<MarketplaceSearchText>, MarketplaceQueryError> { - let Some(q) = q else { - return Ok(None); - }; - limits - .validate_search_query(&q) - .map_err(MarketplaceQueryError::runtime_limit)?; - let raw = q.trim(); - if raw.is_empty() { - return Ok(None); - } - let terms = unique_sorted( - raw.split_whitespace() - .map(|term| term.to_ascii_lowercase()) - .collect(), - ); - Ok(Some(MarketplaceSearchText { - raw: raw.to_owned(), - terms, - })) -} - -fn normalized_text_filters( - field: &'static str, - values: Vec<String>, -) -> Result<Vec<String>, MarketplaceQueryError> { - let values = values - .into_iter() - .map(|value| normalize_required_text(field, value)) - .collect::<Result<Vec<_>, _>>()?; - Ok(unique_sorted(values)) -} - -fn normalized_currencies(values: Vec<String>) -> Result<Vec<String>, MarketplaceQueryError> { - let values = values - .into_iter() - .map(|value| { - normalize_required_text("currency", value).map(|value| value.to_ascii_uppercase()) - }) - .collect::<Result<Vec<_>, _>>()?; - Ok(unique_sorted(values)) -} - -fn normalize_required_text( - field: &'static str, - value: String, -) -> Result<String, MarketplaceQueryError> { - let normalized = value.trim().to_ascii_lowercase(); - if normalized.is_empty() { - return Err(MarketplaceQueryError::new( - MarketplaceQueryErrorKind::EmptyFilterValue, - format!("{field} filter value must not be empty"), - )); - } - Ok(normalized) -} - -fn normalize_optional_text( - field: &'static str, - value: Option<String>, -) -> Result<Option<String>, MarketplaceQueryError> { - value - .map(|value| normalize_required_text(field, value)) - .transpose() -} - -fn unique_listing_units(mut values: Vec<ListingUnit>) -> Vec<ListingUnit> { - values.sort_by_key(|unit| unit.canonical()); - values.dedup(); - values -} - -fn normalized_decimal_parts(value: &str) -> Option<(String, String)> { - let mut parts = value.split('.'); - let whole = parts.next().unwrap_or_default(); - let fraction = parts.next(); - if parts.next().is_some() - || whole.is_empty() - || !whole.bytes().all(|byte| byte.is_ascii_digit()) - { - return None; - } - let fraction = match fraction { - Some(value) if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) => { - return None; - } - Some(value) => value.trim_end_matches('0').to_owned(), - None => String::new(), - }; - let whole = whole.trim_start_matches('0'); - let whole = if whole.is_empty() { "0" } else { whole }; - Some((whole.to_owned(), fraction)) -} - -fn decimal_greater_than(left: &MarketplaceDecimal, right: &MarketplaceDecimal) -> bool { - match left.whole.len().cmp(&right.whole.len()) { - std::cmp::Ordering::Equal => {} - ordering => return ordering == std::cmp::Ordering::Greater, - } - match left.whole.cmp(&right.whole) { - std::cmp::Ordering::Equal => {} - ordering => return ordering == std::cmp::Ordering::Greater, - } - left.fraction > right.fraction -} - -fn invalid_cursor(message: &'static str) -> MarketplaceQueryError { - MarketplaceQueryError::new(MarketplaceQueryErrorKind::InvalidCursor, message) -} - -fn unique_sorted<T>(values: Vec<T>) -> Vec<T> -where - T: Ord, -{ - values - .into_iter() - .collect::<BTreeSet<_>>() - .into_iter() - .collect() -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct NostrFilterCompiler { - limits: RuntimeLimits, -} - -impl NostrFilterCompiler { - pub fn new(limits: RuntimeLimits) -> Self { - Self { limits } - } - - pub fn limits(self) -> RuntimeLimits { - self.limits - } - - pub fn compile( - &self, - filters: &[Filter], - mode: QueryExecutionMode, - ) -> Result<QueryPlan, NostrFilterCompileError> { - self.limits - .validate_filters(filters.len() as u64, filter_complexity(filters)) - .map_err(NostrFilterCompileError::RuntimeLimit)?; - let branches = filters - .iter() - .map(|filter| compile_filter_branch(filter, self.limits)) - .collect::<Result<Vec<_>, _>>()?; - let source = if branches.iter().any(|branch| branch.search().is_some()) { - QuerySource::SearchDocuments - } else { - QuerySource::RawEvents - }; - let sort = if source == QuerySource::SearchDocuments { - QuerySort::ScoreDescCreatedAtDescEventIdAsc - } else { - QuerySort::CreatedAtDescEventIdAsc - }; - QueryPlan::new(source, mode, sort, branches).map_err(NostrFilterCompileError::QueryPlan) - } -} - -impl Default for NostrFilterCompiler { - fn default() -> Self { - Self::new(RuntimeLimits::default()) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum NostrFilterCompileError { - RuntimeLimit(RuntimeLimitViolation), - QueryPlan(QueryPlanError), -} - -impl NostrFilterCompileError { - pub fn kind(&self) -> NostrFilterCompileErrorKind { - match self { - Self::RuntimeLimit(_) => NostrFilterCompileErrorKind::RuntimeLimit, - Self::QueryPlan(_) => NostrFilterCompileErrorKind::QueryPlan, - } - } -} - -impl fmt::Display for NostrFilterCompileError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::RuntimeLimit(violation) => write!(formatter, "runtime limit: {violation}"), - Self::QueryPlan(error) => write!(formatter, "query plan: {error}"), - } - } -} - -impl std::error::Error for NostrFilterCompileError {} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum NostrFilterCompileErrorKind { - RuntimeLimit, - QueryPlan, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Nip50QueryCompiler { - limits: RuntimeLimits, -} - -impl Nip50QueryCompiler { - pub fn new(limits: RuntimeLimits) -> Self { - Self { limits } - } - - pub fn limits(self) -> RuntimeLimits { - self.limits - } - - pub fn compile( - &self, - filters: &[Filter], - mode: QueryExecutionMode, - ) -> Result<QueryPlan, Nip50QueryCompileError> { - self.limits - .validate_filters(filters.len() as u64, filter_complexity(filters)) - .map_err(Nip50QueryCompileError::RuntimeLimit)?; - let branches = filters - .iter() - .map(|filter| compile_nip50_filter_branch(filter, self.limits)) - .collect::<Result<Vec<_>, _>>()? - .into_iter() - .flatten() - .collect::<Vec<_>>(); - if branches.is_empty() { - return Err(Nip50QueryCompileError::MissingSearchTerms); - } - QueryPlan::new( - QuerySource::SearchDocuments, - mode, - QuerySort::ScoreDescCreatedAtDescEventIdAsc, - branches, - ) - .map_err(Nip50QueryCompileError::QueryPlan) - } -} - -impl Default for Nip50QueryCompiler { - fn default() -> Self { - Self::new(RuntimeLimits::default()) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Nip50QueryCompileError { - RuntimeLimit(RuntimeLimitViolation), - QueryPlan(QueryPlanError), - MissingSearchTerms, -} - -impl Nip50QueryCompileError { - pub fn kind(&self) -> Nip50QueryCompileErrorKind { - match self { - Self::RuntimeLimit(_) => Nip50QueryCompileErrorKind::RuntimeLimit, - Self::QueryPlan(_) => Nip50QueryCompileErrorKind::QueryPlan, - Self::MissingSearchTerms => Nip50QueryCompileErrorKind::MissingSearchTerms, - } - } -} - -impl fmt::Display for Nip50QueryCompileError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::RuntimeLimit(violation) => write!(formatter, "runtime limit: {violation}"), - Self::QueryPlan(error) => write!(formatter, "query plan: {error}"), - Self::MissingSearchTerms => { - formatter.write_str("nip50 query must include plain search terms") - } - } - } -} - -impl std::error::Error for Nip50QueryCompileError {} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Nip50QueryCompileErrorKind { - RuntimeLimit, - QueryPlan, - MissingSearchTerms, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct SubscriptionMatcher { - live_search_policy: LiveSearchPolicy, -} - -impl SubscriptionMatcher { - pub fn new(live_search_policy: LiveSearchPolicy) -> Self { - Self { live_search_policy } - } - - pub fn live_search_policy(self) -> LiveSearchPolicy { - self.live_search_policy - } - - pub fn match_event(&self, plan: &QueryPlan, event: &Event) -> SubscriptionMatch { - if !plan.subscribes_to_live_events() { - return SubscriptionMatch::empty(); - } - let branch_indexes = plan - .branches() - .iter() - .enumerate() - .filter_map(|(index, branch)| { - branch_matches_event(branch, event, self.live_search_policy).then_some(index) - }) - .collect(); - SubscriptionMatch { branch_indexes } - } -} - -impl Default for SubscriptionMatcher { - fn default() -> Self { - Self::new(LiveSearchPolicy::BestEffortTokenMatch) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum LiveSearchPolicy { - BestEffortTokenMatch, - DisabledLiveSearch, -} - -impl LiveSearchPolicy { - pub fn as_str(self) -> &'static str { - match self { - Self::BestEffortTokenMatch => "best_effort_token_match", - Self::DisabledLiveSearch => "disabled_live_search", - } - } -} - -impl fmt::Display for LiveSearchPolicy { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(self.as_str()) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SubscriptionMatch { - branch_indexes: Vec<usize>, -} - -impl SubscriptionMatch { - pub fn empty() -> Self { - Self { - branch_indexes: Vec::new(), - } - } - - pub fn matched(&self) -> bool { - !self.branch_indexes.is_empty() - } - - pub fn branch_indexes(&self) -> &[usize] { - &self.branch_indexes - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SubscriptionManager { - limits: RuntimeLimits, - matcher: SubscriptionMatcher, - subscriptions: BTreeMap<SubscriptionId, QueryPlan>, -} - -impl SubscriptionManager { - pub fn new(limits: RuntimeLimits, matcher: SubscriptionMatcher) -> Self { - Self { - limits, - matcher, - subscriptions: BTreeMap::new(), - } - } - - pub fn limits(&self) -> RuntimeLimits { - self.limits - } - - pub fn matcher(&self) -> SubscriptionMatcher { - self.matcher - } - - pub fn active_count(&self) -> usize { - self.subscriptions.len() - } - - pub fn plan(&self, subscription_id: &SubscriptionId) -> Option<&QueryPlan> { - self.subscriptions.get(subscription_id) - } - - pub fn subscribe( - &mut self, - subscription_id: SubscriptionId, - plan: QueryPlan, - ) -> Result<SubscriptionAddOutcome, SubscriptionManagerError> { - let replacing = self.subscriptions.contains_key(&subscription_id); - let active_count = self.subscriptions.len() + usize::from(!replacing); - self.limits - .validate_subscription_count(active_count as u64) - .map_err(SubscriptionManagerError::RuntimeLimit)?; - let outcome = if replacing { - SubscriptionAddOutcome::Replaced - } else { - SubscriptionAddOutcome::Inserted - }; - self.subscriptions.insert(subscription_id, plan); - Ok(outcome) - } - - pub fn close(&mut self, subscription_id: &SubscriptionId) -> SubscriptionCloseOutcome { - match self.subscriptions.remove(subscription_id) { - Some(_) => SubscriptionCloseOutcome::Closed, - None => SubscriptionCloseOutcome::NotFound, - } - } - - pub fn match_event(&self, event: &Event) -> Vec<SubscriptionEventMatch> { - self.subscriptions - .iter() - .filter_map(|(subscription_id, plan)| { - let subscription_match = self.matcher.match_event(plan, event); - subscription_match - .matched() - .then(|| SubscriptionEventMatch { - subscription_id: subscription_id.clone(), - branch_indexes: subscription_match.branch_indexes().to_vec(), - }) - }) - .collect() - } -} - -impl Default for SubscriptionManager { - fn default() -> Self { - Self::new(RuntimeLimits::default(), SubscriptionMatcher::default()) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SubscriptionAddOutcome { - Inserted, - Replaced, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SubscriptionCloseOutcome { - Closed, - NotFound, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SubscriptionEventMatch { - pub subscription_id: SubscriptionId, - pub branch_indexes: Vec<usize>, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum SubscriptionManagerError { - RuntimeLimit(RuntimeLimitViolation), -} - -impl SubscriptionManagerError { - pub fn kind(&self) -> SubscriptionManagerErrorKind { - match self { - Self::RuntimeLimit(_) => SubscriptionManagerErrorKind::RuntimeLimit, - } - } -} - -impl fmt::Display for SubscriptionManagerError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::RuntimeLimit(violation) => write!(formatter, "runtime limit: {violation}"), - } - } -} - -impl std::error::Error for SubscriptionManagerError {} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SubscriptionManagerErrorKind { - RuntimeLimit, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AuthChallengeState { - relay_url: String, - ttl_seconds: u64, - active_challenge: Option<AuthChallenge>, - authenticated_pubkey: Option<PublicKeyHex>, -} - -impl AuthChallengeState { - pub fn new(relay_url: &str, ttl_seconds: u64) -> Result<Self, AuthChallengeStateError> { - let relay_url = relay_url.trim(); - if relay_url.is_empty() { - return Err(AuthChallengeStateError::InvalidRelayUrl); - } - if ttl_seconds == 0 { - return Err(AuthChallengeStateError::InvalidTtl); - } - Ok(Self { - relay_url: relay_url.to_owned(), - ttl_seconds, - active_challenge: None, - authenticated_pubkey: None, - }) - } - - pub fn relay_url(&self) -> &str { - &self.relay_url - } - - pub fn ttl_seconds(&self) -> u64 { - self.ttl_seconds - } - - pub fn active_challenge(&self) -> Option<&AuthChallenge> { - self.active_challenge.as_ref() - } - - pub fn authenticated_pubkey(&self) -> Option<&PublicKeyHex> { - self.authenticated_pubkey.as_ref() - } - - pub fn issue_challenge( - &mut self, - challenge: &str, - issued_at: UnixTimestamp, - ) -> Result<AuthChallenge, AuthChallengeStateError> { - let challenge = challenge.trim(); - if challenge.is_empty() { - return Err(AuthChallengeStateError::EmptyChallenge); - } - let challenge = AuthChallenge { - value: challenge.to_owned(), - relay_url: self.relay_url.clone(), - issued_at, - expires_at: UnixTimestamp::new(issued_at.as_u64().saturating_add(self.ttl_seconds)), - }; - self.active_challenge = Some(challenge.clone()); - self.authenticated_pubkey = None; - Ok(challenge) - } - - pub fn authenticate( - &mut self, - auth: &RelayAuthEvent, - now: UnixTimestamp, - ) -> Result<AuthChallengeAuthentication, AuthChallengeStateError> { - let challenge = self - .active_challenge - .as_ref() - .ok_or(AuthChallengeStateError::MissingChallenge)?; - if now > challenge.expires_at { - return Err(AuthChallengeStateError::Expired { - expired_at: challenge.expires_at, - now, - }); - } - if auth.relay() != challenge.relay_url { - return Err(AuthChallengeStateError::RelayMismatch { - expected: challenge.relay_url.clone(), - actual: auth.relay().to_owned(), - }); - } - if auth.challenge() != challenge.value { - return Err(AuthChallengeStateError::ChallengeMismatch); - } - if auth.created_at() < challenge.issued_at { - return Err(AuthChallengeStateError::CreatedBeforeChallenge { - created_at: auth.created_at(), - issued_at: challenge.issued_at, - }); - } - let authentication = AuthChallengeAuthentication { - pubkey: auth.pubkey().clone(), - }; - self.authenticated_pubkey = Some(authentication.pubkey.clone()); - self.active_challenge = None; - Ok(authentication) - } - - pub fn clear_authentication(&mut self) { - self.authenticated_pubkey = None; - } -} - -impl Default for AuthChallengeState { - fn default() -> Self { - Self::new("wss://relay.radroots.test", 300).expect("default auth challenge state") - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AuthChallenge { - pub value: String, - pub relay_url: String, - pub issued_at: UnixTimestamp, - pub expires_at: UnixTimestamp, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AuthChallengeAuthentication { - pub pubkey: PublicKeyHex, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum AuthChallengeStateError { - InvalidRelayUrl, - InvalidTtl, - EmptyChallenge, - MissingChallenge, - Expired { - expired_at: UnixTimestamp, - now: UnixTimestamp, - }, - RelayMismatch { - expected: String, - actual: String, - }, - ChallengeMismatch, - CreatedBeforeChallenge { - created_at: UnixTimestamp, - issued_at: UnixTimestamp, - }, -} - -impl AuthChallengeStateError { - pub fn kind(&self) -> AuthChallengeStateErrorKind { - match self { - Self::InvalidRelayUrl => AuthChallengeStateErrorKind::InvalidRelayUrl, - Self::InvalidTtl => AuthChallengeStateErrorKind::InvalidTtl, - Self::EmptyChallenge => AuthChallengeStateErrorKind::EmptyChallenge, - Self::MissingChallenge => AuthChallengeStateErrorKind::MissingChallenge, - Self::Expired { .. } => AuthChallengeStateErrorKind::Expired, - Self::RelayMismatch { .. } => AuthChallengeStateErrorKind::RelayMismatch, - Self::ChallengeMismatch => AuthChallengeStateErrorKind::ChallengeMismatch, - Self::CreatedBeforeChallenge { .. } => { - AuthChallengeStateErrorKind::CreatedBeforeChallenge - } - } - } -} - -impl fmt::Display for AuthChallengeStateError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::InvalidRelayUrl => formatter.write_str("relay url must not be empty"), - Self::InvalidTtl => formatter.write_str("auth challenge ttl must be greater than zero"), - Self::EmptyChallenge => formatter.write_str("auth challenge must not be empty"), - Self::MissingChallenge => formatter.write_str("auth challenge is missing"), - Self::Expired { expired_at, now } => { - write!( - formatter, - "auth challenge expired at {expired_at}, now {now}" - ) - } - Self::RelayMismatch { expected, actual } => { - write!( - formatter, - "auth relay mismatch: expected {expected}, got {actual}" - ) - } - Self::ChallengeMismatch => formatter.write_str("auth challenge mismatch"), - Self::CreatedBeforeChallenge { - created_at, - issued_at, - } => write!( - formatter, - "auth event created_at {created_at} is before challenge issued_at {issued_at}" - ), - } - } -} - -impl std::error::Error for AuthChallengeStateError {} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AuthChallengeStateErrorKind { - InvalidRelayUrl, - InvalidTtl, - EmptyChallenge, - MissingChallenge, - Expired, - RelayMismatch, - ChallengeMismatch, - CreatedBeforeChallenge, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct RateLimitConfig { - pub limit: u64, - pub window_seconds: u64, -} - -impl RateLimitConfig { - pub fn new(limit: u64, window_seconds: u64) -> Result<Self, RateLimitConfigError> { - if limit == 0 { - return Err(RateLimitConfigError::ZeroLimit); - } - if window_seconds == 0 { - return Err(RateLimitConfigError::ZeroWindowSeconds); - } - Ok(Self { - limit, - window_seconds, - }) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RateLimitConfigError { - ZeroLimit, - ZeroWindowSeconds, -} - -impl fmt::Display for RateLimitConfigError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::ZeroLimit => formatter.write_str("rate limit must be greater than zero"), - Self::ZeroWindowSeconds => { - formatter.write_str("rate limit window must be greater than zero seconds") - } - } - } -} - -impl std::error::Error for RateLimitConfigError {} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RateLimitDecision { - Accepted { - remaining: u64, - reset_at: UnixTimestamp, - }, - Rejected { - retry_after_seconds: u64, - reset_at: UnixTimestamp, - }, -} - -impl RateLimitDecision { - pub fn allowed(self) -> bool { - matches!(self, Self::Accepted { .. }) - } - - pub fn remaining(self) -> u64 { - match self { - Self::Accepted { remaining, .. } => remaining, - Self::Rejected { .. } => 0, - } - } - - pub fn reset_at(self) -> UnixTimestamp { - match self { - Self::Accepted { reset_at, .. } | Self::Rejected { reset_at, .. } => reset_at, - } - } - - pub fn retry_after_seconds(self) -> Option<u64> { - match self { - Self::Accepted { .. } => None, - Self::Rejected { - retry_after_seconds, - .. - } => Some(retry_after_seconds), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct FixedWindowRateLimiter { - config: RateLimitConfig, - windows: BTreeMap<String, RateLimitWindow>, -} - -impl FixedWindowRateLimiter { - pub fn new(config: RateLimitConfig) -> Self { - Self { - config, - windows: BTreeMap::new(), - } - } - - pub fn config(&self) -> RateLimitConfig { - self.config - } - - pub fn tracked_key_count(&self) -> usize { - self.windows.len() - } - - pub fn check( - &mut self, - key: &str, - now: UnixTimestamp, - cost: u64, - ) -> Result<RateLimitDecision, RateLimitError> { - let key = key.trim(); - if key.is_empty() { - return Err(RateLimitError::EmptyKey); - } - if cost == 0 { - return Err(RateLimitError::ZeroCost); - } - if cost > self.config.limit { - return Err(RateLimitError::CostExceedsLimit { - cost, - limit: self.config.limit, - }); - } - let limit = self.config.limit; - let window_seconds = self.config.window_seconds; - let window = self - .windows - .entry(key.to_owned()) - .and_modify(|window| window.reset_if_elapsed(now, window_seconds)) - .or_insert_with(|| RateLimitWindow::new(now)); - let reset_at = window.reset_at(window_seconds); - if window.used + cost > limit { - return Ok(RateLimitDecision::Rejected { - retry_after_seconds: reset_at.as_u64().saturating_sub(now.as_u64()), - reset_at, - }); - } - window.used += cost; - Ok(RateLimitDecision::Accepted { - remaining: limit - window.used, - reset_at, - }) - } - - pub fn prune_expired(&mut self, now: UnixTimestamp) -> usize { - let before = self.windows.len(); - let window_seconds = self.config.window_seconds; - self.windows - .retain(|_, window| window.reset_at(window_seconds) > now); - before - self.windows.len() - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct RateLimitWindow { - started_at: UnixTimestamp, - used: u64, -} - -impl RateLimitWindow { - fn new(started_at: UnixTimestamp) -> Self { - Self { - started_at, - used: 0, - } - } - - fn reset_at(self, window_seconds: u64) -> UnixTimestamp { - UnixTimestamp::new(self.started_at.as_u64().saturating_add(window_seconds)) - } - - fn reset_if_elapsed(&mut self, now: UnixTimestamp, window_seconds: u64) { - if now >= self.reset_at(window_seconds) || now < self.started_at { - self.started_at = now; - self.used = 0; - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RateLimitError { - EmptyKey, - ZeroCost, - CostExceedsLimit { cost: u64, limit: u64 }, -} - -impl RateLimitError { - pub fn kind(self) -> RateLimitErrorKind { - match self { - Self::EmptyKey => RateLimitErrorKind::EmptyKey, - Self::ZeroCost => RateLimitErrorKind::ZeroCost, - Self::CostExceedsLimit { .. } => RateLimitErrorKind::CostExceedsLimit, - } - } -} - -impl fmt::Display for RateLimitError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::EmptyKey => formatter.write_str("rate limit key must not be empty"), - Self::ZeroCost => formatter.write_str("rate limit cost must be greater than zero"), - Self::CostExceedsLimit { cost, limit } => { - write!(formatter, "rate limit cost {cost} exceeds limit {limit}") - } - } - } -} - -impl std::error::Error for RateLimitError {} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RateLimitErrorKind { - EmptyKey, - ZeroCost, - CostExceedsLimit, -} - -fn compile_filter_branch( - filter: &Filter, - limits: RuntimeLimits, -) -> Result<QueryPlanBranch, NostrFilterCompileError> { - let tag_filters = - compile_filter_tag_constraints(filter).map_err(NostrFilterCompileError::QueryPlan)?; - let search = filter - .search() - .map(|raw| { - limits - .validate_search_query(raw) - .map_err(NostrFilterCompileError::RuntimeLimit)?; - QuerySearch::new( - raw, - raw.split_whitespace() - .map(str::to_owned) - .collect::<Vec<_>>(), - ) - .map_err(NostrFilterCompileError::QueryPlan) - }) - .transpose()?; - QueryPlanBranch::from_spec(QueryPlanBranchSpec { - ids: filter.ids().to_vec(), - authors: filter.authors().to_vec(), - kinds: filter.kinds().to_vec(), - tag_filters, - since: filter.since(), - until: filter.until(), - limit: filter.limit(), - search, - }) - .map_err(NostrFilterCompileError::QueryPlan) -} - -fn compile_nip50_filter_branch( - filter: &Filter, - limits: RuntimeLimits, -) -> Result<Option<QueryPlanBranch>, Nip50QueryCompileError> { - if let Some(raw) = filter.search() { - limits - .validate_search_query(raw) - .map_err(Nip50QueryCompileError::RuntimeLimit)?; - } - let search = match parse_nip50_filter_search(filter) - .expect("validated protocol filters are valid nip50 parser input") - { - Some(search) => search, - None => return Ok(None), - }; - let search = QuerySearch::new(search.text(), search.terms().to_vec()) - .expect("nip50 parser only returns search queries with plain terms"); - let tag_filters = - compile_filter_tag_constraints(filter).map_err(Nip50QueryCompileError::QueryPlan)?; - QueryPlanBranch::from_spec(QueryPlanBranchSpec { - ids: filter.ids().to_vec(), - authors: filter.authors().to_vec(), - kinds: filter.kinds().to_vec(), - tag_filters, - since: filter.since(), - until: filter.until(), - limit: filter.limit(), - search: Some(search), - }) - .map(Some) - .map_err(Nip50QueryCompileError::QueryPlan) -} - -fn branch_matches_event( - branch: &QueryPlanBranch, - event: &Event, - live_search_policy: LiveSearchPolicy, -) -> bool { - if !branch.ids().is_empty() && !branch.ids().iter().any(|id| id == event.id()) { - return false; - } - if !branch.authors().is_empty() - && !branch - .authors() - .iter() - .any(|author| author == event.unsigned().pubkey()) - { - return false; - } - if !branch.kinds().is_empty() - && !branch - .kinds() - .iter() - .any(|kind| *kind == event.unsigned().kind()) - { - return false; - } - if let Some(since) = branch.since() - && event.unsigned().created_at() < since - { - return false; - } - if let Some(until) = branch.until() - && event.unsigned().created_at() > until - { - return false; - } - for (name, values) in branch.tag_filters() { - let matched = event.unsigned().tags().iter().any(|tag| { - tag.indexed_pair().is_some_and(|(tag_name, tag_value)| { - tag_name == name.to_string() && values.iter().any(|value| value == tag_value) - }) - }); - if !matched { - return false; - } - } - match branch.search() { - Some(search) => live_search_matches(search, event, live_search_policy), - None => true, - } -} - -fn live_search_matches( - search: &QuerySearch, - event: &Event, - live_search_policy: LiveSearchPolicy, -) -> bool { - match live_search_policy { - LiveSearchPolicy::DisabledLiveSearch => false, - LiveSearchPolicy::BestEffortTokenMatch => { - let tokens = event_search_tokens(event); - search - .terms() - .iter() - .all(|term| tokens.contains(&term.to_ascii_lowercase())) - } - } -} - -fn event_search_tokens(event: &Event) -> BTreeSet<String> { - let mut tokens = BTreeSet::new(); - collect_search_tokens(event.unsigned().content(), &mut tokens); - for tag in event.unsigned().tags() { - for value in tag.values() { - collect_search_tokens(value, &mut tokens); - } - } - tokens -} - -fn collect_search_tokens(value: &str, tokens: &mut BTreeSet<String>) { - tokens.extend( - value - .split(|character: char| !character.is_ascii_alphanumeric()) - .filter(|term| !term.is_empty()) - .map(|term| term.to_ascii_lowercase()), - ); -} - -fn compile_filter_tag_constraints(filter: &Filter) -> Result<Vec<QueryTagFilter>, QueryPlanError> { - filter - .tag_filters() - .iter() - .map(|(name, values)| { - let name = name - .as_str() - .chars() - .next() - .expect("protocol tag filters are non-empty"); - QueryTagFilter::new( - name, - values - .iter() - .map(|value| value.as_str().to_owned()) - .collect(), - ) - }) - .collect() -} - -fn filter_complexity(filters: &[Filter]) -> u64 { - filters - .iter() - .map(|filter| { - let tag_value_count = filter.tag_filters().values().map(Vec::len).sum::<usize>(); - let search_terms = filter - .search() - .map_or(0, |search| search.split_whitespace().count()); - 1 + filter.ids().len() - + filter.authors().len() - + filter.kinds().len() - + filter.tag_filters().len() - + tag_value_count - + usize::from(filter.since().is_some()) - + usize::from(filter.until().is_some()) - + usize::from(filter.limit().is_some()) - + search_terms - }) - .sum::<usize>() as u64 -} - -fn require_positive(field: &'static str, value: u64) -> Result<(), RuntimeLimitConfigError> { - if value == 0 { - Err(RuntimeLimitConfigError::Zero { field }) - } else { - Ok(()) - } -} - -fn require_within( - kind: RuntimeLimitKind, - actual: u64, - maximum: u64, -) -> Result<(), RuntimeLimitViolation> { - if actual > maximum { - Err(RuntimeLimitViolation::new(kind, actual, maximum)) - } else { - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::{ - AdmissionContext, AdmissionEffect, AdmissionEvent, AdmissionEventKind, AdmissionPolicy, - AdmissionRejectionKind, AuthChallengeState, AuthChallengeStateErrorKind, - EventIngestionEffect, EventIngestionRejectionKind, EventIngestor, EventParser, - EventValidationRejection, EventValidationRejectionKind, EventValidator, - FixedWindowRateLimiter, LiveSearchPolicy, MarketplaceCursor, MarketplaceCursorSpec, - MarketplaceDecimal, MarketplaceGeoPoint, MarketplaceListingStatus, - MarketplaceLocationFilter, MarketplaceQuery, MarketplaceQueryErrorKind, - MarketplaceQuerySpec, MarketplaceSort, Nip50QueryCompileErrorKind, Nip50QueryCompiler, - NostrFilterCompileErrorKind, NostrFilterCompiler, ProjectionExclusionReason, - QueryExecutionMode, QueryPlan, QueryPlanBranch, QueryPlanBranchSpec, QueryPlanError, - QuerySearch, QuerySort, QuerySource, QueryTagFilter, RateLimitConfig, RateLimitConfigError, - RateLimitDecision, RateLimitErrorKind, RuntimeLimitConfigError, RuntimeLimitKind, - RuntimeLimitValues, RuntimeLimits, SubscriptionAddOutcome, SubscriptionCloseOutcome, - SubscriptionManager, SubscriptionManagerErrorKind, SubscriptionMatch, SubscriptionMatcher, - UnapprovedSellerAction, - }; - use tangle_nips::{ - FulfillmentMethod, ListingProjection, ListingUnit, RelayAuthEvent, - evaluate_listing_projection, parse_deletion_request, parse_relay_auth_event, - }; - use tangle_protocol::{ - AddressCoordinate, Event, EventId, Kind, PublicKeyHex, SignatureHex, SubscriptionId, Tag, - UnixTimestamp, UnsignedEvent, filter_from_value, - }; - use tangle_store::{ - DeletionMarker, DeletionMarkerRepository, ListingProjectionRepository, RawEventRepository, - RepositoryError, StoreEventOutcome, StoreProjectionOutcome, StoredEvent, - }; - use tangle_test_support::{ - FixtureKey, InMemoryRepository, auth_event_spec, build_fixture_event, - build_fixture_event_from_parts, deletion_event_spec, fixture_spec_from_json, - projection_ineligible_listing_spec, valid_public_listing_spec, - }; - - #[test] - fn default_runtime_limits_expose_reference_aligned_boundaries() { - let limits = RuntimeLimits::default(); - let values = limits.values(); - - assert_eq!(limits.max_event_bytes(), 131_072); - assert_eq!(limits.max_content_bytes(), 65_536); - assert_eq!(limits.max_tags_per_event(), 128); - assert_eq!(limits.max_tag_values_per_tag(), 16); - assert_eq!(limits.max_tag_value_bytes(), 1_024); - assert_eq!(limits.max_filters_per_subscription(), 16); - assert_eq!(limits.max_subscriptions_per_connection(), 64); - assert_eq!(limits.max_search_query_bytes(), 256); - assert_eq!(limits.max_search_tokens(), 16); - assert_eq!(limits.max_filter_complexity(), 512); - assert_eq!(limits.max_future_seconds(), 900); - assert_eq!(limits.live_event_buffer(), 1_024); - assert_eq!(limits.pending_store_events(), 4_096); - assert_eq!(values.max_event_bytes, 131_072); - } - - #[test] - fn runtime_limit_config_rejects_zero_and_inconsistent_values() { - let zero_cases = [ - ( - "max_event_bytes", - RuntimeLimitValues { - max_event_bytes: 0, - ..RuntimeLimitValues::default() - }, - ), - ( - "max_content_bytes", - RuntimeLimitValues { - max_content_bytes: 0, - ..RuntimeLimitValues::default() - }, - ), - ( - "max_tags_per_event", - RuntimeLimitValues { - max_tags_per_event: 0, - ..RuntimeLimitValues::default() - }, - ), - ( - "max_tag_values_per_tag", - RuntimeLimitValues { - max_tag_values_per_tag: 0, - ..RuntimeLimitValues::default() - }, - ), - ( - "max_tag_value_bytes", - RuntimeLimitValues { - max_tag_value_bytes: 0, - ..RuntimeLimitValues::default() - }, - ), - ( - "max_filters_per_subscription", - RuntimeLimitValues { - max_filters_per_subscription: 0, - ..RuntimeLimitValues::default() - }, - ), - ( - "max_subscriptions_per_connection", - RuntimeLimitValues { - max_subscriptions_per_connection: 0, - ..RuntimeLimitValues::default() - }, - ), - ( - "max_search_query_bytes", - RuntimeLimitValues { - max_search_query_bytes: 0, - ..RuntimeLimitValues::default() - }, - ), - ( - "max_search_tokens", - RuntimeLimitValues { - max_search_tokens: 0, - ..RuntimeLimitValues::default() - }, - ), - ( - "max_filter_complexity", - RuntimeLimitValues { - max_filter_complexity: 0, - ..RuntimeLimitValues::default() - }, - ), - ( - "live_event_buffer", - RuntimeLimitValues { - live_event_buffer: 0, - ..RuntimeLimitValues::default() - }, - ), - ( - "pending_store_events", - RuntimeLimitValues { - pending_store_events: 0, - ..RuntimeLimitValues::default() - }, - ), - ]; - let inconsistent = RuntimeLimitValues { - max_event_bytes: 10, - max_content_bytes: 11, - ..RuntimeLimitValues::default() - }; - - for (field, values) in zero_cases { - assert_eq!( - RuntimeLimits::from_values(values).expect_err(field), - RuntimeLimitConfigError::Zero { field } - ); - } - assert_eq!( - RuntimeLimits::from_values(zero_cases[0].1) - .expect_err("zero") - .to_string(), - "`max_event_bytes` must be greater than zero" - ); - assert_eq!( - RuntimeLimits::from_values(inconsistent).expect_err("inconsistent"), - RuntimeLimitConfigError::Inconsistent { - field: "max_content_bytes", - maximum_field: "max_event_bytes", - value: 11, - maximum: 10, - } - ); - assert_eq!( - RuntimeLimits::from_values(inconsistent) - .expect_err("inconsistent") - .to_string(), - "`max_content_bytes` must not exceed `max_event_bytes` (11 > 10)" - ); - } - - #[test] - fn runtime_limits_accept_fixture_event_inside_boundaries() { - let event = build_fixture_event(&valid_public_listing_spec()).expect("event"); - - assert_eq!(RuntimeLimits::default().validate_event(&event), Ok(())); - assert_eq!( - RuntimeLimits::default() - .validate_event_timestamp(&event, UnixTimestamp::new(1_714_124_433)), - Ok(()) - ); - } - - #[test] - fn runtime_limits_reject_event_shape_boundaries() { - assert_eq!( - limits_with(|values| { - values.max_event_bytes = 10; - values.max_content_bytes = 10; - }) - .validate_event(&event_with(vec![], "small", UnixTimestamp::new(10))) - .expect_err("event bytes") - .kind(), - RuntimeLimitKind::EventBytes - ); - assert_eq!( - limits_with(|values| values.max_content_bytes = 3) - .validate_event(&event_with(vec![], "large", UnixTimestamp::new(10))) - .expect_err("content bytes") - .kind(), - RuntimeLimitKind::ContentBytes - ); - assert_eq!( - limits_with(|values| values.max_tags_per_event = 1) - .validate_event(&event_with( - vec![ - Tag::from_parts("d", &["one"]).expect("d"), - Tag::from_parts("t", &["two"]).expect("t"), - ], - "", - UnixTimestamp::new(10), - )) - .expect_err("tag count") - .kind(), - RuntimeLimitKind::TagsPerEvent - ); - assert_eq!( - limits_with(|values| values.max_tag_values_per_tag = 1) - .validate_event(&event_with( - vec![Tag::from_parts("t", &["one"]).expect("t")], - "", - UnixTimestamp::new(10), - )) - .expect_err("tag values") - .kind(), - RuntimeLimitKind::TagValuesPerTag - ); - assert_eq!( - limits_with(|values| values.max_tag_value_bytes = 1) - .validate_event(&event_with( - vec![Tag::from_parts("t", &["two"]).expect("t")], - "", - UnixTimestamp::new(10), - )) - .expect_err("tag value bytes") - .kind(), - RuntimeLimitKind::TagValueBytes - ); - } - - #[test] - fn runtime_limits_reject_filter_subscription_search_and_future_boundaries() { - let limits = limits_with(|values| { - values.max_filters_per_subscription = 2; - values.max_filter_complexity = 3; - values.max_subscriptions_per_connection = 4; - values.max_search_query_bytes = 32; - values.max_search_tokens = 2; - values.max_future_seconds = 10; - }); - - assert_eq!(limits.validate_filters(2, 3), Ok(())); - assert_eq!( - limits.validate_filters(3, 3).expect_err("filters").kind(), - RuntimeLimitKind::FiltersPerSubscription - ); - assert_eq!( - limits - .validate_filters(2, 4) - .expect_err("complexity") - .kind(), - RuntimeLimitKind::FilterComplexity - ); - assert_eq!(limits.validate_subscription_count(4), Ok(())); - assert_eq!( - limits - .validate_subscription_count(5) - .expect_err("subscriptions") - .kind(), - RuntimeLimitKind::SubscriptionsPerConnection - ); - assert_eq!(limits.validate_search_query("one two"), Ok(())); - assert_eq!( - limits - .validate_search_query("123456789012345678901234567890123") - .expect_err("search bytes") - .kind(), - RuntimeLimitKind::SearchQueryBytes - ); - assert_eq!( - limits - .validate_search_query("a b c") - .expect_err("search tokens") - .kind(), - RuntimeLimitKind::SearchTokens - ); - assert_eq!( - limits - .validate_event_timestamp( - &event_with(vec![], "", UnixTimestamp::new(111)), - UnixTimestamp::new(100), - ) - .expect_err("future") - .kind(), - RuntimeLimitKind::FutureSeconds - ); - assert_eq!( - limits.validate_event_timestamp( - &event_with(vec![], "", UnixTimestamp::new(100)), - UnixTimestamp::new(100), - ), - Ok(()) - ); - } - - #[test] - fn runtime_limit_violation_reports_stable_values() { - let violation = limits_with(|values| values.max_search_tokens = 1) - .validate_search_query("one two") - .expect_err("tokens"); - - assert_eq!(violation.kind(), RuntimeLimitKind::SearchTokens); - assert_eq!(violation.actual(), 2); - assert_eq!(violation.maximum(), 1); - assert_eq!(violation.to_string(), "search tokens exceeded: 2 > 1"); - assert_eq!( - [ - RuntimeLimitKind::EventBytes.as_str(), - RuntimeLimitKind::ContentBytes.as_str(), - RuntimeLimitKind::TagsPerEvent.as_str(), - RuntimeLimitKind::TagValuesPerTag.as_str(), - RuntimeLimitKind::TagValueBytes.as_str(), - RuntimeLimitKind::FiltersPerSubscription.as_str(), - RuntimeLimitKind::SubscriptionsPerConnection.as_str(), - RuntimeLimitKind::SearchQueryBytes.as_str(), - RuntimeLimitKind::SearchTokens.as_str(), - RuntimeLimitKind::FilterComplexity.as_str(), - RuntimeLimitKind::FutureSeconds.as_str(), - ], - [ - "event bytes", - "content bytes", - "tags per event", - "tag values per tag", - "tag value bytes", - "filters per subscription", - "subscriptions per connection", - "search query bytes", - "search tokens", - "filter complexity", - "future seconds", - ] - ); - assert_eq!( - RuntimeLimitKind::FutureSeconds.to_string(), - "future seconds" - ); - } - - #[test] - fn admission_policy_defaults_require_matching_write_auth() { - let author = pubkey("1"); - let other = pubkey("2"); - let event = AdmissionEvent::new(author.clone(), AdmissionEventKind::Write); - let policy = AdmissionPolicy::new(); - - assert!(policy.require_write_auth()); - assert_eq!( - policy.unapproved_seller_action(), - UnapprovedSellerAction::StoreRawOnly - ); - assert!(policy.approved_sellers().is_empty()); - assert!(policy.blocked_pubkeys().is_empty()); - assert_eq!( - policy - .admit(&event, &AdmissionContext::unauthenticated()) - .rejection() - .expect("unauthenticated") - .kind(), - AdmissionRejectionKind::AuthenticationRequired - ); - assert_eq!( - policy - .admit(&event, &AdmissionContext::authenticated(other)) - .rejection() - .expect("mismatch") - .kind(), - AdmissionRejectionKind::AuthenticatedPubkeyMismatch - ); - assert_eq!( - policy - .admit(&event, &AdmissionContext::authenticated(author.clone())) - .accepted() - .expect("accepted") - .effect(), - AdmissionEffect::StoreRaw - ); - assert_eq!(event.author_pubkey(), &author); - assert_eq!(event.kind(), AdmissionEventKind::Write); - } - - #[test] - fn admission_policy_accepts_auth_events_without_prior_authentication() { - let event = AdmissionEvent::new(pubkey("1"), AdmissionEventKind::RelayAuth); - let decision = AdmissionPolicy::new().admit(&event, &AdmissionContext::unauthenticated()); - - assert_eq!( - decision.accepted().expect("accepted").effect(), - AdmissionEffect::AuthenticateOnly - ); - assert_eq!( - decision - .accepted() - .expect("accepted") - .projection_exclusion(), - None - ); - assert!(decision.rejection().is_none()); - } - - #[test] - fn admission_policy_projects_public_listings_for_approved_sellers() { - let seller = pubkey("3"); - let event = AdmissionEvent::new(seller.clone(), AdmissionEventKind::PublicListing); - let policy = AdmissionPolicy::new().approve_seller(seller.clone()); - let decision = policy.admit(&event, &AdmissionContext::authenticated(seller.clone())); - - assert!(policy.is_seller_approved(&seller)); - assert_eq!( - decision.accepted().expect("accepted").effect(), - AdmissionEffect::StoreRawAndProjectPublicListing - ); - assert_eq!( - decision - .accepted() - .expect("accepted") - .projection_exclusion(), - None - ); - } - - #[test] - fn admission_policy_handles_unapproved_sellers_by_configured_action() { - let seller = pubkey("4"); - let event = AdmissionEvent::new(seller.clone(), AdmissionEventKind::PublicListing); - let context = AdmissionContext::authenticated(seller.clone()); - let raw_only = AdmissionPolicy::new().admit(&event, &context); - let reject = AdmissionPolicy::new() - .with_unapproved_seller_action(UnapprovedSellerAction::RejectWrite) - .admit(&event, &context); - - assert_eq!( - raw_only.accepted().expect("raw only").effect(), - AdmissionEffect::StoreRawWithoutPublicListingProjection - ); - assert_eq!( - raw_only - .accepted() - .expect("raw only") - .projection_exclusion(), - Some(ProjectionExclusionReason::UnapprovedSeller) - ); - assert_eq!( - reject.rejection().expect("reject").kind(), - AdmissionRejectionKind::UnapprovedSeller - ); - assert!(reject.accepted().is_none()); - assert_eq!( - reject.rejection().expect("reject").message(), - "seller is not approved" - ); - } - - #[test] - fn admission_policy_applies_blocked_pubkey_policy() { - let seller = pubkey("5"); - let write = AdmissionEvent::new(seller.clone(), AdmissionEventKind::Write); - let listing = AdmissionEvent::new(seller.clone(), AdmissionEventKind::PublicListing); - let context = AdmissionContext::authenticated(seller.clone()); - let policy = AdmissionPolicy::new() - .approve_seller(seller.clone()) - .block_pubkey(seller.clone()); - - assert!(policy.is_pubkey_blocked(&seller)); - assert_eq!(policy.blocked_pubkeys().len(), 1); - assert_eq!( - policy - .admit(&write, &context) - .rejection() - .expect("blocked") - .kind(), - AdmissionRejectionKind::BlockedPubkey - ); - assert_eq!( - policy - .admit(&listing, &context) - .accepted() - .expect("listing") - .effect(), - AdmissionEffect::StoreRawWithoutPublicListingProjection - ); - assert_eq!( - policy - .admit(&listing, &context) - .accepted() - .expect("listing") - .projection_exclusion(), - Some(ProjectionExclusionReason::BlockedSeller) - ); - } - - #[test] - fn admission_policy_can_disable_write_auth_for_internal_tests() { - let event = AdmissionEvent::new(pubkey("6"), AdmissionEventKind::DraftListing); - let decision = AdmissionPolicy::new() - .with_write_auth_required(false) - .admit(&event, &AdmissionContext::unauthenticated()); - - assert_eq!( - decision.accepted().expect("accepted").effect(), - AdmissionEffect::StoreRaw - ); - } - - #[test] - fn admission_policy_labels_and_rejections_are_stable() { - let rejection = AdmissionPolicy::new() - .admit( - &AdmissionEvent::new(pubkey("7"), AdmissionEventKind::Write), - &AdmissionContext::unauthenticated(), - ) - .rejection() - .expect("rejection") - .clone(); - - assert_eq!( - [ - AdmissionEventKind::RelayAuth.as_str(), - AdmissionEventKind::Write.as_str(), - AdmissionEventKind::PublicListing.as_str(), - AdmissionEventKind::DraftListing.as_str(), - ], - ["relay auth", "write", "public listing", "draft listing"] - ); - assert_eq!( - [ - UnapprovedSellerAction::StoreRawOnly.as_str(), - UnapprovedSellerAction::RejectWrite.as_str(), - ], - ["store raw only", "reject write"] - ); - assert_eq!( - [ - AdmissionEffect::AuthenticateOnly.as_str(), - AdmissionEffect::StoreRaw.as_str(), - AdmissionEffect::StoreRawAndProjectPublicListing.as_str(), - AdmissionEffect::StoreRawWithoutPublicListingProjection.as_str(), - ], - [ - "authenticate only", - "store raw", - "store raw and project public listing", - "store raw without public listing projection", - ] - ); - assert_eq!( - [ - ProjectionExclusionReason::UnapprovedSeller.as_str(), - ProjectionExclusionReason::BlockedSeller.as_str(), - ], - ["unapproved seller", "blocked seller"] - ); - assert_eq!( - [ - AdmissionRejectionKind::AuthenticationRequired.as_str(), - AdmissionRejectionKind::AuthenticatedPubkeyMismatch.as_str(), - AdmissionRejectionKind::BlockedPubkey.as_str(), - AdmissionRejectionKind::UnapprovedSeller.as_str(), - ], - [ - "authentication required", - "authenticated pubkey mismatch", - "blocked pubkey", - "unapproved seller", - ] - ); - assert_eq!(AdmissionEventKind::Write.to_string(), "write"); - assert_eq!( - UnapprovedSellerAction::RejectWrite.to_string(), - "reject write" - ); - assert_eq!(AdmissionEffect::StoreRaw.to_string(), "store raw"); - assert_eq!( - ProjectionExclusionReason::BlockedSeller.to_string(), - "blocked seller" - ); - assert_eq!( - AdmissionRejectionKind::BlockedPubkey.to_string(), - "blocked pubkey" - ); - assert_eq!( - rejection.to_string(), - "authentication required: write authentication required" - ); - } - - #[test] - fn event_validator_accepts_approved_public_listing_with_projection_payload() { - let event = build_fixture_event(&valid_public_listing_spec()).expect("event"); - let seller = FixtureKey::Seller.public_key(); - let validator = EventValidator::new( - RuntimeLimits::default(), - AdmissionPolicy::new().approve_seller(seller.clone()), - ); - let validated = validator - .validate( - &event, - &AdmissionContext::authenticated(seller.clone()), - UnixTimestamp::new(1_714_124_500), - ) - .expect("validated"); - - assert_eq!(validator.limits(), RuntimeLimits::default()); - assert!(validator.admission_policy().is_seller_approved(&seller)); - assert_eq!(validated.event_id(), event.id()); - assert_eq!(validated.author_pubkey(), &seller); - assert_eq!( - validated.admission_kind(), - AdmissionEventKind::PublicListing - ); - assert_eq!( - validated.admission().effect(), - AdmissionEffect::StoreRawAndProjectPublicListing - ); - assert!( - validated - .payload() - .listing_evaluation() - .expect("listing") - .is_eligible() - ); - assert!(validated.payload().relay_auth().is_none()); - assert!(validated.payload().deletion_request().is_none()); - } - - #[test] - fn event_validator_accepts_projection_ineligible_listing_as_raw_store_candidate() { - let event = build_fixture_event(&projection_ineligible_listing_spec()).expect("event"); - let seller = FixtureKey::Seller.public_key(); - let validated = EventValidator::new( - RuntimeLimits::default(), - AdmissionPolicy::new().approve_seller(seller.clone()), - ) - .validate( - &event, - &AdmissionContext::authenticated(seller), - UnixTimestamp::new(1_714_124_500), - ) - .expect("validated"); - let rejection = validated - .payload() - .listing_evaluation() - .expect("listing") - .rejection() - .expect("projection rejection"); - - assert_eq!( - validated.admission_kind(), - AdmissionEventKind::PublicListing - ); - assert_eq!(rejection.reasons(), &["tag `title` is required".to_owned()]); - } - - #[test] - fn event_validator_accepts_auth_deletion_and_other_write_payloads() { - let seller = FixtureKey::Seller.public_key(); - let auth = build_fixture_event(&auth_event_spec()).expect("auth"); - let deletion = build_fixture_event(&deletion_event_spec()).expect("deletion"); - let draft_listing = build_fixture_event( - &fixture_spec_from_json( - r#"{"name":"draft_listing","key":"seller","created_at":1714124438,"kind":30403,"tags":[["d","draft-carrots"],["title","Draft carrots"],["price","3.25","USD"],["unit","lb"],["fulfillment","pickup"]],"content":"Draft storage carrots."}"#, - ) - .expect("draft listing"), - ) - .expect("draft listing event"); - let note = build_fixture_event( - &fixture_spec_from_json( - r#"{"name":"note","key":"seller","created_at":1714124437,"kind":1,"tags":[],"content":"hello"}"#, - ) - .expect("note"), - ) - .expect("note event"); - let validator = EventValidator::default(); - let auth = validator - .validate( - &auth, - &AdmissionContext::unauthenticated(), - UnixTimestamp::new(1_714_124_500), - ) - .expect("auth validated"); - let deletion = validator - .validate( - &deletion, - &AdmissionContext::authenticated(seller.clone()), - UnixTimestamp::new(1_714_124_500), - ) - .expect("deletion validated"); - let draft_listing = validator - .validate( - &draft_listing, - &AdmissionContext::authenticated(seller.clone()), - UnixTimestamp::new(1_714_124_500), - ) - .expect("draft listing validated"); - let note = validator - .validate( - &note, - &AdmissionContext::authenticated(seller), - UnixTimestamp::new(1_714_124_500), - ) - .expect("note validated"); - - assert_eq!(auth.admission_kind(), AdmissionEventKind::RelayAuth); - assert_eq!( - auth.payload().relay_auth().expect("auth").challenge(), - "challenge-001" - ); - assert!(auth.payload().listing_evaluation().is_none()); - assert_eq!(deletion.admission_kind(), AdmissionEventKind::Write); - assert_eq!( - deletion - .payload() - .deletion_request() - .expect("deletion") - .targets() - .len(), - 1 - ); - assert_eq!( - draft_listing.admission_kind(), - AdmissionEventKind::DraftListing - ); - assert_eq!( - draft_listing.admission().effect(), - AdmissionEffect::StoreRaw - ); - assert!( - draft_listing - .payload() - .listing_evaluation() - .expect("draft listing") - .rejection() - .is_some() - ); - assert_eq!(note.admission_kind(), AdmissionEventKind::Write); - assert_eq!(note.payload(), &super::ValidatedEventPayload::Other); - } - - #[test] - fn event_validator_rejects_private_commerce_plaintext_before_storage() { - let seller = FixtureKey::Seller.public_key(); - let validator = EventValidator::new( - RuntimeLimits::default(), - AdmissionPolicy::new().approve_seller(seller.clone()), - ); - let delivery_payload = build_fixture_event_from_parts( - FixtureKey::Seller, - 1_714_124_437, - 1, - vec![vec!["t".to_owned(), "commerce-privacy".to_owned()]], - r#"{"private_commerce":{"delivery_address":"100 Privacy Fixture Way","payment_details":"fixture-payment-token"}}"#, - ) - .expect("delivery payload"); - let phone_tag = build_fixture_event_from_parts( - FixtureKey::Seller, - 1_714_124_438, - 1, - vec![vec!["phone".to_owned(), "5550100".to_owned()]], - "private phone detail", - ) - .expect("phone tag"); - let public_listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - - let delivery_rejection = validator - .validate( - &delivery_payload, - &AdmissionContext::authenticated(seller.clone()), - UnixTimestamp::new(1_714_124_500), - ) - .expect_err("delivery privacy rejection"); - let tag_rejection = validator - .validate( - &phone_tag, - &AdmissionContext::authenticated(seller.clone()), - UnixTimestamp::new(1_714_124_500), - ) - .expect_err("phone privacy rejection"); - - assert_eq!( - delivery_rejection.kind(), - EventValidationRejectionKind::Privacy - ); - assert_eq!( - delivery_rejection.to_string(), - "privacy: private commerce plaintext field `delivery_address` is not allowed" - ); - assert_eq!( - tag_rejection, - EventValidationRejection::Privacy("phone".to_owned()) - ); - validator - .validate( - &public_listing, - &AdmissionContext::authenticated(seller), - UnixTimestamp::new(1_714_124_500), - ) - .expect("public listing remains valid"); - } - - #[test] - fn event_validator_rejects_private_commerce_plaintext_inside_arrays() { - let seller = FixtureKey::Seller.public_key(); - let validator = EventValidator::new( - RuntimeLimits::default(), - AdmissionPolicy::new().approve_seller(seller.clone()), - ); - let event = build_fixture_event_from_parts( - FixtureKey::Seller, - 1_714_124_439, - 1, - vec![vec!["t".to_owned(), "commerce-privacy".to_owned()]], - r#"{"items":[{"note":"public"},{"delivery_address":"100 Privacy Fixture Way"}]}"#, - ) - .expect("array privacy event"); - - let rejection = validator - .validate( - &event, - &AdmissionContext::authenticated(seller), - UnixTimestamp::new(1_714_124_500), - ) - .expect_err("privacy rejection"); - - assert_eq!( - rejection, - EventValidationRejection::Privacy("delivery_address".to_owned()) - ); - } - - #[test] - fn event_validator_rejects_limits_crypto_parser_and_admission_failures() { - let seller = FixtureKey::Seller.public_key(); - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let bad_id = Event::new( - EventId::new(&"f".repeat(EventId::HEX_LENGTH)).expect("id"), - listing.unsigned().clone(), - listing.sig().clone(), - ); - let bad_auth = build_fixture_event( - &fixture_spec_from_json( - r#"{"name":"bad_auth","key":"seller","created_at":1714124435,"kind":22242,"tags":[["relay","wss://relay.radroots.test"]],"content":""}"#, - ) - .expect("bad auth"), - ) - .expect("bad auth event"); - let note = build_fixture_event( - &fixture_spec_from_json( - r#"{"name":"note","key":"seller","created_at":1714124437,"kind":1,"tags":[],"content":"hello"}"#, - ) - .expect("note"), - ) - .expect("note event"); - let limit_rejection = EventValidator::new( - limits_with(|values| { - values.max_event_bytes = 1; - values.max_content_bytes = 1; - }), - AdmissionPolicy::new(), - ) - .validate( - &listing, - &AdmissionContext::authenticated(seller.clone()), - UnixTimestamp::new(1_714_124_500), - ) - .expect_err("limit"); - let crypto_rejection = EventValidator::default() - .validate( - &bad_id, - &AdmissionContext::authenticated(seller.clone()), - UnixTimestamp::new(1_714_124_500), - ) - .expect_err("crypto"); - let parser_rejection = EventValidator::default() - .validate( - &bad_auth, - &AdmissionContext::unauthenticated(), - UnixTimestamp::new(1_714_124_500), - ) - .expect_err("parser"); - let admission_rejection = EventValidator::default() - .validate( - &note, - &AdmissionContext::unauthenticated(), - UnixTimestamp::new(1_714_124_500), - ) - .expect_err("admission"); - - assert_eq!( - limit_rejection.kind(), - EventValidationRejectionKind::RuntimeLimit - ); - assert_eq!( - crypto_rejection.kind(), - EventValidationRejectionKind::Crypto - ); - assert_eq!( - parser_rejection.kind(), - EventValidationRejectionKind::Parser - ); - assert_eq!( - admission_rejection.kind(), - EventValidationRejectionKind::Admission - ); - assert!(limit_rejection.to_string().starts_with("runtime limit:")); - assert!(crypto_rejection.to_string().starts_with("crypto:")); - assert_eq!( - parser_rejection.to_string(), - "parser: relay auth: tag `challenge` is required" - ); - assert_eq!( - admission_rejection.to_string(), - "admission: authentication required: write authentication required" - ); - let expected_parser = super::EventParserRejection::new( - EventParser::RelayAuth, - "tag `challenge` is required".to_owned(), - ); - assert_eq!(expected_parser.parser(), EventParser::RelayAuth); - assert_eq!(expected_parser.message(), "tag `challenge` is required"); - assert_eq!( - parser_rejection, - EventValidationRejection::Parser(expected_parser) - ); - } - - #[test] - fn event_validator_rejects_malformed_deletion_and_future_timestamp() { - let seller = FixtureKey::Seller.public_key(); - let bad_deletion = build_fixture_event( - &fixture_spec_from_json( - r#"{"name":"bad_deletion","key":"seller","created_at":1714124436,"kind":5,"tags":[],"content":""}"#, - ) - .expect("bad deletion"), - ) - .expect("bad deletion event"); - let future_note = build_fixture_event( - &fixture_spec_from_json( - r#"{"name":"future_note","key":"seller","created_at":1714125400,"kind":1,"tags":[],"content":"hello"}"#, - ) - .expect("future note"), - ) - .expect("future note event"); - let deletion_rejection = EventValidator::default() - .validate( - &bad_deletion, - &AdmissionContext::authenticated(seller.clone()), - UnixTimestamp::new(1_714_124_500), - ) - .expect_err("deletion"); - let future_rejection = EventValidator::default() - .validate( - &future_note, - &AdmissionContext::authenticated(seller), - UnixTimestamp::new(1_714_124_433), - ) - .expect_err("future"); - - assert_eq!( - deletion_rejection.kind(), - EventValidationRejectionKind::Parser - ); - assert_eq!( - deletion_rejection.to_string(), - "parser: deletion: deletion event must target at least one e or a tag" - ); - assert_eq!( - future_rejection.kind(), - EventValidationRejectionKind::RuntimeLimit - ); - assert_eq!(EventParser::RelayAuth.as_str(), "relay auth"); - assert_eq!(EventParser::Deletion.to_string(), "deletion"); - } - - #[test] - fn event_ingestor_keeps_auth_and_ephemeral_events_out_of_raw_storage() { - let seller = FixtureKey::Seller.public_key(); - let auth = build_fixture_event(&auth_event_spec()).expect("auth"); - let ephemeral = build_fixture_event( - &fixture_spec_from_json( - r#"{"name":"ephemeral","key":"seller","created_at":1714124440,"kind":20000,"tags":[],"content":"typing"}"#, - ) - .expect("ephemeral"), - ) - .expect("ephemeral event"); - let mut repository = InMemoryRepository::new(); - let ingestor = EventIngestor::default(); - let auth = ingestor - .ingest( - &mut repository, - auth, - &AdmissionContext::unauthenticated(), - UnixTimestamp::new(1_714_124_501), - UnixTimestamp::new(1_714_124_501), - ) - .expect("auth"); - let ephemeral = ingestor - .ingest( - &mut repository, - ephemeral, - &AdmissionContext::authenticated(seller), - UnixTimestamp::new(1_714_124_502), - UnixTimestamp::new(1_714_124_502), - ) - .expect("ephemeral"); - - assert_eq!(ingestor.validator().limits(), RuntimeLimits::default()); - assert_eq!(auth.effect(), EventIngestionEffect::Authenticated); - assert_eq!(ephemeral.effect(), EventIngestionEffect::EphemeralAccepted); - assert_eq!(auth.raw_event_outcome(), None); - assert_eq!(ephemeral.raw_event_outcome(), None); - assert_eq!(repository.events().expect("events"), Vec::new()); - } - - #[test] - fn event_ingestor_stores_raw_events_and_projects_approved_listings() { - let event = build_fixture_event(&valid_public_listing_spec()).expect("event"); - let seller = FixtureKey::Seller.public_key(); - let projection_address = AddressCoordinate::from_event(&event) - .expect("address") - .expect("address"); - let mut repository = InMemoryRepository::new(); - let ingestor = EventIngestor::new(EventValidator::new( - RuntimeLimits::default(), - AdmissionPolicy::new().approve_seller(seller.clone()), - )); - let ingestion = ingestor - .ingest( - &mut repository, - event.clone(), - &AdmissionContext::authenticated(seller), - UnixTimestamp::new(1_714_124_501), - UnixTimestamp::new(1_714_124_501), - ) - .expect("ingestion"); - - assert_eq!(ingestion.event_id(), event.id()); - assert_eq!(ingestion.effect(), EventIngestionEffect::Stored); - assert_eq!( - ingestion.raw_event_outcome(), - Some(StoreEventOutcome::Inserted) - ); - assert_eq!( - ingestion.projection_outcome(), - Some(StoreProjectionOutcome::Inserted) - ); - assert_eq!(ingestion.deletion_marker_count(), 0); - assert_eq!( - repository - .event_by_id(event.id()) - .expect("event") - .expect("stored") - .event(), - &event - ); - assert!( - repository - .listing_projection(&projection_address) - .expect("projection") - .is_some() - ); - } - - #[test] - fn event_ingestor_stores_projection_ineligible_listing_without_projection() { - let event = build_fixture_event(&projection_ineligible_listing_spec()).expect("event"); - let seller = FixtureKey::Seller.public_key(); - let mut repository = InMemoryRepository::new(); - let ingestor = EventIngestor::new(EventValidator::new( - RuntimeLimits::default(), - AdmissionPolicy::new().approve_seller(seller.clone()), - )); - let ingestion = ingestor - .ingest( - &mut repository, - event, - &AdmissionContext::authenticated(seller), - UnixTimestamp::new(1_714_124_501), - UnixTimestamp::new(1_714_124_501), - ) - .expect("ingestion"); - - assert_eq!(ingestion.effect(), EventIngestionEffect::Stored); - assert_eq!( - ingestion.raw_event_outcome(), - Some(StoreEventOutcome::Inserted) - ); - assert_eq!(ingestion.projection_outcome(), None); - } - - #[test] - fn event_ingestor_creates_deletion_markers_after_raw_insert() { - let event = build_fixture_event(&deletion_event_spec()).expect("deletion"); - let seller = FixtureKey::Seller.public_key(); - let mut repository = InMemoryRepository::new(); - let ingestion = EventIngestor::default() - .ingest( - &mut repository, - event.clone(), - &AdmissionContext::authenticated(seller.clone()), - UnixTimestamp::new(1_714_124_501), - UnixTimestamp::new(1_714_124_501), - ) - .expect("ingestion"); - let markers = repository.deletion_markers().expect("markers"); - - assert_eq!(ingestion.effect(), EventIngestionEffect::Stored); - assert_eq!(ingestion.deletion_marker_count(), 1); - assert_eq!(markers.len(), 1); - assert_eq!(markers[0].deletion_event_id(), event.id()); - assert_eq!(markers[0].author_pubkey(), &seller); - assert_eq!(markers[0].deleted_at(), event.unsigned().created_at()); - } - - #[test] - fn event_ingestor_skips_duplicate_side_effects() { - let event = build_fixture_event(&valid_public_listing_spec()).expect("event"); - let seller = FixtureKey::Seller.public_key(); - let mut repository = InMemoryRepository::new(); - let ingestor = EventIngestor::new(EventValidator::new( - RuntimeLimits::default(), - AdmissionPolicy::new().approve_seller(seller.clone()), - )); - let first = ingestor - .ingest( - &mut repository, - event.clone(), - &AdmissionContext::authenticated(seller.clone()), - UnixTimestamp::new(1_714_124_501), - UnixTimestamp::new(1_714_124_501), - ) - .expect("first"); - let duplicate = ingestor - .ingest( - &mut repository, - event, - &AdmissionContext::authenticated(seller), - UnixTimestamp::new(1_714_124_502), - UnixTimestamp::new(1_714_124_502), - ) - .expect("duplicate"); - - assert_eq!( - first.projection_outcome(), - Some(StoreProjectionOutcome::Inserted) - ); - assert_eq!(duplicate.effect(), EventIngestionEffect::Duplicate); - assert_eq!( - duplicate.raw_event_outcome(), - Some(StoreEventOutcome::Duplicate) - ); - assert_eq!(duplicate.projection_outcome(), None); - } - - #[test] - fn event_ingestor_reports_validation_and_repository_rejections() { - let seller = FixtureKey::Seller.public_key(); - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let deletion = build_fixture_event(&deletion_event_spec()).expect("deletion"); - let note = build_fixture_event( - &fixture_spec_from_json( - r#"{"name":"note","key":"seller","created_at":1714124437,"kind":1,"tags":[],"content":"hello"}"#, - ) - .expect("note"), - ) - .expect("note event"); - let validation_rejection = EventIngestor::default() - .ingest( - &mut InMemoryRepository::new(), - note.clone(), - &AdmissionContext::unauthenticated(), - UnixTimestamp::new(1_714_124_501), - UnixTimestamp::new(1_714_124_501), - ) - .expect_err("validation"); - let repository_rejection = EventIngestor::default() - .ingest( - &mut RawFailingRepository, - note, - &AdmissionContext::authenticated(seller), - UnixTimestamp::new(1_714_124_501), - UnixTimestamp::new(1_714_124_501), - ) - .expect_err("repository"); - let projection_rejection = EventIngestor::new(EventValidator::new( - RuntimeLimits::default(), - AdmissionPolicy::new().approve_seller(FixtureKey::Seller.public_key()), - )) - .ingest( - &mut ProjectionFailingRepository::new(), - listing, - &AdmissionContext::authenticated(FixtureKey::Seller.public_key()), - UnixTimestamp::new(1_714_124_501), - UnixTimestamp::new(1_714_124_501), - ) - .expect_err("projection repository"); - let deletion_rejection = EventIngestor::default() - .ingest( - &mut DeletionFailingRepository::new(), - deletion, - &AdmissionContext::authenticated(FixtureKey::Seller.public_key()), - UnixTimestamp::new(1_714_124_501), - UnixTimestamp::new(1_714_124_501), - ) - .expect_err("deletion repository"); - - assert_eq!( - validation_rejection.kind(), - EventIngestionRejectionKind::Validation - ); - assert_eq!( - repository_rejection.kind(), - EventIngestionRejectionKind::Repository - ); - assert!(validation_rejection.to_string().starts_with("validation:")); - assert_eq!( - repository_rejection.to_string(), - "repository: repository unavailable" - ); - assert_eq!( - projection_rejection.kind(), - EventIngestionRejectionKind::Repository - ); - assert_eq!( - projection_rejection.to_string(), - "repository: projection unavailable" - ); - assert_eq!( - deletion_rejection.kind(), - EventIngestionRejectionKind::Repository - ); - assert_eq!( - deletion_rejection.to_string(), - "repository: deletion unavailable" - ); - assert_eq!(EventIngestionEffect::Stored.to_string(), "stored"); - assert_eq!( - [ - EventIngestionEffect::Authenticated.as_str(), - EventIngestionEffect::EphemeralAccepted.as_str(), - EventIngestionEffect::Stored.as_str(), - EventIngestionEffect::Duplicate.as_str(), - ], - ["authenticated", "ephemeral accepted", "stored", "duplicate",] - ); - } - - #[test] - fn failing_repository_helpers_cover_trait_surfaces() { - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let deletion = build_fixture_event(&deletion_event_spec()).expect("deletion"); - let projection = evaluate_listing_projection(&listing) - .projection() - .expect("projection") - .clone(); - let address = projection.identity().address().clone(); - let deletion_request = parse_deletion_request(&deletion) - .expect("deletion parse") - .expect("deletion request"); - let marker = DeletionMarker::new( - deletion.id().clone(), - deletion.unsigned().pubkey().clone(), - deletion_request.targets()[0].clone(), - deletion.unsigned().created_at(), - ); - let stored = StoredEvent::new(listing.clone(), UnixTimestamp::new(1_714_124_501)); - let mut raw = RawFailingRepository; - - assert!(raw.event_by_id(listing.id()).is_err()); - assert!(raw.events().is_err()); - assert!(raw.put_listing_projection(projection.clone()).is_err()); - assert!(raw.listing_projection(&address).is_err()); - assert!(raw.put_deletion_marker(marker.clone()).is_err()); - assert!(raw.deletion_markers().is_err()); - - let mut projection_failing = ProjectionFailingRepository::new(); - assert_eq!( - projection_failing.put_event(stored.clone()).expect("raw"), - StoreEventOutcome::Inserted - ); - assert_eq!( - projection_failing - .event_by_id(listing.id()) - .expect("event") - .expect("stored") - .event(), - &listing - ); - assert_eq!(projection_failing.events().expect("events").len(), 1); - assert!( - projection_failing - .put_listing_projection(projection.clone()) - .is_err() - ); - assert_eq!( - projection_failing - .listing_projection(&address) - .expect("projection"), - None - ); - assert_eq!( - projection_failing.put_deletion_marker(marker.clone()), - Ok(()) - ); - assert_eq!( - projection_failing.deletion_markers().expect("markers"), - vec![marker.clone()] - ); - - let mut deletion_failing = DeletionFailingRepository::new(); - assert_eq!( - deletion_failing.put_event(stored).expect("raw"), - StoreEventOutcome::Inserted - ); - assert_eq!( - deletion_failing - .put_listing_projection(projection.clone()) - .expect("projection"), - StoreProjectionOutcome::Inserted - ); - assert_eq!( - deletion_failing - .listing_projection(&address) - .expect("projection"), - Some(projection) - ); - assert!(deletion_failing.put_deletion_marker(marker).is_err()); - assert_eq!( - deletion_failing.deletion_markers().expect("markers"), - Vec::new() - ); - assert!( - deletion_failing - .event_by_id(listing.id()) - .expect("event") - .is_some() - ); - assert_eq!(deletion_failing.events().expect("events").len(), 1); - } - - #[test] - fn query_plan_model_accepts_multi_branch_historical_live_plans() { - let search = QuerySearch::new( - " carrots local ", - vec![ - "local".to_owned(), - "carrots".to_owned(), - "carrots".to_owned(), - ], - ) - .expect("search"); - let tag_filter = QueryTagFilter::new( - 't', - vec![ - "vegetables".to_owned(), - "carrots".to_owned(), - "vegetables".to_owned(), - ], - ) - .expect("tag"); - let branch = QueryPlanBranch::from_spec(QueryPlanBranchSpec { - ids: vec![ - EventId::new(&"b".repeat(EventId::HEX_LENGTH)).expect("id"), - EventId::new(&"a".repeat(EventId::HEX_LENGTH)).expect("id"), - EventId::new(&"a".repeat(EventId::HEX_LENGTH)).expect("id"), - ], - authors: vec![pubkey("2"), pubkey("1"), pubkey("1")], - kinds: vec![ - Kind::new(30_402).expect("kind"), - Kind::new(1).expect("kind"), - Kind::new(1).expect("kind"), - ], - tag_filters: vec![ - tag_filter.clone(), - QueryTagFilter::new('t', vec!["local".to_owned()]).expect("tag"), - ], - since: Some(UnixTimestamp::new(10)), - until: Some(UnixTimestamp::new(20)), - limit: Some(50), - search: Some(search.clone()), - }) - .expect("branch"); - let second_branch = - QueryPlanBranch::from_spec(QueryPlanBranchSpec::default()).expect("second branch"); - let plan = QueryPlan::new( - QuerySource::RawEvents, - QueryExecutionMode::HistoricalThenLive, - QuerySort::CreatedAtDescEventIdAsc, - vec![branch.clone(), second_branch], - ) - .expect("plan"); - - assert_eq!(search.raw(), "carrots local"); - assert_eq!(search.terms(), &["carrots".to_owned(), "local".to_owned()]); - assert_eq!(tag_filter.name(), 't'); - assert_eq!( - tag_filter.values(), - &["carrots".to_owned(), "vegetables".to_owned()] - ); - assert_eq!(plan.source(), QuerySource::RawEvents); - assert_eq!(plan.mode(), QueryExecutionMode::HistoricalThenLive); - assert_eq!(plan.sort(), QuerySort::CreatedAtDescEventIdAsc); - assert_eq!(plan.branches().len(), 2); - assert!(plan.requires_historical_query()); - assert!(plan.subscribes_to_live_events()); - assert_eq!(branch.ids()[0].as_str(), &"a".repeat(EventId::HEX_LENGTH)); - assert_eq!(branch.authors()[0], pubkey("1")); - assert_eq!(branch.kinds()[0], Kind::new(1).expect("kind")); - assert_eq!( - branch.tag_filters().get(&'t').expect("tag values"), - &[ - "carrots".to_owned(), - "local".to_owned(), - "vegetables".to_owned(), - ] - ); - assert_eq!(branch.since(), Some(UnixTimestamp::new(10))); - assert_eq!(branch.until(), Some(UnixTimestamp::new(20))); - assert_eq!(branch.limit(), Some(50)); - assert_eq!(branch.search(), Some(&search)); - } - - #[test] - fn query_plan_model_distinguishes_historical_and_live_execution() { - let zero_limit_branch = QueryPlanBranch::from_spec(QueryPlanBranchSpec { - limit: Some(0), - ..QueryPlanBranchSpec::default() - }) - .expect("branch"); - let historical_then_live = QueryPlan::new( - QuerySource::RawEvents, - QueryExecutionMode::HistoricalThenLive, - QuerySort::CreatedAtDescEventIdAsc, - vec![zero_limit_branch.clone()], - ) - .expect("historical then live"); - let live = QueryPlan::new( - QuerySource::RawEvents, - QueryExecutionMode::Live, - QuerySort::CreatedAtDescEventIdAsc, - vec![zero_limit_branch.clone()], - ) - .expect("live"); - let historical = QueryPlan::new( - QuerySource::ListingProjections, - QueryExecutionMode::Historical, - QuerySort::ScoreDescCreatedAtDescEventIdAsc, - vec![zero_limit_branch], - ) - .expect("historical"); - - assert!(!historical_then_live.requires_historical_query()); - assert!(historical_then_live.subscribes_to_live_events()); - assert!(!live.requires_historical_query()); - assert!(live.subscribes_to_live_events()); - assert!(!historical.requires_historical_query()); - assert!(!historical.subscribes_to_live_events()); - assert_eq!(historical.source(), QuerySource::ListingProjections); - assert_eq!( - historical.sort(), - QuerySort::ScoreDescCreatedAtDescEventIdAsc - ); - } - - #[test] - fn query_plan_model_rejects_invalid_shapes_and_has_stable_labels() { - let empty_branches = QueryPlan::new( - QuerySource::RawEvents, - QueryExecutionMode::Historical, - QuerySort::CreatedAtDescEventIdAsc, - Vec::new(), - ) - .expect_err("empty"); - let invalid_time = QueryPlanBranch::from_spec(QueryPlanBranchSpec { - since: Some(UnixTimestamp::new(20)), - until: Some(UnixTimestamp::new(10)), - ..QueryPlanBranchSpec::default() - }) - .expect_err("time"); - let invalid_tag = QueryTagFilter::new('1', vec!["value".to_owned()]).expect_err("tag"); - let empty_tag_values = QueryTagFilter::new('t', Vec::new()).expect_err("tag values"); - let empty_tag_value = QueryTagFilter::new('t', vec![String::new()]).expect_err("tag value"); - let empty_search = QuerySearch::new(" ", vec!["carrots".to_owned()]).expect_err("search"); - let empty_search_terms = QuerySearch::new("carrots", Vec::new()).expect_err("terms"); - let empty_search_term = QuerySearch::new("carrots", vec![String::new()]).expect_err("term"); - - assert_eq!(empty_branches, QueryPlanError::EmptyBranches); - assert_eq!( - invalid_time, - QueryPlanError::InvalidTimeRange { - since: UnixTimestamp::new(20), - until: UnixTimestamp::new(10), - } - ); - assert_eq!(invalid_tag, QueryPlanError::InvalidTagName { name: '1' }); - assert_eq!( - empty_tag_values, - QueryPlanError::EmptyTagValues { name: 't' } - ); - assert_eq!(empty_tag_value, QueryPlanError::EmptyTagValue { name: 't' }); - assert_eq!(empty_search, QueryPlanError::EmptySearch); - assert_eq!(empty_search_terms, QueryPlanError::EmptySearch); - assert_eq!(empty_search_term, QueryPlanError::EmptySearch); - assert_eq!( - empty_branches.to_string(), - "query plan must include at least one branch" - ); - assert_eq!( - invalid_time.to_string(), - "query time range is invalid: since 20 > until 10" - ); - assert_eq!( - invalid_tag.to_string(), - "tag filter name must be ASCII alphabetic, got `1`" - ); - assert_eq!( - empty_tag_values.to_string(), - "tag filter `t` must include at least one value" - ); - assert_eq!( - empty_tag_value.to_string(), - "tag filter `t` values must not be empty" - ); - assert_eq!(empty_search.to_string(), "search query must include terms"); - assert_eq!( - [ - QuerySource::RawEvents.as_str(), - QuerySource::ListingProjections.as_str(), - QuerySource::SearchDocuments.as_str(), - ], - ["raw events", "listing projections", "search documents"] - ); - assert_eq!( - [ - QueryExecutionMode::Historical.as_str(), - QueryExecutionMode::Live.as_str(), - QueryExecutionMode::HistoricalThenLive.as_str(), - ], - ["historical", "live", "historical then live"] - ); - assert_eq!( - [ - QuerySort::CreatedAtDescEventIdAsc.as_str(), - QuerySort::ScoreDescCreatedAtDescEventIdAsc.as_str(), - ], - [ - "created_at desc event_id asc", - "score desc created_at desc event_id asc", - ] - ); - assert_eq!(QuerySource::SearchDocuments.to_string(), "search documents"); - assert_eq!(QueryExecutionMode::Live.to_string(), "live"); - assert_eq!( - QuerySort::ScoreDescCreatedAtDescEventIdAsc.to_string(), - "score desc created_at desc event_id asc" - ); - } - - #[test] - fn marketplace_query_model_normalizes_http_search_constraints() { - let event_id = EventId::new(&"c".repeat(EventId::HEX_LENGTH)).expect("id"); - let cursor = MarketplaceCursor::from_spec(MarketplaceCursorSpec { - version: 1, - sort: MarketplaceSort::Distance, - score: None, - distance_meters: Some(1234), - price: None, - updated_at: UnixTimestamp::new(50), - event_id: event_id.clone(), - filter_hash: " hash ".to_owned(), - }) - .expect("cursor"); - let query = MarketplaceQuery::from_spec( - MarketplaceQuerySpec { - q: Some(" Fresh carrots fresh ".to_owned()), - categories: vec![ - " Vegetables ".to_owned(), - "csa".to_owned(), - "vegetables".to_owned(), - ], - seller: Some(pubkey("1")), - statuses: vec![ - MarketplaceListingStatus::Sold, - MarketplaceListingStatus::Active, - MarketplaceListingStatus::Active, - ], - currencies: vec!["usd".to_owned(), " CAD ".to_owned(), "USD".to_owned()], - units: vec![ListingUnit::Lb, ListingUnit::Kg, ListingUnit::Lb], - min_price: Some("001.500".to_owned()), - max_price: Some("10.0".to_owned()), - fulfillment: vec![ - FulfillmentMethod::Delivery, - FulfillmentMethod::Pickup, - FulfillmentMethod::Delivery, - ], - delivery_only: Some(false), - pickup: Some(true), - latitude_microdegrees: Some(47_606_200), - longitude_microdegrees: Some(-122_332_100), - radius_meters: Some(25_000), - near: Some(" Ballard ".to_owned()), - sort: MarketplaceSort::Distance, - limit: Some(25), - cursor: Some(cursor.clone()), - }, - RuntimeLimits::default(), - ) - .expect("query"); - - assert_eq!( - query.text.as_ref().expect("text").raw, - "Fresh carrots fresh" - ); - assert_eq!( - query.text.as_ref().expect("text").terms, - ["carrots".to_owned(), "fresh".to_owned()] - ); - assert_eq!( - query.categories, - ["csa".to_owned(), "vegetables".to_owned()] - ); - assert_eq!(query.seller, Some(pubkey("1"))); - assert_eq!( - query.statuses, - [ - MarketplaceListingStatus::Active, - MarketplaceListingStatus::Sold - ] - ); - assert_eq!(query.currencies, ["CAD".to_owned(), "USD".to_owned()]); - assert_eq!(query.units, [ListingUnit::Kg, ListingUnit::Lb]); - assert_eq!(query.min_price.as_ref().expect("min").raw, "001.500"); - assert_eq!(query.min_price.as_ref().expect("min").whole, "1"); - assert_eq!(query.min_price.as_ref().expect("min").fraction, "5"); - assert_eq!(query.max_price.as_ref().expect("max").whole, "10"); - assert_eq!(query.max_price.as_ref().expect("max").fraction, ""); - assert_eq!( - query.fulfillment, - [FulfillmentMethod::Pickup, FulfillmentMethod::Delivery] - ); - assert_eq!(query.delivery_only, Some(false)); - assert_eq!(query.pickup, Some(true)); - assert_eq!( - query.location.point, - Some(MarketplaceGeoPoint { - latitude_microdegrees: 47_606_200, - longitude_microdegrees: -122_332_100, - }) - ); - assert_eq!(query.location.radius_meters, Some(25_000)); - assert_eq!(query.location.near, Some("ballard".to_owned())); - assert!(query.location.has_distance_reference()); - assert_eq!(query.sort, MarketplaceSort::Distance); - assert_eq!(query.limit, 25); - assert_eq!(query.cursor, Some(cursor)); - assert_eq!(event_id.as_str(), &"c".repeat(EventId::HEX_LENGTH)); - } - - #[test] - fn marketplace_query_model_handles_defaults_labels_and_cursors() { - let default_query = - MarketplaceQuery::from_spec(MarketplaceQuerySpec::default(), RuntimeLimits::default()) - .expect("default query"); - let blank_query = MarketplaceQuery::from_spec( - MarketplaceQuerySpec { - q: Some(" ".to_owned()), - ..MarketplaceQuerySpec::default() - }, - RuntimeLimits::default(), - ) - .expect("blank query"); - let relevance_cursor = MarketplaceCursor::from_spec(MarketplaceCursorSpec { - version: 1, - sort: MarketplaceSort::Relevance, - score: Some(9), - distance_meters: None, - price: None, - updated_at: UnixTimestamp::new(60), - event_id: EventId::new(&"d".repeat(EventId::HEX_LENGTH)).expect("id"), - filter_hash: "filter".to_owned(), - }) - .expect("relevance cursor"); - let price_cursor = MarketplaceCursor::from_spec(MarketplaceCursorSpec { - version: 1, - sort: MarketplaceSort::PriceAsc, - score: None, - distance_meters: None, - price: Some("009.9900".to_owned()), - updated_at: UnixTimestamp::new(61), - event_id: EventId::new(&"e".repeat(EventId::HEX_LENGTH)).expect("id"), - filter_hash: "price".to_owned(), - }) - .expect("price cursor"); - let freshness_cursor = MarketplaceCursor::from_spec(MarketplaceCursorSpec { - version: 1, - sort: MarketplaceSort::Freshness, - score: None, - distance_meters: None, - price: None, - updated_at: UnixTimestamp::new(62), - event_id: EventId::new(&"f".repeat(EventId::HEX_LENGTH)).expect("id"), - filter_hash: "freshness".to_owned(), - }) - .expect("freshness cursor"); - let zero_decimal = MarketplaceDecimal::new("price", "000").expect("zero"); - let fraction_decimal = MarketplaceDecimal::new("price", "1.2300").expect("fraction"); - - assert_eq!(default_query.text, None); - assert_eq!(default_query.limit, MarketplaceQuery::DEFAULT_LIMIT); - assert_eq!(default_query.sort, MarketplaceSort::Relevance); - assert_eq!(blank_query.text, None); - assert_eq!(relevance_cursor.score, Some(9)); - assert_eq!(price_cursor.price.as_ref().expect("price").whole, "9"); - assert_eq!(price_cursor.price.as_ref().expect("price").fraction, "99"); - assert_eq!(freshness_cursor.filter_hash, "freshness"); - assert_eq!(zero_decimal.whole, "0"); - assert_eq!(zero_decimal.fraction, ""); - assert_eq!(fraction_decimal.whole, "1"); - assert_eq!(fraction_decimal.fraction, "23"); - assert_eq!( - [ - MarketplaceListingStatus::Active.as_str(), - MarketplaceListingStatus::Sold.as_str(), - MarketplaceListingStatus::Draft.as_str(), - MarketplaceListingStatus::Inactive.as_str(), - MarketplaceListingStatus::Expired.as_str(), - MarketplaceListingStatus::Deleted.as_str(), - MarketplaceListingStatus::Hidden.as_str(), - MarketplaceListingStatus::Rejected.as_str(), - ], - [ - "active", "sold", "draft", "inactive", "expired", "deleted", "hidden", "rejected", - ] - ); - assert_eq!( - [ - MarketplaceSort::Relevance.as_str(), - MarketplaceSort::Freshness.as_str(), - MarketplaceSort::PriceAsc.as_str(), - MarketplaceSort::PriceDesc.as_str(), - MarketplaceSort::Distance.as_str(), - MarketplaceSort::SellerTrust.as_str(), - ], - [ - "relevance", - "freshness", - "price_asc", - "price_desc", - "distance", - "seller_trust", - ] - ); - assert_eq!(MarketplaceListingStatus::Hidden.to_string(), "hidden"); - assert_eq!(MarketplaceSort::SellerTrust.to_string(), "seller_trust"); - } - - #[test] - fn marketplace_query_model_rejects_invalid_constraints() { - let runtime_limit = MarketplaceQuery::from_spec( - MarketplaceQuerySpec { - q: Some("fresh carrots".to_owned()), - ..MarketplaceQuerySpec::default() - }, - limits_with(|values| values.max_search_tokens = 1), - ) - .expect_err("runtime"); - let empty_category = MarketplaceQuery::from_spec( - MarketplaceQuerySpec { - categories: vec![" ".to_owned()], - ..MarketplaceQuerySpec::default() - }, - RuntimeLimits::default(), - ) - .expect_err("category"); - let empty_near = - MarketplaceLocationFilter::from_spec(None, None, None, Some(" ".to_owned())) - .expect_err("near"); - let invalid_decimal = MarketplaceDecimal::new("min_price", "1..2").expect_err("decimal"); - let empty_whole = MarketplaceDecimal::new("min_price", ".2").expect_err("decimal"); - let bad_whole = MarketplaceDecimal::new("min_price", "a.2").expect_err("decimal"); - let empty_fraction = MarketplaceDecimal::new("min_price", "1.").expect_err("decimal"); - let bad_fraction = MarketplaceDecimal::new("min_price", "1.a").expect_err("decimal"); - let invalid_price_range = MarketplaceQuery::from_spec( - MarketplaceQuerySpec { - min_price: Some("2".to_owned()), - max_price: Some("1.99".to_owned()), - ..MarketplaceQuerySpec::default() - }, - RuntimeLimits::default(), - ) - .expect_err("price range"); - let invalid_fraction_range = MarketplaceQuery::from_spec( - MarketplaceQuerySpec { - min_price: Some("1.2".to_owned()), - max_price: Some("1.10".to_owned()), - ..MarketplaceQuerySpec::default() - }, - RuntimeLimits::default(), - ) - .expect_err("fraction range"); - let missing_lon = - MarketplaceLocationFilter::from_spec(Some(1), None, None, None).expect_err("location"); - let query_missing_lon = MarketplaceQuery::from_spec( - MarketplaceQuerySpec { - latitude_microdegrees: Some(1), - ..MarketplaceQuerySpec::default() - }, - RuntimeLimits::default(), - ) - .expect_err("query location"); - let zero_radius = MarketplaceLocationFilter::from_spec(Some(1), Some(2), Some(0), None) - .expect_err("radius"); - let radius_without_point = MarketplaceLocationFilter::from_spec(None, None, Some(1), None) - .expect_err("radius point"); - let bad_latitude = MarketplaceGeoPoint::new(90_000_001, 0).expect_err("lat"); - let bad_longitude = MarketplaceGeoPoint::new(0, 180_000_001).expect_err("lon"); - let missing_distance_reference = MarketplaceQuery::from_spec( - MarketplaceQuerySpec { - sort: MarketplaceSort::Distance, - ..MarketplaceQuerySpec::default() - }, - RuntimeLimits::default(), - ) - .expect_err("distance"); - let bad_limit = MarketplaceQuery::from_spec( - MarketplaceQuerySpec { - limit: Some(MarketplaceQuery::MAX_LIMIT + 1), - ..MarketplaceQuerySpec::default() - }, - RuntimeLimits::default(), - ) - .expect_err("limit"); - let cursor_sort = MarketplaceCursor::from_spec(MarketplaceCursorSpec { - version: 1, - sort: MarketplaceSort::Relevance, - score: Some(1), - distance_meters: None, - price: None, - updated_at: UnixTimestamp::new(70), - event_id: EventId::new(&"a".repeat(EventId::HEX_LENGTH)).expect("id"), - filter_hash: "cursor".to_owned(), - }) - .expect("cursor"); - let cursor_sort_mismatch = MarketplaceQuery::from_spec( - MarketplaceQuerySpec { - sort: MarketplaceSort::Freshness, - cursor: Some(cursor_sort), - ..MarketplaceQuerySpec::default() - }, - RuntimeLimits::default(), - ) - .expect_err("cursor sort"); - - assert_eq!( - runtime_limit.kind(), - MarketplaceQueryErrorKind::RuntimeLimit - ); - assert!(runtime_limit.message().starts_with("runtime limit:")); - assert_eq!(runtime_limit.to_string(), runtime_limit.message()); - assert_eq!( - empty_category.kind(), - MarketplaceQueryErrorKind::EmptyFilterValue - ); - assert_eq!( - empty_category.message(), - "category filter value must not be empty" - ); - assert_eq!( - empty_near.kind(), - MarketplaceQueryErrorKind::EmptyFilterValue - ); - assert_eq!(empty_near.message(), "near filter value must not be empty"); - for error in [ - invalid_decimal, - empty_whole, - bad_whole, - empty_fraction, - bad_fraction, - ] { - assert_eq!(error.kind(), MarketplaceQueryErrorKind::InvalidDecimal); - assert_eq!( - error.message(), - "min_price must be an exact unsigned decimal" - ); - } - assert_eq!( - invalid_price_range.kind(), - MarketplaceQueryErrorKind::InvalidPriceRange - ); - assert_eq!( - invalid_fraction_range.kind(), - MarketplaceQueryErrorKind::InvalidPriceRange - ); - for error in [ - missing_lon, - query_missing_lon, - zero_radius, - radius_without_point, - bad_latitude, - bad_longitude, - ] { - assert_eq!(error.kind(), MarketplaceQueryErrorKind::InvalidLocation); - } - assert_eq!( - missing_distance_reference.kind(), - MarketplaceQueryErrorKind::MissingDistanceReference - ); - assert_eq!(bad_limit.kind(), MarketplaceQueryErrorKind::LimitOutOfRange); - assert_eq!( - cursor_sort_mismatch.kind(), - MarketplaceQueryErrorKind::CursorSortMismatch - ); - } - - #[test] - fn marketplace_cursor_model_rejects_invalid_payloads() { - let base = || MarketplaceCursorSpec { - version: 1, - sort: MarketplaceSort::Freshness, - score: None, - distance_meters: None, - price: None, - updated_at: UnixTimestamp::new(80), - event_id: EventId::new(&"b".repeat(EventId::HEX_LENGTH)).expect("id"), - filter_hash: "filter".to_owned(), - }; - let zero_version = MarketplaceCursor::from_spec(MarketplaceCursorSpec { - version: 0, - ..base() - }) - .expect_err("version"); - let empty_hash = MarketplaceCursor::from_spec(MarketplaceCursorSpec { - filter_hash: " ".to_owned(), - ..base() - }) - .expect_err("hash"); - let missing_score = MarketplaceCursor::from_spec(MarketplaceCursorSpec { - sort: MarketplaceSort::Relevance, - ..base() - }) - .expect_err("score"); - let missing_distance = MarketplaceCursor::from_spec(MarketplaceCursorSpec { - sort: MarketplaceSort::Distance, - ..base() - }) - .expect_err("distance"); - let missing_price = MarketplaceCursor::from_spec(MarketplaceCursorSpec { - sort: MarketplaceSort::PriceDesc, - ..base() - }) - .expect_err("price"); - let invalid_price = MarketplaceCursor::from_spec(MarketplaceCursorSpec { - sort: MarketplaceSort::PriceAsc, - price: Some("bad".to_owned()), - ..base() - }) - .expect_err("price decimal"); - - for error in [ - zero_version, - empty_hash, - missing_score, - missing_distance, - missing_price, - ] { - assert_eq!(error.kind(), MarketplaceQueryErrorKind::InvalidCursor); - } - assert_eq!( - invalid_price.kind(), - MarketplaceQueryErrorKind::InvalidDecimal - ); - } - - #[test] - fn nostr_filter_compiler_builds_search_backed_query_plans() { - let filter = filter_from_value(&serde_json::json!({ - "ids": ["bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], - "authors": ["1111111111111111111111111111111111111111111111111111111111111111"], - "kinds": [1, 30402], - "#t": ["vegetables", "carrots", "vegetables"], - "since": 10, - "until": 20, - "limit": 25, - "search": "fresh carrots" - })) - .expect("filter"); - let compiler = NostrFilterCompiler::default(); - let plan = compiler - .compile(&[filter], QueryExecutionMode::HistoricalThenLive) - .expect("plan"); - let branch = &plan.branches()[0]; - - assert_eq!(compiler.limits(), RuntimeLimits::default()); - assert_eq!(plan.source(), QuerySource::SearchDocuments); - assert_eq!(plan.sort(), QuerySort::ScoreDescCreatedAtDescEventIdAsc); - assert_eq!(plan.mode(), QueryExecutionMode::HistoricalThenLive); - assert!(plan.requires_historical_query()); - assert!(plan.subscribes_to_live_events()); - assert_eq!(branch.ids()[0].as_str(), &"b".repeat(EventId::HEX_LENGTH)); - assert_eq!(branch.authors()[0], pubkey("1")); - assert_eq!( - branch.kinds(), - &[ - Kind::new(1).expect("kind"), - Kind::new(30_402).expect("kind") - ] - ); - assert_eq!( - branch.tag_filters().get(&'t').expect("tag"), - &["carrots".to_owned(), "vegetables".to_owned()] - ); - assert_eq!(branch.since(), Some(UnixTimestamp::new(10))); - assert_eq!(branch.until(), Some(UnixTimestamp::new(20))); - assert_eq!(branch.limit(), Some(25)); - assert_eq!( - branch.search().expect("search").terms(), - &["carrots".to_owned(), "fresh".to_owned()] - ); - } - - #[test] - fn nostr_filter_compiler_preserves_limit_zero_historical_skip() { - let filter = filter_from_value(&serde_json::json!({ - "limit": 0, - "#p": ["1111111111111111111111111111111111111111111111111111111111111111"] - })) - .expect("filter"); - let plan = NostrFilterCompiler::default() - .compile(&[filter], QueryExecutionMode::HistoricalThenLive) - .expect("plan"); - - assert_eq!(plan.source(), QuerySource::RawEvents); - assert_eq!(plan.sort(), QuerySort::CreatedAtDescEventIdAsc); - assert!(!plan.requires_historical_query()); - assert!(plan.subscribes_to_live_events()); - assert_eq!( - plan.branches()[0].tag_filters().get(&'p').expect("p"), - &["1111111111111111111111111111111111111111111111111111111111111111".to_owned()] - ); - } - - #[test] - fn nostr_filter_compiler_rejects_limit_and_plan_errors() { - let empty_filters = NostrFilterCompiler::default() - .compile(&[], QueryExecutionMode::Historical) - .expect_err("empty filters"); - let too_many_filters = NostrFilterCompiler::new(limits_with(|values| { - values.max_filters_per_subscription = 1; - })) - .compile( - &[ - tangle_protocol::Filter::empty(), - tangle_protocol::Filter::empty(), - ], - QueryExecutionMode::Historical, - ) - .expect_err("filter count"); - let too_complex = NostrFilterCompiler::new(limits_with(|values| { - values.max_filter_complexity = 1; - })) - .compile( - &[filter_from_value(&serde_json::json!({ - "ids": ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"], - "authors": ["1111111111111111111111111111111111111111111111111111111111111111"] - })) - .expect("filter")], - QueryExecutionMode::Historical, - ) - .expect_err("complexity"); - let blank_search = NostrFilterCompiler::default() - .compile( - &[filter_from_value(&serde_json::json!({ "search": " " })).expect("filter")], - QueryExecutionMode::Historical, - ) - .expect_err("blank search"); - let too_many_search_tokens = NostrFilterCompiler::new(limits_with(|values| { - values.max_search_tokens = 1; - })) - .compile( - &[ - filter_from_value(&serde_json::json!({ "search": "fresh carrots" })) - .expect("filter"), - ], - QueryExecutionMode::Historical, - ) - .expect_err("search tokens"); - let empty_tag = NostrFilterCompiler::default() - .compile( - &[filter_from_value(&serde_json::json!({ "#t": [""] })).expect("filter")], - QueryExecutionMode::Historical, - ) - .expect_err("empty tag"); - - assert_eq!(empty_filters.kind(), NostrFilterCompileErrorKind::QueryPlan); - assert_eq!( - too_many_filters.kind(), - NostrFilterCompileErrorKind::RuntimeLimit - ); - assert_eq!( - too_complex.kind(), - NostrFilterCompileErrorKind::RuntimeLimit - ); - assert_eq!(blank_search.kind(), NostrFilterCompileErrorKind::QueryPlan); - assert_eq!( - too_many_search_tokens.kind(), - NostrFilterCompileErrorKind::RuntimeLimit - ); - assert_eq!(empty_tag.kind(), NostrFilterCompileErrorKind::QueryPlan); - assert_eq!( - empty_filters.to_string(), - "query plan: query plan must include at least one branch" - ); - assert!(too_many_filters.to_string().starts_with("runtime limit:")); - assert!(too_complex.to_string().starts_with("runtime limit:")); - assert_eq!( - blank_search.to_string(), - "query plan: search query must include terms" - ); - assert!(too_many_search_tokens.to_string().contains("search tokens")); - assert_eq!( - empty_tag.to_string(), - "query plan: tag filter `t` values must not be empty" - ); - } - - #[test] - fn nip50_query_compiler_builds_search_document_plan_from_plain_terms() { - let filter = filter_from_value(&serde_json::json!({ - "authors": ["1111111111111111111111111111111111111111111111111111111111111111"], - "kinds": [30402], - "#t": ["carrots", "vegetables"], - "since": 10, - "until": 20, - "limit": 10, - "search": "fresh seller:ignored carrots status:ignored carrots" - })) - .expect("filter"); - let compiler = Nip50QueryCompiler::default(); - let plan = compiler - .compile(&[filter], QueryExecutionMode::Historical) - .expect("plan"); - let branch = &plan.branches()[0]; - - assert_eq!(compiler.limits(), RuntimeLimits::default()); - assert_eq!(plan.source(), QuerySource::SearchDocuments); - assert_eq!(plan.sort(), QuerySort::ScoreDescCreatedAtDescEventIdAsc); - assert_eq!(plan.mode(), QueryExecutionMode::Historical); - assert!(plan.requires_historical_query()); - assert!(!plan.subscribes_to_live_events()); - assert_eq!(branch.authors()[0], pubkey("1")); - assert_eq!(branch.kinds(), &[Kind::new(30_402).expect("kind")]); - assert_eq!( - branch.tag_filters().get(&'t').expect("tag"), - &["carrots".to_owned(), "vegetables".to_owned()] - ); - assert_eq!(branch.since(), Some(UnixTimestamp::new(10))); - assert_eq!(branch.until(), Some(UnixTimestamp::new(20))); - assert_eq!(branch.limit(), Some(10)); - assert_eq!( - branch.search().expect("search").raw(), - "fresh carrots carrots" - ); - assert_eq!( - branch.search().expect("search").terms(), - &["carrots".to_owned(), "fresh".to_owned()] - ); - } - - #[test] - fn nip50_query_compiler_ignores_extension_only_filters() { - let extension_only = filter_from_value(&serde_json::json!({ - "search": "seller:ignored status:ignored", - "limit": 0 - })) - .expect("extension"); - let searchable = filter_from_value(&serde_json::json!({ - "search": "greens", - "kinds": [1] - })) - .expect("search"); - let plan = Nip50QueryCompiler::default() - .compile( - &[extension_only, tangle_protocol::Filter::empty(), searchable], - QueryExecutionMode::HistoricalThenLive, - ) - .expect("plan"); - - assert_eq!(plan.branches().len(), 1); - assert_eq!(plan.branches()[0].search().expect("search").raw(), "greens"); - assert_eq!(plan.branches()[0].kinds(), &[Kind::new(1).expect("kind")]); - assert!(plan.requires_historical_query()); - assert!(plan.subscribes_to_live_events()); - } - - #[test] - fn nip50_query_compiler_rejects_missing_terms_limits_and_bad_plans() { - let empty = Nip50QueryCompiler::default() - .compile(&[], QueryExecutionMode::Historical) - .expect_err("empty"); - let extension_only = Nip50QueryCompiler::default() - .compile( - &[filter_from_value(&serde_json::json!({ - "search": "seller:ignored status:ignored" - })) - .expect("filter")], - QueryExecutionMode::Historical, - ) - .expect_err("extension only"); - let too_many_filters = Nip50QueryCompiler::new(limits_with(|values| { - values.max_filters_per_subscription = 1; - })) - .compile( - &[ - filter_from_value(&serde_json::json!({ "search": "carrots" })).expect("filter"), - filter_from_value(&serde_json::json!({ "search": "greens" })).expect("filter"), - ], - QueryExecutionMode::Historical, - ) - .expect_err("filter count"); - let too_many_tokens = Nip50QueryCompiler::new(limits_with(|values| { - values.max_search_tokens = 1; - })) - .compile( - &[ - filter_from_value(&serde_json::json!({ "search": "fresh carrots" })) - .expect("filter"), - ], - QueryExecutionMode::Historical, - ) - .expect_err("tokens"); - let bad_plan = Nip50QueryCompiler::default() - .compile( - &[filter_from_value(&serde_json::json!({ - "search": "carrots", - "since": 20, - "until": 10 - })) - .expect("filter")], - QueryExecutionMode::Historical, - ) - .expect_err("plan"); - let empty_tag = Nip50QueryCompiler::default() - .compile( - &[filter_from_value(&serde_json::json!({ - "search": "carrots", - "#t": [""] - })) - .expect("filter")], - QueryExecutionMode::Historical, - ) - .expect_err("tag"); - - assert_eq!(empty.kind(), Nip50QueryCompileErrorKind::MissingSearchTerms); - assert_eq!( - extension_only.kind(), - Nip50QueryCompileErrorKind::MissingSearchTerms - ); - assert_eq!( - too_many_filters.kind(), - Nip50QueryCompileErrorKind::RuntimeLimit - ); - assert_eq!( - too_many_tokens.kind(), - Nip50QueryCompileErrorKind::RuntimeLimit - ); - assert_eq!(bad_plan.kind(), Nip50QueryCompileErrorKind::QueryPlan); - assert_eq!(empty_tag.kind(), Nip50QueryCompileErrorKind::QueryPlan); - assert_eq!( - empty.to_string(), - "nip50 query must include plain search terms" - ); - assert!( - too_many_filters - .to_string() - .contains("filters per subscription") - ); - assert!(too_many_tokens.to_string().contains("search tokens")); - assert_eq!( - bad_plan.to_string(), - "query plan: query time range is invalid: since 20 > until 10" - ); - assert_eq!( - empty_tag.to_string(), - "query plan: tag filter `t` values must not be empty" - ); - } - - #[test] - fn subscription_matcher_matches_live_query_plan_branches() { - let event = event_with( - vec![ - Tag::new(vec!["t".to_owned(), "carrots".to_owned()]).expect("tag"), - Tag::new(vec!["title".to_owned(), "Sweet carrots".to_owned()]).expect("tag"), - ], - "Sweet storage carrots.", - UnixTimestamp::new(100), - ); - let matching_branch = QueryPlanBranch::from_spec(QueryPlanBranchSpec { - ids: vec![event.id().clone()], - authors: vec![event.unsigned().pubkey().clone()], - kinds: vec![event.unsigned().kind()], - tag_filters: vec![QueryTagFilter::new('t', vec!["carrots".to_owned()]).expect("tag")], - since: Some(UnixTimestamp::new(99)), - until: Some(UnixTimestamp::new(101)), - search: Some( - QuerySearch::new( - "sweet carrots", - vec!["sweet".to_owned(), "carrots".to_owned()], - ) - .expect("search"), - ), - ..QueryPlanBranchSpec::default() - }) - .expect("matching"); - let id_miss = QueryPlanBranch::from_spec(QueryPlanBranchSpec { - ids: vec![EventId::new(&"c".repeat(EventId::HEX_LENGTH)).expect("id")], - ..QueryPlanBranchSpec::default() - }) - .expect("id"); - let author_miss = QueryPlanBranch::from_spec(QueryPlanBranchSpec { - authors: vec![pubkey("2")], - ..QueryPlanBranchSpec::default() - }) - .expect("author"); - let kind_miss = QueryPlanBranch::from_spec(QueryPlanBranchSpec { - kinds: vec![Kind::new(1).expect("kind")], - ..QueryPlanBranchSpec::default() - }) - .expect("kind"); - let since_miss = QueryPlanBranch::from_spec(QueryPlanBranchSpec { - since: Some(UnixTimestamp::new(101)), - ..QueryPlanBranchSpec::default() - }) - .expect("since"); - let until_miss = QueryPlanBranch::from_spec(QueryPlanBranchSpec { - until: Some(UnixTimestamp::new(99)), - ..QueryPlanBranchSpec::default() - }) - .expect("until"); - let tag_miss = QueryPlanBranch::from_spec(QueryPlanBranchSpec { - tag_filters: vec![QueryTagFilter::new('t', vec!["greens".to_owned()]).expect("tag")], - ..QueryPlanBranchSpec::default() - }) - .expect("tag"); - let search_miss = QueryPlanBranch::from_spec(QueryPlanBranchSpec { - search: Some(QuerySearch::new("missing", vec!["missing".to_owned()]).expect("search")), - ..QueryPlanBranchSpec::default() - }) - .expect("search"); - let no_search_match = QueryPlanBranch::from_spec(QueryPlanBranchSpec { - limit: Some(0), - ..QueryPlanBranchSpec::default() - }) - .expect("no search"); - let plan = QueryPlan::new( - QuerySource::SearchDocuments, - QueryExecutionMode::HistoricalThenLive, - QuerySort::ScoreDescCreatedAtDescEventIdAsc, - vec![ - id_miss, - author_miss, - kind_miss, - since_miss, - until_miss, - tag_miss, - search_miss, - matching_branch, - no_search_match, - ], - ) - .expect("plan"); - let matcher = SubscriptionMatcher::default(); - let matched = matcher.match_event(&plan, &event); - - assert_eq!( - matcher.live_search_policy(), - LiveSearchPolicy::BestEffortTokenMatch - ); - assert!(matched.matched()); - assert_eq!(matched.branch_indexes(), &[7, 8]); - } - - #[test] - fn subscription_matcher_respects_historical_mode_and_live_search_policy() { - let event = event_with( - vec![Tag::new(vec!["t".to_owned(), "carrots".to_owned()]).expect("tag")], - "Sweet storage carrots.", - UnixTimestamp::new(100), - ); - let search_branch = QueryPlanBranch::from_spec(QueryPlanBranchSpec { - search: Some( - QuerySearch::new( - "storage carrots", - vec!["storage".to_owned(), "carrots".to_owned()], - ) - .expect("search"), - ), - ..QueryPlanBranchSpec::default() - }) - .expect("search branch"); - let historical = QueryPlan::new( - QuerySource::RawEvents, - QueryExecutionMode::Historical, - QuerySort::CreatedAtDescEventIdAsc, - vec![search_branch.clone()], - ) - .expect("historical"); - let live_search = QueryPlan::new( - QuerySource::SearchDocuments, - QueryExecutionMode::Live, - QuerySort::ScoreDescCreatedAtDescEventIdAsc, - vec![search_branch], - ) - .expect("live search"); - let disabled = SubscriptionMatcher::new(LiveSearchPolicy::DisabledLiveSearch); - let historical_match = SubscriptionMatcher::default().match_event(&historical, &event); - let disabled_match = disabled.match_event(&live_search, &event); - let empty = SubscriptionMatch::empty(); - - assert_eq!( - disabled.live_search_policy(), - LiveSearchPolicy::DisabledLiveSearch - ); - assert!(!historical_match.matched()); - assert_eq!(historical_match.branch_indexes(), &[] as &[usize]); - assert!(!disabled_match.matched()); - assert!(!empty.matched()); - assert_eq!(empty.branch_indexes(), &[] as &[usize]); - assert_eq!( - LiveSearchPolicy::BestEffortTokenMatch.as_str(), - "best_effort_token_match" - ); - assert_eq!( - LiveSearchPolicy::DisabledLiveSearch.to_string(), - "disabled_live_search" - ); - } - - #[test] - fn subscription_manager_inserts_replaces_closes_and_fans_out() { - let event = event_with( - vec![Tag::new(vec!["t".to_owned(), "carrots".to_owned()]).expect("tag")], - "Sweet storage carrots.", - UnixTimestamp::new(100), - ); - let matching_branch = QueryPlanBranch::from_spec(QueryPlanBranchSpec { - tag_filters: vec![QueryTagFilter::new('t', vec!["carrots".to_owned()]).expect("tag")], - search: Some(QuerySearch::new("carrots", vec!["carrots".to_owned()]).expect("search")), - ..QueryPlanBranchSpec::default() - }) - .expect("matching"); - let no_match_branch = QueryPlanBranch::from_spec(QueryPlanBranchSpec { - ids: vec![EventId::new(&"c".repeat(EventId::HEX_LENGTH)).expect("id")], - ..QueryPlanBranchSpec::default() - }) - .expect("no match"); - let matching_plan = QueryPlan::new( - QuerySource::SearchDocuments, - QueryExecutionMode::Live, - QuerySort::ScoreDescCreatedAtDescEventIdAsc, - vec![matching_branch], - ) - .expect("matching plan"); - let no_match_plan = QueryPlan::new( - QuerySource::RawEvents, - QueryExecutionMode::Live, - QuerySort::CreatedAtDescEventIdAsc, - vec![no_match_branch], - ) - .expect("no match plan"); - let mut manager = SubscriptionManager::default(); - let id_a = SubscriptionId::new("a").expect("id"); - let id_b = SubscriptionId::new("b").expect("id"); - - assert_eq!(manager.limits(), RuntimeLimits::default()); - assert_eq!(manager.matcher(), SubscriptionMatcher::default()); - assert_eq!( - manager.subscribe(id_b.clone(), matching_plan.clone()), - Ok(SubscriptionAddOutcome::Inserted) - ); - assert_eq!( - manager.subscribe(id_a.clone(), no_match_plan), - Ok(SubscriptionAddOutcome::Inserted) - ); - assert_eq!(manager.active_count(), 2); - assert!(manager.plan(&id_a).is_some()); - assert_eq!(manager.match_event(&event).len(), 1); - assert_eq!(manager.match_event(&event)[0].subscription_id, id_b); - assert_eq!(manager.match_event(&event)[0].branch_indexes, [0]); - assert_eq!( - manager.subscribe(id_a.clone(), matching_plan), - Ok(SubscriptionAddOutcome::Replaced) - ); - let matches = manager.match_event(&event); - - assert_eq!(matches.len(), 2); - assert_eq!(matches[0].subscription_id, id_a); - assert_eq!(matches[0].branch_indexes, [0]); - assert_eq!( - matches[1].subscription_id, - SubscriptionId::new("b").expect("id") - ); - assert_eq!( - manager.close(&SubscriptionId::new("b").expect("id")), - SubscriptionCloseOutcome::Closed - ); - assert_eq!( - manager.close(&SubscriptionId::new("b").expect("id")), - SubscriptionCloseOutcome::NotFound - ); - assert_eq!(manager.active_count(), 1); - assert!( - manager - .plan(&SubscriptionId::new("b").expect("id")) - .is_none() - ); - } - - #[test] - fn subscription_manager_enforces_subscription_count_limits() { - let branch = QueryPlanBranch::from_spec(QueryPlanBranchSpec::default()).expect("branch"); - let plan = QueryPlan::new( - QuerySource::RawEvents, - QueryExecutionMode::Live, - QuerySort::CreatedAtDescEventIdAsc, - vec![branch], - ) - .expect("plan"); - let mut manager = SubscriptionManager::new( - limits_with(|values| values.max_subscriptions_per_connection = 1), - SubscriptionMatcher::new(LiveSearchPolicy::DisabledLiveSearch), - ); - let id_a = SubscriptionId::new("a").expect("id"); - let id_b = SubscriptionId::new("b").expect("id"); - - assert_eq!( - manager.subscribe(id_a.clone(), plan.clone()), - Ok(SubscriptionAddOutcome::Inserted) - ); - assert_eq!( - manager.subscribe(id_a, plan.clone()), - Ok(SubscriptionAddOutcome::Replaced) - ); - let too_many = manager.subscribe(id_b, plan).expect_err("limit"); - - assert_eq!(too_many.kind(), SubscriptionManagerErrorKind::RuntimeLimit); - assert_eq!( - too_many.to_string(), - "runtime limit: subscriptions per connection exceeded: 2 > 1" - ); - } - - #[test] - fn auth_challenge_state_issues_and_authenticates_nip42_events() { - let mut state = - AuthChallengeState::new(" wss://relay.radroots.test ", 10).expect("auth state"); - let default_state = AuthChallengeState::default(); - let challenge = state - .issue_challenge(" challenge-001 ", UnixTimestamp::new(100)) - .expect("challenge"); - let auth = relay_auth_event("wss://relay.radroots.test", "challenge-001", 105); - let authenticated = state - .authenticate(&auth, UnixTimestamp::new(105)) - .expect("authenticated"); - - assert_eq!(default_state.relay_url(), "wss://relay.radroots.test"); - assert_eq!(default_state.ttl_seconds(), 300); - assert_eq!(state.relay_url(), "wss://relay.radroots.test"); - assert_eq!(state.ttl_seconds(), 10); - assert_eq!(challenge.value, "challenge-001"); - assert_eq!(challenge.relay_url, "wss://relay.radroots.test"); - assert_eq!(challenge.issued_at, UnixTimestamp::new(100)); - assert_eq!(challenge.expires_at, UnixTimestamp::new(110)); - assert_eq!(authenticated.pubkey, FixtureKey::Seller.public_key()); - assert_eq!(state.authenticated_pubkey(), Some(auth.pubkey())); - assert_eq!(state.active_challenge(), None); - - state.clear_authentication(); - assert_eq!(state.authenticated_pubkey(), None); - state - .issue_challenge("challenge-002", UnixTimestamp::new(120)) - .expect("challenge"); - assert_eq!(state.authenticated_pubkey(), None); - assert_eq!( - state.active_challenge().expect("active").expires_at, - UnixTimestamp::new(130) - ); - } - - #[test] - fn auth_challenge_state_rejects_invalid_and_mismatched_auth() { - let invalid_relay = AuthChallengeState::new(" ", 10).expect_err("relay"); - let invalid_ttl = AuthChallengeState::new("wss://relay.radroots.test", 0).expect_err("ttl"); - let mut empty_challenge = - AuthChallengeState::new("wss://relay.radroots.test", 10).expect("state"); - let empty_challenge = empty_challenge - .issue_challenge(" ", UnixTimestamp::new(1)) - .expect_err("challenge"); - let missing_challenge = AuthChallengeState::new("wss://relay.radroots.test", 10) - .expect("state") - .authenticate( - &relay_auth_event("wss://relay.radroots.test", "challenge-001", 10), - UnixTimestamp::new(10), - ) - .expect_err("missing"); - let mut expired = AuthChallengeState::new("wss://relay.radroots.test", 5).expect("state"); - expired - .issue_challenge("challenge-001", UnixTimestamp::new(10)) - .expect("challenge"); - let expired = expired - .authenticate( - &relay_auth_event("wss://relay.radroots.test", "challenge-001", 11), - UnixTimestamp::new(16), - ) - .expect_err("expired"); - let mut relay_mismatch = - AuthChallengeState::new("wss://relay.radroots.test", 10).expect("state"); - relay_mismatch - .issue_challenge("challenge-001", UnixTimestamp::new(10)) - .expect("challenge"); - let relay_mismatch = relay_mismatch - .authenticate( - &relay_auth_event("wss://other.radroots.test", "challenge-001", 11), - UnixTimestamp::new(11), - ) - .expect_err("relay"); - let mut challenge_mismatch = - AuthChallengeState::new("wss://relay.radroots.test", 10).expect("state"); - challenge_mismatch - .issue_challenge("challenge-001", UnixTimestamp::new(10)) - .expect("challenge"); - let challenge_mismatch = challenge_mismatch - .authenticate( - &relay_auth_event("wss://relay.radroots.test", "challenge-002", 11), - UnixTimestamp::new(11), - ) - .expect_err("challenge"); - let mut created_before = - AuthChallengeState::new("wss://relay.radroots.test", 10).expect("state"); - created_before - .issue_challenge("challenge-001", UnixTimestamp::new(20)) - .expect("challenge"); - let created_before = created_before - .authenticate( - &relay_auth_event("wss://relay.radroots.test", "challenge-001", 19), - UnixTimestamp::new(21), - ) - .expect_err("created before"); - - assert_eq!( - invalid_relay.kind(), - AuthChallengeStateErrorKind::InvalidRelayUrl - ); - assert_eq!(invalid_relay.to_string(), "relay url must not be empty"); - assert_eq!(invalid_ttl.kind(), AuthChallengeStateErrorKind::InvalidTtl); - assert_eq!( - invalid_ttl.to_string(), - "auth challenge ttl must be greater than zero" - ); - assert_eq!( - empty_challenge.kind(), - AuthChallengeStateErrorKind::EmptyChallenge - ); - assert_eq!( - empty_challenge.to_string(), - "auth challenge must not be empty" - ); - assert_eq!( - missing_challenge.kind(), - AuthChallengeStateErrorKind::MissingChallenge - ); - assert_eq!(missing_challenge.to_string(), "auth challenge is missing"); - assert_eq!(expired.kind(), AuthChallengeStateErrorKind::Expired); - assert_eq!(expired.to_string(), "auth challenge expired at 15, now 16"); - assert_eq!( - relay_mismatch.kind(), - AuthChallengeStateErrorKind::RelayMismatch - ); - assert_eq!( - relay_mismatch.to_string(), - "auth relay mismatch: expected wss://relay.radroots.test, got wss://other.radroots.test" - ); - assert_eq!( - challenge_mismatch.kind(), - AuthChallengeStateErrorKind::ChallengeMismatch - ); - assert_eq!(challenge_mismatch.to_string(), "auth challenge mismatch"); - assert_eq!( - created_before.kind(), - AuthChallengeStateErrorKind::CreatedBeforeChallenge - ); - assert_eq!( - created_before.to_string(), - "auth event created_at 19 is before challenge issued_at 20" - ); - } - - #[test] - fn fixed_window_rate_limiter_accepts_rejects_resets_and_prunes() { - let config = RateLimitConfig::new(3, 60).expect("config"); - let mut limiter = FixedWindowRateLimiter::new(config); - let first = limiter - .check(" ip:1 ", UnixTimestamp::new(100), 1) - .expect("first"); - let second = limiter - .check("ip:1", UnixTimestamp::new(110), 2) - .expect("second"); - let rejected = limiter - .check("ip:1", UnixTimestamp::new(110), 1) - .expect("rejected"); - let other_key = limiter - .check("ip:2", UnixTimestamp::new(110), 1) - .expect("other"); - let reset = limiter - .check("ip:1", UnixTimestamp::new(160), 1) - .expect("reset"); - let rewind = limiter - .check("ip:1", UnixTimestamp::new(150), 1) - .expect("rewind"); - let pruned = limiter.prune_expired(UnixTimestamp::new(170)); - - assert_eq!(limiter.config(), config); - assert!(first.allowed()); - assert_eq!(first.remaining(), 2); - assert_eq!(first.reset_at(), UnixTimestamp::new(160)); - assert_eq!(first.retry_after_seconds(), None); - assert!(second.allowed()); - assert_eq!(second.remaining(), 0); - assert!(!rejected.allowed()); - assert_eq!(rejected.remaining(), 0); - assert_eq!(rejected.reset_at(), UnixTimestamp::new(160)); - assert_eq!(rejected.retry_after_seconds(), Some(50)); - assert_eq!( - rejected, - RateLimitDecision::Rejected { - retry_after_seconds: 50, - reset_at: UnixTimestamp::new(160), - } - ); - assert_eq!(other_key.remaining(), 2); - assert_eq!(reset.remaining(), 2); - assert_eq!(reset.reset_at(), UnixTimestamp::new(220)); - assert_eq!(rewind.remaining(), 2); - assert_eq!(rewind.reset_at(), UnixTimestamp::new(210)); - assert_eq!(pruned, 1); - assert_eq!(limiter.tracked_key_count(), 1); - } - - #[test] - fn fixed_window_rate_limiter_rejects_invalid_config_keys_and_costs() { - let zero_limit = RateLimitConfig::new(0, 60).expect_err("limit"); - let zero_window = RateLimitConfig::new(1, 0).expect_err("window"); - let mut limiter = FixedWindowRateLimiter::new(RateLimitConfig::new(2, 60).expect("config")); - let empty_key = limiter - .check(" ", UnixTimestamp::new(1), 1) - .expect_err("key"); - let zero_cost = limiter - .check("ip:1", UnixTimestamp::new(1), 0) - .expect_err("cost"); - let cost_exceeds_limit = limiter - .check("ip:1", UnixTimestamp::new(1), 3) - .expect_err("limit"); - - assert_eq!(zero_limit, RateLimitConfigError::ZeroLimit); - assert_eq!( - zero_limit.to_string(), - "rate limit must be greater than zero" - ); - assert_eq!(zero_window, RateLimitConfigError::ZeroWindowSeconds); - assert_eq!( - zero_window.to_string(), - "rate limit window must be greater than zero seconds" - ); - assert_eq!(empty_key.kind(), RateLimitErrorKind::EmptyKey); - assert_eq!(empty_key.to_string(), "rate limit key must not be empty"); - assert_eq!(zero_cost.kind(), RateLimitErrorKind::ZeroCost); - assert_eq!( - zero_cost.to_string(), - "rate limit cost must be greater than zero" - ); - assert_eq!( - cost_exceeds_limit.kind(), - RateLimitErrorKind::CostExceedsLimit - ); - assert_eq!( - cost_exceeds_limit.to_string(), - "rate limit cost 3 exceeds limit 2" - ); - assert_eq!(limiter.tracked_key_count(), 0); - } - - fn limits_with(update: impl FnOnce(&mut RuntimeLimitValues)) -> RuntimeLimits { - let mut values = RuntimeLimitValues::default(); - update(&mut values); - RuntimeLimits::from_values(values).expect("limits") - } - - fn event_with(tags: Vec<Tag>, content: &str, created_at: UnixTimestamp) -> Event { - Event::new( - EventId::new(&"a".repeat(EventId::HEX_LENGTH)).expect("id"), - UnsignedEvent::new( - PublicKeyHex::new(&"1".repeat(PublicKeyHex::HEX_LENGTH)).expect("pubkey"), - created_at, - Kind::new(30_402).expect("kind"), - tags, - content, - ), - SignatureHex::new(&"b".repeat(SignatureHex::HEX_LENGTH)).expect("sig"), - ) - } - - fn relay_auth_event(relay: &str, challenge: &str, created_at: u64) -> RelayAuthEvent { - let spec = fixture_spec_from_json(&format!( - r#"{{"name":"auth","key":"seller","created_at":{created_at},"kind":22242,"tags":[["relay","{relay}"],["challenge","{challenge}"]],"content":""}}"# - )) - .expect("auth spec"); - let event = build_fixture_event(&spec).expect("auth event"); - parse_relay_auth_event(&event) - .expect("auth parse") - .expect("auth event") - } - - struct RawFailingRepository; - - impl RawEventRepository for RawFailingRepository { - fn put_event( - &mut self, - _record: StoredEvent, - ) -> Result<StoreEventOutcome, RepositoryError> { - Err(RepositoryError::new("repository unavailable")) - } - - fn event_by_id(&self, _event_id: &EventId) -> Result<Option<StoredEvent>, RepositoryError> { - Err(RepositoryError::new("repository unavailable")) - } - - fn events(&self) -> Result<Vec<StoredEvent>, RepositoryError> { - Err(RepositoryError::new("repository unavailable")) - } - } - - impl ListingProjectionRepository for RawFailingRepository { - fn put_listing_projection( - &mut self, - _projection: ListingProjection, - ) -> Result<StoreProjectionOutcome, RepositoryError> { - Err(RepositoryError::new("repository unavailable")) - } - - fn listing_projection( - &self, - _address: &AddressCoordinate, - ) -> Result<Option<ListingProjection>, RepositoryError> { - Err(RepositoryError::new("repository unavailable")) - } - } - - impl DeletionMarkerRepository for RawFailingRepository { - fn put_deletion_marker(&mut self, _marker: DeletionMarker) -> Result<(), RepositoryError> { - Err(RepositoryError::new("repository unavailable")) - } - - fn deletion_markers(&self) -> Result<Vec<DeletionMarker>, RepositoryError> { - Err(RepositoryError::new("repository unavailable")) - } - } - - struct ProjectionFailingRepository { - inner: InMemoryRepository, - } - - impl ProjectionFailingRepository { - fn new() -> Self { - Self { - inner: InMemoryRepository::new(), - } - } - } - - impl RawEventRepository for ProjectionFailingRepository { - fn put_event(&mut self, record: StoredEvent) -> Result<StoreEventOutcome, RepositoryError> { - self.inner.put_event(record) - } - - fn event_by_id(&self, event_id: &EventId) -> Result<Option<StoredEvent>, RepositoryError> { - self.inner.event_by_id(event_id) - } - - fn events(&self) -> Result<Vec<StoredEvent>, RepositoryError> { - self.inner.events() - } - } - - impl ListingProjectionRepository for ProjectionFailingRepository { - fn put_listing_projection( - &mut self, - _projection: ListingProjection, - ) -> Result<StoreProjectionOutcome, RepositoryError> { - Err(RepositoryError::new("projection unavailable")) - } - - fn listing_projection( - &self, - address: &AddressCoordinate, - ) -> Result<Option<ListingProjection>, RepositoryError> { - self.inner.listing_projection(address) - } - } - - impl DeletionMarkerRepository for ProjectionFailingRepository { - fn put_deletion_marker(&mut self, marker: DeletionMarker) -> Result<(), RepositoryError> { - self.inner.put_deletion_marker(marker) - } - - fn deletion_markers(&self) -> Result<Vec<DeletionMarker>, RepositoryError> { - self.inner.deletion_markers() - } - } - - struct DeletionFailingRepository { - inner: InMemoryRepository, - } - - impl DeletionFailingRepository { - fn new() -> Self { - Self { - inner: InMemoryRepository::new(), - } - } - } - - impl RawEventRepository for DeletionFailingRepository { - fn put_event(&mut self, record: StoredEvent) -> Result<StoreEventOutcome, RepositoryError> { - self.inner.put_event(record) - } - - fn event_by_id(&self, event_id: &EventId) -> Result<Option<StoredEvent>, RepositoryError> { - self.inner.event_by_id(event_id) - } - - fn events(&self) -> Result<Vec<StoredEvent>, RepositoryError> { - self.inner.events() - } - } - - impl ListingProjectionRepository for DeletionFailingRepository { - fn put_listing_projection( - &mut self, - projection: ListingProjection, - ) -> Result<StoreProjectionOutcome, RepositoryError> { - self.inner.put_listing_projection(projection) - } - - fn listing_projection( - &self, - address: &AddressCoordinate, - ) -> Result<Option<ListingProjection>, RepositoryError> { - self.inner.listing_projection(address) - } - } - - impl DeletionMarkerRepository for DeletionFailingRepository { - fn put_deletion_marker(&mut self, _marker: DeletionMarker) -> Result<(), RepositoryError> { - Err(RepositoryError::new("deletion unavailable")) - } - - fn deletion_markers(&self) -> Result<Vec<DeletionMarker>, RepositoryError> { - self.inner.deletion_markers() - } - } - - fn pubkey(hex: &str) -> PublicKeyHex { - PublicKeyHex::new(&hex.repeat(PublicKeyHex::HEX_LENGTH)).expect("pubkey") - } -} diff --git a/crates/tangle_nips/Cargo.toml b/crates/tangle_nips/Cargo.toml @@ -1,15 +0,0 @@ -[package] -name = "tangle_nips" -version.workspace = true -edition.workspace = true -authors.workspace = true -rust-version.workspace = true -license.workspace = true -description = "MVP NIP parsers and projection contracts for tangle" - -[dependencies] -serde_json = "1" -tangle_protocol = { path = "../tangle_protocol" } - -[lints] -workspace = true diff --git a/crates/tangle_nips/src/lib.rs b/crates/tangle_nips/src/lib.rs @@ -1,4760 +0,0 @@ -#![forbid(unsafe_code)] - -use core::str::FromStr; -use tangle_protocol::{ - AddressCoordinate, DTag, Event, EventId, Filter, PublicKeyHex, TagName, UnixTimestamp, -}; - -pub const NIP01_METADATA_KIND: u32 = 0; -pub const NIP99_PUBLIC_LISTING_KIND: u32 = 30_402; -pub const NIP99_DRAFT_LISTING_KIND: u32 = 30_403; -pub const NIP22_COMMENT_KIND: u32 = 1_111; -pub const NIP25_REACTION_KIND: u32 = 7; -pub const NIP23_LONG_FORM_KIND: u32 = 30_023; -pub const NIP23_LONG_FORM_DRAFT_KIND: u32 = 30_024; -pub const NIP7D_THREAD_KIND: u32 = 11; -pub const NIP56_REPORT_KIND: u32 = 1_984; -pub const NIP32_LABEL_KIND: u32 = 1_985; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ParsedTag { - name: String, - values: Vec<String>, -} - -impl ParsedTag { - pub fn name(&self) -> &str { - &self.name - } - - pub fn values(&self) -> &[String] { - &self.values - } - - pub fn first_value(&self) -> Option<&str> { - self.values.first().map(String::as_str) - } -} - -pub fn matching_tags(event: &Event, name: &str) -> Vec<ParsedTag> { - event - .unsigned() - .tags() - .iter() - .filter(|tag| tag.name().as_str() == name) - .map(|tag| ParsedTag { - name: tag.name().to_string(), - values: tag.values().iter().skip(1).cloned().collect(), - }) - .collect() -} - -pub fn tag_count(event: &Event, name: &str) -> usize { - matching_tags(event, name).len() -} - -pub fn optional_tag_value(event: &Event, name: &str) -> Result<Option<String>, String> { - let tags = matching_tags(event, name); - match tags.as_slice() { - [] => Ok(None), - [tag] => tag - .first_value() - .map(|value| Some(value.to_owned())) - .ok_or_else(|| format!("tag `{name}` must include a value")), - _ => Err(format!("tag `{name}` must not be repeated")), - } -} - -pub fn required_tag_value(event: &Event, name: &str) -> Result<String, String> { - optional_tag_value(event, name)?.ok_or_else(|| format!("tag `{name}` is required")) -} - -pub fn optional_tag_values(event: &Event, name: &str) -> Result<Option<Vec<String>>, String> { - let tags = matching_tags(event, name); - match tags.as_slice() { - [] => Ok(None), - [tag] => Ok(Some(tag.values().to_vec())), - _ => Err(format!("tag `{name}` must not be repeated")), - } -} - -pub fn required_tag_values(event: &Event, name: &str) -> Result<Vec<String>, String> { - optional_tag_values(event, name)?.ok_or_else(|| format!("tag `{name}` is required")) -} - -pub fn parse_u64_field(field: &str, value: &str) -> Result<u64, String> { - value - .parse::<u64>() - .map_err(|_| format!("field `{field}` must be an unsigned integer")) -} - -pub fn parse_required_u64_tag(event: &Event, name: &str) -> Result<u64, String> { - parse_u64_field(name, &required_tag_value(event, name)?) -} - -pub fn repeated_or_missing_policy_boundary( - event: &Event, - name: &str, -) -> Result<Option<String>, String> { - optional_tag_value(event, name) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SingleLetterTagValue { - name: String, - value: String, -} - -impl SingleLetterTagValue { - pub fn name(&self) -> &str { - &self.name - } - - pub fn value(&self) -> &str { - &self.value - } -} - -pub fn single_letter_tag_values(event: &Event) -> Vec<SingleLetterTagValue> { - event - .unsigned() - .tags() - .iter() - .filter_map(|tag| { - tag.indexed_pair() - .map(|(name, value)| SingleLetterTagValue { - name: name.to_owned(), - value: value.to_owned(), - }) - }) - .collect() -} - -pub fn single_letter_values_for(event: &Event, name: &str) -> Result<Vec<String>, String> { - if !TagName::is_indexable_name(name) { - return Err(format!( - "single-letter tag name `{name}` must be one ASCII letter" - )); - } - Ok(single_letter_tag_values(event) - .into_iter() - .filter(|tag| tag.name() == name) - .map(|tag| tag.value) - .collect()) -} - -pub fn first_single_letter_value(event: &Event, name: &str) -> Result<Option<String>, String> { - Ok(single_letter_values_for(event, name)?.into_iter().next()) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum DeletionTarget { - Event(EventId), - Address(AddressCoordinate), -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DeletionRequest { - event_id: EventId, - targets: Vec<DeletionTarget>, -} - -impl DeletionRequest { - pub fn event_id(&self) -> &EventId { - &self.event_id - } - - pub fn targets(&self) -> &[DeletionTarget] { - &self.targets - } -} - -pub fn parse_deletion_request(event: &Event) -> Result<Option<DeletionRequest>, String> { - if event.unsigned().kind().as_u32() != 5 { - return Ok(None); - } - let mut targets = single_letter_values_for(event, "e")? - .into_iter() - .map(|value| EventId::new(&value).map(DeletionTarget::Event)) - .collect::<Result<Vec<_>, _>>()?; - let address_targets = single_letter_values_for(event, "a")? - .into_iter() - .map(|value| AddressCoordinate::from_str(&value).map(DeletionTarget::Address)) - .collect::<Result<Vec<_>, _>>()?; - targets.extend(address_targets); - if targets.is_empty() { - return Err("deletion event must target at least one e or a tag".to_owned()); - } - Ok(Some(DeletionRequest { - event_id: event.id().clone(), - targets, - })) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RelayAuthEvent { - event_id: EventId, - pubkey: PublicKeyHex, - created_at: UnixTimestamp, - relay: String, - challenge: String, -} - -impl RelayAuthEvent { - pub fn event_id(&self) -> &EventId { - &self.event_id - } - - pub fn pubkey(&self) -> &PublicKeyHex { - &self.pubkey - } - - pub fn created_at(&self) -> UnixTimestamp { - self.created_at - } - - pub fn relay(&self) -> &str { - &self.relay - } - - pub fn challenge(&self) -> &str { - &self.challenge - } -} - -pub fn parse_relay_auth_event(event: &Event) -> Result<Option<RelayAuthEvent>, String> { - if event.unsigned().kind().as_u32() != 22_242 { - return Ok(None); - } - let relay = required_tag_value(event, "relay")?; - let challenge = required_tag_value(event, "challenge")?; - if relay.is_empty() { - return Err("relay auth relay tag must not be empty".to_owned()); - } - if challenge.is_empty() { - return Err("relay auth challenge tag must not be empty".to_owned()); - } - Ok(Some(RelayAuthEvent { - event_id: event.id().clone(), - pubkey: event.unsigned().pubkey().clone(), - created_at: event.unsigned().created_at(), - relay, - challenge, - })) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Nip50SearchQuery { - text: String, - terms: Vec<String>, -} - -impl Nip50SearchQuery { - pub fn text(&self) -> &str { - &self.text - } - - pub fn terms(&self) -> &[String] { - &self.terms - } -} - -pub fn parse_nip50_search(search: &str) -> Result<Option<Nip50SearchQuery>, String> { - let terms = search - .split_whitespace() - .filter(|term| !term.contains(':')) - .map(str::to_owned) - .collect::<Vec<_>>(); - if terms.is_empty() { - return Ok(None); - } - Ok(Some(Nip50SearchQuery { - text: terms.join(" "), - terms, - })) -} - -pub fn parse_nip50_filter_search(filter: &Filter) -> Result<Option<Nip50SearchQuery>, String> { - match filter.search() { - Some(search) => parse_nip50_search(search), - None => Ok(None), - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum CommentTarget { - Event { - event_id: EventId, - relay_hint: Option<String>, - pubkey_hint: Option<PublicKeyHex>, - }, - Address { - address: AddressCoordinate, - relay_hint: Option<String>, - }, - External { - identity: String, - relay_hint: Option<String>, - }, -} - -impl CommentTarget { - pub fn target_type(&self) -> &'static str { - match self { - Self::Event { .. } => "event", - Self::Address { .. } => "address", - Self::External { .. } => "external", - } - } - - pub fn target_ref(&self) -> String { - match self { - Self::Event { event_id, .. } => event_id.as_str().to_owned(), - Self::Address { address, .. } => address.key().to_string(), - Self::External { identity, .. } => identity.clone(), - } - } - - pub fn relay_hint(&self) -> Option<&str> { - match self { - Self::Event { relay_hint, .. } - | Self::Address { relay_hint, .. } - | Self::External { relay_hint, .. } => relay_hint.as_deref(), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CommentReference { - target: CommentTarget, - kind: String, - author: Option<PublicKeyHex>, -} - -impl CommentReference { - pub fn target(&self) -> &CommentTarget { - &self.target - } - - pub fn kind(&self) -> &str { - &self.kind - } - - pub fn author(&self) -> Option<&PublicKeyHex> { - self.author.as_ref() - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CommentEvent { - event_id: EventId, - pubkey: PublicKeyHex, - created_at: UnixTimestamp, - content: String, - root: CommentReference, - parent: CommentReference, - cited_events: Vec<String>, - mentioned_pubkeys: Vec<PublicKeyHex>, -} - -impl CommentEvent { - pub fn event_id(&self) -> &EventId { - &self.event_id - } - - pub fn pubkey(&self) -> &PublicKeyHex { - &self.pubkey - } - - pub fn created_at(&self) -> UnixTimestamp { - self.created_at - } - - pub fn content(&self) -> &str { - &self.content - } - - pub fn root(&self) -> &CommentReference { - &self.root - } - - pub fn parent(&self) -> &CommentReference { - &self.parent - } - - pub fn cited_events(&self) -> &[String] { - &self.cited_events - } - - pub fn mentioned_pubkeys(&self) -> &[PublicKeyHex] { - &self.mentioned_pubkeys - } -} - -pub fn parse_comment_event(event: &Event) -> Result<Option<CommentEvent>, String> { - if event.unsigned().kind().as_u32() != NIP22_COMMENT_KIND { - return Ok(None); - } - let root_kind = required_tag_value(event, "K")?; - let parent_kind = required_tag_value(event, "k")?; - if root_kind.is_empty() { - return Err("comment root kind tag must not be empty".to_owned()); - } - if parent_kind.is_empty() { - return Err("comment parent kind tag must not be empty".to_owned()); - } - if root_kind == "1" || parent_kind == "1" { - return Err("NIP-22 comments must not reply to kind 1 notes".to_owned()); - } - Ok(Some(CommentEvent { - event_id: event.id().clone(), - pubkey: event.unsigned().pubkey().clone(), - created_at: event.unsigned().created_at(), - content: event.unsigned().content().to_owned(), - root: CommentReference { - target: parse_scoped_comment_target(event, &["A", "E", "I"], "root")?, - kind: root_kind, - author: optional_single_pubkey(event, "P", "root author")?, - }, - parent: CommentReference { - target: parse_scoped_comment_target(event, &["a", "e", "i"], "parent")?, - kind: parent_kind, - author: first_optional_pubkey(event, "p", "parent author")?, - }, - cited_events: single_letter_values_for(event, "q")?, - mentioned_pubkeys: parse_pubkey_values(event, "p", "mentioned pubkey")?, - })) -} - -fn parse_scoped_comment_target( - event: &Event, - names: &[&str], - scope: &str, -) -> Result<CommentTarget, String> { - let mut found = Vec::new(); - for name in names { - for tag in matching_tags(event, name) { - found.push((*name, tag)); - } - } - match found.len() { - 0 => Err(format!("comment {scope} target tag is required")), - 1 => { - let (name, tag) = found.remove(0); - parse_comment_target_tag(name, &tag, scope) - } - _ => Err(format!("comment {scope} target tag must not be repeated")), - } -} - -fn parse_comment_target_tag( - name: &str, - tag: &ParsedTag, - scope: &str, -) -> Result<CommentTarget, String> { - let values = tag.values(); - let target = values - .first() - .ok_or_else(|| format!("comment {scope} target tag `{name}` must include a value"))?; - if target.is_empty() { - return Err(format!( - "comment {scope} target tag `{name}` must not be empty" - )); - } - let relay_hint = normalized_optional_hint(values.get(1), scope, "relay")?; - match name { - "E" | "e" => { - if values.len() > 3 { - return Err(format!( - "comment {scope} event target tag `{name}` must include at most event relay and pubkey values" - )); - } - let pubkey_hint = values - .get(2) - .map(|value| parse_pubkey_value(value, scope, "event pubkey hint")) - .transpose()?; - Ok(CommentTarget::Event { - event_id: EventId::new(target)?, - relay_hint, - pubkey_hint, - }) - } - "A" | "a" => { - if values.len() > 2 { - return Err(format!( - "comment {scope} address target tag `{name}` must include at most address and relay values" - )); - } - Ok(CommentTarget::Address { - address: AddressCoordinate::from_str(target)?, - relay_hint, - }) - } - "I" | "i" => { - if values.len() > 2 { - return Err(format!( - "comment {scope} external target tag `{name}` must include at most identity and relay values" - )); - } - Ok(CommentTarget::External { - identity: target.to_owned(), - relay_hint, - }) - } - _ => Err(format!( - "comment {scope} target tag `{name}` is unsupported" - )), - } -} - -fn optional_single_pubkey( - event: &Event, - name: &str, - description: &str, -) -> Result<Option<PublicKeyHex>, String> { - let values = single_letter_values_for(event, name)?; - match values.as_slice() { - [] => Ok(None), - [value] => Ok(Some(parse_pubkey_value(value, description, "pubkey")?)), - _ => Err(format!( - "comment {description} tag `{name}` must not be repeated" - )), - } -} - -fn first_optional_pubkey( - event: &Event, - name: &str, - description: &str, -) -> Result<Option<PublicKeyHex>, String> { - match single_letter_values_for(event, name)?.first() { - Some(value) => Ok(Some(parse_pubkey_value(value, description, "pubkey")?)), - None => Ok(None), - } -} - -fn parse_pubkey_values( - event: &Event, - name: &str, - description: &str, -) -> Result<Vec<PublicKeyHex>, String> { - single_letter_values_for(event, name)? - .into_iter() - .map(|value| parse_pubkey_value(&value, description, "pubkey")) - .collect() -} - -fn parse_pubkey_value(value: &str, description: &str, field: &str) -> Result<PublicKeyHex, String> { - PublicKeyHex::new(value).map_err(|source| format!("{description} {field} is invalid: {source}")) -} - -fn normalized_optional_hint( - value: Option<&String>, - scope: &str, - field: &str, -) -> Result<Option<String>, String> { - match value { - Some(value) if value.is_empty() => Err(format!( - "comment {scope} target {field} hint must not be empty" - )), - Some(value) => Ok(Some(value.clone())), - None => Ok(None), - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ReactionValue { - Like, - Dislike, - Emoji(String), - Text(String), -} - -impl ReactionValue { - pub fn canonical(&self) -> &str { - match self { - Self::Like => "like", - Self::Dislike => "dislike", - Self::Emoji(_) => "emoji", - Self::Text(_) => "text", - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ReactionEvent { - event_id: EventId, - pubkey: PublicKeyHex, - created_at: UnixTimestamp, - content: String, - value: ReactionValue, - target_event_id: EventId, - target_relay_hint: Option<String>, - target_pubkey_hint: Option<PublicKeyHex>, - target_pubkey: Option<PublicKeyHex>, - target_address: Option<AddressCoordinate>, - target_kind: Option<String>, -} - -impl ReactionEvent { - pub fn event_id(&self) -> &EventId { - &self.event_id - } - - pub fn pubkey(&self) -> &PublicKeyHex { - &self.pubkey - } - - pub fn created_at(&self) -> UnixTimestamp { - self.created_at - } - - pub fn content(&self) -> &str { - &self.content - } - - pub fn value(&self) -> &ReactionValue { - &self.value - } - - pub fn target_event_id(&self) -> &EventId { - &self.target_event_id - } - - pub fn target_relay_hint(&self) -> Option<&str> { - self.target_relay_hint.as_deref() - } - - pub fn target_pubkey_hint(&self) -> Option<&PublicKeyHex> { - self.target_pubkey_hint.as_ref() - } - - pub fn target_pubkey(&self) -> Option<&PublicKeyHex> { - self.target_pubkey.as_ref() - } - - pub fn target_address(&self) -> Option<&AddressCoordinate> { - self.target_address.as_ref() - } - - pub fn target_kind(&self) -> Option<&str> { - self.target_kind.as_deref() - } -} - -pub fn parse_reaction_event(event: &Event) -> Result<Option<ReactionEvent>, String> { - if event.unsigned().kind().as_u32() != NIP25_REACTION_KIND { - return Ok(None); - } - let target = last_matching_tag(event, "e") - .ok_or_else(|| "reaction event must include an e target tag".to_owned())?; - let target_event_id = target - .first_value() - .ok_or_else(|| "reaction e target tag must include an event id".to_owned()) - .and_then(EventId::new)?; - let target_relay_hint = normalized_optional_hint(target.values().get(1), "reaction", "relay")?; - let target_pubkey_hint = target - .values() - .get(2) - .map(|value| parse_pubkey_value(value, "reaction target hint", "pubkey")) - .transpose()?; - let target_pubkey = last_matching_tag(event, "p") - .and_then(|tag| tag.first_value().map(str::to_owned)) - .map(|value| parse_pubkey_value(&value, "reaction target", "pubkey")) - .transpose()?; - let target_address = last_matching_tag(event, "a") - .and_then(|tag| tag.first_value().map(str::to_owned)) - .map(|value| AddressCoordinate::from_str(&value)) - .transpose()?; - let target_kind = optional_tag_value(event, "k")?; - if target_kind.as_ref().is_some_and(String::is_empty) { - return Err("reaction k tag must not be empty".to_owned()); - } - Ok(Some(ReactionEvent { - event_id: event.id().clone(), - pubkey: event.unsigned().pubkey().clone(), - created_at: event.unsigned().created_at(), - content: event.unsigned().content().to_owned(), - value: reaction_value(event.unsigned().content()), - target_event_id, - target_relay_hint, - target_pubkey_hint, - target_pubkey, - target_address, - target_kind, - })) -} - -fn reaction_value(content: &str) -> ReactionValue { - match content { - "" | "+" => ReactionValue::Like, - "-" => ReactionValue::Dislike, - value if looks_like_single_emoji(value) => ReactionValue::Emoji(value.to_owned()), - value => ReactionValue::Text(value.to_owned()), - } -} - -fn looks_like_single_emoji(value: &str) -> bool { - let mut chars = value.chars(); - match (chars.next(), chars.next()) { - (Some(character), None) => !character.is_ascii() && !character.is_alphanumeric(), - _ => false, - } -} - -fn last_matching_tag(event: &Event, name: &str) -> Option<ParsedTag> { - matching_tags(event, name).into_iter().last() -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum LongFormKind { - Published, - Draft, -} - -impl LongFormKind { - pub fn canonical(self) -> &'static str { - match self { - Self::Published => "published", - Self::Draft => "draft", - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct LongFormEvent { - event_id: EventId, - pubkey: PublicKeyHex, - created_at: UnixTimestamp, - content: String, - long_form_kind: LongFormKind, - address: AddressCoordinate, - d: DTag, - title: Option<String>, - image: Option<String>, - summary: Option<String>, - published_at: Option<u64>, - topics: Vec<String>, - referenced_events: Vec<EventId>, - referenced_addresses: Vec<AddressCoordinate>, - referenced_pubkeys: Vec<PublicKeyHex>, -} - -impl LongFormEvent { - pub fn event_id(&self) -> &EventId { - &self.event_id - } - - pub fn pubkey(&self) -> &PublicKeyHex { - &self.pubkey - } - - pub fn created_at(&self) -> UnixTimestamp { - self.created_at - } - - pub fn content(&self) -> &str { - &self.content - } - - pub fn long_form_kind(&self) -> LongFormKind { - self.long_form_kind - } - - pub fn address(&self) -> &AddressCoordinate { - &self.address - } - - pub fn d(&self) -> &DTag { - &self.d - } - - pub fn title(&self) -> Option<&str> { - self.title.as_deref() - } - - pub fn image(&self) -> Option<&str> { - self.image.as_deref() - } - - pub fn summary(&self) -> Option<&str> { - self.summary.as_deref() - } - - pub fn published_at(&self) -> Option<u64> { - self.published_at - } - - pub fn topics(&self) -> &[String] { - &self.topics - } - - pub fn referenced_events(&self) -> &[EventId] { - &self.referenced_events - } - - pub fn referenced_addresses(&self) -> &[AddressCoordinate] { - &self.referenced_addresses - } - - pub fn referenced_pubkeys(&self) -> &[PublicKeyHex] { - &self.referenced_pubkeys - } -} - -pub fn parse_long_form_event(event: &Event) -> Result<Option<LongFormEvent>, String> { - let Some(long_form_kind) = long_form_kind_for_event(event) else { - return Ok(None); - }; - let d = required_tag_value(event, "d")?; - if d.is_empty() { - return Err("long-form d tag must not be empty".to_owned()); - } - let title = optional_non_empty_tag_value(event, "title", "long-form title")?; - let image = optional_non_empty_tag_value(event, "image", "long-form image")?; - let summary = optional_non_empty_tag_value(event, "summary", "long-form summary")?; - let published_at = optional_tag_value(event, "published_at")? - .map(|value| parse_u64_field("published_at", &value)) - .transpose()?; - Ok(Some(LongFormEvent { - event_id: event.id().clone(), - pubkey: event.unsigned().pubkey().clone(), - created_at: event.unsigned().created_at(), - content: event.unsigned().content().to_owned(), - long_form_kind, - address: AddressCoordinate::new( - event.unsigned().kind(), - event.unsigned().pubkey().clone(), - DTag::new(&d), - ) - .expect("long-form kind must be addressable"), - d: DTag::new(&d), - title, - image, - summary, - published_at, - topics: collect_normalized_tag_values(event, "t", "long-form topic")?, - referenced_events: single_letter_values_for(event, "e")? - .into_iter() - .map(|value| EventId::new(&value)) - .collect::<Result<Vec<_>, _>>()?, - referenced_addresses: single_letter_values_for(event, "a")? - .into_iter() - .map(|value| AddressCoordinate::from_str(&value)) - .collect::<Result<Vec<_>, _>>()?, - referenced_pubkeys: parse_pubkey_values(event, "p", "long-form reference")?, - })) -} - -fn long_form_kind_for_event(event: &Event) -> Option<LongFormKind> { - match event.unsigned().kind().as_u32() { - NIP23_LONG_FORM_KIND => Some(LongFormKind::Published), - NIP23_LONG_FORM_DRAFT_KIND => Some(LongFormKind::Draft), - _ => None, - } -} - -fn optional_non_empty_tag_value( - event: &Event, - name: &str, - description: &str, -) -> Result<Option<String>, String> { - let value = optional_tag_value(event, name)?; - if value.as_ref().is_some_and(String::is_empty) { - return Err(format!("{description} tag must not be empty")); - } - Ok(value) -} - -fn collect_normalized_tag_values( - event: &Event, - name: &str, - description: &str, -) -> Result<Vec<String>, String> { - let mut values = Vec::new(); - for tag in matching_tags(event, name) { - match tag.values() { - [] => return Err(format!("{description} tag must include a value")), - [value] => { - let normalized = value.trim().to_ascii_lowercase(); - if normalized.is_empty() { - return Err(format!("{description} value must not be empty")); - } - values.push(normalized); - } - _ => return Err(format!("{description} tag must include exactly one value")), - } - } - values.sort(); - values.dedup(); - Ok(values) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ForumThreadEvent { - event_id: EventId, - pubkey: PublicKeyHex, - created_at: UnixTimestamp, - title: Option<String>, - content: String, - topics: Vec<String>, - referenced_events: Vec<EventId>, - referenced_pubkeys: Vec<PublicKeyHex>, -} - -impl ForumThreadEvent { - pub fn event_id(&self) -> &EventId { - &self.event_id - } - - pub fn pubkey(&self) -> &PublicKeyHex { - &self.pubkey - } - - pub fn created_at(&self) -> UnixTimestamp { - self.created_at - } - - pub fn title(&self) -> Option<&str> { - self.title.as_deref() - } - - pub fn content(&self) -> &str { - &self.content - } - - pub fn topics(&self) -> &[String] { - &self.topics - } - - pub fn referenced_events(&self) -> &[EventId] { - &self.referenced_events - } - - pub fn referenced_pubkeys(&self) -> &[PublicKeyHex] { - &self.referenced_pubkeys - } -} - -pub fn parse_forum_thread_event(event: &Event) -> Result<Option<ForumThreadEvent>, String> { - if event.unsigned().kind().as_u32() != NIP7D_THREAD_KIND { - return Ok(None); - } - Ok(Some(ForumThreadEvent { - event_id: event.id().clone(), - pubkey: event.unsigned().pubkey().clone(), - created_at: event.unsigned().created_at(), - title: optional_non_empty_tag_value(event, "title", "forum thread title")?, - content: event.unsigned().content().to_owned(), - topics: collect_normalized_tag_values(event, "t", "forum thread topic")?, - referenced_events: single_letter_values_for(event, "e")? - .into_iter() - .map(|value| EventId::new(&value)) - .collect::<Result<Vec<_>, _>>()?, - referenced_pubkeys: parse_pubkey_values(event, "p", "forum thread reference")?, - })) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ReportType { - Nudity, - Malware, - Profanity, - Illegal, - Spam, - Impersonation, - Other, -} - -impl ReportType { - pub fn canonical(self) -> &'static str { - match self { - Self::Nudity => "nudity", - Self::Malware => "malware", - Self::Profanity => "profanity", - Self::Illegal => "illegal", - Self::Spam => "spam", - Self::Impersonation => "impersonation", - Self::Other => "other", - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ReportTarget { - Pubkey { - pubkey: PublicKeyHex, - report_type: ReportType, - }, - Event { - event_id: EventId, - report_type: ReportType, - }, - Blob { - hash: String, - report_type: ReportType, - }, -} - -impl ReportTarget { - pub fn target_type(&self) -> &'static str { - match self { - Self::Pubkey { .. } => "pubkey", - Self::Event { .. } => "event", - Self::Blob { .. } => "blob", - } - } - - pub fn target_ref(&self) -> &str { - match self { - Self::Pubkey { pubkey, .. } => pubkey.as_str(), - Self::Event { event_id, .. } => event_id.as_str(), - Self::Blob { hash, .. } => hash, - } - } - - pub fn report_type(&self) -> ReportType { - match self { - Self::Pubkey { report_type, .. } - | Self::Event { report_type, .. } - | Self::Blob { report_type, .. } => *report_type, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ReportEvent { - event_id: EventId, - pubkey: PublicKeyHex, - created_at: UnixTimestamp, - content: String, - reported_pubkeys: Vec<PublicKeyHex>, - targets: Vec<ReportTarget>, - server_urls: Vec<String>, -} - -impl ReportEvent { - pub fn event_id(&self) -> &EventId { - &self.event_id - } - - pub fn pubkey(&self) -> &PublicKeyHex { - &self.pubkey - } - - pub fn created_at(&self) -> UnixTimestamp { - self.created_at - } - - pub fn content(&self) -> &str { - &self.content - } - - pub fn reported_pubkeys(&self) -> &[PublicKeyHex] { - &self.reported_pubkeys - } - - pub fn targets(&self) -> &[ReportTarget] { - &self.targets - } - - pub fn server_urls(&self) -> &[String] { - &self.server_urls - } -} - -pub fn parse_report_event(event: &Event) -> Result<Option<ReportEvent>, String> { - if event.unsigned().kind().as_u32() != NIP56_REPORT_KIND { - return Ok(None); - } - let mut reported_pubkeys = Vec::new(); - let mut targets = Vec::new(); - for tag in matching_tags(event, "p") { - let pubkey = tag - .first_value() - .ok_or_else(|| "report p tag must include a pubkey".to_owned()) - .and_then(|value| parse_pubkey_value(value, "report p target", "pubkey"))?; - if let Some(report_type) = optional_report_type(&tag)? { - targets.push(ReportTarget::Pubkey { - pubkey: pubkey.clone(), - report_type, - }); - } - reported_pubkeys.push(pubkey); - } - if reported_pubkeys.is_empty() { - return Err("report event must include at least one p tag".to_owned()); - } - let mut event_context_count = 0; - for tag in matching_tags(event, "e") { - let event_id = tag - .first_value() - .ok_or_else(|| "report e tag must include an event id".to_owned()) - .and_then(EventId::new)?; - event_context_count += 1; - if let Some(report_type) = optional_report_type(&tag)? { - targets.push(ReportTarget::Event { - event_id, - report_type, - }); - } - } - let mut blob_count = 0; - for tag in matching_tags(event, "x") { - let hash = tag - .first_value() - .ok_or_else(|| "report x tag must include a hash".to_owned())?; - if hash.is_empty() { - return Err("report x hash must not be empty".to_owned()); - } - blob_count += 1; - targets.push(ReportTarget::Blob { - hash: hash.to_owned(), - report_type: required_report_type(&tag, "x")?, - }); - } - if blob_count > 0 && event_context_count == 0 { - return Err("report x target requires an e tag context".to_owned()); - } - if targets.is_empty() { - return Err( - "report event must include at least one p e or x tag with a report type".to_owned(), - ); - } - let mut server_urls = parse_report_server_urls(event)?; - reported_pubkeys.sort_by(|left, right| left.as_str().cmp(right.as_str())); - reported_pubkeys.dedup(); - server_urls.sort(); - server_urls.dedup(); - Ok(Some(ReportEvent { - event_id: event.id().clone(), - pubkey: event.unsigned().pubkey().clone(), - created_at: event.unsigned().created_at(), - content: event.unsigned().content().to_owned(), - reported_pubkeys, - targets, - server_urls, - })) -} - -fn optional_report_type(tag: &ParsedTag) -> Result<Option<ReportType>, String> { - tag.values() - .get(1) - .map(|value| parse_report_type(value)) - .transpose() -} - -fn required_report_type(tag: &ParsedTag, name: &str) -> Result<ReportType, String> { - match optional_report_type(tag)? { - Some(report_type) => Ok(report_type), - None => Err(format!("report {name} tag must include a report type")), - } -} - -fn parse_report_type(value: &str) -> Result<ReportType, String> { - match value { - "nudity" => Ok(ReportType::Nudity), - "malware" => Ok(ReportType::Malware), - "profanity" => Ok(ReportType::Profanity), - "illegal" => Ok(ReportType::Illegal), - "spam" => Ok(ReportType::Spam), - "impersonation" => Ok(ReportType::Impersonation), - "other" => Ok(ReportType::Other), - "" => Err("report type must not be empty".to_owned()), - _ => Err(format!("report type `{value}` is unsupported")), - } -} - -fn parse_report_server_urls(event: &Event) -> Result<Vec<String>, String> { - let mut urls = Vec::new(); - for tag in matching_tags(event, "server") { - match tag.values() { - [value] if !value.is_empty() => urls.push(value.clone()), - [..] => { - return Err("report server tag must include exactly one non-empty URL".to_owned()); - } - } - } - Ok(urls) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum LabelTarget { - Event(EventId), - Pubkey(PublicKeyHex), - Address(AddressCoordinate), - Relay(String), - Topic(String), -} - -impl LabelTarget { - pub fn target_type(&self) -> &'static str { - match self { - Self::Event(_) => "event", - Self::Pubkey(_) => "pubkey", - Self::Address(_) => "address", - Self::Relay(_) => "relay", - Self::Topic(_) => "topic", - } - } - - pub fn target_ref(&self) -> String { - match self { - Self::Event(event_id) => event_id.as_str().to_owned(), - Self::Pubkey(pubkey) => pubkey.as_str().to_owned(), - Self::Address(address) => address.key().to_string(), - Self::Relay(relay) | Self::Topic(relay) => relay.clone(), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct LabelValue { - value: String, - namespace: String, -} - -impl LabelValue { - pub fn value(&self) -> &str { - &self.value - } - - pub fn namespace(&self) -> &str { - &self.namespace - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct LabelEvent { - event_id: EventId, - pubkey: PublicKeyHex, - created_at: UnixTimestamp, - content: String, - namespaces: Vec<String>, - labels: Vec<LabelValue>, - targets: Vec<LabelTarget>, -} - -impl LabelEvent { - pub fn event_id(&self) -> &EventId { - &self.event_id - } - - pub fn pubkey(&self) -> &PublicKeyHex { - &self.pubkey - } - - pub fn created_at(&self) -> UnixTimestamp { - self.created_at - } - - pub fn content(&self) -> &str { - &self.content - } - - pub fn namespaces(&self) -> &[String] { - &self.namespaces - } - - pub fn labels(&self) -> &[LabelValue] { - &self.labels - } - - pub fn targets(&self) -> &[LabelTarget] { - &self.targets - } -} - -pub fn parse_label_event(event: &Event) -> Result<Option<LabelEvent>, String> { - if event.unsigned().kind().as_u32() != NIP32_LABEL_KIND { - return Ok(None); - } - let namespaces = parse_label_namespaces(event)?; - let labels = parse_label_values(event, &namespaces)?; - if labels.is_empty() { - return Err("label event must include at least one l tag".to_owned()); - } - let targets = parse_label_targets(event)?; - if targets.is_empty() { - return Err("label event must target at least one e p a r or t tag".to_owned()); - } - Ok(Some(LabelEvent { - event_id: event.id().clone(), - pubkey: event.unsigned().pubkey().clone(), - created_at: event.unsigned().created_at(), - content: event.unsigned().content().to_owned(), - namespaces, - labels, - targets, - })) -} - -fn parse_label_namespaces(event: &Event) -> Result<Vec<String>, String> { - let mut namespaces = Vec::new(); - for tag in matching_tags(event, "L") { - match tag.values() { - [value] if !value.is_empty() => namespaces.push(value.clone()), - [..] => { - return Err( - "label namespace L tag must include exactly one non-empty value".to_owned(), - ); - } - } - } - namespaces.sort(); - namespaces.dedup(); - Ok(namespaces) -} - -fn parse_label_values(event: &Event, namespaces: &[String]) -> Result<Vec<LabelValue>, String> { - let mut labels = Vec::new(); - for tag in matching_tags(event, "l") { - let values = tag.values(); - let value = values - .first() - .ok_or_else(|| "label l tag must include a value".to_owned())?; - if value.is_empty() { - return Err("label l value must not be empty".to_owned()); - } - if values.len() > 2 { - return Err("label l tag must include at most value and namespace".to_owned()); - } - let namespace = match values.get(1) { - Some(namespace) if namespace.is_empty() => { - return Err("label l namespace must not be empty".to_owned()); - } - Some(namespace) => namespace.clone(), - None if namespaces.is_empty() => "ugc".to_owned(), - None => return Err("label l tag must include a namespace matching an L tag".to_owned()), - }; - if !namespaces.is_empty() && !namespaces.contains(&namespace) { - return Err("label l namespace must match an L tag".to_owned()); - } - labels.push(LabelValue { - value: value.clone(), - namespace, - }); - } - Ok(labels) -} - -fn parse_label_targets(event: &Event) -> Result<Vec<LabelTarget>, String> { - let mut targets = Vec::new(); - for value in single_letter_values_for(event, "e")? { - targets.push(LabelTarget::Event(EventId::new(&value)?)); - } - for value in single_letter_values_for(event, "p")? { - targets.push(LabelTarget::Pubkey(parse_pubkey_value( - &value, - "label target", - "pubkey", - )?)); - } - for value in single_letter_values_for(event, "a")? { - targets.push(LabelTarget::Address(AddressCoordinate::from_str(&value)?)); - } - for value in single_letter_values_for(event, "r")? { - if value.is_empty() { - return Err("label relay target must not be empty".to_owned()); - } - targets.push(LabelTarget::Relay(value)); - } - for value in single_letter_values_for(event, "t")? { - if value.is_empty() { - return Err("label topic target must not be empty".to_owned()); - } - targets.push(LabelTarget::Topic(value)); - } - Ok(targets) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SellerProfileMetadata { - name: Option<String>, - display_name: Option<String>, - about: Option<String>, - picture: Option<String>, - website: Option<String>, - nip05: Option<String>, - lud16: Option<String>, -} - -impl SellerProfileMetadata { - pub fn name(&self) -> Option<&str> { - self.name.as_deref() - } - - pub fn display_name(&self) -> Option<&str> { - self.display_name.as_deref() - } - - pub fn about(&self) -> Option<&str> { - self.about.as_deref() - } - - pub fn picture(&self) -> Option<&str> { - self.picture.as_deref() - } - - pub fn website(&self) -> Option<&str> { - self.website.as_deref() - } - - pub fn nip05(&self) -> Option<&str> { - self.nip05.as_deref() - } - - pub fn lud16(&self) -> Option<&str> { - self.lud16.as_deref() - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SellerProfileEvent { - event_id: EventId, - pubkey: PublicKeyHex, - created_at: UnixTimestamp, - metadata: SellerProfileMetadata, - regions: Vec<String>, - categories: Vec<String>, - trust_markers: Vec<String>, -} - -impl SellerProfileEvent { - pub fn event_id(&self) -> &EventId { - &self.event_id - } - - pub fn pubkey(&self) -> &PublicKeyHex { - &self.pubkey - } - - pub fn created_at(&self) -> UnixTimestamp { - self.created_at - } - - pub fn metadata(&self) -> &SellerProfileMetadata { - &self.metadata - } - - pub fn regions(&self) -> &[String] { - &self.regions - } - - pub fn categories(&self) -> &[String] { - &self.categories - } - - pub fn trust_markers(&self) -> &[String] { - &self.trust_markers - } -} - -pub fn parse_seller_profile_event(event: &Event) -> Result<Option<SellerProfileEvent>, String> { - if event.unsigned().kind().as_u32() != NIP01_METADATA_KIND { - return Ok(None); - } - let metadata = parse_seller_profile_metadata(event.unsigned().content())?; - Ok(Some(SellerProfileEvent { - event_id: event.id().clone(), - pubkey: event.unsigned().pubkey().clone(), - created_at: event.unsigned().created_at(), - metadata, - regions: collect_normalized_tag_values(event, "region", "seller profile region")?, - categories: collect_normalized_tag_values(event, "category", "seller profile category")?, - trust_markers: collect_normalized_tag_values(event, "trust", "seller profile trust")?, - })) -} - -fn parse_seller_profile_metadata(content: &str) -> Result<SellerProfileMetadata, String> { - let value = serde_json::from_str::<serde_json::Value>(content) - .map_err(|error| format!("seller profile metadata JSON is invalid: {error}"))?; - let object = value - .as_object() - .ok_or_else(|| "seller profile metadata must be a JSON object".to_owned())?; - Ok(SellerProfileMetadata { - name: optional_metadata_string(object, "name")?, - display_name: optional_metadata_string(object, "display_name")?, - about: optional_metadata_string(object, "about")?, - picture: optional_metadata_string(object, "picture")?, - website: optional_metadata_string(object, "website")?, - nip05: optional_metadata_string(object, "nip05")?, - lud16: optional_metadata_string(object, "lud16")?, - }) -} - -fn optional_metadata_string( - object: &serde_json::Map<String, serde_json::Value>, - field: &'static str, -) -> Result<Option<String>, String> { - match object.get(field) { - Some(value) if value.is_null() => Ok(None), - Some(value) => { - let value = value - .as_str() - .ok_or_else(|| format!("seller profile metadata `{field}` must be a string"))? - .trim(); - if value.is_empty() { - return Ok(None); - } - Ok(Some(value.to_owned())) - } - None => Ok(None), - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ListingKind { - Public, - Draft, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ListingIdentity { - event_id: EventId, - listing_kind: ListingKind, - address: AddressCoordinate, -} - -impl ListingIdentity { - pub fn event_id(&self) -> &EventId { - &self.event_id - } - - pub fn listing_kind(&self) -> ListingKind { - self.listing_kind - } - - pub fn address(&self) -> &AddressCoordinate { - &self.address - } - - pub fn seller_pubkey(&self) -> &PublicKeyHex { - self.address.pubkey() - } - - pub fn d(&self) -> &DTag { - self.address.d() - } -} - -pub fn parse_listing_identity(event: &Event) -> Result<Option<ListingIdentity>, String> { - let Some(listing_kind) = listing_kind_for_event(event) else { - return Ok(None); - }; - let d = required_tag_value(event, "d")?; - if d.is_empty() { - return Err("listing d tag must not be empty".to_owned()); - } - let address = AddressCoordinate::new( - event.unsigned().kind(), - event.unsigned().pubkey().clone(), - DTag::new(&d), - ) - .expect("listing kind must be addressable"); - Ok(Some(ListingIdentity { - event_id: event.id().clone(), - listing_kind, - address, - })) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ListingText { - title: String, - summary: Option<String>, - body: String, -} - -impl ListingText { - pub fn title(&self) -> &str { - &self.title - } - - pub fn summary(&self) -> Option<&str> { - self.summary.as_deref() - } - - pub fn body(&self) -> &str { - &self.body - } -} - -pub fn parse_listing_text(event: &Event) -> Result<Option<ListingText>, String> { - if listing_kind_for_event(event).is_none() { - return Ok(None); - } - let title = required_tag_value(event, "title")?; - if title.is_empty() { - return Err("listing title tag must not be empty".to_owned()); - } - let summary = optional_tag_value(event, "summary")?; - if summary.as_ref().is_some_and(String::is_empty) { - return Err("listing summary tag must not be empty".to_owned()); - } - Ok(Some(ListingText { - title, - summary, - body: event.unsigned().content().to_owned(), - })) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PriceAmount { - raw: String, -} - -impl PriceAmount { - pub fn raw(&self) -> &str { - &self.raw - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ListingPrice { - amount: PriceAmount, - currency: String, - display_currency: String, - frequency: Option<String>, -} - -impl ListingPrice { - pub fn amount(&self) -> &PriceAmount { - &self.amount - } - - pub fn currency(&self) -> &str { - &self.currency - } - - pub fn display_currency(&self) -> &str { - &self.display_currency - } - - pub fn frequency(&self) -> Option<&str> { - self.frequency.as_deref() - } -} - -pub fn parse_listing_price(event: &Event) -> Result<Option<ListingPrice>, String> { - if listing_kind_for_event(event).is_none() { - return Ok(None); - } - let values = required_tag_values(event, "price")?; - match values.len() { - 0 | 1 => Err("price tag must include amount and currency".to_owned()), - 2 | 3 => { - let amount = parse_price_amount(&values[0])?; - let currency = values[1].clone(); - if currency.is_empty() { - return Err("price currency must not be empty".to_owned()); - } - let frequency = values.get(2).cloned(); - if frequency.as_ref().is_some_and(String::is_empty) { - return Err("price frequency must not be empty".to_owned()); - } - Ok(Some(ListingPrice { - amount, - display_currency: currency.to_ascii_uppercase(), - currency, - frequency, - })) - } - _ => Err("price tag must not include more than amount currency and frequency".to_owned()), - } -} - -fn parse_price_amount(value: &str) -> Result<PriceAmount, String> { - if is_exact_unsigned_decimal(value) { - return Ok(PriceAmount { - raw: value.to_owned(), - }); - } - Err("price amount must be an exact unsigned decimal".to_owned()) -} - -fn is_exact_unsigned_decimal(value: &str) -> bool { - let mut parts = value.split('.'); - let whole = parts.next().unwrap_or_default(); - let fraction = parts.next(); - if parts.next().is_some() - || whole.is_empty() - || !whole.bytes().all(|byte| byte.is_ascii_digit()) - { - return false; - } - match fraction { - Some(value) => !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit()), - None => true, - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ListingUnit { - Lb, - Oz, - Each, - Bunch, - Dozen, - Kg, - G, - Share, - Pint, - Quart, - Box, - Crate, - Flat, -} - -impl ListingUnit { - pub fn canonical(self) -> &'static str { - match self { - Self::Lb => "lb", - Self::Oz => "oz", - Self::Each => "each", - Self::Bunch => "bunch", - Self::Dozen => "dozen", - Self::Kg => "kg", - Self::G => "g", - Self::Share => "share", - Self::Pint => "pint", - Self::Quart => "quart", - Self::Box => "box", - Self::Crate => "crate", - Self::Flat => "flat", - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ListingUnitTag { - raw: String, - unit: ListingUnit, -} - -impl ListingUnitTag { - pub fn raw(&self) -> &str { - &self.raw - } - - pub fn unit(&self) -> ListingUnit { - self.unit - } - - pub fn canonical(&self) -> &'static str { - self.unit.canonical() - } -} - -pub fn parse_listing_unit(event: &Event) -> Result<Option<ListingUnitTag>, String> { - if listing_kind_for_event(event).is_none() { - return Ok(None); - } - let raw = required_tag_value(event, "unit")?; - let normalized = raw.trim().to_ascii_lowercase(); - if normalized.is_empty() { - return Err("listing unit tag must not be empty".to_owned()); - } - let unit = parse_unit_value(&normalized) - .ok_or_else(|| format!("listing unit `{raw}` is unsupported"))?; - Ok(Some(ListingUnitTag { raw, unit })) -} - -fn parse_unit_value(value: &str) -> Option<ListingUnit> { - match value { - "lb" | "lbs" | "pound" | "pounds" => Some(ListingUnit::Lb), - "oz" | "ounce" | "ounces" => Some(ListingUnit::Oz), - "each" | "ea" => Some(ListingUnit::Each), - "bunch" | "bunches" => Some(ListingUnit::Bunch), - "dozen" => Some(ListingUnit::Dozen), - "kg" | "kilogram" | "kilograms" => Some(ListingUnit::Kg), - "g" | "gram" | "grams" => Some(ListingUnit::G), - "share" | "shares" => Some(ListingUnit::Share), - "pint" | "pints" => Some(ListingUnit::Pint), - "quart" | "quarts" => Some(ListingUnit::Quart), - "box" | "boxes" => Some(ListingUnit::Box), - "crate" | "crates" => Some(ListingUnit::Crate), - "flat" | "flats" => Some(ListingUnit::Flat), - _ => None, - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -pub enum FulfillmentMethod { - Pickup, - Delivery, - Shipping, -} - -impl FulfillmentMethod { - pub fn canonical(self) -> &'static str { - match self { - Self::Pickup => "pickup", - Self::Delivery => "delivery", - Self::Shipping => "shipping", - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ListingFulfillment { - methods: Vec<FulfillmentMethod>, -} - -impl ListingFulfillment { - pub fn methods(&self) -> &[FulfillmentMethod] { - &self.methods - } - - pub fn pickup_available(&self) -> bool { - self.methods.contains(&FulfillmentMethod::Pickup) - } - - pub fn delivery_available(&self) -> bool { - self.methods.contains(&FulfillmentMethod::Delivery) - } - - pub fn shipping_available(&self) -> bool { - self.methods.contains(&FulfillmentMethod::Shipping) - } - - pub fn delivery_only(&self) -> bool { - self.delivery_available() && !self.pickup_available() && !self.shipping_available() - } -} - -pub fn parse_listing_fulfillment(event: &Event) -> Result<Option<ListingFulfillment>, String> { - if listing_kind_for_event(event).is_none() { - return Ok(None); - } - let tags = matching_tags(event, "fulfillment"); - if tags.is_empty() { - return Err("tag `fulfillment` is required".to_owned()); - } - let mut methods = Vec::new(); - for tag in tags { - let values = tag.values(); - let raw = values - .first() - .ok_or_else(|| "tag `fulfillment` must include a value".to_owned())?; - if values.len() > 1 { - return Err("fulfillment tag must include exactly one method".to_owned()); - } - let normalized = raw.trim().to_ascii_lowercase(); - if normalized.is_empty() { - return Err("fulfillment tag method must not be empty".to_owned()); - } - let method = parse_fulfillment_method(&normalized) - .ok_or_else(|| format!("fulfillment method `{raw}` is unsupported"))?; - if !methods.contains(&method) { - methods.push(method); - } - } - methods.sort_unstable(); - Ok(Some(ListingFulfillment { methods })) -} - -fn parse_fulfillment_method(value: &str) -> Option<FulfillmentMethod> { - match value { - "pickup" => Some(FulfillmentMethod::Pickup), - "delivery" => Some(FulfillmentMethod::Delivery), - "shipping" => Some(FulfillmentMethod::Shipping), - _ => None, - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ListingEffectiveStatus { - Active, - Sold, - Draft, -} - -impl ListingEffectiveStatus { - pub fn canonical(self) -> &'static str { - match self { - Self::Active => "active", - Self::Sold => "sold", - Self::Draft => "draft", - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ListingStatus { - raw_status: Option<String>, - effective_status: ListingEffectiveStatus, -} - -impl ListingStatus { - pub fn raw_status(&self) -> Option<&str> { - self.raw_status.as_deref() - } - - pub fn effective_status(&self) -> ListingEffectiveStatus { - self.effective_status - } -} - -pub fn parse_listing_status(event: &Event) -> Result<Option<ListingStatus>, String> { - let Some(listing_kind) = listing_kind_for_event(event) else { - return Ok(None); - }; - let raw_status = optional_tag_value(event, "status")?; - let parsed_status = match raw_status.as_deref() { - Some("") => return Err("listing status tag must not be empty".to_owned()), - Some("active") => Some(ListingEffectiveStatus::Active), - Some("sold") => Some(ListingEffectiveStatus::Sold), - Some(value) => return Err(format!("listing status `{value}` is unsupported")), - None => None, - }; - let effective_status = match listing_kind { - ListingKind::Draft => ListingEffectiveStatus::Draft, - ListingKind::Public => parsed_status.unwrap_or(ListingEffectiveStatus::Active), - }; - Ok(Some(ListingStatus { - raw_status, - effective_status, - })) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ListingLocation { - location_text: Option<String>, - geohash: Option<String>, - geohash4: Option<String>, - geohash5: Option<String>, - geohash6: Option<String>, - geohash7: Option<String>, -} - -impl ListingLocation { - pub fn location_text(&self) -> Option<&str> { - self.location_text.as_deref() - } - - pub fn geohash(&self) -> Option<&str> { - self.geohash.as_deref() - } - - pub fn geohash4(&self) -> Option<&str> { - self.geohash4.as_deref() - } - - pub fn geohash5(&self) -> Option<&str> { - self.geohash5.as_deref() - } - - pub fn geohash6(&self) -> Option<&str> { - self.geohash6.as_deref() - } - - pub fn geohash7(&self) -> Option<&str> { - self.geohash7.as_deref() - } - - pub fn location_precision(&self) -> Option<usize> { - self.geohash.as_ref().map(String::len) - } -} - -pub fn parse_listing_location(event: &Event) -> Result<Option<ListingLocation>, String> { - if listing_kind_for_event(event).is_none() { - return Ok(None); - } - let location_text = optional_tag_value(event, "location")?; - if location_text.as_ref().is_some_and(String::is_empty) { - return Err("listing location tag must not be empty".to_owned()); - } - let geohash = optional_exact_tag_value(event, "g")? - .map(|value| parse_geohash(&value)) - .transpose()?; - Ok(Some(ListingLocation { - location_text, - geohash4: geohash_prefix(&geohash, 4), - geohash5: geohash_prefix(&geohash, 5), - geohash6: geohash_prefix(&geohash, 6), - geohash7: geohash_prefix(&geohash, 7), - geohash, - })) -} - -fn optional_exact_tag_value(event: &Event, name: &str) -> Result<Option<String>, String> { - let tags = matching_tags(event, name); - match tags.as_slice() { - [] => Ok(None), - [tag] => match tag.values() { - [] => Err(format!("tag `{name}` must include a value")), - [value] => Ok(Some(value.clone())), - _ => Err(format!("tag `{name}` must include exactly one value")), - }, - _ => Err(format!("tag `{name}` must not be repeated")), - } -} - -fn parse_geohash(value: &str) -> Result<String, String> { - let normalized = value.trim().to_ascii_lowercase(); - if normalized.len() < 4 - || normalized.len() > 12 - || !normalized - .bytes() - .all(|byte| b"0123456789bcdefghjkmnpqrstuvwxyz".contains(&byte)) - { - return Err("geohash must be 4 to 12 geohash characters".to_owned()); - } - Ok(normalized) -} - -fn geohash_prefix(geohash: &Option<String>, length: usize) -> Option<String> { - geohash - .as_ref() - .filter(|value| value.len() >= length) - .map(|value| value[..length].to_owned()) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ListingTaxonomy { - categories: Vec<String>, - topics: Vec<String>, - practices: Vec<String>, - certifications: Vec<String>, -} - -impl ListingTaxonomy { - pub fn categories(&self) -> &[String] { - &self.categories - } - - pub fn topics(&self) -> &[String] { - &self.topics - } - - pub fn practices(&self) -> &[String] { - &self.practices - } - - pub fn certifications(&self) -> &[String] { - &self.certifications - } -} - -pub fn parse_listing_taxonomy(event: &Event) -> Result<Option<ListingTaxonomy>, String> { - if listing_kind_for_event(event).is_none() { - return Ok(None); - } - Ok(Some(ListingTaxonomy { - categories: collect_taxonomy_values(event, "category")?, - topics: collect_taxonomy_values(event, "t")?, - practices: collect_taxonomy_values(event, "practice")?, - certifications: collect_taxonomy_values(event, "certification")?, - })) -} - -fn collect_taxonomy_values(event: &Event, name: &str) -> Result<Vec<String>, String> { - let mut values = Vec::new(); - for tag in matching_tags(event, name) { - match tag.values() { - [] => return Err(format!("tag `{name}` must include a value")), - [value] => { - let normalized = normalize_taxonomy_value(name, value)?; - values.push(normalized); - } - _ => return Err(format!("tag `{name}` must include exactly one value")), - } - } - values.sort(); - values.dedup(); - Ok(values) -} - -fn normalize_taxonomy_value(name: &str, value: &str) -> Result<String, String> { - let normalized = value.trim().to_ascii_lowercase(); - if normalized.is_empty() { - return Err(format!("listing taxonomy `{name}` value must not be empty")); - } - Ok(normalized) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ListingProjection { - identity: ListingIdentity, - text: ListingText, - price: ListingPrice, - unit: ListingUnitTag, - fulfillment: ListingFulfillment, - status: ListingStatus, - location: ListingLocation, - taxonomy: ListingTaxonomy, -} - -impl ListingProjection { - pub fn identity(&self) -> &ListingIdentity { - &self.identity - } - - pub fn text(&self) -> &ListingText { - &self.text - } - - pub fn price(&self) -> &ListingPrice { - &self.price - } - - pub fn unit(&self) -> &ListingUnitTag { - &self.unit - } - - pub fn fulfillment(&self) -> &ListingFulfillment { - &self.fulfillment - } - - pub fn status(&self) -> &ListingStatus { - &self.status - } - - pub fn location(&self) -> &ListingLocation { - &self.location - } - - pub fn taxonomy(&self) -> &ListingTaxonomy { - &self.taxonomy - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ListingProjectionRejection { - event_id: EventId, - reasons: Vec<String>, -} - -impl ListingProjectionRejection { - pub fn event_id(&self) -> &EventId { - &self.event_id - } - - pub fn reasons(&self) -> &[String] { - &self.reasons - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ListingProjectionEvaluation { - NotListing, - Eligible(Box<ListingProjection>), - Ineligible(ListingProjectionRejection), -} - -impl ListingProjectionEvaluation { - pub fn is_eligible(&self) -> bool { - matches!(self, Self::Eligible(_)) - } - - pub fn projection(&self) -> Option<&ListingProjection> { - match self { - Self::Eligible(projection) => Some(projection), - Self::NotListing | Self::Ineligible(_) => None, - } - } - - pub fn rejection(&self) -> Option<&ListingProjectionRejection> { - match self { - Self::Ineligible(rejection) => Some(rejection), - Self::NotListing | Self::Eligible(_) => None, - } - } -} - -pub fn evaluate_listing_projection(event: &Event) -> ListingProjectionEvaluation { - if listing_kind_for_event(event).is_none() { - return ListingProjectionEvaluation::NotListing; - } - let mut reasons = Vec::new(); - let identity = parse_listing_identity(event) - .map_err(|reason| reasons.push(reason)) - .ok() - .flatten(); - let text = parse_listing_text(event) - .map_err(|reason| reasons.push(reason)) - .ok() - .flatten(); - let price = parse_listing_price(event) - .map_err(|reason| reasons.push(reason)) - .ok() - .flatten(); - let unit = parse_listing_unit(event) - .map_err(|reason| reasons.push(reason)) - .ok() - .flatten(); - let fulfillment = parse_listing_fulfillment(event) - .map_err(|reason| reasons.push(reason)) - .ok() - .flatten(); - let status = parse_listing_status(event) - .map_err(|reason| reasons.push(reason)) - .ok() - .flatten(); - let location = parse_listing_location(event) - .map_err(|reason| reasons.push(reason)) - .ok() - .flatten(); - let taxonomy = parse_listing_taxonomy(event) - .map_err(|reason| reasons.push(reason)) - .ok() - .flatten(); - if identity - .as_ref() - .is_some_and(|identity| identity.listing_kind() == ListingKind::Draft) - { - reasons.push("draft listing is not public projection eligible".to_owned()); - } - match ( - identity, - text, - price, - unit, - fulfillment, - status, - location, - taxonomy, - ) { - ( - Some(identity), - Some(text), - Some(price), - Some(unit), - Some(fulfillment), - Some(status), - Some(location), - Some(taxonomy), - ) if reasons.is_empty() => { - ListingProjectionEvaluation::Eligible(Box::new(ListingProjection { - identity, - text, - price, - unit, - fulfillment, - status, - location, - taxonomy, - })) - } - _ => ListingProjectionEvaluation::Ineligible(ListingProjectionRejection { - event_id: event.id().clone(), - reasons, - }), - } -} - -fn listing_kind_for_event(event: &Event) -> Option<ListingKind> { - match event.unsigned().kind().as_u32() { - NIP99_PUBLIC_LISTING_KIND => Some(ListingKind::Public), - NIP99_DRAFT_LISTING_KIND => Some(ListingKind::Draft), - _ => None, - } -} - -#[cfg(test)] -mod tests { - use super::{ - CommentTarget, DeletionTarget, FulfillmentMethod, LabelTarget, ListingEffectiveStatus, - ListingKind, ListingProjectionEvaluation, ListingUnit, LongFormKind, NIP01_METADATA_KIND, - NIP7D_THREAD_KIND, NIP22_COMMENT_KIND, NIP23_LONG_FORM_DRAFT_KIND, NIP23_LONG_FORM_KIND, - NIP25_REACTION_KIND, NIP32_LABEL_KIND, NIP56_REPORT_KIND, NIP99_PUBLIC_LISTING_KIND, - ParsedTag, ReactionValue, ReportTarget, ReportType, evaluate_listing_projection, - matching_tags, optional_tag_value, optional_tag_values, parse_comment_event, - parse_deletion_request, parse_forum_thread_event, parse_label_event, - parse_listing_fulfillment, parse_listing_identity, parse_listing_location, - parse_listing_price, parse_listing_status, parse_listing_taxonomy, parse_listing_text, - parse_listing_unit, parse_long_form_event, parse_nip50_filter_search, parse_nip50_search, - parse_reaction_event, parse_relay_auth_event, parse_report_event, parse_required_u64_tag, - parse_seller_profile_event, parse_u64_field, repeated_or_missing_policy_boundary, - required_tag_value, required_tag_values, single_letter_tag_values, - single_letter_values_for, tag_count, - }; - use tangle_protocol::{ - Event, EventId, Kind, PublicKeyHex, SignatureHex, Tag, UnixTimestamp, UnsignedEvent, - filter_from_value, - }; - - #[test] - fn shared_parser_utilities_extract_matching_tags() { - let event = event_with_tags(vec![ - Tag::from_parts("d", &["listing-a"]).expect("d"), - Tag::from_parts("title", &["Carrots"]).expect("title"), - Tag::from_parts("price", &["12.50", "USD"]).expect("price"), - ]); - let price = matching_tags(&event, "price"); - - assert_eq!(tag_count(&event, "d"), 1); - assert_eq!(tag_count(&event, "missing"), 0); - assert_eq!(price[0].name(), "price"); - assert_eq!(price[0].first_value(), Some("12.50")); - assert_eq!(price[0].values(), &["12.50".to_owned(), "USD".to_owned()]); - assert_eq!( - optional_tag_value(&event, "d"), - Ok(Some("listing-a".to_owned())) - ); - assert_eq!(optional_tag_value(&event, "missing"), Ok(None)); - assert_eq!( - required_tag_value(&event, "title"), - Ok("Carrots".to_owned()) - ); - assert_eq!( - optional_tag_values(&event, "price"), - Ok(Some(vec!["12.50".to_owned(), "USD".to_owned()])) - ); - assert_eq!( - required_tag_values(&event, "price"), - Ok(vec!["12.50".to_owned(), "USD".to_owned()]) - ); - } - - #[test] - fn shared_parser_utilities_reject_missing_repeated_and_malformed_values() { - let repeated = event_with_tags(vec![ - Tag::from_parts("d", &["one"]).expect("d"), - Tag::from_parts("d", &["two"]).expect("d"), - ]); - let missing_value = event_with_tags(vec![Tag::from_parts("d", &[]).expect("d")]); - let missing = event_with_tags(Vec::new()); - - assert_eq!( - optional_tag_value(&repeated, "d").expect_err("repeated"), - "tag `d` must not be repeated" - ); - assert_eq!( - optional_tag_values(&repeated, "d").expect_err("repeated values"), - "tag `d` must not be repeated" - ); - assert_eq!( - optional_tag_value(&missing_value, "d").expect_err("value"), - "tag `d` must include a value" - ); - assert_eq!( - required_tag_value(&missing, "d").expect_err("missing"), - "tag `d` is required" - ); - assert_eq!( - required_tag_values(&missing, "d").expect_err("missing values"), - "tag `d` is required" - ); - assert_eq!( - parse_u64_field("published_at", "now").expect_err("number"), - "field `published_at` must be an unsigned integer" - ); - } - - #[test] - fn shared_parser_utilities_parse_numeric_tags_and_policy_boundaries() { - let event = event_with_tags(vec![Tag::from_parts("published_at", &["42"]).expect("tag")]); - let missing = event_with_tags(Vec::new()); - - assert_eq!(parse_u64_field("published_at", "42"), Ok(42)); - assert_eq!(parse_required_u64_tag(&event, "published_at"), Ok(42)); - assert_eq!(repeated_or_missing_policy_boundary(&missing, "d"), Ok(None)); - } - - #[test] - fn single_letter_tag_extraction_indexes_first_values_only() { - let event = event_with_tags(vec![ - Tag::from_parts("e", &["root", "relay"]).expect("e"), - Tag::from_parts("p", &["peer"]).expect("p"), - Tag::from_parts("E", &["uppercase-root"]).expect("E"), - Tag::from_parts("t", &["carrots"]).expect("t"), - Tag::from_parts("alt", &["not indexed"]).expect("alt"), - Tag::from_parts("1", &["not indexed"]).expect("number"), - Tag::from_parts("g", &[]).expect("missing value"), - ]); - let values = single_letter_tag_values(&event); - - assert_eq!(values.len(), 4); - assert_eq!(values[0].name(), "e"); - assert_eq!(values[0].value(), "root"); - assert_eq!(values[2].name(), "E"); - assert_eq!( - single_letter_values_for(&event, "e"), - Ok(vec!["root".to_owned()]) - ); - assert_eq!( - single_letter_values_for(&event, "E"), - Ok(vec!["uppercase-root".to_owned()]) - ); - assert_eq!(single_letter_values_for(&event, "g"), Ok(Vec::new())); - } - - #[test] - fn single_letter_tag_extraction_handles_repeated_missing_and_malformed_names() { - let repeated = event_with_tags(vec![ - Tag::from_parts("t", &["carrots"]).expect("t"), - Tag::from_parts("t", &["greens"]).expect("t"), - ]); - let missing = event_with_tags(Vec::new()); - - assert_eq!( - single_letter_values_for(&repeated, "t"), - Ok(vec!["carrots".to_owned(), "greens".to_owned()]) - ); - assert_eq!( - super::first_single_letter_value(&repeated, "t"), - Ok(Some("carrots".to_owned())) - ); - assert_eq!(super::first_single_letter_value(&missing, "t"), Ok(None)); - assert_eq!( - single_letter_values_for(&missing, "topic").expect_err("long"), - "single-letter tag name `topic` must be one ASCII letter" - ); - assert_eq!( - single_letter_values_for(&missing, "é").expect_err("non ascii"), - "single-letter tag name `é` must be one ASCII letter" - ); - } - - #[test] - fn comment_parser_extracts_root_parent_authors_and_references() { - let root_pubkey = "2".repeat(PublicKeyHex::HEX_LENGTH); - let parent_pubkey = "3".repeat(PublicKeyHex::HEX_LENGTH); - let comment_event = "4".repeat(EventId::HEX_LENGTH); - let mentioned_pubkey = "5".repeat(PublicKeyHex::HEX_LENGTH); - let address = format!("30023:{root_pubkey}:article-a"); - let event = event_with_kind_tags_and_content( - NIP22_COMMENT_KIND.into(), - vec![ - Tag::from_parts("A", &[&address, "wss://relay.radroots.test"]).expect("A"), - Tag::from_parts("K", &["30023"]).expect("K"), - Tag::from_parts("P", &[&root_pubkey]).expect("P"), - Tag::from_parts( - "e", - &[&comment_event, "wss://relay.radroots.test", &parent_pubkey], - ) - .expect("e"), - Tag::from_parts("k", &["1111"]).expect("k"), - Tag::from_parts("p", &[&parent_pubkey]).expect("p"), - Tag::from_parts("p", &[&mentioned_pubkey]).expect("mention"), - Tag::from_parts("q", &[&comment_event]).expect("q"), - ], - "That harvest note helped.", - ); - - let comment = parse_comment_event(&event) - .expect("parse") - .expect("comment"); - - assert_eq!(comment.event_id(), event.id()); - assert_eq!(comment.pubkey(), event.unsigned().pubkey()); - assert_eq!(comment.created_at(), event.unsigned().created_at()); - assert_eq!(comment.content(), "That harvest note helped."); - assert_eq!(comment.root().kind(), "30023"); - assert_eq!( - comment.root().author().expect("root author").as_str(), - root_pubkey - ); - assert_eq!(comment.parent().kind(), "1111"); - assert_eq!( - comment.parent().author().expect("parent author").as_str(), - parent_pubkey - ); - assert_eq!(comment.cited_events(), std::slice::from_ref(&comment_event)); - assert_eq!(comment.mentioned_pubkeys()[0].as_str(), parent_pubkey); - assert_eq!(comment.mentioned_pubkeys()[1].as_str(), mentioned_pubkey); - assert!(matches!( - comment.root().target(), - CommentTarget::Address { .. } - )); - assert_eq!(comment.root().target().target_type(), "address"); - assert_eq!(comment.root().target().target_ref(), address); - assert_eq!( - comment.root().target().relay_hint(), - Some("wss://relay.radroots.test") - ); - assert!(matches!( - comment.parent().target(), - CommentTarget::Event { .. } - )); - assert_eq!(comment.parent().target().target_type(), "event"); - assert_eq!(comment.parent().target().target_ref(), comment_event); - assert_eq!( - comment.parent().target().relay_hint(), - Some("wss://relay.radroots.test") - ); - } - - #[test] - fn comment_parser_extracts_external_scope_and_ignores_other_kinds() { - let event = event_with_kind_and_tags( - NIP22_COMMENT_KIND.into(), - vec![ - Tag::from_parts("I", &["https://radroots.test/posts/harvest"]).expect("I"), - Tag::from_parts("K", &["web"]).expect("K"), - Tag::from_parts("i", &["https://radroots.test/posts/harvest"]).expect("i"), - Tag::from_parts("k", &["web"]).expect("k"), - ], - ); - let note = event_with_kind_and_tags(1, Vec::new()); - - let comment = parse_comment_event(&event) - .expect("parse") - .expect("comment"); - - assert_eq!(parse_comment_event(&note), Ok(None)); - assert!(matches!( - comment.root().target(), - CommentTarget::External { .. } - )); - assert_eq!(comment.root().target().target_type(), "external"); - assert_eq!( - comment.root().target().target_ref(), - "https://radroots.test/posts/harvest" - ); - assert_eq!(comment.root().target().relay_hint(), None); - } - - #[test] - fn comment_parser_rejects_missing_repeated_empty_and_kind_one_targets() { - let target = "2".repeat(EventId::HEX_LENGTH); - let valid = vec![ - Tag::from_parts("E", &[&target]).expect("E"), - Tag::from_parts("K", &["30023"]).expect("K"), - Tag::from_parts("e", &[&target]).expect("e"), - Tag::from_parts("k", &["30023"]).expect("k"), - ]; - let missing_root = event_with_kind_tags_and_content( - NIP22_COMMENT_KIND.into(), - valid - .iter() - .filter(|tag| tag.name().as_str() != "E") - .cloned() - .collect(), - "", - ); - let repeated_root = event_with_kind_tags_and_content( - NIP22_COMMENT_KIND.into(), - [valid.clone(), vec![Tag::from_parts("A", &["30023:1111111111111111111111111111111111111111111111111111111111111111:article"]).expect("A")]].concat(), - "", - ); - let empty_parent = event_with_kind_tags_and_content( - NIP22_COMMENT_KIND.into(), - vec![ - Tag::from_parts("E", &[&target]).expect("E"), - Tag::from_parts("K", &["30023"]).expect("K"), - Tag::from_parts("e", &[""]).expect("e"), - Tag::from_parts("k", &["30023"]).expect("k"), - ], - "", - ); - let kind_one = event_with_kind_tags_and_content( - NIP22_COMMENT_KIND.into(), - vec![ - Tag::from_parts("E", &[&target]).expect("E"), - Tag::from_parts("K", &["1"]).expect("K"), - Tag::from_parts("e", &[&target]).expect("e"), - Tag::from_parts("k", &["30023"]).expect("k"), - ], - "", - ); - - assert_eq!( - parse_comment_event(&missing_root).expect_err("missing"), - "comment root target tag is required" - ); - assert_eq!( - parse_comment_event(&repeated_root).expect_err("repeated"), - "comment root target tag must not be repeated" - ); - assert_eq!( - parse_comment_event(&empty_parent).expect_err("empty"), - "comment parent target tag `e` must not be empty" - ); - assert_eq!( - parse_comment_event(&kind_one).expect_err("kind one"), - "NIP-22 comments must not reply to kind 1 notes" - ); - } - - #[test] - fn comment_parser_rejects_empty_kinds_authors_and_malformed_targets() { - let event_id = "2".repeat(EventId::HEX_LENGTH); - let pubkey = "3".repeat(PublicKeyHex::HEX_LENGTH); - let address = format!("30023:{pubkey}:article-a"); - let empty_root_kind = event_with_kind_and_tags( - NIP22_COMMENT_KIND.into(), - vec![ - Tag::from_parts("E", &[&event_id]).expect("E"), - Tag::from_parts("K", &[""]).expect("K"), - Tag::from_parts("e", &[&event_id]).expect("e"), - Tag::from_parts("k", &["30023"]).expect("k"), - ], - ); - let empty_parent_kind = event_with_kind_and_tags( - NIP22_COMMENT_KIND.into(), - vec![ - Tag::from_parts("E", &[&event_id]).expect("E"), - Tag::from_parts("K", &["30023"]).expect("K"), - Tag::from_parts("e", &[&event_id]).expect("e"), - Tag::from_parts("k", &[""]).expect("k"), - ], - ); - let repeated_root_author = event_with_kind_and_tags( - NIP22_COMMENT_KIND.into(), - vec![ - Tag::from_parts("E", &[&event_id]).expect("E"), - Tag::from_parts("K", &["30023"]).expect("K"), - Tag::from_parts("P", &[&pubkey]).expect("P"), - Tag::from_parts("P", &[&pubkey]).expect("P2"), - Tag::from_parts("e", &[&event_id]).expect("e"), - Tag::from_parts("k", &["30023"]).expect("k"), - ], - ); - let empty_relay = event_with_kind_and_tags( - NIP22_COMMENT_KIND.into(), - vec![ - Tag::from_parts("E", &[&event_id, ""]).expect("E"), - Tag::from_parts("K", &["30023"]).expect("K"), - Tag::from_parts("e", &[&event_id]).expect("e"), - Tag::from_parts("k", &["30023"]).expect("k"), - ], - ); - - assert_eq!( - parse_comment_event(&empty_root_kind).expect_err("root kind"), - "comment root kind tag must not be empty" - ); - assert_eq!( - parse_comment_event(&empty_parent_kind).expect_err("parent kind"), - "comment parent kind tag must not be empty" - ); - assert_eq!( - parse_comment_event(&repeated_root_author).expect_err("root author"), - "comment root author tag `P` must not be repeated" - ); - assert_eq!( - parse_comment_event(&empty_relay).expect_err("relay"), - "comment root target relay hint must not be empty" - ); - assert_eq!( - super::parse_comment_target_tag( - "E", - &ParsedTag { - name: "E".to_owned(), - values: vec![ - event_id.clone(), - "relay".to_owned(), - pubkey.clone(), - "extra".to_owned(), - ], - }, - "root", - ) - .expect_err("event target"), - "comment root event target tag `E` must include at most event relay and pubkey values" - ); - assert_eq!( - super::parse_comment_target_tag( - "A", - &ParsedTag { - name: "A".to_owned(), - values: vec![address, "relay".to_owned(), "extra".to_owned()], - }, - "root", - ) - .expect_err("address target"), - "comment root address target tag `A` must include at most address and relay values" - ); - assert_eq!( - super::parse_comment_target_tag( - "I", - &ParsedTag { - name: "I".to_owned(), - values: vec![ - "https://radroots.test/post".to_owned(), - "relay".to_owned(), - "extra".to_owned(), - ], - }, - "root", - ) - .expect_err("external target"), - "comment root external target tag `I` must include at most identity and relay values" - ); - assert_eq!( - super::parse_comment_target_tag( - "x", - &ParsedTag { - name: "x".to_owned(), - values: vec!["value".to_owned()], - }, - "root", - ) - .expect_err("unsupported target"), - "comment root target tag `x` is unsupported" - ); - } - - #[test] - fn reaction_parser_extracts_addressable_target_and_like_reaction() { - let target_event = "2".repeat(EventId::HEX_LENGTH); - let previous_event = "3".repeat(EventId::HEX_LENGTH); - let target_pubkey = "4".repeat(PublicKeyHex::HEX_LENGTH); - let address = format!("30023:{target_pubkey}:article-a"); - let event = event_with_kind_tags_and_content( - NIP25_REACTION_KIND.into(), - vec![ - Tag::from_parts("e", &[&previous_event]).expect("old e"), - Tag::from_parts( - "e", - &[&target_event, "wss://relay.radroots.test", &target_pubkey], - ) - .expect("e"), - Tag::from_parts("p", &[&target_pubkey]).expect("p"), - Tag::from_parts("a", &[&address]).expect("a"), - Tag::from_parts("k", &["30023"]).expect("k"), - ], - "+", - ); - - let reaction = parse_reaction_event(&event) - .expect("parse") - .expect("reaction"); - - assert_eq!(reaction.event_id(), event.id()); - assert_eq!(reaction.pubkey(), event.unsigned().pubkey()); - assert_eq!(reaction.created_at(), event.unsigned().created_at()); - assert_eq!(reaction.content(), "+"); - assert_eq!(reaction.value(), &ReactionValue::Like); - assert_eq!(reaction.value().canonical(), "like"); - assert_eq!(reaction.target_event_id().as_str(), target_event); - assert_eq!( - reaction.target_relay_hint(), - Some("wss://relay.radroots.test") - ); - assert_eq!( - reaction.target_pubkey_hint().expect("pubkey hint").as_str(), - target_pubkey - ); - assert_eq!( - reaction.target_pubkey().expect("target pubkey").as_str(), - target_pubkey - ); - assert_eq!( - reaction - .target_address() - .expect("target address") - .key() - .to_string(), - address - ); - assert_eq!(reaction.target_kind(), Some("30023")); - } - - #[test] - fn reaction_parser_classifies_empty_dislike_emoji_and_text_reactions() { - let target_event = "2".repeat(EventId::HEX_LENGTH); - let cases = [ - ("", ReactionValue::Like), - ("-", ReactionValue::Dislike), - ("⭐", ReactionValue::Emoji("⭐".to_owned())), - ("agree", ReactionValue::Text("agree".to_owned())), - ]; - for (content, expected) in cases { - let event = event_with_kind_tags_and_content( - NIP25_REACTION_KIND.into(), - vec![Tag::from_parts("e", &[&target_event]).expect("e")], - content, - ); - let reaction = parse_reaction_event(&event) - .expect("parse") - .expect("reaction"); - - assert_eq!(reaction.value(), &expected); - assert_eq!(reaction.value().canonical(), expected.canonical()); - } - let note = event_with_kind_and_tags(1, Vec::new()); - assert_eq!(parse_reaction_event(&note), Ok(None)); - } - - #[test] - fn reaction_parser_rejects_missing_and_malformed_targets() { - let bad_event_id = event_with_kind_tags_and_content( - NIP25_REACTION_KIND.into(), - vec![Tag::from_parts("e", &["bad"]).expect("e")], - "+", - ); - let missing_event_id = event_with_kind_tags_and_content( - NIP25_REACTION_KIND.into(), - vec![Tag::from_parts("e", &[]).expect("e")], - "+", - ); - let missing_target = event_with_kind_and_tags(NIP25_REACTION_KIND.into(), Vec::new()); - let empty_kind = event_with_kind_tags_and_content( - NIP25_REACTION_KIND.into(), - vec![ - Tag::from_parts("e", &[&"2".repeat(EventId::HEX_LENGTH)]).expect("e"), - Tag::from_parts("k", &[""]).expect("k"), - ], - "+", - ); - - assert_eq!( - parse_reaction_event(&missing_target).expect_err("missing target"), - "reaction event must include an e target tag" - ); - assert!( - parse_reaction_event(&bad_event_id) - .expect_err("bad event id") - .contains("event id") - ); - assert_eq!( - parse_reaction_event(&missing_event_id).expect_err("missing event id"), - "reaction e target tag must include an event id" - ); - assert_eq!( - parse_reaction_event(&empty_kind).expect_err("empty kind"), - "reaction k tag must not be empty" - ); - } - - #[test] - fn long_form_parser_extracts_metadata_topics_and_references() { - let referenced_event = "2".repeat(EventId::HEX_LENGTH); - let referenced_pubkey = "3".repeat(PublicKeyHex::HEX_LENGTH); - let referenced_address = format!("30023:{referenced_pubkey}:soil-notes"); - let event = event_with_kind_tags_and_content( - NIP23_LONG_FORM_KIND.into(), - vec![ - Tag::from_parts("d", &["harvest-notes"]).expect("d"), - Tag::from_parts("title", &["Harvest notes"]).expect("title"), - Tag::from_parts("summary", &["What changed this week."]).expect("summary"), - Tag::from_parts("image", &["https://radroots.test/harvest.jpg"]).expect("image"), - Tag::from_parts("published_at", &["1714124400"]).expect("published_at"), - Tag::from_parts("t", &["Carrots"]).expect("topic"), - Tag::from_parts("t", &[" carrots "]).expect("topic duplicate"), - Tag::from_parts("t", &["CSA"]).expect("topic csa"), - Tag::from_parts("e", &[&referenced_event]).expect("e"), - Tag::from_parts("a", &[&referenced_address]).expect("a"), - Tag::from_parts("p", &[&referenced_pubkey]).expect("p"), - ], - "## Harvest notes\n\nThe storage carrots held well.", - ); - - let article = parse_long_form_event(&event) - .expect("parse") - .expect("article"); - - assert_eq!(article.event_id(), event.id()); - assert_eq!(article.pubkey(), event.unsigned().pubkey()); - assert_eq!(article.created_at(), event.unsigned().created_at()); - assert_eq!( - article.content(), - "## Harvest notes\n\nThe storage carrots held well." - ); - assert_eq!(article.long_form_kind(), LongFormKind::Published); - assert_eq!(article.long_form_kind().canonical(), "published"); - assert_eq!( - article.address().key().to_string(), - format!("30023:{}:harvest-notes", event.unsigned().pubkey()) - ); - assert_eq!(article.d().as_str(), "harvest-notes"); - assert_eq!(article.title(), Some("Harvest notes")); - assert_eq!(article.summary(), Some("What changed this week.")); - assert_eq!(article.image(), Some("https://radroots.test/harvest.jpg")); - assert_eq!(article.published_at(), Some(1_714_124_400)); - assert_eq!(article.topics(), &["carrots".to_owned(), "csa".to_owned()]); - assert_eq!(article.referenced_events()[0].as_str(), referenced_event); - assert_eq!( - article.referenced_addresses()[0].key().to_string(), - referenced_address - ); - assert_eq!(article.referenced_pubkeys()[0].as_str(), referenced_pubkey); - } - - #[test] - fn long_form_parser_extracts_drafts_and_ignores_other_kinds() { - let draft = event_with_kind_tags_and_content( - NIP23_LONG_FORM_DRAFT_KIND.into(), - vec![Tag::from_parts("d", &["draft-a"]).expect("d")], - "Draft body.", - ); - let note = event_with_kind_and_tags(1, vec![Tag::from_parts("d", &["note"]).expect("d")]); - - let article = parse_long_form_event(&draft) - .expect("parse") - .expect("draft"); - - assert_eq!(article.long_form_kind(), LongFormKind::Draft); - assert_eq!(article.long_form_kind().canonical(), "draft"); - assert_eq!(article.title(), None); - assert_eq!(article.published_at(), None); - assert_eq!(article.topics(), &[] as &[String]); - assert_eq!(parse_long_form_event(&note), Ok(None)); - } - - #[test] - fn long_form_parser_rejects_malformed_metadata_and_references() { - let repeated_d = event_with_kind_and_tags( - NIP23_LONG_FORM_KIND.into(), - vec![ - Tag::from_parts("d", &["one"]).expect("d"), - Tag::from_parts("d", &["two"]).expect("d"), - ], - ); - let missing_d = event_with_kind_and_tags(NIP23_LONG_FORM_KIND.into(), Vec::new()); - let empty_d = event_with_kind_and_tags( - NIP23_LONG_FORM_KIND.into(), - vec![Tag::from_parts("d", &[""]).expect("d")], - ); - let empty_title = event_with_kind_and_tags( - NIP23_LONG_FORM_KIND.into(), - vec![ - Tag::from_parts("d", &["article"]).expect("d"), - Tag::from_parts("title", &[""]).expect("title"), - ], - ); - let bad_published_at = event_with_kind_and_tags( - NIP23_LONG_FORM_KIND.into(), - vec![ - Tag::from_parts("d", &["article"]).expect("d"), - Tag::from_parts("published_at", &["now"]).expect("published_at"), - ], - ); - let empty_topic = event_with_kind_and_tags( - NIP23_LONG_FORM_KIND.into(), - vec![ - Tag::from_parts("d", &["article"]).expect("d"), - Tag::from_parts("t", &[" "]).expect("topic"), - ], - ); - let bad_reference = event_with_kind_and_tags( - NIP23_LONG_FORM_KIND.into(), - vec![ - Tag::from_parts("d", &["article"]).expect("d"), - Tag::from_parts("e", &["bad"]).expect("e"), - ], - ); - - assert_eq!( - parse_long_form_event(&repeated_d).expect_err("repeated"), - "tag `d` must not be repeated" - ); - assert_eq!( - parse_long_form_event(&missing_d).expect_err("missing"), - "tag `d` is required" - ); - assert_eq!( - parse_long_form_event(&empty_d).expect_err("empty d"), - "long-form d tag must not be empty" - ); - assert_eq!( - parse_long_form_event(&empty_title).expect_err("empty title"), - "long-form title tag must not be empty" - ); - assert_eq!( - parse_long_form_event(&bad_published_at).expect_err("published_at"), - "field `published_at` must be an unsigned integer" - ); - assert_eq!( - parse_long_form_event(&empty_topic).expect_err("empty topic"), - "long-form topic value must not be empty" - ); - assert_eq!( - parse_long_form_event(&bad_reference).expect_err("bad reference"), - "event id must be 64 characters, got 3" - ); - } - - #[test] - fn forum_thread_parser_extracts_title_topics_and_references() { - let referenced_event = "6".repeat(EventId::HEX_LENGTH); - let referenced_pubkey = "7".repeat(PublicKeyHex::HEX_LENGTH); - let event = event_with_kind_tags_and_content( - NIP7D_THREAD_KIND.into(), - vec![ - Tag::from_parts("title", &["Market day thread"]).expect("title"), - Tag::from_parts("t", &["Market"]).expect("topic"), - Tag::from_parts("t", &[" market "]).expect("topic duplicate"), - Tag::from_parts("t", &["Carrots"]).expect("topic carrots"), - Tag::from_parts("e", &[&referenced_event]).expect("e"), - Tag::from_parts("p", &[&referenced_pubkey]).expect("p"), - ], - "What is everyone bringing this weekend?", - ); - - let thread = parse_forum_thread_event(&event) - .expect("parse") - .expect("thread"); - - assert_eq!(thread.event_id(), event.id()); - assert_eq!(thread.pubkey(), event.unsigned().pubkey()); - assert_eq!(thread.created_at(), event.unsigned().created_at()); - assert_eq!(thread.title(), Some("Market day thread")); - assert_eq!(thread.content(), "What is everyone bringing this weekend?"); - assert_eq!( - thread.topics(), - &["carrots".to_owned(), "market".to_owned()] - ); - assert_eq!(thread.referenced_events()[0].as_str(), referenced_event); - assert_eq!(thread.referenced_pubkeys()[0].as_str(), referenced_pubkey); - } - - #[test] - fn forum_thread_parser_allows_missing_title_and_ignores_other_kinds() { - let thread = event_with_kind_tags_and_content( - NIP7D_THREAD_KIND.into(), - vec![Tag::from_parts("t", &["market"]).expect("topic")], - "Open thread.", - ); - let note = event_with_kind_and_tags(1, vec![Tag::from_parts("title", &["GM"]).expect("t")]); - - let thread = parse_forum_thread_event(&thread) - .expect("parse") - .expect("thread"); - - assert_eq!(thread.title(), None); - assert_eq!(thread.topics(), &["market".to_owned()]); - assert_eq!(parse_forum_thread_event(&note), Ok(None)); - } - - #[test] - fn forum_thread_parser_rejects_malformed_title_topics_and_references() { - let empty_title = event_with_kind_and_tags( - NIP7D_THREAD_KIND.into(), - vec![Tag::from_parts("title", &[""]).expect("title")], - ); - let repeated_title = event_with_kind_and_tags( - NIP7D_THREAD_KIND.into(), - vec![ - Tag::from_parts("title", &["one"]).expect("title"), - Tag::from_parts("title", &["two"]).expect("title"), - ], - ); - let empty_topic = event_with_kind_and_tags( - NIP7D_THREAD_KIND.into(), - vec![Tag::from_parts("t", &[" "]).expect("topic")], - ); - let extra_topic = event_with_kind_and_tags( - NIP7D_THREAD_KIND.into(), - vec![Tag::from_parts("t", &["market", "extra"]).expect("topic")], - ); - let bad_event_reference = event_with_kind_and_tags( - NIP7D_THREAD_KIND.into(), - vec![Tag::from_parts("e", &["bad"]).expect("e")], - ); - let bad_pubkey_reference = event_with_kind_and_tags( - NIP7D_THREAD_KIND.into(), - vec![Tag::from_parts("p", &["bad"]).expect("p")], - ); - - assert_eq!( - parse_forum_thread_event(&empty_title).expect_err("empty title"), - "forum thread title tag must not be empty" - ); - assert_eq!( - parse_forum_thread_event(&repeated_title).expect_err("repeated title"), - "tag `title` must not be repeated" - ); - assert_eq!( - parse_forum_thread_event(&empty_topic).expect_err("empty topic"), - "forum thread topic value must not be empty" - ); - assert_eq!( - parse_forum_thread_event(&extra_topic).expect_err("extra topic"), - "forum thread topic tag must include exactly one value" - ); - assert_eq!( - parse_forum_thread_event(&bad_event_reference).expect_err("bad event"), - "event id must be 64 characters, got 3" - ); - assert_eq!( - parse_forum_thread_event(&bad_pubkey_reference).expect_err("bad pubkey"), - "forum thread reference pubkey is invalid: public key must be 64 characters, got 3" - ); - } - - #[test] - fn report_parser_extracts_pubkey_event_blob_targets_and_servers() { - let reported_pubkey = "7".repeat(PublicKeyHex::HEX_LENGTH); - let reported_event = "8".repeat(EventId::HEX_LENGTH); - let blob_hash = "9".repeat(64); - let event = event_with_kind_tags_and_content( - NIP56_REPORT_KIND.into(), - vec![ - Tag::from_parts("p", &[&reported_pubkey, "spam"]).expect("p"), - Tag::from_parts("e", &[&reported_event, "illegal"]).expect("e"), - Tag::from_parts("x", &[&blob_hash, "malware"]).expect("x"), - Tag::from_parts("server", &["https://media.radroots.test/blob.jpg"]) - .expect("server"), - ], - "moderator report", - ); - - let report = parse_report_event(&event).expect("parse").expect("report"); - - assert_eq!(report.event_id(), event.id()); - assert_eq!(report.pubkey(), event.unsigned().pubkey()); - assert_eq!(report.created_at(), event.unsigned().created_at()); - assert_eq!(report.content(), "moderator report"); - assert_eq!(report.reported_pubkeys()[0].as_str(), reported_pubkey); - assert_eq!( - report.server_urls(), - &["https://media.radroots.test/blob.jpg".to_owned()] - ); - assert_eq!(report.targets().len(), 3); - assert_eq!(report.targets()[0].target_type(), "pubkey"); - assert_eq!(report.targets()[0].target_ref(), reported_pubkey); - assert_eq!(report.targets()[0].report_type(), ReportType::Spam); - assert_eq!(ReportType::Spam.canonical(), "spam"); - assert_eq!(report.targets()[1].target_type(), "event"); - assert_eq!(report.targets()[1].target_ref(), reported_event); - assert_eq!(report.targets()[1].report_type(), ReportType::Illegal); - assert_eq!(report.targets()[2].target_type(), "blob"); - assert_eq!(report.targets()[2].target_ref(), blob_hash); - assert_eq!( - [ - ReportType::Nudity.canonical(), - ReportType::Malware.canonical(), - ReportType::Profanity.canonical(), - ReportType::Illegal.canonical(), - ReportType::Spam.canonical(), - ReportType::Impersonation.canonical(), - ReportType::Other.canonical(), - ], - [ - "nudity", - "malware", - "profanity", - "illegal", - "spam", - "impersonation", - "other", - ] - ); - assert!( - matches!(&report.targets()[2], ReportTarget::Blob { hash, report_type } if hash == &blob_hash && *report_type == ReportType::Malware) - ); - } - - #[test] - fn report_parser_accepts_note_report_pubkey_context_and_ignores_other_kinds() { - let reported_pubkey = "7".repeat(PublicKeyHex::HEX_LENGTH); - let reported_event = "8".repeat(EventId::HEX_LENGTH); - let event = event_with_kind_tags_and_content( - NIP56_REPORT_KIND.into(), - vec![ - Tag::from_parts("p", &[&reported_pubkey]).expect("p"), - Tag::from_parts("e", &[&reported_event, "profanity"]).expect("e"), - ], - "note report", - ); - let note = event_with_kind_and_tags( - 1, - vec![Tag::from_parts("p", &[&reported_pubkey, "spam"]).expect("p")], - ); - - let report = parse_report_event(&event).expect("parse").expect("report"); - - assert_eq!(report.reported_pubkeys()[0].as_str(), reported_pubkey); - assert_eq!(report.targets().len(), 1); - assert_eq!(report.targets()[0].target_type(), "event"); - assert_eq!(report.targets()[0].target_ref(), reported_event); - assert_eq!(report.targets()[0].report_type(), ReportType::Profanity); - assert_eq!(parse_report_event(&note), Ok(None)); - } - - #[test] - fn report_parser_rejects_missing_context_type_and_bad_tags() { - let reported_pubkey = "7".repeat(PublicKeyHex::HEX_LENGTH); - let reported_event = "8".repeat(EventId::HEX_LENGTH); - let blob_hash = "9".repeat(64); - let missing_pubkey = event_with_kind_and_tags( - NIP56_REPORT_KIND.into(), - vec![Tag::from_parts("e", &[&reported_event, "spam"]).expect("e")], - ); - let missing_report_type = event_with_kind_and_tags( - NIP56_REPORT_KIND.into(), - vec![Tag::from_parts("p", &[&reported_pubkey]).expect("p")], - ); - let unsupported_report_type = event_with_kind_and_tags( - NIP56_REPORT_KIND.into(), - vec![Tag::from_parts("p", &[&reported_pubkey, "scam"]).expect("p")], - ); - let x_without_event = event_with_kind_and_tags( - NIP56_REPORT_KIND.into(), - vec![ - Tag::from_parts("p", &[&reported_pubkey]).expect("p"), - Tag::from_parts("x", &[&blob_hash, "malware"]).expect("x"), - ], - ); - let empty_x_hash = event_with_kind_and_tags( - NIP56_REPORT_KIND.into(), - vec![ - Tag::from_parts("p", &[&reported_pubkey]).expect("p"), - Tag::from_parts("e", &[&reported_event]).expect("e"), - Tag::from_parts("x", &["", "malware"]).expect("x"), - ], - ); - let missing_x_report_type = event_with_kind_and_tags( - NIP56_REPORT_KIND.into(), - vec![ - Tag::from_parts("p", &[&reported_pubkey]).expect("p"), - Tag::from_parts("e", &[&reported_event]).expect("e"), - Tag::from_parts("x", &[&blob_hash]).expect("x"), - ], - ); - let empty_report_type = event_with_kind_and_tags( - NIP56_REPORT_KIND.into(), - vec![Tag::from_parts("p", &[&reported_pubkey, ""]).expect("p")], - ); - let malformed_pubkey = event_with_kind_and_tags( - NIP56_REPORT_KIND.into(), - vec![Tag::from_parts("p", &["bad", "spam"]).expect("p")], - ); - let empty_server = event_with_kind_and_tags( - NIP56_REPORT_KIND.into(), - vec![ - Tag::from_parts("p", &[&reported_pubkey, "spam"]).expect("p"), - Tag::from_parts("server", &[""]).expect("server"), - ], - ); - - assert_eq!( - parse_report_event(&missing_pubkey).expect_err("missing p"), - "report event must include at least one p tag" - ); - assert_eq!( - parse_report_event(&missing_report_type).expect_err("missing type"), - "report event must include at least one p e or x tag with a report type" - ); - assert_eq!( - parse_report_event(&unsupported_report_type).expect_err("bad type"), - "report type `scam` is unsupported" - ); - assert_eq!( - parse_report_event(&x_without_event).expect_err("x context"), - "report x target requires an e tag context" - ); - assert_eq!( - parse_report_event(&empty_x_hash).expect_err("x hash"), - "report x hash must not be empty" - ); - assert_eq!( - parse_report_event(&missing_x_report_type).expect_err("x type"), - "report x tag must include a report type" - ); - assert_eq!( - parse_report_event(&empty_report_type).expect_err("empty type"), - "report type must not be empty" - ); - assert_eq!( - parse_report_event(&malformed_pubkey).expect_err("bad pubkey"), - "report p target pubkey is invalid: public key must be 64 characters, got 3" - ); - assert_eq!( - parse_report_event(&empty_server).expect_err("empty server"), - "report server tag must include exactly one non-empty URL" - ); - } - - #[test] - fn label_parser_extracts_namespaced_labels_and_targets() { - let event_id = "8".repeat(EventId::HEX_LENGTH); - let pubkey = "9".repeat(PublicKeyHex::HEX_LENGTH); - let address = format!("30023:{pubkey}:harvest-notes"); - let event = event_with_kind_tags_and_content( - NIP32_LABEL_KIND.into(), - vec![ - Tag::from_parts("L", &["com.radroots.moderation"]).expect("L"), - Tag::from_parts("l", &["approve", "com.radroots.moderation"]).expect("l"), - Tag::from_parts("e", &[&event_id, "wss://relay.radroots.test"]).expect("e"), - Tag::from_parts("p", &[&pubkey, "wss://relay.radroots.test"]).expect("p"), - Tag::from_parts("a", &[&address]).expect("a"), - Tag::from_parts("r", &["wss://relay.radroots.test"]).expect("r"), - Tag::from_parts("t", &["market"]).expect("t"), - ], - "moderator note", - ); - - let label = parse_label_event(&event).expect("parse").expect("label"); - - assert_eq!(label.event_id(), event.id()); - assert_eq!(label.pubkey(), event.unsigned().pubkey()); - assert_eq!(label.created_at(), event.unsigned().created_at()); - assert_eq!(label.content(), "moderator note"); - assert_eq!(label.namespaces(), &["com.radroots.moderation".to_owned()]); - assert_eq!(label.labels()[0].value(), "approve"); - assert_eq!(label.labels()[0].namespace(), "com.radroots.moderation"); - assert_eq!(label.targets().len(), 5); - assert_eq!(label.targets()[0].target_type(), "event"); - assert_eq!(label.targets()[0].target_ref(), event_id); - assert_eq!(label.targets()[1].target_type(), "pubkey"); - assert_eq!(label.targets()[1].target_ref(), pubkey); - assert_eq!(label.targets()[2].target_type(), "address"); - assert_eq!(label.targets()[2].target_ref(), address); - assert!( - matches!(&label.targets()[2], LabelTarget::Address(parsed) if parsed.key().to_string() == address) - ); - assert_eq!(label.targets()[3].target_type(), "relay"); - assert_eq!(label.targets()[3].target_ref(), "wss://relay.radroots.test"); - assert_eq!(label.targets()[4].target_type(), "topic"); - assert_eq!(label.targets()[4].target_ref(), "market"); - } - - #[test] - fn label_parser_defaults_to_ugc_namespace_and_ignores_other_kinds() { - let target = "8".repeat(EventId::HEX_LENGTH); - let event = event_with_kind_and_tags( - NIP32_LABEL_KIND.into(), - vec![ - Tag::from_parts("l", &["needs-review"]).expect("l"), - Tag::from_parts("e", &[&target]).expect("e"), - ], - ); - let note = event_with_kind_and_tags(1, vec![Tag::from_parts("l", &["topic"]).expect("l")]); - - let label = parse_label_event(&event).expect("parse").expect("label"); - - assert_eq!(label.namespaces(), &[] as &[String]); - assert_eq!(label.labels()[0].namespace(), "ugc"); - assert_eq!(parse_label_event(&note), Ok(None)); - } - - #[test] - fn label_parser_rejects_missing_labels_targets_and_bad_namespaces() { - let target = "8".repeat(EventId::HEX_LENGTH); - let missing_label = event_with_kind_and_tags( - NIP32_LABEL_KIND.into(), - vec![Tag::from_parts("e", &[&target]).expect("e")], - ); - let missing_target = event_with_kind_and_tags( - NIP32_LABEL_KIND.into(), - vec![Tag::from_parts("l", &["approve"]).expect("l")], - ); - let unmatched_namespace = event_with_kind_and_tags( - NIP32_LABEL_KIND.into(), - vec![ - Tag::from_parts("L", &["one"]).expect("L"), - Tag::from_parts("l", &["approve", "two"]).expect("l"), - Tag::from_parts("e", &[&target]).expect("e"), - ], - ); - let missing_label_namespace = event_with_kind_and_tags( - NIP32_LABEL_KIND.into(), - vec![ - Tag::from_parts("L", &["one"]).expect("L"), - Tag::from_parts("l", &["approve"]).expect("l"), - Tag::from_parts("e", &[&target]).expect("e"), - ], - ); - let empty_namespace = event_with_kind_and_tags( - NIP32_LABEL_KIND.into(), - vec![ - Tag::from_parts("L", &[""]).expect("L"), - Tag::from_parts("l", &["approve"]).expect("l"), - Tag::from_parts("e", &[&target]).expect("e"), - ], - ); - let missing_label_value = event_with_kind_and_tags( - NIP32_LABEL_KIND.into(), - vec![ - Tag::from_parts("l", &[]).expect("l"), - Tag::from_parts("e", &[&target]).expect("e"), - ], - ); - let empty_label_value = event_with_kind_and_tags( - NIP32_LABEL_KIND.into(), - vec![ - Tag::from_parts("l", &[""]).expect("l"), - Tag::from_parts("e", &[&target]).expect("e"), - ], - ); - let empty_label_namespace = event_with_kind_and_tags( - NIP32_LABEL_KIND.into(), - vec![ - Tag::from_parts("l", &["approve", ""]).expect("l"), - Tag::from_parts("e", &[&target]).expect("e"), - ], - ); - let extra_label_value = event_with_kind_and_tags( - NIP32_LABEL_KIND.into(), - vec![ - Tag::from_parts("l", &["approve", "ugc", "extra"]).expect("l"), - Tag::from_parts("e", &[&target]).expect("e"), - ], - ); - let bad_target = event_with_kind_and_tags( - NIP32_LABEL_KIND.into(), - vec![ - Tag::from_parts("l", &["approve"]).expect("l"), - Tag::from_parts("e", &["bad"]).expect("e"), - ], - ); - let bad_pubkey_target = event_with_kind_and_tags( - NIP32_LABEL_KIND.into(), - vec![ - Tag::from_parts("l", &["approve"]).expect("l"), - Tag::from_parts("p", &["bad"]).expect("p"), - ], - ); - let empty_relay = event_with_kind_and_tags( - NIP32_LABEL_KIND.into(), - vec![ - Tag::from_parts("l", &["approve"]).expect("l"), - Tag::from_parts("r", &[""]).expect("r"), - ], - ); - let empty_topic = event_with_kind_and_tags( - NIP32_LABEL_KIND.into(), - vec![ - Tag::from_parts("l", &["approve"]).expect("l"), - Tag::from_parts("t", &[""]).expect("t"), - ], - ); - - assert_eq!( - parse_label_event(&missing_label).expect_err("missing label"), - "label event must include at least one l tag" - ); - assert_eq!( - parse_label_event(&missing_target).expect_err("missing target"), - "label event must target at least one e p a r or t tag" - ); - assert_eq!( - parse_label_event(&unmatched_namespace).expect_err("unmatched namespace"), - "label l namespace must match an L tag" - ); - assert_eq!( - parse_label_event(&missing_label_namespace).expect_err("missing namespace"), - "label l tag must include a namespace matching an L tag" - ); - assert_eq!( - parse_label_event(&empty_namespace).expect_err("empty namespace"), - "label namespace L tag must include exactly one non-empty value" - ); - assert_eq!( - parse_label_event(&missing_label_value).expect_err("missing value"), - "label l tag must include a value" - ); - assert_eq!( - parse_label_event(&empty_label_value).expect_err("empty value"), - "label l value must not be empty" - ); - assert_eq!( - parse_label_event(&empty_label_namespace).expect_err("empty label namespace"), - "label l namespace must not be empty" - ); - assert_eq!( - parse_label_event(&extra_label_value).expect_err("extra label value"), - "label l tag must include at most value and namespace" - ); - assert_eq!( - parse_label_event(&bad_target).expect_err("bad target"), - "event id must be 64 characters, got 3" - ); - assert_eq!( - parse_label_event(&bad_pubkey_target).expect_err("bad pubkey target"), - "label target pubkey is invalid: public key must be 64 characters, got 3" - ); - assert_eq!( - parse_label_event(&empty_relay).expect_err("empty relay"), - "label relay target must not be empty" - ); - assert_eq!( - parse_label_event(&empty_topic).expect_err("empty topic"), - "label topic target must not be empty" - ); - } - - #[test] - fn deletion_request_parser_extracts_event_and_address_targets() { - let target_event_id = "2".repeat(EventId::HEX_LENGTH); - let target_pubkey = "3".repeat(PublicKeyHex::HEX_LENGTH); - let address = format!("30402:{target_pubkey}:listing-a"); - let event = event_with_kind_and_tags( - 5, - vec![ - Tag::from_parts("e", &[&target_event_id]).expect("e"), - Tag::from_parts("a", &[&address]).expect("a"), - ], - ); - - let request = parse_deletion_request(&event) - .expect("parse") - .expect("request"); - - assert_eq!(request.event_id(), event.id()); - assert_eq!(request.targets().len(), 2); - assert_eq!( - request.targets()[0], - DeletionTarget::Event(EventId::new(&target_event_id).expect("event id")) - ); - assert!(matches!( - &request.targets()[1], - DeletionTarget::Address(address) if address.to_string() == format!("30402:{target_pubkey}:listing-a") - )); - } - - #[test] - fn deletion_request_parser_ignores_non_deletion_kinds() { - let event = event_with_tags(vec![Tag::from_parts("e", &["ignored"]).expect("e")]); - - assert_eq!(parse_deletion_request(&event), Ok(None)); - } - - #[test] - fn deletion_request_parser_rejects_missing_and_malformed_targets() { - let missing = event_with_kind_and_tags(5, Vec::new()); - let malformed_event = - event_with_kind_and_tags(5, vec![Tag::from_parts("e", &["not-hex"]).expect("e")]); - let malformed_address = event_with_kind_and_tags( - 5, - vec![Tag::from_parts("a", &["30402:not-a-pubkey:listing"]).expect("a")], - ); - - assert_eq!( - parse_deletion_request(&missing).expect_err("missing"), - "deletion event must target at least one e or a tag" - ); - assert_eq!( - parse_deletion_request(&malformed_event).expect_err("event"), - "event id must be 64 characters, got 7" - ); - assert_eq!( - parse_deletion_request(&malformed_address).expect_err("address"), - "public key must be 64 characters, got 12" - ); - } - - #[test] - fn deletion_request_parser_keeps_repeated_targets_in_order() { - let first = "4".repeat(EventId::HEX_LENGTH); - let second = "5".repeat(EventId::HEX_LENGTH); - let event = event_with_kind_and_tags( - 5, - vec![ - Tag::from_parts("e", &[&first]).expect("e"), - Tag::from_parts("e", &[&second]).expect("e"), - ], - ); - let request = parse_deletion_request(&event) - .expect("parse") - .expect("request"); - - assert_eq!( - request.targets(), - &[ - DeletionTarget::Event(EventId::new(&first).expect("first")), - DeletionTarget::Event(EventId::new(&second).expect("second")), - ] - ); - } - - #[test] - fn relay_auth_parser_extracts_mandatory_fields() { - let event = event_with_kind_and_tags( - 22_242, - vec![ - Tag::from_parts("relay", &["wss://relay.radroots.test"]).expect("relay"), - Tag::from_parts("challenge", &["auth-challenge-001"]).expect("challenge"), - ], - ); - - let auth = parse_relay_auth_event(&event) - .expect("parse") - .expect("auth"); - - assert_eq!(auth.event_id(), event.id()); - assert_eq!(auth.pubkey(), event.unsigned().pubkey()); - assert_eq!(auth.created_at(), event.unsigned().created_at()); - assert_eq!(auth.relay(), "wss://relay.radroots.test"); - assert_eq!(auth.challenge(), "auth-challenge-001"); - } - - #[test] - fn relay_auth_parser_ignores_non_auth_kinds() { - let event = event_with_tags(vec![ - Tag::from_parts("relay", &["wss://relay.radroots.test"]).expect("relay"), - Tag::from_parts("challenge", &["auth-challenge-001"]).expect("challenge"), - ]); - - assert_eq!(parse_relay_auth_event(&event), Ok(None)); - } - - #[test] - fn relay_auth_parser_rejects_missing_and_repeated_fields() { - let missing_relay = event_with_kind_and_tags( - 22_242, - vec![Tag::from_parts("challenge", &["challenge"]).expect("challenge")], - ); - let missing_challenge = event_with_kind_and_tags( - 22_242, - vec![Tag::from_parts("relay", &["wss://relay.radroots.test"]).expect("relay")], - ); - let repeated_relay = event_with_kind_and_tags( - 22_242, - vec![ - Tag::from_parts("relay", &["wss://relay-a.radroots.test"]).expect("relay"), - Tag::from_parts("relay", &["wss://relay-b.radroots.test"]).expect("relay"), - Tag::from_parts("challenge", &["challenge"]).expect("challenge"), - ], - ); - - assert_eq!( - parse_relay_auth_event(&missing_relay).expect_err("relay"), - "tag `relay` is required" - ); - assert_eq!( - parse_relay_auth_event(&missing_challenge).expect_err("challenge"), - "tag `challenge` is required" - ); - assert_eq!( - parse_relay_auth_event(&repeated_relay).expect_err("repeated"), - "tag `relay` must not be repeated" - ); - } - - #[test] - fn relay_auth_parser_rejects_empty_fields() { - let empty_relay = event_with_kind_and_tags( - 22_242, - vec![ - Tag::from_parts("relay", &[""]).expect("relay"), - Tag::from_parts("challenge", &["challenge"]).expect("challenge"), - ], - ); - let empty_challenge = event_with_kind_and_tags( - 22_242, - vec![ - Tag::from_parts("relay", &["wss://relay.radroots.test"]).expect("relay"), - Tag::from_parts("challenge", &[""]).expect("challenge"), - ], - ); - - assert_eq!( - parse_relay_auth_event(&empty_relay).expect_err("empty relay"), - "relay auth relay tag must not be empty" - ); - assert_eq!( - parse_relay_auth_event(&empty_challenge).expect_err("empty challenge"), - "relay auth challenge tag must not be empty" - ); - } - - #[test] - fn nip50_search_parser_extracts_plain_terms_and_ignores_extensions() { - let query = parse_nip50_search(" fresh seller:ignored carrots status:ignored greens ") - .expect("parse") - .expect("query"); - - assert_eq!(query.text(), "fresh carrots greens"); - assert_eq!( - query.terms(), - &[ - "fresh".to_owned(), - "carrots".to_owned(), - "greens".to_owned() - ] - ); - } - - #[test] - fn nip50_search_parser_treats_empty_and_extension_only_queries_as_absent() { - assert_eq!(parse_nip50_search(" "), Ok(None)); - assert_eq!( - parse_nip50_search("seller:ignored status:ignored"), - Ok(None) - ); - } - - #[test] - fn nip50_search_parser_reads_filter_search_field() { - let filter = filter_from_value(&serde_json::json!({ - "search": "farmstand tomatoes", - "kinds": [1] - })) - .expect("filter"); - let missing = filter_from_value(&serde_json::json!({ - "kinds": [1] - })) - .expect("missing"); - - assert_eq!( - parse_nip50_filter_search(&filter) - .expect("filter") - .expect("query") - .text(), - "farmstand tomatoes" - ); - assert_eq!(parse_nip50_filter_search(&missing), Ok(None)); - } - - #[test] - fn seller_profile_parser_extracts_metadata_tags_and_trust_markers() { - let event = event_with_kind_tags_and_content( - u64::from(NIP01_METADATA_KIND), - vec![ - Tag::from_parts("region", &[" Vancouver Island "]).expect("region"), - Tag::from_parts("category", &["Vegetables"]).expect("category"), - Tag::from_parts("category", &[" vegetables "]).expect("category duplicate"), - Tag::from_parts("trust", &["verified-local"]).expect("trust"), - ], - r#"{ - "name": "sunrise-farm", - "display_name": "Sunrise Farm", - "about": "Certified organic farm stand.", - "picture": "https://radroots.test/sunrise.jpg", - "website": "https://sunrise.radroots.test", - "nip05": "sunrise@radroots.test", - "lud16": "sunrise@wallet.radroots.test" - }"#, - ); - - let profile = parse_seller_profile_event(&event) - .expect("parse") - .expect("profile"); - - assert_eq!(profile.event_id(), event.id()); - assert_eq!(profile.pubkey(), event.unsigned().pubkey()); - assert_eq!(profile.created_at(), event.unsigned().created_at()); - assert_eq!(profile.metadata().name(), Some("sunrise-farm")); - assert_eq!(profile.metadata().display_name(), Some("Sunrise Farm")); - assert_eq!( - profile.metadata().about(), - Some("Certified organic farm stand.") - ); - assert_eq!( - profile.metadata().picture(), - Some("https://radroots.test/sunrise.jpg") - ); - assert_eq!( - profile.metadata().website(), - Some("https://sunrise.radroots.test") - ); - assert_eq!(profile.metadata().nip05(), Some("sunrise@radroots.test")); - assert_eq!( - profile.metadata().lud16(), - Some("sunrise@wallet.radroots.test") - ); - assert_eq!(profile.regions(), &["vancouver island".to_owned()]); - assert_eq!(profile.categories(), &["vegetables".to_owned()]); - assert_eq!(profile.trust_markers(), &["verified-local".to_owned()]); - } - - #[test] - fn seller_profile_parser_ignores_other_kinds_and_trims_empty_metadata() { - let profile = event_with_kind_tags_and_content( - u64::from(NIP01_METADATA_KIND), - Vec::new(), - r#"{"name":" ","display_name":null,"about":" Market seller. "}"#, - ); - let note = event_with_kind_tags_and_content(1, Vec::new(), r#"{"name":"not-a-profile"}"#); - - let profile = parse_seller_profile_event(&profile) - .expect("parse") - .expect("profile"); - - assert_eq!(profile.metadata().name(), None); - assert_eq!(profile.metadata().display_name(), None); - assert_eq!(profile.metadata().about(), Some("Market seller.")); - assert_eq!(parse_seller_profile_event(&note), Ok(None)); - } - - #[test] - fn seller_profile_parser_rejects_bad_metadata_and_tags() { - let invalid_json = - event_with_kind_tags_and_content(u64::from(NIP01_METADATA_KIND), Vec::new(), "{"); - let non_object = - event_with_kind_tags_and_content(u64::from(NIP01_METADATA_KIND), Vec::new(), "[]"); - let non_string = event_with_kind_tags_and_content( - u64::from(NIP01_METADATA_KIND), - Vec::new(), - r#"{"name":1}"#, - ); - let empty_region = event_with_kind_tags_and_content( - u64::from(NIP01_METADATA_KIND), - vec![Tag::from_parts("region", &[" "]).expect("region")], - "{}", - ); - - assert!( - parse_seller_profile_event(&invalid_json) - .expect_err("invalid json") - .contains("seller profile metadata JSON is invalid") - ); - assert_eq!( - parse_seller_profile_event(&non_object).expect_err("non object"), - "seller profile metadata must be a JSON object" - ); - assert_eq!( - parse_seller_profile_event(&non_string).expect_err("non string"), - "seller profile metadata `name` must be a string" - ); - assert_eq!( - parse_seller_profile_event(&empty_region).expect_err("empty region"), - "seller profile region value must not be empty" - ); - } - - #[test] - fn listing_identity_parser_extracts_public_listing_address() { - let event = event_with_kind_and_tags( - u64::from(NIP99_PUBLIC_LISTING_KIND), - vec![Tag::from_parts("d", &["listing-a"]).expect("d")], - ); - - let identity = parse_listing_identity(&event) - .expect("parse") - .expect("identity"); - - assert_eq!(identity.event_id(), event.id()); - assert_eq!(identity.listing_kind(), ListingKind::Public); - assert_eq!(identity.seller_pubkey(), event.unsigned().pubkey()); - assert_eq!(identity.d().as_str(), "listing-a"); - assert_eq!( - identity.address().to_string(), - format!("30402:{}:listing-a", event.unsigned().pubkey().as_str()) - ); - } - - #[test] - fn listing_identity_parser_extracts_draft_listing_address() { - let event = - event_with_kind_and_tags(30_403, vec![Tag::from_parts("d", &["draft-a"]).expect("d")]); - - let identity = parse_listing_identity(&event) - .expect("parse") - .expect("identity"); - - assert_eq!(identity.listing_kind(), ListingKind::Draft); - assert_eq!(identity.address().kind().as_u32(), 30_403); - } - - #[test] - fn listing_identity_parser_ignores_non_listing_kinds() { - let event = event_with_kind_and_tags(1, vec![Tag::from_parts("d", &["note"]).expect("d")]); - - assert_eq!(parse_listing_identity(&event), Ok(None)); - } - - #[test] - fn listing_identity_parser_rejects_missing_repeated_and_empty_d_tags() { - let missing = event_with_kind_and_tags(u64::from(NIP99_PUBLIC_LISTING_KIND), Vec::new()); - let repeated = event_with_kind_and_tags( - u64::from(NIP99_PUBLIC_LISTING_KIND), - vec![ - Tag::from_parts("d", &["listing-a"]).expect("d"), - Tag::from_parts("d", &["listing-b"]).expect("d"), - ], - ); - let empty = event_with_kind_and_tags( - u64::from(NIP99_PUBLIC_LISTING_KIND), - vec![Tag::from_parts("d", &[""]).expect("d")], - ); - - assert_eq!( - parse_listing_identity(&missing).expect_err("missing"), - "tag `d` is required" - ); - assert_eq!( - parse_listing_identity(&repeated).expect_err("repeated"), - "tag `d` must not be repeated" - ); - assert_eq!( - parse_listing_identity(&empty).expect_err("empty"), - "listing d tag must not be empty" - ); - } - - #[test] - fn listing_text_parser_extracts_title_summary_and_body() { - let event = event_with_kind_tags_and_content( - u64::from(NIP99_PUBLIC_LISTING_KIND), - vec![ - Tag::from_parts("title", &["Carrot bunches"]).expect("title"), - Tag::from_parts("summary", &["Fresh field bunches"]).expect("summary"), - ], - "Harvested this morning.", - ); - - let text = parse_listing_text(&event).expect("parse").expect("text"); - - assert_eq!(text.title(), "Carrot bunches"); - assert_eq!(text.summary(), Some("Fresh field bunches")); - assert_eq!(text.body(), "Harvested this morning."); - } - - #[test] - fn listing_text_parser_parses_draft_text_and_ignores_non_listings() { - let draft = event_with_kind_and_tags( - 30_403, - vec![Tag::from_parts("title", &["Draft carrots"]).expect("title")], - ); - let note = event_with_kind_and_tags( - 1, - vec![Tag::from_parts("title", &["Note title"]).expect("title")], - ); - - assert_eq!( - parse_listing_text(&draft) - .expect("draft") - .expect("text") - .title(), - "Draft carrots" - ); - assert_eq!(parse_listing_text(&note), Ok(None)); - } - - #[test] - fn listing_text_parser_rejects_missing_repeated_and_empty_titles() { - let missing = event_with_kind_and_tags(u64::from(NIP99_PUBLIC_LISTING_KIND), Vec::new()); - let repeated = event_with_kind_and_tags( - u64::from(NIP99_PUBLIC_LISTING_KIND), - vec![ - Tag::from_parts("title", &["Carrots"]).expect("title"), - Tag::from_parts("title", &["Greens"]).expect("title"), - ], - ); - let missing_value = event_with_kind_and_tags( - u64::from(NIP99_PUBLIC_LISTING_KIND), - vec![Tag::from_parts("title", &[]).expect("title")], - ); - let empty = event_with_kind_and_tags( - u64::from(NIP99_PUBLIC_LISTING_KIND), - vec![Tag::from_parts("title", &[""]).expect("title")], - ); - - assert_eq!( - parse_listing_text(&missing).expect_err("missing"), - "tag `title` is required" - ); - assert_eq!( - parse_listing_text(&repeated).expect_err("repeated"), - "tag `title` must not be repeated" - ); - assert_eq!( - parse_listing_text(&missing_value).expect_err("value"), - "tag `title` must include a value" - ); - assert_eq!( - parse_listing_text(&empty).expect_err("empty"), - "listing title tag must not be empty" - ); - } - - #[test] - fn listing_text_parser_rejects_malformed_summary_tags() { - let repeated = event_with_kind_and_tags( - u64::from(NIP99_PUBLIC_LISTING_KIND), - vec![ - Tag::from_parts("title", &["Carrots"]).expect("title"), - Tag::from_parts("summary", &["Fresh"]).expect("summary"), - Tag::from_parts("summary", &["Sweet"]).expect("summary"), - ], - ); - let missing_value = event_with_kind_and_tags( - u64::from(NIP99_PUBLIC_LISTING_KIND), - vec![ - Tag::from_parts("title", &["Carrots"]).expect("title"), - Tag::from_parts("summary", &[]).expect("summary"), - ], - ); - let empty = event_with_kind_and_tags( - u64::from(NIP99_PUBLIC_LISTING_KIND), - vec![ - Tag::from_parts("title", &["Carrots"]).expect("title"), - Tag::from_parts("summary", &[""]).expect("summary"), - ], - ); - - assert_eq!( - parse_listing_text(&repeated).expect_err("repeated"), - "tag `summary` must not be repeated" - ); - assert_eq!( - parse_listing_text(&missing_value).expect_err("value"), - "tag `summary` must include a value" - ); - assert_eq!( - parse_listing_text(&empty).expect_err("empty"), - "listing summary tag must not be empty" - ); - } - - #[test] - fn listing_price_parser_extracts_exact_decimal_currency_and_frequency() { - let event = event_with_kind_and_tags( - u64::from(NIP99_PUBLIC_LISTING_KIND), - vec![Tag::from_parts("price", &["12.50", "usd", "weekly"]).expect("price")], - ); - - let price = parse_listing_price(&event).expect("parse").expect("price"); - - assert_eq!(price.amount().raw(), "12.50"); - assert_eq!(price.currency(), "usd"); - assert_eq!(price.display_currency(), "USD"); - assert_eq!(price.frequency(), Some("weekly")); - } - - #[test] - fn listing_price_parser_accepts_integer_amount_without_frequency() { - let event = event_with_kind_and_tags( - 30_403, - vec![Tag::from_parts("price", &["7", "CAD"]).expect("price")], - ); - - let price = parse_listing_price(&event).expect("parse").expect("price"); - - assert_eq!(price.amount().raw(), "7"); - assert_eq!(price.currency(), "CAD"); - assert_eq!(price.display_currency(), "CAD"); - assert_eq!(price.frequency(), None); - } - - #[test] - fn listing_price_parser_ignores_non_listing_kinds() { - let event = - event_with_kind_and_tags(1, vec![Tag::from_parts("price", &["3", "USD"]).expect("p")]); - - assert_eq!(parse_listing_price(&event), Ok(None)); - } - - #[test] - fn listing_price_parser_rejects_missing_repeated_and_bad_shape() { - let missing = event_with_kind_and_tags(u64::from(NIP99_PUBLIC_LISTING_KIND), Vec::new()); - let repeated = event_with_kind_and_tags( - u64::from(NIP99_PUBLIC_LISTING_KIND), - vec![ - Tag::from_parts("price", &["3", "USD"]).expect("price"), - Tag::from_parts("price", &["4", "USD"]).expect("price"), - ], - ); - let no_values = event_with_kind_and_tags( - u64::from(NIP99_PUBLIC_LISTING_KIND), - vec![Tag::from_parts("price", &[]).expect("price")], - ); - let no_currency = event_with_kind_and_tags( - u64::from(NIP99_PUBLIC_LISTING_KIND), - vec![Tag::from_parts("price", &["3"]).expect("price")], - ); - let extra = event_with_kind_and_tags( - u64::from(NIP99_PUBLIC_LISTING_KIND), - vec![Tag::from_parts("price", &["3", "USD", "weekly", "extra"]).expect("price")], - ); - - assert_eq!( - parse_listing_price(&missing).expect_err("missing"), - "tag `price` is required" - ); - assert_eq!( - parse_listing_price(&repeated).expect_err("repeated"), - "tag `price` must not be repeated" - ); - assert_eq!( - parse_listing_price(&no_values).expect_err("values"), - "price tag must include amount and currency" - ); - assert_eq!( - parse_listing_price(&no_currency).expect_err("currency"), - "price tag must include amount and currency" - ); - assert_eq!( - parse_listing_price(&extra).expect_err("extra"), - "price tag must not include more than amount currency and frequency" - ); - } - - #[test] - fn listing_price_parser_rejects_malformed_amount_currency_and_frequency() { - let bad_amounts = ["", ".50", "12.", "12.5.0", "-12", "1e3", "12 usd"]; - for amount in bad_amounts { - let event = event_with_kind_and_tags( - u64::from(NIP99_PUBLIC_LISTING_KIND), - vec![Tag::from_parts("price", &[amount, "USD"]).expect("price")], - ); - assert_eq!( - parse_listing_price(&event).expect_err("amount"), - "price amount must be an exact unsigned decimal" - ); - } - let empty_currency = event_with_kind_and_tags( - u64::from(NIP99_PUBLIC_LISTING_KIND), - vec![Tag::from_parts("price", &["3", ""]).expect("price")], - ); - let empty_frequency = event_with_kind_and_tags( - u64::from(NIP99_PUBLIC_LISTING_KIND), - vec![Tag::from_parts("price", &["3", "USD", ""]).expect("price")], - ); - - assert_eq!( - parse_listing_price(&empty_currency).expect_err("currency"), - "price currency must not be empty" - ); - assert_eq!( - parse_listing_price(&empty_frequency).expect_err("frequency"), - "price frequency must not be empty" - ); - } - - #[test] - fn listing_unit_parser_normalizes_supported_units_and_aliases() { - let cases = [ - ("LB", ListingUnit::Lb, "lb"), - ("ounces", ListingUnit::Oz, "oz"), - ("ea", ListingUnit::Each, "each"), - ("bunches", ListingUnit::Bunch, "bunch"), - ("dozen", ListingUnit::Dozen, "dozen"), - ("kilograms", ListingUnit::Kg, "kg"), - ("grams", ListingUnit::G, "g"), - ("shares", ListingUnit::Share, "share"), - ("pints", ListingUnit::Pint, "pint"), - ("quarts", ListingUnit::Quart, "quart"), - ("boxes", ListingUnit::Box, "box"), - ("crates", ListingUnit::Crate, "crate"), - ("flats", ListingUnit::Flat, "flat"), - ]; - - for (raw, unit, canonical) in cases { - let event = event_with_kind_and_tags( - u64::from(NIP99_PUBLIC_LISTING_KIND), - vec![Tag::from_parts("unit", &[raw]).expect("unit")], - ); - let parsed = parse_listing_unit(&event).expect("parse").expect("unit"); - - assert_eq!(parsed.raw(), raw); - assert_eq!(parsed.unit(), unit); - assert_eq!(parsed.canonical(), canonical); - assert_eq!(unit.canonical(), canonical); - } - } - - #[test] - fn listing_unit_parser_trims_input_and_ignores_non_listing_kinds() { - let listing = event_with_kind_and_tags( - 30_403, - vec![Tag::from_parts("unit", &[" pound "]).expect("unit")], - ); - let note = - event_with_kind_and_tags(1, vec![Tag::from_parts("unit", &["lb"]).expect("unit")]); - - assert_eq!( - parse_listing_unit(&listing) - .expect("listing") - .expect("unit") - .canonical(), - "lb" - ); - assert_eq!(parse_listing_unit(&note), Ok(None)); - } - - #[test] - fn listing_unit_parser_rejects_missing_repeated_and_empty_units() { - let missing = event_with_kind_and_tags(u64::from(NIP99_PUBLIC_LISTING_KIND), Vec::new()); - let repeated = event_with_kind_and_tags( - u64::from(NIP99_PUBLIC_LISTING_KIND), - vec![ - Tag::from_parts("unit", &["lb"]).expect("unit"), - Tag::from_parts("unit", &["kg"]).expect("unit"), - ], - ); - let missing_value = event_with_kind_and_tags( - u64::from(NIP99_PUBLIC_LISTING_KIND), - vec![Tag::from_parts("unit", &[]).expect("unit")], - ); - let empty = event_with_kind_and_tags( - u64::from(NIP99_PUBLIC_LISTING_KIND), - vec![Tag::from_parts("unit", &[" "]).expect("unit")], - ); - - assert_eq!( - parse_listing_unit(&missing).expect_err("missing"), - "tag `unit` is required" - ); - assert_eq!( - parse_listing_unit(&repeated).expect_err("repeated"), - "tag `unit` must not be repeated" - ); - assert_eq!( - parse_listing_unit(&missing_value).expect_err("value"), - "tag `unit` must include a value" - ); - assert_eq!( - parse_listing_unit(&empty).expect_err("empty"), - "listing unit tag must not be empty" - ); - } - - #[test] - fn listing_unit_parser_rejects_unsupported_units() { - let event = event_with_kind_and_tags( - u64::from(NIP99_PUBLIC_LISTING_KIND), - vec![Tag::from_parts("unit", &["bushel"]).expect("unit")], - ); - - assert_eq!( - parse_listing_unit(&event).expect_err("unsupported"), - "listing unit `bushel` is unsupported" - ); - } - - #[test] - fn listing_fulfillment_parser_extracts_methods_and_availability_flags() { - let event = event_with_kind_and_tags( - u64::from(NIP99_PUBLIC_LISTING_KIND), - vec![ - Tag::from_parts("fulfillment", &["shipping"]).expect("shipping"), - Tag::from_parts("fulfillment", &["pickup"]).expect("pickup"), - Tag::from_parts("fulfillment", &["delivery"]).expect("delivery"), - Tag::from_parts("fulfillment", &["pickup"]).expect("pickup"), - ], - ); - - let fulfillment = parse_listing_fulfillment(&event) - .expect("parse") - .expect("fulfillment"); - - assert_eq!( - fulfillment.methods(), - &[ - FulfillmentMethod::Pickup, - FulfillmentMethod::Delivery, - FulfillmentMethod::Shipping - ] - ); - assert_eq!(FulfillmentMethod::Pickup.canonical(), "pickup"); - assert_eq!(FulfillmentMethod::Delivery.canonical(), "delivery"); - assert_eq!(FulfillmentMethod::Shipping.canonical(), "shipping"); - assert!(fulfillment.pickup_available()); - assert!(fulfillment.delivery_available()); - assert!(fulfillment.shipping_available()); - assert!(!fulfillment.delivery_only()); - } - - #[test] - fn listing_fulfillment_parser_derives_delivery_only_and_ignores_non_listings() { - let delivery = event_with_kind_and_tags( - 30_403, - vec![Tag::from_parts("fulfillment", &[" delivery "]).expect("delivery")], - ); - let note = event_with_kind_and_tags( - 1, - vec![Tag::from_parts("fulfillment", &["pickup"]).expect("pickup")], - ); - - let fulfillment = parse_listing_fulfillment(&delivery) - .expect("delivery") - .expect("fulfillment"); - - assert_eq!(fulfillment.methods(), &[FulfillmentMethod::Delivery]); - assert!(fulfillment.delivery_only()); - assert_eq!(parse_listing_fulfillment(&note), Ok(None)); - } - - #[test] - fn listing_fulfillment_parser_rejects_missing_and_malformed_tags() { - let missing = event_with_kind_and_tags(u64::from(NIP99_PUBLIC_LISTING_KIND), Vec::new()); - let missing_value = event_with_kind_and_tags( - u64::from(NIP99_PUBLIC_LISTING_KIND), - vec![Tag::from_parts("fulfillment", &[]).expect("fulfillment")], - ); - let extra_value = event_with_kind_and_tags( - u64::from(NIP99_PUBLIC_LISTING_KIND), - vec![Tag::from_parts("fulfillment", &["pickup", "delivery"]).expect("fulfillment")], - ); - let empty = event_with_kind_and_tags( - u64::from(NIP99_PUBLIC_LISTING_KIND), - vec![Tag::from_parts("fulfillment", &[" "]).expect("fulfillment")], - ); - - assert_eq!( - parse_listing_fulfillment(&missing).expect_err("missing"), - "tag `fulfillment` is required" - ); - assert_eq!( - parse_listing_fulfillment(&missing_value).expect_err("value"), - "tag `fulfillment` must include a value" - ); - assert_eq!( - parse_listing_fulfillment(&extra_value).expect_err("extra"), - "fulfillment tag must include exactly one method" - ); - assert_eq!( - parse_listing_fulfillment(&empty).expect_err("empty"), - "fulfillment tag method must not be empty" - ); - } - - #[test] - fn listing_fulfillment_parser_rejects_unsupported_methods() { - let event = event_with_kind_and_tags( - u64::from(NIP99_PUBLIC_LISTING_KIND), - vec![Tag::from_parts("fulfillment", &["drone"]).expect("fulfillment")], - ); - - assert_eq!( - parse_listing_fulfillment(&event).expect_err("unsupported"), - "fulfillment method `drone` is unsupported" - ); - } - - #[test] - fn listing_status_parser_defaults_public_listings_to_active() { - let event = event_with_kind_and_tags(u64::from(NIP99_PUBLIC_LISTING_KIND), Vec::new()); - - let status = parse_listing_status(&event) - .expect("parse") - .expect("status"); - - assert_eq!(status.raw_status(), None); - assert_eq!(status.effective_status(), ListingEffectiveStatus::Active); - assert_eq!(ListingEffectiveStatus::Active.canonical(), "active"); - } - - #[test] - fn listing_status_parser_extracts_sold_and_active_status_tags() { - let sold = event_with_kind_and_tags( - u64::from(NIP99_PUBLIC_LISTING_KIND), - vec![Tag::from_parts("status", &["sold"]).expect("status")], - ); - let active = event_with_kind_and_tags( - u64::from(NIP99_PUBLIC_LISTING_KIND), - vec![Tag::from_parts("status", &["active"]).expect("status")], - ); - - let sold_status = parse_listing_status(&sold).expect("sold").expect("status"); - let active_status = parse_listing_status(&active) - .expect("active") - .expect("status"); - - assert_eq!(sold_status.raw_status(), Some("sold")); - assert_eq!(sold_status.effective_status(), ListingEffectiveStatus::Sold); - assert_eq!(ListingEffectiveStatus::Sold.canonical(), "sold"); - assert_eq!(active_status.raw_status(), Some("active")); - assert_eq!( - active_status.effective_status(), - ListingEffectiveStatus::Active - ); - } - - #[test] - fn listing_status_parser_derives_draft_from_kind_and_ignores_non_listings() { - let draft = event_with_kind_and_tags( - 30_403, - vec![Tag::from_parts("status", &["sold"]).expect("status")], - ); - let note = - event_with_kind_and_tags(1, vec![Tag::from_parts("status", &["active"]).expect("s")]); - - let status = parse_listing_status(&draft) - .expect("draft") - .expect("status"); - - assert_eq!(status.raw_status(), Some("sold")); - assert_eq!(status.effective_status(), ListingEffectiveStatus::Draft); - assert_eq!(ListingEffectiveStatus::Draft.canonical(), "draft"); - assert_eq!(parse_listing_status(&note), Ok(None)); - } - - #[test] - fn listing_status_parser_rejects_repeated_missing_empty_and_unsupported_tags() { - let repeated = event_with_kind_and_tags( - u64::from(NIP99_PUBLIC_LISTING_KIND), - vec![ - Tag::from_parts("status", &["active"]).expect("status"), - Tag::from_parts("status", &["sold"]).expect("status"), - ], - ); - let missing_value = event_with_kind_and_tags( - u64::from(NIP99_PUBLIC_LISTING_KIND), - vec![Tag::from_parts("status", &[]).expect("status")], - ); - let empty = event_with_kind_and_tags( - u64::from(NIP99_PUBLIC_LISTING_KIND), - vec![Tag::from_parts("status", &[""]).expect("status")], - ); - let unsupported = event_with_kind_and_tags( - u64::from(NIP99_PUBLIC_LISTING_KIND), - vec![Tag::from_parts("status", &["inactive"]).expect("status")], - ); - - assert_eq!( - parse_listing_status(&repeated).expect_err("repeated"), - "tag `status` must not be repeated" - ); - assert_eq!( - parse_listing_status(&missing_value).expect_err("value"), - "tag `status` must include a value" - ); - assert_eq!( - parse_listing_status(&empty).expect_err("empty"), - "listing status tag must not be empty" - ); - assert_eq!( - parse_listing_status(&unsupported).expect_err("unsupported"), - "listing status `inactive` is unsupported" - ); - } - - #[test] - fn listing_location_parser_extracts_text_geohash_and_prefixes() { - let event = event_with_kind_and_tags( - u64::from(NIP99_PUBLIC_LISTING_KIND), - vec![ - Tag::from_parts("location", &["Olympia Farmers Market"]).expect("location"), - Tag::from_parts("g", &[" C22YZUG "]).expect("g"), - ], - ); - - let location = parse_listing_location(&event) - .expect("parse") - .expect("location"); - - assert_eq!(location.location_text(), Some("Olympia Farmers Market")); - assert_eq!(location.geohash(), Some("c22yzug")); - assert_eq!(location.geohash4(), Some("c22y")); - assert_eq!(location.geohash5(), Some("c22yz")); - assert_eq!(location.geohash6(), Some("c22yzu")); - assert_eq!(location.geohash7(), Some("c22yzug")); - assert_eq!(location.location_precision(), Some(7)); - } - - #[test] - fn listing_location_parser_allows_missing_location_fields_and_ignores_non_listings() { - let listing = event_with_kind_and_tags(u64::from(NIP99_PUBLIC_LISTING_KIND), Vec::new()); - let note = event_with_kind_and_tags( - 1, - vec![Tag::from_parts("location", &["Somewhere"]).expect("location")], - ); - - let location = parse_listing_location(&listing) - .expect("listing") - .expect("location"); - - assert_eq!(location.location_text(), None); - assert_eq!(location.geohash(), None); - assert_eq!(location.geohash4(), None); - assert_eq!(location.location_precision(), None); - assert_eq!(parse_listing_location(&note), Ok(None)); - } - - #[test] - fn listing_location_parser_rejects_malformed_location_tags() { - let repeated = event_with_kind_and_tags( - u64::from(NIP99_PUBLIC_LISTING_KIND), - vec![ - Tag::from_parts("location", &["A"]).expect("location"), - Tag::from_parts("location", &["B"]).expect("location"), - ], - ); - let missing_value = event_with_kind_and_tags( - u64::from(NIP99_PUBLIC_LISTING_KIND), - vec![Tag::from_parts("location", &[]).expect("location")], - ); - let empty = event_with_kind_and_tags( - u64::from(NIP99_PUBLIC_LISTING_KIND), - vec![Tag::from_parts("location", &[""]).expect("location")], - ); - - assert_eq!( - parse_listing_location(&repeated).expect_err("repeated"), - "tag `location` must not be repeated" - ); - assert_eq!( - parse_listing_location(&missing_value).expect_err("value"), - "tag `location` must include a value" - ); - assert_eq!( - parse_listing_location(&empty).expect_err("empty"), - "listing location tag must not be empty" - ); - } - - #[test] - fn listing_location_parser_rejects_malformed_geohash_tags() { - let repeated = event_with_kind_and_tags( - u64::from(NIP99_PUBLIC_LISTING_KIND), - vec![ - Tag::from_parts("g", &["c22y"]).expect("g"), - Tag::from_parts("g", &["c23n"]).expect("g"), - ], - ); - let missing_value = event_with_kind_and_tags( - u64::from(NIP99_PUBLIC_LISTING_KIND), - vec![Tag::from_parts("g", &[]).expect("g")], - ); - let extra_value = event_with_kind_and_tags( - u64::from(NIP99_PUBLIC_LISTING_KIND), - vec![Tag::from_parts("g", &["c22y", "extra"]).expect("g")], - ); - let invalid = event_with_kind_and_tags( - u64::from(NIP99_PUBLIC_LISTING_KIND), - vec![Tag::from_parts("g", &["c22a"]).expect("g")], - ); - let too_short = event_with_kind_and_tags( - u64::from(NIP99_PUBLIC_LISTING_KIND), - vec![Tag::from_parts("g", &["c22"]).expect("g")], - ); - - assert_eq!( - parse_listing_location(&repeated).expect_err("repeated"), - "tag `g` must not be repeated" - ); - assert_eq!( - parse_listing_location(&missing_value).expect_err("value"), - "tag `g` must include a value" - ); - assert_eq!( - parse_listing_location(&extra_value).expect_err("extra"), - "tag `g` must include exactly one value" - ); - assert_eq!( - parse_listing_location(&invalid).expect_err("invalid"), - "geohash must be 4 to 12 geohash characters" - ); - assert_eq!( - parse_listing_location(&too_short).expect_err("short"), - "geohash must be 4 to 12 geohash characters" - ); - } - - #[test] - fn listing_taxonomy_parser_extracts_normalized_distinct_values() { - let event = event_with_kind_and_tags( - u64::from(NIP99_PUBLIC_LISTING_KIND), - vec![ - Tag::from_parts("category", &["Vegetables"]).expect("category"), - Tag::from_parts("category", &[" vegetables "]).expect("category"), - Tag::from_parts("t", &["Carrots"]).expect("topic"), - Tag::from_parts("t", &["CSA"]).expect("topic"), - Tag::from_parts("practice", &["No Spray"]).expect("practice"), - Tag::from_parts("certification", &["Organic"]).expect("certification"), - ], - ); - - let taxonomy = parse_listing_taxonomy(&event) - .expect("parse") - .expect("taxonomy"); - - assert_eq!(taxonomy.categories(), &["vegetables".to_owned()]); - assert_eq!(taxonomy.topics(), &["carrots".to_owned(), "csa".to_owned()]); - assert_eq!(taxonomy.practices(), &["no spray".to_owned()]); - assert_eq!(taxonomy.certifications(), &["organic".to_owned()]); - } - - #[test] - fn listing_taxonomy_parser_allows_missing_taxonomy_and_ignores_non_listings() { - let listing = event_with_kind_and_tags(30_403, Vec::new()); - let note = event_with_kind_and_tags( - 1, - vec![Tag::from_parts("category", &["vegetables"]).expect("category")], - ); - - let taxonomy = parse_listing_taxonomy(&listing) - .expect("listing") - .expect("taxonomy"); - - assert_eq!(taxonomy.categories(), &[] as &[String]); - assert_eq!(taxonomy.topics(), &[] as &[String]); - assert_eq!(taxonomy.practices(), &[] as &[String]); - assert_eq!(taxonomy.certifications(), &[] as &[String]); - assert_eq!(parse_listing_taxonomy(&note), Ok(None)); - } - - #[test] - fn listing_taxonomy_parser_rejects_malformed_category_and_topic_tags() { - let category_missing_value = event_with_kind_and_tags( - u64::from(NIP99_PUBLIC_LISTING_KIND), - vec![Tag::from_parts("category", &[]).expect("category")], - ); - let category_extra = event_with_kind_and_tags( - u64::from(NIP99_PUBLIC_LISTING_KIND), - vec![Tag::from_parts("category", &["vegetables", "extra"]).expect("category")], - ); - let topic_empty = event_with_kind_and_tags( - u64::from(NIP99_PUBLIC_LISTING_KIND), - vec![Tag::from_parts("t", &[" "]).expect("topic")], - ); - - assert_eq!( - parse_listing_taxonomy(&category_missing_value).expect_err("value"), - "tag `category` must include a value" - ); - assert_eq!( - parse_listing_taxonomy(&category_extra).expect_err("extra"), - "tag `category` must include exactly one value" - ); - assert_eq!( - parse_listing_taxonomy(&topic_empty).expect_err("empty"), - "listing taxonomy `t` value must not be empty" - ); - } - - #[test] - fn listing_taxonomy_parser_rejects_malformed_practice_and_certification_tags() { - let practice_empty = event_with_kind_and_tags( - u64::from(NIP99_PUBLIC_LISTING_KIND), - vec![Tag::from_parts("practice", &[""]).expect("practice")], - ); - let certification_extra = event_with_kind_and_tags( - u64::from(NIP99_PUBLIC_LISTING_KIND), - vec![Tag::from_parts("certification", &["organic", "extra"]).expect("certification")], - ); - - assert_eq!( - parse_listing_taxonomy(&practice_empty).expect_err("practice"), - "listing taxonomy `practice` value must not be empty" - ); - assert_eq!( - parse_listing_taxonomy(&certification_extra).expect_err("certification"), - "tag `certification` must include exactly one value" - ); - } - - #[test] - fn listing_projection_contract_accepts_complete_public_listing() { - let event = event_with_kind_tags_and_content( - u64::from(NIP99_PUBLIC_LISTING_KIND), - complete_listing_tags(), - "Sweet storage carrots.", - ); - - let evaluation = evaluate_listing_projection(&event); - - assert!(evaluation.is_eligible()); - assert_eq!(evaluation.rejection(), None); - let projection = evaluation.projection().expect("projection"); - - assert_eq!(projection.identity().d().as_str(), "listing-a"); - assert_eq!(projection.text().title(), "Carrot bunches"); - assert_eq!(projection.price().amount().raw(), "12.50"); - assert_eq!(projection.unit().unit(), ListingUnit::Lb); - assert_eq!(projection.unit().canonical(), "lb"); - assert!(projection.fulfillment().pickup_available()); - assert_eq!( - projection.status().effective_status(), - ListingEffectiveStatus::Active - ); - assert_eq!(projection.location().geohash4(), Some("c22y")); - assert_eq!( - projection.taxonomy().categories(), - &["vegetables".to_owned()] - ); - } - - #[test] - fn listing_projection_contract_ignores_non_listing_events() { - let event = event_with_kind_and_tags(1, complete_listing_tags()); - let evaluation = evaluate_listing_projection(&event); - - assert_eq!(evaluation, ListingProjectionEvaluation::NotListing); - assert!(!evaluation.is_eligible()); - assert_eq!(evaluation.projection(), None); - assert_eq!(evaluation.rejection(), None); - } - - #[test] - fn listing_projection_contract_rejects_draft_projection() { - let event = event_with_kind_and_tags(30_403, complete_listing_tags()); - let evaluation = evaluate_listing_projection(&event); - - assert!(!evaluation.is_eligible()); - let rejection = evaluation.rejection().expect("rejection"); - - assert_eq!(rejection.event_id(), event.id()); - assert_eq!( - rejection.reasons(), - &["draft listing is not public projection eligible".to_owned()] - ); - } - - #[test] - fn listing_projection_contract_accumulates_required_parser_failures() { - let event = event_with_kind_and_tags( - u64::from(NIP99_PUBLIC_LISTING_KIND), - vec![Tag::from_parts("d", &["listing-a"]).expect("d")], - ); - let evaluation = evaluate_listing_projection(&event); - - let rejection = evaluation.rejection().expect("rejection"); - - assert_eq!( - rejection.reasons(), - &[ - "tag `title` is required".to_owned(), - "tag `price` is required".to_owned(), - "tag `unit` is required".to_owned(), - "tag `fulfillment` is required".to_owned(), - ] - ); - } - - #[test] - fn listing_projection_contract_accumulates_optional_parser_failures() { - let mut tags = complete_listing_tags(); - tags.push(Tag::from_parts("location", &[""]).expect("location")); - tags.push(Tag::from_parts("category", &["vegetables", "extra"]).expect("category")); - let event = event_with_kind_and_tags(u64::from(NIP99_PUBLIC_LISTING_KIND), tags); - let evaluation = evaluate_listing_projection(&event); - - let rejection = evaluation.rejection().expect("rejection"); - - assert_eq!( - rejection.reasons(), - &[ - "listing location tag must not be empty".to_owned(), - "tag `category` must include exactly one value".to_owned(), - ] - ); - } - - #[test] - fn listing_projection_contract_accumulates_identity_and_status_failures() { - let mut tags = complete_listing_tags(); - tags.retain(|tag| tag.name().as_str() != "d"); - tags.push(Tag::from_parts("status", &["inactive"]).expect("status")); - let event = event_with_kind_and_tags(u64::from(NIP99_PUBLIC_LISTING_KIND), tags); - let evaluation = evaluate_listing_projection(&event); - - let rejection = evaluation.rejection().expect("rejection"); - - assert_eq!( - rejection.reasons(), - &[ - "tag `d` is required".to_owned(), - "listing status `inactive` is unsupported".to_owned(), - ] - ); - } - - fn event_with_tags(tags: Vec<Tag>) -> Event { - event_with_kind_and_tags(30_402, tags) - } - - fn event_with_kind_and_tags(kind: u64, tags: Vec<Tag>) -> Event { - event_with_kind_tags_and_content(kind, tags, "") - } - - fn event_with_kind_tags_and_content(kind: u64, tags: Vec<Tag>, content: &str) -> Event { - Event::new( - EventId::new(&"a".repeat(EventId::HEX_LENGTH)).expect("id"), - UnsignedEvent::new( - PublicKeyHex::new(&"1".repeat(PublicKeyHex::HEX_LENGTH)).expect("pubkey"), - UnixTimestamp::new(1_714_124_433), - Kind::new(kind).expect("kind"), - tags, - content, - ), - SignatureHex::new(&"b".repeat(SignatureHex::HEX_LENGTH)).expect("sig"), - ) - } - - fn complete_listing_tags() -> Vec<Tag> { - vec![ - Tag::from_parts("d", &["listing-a"]).expect("d"), - Tag::from_parts("title", &["Carrot bunches"]).expect("title"), - Tag::from_parts("price", &["12.50", "USD"]).expect("price"), - Tag::from_parts("unit", &["lb"]).expect("unit"), - Tag::from_parts("fulfillment", &["pickup"]).expect("fulfillment"), - Tag::from_parts("g", &["c22yzug"]).expect("g"), - Tag::from_parts("category", &["vegetables"]).expect("category"), - Tag::from_parts("t", &["carrots"]).expect("topic"), - Tag::from_parts("practice", &["no spray"]).expect("practice"), - Tag::from_parts("certification", &["organic"]).expect("certification"), - ] - } -} diff --git a/crates/tangle_runtime/Cargo.toml b/crates/tangle_runtime/Cargo.toml @@ -12,24 +12,16 @@ axum = { version = "0.8", features = ["ws"] } http = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" -sha2 = "0.10" tangle_crypto = { path = "../tangle_crypto" } -tangle_core = { path = "../tangle_core" } tangle_groups = { path = "../tangle_groups" } -tangle_nips = { path = "../tangle_nips" } tangle_protocol = { path = "../tangle_protocol" } -tangle_store = { path = "../tangle_store" } tangle_store_pocket = { path = "../tangle_store_pocket" } -tangle_store_surreal = { path = "../tangle_store_surreal" } tokio = { version = "1", features = ["net", "sync"] } tracing = "0.1" -url = "2" [dev-dependencies] -futures-util = "0.3" tangle_test_support = { path = "../tangle_test_support" } tokio = { version = "1", features = ["macros", "rt", "time"] } -tokio-tungstenite = "0.28" tower = { version = "0.5", features = ["util"] } [lints] diff --git a/crates/tangle_runtime/src/base_relay.rs b/crates/tangle_runtime/src/base_relay.rs @@ -21,7 +21,6 @@ use tangle_groups::{ projection_checkpoint_key, role_current_key, tombstone_key, validate_client_group_event_structure, }; -use tangle_nips::parse_relay_auth_event; use tangle_protocol::{ ClientMessage, Event, EventId, Filter, PublicKeyHex, RelayMessage, SubscriptionId, UnixTimestamp, event_to_value, filter_to_value, parse_event_json, @@ -586,7 +585,7 @@ impl BaseAuthState { now: UnixTimestamp, ) -> Result<PublicKeyHex, BaseRelayError> { verify_event_signature(event).map_err(BaseRelayError::invalid)?; - let auth = parse_relay_auth_event(event) + let auth = parse_base_relay_auth_event(event) .map_err(BaseRelayError::invalid)? .ok_or_else(|| BaseRelayError::invalid("AUTH message must contain kind 22242"))?; let challenge = self @@ -627,6 +626,64 @@ struct BaseAuthChallenge { issued_at: UnixTimestamp, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct BaseRelayAuthEvent { + pubkey: PublicKeyHex, + relay: String, + challenge: String, +} + +impl BaseRelayAuthEvent { + fn pubkey(&self) -> &PublicKeyHex { + &self.pubkey + } + + fn relay(&self) -> &str { + &self.relay + } + + fn challenge(&self) -> &str { + &self.challenge + } +} + +fn parse_base_relay_auth_event(event: &Event) -> Result<Option<BaseRelayAuthEvent>, String> { + if event.unsigned().kind().as_u32() != 22_242 { + return Ok(None); + } + let relay = required_single_tag_value(event, "relay")?; + let challenge = required_single_tag_value(event, "challenge")?; + if relay.is_empty() { + return Err("relay auth relay tag must not be empty".to_owned()); + } + if challenge.is_empty() { + return Err("relay auth challenge tag must not be empty".to_owned()); + } + Ok(Some(BaseRelayAuthEvent { + pubkey: event.unsigned().pubkey().clone(), + relay, + challenge, + })) +} + +fn required_single_tag_value(event: &Event, name: &str) -> Result<String, String> { + let mut matches = event + .unsigned() + .tags() + .iter() + .filter(|tag| tag.name().as_str() == name); + let tag = matches + .next() + .ok_or_else(|| format!("tag `{name}` is required"))?; + if matches.next().is_some() { + return Err(format!("tag `{name}` must not be repeated")); + } + tag.values() + .get(1) + .cloned() + .ok_or_else(|| format!("tag `{name}` must include a value")) +} + pub struct BaseRelay { store: PocketStoreHandle, subscriptions: LiveSubscriptionSet, diff --git a/crates/tangle_runtime/src/lib.rs b/crates/tangle_runtime/src/lib.rs @@ -3,9677 +3,99 @@ pub mod base_relay; pub mod chorus_pocket; -use axum::{ - Json, Router, - extract::ws::{Message, WebSocket, WebSocketUpgrade}, - extract::{Path, RawQuery, State}, - response::{IntoResponse, Response}, - routing::{get, post}, -}; -use core::fmt; -use http::{HeaderMap, HeaderValue, StatusCode, header}; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use std::{ - collections::BTreeSet, - fs, - future::Future, - net::SocketAddr, - path::{Component, Path as FsPath, PathBuf}, - sync::{ - Arc, - atomic::{AtomicU64, Ordering}, - }, - time::{SystemTime, UNIX_EPOCH}, -}; -use tangle_core::{ - AdmissionContext, AdmissionEffect, AdmissionPolicy, AuthChallengeState, EventValidator, - FixedWindowRateLimiter, MarketplaceListingStatus, MarketplaceQuery, MarketplaceQueryError, - MarketplaceQuerySpec, MarketplaceSort, NostrFilterCompiler, QueryExecutionMode, - RateLimitConfig, RateLimitDecision, RuntimeLimitValues, RuntimeLimits, - SubscriptionCloseOutcome, SubscriptionManager, SubscriptionMatcher, UnapprovedSellerAction, -}; -use tangle_nips::{FulfillmentMethod, ListingUnit, parse_relay_auth_event}; -use tangle_protocol::{ - ClientMessage, Event, EventId, Filter, PublicKeyHex, RawEventJson, RelayMessage, - SubscriptionId, UnixTimestamp, parse_client_message, parse_event_json, -}; -use tangle_store::{StoreEventOutcome, StoredEvent}; -use tangle_store_surreal::{ - CommentProjectionOutcome, CommentProjectionQuery, DurableRateLimitDecision, - ForumThreadProjectionOutcome, ForumThreadProjectionQuery, LabelProjectionOutcome, - LabelProjectionQuery, ListingProjectionQuery, LongFormProjectionOutcome, MigrationApplyOutcome, - ReactionProjectionOutcome, ReportProjectionOutcome, ReportProjectionQuery, SearchDocumentQuery, - SellerProfileProjectionOutcome, SurrealConnectionConfig, SurrealMetricsSnapshot, SurrealStore, - base_migration_plan, +use std::{fmt, fs, path::Path, path::PathBuf}; + +use base_relay::{ + BaseRelayError, BaseRelayReadinessState, BaseRelayRuntimeConfig, + parse_base_relay_runtime_config_json, }; -use tokio::net::TcpListener; -use tokio::sync::broadcast; -use url::form_urlencoded; -pub const TANGLE_SUPPORTED_NIPS: [u16; 13] = [1, 9, 11, 16, 22, 23, 25, 32, 33, 42, 50, 56, 99]; +pub const TANGLE_SUPPORTED_NIPS: [u16; 6] = [1, 11, 29, 42, 45, 70]; pub const TANGLE_RELAY_SOFTWARE: &str = "https://github.com/radrootslabs/tangle"; pub const TANGLE_RELAY_VERSION: &str = env!("CARGO_PKG_VERSION"); -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct RelayConnectionId(String); - -impl RelayConnectionId { - pub const MAX_LENGTH: usize = 128; - - pub fn new(value: &str) -> Result<Self, String> { - let value = value.trim(); - if value.is_empty() { - return Err("connection id must not be empty".to_owned()); - } - if value.len() > Self::MAX_LENGTH { - return Err(format!( - "connection id must be at most {} bytes, got {}", - Self::MAX_LENGTH, - value.len() - )); - } - Ok(Self(value.to_owned())) - } - - pub fn as_str(&self) -> &str { - &self.0 - } -} - -impl fmt::Display for RelayConnectionId { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(self.as_str()) - } -} - #[derive(Debug, Clone, PartialEq, Eq)] -pub struct RelayConnectionConfig { +pub struct TangleRuntimeStartupReport { relay_url: String, - auth_ttl_seconds: u64, - message_rate_limit: RateLimitConfig, - runtime_limits: RuntimeLimits, + data_directory: PathBuf, + groups_enabled: bool, + readiness: BaseRelayReadinessState, } -impl RelayConnectionConfig { - pub fn new( - relay_url: impl Into<String>, - auth_ttl_seconds: u64, - message_rate_limit: RateLimitConfig, - runtime_limits: RuntimeLimits, - ) -> Result<Self, String> { - let relay_url = relay_url.into(); - let auth = AuthChallengeState::new(&relay_url, auth_ttl_seconds) - .map_err(|error| error.to_string())?; - Ok(Self { - relay_url: auth.relay_url().to_owned(), - auth_ttl_seconds, - message_rate_limit, - runtime_limits, - }) - } - +impl TangleRuntimeStartupReport { pub fn relay_url(&self) -> &str { &self.relay_url } - pub fn auth_ttl_seconds(&self) -> u64 { - self.auth_ttl_seconds - } - - pub fn message_rate_limit(&self) -> RateLimitConfig { - self.message_rate_limit - } - - pub fn runtime_limits(&self) -> RuntimeLimits { - self.runtime_limits - } -} - -impl Default for RelayConnectionConfig { - fn default() -> Self { - Self::new( - "wss://relay.radroots.test", - 300, - RateLimitConfig::new(120, 60).expect("default message rate limit is valid"), - RuntimeLimits::default(), - ) - .expect("default relay connection config is valid") - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RelayConnection { - id: RelayConnectionId, - remote_addr: Option<String>, - subscriptions: SubscriptionManager, - auth: AuthChallengeState, - rate_limiter: FixedWindowRateLimiter, -} - -impl RelayConnection { - pub fn new(id: RelayConnectionId, config: RelayConnectionConfig) -> Self { - Self { - id, - remote_addr: None, - subscriptions: SubscriptionManager::new( - config.runtime_limits(), - SubscriptionMatcher::default(), - ), - auth: AuthChallengeState::new(config.relay_url(), config.auth_ttl_seconds()) - .expect("connection config validates auth state"), - rate_limiter: FixedWindowRateLimiter::new(config.message_rate_limit()), - } - } - - pub fn id(&self) -> &RelayConnectionId { - &self.id - } - - pub fn remote_addr(&self) -> Option<&str> { - self.remote_addr.as_deref() - } - - pub fn set_remote_addr(&mut self, remote_addr: impl Into<String>) { - self.remote_addr = Some(remote_addr.into()); - } - - pub fn subscriptions(&self) -> &SubscriptionManager { - &self.subscriptions - } - - pub fn subscriptions_mut(&mut self) -> &mut SubscriptionManager { - &mut self.subscriptions - } - - pub fn auth(&self) -> &AuthChallengeState { - &self.auth - } - - pub fn auth_mut(&mut self) -> &mut AuthChallengeState { - &mut self.auth - } - - pub fn rate_limiter(&self) -> &FixedWindowRateLimiter { - &self.rate_limiter - } - - pub fn rate_limiter_mut(&mut self) -> &mut FixedWindowRateLimiter { - &mut self.rate_limiter - } -} - -#[derive(Debug, Clone)] -pub struct WebSocketHttpState { - connection_config: RelayConnectionConfig, - shutdown_signal: GracefulShutdownSignal, -} - -impl WebSocketHttpState { - pub fn new(connection_config: RelayConnectionConfig) -> Self { - let (shutdown_signal, _) = GracefulShutdownSignal::new(); - Self::with_shutdown(connection_config, shutdown_signal) - } - - pub fn with_shutdown( - connection_config: RelayConnectionConfig, - shutdown_signal: GracefulShutdownSignal, - ) -> Self { - Self { - connection_config, - shutdown_signal, - } - } - - pub fn connection_config(&self) -> &RelayConnectionConfig { - &self.connection_config - } - - pub fn shutdown_signal(&self) -> &GracefulShutdownSignal { - &self.shutdown_signal - } -} - -impl Default for WebSocketHttpState { - fn default() -> Self { - Self::new(RelayConnectionConfig::default()) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TangleRuntimeConfig { - listen_addr: SocketAddr, - relay_connection: RelayConnectionConfig, - database: SurrealConnectionConfig, - admission_policy: AdmissionPolicy, - durable_write_rate_limit: Option<RateLimitConfig>, - admin_pubkeys: BTreeSet<PublicKeyHex>, - limits: RuntimeLimits, - tracing: RuntimeTracingConfig, -} - -impl TangleRuntimeConfig { - pub fn listen_addr(&self) -> SocketAddr { - self.listen_addr - } - - pub fn relay_connection_config(&self) -> &RelayConnectionConfig { - &self.relay_connection - } - - pub fn database_config(&self) -> &SurrealConnectionConfig { - &self.database - } - - pub fn admission_policy(&self) -> &AdmissionPolicy { - &self.admission_policy - } - - pub fn durable_write_rate_limit(&self) -> Option<RateLimitConfig> { - self.durable_write_rate_limit - } - - pub fn admin_pubkeys(&self) -> &BTreeSet<PublicKeyHex> { - &self.admin_pubkeys - } - - pub fn limits(&self) -> RuntimeLimits { - self.limits - } - - pub fn tracing_config(&self) -> &RuntimeTracingConfig { - &self.tracing - } - - pub fn websocket_state(&self, shutdown_signal: GracefulShutdownSignal) -> WebSocketHttpState { - WebSocketHttpState::with_shutdown(self.relay_connection.clone(), shutdown_signal) - } - - pub fn listings_state(&self, store: SurrealStore) -> ListingsHttpState { - ListingsHttpState::new(store, self.limits) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RuntimeTracingFormat { - Compact, - Json, -} - -impl RuntimeTracingFormat { - pub fn as_str(self) -> &'static str { - match self { - Self::Compact => "compact", - Self::Json => "json", - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RuntimeTracingConfig { - enabled: bool, - filter: String, - format: RuntimeTracingFormat, -} - -impl RuntimeTracingConfig { - pub fn new( - enabled: bool, - filter: impl Into<String>, - format: RuntimeTracingFormat, - ) -> Result<Self, RuntimeConfigError> { - let filter = filter.into(); - if filter.trim().is_empty() { - return Err(RuntimeConfigError::invalid( - "observability.tracing.filter must not be empty", - )); - } - Ok(Self { - enabled, - filter: filter.trim().to_owned(), - format, - }) - } - - pub fn disabled() -> Self { - Self { - enabled: false, - filter: "info,tangle=info,tangle_runtime=info".to_owned(), - format: RuntimeTracingFormat::Compact, - } - } - - pub fn enabled(&self) -> bool { - self.enabled - } - - pub fn filter(&self) -> &str { - &self.filter - } - - pub fn format(&self) -> RuntimeTracingFormat { - self.format - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RuntimeConfigErrorKind { - Read, - Parse, - Invalid, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RuntimeConfigError { - kind: RuntimeConfigErrorKind, - message: String, -} - -impl RuntimeConfigError { - pub fn new(kind: RuntimeConfigErrorKind, message: impl Into<String>) -> Self { - Self { - kind, - message: message.into(), - } - } - - pub fn read(message: impl Into<String>) -> Self { - Self::new(RuntimeConfigErrorKind::Read, message) - } - - pub fn parse(message: impl Into<String>) -> Self { - Self::new(RuntimeConfigErrorKind::Parse, message) + pub fn data_directory(&self) -> &Path { + &self.data_directory } - pub fn invalid(message: impl Into<String>) -> Self { - Self::new(RuntimeConfigErrorKind::Invalid, message) + pub fn groups_enabled(&self) -> bool { + self.groups_enabled } - pub fn kind(&self) -> RuntimeConfigErrorKind { - self.kind + pub fn readiness(&self) -> &BaseRelayReadinessState { + &self.readiness } - - pub fn message(&self) -> &str { - &self.message - } -} - -impl fmt::Display for RuntimeConfigError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(formatter, "{:?}: {}", self.kind, self.message) - } -} - -impl std::error::Error for RuntimeConfigError {} - -pub fn load_runtime_config( - path: impl AsRef<FsPath>, -) -> Result<TangleRuntimeConfig, RuntimeConfigError> { - let path = path.as_ref(); - let raw = fs::read_to_string(path).map_err(|error| { - RuntimeConfigError::read(format!( - "failed to read runtime config `{}`: {error}", - path.display() - )) - })?; - parse_runtime_config_json(&raw) -} - -pub fn parse_runtime_config_json(raw: &str) -> Result<TangleRuntimeConfig, RuntimeConfigError> { - let document = serde_json::from_str::<RuntimeConfigDocument>(raw).map_err(|error| { - RuntimeConfigError::parse(format!("runtime config JSON is invalid: {error}")) - })?; - runtime_config_from_document(document) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct RuntimeMigrationReport { - applied: u64, - already_applied: u64, - total: u64, } -impl RuntimeMigrationReport { - pub fn new(applied: u64, already_applied: u64, total: u64) -> Self { - Self { - applied, - already_applied, - total, - } - } - - pub fn applied(self) -> u64 { - self.applied - } - - pub fn already_applied(self) -> u64 { - self.already_applied - } - - pub fn total(self) -> u64 { - self.total - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RuntimeCommandErrorKind { - Unsupported, - Input, - Store, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RuntimeCommandError { - kind: RuntimeCommandErrorKind, - message: String, -} - -impl RuntimeCommandError { - pub fn new(kind: RuntimeCommandErrorKind, message: impl Into<String>) -> Self { - Self { - kind, - message: message.into(), - } - } - - pub fn unsupported(message: impl Into<String>) -> Self { - Self::new(RuntimeCommandErrorKind::Unsupported, message) - } - - pub fn input(message: impl Into<String>) -> Self { - Self::new(RuntimeCommandErrorKind::Input, message) - } - - pub fn store(message: impl Into<String>) -> Self { - Self::new(RuntimeCommandErrorKind::Store, message) - } - - pub fn kind(&self) -> RuntimeCommandErrorKind { - self.kind - } - - pub fn message(&self) -> &str { - &self.message - } +#[derive(Debug)] +pub enum TangleRuntimeLoadError { + ReadConfig { + path: PathBuf, + source: std::io::Error, + }, + ParseConfig(BaseRelayError), + OpenRelay(BaseRelayError), + ShutdownRelay(BaseRelayError), } -impl fmt::Display for RuntimeCommandError { +impl fmt::Display for TangleRuntimeLoadError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(formatter, "{:?}: {}", self.kind, self.message) - } -} - -impl std::error::Error for RuntimeCommandError {} - -pub async fn migrate_runtime_database( - config: &TangleRuntimeConfig, -) -> Result<RuntimeMigrationReport, RuntimeCommandError> { - tracing::info!("starting runtime database migration"); - let store = connect_runtime_store(config).await?; - let outcomes = store - .apply_plan(&base_migration_plan()) - .await - .map_err(|error| RuntimeCommandError::store(error.to_string()))?; - let applied = outcomes - .iter() - .filter(|outcome| **outcome == MigrationApplyOutcome::Applied) - .count() as u64; - let already_applied = outcomes - .iter() - .filter(|outcome| **outcome == MigrationApplyOutcome::AlreadyApplied) - .count() as u64; - tracing::info!("finished runtime database migration"); - Ok(RuntimeMigrationReport::new( - applied, - already_applied, - outcomes.len() as u64, - )) -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub struct RuntimeEventImportReport { - total: u64, - inserted: u64, - duplicate: u64, - projected: u64, - skipped: u64, -} - -impl RuntimeEventImportReport { - pub fn new(total: u64, inserted: u64, duplicate: u64, projected: u64, skipped: u64) -> Self { - Self { - total, - inserted, - duplicate, - projected, - skipped, - } - } - - pub fn total(self) -> u64 { - self.total - } - - pub fn inserted(self) -> u64 { - self.inserted - } - - pub fn duplicate(self) -> u64 { - self.duplicate - } - - pub fn projected(self) -> u64 { - self.projected - } - - pub fn skipped(self) -> u64 { - self.skipped - } - - fn record(&mut self, outcome: RuntimeEventImportOutcome) { - self.total += 1; - match outcome { - RuntimeEventImportOutcome::Inserted { projected } => { - self.inserted += 1; - if projected { - self.projected += 1; - } - } - RuntimeEventImportOutcome::Duplicate => { - self.duplicate += 1; - } - RuntimeEventImportOutcome::Skipped => { - self.skipped += 1; + match self { + Self::ReadConfig { path, source } => { + write!( + formatter, + "failed to read tangle runtime config `{}`: {source}", + path.display() + ) } + Self::ParseConfig(error) => write!(formatter, "{error}"), + Self::OpenRelay(error) => write!(formatter, "{error}"), + Self::ShutdownRelay(error) => write!(formatter, "{error}"), } } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum RuntimeEventImportOutcome { - Inserted { projected: bool }, - Duplicate, - Skipped, -} +impl std::error::Error for TangleRuntimeLoadError {} -pub async fn import_events_from_path( - config: &TangleRuntimeConfig, - path: impl AsRef<FsPath>, -) -> Result<RuntimeEventImportReport, RuntimeCommandError> { +pub fn load_base_relay_runtime_config( + path: impl AsRef<Path>, +) -> Result<BaseRelayRuntimeConfig, TangleRuntimeLoadError> { let path = path.as_ref(); - tracing::info!("starting event import"); - let raw = fs::read_to_string(path).map_err(|error| { - RuntimeCommandError::input(format!( - "failed to read event import file `{}`: {error}", - path.display() - )) + let raw = fs::read_to_string(path).map_err(|source| TangleRuntimeLoadError::ReadConfig { + path: path.to_path_buf(), + source, })?; - let events = parse_event_import_document(&raw)?; - let store = connect_runtime_store(config).await?; - let report = import_events_into_store(config, &store, events).await?; - tracing::info!("finished event import"); - Ok(report) -} - -async fn import_events_into_store( - config: &TangleRuntimeConfig, - store: &SurrealStore, - events: Vec<Event>, -) -> Result<RuntimeEventImportReport, RuntimeCommandError> { - store - .apply_plan(&base_migration_plan()) - .await - .map_err(|error| RuntimeCommandError::store(error.to_string()))?; - let validator = EventValidator::new( - config.limits(), - config - .admission_policy() - .clone() - .with_write_auth_required(false), - ); - let mut report = RuntimeEventImportReport::default(); - let now = now_timestamp(); - for event in events { - let outcome = import_single_event(store, &validator, event, now).await?; - report.record(outcome); - } - Ok(report) -} - -async fn import_single_event( - store: &SurrealStore, - validator: &EventValidator, - event: Event, - now: UnixTimestamp, -) -> Result<RuntimeEventImportOutcome, RuntimeCommandError> { - if is_non_auth_ephemeral(&event) { - return Ok(RuntimeEventImportOutcome::Skipped); - } - let validated = match validator.validate(&event, &AdmissionContext::unauthenticated(), now) { - Ok(validated) => validated, - Err(_) => return Ok(RuntimeEventImportOutcome::Skipped), - }; - if validated.admission().effect() != AdmissionEffect::AuthenticateOnly { - let raw_outcome = store - .store_raw_event(&StoredEvent::new(event.clone(), now)) - .await - .map_err(|error| RuntimeCommandError::store(error.to_string()))?; - if raw_outcome == StoreEventOutcome::Duplicate { - return Ok(RuntimeEventImportOutcome::Duplicate); - } - let projected = - project_stored_event(store, &event, validated.admission().effect(), now).await?; - return Ok(RuntimeEventImportOutcome::Inserted { projected }); - } - Ok(RuntimeEventImportOutcome::Skipped) -} - -async fn project_stored_event( - store: &SurrealStore, - event: &Event, - effect: AdmissionEffect, - now: UnixTimestamp, -) -> Result<bool, RuntimeCommandError> { - store - .index_event_tags(event) - .await - .map_err(|_| RuntimeCommandError::store("event projection failed"))?; - store - .maintain_current_event(event) - .await - .map_err(|_| RuntimeCommandError::store("event projection failed"))?; - store - .apply_deletion_markers(event) - .await - .map_err(|_| RuntimeCommandError::store("event projection failed"))?; - store - .store_listing_revision(event, now) - .await - .map_err(|_| RuntimeCommandError::store("event projection failed"))?; - let comment_projected = matches!( - store - .project_comment(event, now) - .await - .map_err(|_| RuntimeCommandError::store("event projection failed"))?, - CommentProjectionOutcome::Projected - ); - let reaction_projected = matches!( - store - .project_reaction(event, now) - .await - .map_err(|_| RuntimeCommandError::store("event projection failed"))?, - ReactionProjectionOutcome::Projected - ); - let long_form_projected = matches!( - store - .project_long_form(event, now) - .await - .map_err(|_| RuntimeCommandError::store("event projection failed"))?, - LongFormProjectionOutcome::Projected - ); - let forum_thread_projected = matches!( - store - .project_forum_thread(event, now) - .await - .map_err(|_| RuntimeCommandError::store("event projection failed"))?, - ForumThreadProjectionOutcome::Projected - ); - let label_projected = matches!( - store - .project_label(event, now) - .await - .map_err(|_| RuntimeCommandError::store("event projection failed"))?, - LabelProjectionOutcome::Projected - ); - let report_projected = matches!( - store - .project_report(event, now) - .await - .map_err(|_| RuntimeCommandError::store("event projection failed"))?, - ReportProjectionOutcome::Projected - ); - let seller_profile_projected = matches!( - store - .project_seller_profile(event, now) - .await - .map_err(|_| RuntimeCommandError::store("event projection failed"))?, - SellerProfileProjectionOutcome::Projected - ); - if effect == AdmissionEffect::StoreRawAndProjectPublicListing { - store - .project_current_listing(event, now) - .await - .map_err(|_| RuntimeCommandError::store("event projection failed"))?; - store - .project_listing_helpers(event) - .await - .map_err(|_| RuntimeCommandError::store("event projection failed"))?; - store - .index_listing_search_document(event) - .await - .map_err(|_| RuntimeCommandError::store("event projection failed"))?; - return Ok(true); - } - Ok(comment_projected - || reaction_projected - || long_form_projected - || forum_thread_projected - || label_projected - || report_projected - || seller_profile_projected) -} - -fn parse_event_import_document(raw: &str) -> Result<Vec<Event>, RuntimeCommandError> { - let trimmed = raw.trim(); - if trimmed.is_empty() { - return Ok(Vec::new()); - } - match serde_json::from_str::<serde_json::Value>(trimmed) { - Ok(serde_json::Value::Array(events)) => events - .iter() - .enumerate() - .map(|(index, value)| event_from_import_value(value, index + 1)) - .collect(), - Ok(value @ serde_json::Value::Object(_)) => { - event_from_import_value(&value, 1).map(|event| vec![event]) - } - Ok(_) => Err(RuntimeCommandError::input( - "event import file must contain event objects", - )), - Err(_) => trimmed - .lines() - .enumerate() - .filter_map(|(index, line)| { - let line = line.trim(); - if line.is_empty() { - None - } else { - Some(event_from_import_line(line, index + 1)) - } - }) - .collect(), - } -} - -fn event_from_import_value( - value: &serde_json::Value, - index: usize, -) -> Result<Event, RuntimeCommandError> { - let raw = RawEventJson::new(&value.to_string()).expect("serialized JSON value is non-empty"); - parse_event_json(&raw).map_err(|error| { - RuntimeCommandError::input(format!("event import item {index} is invalid: {error}")) + parse_base_relay_runtime_config_json(&raw).map_err(TangleRuntimeLoadError::ParseConfig) +} + +pub fn open_base_relay_from_config_path( + path: impl AsRef<Path>, +) -> Result<TangleRuntimeStartupReport, TangleRuntimeLoadError> { + let config = load_base_relay_runtime_config(path)?; + let mut relay = config + .open_relay() + .map_err(TangleRuntimeLoadError::OpenRelay)?; + let readiness = relay.readiness_state(); + relay + .shutdown() + .map_err(TangleRuntimeLoadError::ShutdownRelay)?; + Ok(TangleRuntimeStartupReport { + relay_url: config.relay_url().to_owned(), + data_directory: config.pocket_config().data_directory().to_path_buf(), + groups_enabled: config.groups().enabled(), + readiness, }) } - -fn event_from_import_line(line: &str, index: usize) -> Result<Event, RuntimeCommandError> { - let raw = RawEventJson::new(line).expect("import lines are non-empty after trimming"); - parse_event_json(&raw).map_err(|error| { - RuntimeCommandError::input(format!("event import line {index} is invalid: {error}")) - }) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct RuntimeEventExportReport { - exported: u64, -} - -impl RuntimeEventExportReport { - pub fn new(exported: u64) -> Self { - Self { exported } - } - - pub fn exported(self) -> u64 { - self.exported - } -} - -pub async fn export_events_to_path( - config: &TangleRuntimeConfig, - path: impl AsRef<FsPath>, -) -> Result<RuntimeEventExportReport, RuntimeCommandError> { - let path = path.as_ref(); - tracing::info!("starting event export"); - let store = connect_runtime_store(config).await?; - store - .apply_plan(&base_migration_plan()) - .await - .map_err(|error| RuntimeCommandError::store(error.to_string()))?; - let rows = store - .query_raw_events(&Filter::empty()) - .await - .map_err(|error| RuntimeCommandError::store(error.to_string()))?; - let mut output = String::new(); - for row in &rows { - output.push_str(&runtime_row_string(row, "raw_json")?); - output.push('\n'); - } - fs::write(path, output).map_err(|error| { - RuntimeCommandError::input(format!( - "failed to write event export file `{}`: {error}", - path.display() - )) - })?; - tracing::info!("finished event export"); - Ok(RuntimeEventExportReport::new(rows.len() as u64)) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RuntimeBackupReport { - output_dir: PathBuf, - raw_events_path: PathBuf, - raw_event_count: u64, - raw_events_sha256: String, - manifest_path: PathBuf, - manifest_sha256: String, - surrealdb_export_available: bool, -} - -impl RuntimeBackupReport { - pub fn new( - output_dir: PathBuf, - raw_events_path: PathBuf, - raw_event_count: u64, - raw_events_sha256: String, - manifest_path: PathBuf, - manifest_sha256: String, - surrealdb_export_available: bool, - ) -> Self { - Self { - output_dir, - raw_events_path, - raw_event_count, - raw_events_sha256, - manifest_path, - manifest_sha256, - surrealdb_export_available, - } - } - - pub fn output_dir(&self) -> &FsPath { - &self.output_dir - } - - pub fn raw_events_path(&self) -> &FsPath { - &self.raw_events_path - } - - pub fn raw_event_count(&self) -> u64 { - self.raw_event_count - } - - pub fn raw_events_sha256(&self) -> &str { - &self.raw_events_sha256 - } - - pub fn manifest_path(&self) -> &FsPath { - &self.manifest_path - } - - pub fn manifest_sha256(&self) -> &str { - &self.manifest_sha256 - } - - pub fn surrealdb_export_available(&self) -> bool { - self.surrealdb_export_available - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -struct RuntimeBackupManifestDocument { - format: String, - database: RuntimeBackupDatabaseDocument, - raw_events: RuntimeBackupArtifactDocument, - surrealdb_export: RuntimeBackupOptionalArtifactDocument, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -struct RuntimeBackupDatabaseDocument { - namespace: String, - database: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -struct RuntimeBackupArtifactDocument { - path: String, - count: u64, - sha256: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -struct RuntimeBackupOptionalArtifactDocument { - available: bool, - path: Option<String>, - sha256: Option<String>, -} - -pub async fn backup_runtime_database( - config: &TangleRuntimeConfig, - output_dir: impl AsRef<FsPath>, -) -> Result<RuntimeBackupReport, RuntimeCommandError> { - let output_dir = output_dir.as_ref(); - tracing::info!("starting runtime backup"); - let store = connect_runtime_store(config).await?; - let report = backup_runtime_store(config, &store, output_dir).await?; - tracing::info!("finished runtime backup"); - Ok(report) -} - -async fn backup_runtime_store( - config: &TangleRuntimeConfig, - store: &SurrealStore, - output_dir: &FsPath, -) -> Result<RuntimeBackupReport, RuntimeCommandError> { - fs::create_dir_all(output_dir).map_err(|error| { - RuntimeCommandError::input(format!( - "failed to create backup directory `{}`: {error}", - output_dir.display() - )) - })?; - store - .apply_plan(&base_migration_plan()) - .await - .map_err(|error| RuntimeCommandError::store(error.to_string()))?; - let rows = store - .backup_raw_events() - .await - .map_err(|error| RuntimeCommandError::store(error.to_string()))?; - let mut raw_events = String::new(); - for row in &rows { - raw_events.push_str(&runtime_row_string(row, "raw_json")?); - raw_events.push('\n'); - } - let raw_events_path = output_dir.join("raw-events.jsonl"); - fs::write(&raw_events_path, raw_events.as_bytes()).map_err(|error| { - RuntimeCommandError::input(format!( - "failed to write backup raw events file `{}`: {error}", - raw_events_path.display() - )) - })?; - let raw_events_sha256 = sha256_hex(raw_events.as_bytes()); - let manifest = RuntimeBackupManifestDocument { - format: "tangle-backup-v1".to_owned(), - database: RuntimeBackupDatabaseDocument { - namespace: config.database_config().namespace().to_owned(), - database: config.database_config().database().to_owned(), - }, - raw_events: RuntimeBackupArtifactDocument { - path: "raw-events.jsonl".to_owned(), - count: rows.len() as u64, - sha256: raw_events_sha256.clone(), - }, - surrealdb_export: RuntimeBackupOptionalArtifactDocument { - available: false, - path: None, - sha256: None, - }, - }; - let mut manifest_json = - serde_json::to_vec_pretty(&manifest).expect("backup manifest is serializable"); - manifest_json.push(b'\n'); - let manifest_path = output_dir.join("manifest.json"); - fs::write(&manifest_path, &manifest_json).map_err(|error| { - RuntimeCommandError::input(format!( - "failed to write backup manifest file `{}`: {error}", - manifest_path.display() - )) - })?; - let manifest_sha256 = sha256_hex(&manifest_json); - Ok(RuntimeBackupReport::new( - output_dir.to_path_buf(), - raw_events_path, - rows.len() as u64, - raw_events_sha256, - manifest_path, - manifest_sha256, - false, - )) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RuntimeRestoreReport { - input_dir: PathBuf, - raw_event_count: u64, - raw_events_sha256: String, - import_report: RuntimeEventImportReport, - rebuild_report: RuntimeProjectionRebuildReport, -} - -impl RuntimeRestoreReport { - pub fn new( - input_dir: PathBuf, - raw_event_count: u64, - raw_events_sha256: String, - import_report: RuntimeEventImportReport, - rebuild_report: RuntimeProjectionRebuildReport, - ) -> Self { - Self { - input_dir, - raw_event_count, - raw_events_sha256, - import_report, - rebuild_report, - } - } - - pub fn input_dir(&self) -> &FsPath { - &self.input_dir - } - - pub fn raw_event_count(&self) -> u64 { - self.raw_event_count - } - - pub fn raw_events_sha256(&self) -> &str { - &self.raw_events_sha256 - } - - pub fn import_report(&self) -> RuntimeEventImportReport { - self.import_report - } - - pub fn rebuild_report(&self) -> RuntimeProjectionRebuildReport { - self.rebuild_report - } -} - -pub async fn restore_runtime_database( - config: &TangleRuntimeConfig, - input_dir: impl AsRef<FsPath>, -) -> Result<RuntimeRestoreReport, RuntimeCommandError> { - let input_dir = input_dir.as_ref(); - tracing::info!("starting runtime restore"); - let store = connect_runtime_store(config).await?; - let report = restore_runtime_store(config, &store, input_dir).await?; - tracing::info!("finished runtime restore"); - Ok(report) -} - -async fn restore_runtime_store( - config: &TangleRuntimeConfig, - store: &SurrealStore, - input_dir: &FsPath, -) -> Result<RuntimeRestoreReport, RuntimeCommandError> { - let manifest_path = input_dir.join("manifest.json"); - let manifest_raw = fs::read_to_string(&manifest_path).map_err(|error| { - RuntimeCommandError::input(format!( - "failed to read backup manifest file `{}`: {error}", - manifest_path.display() - )) - })?; - let manifest: RuntimeBackupManifestDocument = - serde_json::from_str(&manifest_raw).map_err(|error| { - RuntimeCommandError::input(format!("backup manifest JSON is invalid: {error}")) - })?; - validate_backup_manifest(&manifest)?; - let raw_events_path = backup_artifact_path(input_dir, &manifest.raw_events.path)?; - let raw_events = fs::read_to_string(&raw_events_path).map_err(|error| { - RuntimeCommandError::input(format!( - "failed to read backup raw events file `{}`: {error}", - raw_events_path.display() - )) - })?; - let raw_events_sha256 = sha256_hex(raw_events.as_bytes()); - if raw_events_sha256 != manifest.raw_events.sha256 { - return Err(RuntimeCommandError::input(format!( - "backup raw events checksum mismatch: expected {}, got {}", - manifest.raw_events.sha256, raw_events_sha256 - ))); - } - let events = parse_event_import_document(&raw_events)?; - if events.len() as u64 != manifest.raw_events.count { - return Err(RuntimeCommandError::input(format!( - "backup raw events count mismatch: expected {}, got {}", - manifest.raw_events.count, - events.len() - ))); - } - let import_report = import_events_into_store(config, store, events).await?; - let rebuild_report = rebuild_projections_in_store(config, store).await?; - Ok(RuntimeRestoreReport::new( - input_dir.to_path_buf(), - manifest.raw_events.count, - raw_events_sha256, - import_report, - rebuild_report, - )) -} - -fn validate_backup_manifest( - manifest: &RuntimeBackupManifestDocument, -) -> Result<(), RuntimeCommandError> { - if manifest.format != "tangle-backup-v1" { - return Err(RuntimeCommandError::input(format!( - "backup manifest format is unsupported: {}", - manifest.format - ))); - } - if manifest.raw_events.path.trim().is_empty() { - return Err(RuntimeCommandError::input( - "backup manifest raw_events.path must not be empty", - )); - } - Ok(()) -} - -fn backup_artifact_path( - input_dir: &FsPath, - artifact: &str, -) -> Result<PathBuf, RuntimeCommandError> { - let path = FsPath::new(artifact); - if path.is_absolute() - || path - .components() - .any(|component| matches!(component, Component::ParentDir)) - { - return Err(RuntimeCommandError::input( - "backup manifest artifact paths must be relative to the backup directory", - )); - } - Ok(input_dir.join(path)) -} - -fn runtime_row_string( - row: &serde_json::Value, - field: &'static str, -) -> Result<String, RuntimeCommandError> { - row.get(field) - .and_then(serde_json::Value::as_str) - .map(str::to_owned) - .ok_or_else(|| RuntimeCommandError::store(format!("stored row field `{field}` is invalid"))) -} - -fn sha256_hex(bytes: &[u8]) -> String { - Sha256::digest(bytes) - .iter() - .map(|byte| format!("{byte:02x}")) - .collect() -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct RuntimeProjectionRebuildReport { - scanned: u64, - rebuilt: u64, - projected: u64, - skipped: u64, -} - -impl RuntimeProjectionRebuildReport { - pub fn new(scanned: u64, rebuilt: u64, projected: u64, skipped: u64) -> Self { - Self { - scanned, - rebuilt, - projected, - skipped, - } - } - - pub fn scanned(self) -> u64 { - self.scanned - } - - pub fn rebuilt(self) -> u64 { - self.rebuilt - } - - pub fn projected(self) -> u64 { - self.projected - } - - pub fn skipped(self) -> u64 { - self.skipped - } - - fn record(&mut self, outcome: RuntimeProjectionRebuildOutcome) { - self.scanned += 1; - match outcome { - RuntimeProjectionRebuildOutcome::Rebuilt { projected } => { - self.rebuilt += 1; - if projected { - self.projected += 1; - } - } - RuntimeProjectionRebuildOutcome::Skipped => { - self.skipped += 1; - } - } - } -} - -impl Default for RuntimeProjectionRebuildReport { - fn default() -> Self { - Self::new(0, 0, 0, 0) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum RuntimeProjectionRebuildOutcome { - Rebuilt { projected: bool }, - Skipped, -} - -pub async fn rebuild_projections( - config: &TangleRuntimeConfig, -) -> Result<RuntimeProjectionRebuildReport, RuntimeCommandError> { - tracing::info!("starting projection rebuild"); - let store = connect_runtime_store(config).await?; - let report = rebuild_projections_in_store(config, &store).await?; - tracing::info!("finished projection rebuild"); - Ok(report) -} - -async fn rebuild_projections_in_store( - config: &TangleRuntimeConfig, - store: &SurrealStore, -) -> Result<RuntimeProjectionRebuildReport, RuntimeCommandError> { - store - .apply_plan(&base_migration_plan()) - .await - .map_err(|error| RuntimeCommandError::store(error.to_string()))?; - let rows = store - .query_raw_events(&Filter::empty()) - .await - .map_err(|error| RuntimeCommandError::store(error.to_string()))?; - let validator = EventValidator::new( - config.limits(), - config - .admission_policy() - .clone() - .with_write_auth_required(false), - ); - let now = now_timestamp(); - let mut report = RuntimeProjectionRebuildReport::default(); - for row in rows { - let raw = RawEventJson::new(&runtime_row_string(&row, "raw_json")?) - .map_err(|error| RuntimeCommandError::store(error.to_string()))?; - let event = parse_event_json(&raw) - .map_err(|error| RuntimeCommandError::store(error.to_string()))?; - let outcome = rebuild_single_event_projection(store, &validator, event, now).await?; - report.record(outcome); - } - Ok(report) -} - -async fn rebuild_single_event_projection( - store: &SurrealStore, - validator: &EventValidator, - event: Event, - now: UnixTimestamp, -) -> Result<RuntimeProjectionRebuildOutcome, RuntimeCommandError> { - if is_non_auth_ephemeral(&event) { - return Ok(RuntimeProjectionRebuildOutcome::Skipped); - } - let validated = match validator.validate(&event, &AdmissionContext::unauthenticated(), now) { - Ok(validated) => validated, - Err(_) => return Ok(RuntimeProjectionRebuildOutcome::Skipped), - }; - if validated.admission().effect() != AdmissionEffect::AuthenticateOnly { - let projected = - project_stored_event(store, &event, validated.admission().effect(), now).await?; - return Ok(RuntimeProjectionRebuildOutcome::Rebuilt { projected }); - } - Ok(RuntimeProjectionRebuildOutcome::Skipped) -} - -fn is_non_auth_ephemeral(event: &Event) -> bool { - event.unsigned().kind().is_ephemeral() && event.unsigned().kind().as_u32() != 22_242 -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct RuntimeServerReport { - listen_addr: SocketAddr, -} - -impl RuntimeServerReport { - pub fn new(listen_addr: SocketAddr) -> Self { - Self { listen_addr } - } - - pub fn listen_addr(self) -> SocketAddr { - self.listen_addr - } -} - -#[derive(Debug, Clone)] -pub struct RuntimeServer { - config: TangleRuntimeConfig, - shutdown_signal: GracefulShutdownSignal, -} - -impl RuntimeServer { - pub fn new(config: TangleRuntimeConfig, shutdown_signal: GracefulShutdownSignal) -> Self { - Self { - config, - shutdown_signal, - } - } - - pub async fn run(&self) -> Result<RuntimeServerReport, RuntimeCommandError> { - tracing::info!("starting runtime server"); - let store = connect_runtime_store(&self.config).await?; - store - .apply_plan(&base_migration_plan()) - .await - .map_err(|error| RuntimeCommandError::store(error.to_string()))?; - let listener = TcpListener::bind(self.config.listen_addr()) - .await - .map_err(|error| RuntimeCommandError::store(format!("listen failed: {error}")))?; - let listen_addr = listener - .local_addr() - .map_err(|error| RuntimeCommandError::store(format!("listen addr failed: {error}")))?; - let mut shutdown = self.shutdown_signal.subscribe(); - let app = runtime_router(self.config.clone(), store, self.shutdown_signal.clone()); - axum::serve(listener, app) - .with_graceful_shutdown(async move { - shutdown.wait_for_shutdown().await; - }) - .await - .map_err(|error| RuntimeCommandError::store(format!("server failed: {error}")))?; - tracing::info!("runtime server stopped"); - Ok(RuntimeServerReport::new(listen_addr)) - } -} - -pub async fn run_runtime_server( - config: TangleRuntimeConfig, - shutdown_signal: GracefulShutdownSignal, -) -> Result<RuntimeServerReport, RuntimeCommandError> { - RuntimeServer::new(config, shutdown_signal).run().await -} - -async fn connect_runtime_store( - config: &TangleRuntimeConfig, -) -> Result<SurrealStore, RuntimeCommandError> { - SurrealStore::connect(config.database_config()) - .await - .map_err(|error| RuntimeCommandError::store(error.to_string())) -} - -#[derive(Clone)] -struct RuntimeRelayState { - config: TangleRuntimeConfig, - store: SurrealStore, - shutdown_signal: GracefulShutdownSignal, - event_tx: broadcast::Sender<Event>, - next_connection_id: Arc<AtomicU64>, -} - -impl RuntimeRelayState { - fn new( - config: TangleRuntimeConfig, - store: SurrealStore, - shutdown_signal: GracefulShutdownSignal, - ) -> Self { - let (event_tx, _) = broadcast::channel(config.limits().values().live_event_buffer as usize); - Self { - config, - store, - shutdown_signal, - event_tx, - next_connection_id: Arc::new(AtomicU64::new(1)), - } - } - - fn next_connection(&self) -> RelayConnection { - let id = self.next_connection_id.fetch_add(1, Ordering::Relaxed); - RelayConnection::new( - RelayConnectionId::new(&format!("conn-{id}")) - .expect("generated connection id is valid"), - self.config.relay_connection_config().clone(), - ) - } - - fn validator(&self) -> EventValidator { - EventValidator::new(self.config.limits(), self.config.admission_policy().clone()) - } -} - -fn runtime_router( - config: TangleRuntimeConfig, - store: SurrealStore, - shutdown_signal: GracefulShutdownSignal, -) -> Router { - let state = RuntimeRelayState::new(config, store, shutdown_signal); - Router::new() - .route("/", get(runtime_relay_info)) - .route("/ws", get(runtime_websocket_upgrade)) - .route("/healthz", get(runtime_healthz)) - .route("/readyz", get(runtime_readyz)) - .route("/metrics", get(runtime_metrics)) - .route("/api/listings", get(runtime_listings)) - .route("/api/listings/{pubkey}/{d}", get(runtime_listing_detail)) - .route( - "/api/listings/{pubkey}/{d}/comments", - get(runtime_listing_comments), - ) - .route( - "/api/listings/{pubkey}/{d}/reactions", - get(runtime_listing_reactions), - ) - .route("/api/forum/threads", get(runtime_forum_threads)) - .route( - "/api/forum/threads/{event_id}", - get(runtime_forum_thread_detail), - ) - .route( - "/api/forum/threads/{event_id}/comments", - get(runtime_forum_thread_comments), - ) - .route("/api/search", get(runtime_marketplace_search)) - .route("/api/sellers/{pubkey}", get(runtime_seller_detail)) - .route( - "/api/admin/sellers/{pubkey}/approve", - post(runtime_admin_approve_seller), - ) - .route( - "/api/admin/pubkeys/{pubkey}/block", - post(runtime_admin_block_pubkey), - ) - .route( - "/api/admin/events/{event_id}/hide", - post(runtime_admin_hide_event), - ) - .route( - "/api/admin/events/{event_id}/unhide", - post(runtime_admin_unhide_event), - ) - .route( - "/api/admin/moderation/labels", - get(runtime_admin_moderation_labels), - ) - .route( - "/api/admin/moderation/reports", - get(runtime_admin_moderation_reports), - ) - .with_state(state) -} - -async fn runtime_relay_info(headers: HeaderMap) -> Response { - relay_info(State(RelayInfoDocument::tangle_default()), headers).await -} - -async fn runtime_websocket_upgrade( - State(state): State<RuntimeRelayState>, - websocket: WebSocketUpgrade, -) -> Response { - websocket - .on_upgrade(move |socket| async move { - handle_websocket(socket, state).await; - }) - .into_response() -} - -async fn runtime_healthz() -> Json<HealthDocument> { - healthz().await -} - -async fn runtime_readyz( - State(state): State<RuntimeRelayState>, -) -> (StatusCode, Json<ReadinessDocument>) { - readyz(State(runtime_readiness_state(&state.store).await)).await -} - -async fn runtime_readiness_state(store: &SurrealStore) -> ReadinessState { - let database = readiness_status(store.ping().await); - let migrations = - readiness_status_after(database.is_ready(), runtime_migrations_ready(store)).await; - let repository = readiness_status_after(database.is_ready() && migrations.is_ready(), async { - store.metrics_snapshot().await.map(|_| ()) - }) - .await; - ReadinessState::new(database, migrations, repository) -} - -async fn runtime_migrations_ready(store: &SurrealStore) -> Result<(), RuntimeCommandError> { - let applied = store - .applied_migrations() - .await - .map_err(|error| RuntimeCommandError::store(error.to_string()))?; - let plan = base_migration_plan(); - if applied.len() != plan.migrations().len() { - return Err(RuntimeCommandError::store( - "runtime migrations are incomplete", - )); - } - for (applied, expected) in applied.iter().zip(plan.migrations()) { - if applied.name() != expected.name() || applied.checksum() != expected.checksum() { - return Err(RuntimeCommandError::store( - "runtime migrations do not match", - )); - } - } - Ok(()) -} - -fn readiness_status<E>(result: Result<(), E>) -> ReadinessCheckStatus { - if result.is_ok() { - ReadinessCheckStatus::Ready - } else { - ReadinessCheckStatus::NotReady - } -} - -async fn readiness_status_after<F, E>(dependencies_ready: bool, result: F) -> ReadinessCheckStatus -where - F: Future<Output = Result<(), E>>, -{ - if dependencies_ready { - readiness_status(result.await) - } else { - ReadinessCheckStatus::NotReady - } -} - -async fn runtime_metrics(State(state): State<RuntimeRelayState>) -> Result<Response, ApiError> { - metrics(State(MetricsHttpState::new(state.store))).await -} - -async fn runtime_listings( - State(state): State<RuntimeRelayState>, - RawQuery(query): RawQuery, -) -> Result<Json<ListingsDocument>, ApiError> { - listings( - State(ListingsHttpState::new( - state.store.clone(), - state.config.limits(), - )), - RawQuery(query), - ) - .await -} - -async fn runtime_listing_detail( - State(state): State<RuntimeRelayState>, - Path((pubkey, d)): Path<(String, String)>, -) -> Result<Json<ListingDetailDocument>, ApiError> { - listing_detail( - State(ListingsHttpState::new( - state.store.clone(), - state.config.limits(), - )), - Path((pubkey, d)), - ) - .await -} - -async fn runtime_listing_comments( - State(state): State<RuntimeRelayState>, - Path((pubkey, d)): Path<(String, String)>, - RawQuery(query): RawQuery, -) -> Result<Json<ListingCommentsDocument>, ApiError> { - listing_comments( - State(ListingsHttpState::new( - state.store.clone(), - state.config.limits(), - )), - Path((pubkey, d)), - RawQuery(query), - ) - .await -} - -async fn runtime_listing_reactions( - State(state): State<RuntimeRelayState>, - Path((pubkey, d)): Path<(String, String)>, -) -> Result<Json<ReactionCountsDocument>, ApiError> { - listing_reactions( - State(ListingsHttpState::new( - state.store.clone(), - state.config.limits(), - )), - Path((pubkey, d)), - ) - .await -} - -async fn runtime_forum_threads( - State(state): State<RuntimeRelayState>, - RawQuery(query): RawQuery, -) -> Result<Json<ForumThreadsDocument>, ApiError> { - forum_threads( - State(ListingsHttpState::new( - state.store.clone(), - state.config.limits(), - )), - RawQuery(query), - ) - .await -} - -async fn runtime_forum_thread_detail( - State(state): State<RuntimeRelayState>, - Path(event_id): Path<String>, -) -> Result<Json<ForumThreadDetailDocument>, ApiError> { - forum_thread_detail( - State(ListingsHttpState::new( - state.store.clone(), - state.config.limits(), - )), - Path(event_id), - ) - .await -} - -async fn runtime_forum_thread_comments( - State(state): State<RuntimeRelayState>, - Path(event_id): Path<String>, - RawQuery(query): RawQuery, -) -> Result<Json<ListingCommentsDocument>, ApiError> { - forum_thread_comments( - State(ListingsHttpState::new( - state.store.clone(), - state.config.limits(), - )), - Path(event_id), - RawQuery(query), - ) - .await -} - -async fn runtime_marketplace_search( - State(state): State<RuntimeRelayState>, - RawQuery(query): RawQuery, -) -> Result<Json<ListingsDocument>, ApiError> { - marketplace_search( - State(ListingsHttpState::new( - state.store.clone(), - state.config.limits(), - )), - RawQuery(query), - ) - .await -} - -async fn runtime_seller_detail( - State(state): State<RuntimeRelayState>, - Path(pubkey): Path<String>, -) -> Result<Json<SellerDocument>, ApiError> { - seller_detail( - State(ListingsHttpState::new( - state.store.clone(), - state.config.limits(), - )), - Path(pubkey), - ) - .await -} - -async fn runtime_admin_approve_seller( - State(state): State<RuntimeRelayState>, - headers: HeaderMap, - Path(pubkey): Path<String>, -) -> Result<Json<AdminPolicyDocument>, ApiError> { - let _admin = require_admin_pubkey(&state.config, &headers)?; - let pubkey = parse_pubkey("pubkey", &pubkey)?; - state - .store - .set_seller_approved(pubkey.as_str(), true, now_timestamp()) - .await - .map_err(|_| ApiError::internal())?; - Ok(Json(AdminPolicyDocument::new( - "approved", - "seller", - pubkey.as_str(), - ))) -} - -async fn runtime_admin_block_pubkey( - State(state): State<RuntimeRelayState>, - headers: HeaderMap, - Path(pubkey): Path<String>, -) -> Result<Json<AdminPolicyDocument>, ApiError> { - let _admin = require_admin_pubkey(&state.config, &headers)?; - let pubkey = parse_pubkey("pubkey", &pubkey)?; - state - .store - .set_pubkey_blocked(pubkey.as_str(), true, now_timestamp()) - .await - .map_err(|_| ApiError::internal())?; - Ok(Json(AdminPolicyDocument::new( - "blocked", - "pubkey", - pubkey.as_str(), - ))) -} - -async fn runtime_admin_hide_event( - State(state): State<RuntimeRelayState>, - headers: HeaderMap, - Path(event_id): Path<String>, - Json(request): Json<AdminEventPolicyRequest>, -) -> Result<Json<AdminPolicyDocument>, ApiError> { - let admin = require_admin_pubkey(&state.config, &headers)?; - let event_id = EventId::new(&event_id) - .map_err(|_| invalid_parameter("event_id", "must be a 64-character hex event id"))?; - let reason = request.reason.unwrap_or_else(|| "admin policy".to_owned()); - let outcome = state - .store - .hide_event( - &event_id, - &reason, - "admin_api", - admin.as_str(), - now_timestamp(), - ) - .await - .map_err(|_| ApiError::internal())?; - if outcome == tangle_store_surreal::HiddenEventOutcome::NotFound { - return Err(ApiError::not_found("event not found")); - } - Ok(Json(AdminPolicyDocument::new( - "hidden", - "event", - event_id.as_str(), - ))) -} - -async fn runtime_admin_unhide_event( - State(state): State<RuntimeRelayState>, - headers: HeaderMap, - Path(event_id): Path<String>, - Json(request): Json<AdminEventPolicyRequest>, -) -> Result<Json<AdminPolicyDocument>, ApiError> { - let admin = require_admin_pubkey(&state.config, &headers)?; - let event_id = EventId::new(&event_id) - .map_err(|_| invalid_parameter("event_id", "must be a 64-character hex event id"))?; - let reason = request.reason.unwrap_or_else(|| "admin policy".to_owned()); - let outcome = state - .store - .unhide_event(&event_id, &reason, admin.as_str(), now_timestamp()) - .await - .map_err(|_| ApiError::internal())?; - if outcome == tangle_store_surreal::HiddenEventOutcome::NotFound { - return Err(ApiError::not_found("event not found")); - } - Ok(Json(AdminPolicyDocument::new( - "unhidden", - "event", - event_id.as_str(), - ))) -} - -async fn runtime_admin_moderation_labels( - State(state): State<RuntimeRelayState>, - headers: HeaderMap, - RawQuery(query): RawQuery, -) -> Result<Json<ModerationLabelsDocument>, ApiError> { - let _admin = require_admin_pubkey(&state.config, &headers)?; - let query = label_projection_query(query.as_deref().unwrap_or_default())?; - let rows = state - .store - .query_label_projections(&query) - .await - .map_err(|_| ApiError::internal())?; - let items = rows - .iter() - .map(moderation_label_document) - .collect::<Result<Vec<_>, _>>()?; - Ok(Json(ModerationLabelsDocument { - items, - next_cursor: None, - })) -} - -async fn runtime_admin_moderation_reports( - State(state): State<RuntimeRelayState>, - headers: HeaderMap, - RawQuery(query): RawQuery, -) -> Result<Json<ModerationReportsDocument>, ApiError> { - let _admin = require_admin_pubkey(&state.config, &headers)?; - let query = report_projection_query(query.as_deref().unwrap_or_default())?; - let rows = state - .store - .query_report_projections(&query) - .await - .map_err(|_| ApiError::internal())?; - let items = rows - .iter() - .map(moderation_report_document) - .collect::<Result<Vec<_>, _>>()?; - Ok(Json(ModerationReportsDocument { - items, - next_cursor: None, - })) -} - -async fn handle_websocket(mut socket: WebSocket, state: RuntimeRelayState) { - let mut shutdown = state.shutdown_signal.subscribe(); - let mut event_rx = state.event_tx.subscribe(); - let mut loop_state = ClientMessageLoop::new(state.next_connection()); - let event_handler = EventMessageHandler::new(state.store.clone(), state.validator()) - .with_durable_write_rate_limit(state.config.durable_write_rate_limit()); - let auth_handler = AuthMessageHandler; - let req_handler = ReqMessageHandler::new( - state.store.clone(), - NostrFilterCompiler::new(state.config.limits()), - ); - let close_handler = CloseMessageHandler; - let fanout = LiveEventFanout; - let challenge = auth_handler.issue_challenge( - loop_state.connection_mut(), - "challenge-001", - UnixTimestamp::new(1_714_124_430), - ); - let _ = send_relay_message(&mut socket, &challenge).await; - loop { - tokio::select! { - _ = shutdown.wait_for_shutdown() => { - let _ = socket.send(Message::Close(None)).await; - break; - } - event = event_rx.recv() => { - let messages = event - .ok() - .into_iter() - .flat_map(|event| fanout.fanout(loop_state.connection(), &event)); - for message in messages { - let fanout_failed = send_relay_message(&mut socket, &message).await.is_err(); - if fanout_failed { return; } - } - } - frame = socket.recv() => { - let Some(frame) = frame else { break; }; - let Ok(frame) = frame else { break; }; - match loop_state.handle_frame_at(client_frame_from_message(frame), now_timestamp()) { - ClientFrameOutcome::Message(message) => { - let message_failed = handle_client_message( - &mut socket, - &mut loop_state, - ClientMessageHandlers { - event: &event_handler, - auth: &auth_handler, - req: &req_handler, - close: &close_handler, - event_tx: &state.event_tx, - }, - message, - ) - .await - .is_err(); - if message_failed { break; } - } - ClientFrameOutcome::Reject(message) => { - let reject_failed = send_relay_message(&mut socket, &message).await.is_err(); - if reject_failed { break; } - } - ClientFrameOutcome::Ignore => {} - ClientFrameOutcome::Close => break, - } - } - } - } -} - -#[derive(Clone, Copy)] -struct ClientMessageHandlers<'a> { - event: &'a EventMessageHandler, - auth: &'a AuthMessageHandler, - req: &'a ReqMessageHandler, - close: &'a CloseMessageHandler, - event_tx: &'a broadcast::Sender<Event>, -} - -async fn handle_client_message( - socket: &mut WebSocket, - loop_state: &mut ClientMessageLoop, - handlers: ClientMessageHandlers<'_>, - message: ClientMessage, -) -> Result<(), axum::Error> { - match message { - ClientMessage::Event(event) => { - let accepted_event = event.clone(); - let response = handlers - .event - .handle_event( - loop_state.connection(), - event, - now_timestamp(), - now_timestamp(), - ) - .await; - let accepted = matches!(response, RelayMessage::Ok { accepted: true, .. }); - send_relay_message(socket, &response).await?; - if accepted { - let _ = handlers.event_tx.send(accepted_event); - } - } - ClientMessage::Auth(event) => { - let response = handlers.auth.handle_auth( - loop_state.connection_mut(), - event.clone(), - event.unsigned().created_at(), - ); - send_relay_message(socket, &response).await?; - } - ClientMessage::Req { - subscription_id, - filters, - } => { - for response in handlers - .req - .handle_req(loop_state.connection_mut(), subscription_id, filters) - .await - { - send_relay_message(socket, &response).await?; - } - } - ClientMessage::Count { - subscription_id, - filters, - } => { - let response = handlers.req.handle_count(subscription_id, filters).await; - send_relay_message(socket, &response).await?; - } - ClientMessage::Close(subscription_id) => { - handlers - .close - .handle_close(loop_state.connection_mut(), &subscription_id); - } - } - Ok(()) -} - -fn client_frame_from_message(message: Message) -> ClientFrame { - match message { - Message::Text(value) => ClientFrame::Text(value.to_string()), - Message::Binary(value) => ClientFrame::Binary(value.to_vec()), - Message::Ping(value) => ClientFrame::Ping(value.to_vec()), - Message::Pong(value) => ClientFrame::Pong(value.to_vec()), - Message::Close(_) => ClientFrame::Close, - } -} - -async fn send_relay_message( - socket: &mut WebSocket, - message: &RelayMessage, -) -> Result<(), axum::Error> { - socket.send(Message::Text(message.encode().into())).await -} - -fn now_timestamp() -> UnixTimestamp { - UnixTimestamp::new( - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs()) - .unwrap_or(0), - ) -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] -struct RuntimeConfigDocument { - server: RuntimeServerConfigDocument, - database: RuntimeDatabaseConfigDocument, - auth: RuntimeAuthConfigDocument, - limits: RuntimeLimitsConfigDocument, - #[serde(default)] - policy: RuntimePolicyConfigDocument, - #[serde(default)] - observability: RuntimeObservabilityConfigDocument, -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] -struct RuntimeServerConfigDocument { - listen_addr: String, - relay_url: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] -struct RuntimeDatabaseConfigDocument { - mode: RuntimeDatabaseModeDocument, - endpoint: Option<String>, - path: Option<String>, - username: Option<String>, - password: Option<String>, - namespace: String, - database: String, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] -#[serde(rename_all = "snake_case")] -enum RuntimeDatabaseModeDocument { - Memory, - RocksDb, - Http, - #[serde(alias = "websocket")] - WebSocket, -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] -struct RuntimeAuthConfigDocument { - challenge_ttl_seconds: u64, -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] -struct RuntimeLimitsConfigDocument { - message_rate_limit: RuntimeRateLimitConfigDocument, - #[serde(default)] - runtime: RuntimeLimitValuesDocument, -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] -struct RuntimeRateLimitConfigDocument { - limit: u64, - window_seconds: u64, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)] -struct RuntimeLimitValuesDocument { - max_event_bytes: Option<u64>, - max_content_bytes: Option<u64>, - max_tags_per_event: Option<u64>, - max_tag_values_per_tag: Option<u64>, - max_tag_value_bytes: Option<u64>, - max_filters_per_subscription: Option<u64>, - max_subscriptions_per_connection: Option<u64>, - max_search_query_bytes: Option<u64>, - max_search_tokens: Option<u64>, - max_filter_complexity: Option<u64>, - max_future_seconds: Option<u64>, - live_event_buffer: Option<u64>, - pending_store_events: Option<u64>, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)] -struct RuntimePolicyConfigDocument { - require_write_auth: Option<bool>, - unapproved_seller_action: Option<RuntimeUnapprovedSellerActionDocument>, - write_rate_limit: Option<RuntimeRateLimitConfigDocument>, - #[serde(default)] - admin_pubkeys: Vec<String>, - #[serde(default)] - approved_sellers: Vec<String>, - #[serde(default)] - blocked_pubkeys: Vec<String>, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)] -struct RuntimeObservabilityConfigDocument { - #[serde(default)] - tracing: RuntimeTracingConfigDocument, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)] -struct RuntimeTracingConfigDocument { - enabled: Option<bool>, - filter: Option<String>, - format: Option<RuntimeTracingFormatDocument>, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] -#[serde(rename_all = "snake_case")] -enum RuntimeTracingFormatDocument { - Compact, - Json, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] -#[serde(rename_all = "snake_case")] -enum RuntimeUnapprovedSellerActionDocument { - StoreRawOnly, - RejectWrite, -} - -fn runtime_config_from_document( - document: RuntimeConfigDocument, -) -> Result<TangleRuntimeConfig, RuntimeConfigError> { - let listen_addr = document - .server - .listen_addr - .parse::<SocketAddr>() - .map_err(|error| { - RuntimeConfigError::invalid(format!("server.listen_addr is invalid: {error}")) - })?; - let limits = runtime_limits_from_document(document.limits)?; - let relay_connection = RelayConnectionConfig::new( - document.server.relay_url, - document.auth.challenge_ttl_seconds, - limits.message_rate_limit, - limits.runtime, - ) - .map_err(RuntimeConfigError::invalid)?; - let database = database_config_from_document(document.database)?; - let durable_write_rate_limit = durable_write_rate_limit_from_document(&document.policy)?; - let admin_pubkeys = admin_pubkeys_from_document(&document.policy)?; - let admission_policy = admission_policy_from_document(&document.policy)?; - let tracing = tracing_config_from_document(document.observability.tracing)?; - Ok(TangleRuntimeConfig { - listen_addr, - relay_connection, - database, - admission_policy, - durable_write_rate_limit, - admin_pubkeys, - limits: limits.runtime, - tracing, - }) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct ResolvedRuntimeLimits { - message_rate_limit: RateLimitConfig, - runtime: RuntimeLimits, -} - -fn runtime_limits_from_document( - document: RuntimeLimitsConfigDocument, -) -> Result<ResolvedRuntimeLimits, RuntimeConfigError> { - let message_rate_limit = RateLimitConfig::new( - document.message_rate_limit.limit, - document.message_rate_limit.window_seconds, - ) - .map_err(|error| RuntimeConfigError::invalid(error.to_string()))?; - let runtime = RuntimeLimits::from_values(document.runtime.apply(RuntimeLimitValues::default())) - .map_err(|error| RuntimeConfigError::invalid(error.to_string()))?; - Ok(ResolvedRuntimeLimits { - message_rate_limit, - runtime, - }) -} - -fn database_config_from_document( - document: RuntimeDatabaseConfigDocument, -) -> Result<SurrealConnectionConfig, RuntimeConfigError> { - match document.mode { - RuntimeDatabaseModeDocument::Memory => { - if document.endpoint.is_some() { - return Err(RuntimeConfigError::invalid( - "database.endpoint must be omitted for memory mode", - )); - } - if document.path.is_some() { - return Err(RuntimeConfigError::invalid( - "database.path must be omitted for memory mode", - )); - } - if document.username.is_some() || document.password.is_some() { - return Err(RuntimeConfigError::invalid( - "database credentials must be omitted for memory mode", - )); - } - SurrealConnectionConfig::memory(&document.namespace, &document.database) - } - RuntimeDatabaseModeDocument::RocksDb => { - if document.endpoint.is_some() { - return Err(RuntimeConfigError::invalid( - "database.endpoint must be omitted for rocksdb mode", - )); - } - if document.username.is_some() || document.password.is_some() { - return Err(RuntimeConfigError::invalid( - "database credentials must be omitted for rocksdb mode", - )); - } - SurrealConnectionConfig::rocksdb( - &required_path(document.path, "rocksdb")?, - &document.namespace, - &document.database, - ) - } - RuntimeDatabaseModeDocument::Http => { - let endpoint = required_endpoint(document.endpoint, "http")?; - let username = required_database_credential(document.username, "username", "http")?; - let password = required_database_credential(document.password, "password", "http")?; - SurrealConnectionConfig::http(&endpoint, &document.namespace, &document.database) - .and_then(|config| config.with_root_credentials(&username, &password)) - } - RuntimeDatabaseModeDocument::WebSocket => { - let endpoint = required_endpoint(document.endpoint, "websocket")?; - let username = - required_database_credential(document.username, "username", "websocket")?; - let password = - required_database_credential(document.password, "password", "websocket")?; - SurrealConnectionConfig::websocket(&endpoint, &document.namespace, &document.database) - .and_then(|config| config.with_root_credentials(&username, &password)) - } - } - .map_err(|error| RuntimeConfigError::invalid(error.to_string())) -} - -fn required_endpoint(value: Option<String>, mode: &str) -> Result<String, RuntimeConfigError> { - value.ok_or_else(|| { - RuntimeConfigError::invalid(format!("database.endpoint is required for {mode} mode")) - }) -} - -fn required_path(value: Option<String>, mode: &str) -> Result<String, RuntimeConfigError> { - value.ok_or_else(|| { - RuntimeConfigError::invalid(format!("database.path is required for {mode} mode")) - }) -} - -fn required_database_credential( - value: Option<String>, - field: &str, - mode: &str, -) -> Result<String, RuntimeConfigError> { - value.ok_or_else(|| { - RuntimeConfigError::invalid(format!("database.{field} is required for {mode} mode")) - }) -} - -fn durable_write_rate_limit_from_document( - document: &RuntimePolicyConfigDocument, -) -> Result<Option<RateLimitConfig>, RuntimeConfigError> { - document - .write_rate_limit - .as_ref() - .map(|value| { - RateLimitConfig::new(value.limit, value.window_seconds) - .map_err(|error| RuntimeConfigError::invalid(error.to_string())) - }) - .transpose() -} - -fn tracing_config_from_document( - document: RuntimeTracingConfigDocument, -) -> Result<RuntimeTracingConfig, RuntimeConfigError> { - let default = RuntimeTracingConfig::disabled(); - let format = match document.format { - Some(RuntimeTracingFormatDocument::Compact) => RuntimeTracingFormat::Compact, - Some(RuntimeTracingFormatDocument::Json) => RuntimeTracingFormat::Json, - None => default.format(), - }; - RuntimeTracingConfig::new( - document.enabled.unwrap_or(default.enabled()), - document - .filter - .unwrap_or_else(|| default.filter().to_owned()), - format, - ) -} - -fn admin_pubkeys_from_document( - document: &RuntimePolicyConfigDocument, -) -> Result<BTreeSet<PublicKeyHex>, RuntimeConfigError> { - document - .admin_pubkeys - .iter() - .map(|pubkey| { - PublicKeyHex::new(pubkey.as_str()).map_err(|error| { - RuntimeConfigError::invalid(format!( - "policy.admin_pubkeys contains invalid pubkey: {error}" - )) - }) - }) - .collect() -} - -fn admission_policy_from_document( - document: &RuntimePolicyConfigDocument, -) -> Result<AdmissionPolicy, RuntimeConfigError> { - let action = match document.unapproved_seller_action { - Some(RuntimeUnapprovedSellerActionDocument::StoreRawOnly) | None => { - UnapprovedSellerAction::StoreRawOnly - } - Some(RuntimeUnapprovedSellerActionDocument::RejectWrite) => { - UnapprovedSellerAction::RejectWrite - } - }; - let mut policy = AdmissionPolicy::new() - .with_write_auth_required(document.require_write_auth.unwrap_or(true)) - .with_unapproved_seller_action(action); - for pubkey in &document.approved_sellers { - policy = policy.approve_seller(PublicKeyHex::new(pubkey.as_str()).map_err(|error| { - RuntimeConfigError::invalid(format!( - "policy.approved_sellers contains invalid pubkey: {error}" - )) - })?); - } - for pubkey in &document.blocked_pubkeys { - policy = policy.block_pubkey(PublicKeyHex::new(pubkey.as_str()).map_err(|error| { - RuntimeConfigError::invalid(format!( - "policy.blocked_pubkeys contains invalid pubkey: {error}" - )) - })?); - } - Ok(policy) -} - -impl RuntimeLimitValuesDocument { - fn apply(self, mut values: RuntimeLimitValues) -> RuntimeLimitValues { - if let Some(value) = self.max_event_bytes { - values.max_event_bytes = value; - } - if let Some(value) = self.max_content_bytes { - values.max_content_bytes = value; - } - if let Some(value) = self.max_tags_per_event { - values.max_tags_per_event = value; - } - if let Some(value) = self.max_tag_values_per_tag { - values.max_tag_values_per_tag = value; - } - if let Some(value) = self.max_tag_value_bytes { - values.max_tag_value_bytes = value; - } - if let Some(value) = self.max_filters_per_subscription { - values.max_filters_per_subscription = value; - } - if let Some(value) = self.max_subscriptions_per_connection { - values.max_subscriptions_per_connection = value; - } - if let Some(value) = self.max_search_query_bytes { - values.max_search_query_bytes = value; - } - if let Some(value) = self.max_search_tokens { - values.max_search_tokens = value; - } - if let Some(value) = self.max_filter_complexity { - values.max_filter_complexity = value; - } - if let Some(value) = self.max_future_seconds { - values.max_future_seconds = value; - } - if let Some(value) = self.live_event_buffer { - values.live_event_buffer = value; - } - if let Some(value) = self.pending_store_events { - values.pending_store_events = value; - } - values - } -} - -#[derive(Debug, Clone)] -pub struct GracefulShutdownSignal { - sender: tokio::sync::watch::Sender<bool>, -} - -impl GracefulShutdownSignal { - pub fn new() -> (Self, GracefulShutdownListener) { - let (sender, receiver) = tokio::sync::watch::channel(false); - (Self { sender }, GracefulShutdownListener { receiver }) - } - - pub fn subscribe(&self) -> GracefulShutdownListener { - GracefulShutdownListener { - receiver: self.sender.subscribe(), - } - } - - pub fn request_shutdown(&self) -> bool { - self.sender.send(true).is_ok() - } - - pub fn is_shutdown_requested(&self) -> bool { - *self.sender.borrow() - } -} - -#[derive(Debug, Clone)] -pub struct GracefulShutdownListener { - receiver: tokio::sync::watch::Receiver<bool>, -} - -impl GracefulShutdownListener { - pub fn is_shutdown_requested(&self) -> bool { - *self.receiver.borrow() - } - - pub async fn wait_for_shutdown(&mut self) { - while !self.is_shutdown_requested() && self.receiver.changed().await.is_ok() {} - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ClientFrame { - Text(String), - Binary(Vec<u8>), - Ping(Vec<u8>), - Pong(Vec<u8>), - Close, -} - -#[derive(Debug, Clone, PartialEq)] -pub enum ClientFrameOutcome { - Message(ClientMessage), - Reject(RelayMessage), - Ignore, - Close, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ClientMessageLoop { - connection: RelayConnection, -} - -impl ClientMessageLoop { - pub fn new(connection: RelayConnection) -> Self { - Self { connection } - } - - pub fn connection(&self) -> &RelayConnection { - &self.connection - } - - pub fn connection_mut(&mut self) -> &mut RelayConnection { - &mut self.connection - } - - pub fn handle_frame(&mut self, frame: ClientFrame) -> ClientFrameOutcome { - self.handle_frame_at(frame, UnixTimestamp::new(0)) - } - - pub fn handle_frame_at( - &mut self, - frame: ClientFrame, - now: UnixTimestamp, - ) -> ClientFrameOutcome { - if matches!(frame, ClientFrame::Text(_) | ClientFrame::Binary(_)) { - let key = self.connection.id().as_str().to_owned(); - match self - .connection - .rate_limiter_mut() - .check(&key, now, 1) - .unwrap_or(RateLimitDecision::Rejected { - retry_after_seconds: 0, - reset_at: now, - }) { - RateLimitDecision::Accepted { .. } => {} - RateLimitDecision::Rejected { - retry_after_seconds, - .. - } => { - return ClientFrameOutcome::Reject(RelayMessage::Notice(format!( - "rate-limited: retry after {retry_after_seconds} seconds" - ))); - } - } - } - match frame { - ClientFrame::Text(raw) => parse_client_message(&raw) - .map(ClientFrameOutcome::Message) - .unwrap_or_else(|error| { - ClientFrameOutcome::Reject(RelayMessage::Notice(format!("invalid: {error}"))) - }), - ClientFrame::Binary(_) => ClientFrameOutcome::Reject(RelayMessage::Notice( - "unsupported: binary websocket messages are not supported".to_owned(), - )), - ClientFrame::Ping(_) | ClientFrame::Pong(_) => ClientFrameOutcome::Ignore, - ClientFrame::Close => ClientFrameOutcome::Close, - } - } -} - -#[derive(Debug, Clone)] -pub struct EventMessageHandler { - store: SurrealStore, - validator: EventValidator, - durable_write_rate_limit: Option<RateLimitConfig>, -} - -impl EventMessageHandler { - pub fn new(store: SurrealStore, validator: EventValidator) -> Self { - Self { - store, - validator, - durable_write_rate_limit: None, - } - } - - pub fn store(&self) -> &SurrealStore { - &self.store - } - - pub fn validator(&self) -> &EventValidator { - &self.validator - } - - pub fn durable_write_rate_limit(&self) -> Option<RateLimitConfig> { - self.durable_write_rate_limit - } - - pub fn with_durable_write_rate_limit(mut self, config: Option<RateLimitConfig>) -> Self { - self.durable_write_rate_limit = config; - self - } - - pub async fn handle_event( - &self, - connection: &RelayConnection, - event: Event, - received_at: UnixTimestamp, - now: UnixTimestamp, - ) -> RelayMessage { - let event_id = event.id().clone(); - let context = admission_context(connection); - let validated = match self.validator.validate(&event, &context, now) { - Ok(validated) => validated, - Err(error) => return ok_rejected(event_id, format!("invalid: {error}")), - }; - if validated.admission().effect() == AdmissionEffect::AuthenticateOnly { - return ok_rejected(event_id, "invalid: auth events must use AUTH".to_owned()); - } - let effect = match self - .effective_admission_effect(&event, validated.admission().effect()) - .await - { - Ok(effect) => effect, - Err(_) => return ok_rejected(event_id, "error: policy unavailable".to_owned()), - }; - if let Some(config) = self.durable_write_rate_limit { - match self - .store - .check_durable_rate_limit( - &durable_write_rate_limit_key(validated.author_pubkey()), - config.limit, - config.window_seconds, - 1, - now, - ) - .await - { - Ok(DurableRateLimitDecision::Accepted { .. }) => {} - Ok(DurableRateLimitDecision::Rejected { - retry_after_seconds, - .. - }) => { - return ok_rejected( - event_id, - format!("rate-limited: retry after {retry_after_seconds} seconds"), - ); - } - Err(_) => { - return ok_rejected(event_id, "error: rate limit unavailable".to_owned()); - } - } - } - if event.unsigned().kind().is_ephemeral() { - return ok_accepted(event_id); - } - let raw_outcome = match self - .store - .store_raw_event(&StoredEvent::new(event.clone(), received_at)) - .await - { - Ok(outcome) => outcome, - Err(_) => return ok_rejected(event_id, "error: store unavailable".to_owned()), - }; - if raw_outcome == StoreEventOutcome::Duplicate { - return ok_accepted(event_id); - } - if project_stored_event(&self.store, &event, effect, now) - .await - .is_err() - { - return ok_rejected(event_id, "error: projection failed".to_owned()); - } - ok_accepted(event_id) - } - - async fn effective_admission_effect( - &self, - event: &Event, - fallback: AdmissionEffect, - ) -> Result<AdmissionEffect, tangle_store_surreal::SurrealStoreError> { - if event.unsigned().kind().as_u32() != 30_402 { - return Ok(fallback); - } - let Some(row) = self - .store - .relay_user_row(event.unsigned().pubkey().as_str()) - .await? - else { - return Ok(fallback); - }; - if row - .get("blocked") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false) - { - return Ok(AdmissionEffect::StoreRawWithoutPublicListingProjection); - } - if row - .get("seller_approved") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false) - { - return Ok(AdmissionEffect::StoreRawAndProjectPublicListing); - } - Ok(fallback) - } -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub struct AuthMessageHandler; - -impl AuthMessageHandler { - pub fn issue_challenge( - &self, - connection: &mut RelayConnection, - challenge: &str, - issued_at: UnixTimestamp, - ) -> RelayMessage { - match connection.auth_mut().issue_challenge(challenge, issued_at) { - Ok(challenge) => RelayMessage::Auth(challenge.value), - Err(error) => RelayMessage::Notice(format!("error: {error}")), - } - } - - pub fn handle_auth( - &self, - connection: &mut RelayConnection, - event: Event, - now: UnixTimestamp, - ) -> RelayMessage { - let event_id = event.id().clone(); - let auth = match parse_relay_auth_event(&event) { - Ok(Some(auth)) => auth, - Ok(None) => { - return ok_rejected( - event_id, - "invalid: AUTH message must contain kind 22242".to_owned(), - ); - } - Err(error) => return ok_rejected(event_id, format!("invalid: {error}")), - }; - match connection.auth_mut().authenticate(&auth, now) { - Ok(_) => ok_accepted(event_id), - Err(error) => ok_rejected(event_id, format!("auth-required: {error}")), - } - } -} - -#[derive(Debug, Clone)] -pub struct ReqMessageHandler { - store: SurrealStore, - compiler: NostrFilterCompiler, -} - -impl ReqMessageHandler { - pub fn new(store: SurrealStore, compiler: NostrFilterCompiler) -> Self { - Self { store, compiler } - } - - pub fn store(&self) -> &SurrealStore { - &self.store - } - - pub fn compiler(&self) -> NostrFilterCompiler { - self.compiler - } - - pub async fn handle_req( - &self, - connection: &mut RelayConnection, - subscription_id: SubscriptionId, - filters: Vec<Filter>, - ) -> Vec<RelayMessage> { - let plan = match self - .compiler - .compile(&filters, QueryExecutionMode::HistoricalThenLive) - { - Ok(plan) => plan, - Err(error) => { - return vec![RelayMessage::Closed { - subscription_id, - message: format!("unsupported: {error}"), - }]; - } - }; - if let Err(error) = connection - .subscriptions_mut() - .subscribe(subscription_id.clone(), plan) - { - return vec![RelayMessage::Closed { - subscription_id, - message: format!("error: {error}"), - }]; - } - let events = match self.query_historical_events(&filters).await { - Ok(events) => events, - Err(error) => { - return vec![RelayMessage::Closed { - subscription_id, - message: error.message().to_owned(), - }]; - } - }; - let mut messages = events - .into_iter() - .map(|event| RelayMessage::Event { - subscription_id: subscription_id.clone(), - event, - }) - .collect::<Vec<_>>(); - messages.push(RelayMessage::Eose(subscription_id)); - messages - } - - pub async fn handle_count( - &self, - subscription_id: SubscriptionId, - filters: Vec<Filter>, - ) -> RelayMessage { - if let Err(error) = self - .compiler - .compile(&filters, QueryExecutionMode::Historical) - { - return RelayMessage::Closed { - subscription_id, - message: format!("unsupported: {error}"), - }; - } - match self.query_historical_events(&filters).await { - Ok(events) => RelayMessage::Count { - subscription_id, - count: events.len() as u64, - }, - Err(error) => RelayMessage::Closed { - subscription_id, - message: error.message().to_owned(), - }, - } - } - - async fn query_historical_events(&self, filters: &[Filter]) -> Result<Vec<Event>, ApiError> { - let mut seen = BTreeSet::new(); - let mut events = Vec::new(); - for filter in filters { - for event in self.query_single_filter_events(filter).await? { - if seen.insert(event.id().clone()) { - events.push(event); - } - } - } - Ok(events) - } - - async fn query_single_filter_events(&self, filter: &Filter) -> Result<Vec<Event>, ApiError> { - let rows = if filter.search().is_some() { - self.query_search_filter_rows(filter).await? - } else if filter.ids().is_empty() - && filter - .kinds() - .iter() - .any(|kind| kind.is_replaceable() || kind.is_addressable()) - { - self.store - .query_current_events(filter) - .await - .map_err(|_| ApiError::internal())? - } else { - self.store - .query_raw_events(filter) - .await - .map_err(|_| ApiError::internal())? - }; - rows.iter().map(event_from_store_row).collect() - } - - async fn query_search_filter_rows( - &self, - filter: &Filter, - ) -> Result<Vec<serde_json::Value>, ApiError> { - let mut query = SearchDocumentQuery::new() - .with_doc_type("listing") - .with_visible(true); - if let Some(search) = filter.search() { - query = query.with_text(search); - } - if filter.kinds().len() == 1 { - query = query.with_kind(filter.kinds()[0].as_u32()); - } - if filter.authors().len() == 1 { - query = query.with_pubkey(filter.authors()[0].as_str()); - } - if let Some(limit) = filter.limit() { - query = query.with_limit(limit); - } - let docs = self - .store - .query_search_documents(&query) - .await - .map_err(|_| ApiError::internal())?; - let mut rows = Vec::new(); - for doc in docs { - let event_id = - EventId::new(&string_field(&doc, "event_id")?).map_err(|_| ApiError::internal())?; - if let Some(row) = self - .store - .raw_event_row(&event_id) - .await - .map_err(|_| ApiError::internal())? - { - rows.push(row); - } - } - Ok(rows) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CloseMessageOutcome { - Closed, - NotFound, -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub struct CloseMessageHandler; - -impl CloseMessageHandler { - pub fn handle_close( - &self, - connection: &mut RelayConnection, - subscription_id: &SubscriptionId, - ) -> CloseMessageOutcome { - match connection.subscriptions_mut().close(subscription_id) { - SubscriptionCloseOutcome::Closed => CloseMessageOutcome::Closed, - SubscriptionCloseOutcome::NotFound => CloseMessageOutcome::NotFound, - } - } -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub struct LiveEventFanout; - -impl LiveEventFanout { - pub fn fanout(&self, connection: &RelayConnection, event: &Event) -> Vec<RelayMessage> { - connection - .subscriptions() - .match_event(event) - .into_iter() - .map(|matched| RelayMessage::Event { - subscription_id: matched.subscription_id, - event: event.clone(), - }) - .collect() - } -} - -fn admission_context(connection: &RelayConnection) -> AdmissionContext { - connection - .auth() - .authenticated_pubkey() - .cloned() - .map(AdmissionContext::authenticated) - .unwrap_or_else(AdmissionContext::unauthenticated) -} - -fn durable_write_rate_limit_key(pubkey: &PublicKeyHex) -> String { - format!("event_write:{}", pubkey.as_str()) -} - -fn ok_accepted(event_id: EventId) -> RelayMessage { - RelayMessage::Ok { - event_id, - accepted: true, - message: String::new(), - } -} - -fn ok_rejected(event_id: EventId, message: String) -> RelayMessage { - RelayMessage::Ok { - event_id, - accepted: false, - message, - } -} - -fn event_from_store_row(row: &serde_json::Value) -> Result<Event, ApiError> { - let raw = - RawEventJson::new(&string_field(row, "raw_json")?).map_err(|_| ApiError::internal())?; - parse_event_json(&raw).map_err(|_| ApiError::internal()) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ApiErrorCode { - InvalidRequest, - Unauthorized, - Forbidden, - NotFound, - Conflict, - Internal, -} - -impl ApiErrorCode { - pub fn as_str(self) -> &'static str { - match self { - Self::InvalidRequest => "invalid_request", - Self::Unauthorized => "unauthorized", - Self::Forbidden => "forbidden", - Self::NotFound => "not_found", - Self::Conflict => "conflict", - Self::Internal => "internal_error", - } - } - - pub fn http_status(self) -> u16 { - match self { - Self::InvalidRequest => 400, - Self::Unauthorized => 401, - Self::Forbidden => 403, - Self::NotFound => 404, - Self::Conflict => 409, - Self::Internal => 500, - } - } -} - -impl fmt::Display for ApiErrorCode { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(self.as_str()) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ApiError { - code: ApiErrorCode, - message: String, -} - -impl ApiError { - pub fn new(code: ApiErrorCode, message: impl Into<String>) -> Self { - Self { - code, - message: message.into(), - } - } - - pub fn invalid_request(message: impl Into<String>) -> Self { - Self::new(ApiErrorCode::InvalidRequest, message) - } - - pub fn unauthorized(message: impl Into<String>) -> Self { - Self::new(ApiErrorCode::Unauthorized, message) - } - - pub fn forbidden(message: impl Into<String>) -> Self { - Self::new(ApiErrorCode::Forbidden, message) - } - - pub fn not_found(message: impl Into<String>) -> Self { - Self::new(ApiErrorCode::NotFound, message) - } - - pub fn conflict(message: impl Into<String>) -> Self { - Self::new(ApiErrorCode::Conflict, message) - } - - pub fn internal() -> Self { - Self::new(ApiErrorCode::Internal, "internal server error") - } - - pub fn code(&self) -> ApiErrorCode { - self.code - } - - pub fn message(&self) -> &str { - &self.message - } - - pub fn http_status(&self) -> u16 { - self.code.http_status() - } - - pub fn envelope(&self) -> ApiErrorEnvelope { - ApiErrorEnvelope { - error: ApiErrorBody { - code: self.code.as_str().to_owned(), - message: self.message.clone(), - }, - } - } -} - -impl fmt::Display for ApiError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(formatter, "{}: {}", self.code, self.message) - } -} - -impl std::error::Error for ApiError {} - -impl IntoResponse for ApiError { - fn into_response(self) -> Response { - let status = - StatusCode::from_u16(self.http_status()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); - (status, Json(self.envelope())).into_response() - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ApiErrorEnvelope { - pub error: ApiErrorBody, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ApiErrorBody { - pub code: String, - pub message: String, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ReadinessCheckStatus { - Ready, - NotReady, -} - -impl ReadinessCheckStatus { - pub fn as_str(self) -> &'static str { - match self { - Self::Ready => "ready", - Self::NotReady => "not_ready", - } - } - - pub fn is_ready(self) -> bool { - self == Self::Ready - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ReadinessState { - pub database: ReadinessCheckStatus, - pub migrations: ReadinessCheckStatus, - pub repository: ReadinessCheckStatus, -} - -impl ReadinessState { - pub fn new( - database: ReadinessCheckStatus, - migrations: ReadinessCheckStatus, - repository: ReadinessCheckStatus, - ) -> Self { - Self { - database, - migrations, - repository, - } - } - - pub fn ready() -> Self { - Self::new( - ReadinessCheckStatus::Ready, - ReadinessCheckStatus::Ready, - ReadinessCheckStatus::Ready, - ) - } - - pub fn is_ready(self) -> bool { - self.database.is_ready() && self.migrations.is_ready() && self.repository.is_ready() - } - - pub fn response(self) -> ReadinessDocument { - ReadinessDocument { - status: if self.is_ready() { - "ready".to_owned() - } else { - "not_ready".to_owned() - }, - checks: ReadinessChecksDocument { - database: self.database.as_str().to_owned(), - migrations: self.migrations.as_str().to_owned(), - repository: self.repository.as_str().to_owned(), - }, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct HealthDocument { - pub status: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ReadinessDocument { - pub status: String, - pub checks: ReadinessChecksDocument, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ReadinessChecksDocument { - pub database: String, - pub migrations: String, - pub repository: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct RelayInfoDocument { - #[serde(skip_serializing_if = "Option::is_none")] - pub id: Option<String>, - pub name: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub pubkey: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub contact: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub icon: Option<String>, - pub supported_nips: Vec<u16>, - pub software: String, - pub version: String, - pub limitation: RelayInfoLimitationDocument, -} - -impl RelayInfoDocument { - pub fn tangle_default() -> Self { - Self { - id: None, - name: "tangle".to_owned(), - description: Some("SurrealDB-backed Nostr relay for NIP-99 marketplaces".to_owned()), - pubkey: None, - contact: None, - icon: None, - supported_nips: TANGLE_SUPPORTED_NIPS.to_vec(), - software: TANGLE_RELAY_SOFTWARE.to_owned(), - version: TANGLE_RELAY_VERSION.to_owned(), - limitation: RelayInfoLimitationDocument { - payment_required: false, - restricted_writes: true, - }, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct RelayInfoLimitationDocument { - pub payment_required: bool, - pub restricted_writes: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ListingHttpQuery { - marketplace: MarketplaceQuery, - geohash: Option<String>, -} - -impl ListingHttpQuery { - pub fn marketplace(&self) -> &MarketplaceQuery { - &self.marketplace - } - - pub fn geohash(&self) -> Option<&str> { - self.geohash.as_deref() - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct MarketplaceSearchHttpQuery { - text: Option<String>, - seller: Option<PublicKeyHex>, - limit: u64, -} - -impl MarketplaceSearchHttpQuery { - pub fn text(&self) -> Option<&str> { - self.text.as_deref() - } - - pub fn seller(&self) -> Option<&PublicKeyHex> { - self.seller.as_ref() - } - - pub fn limit(&self) -> u64 { - self.limit - } -} - -#[derive(Debug, Clone)] -pub struct ListingsHttpState { - store: SurrealStore, - limits: RuntimeLimits, -} - -impl ListingsHttpState { - pub fn new(store: SurrealStore, limits: RuntimeLimits) -> Self { - Self { store, limits } - } -} - -#[derive(Debug, Clone)] -pub struct MetricsHttpState { - store: SurrealStore, -} - -impl MetricsHttpState { - pub fn new(store: SurrealStore) -> Self { - Self { store } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ListingsDocument { - pub items: Vec<ListingItemDocument>, - pub next_cursor: Option<String>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ListingItemDocument { - pub listing_key: String, - pub event_id: String, - pub seller_pubkey: String, - pub d: String, - pub title: String, - pub summary: Option<String>, - pub price: ListingPriceDocument, - pub location: ListingLocationDocument, - pub fulfillment: Vec<String>, - pub status: String, - pub updated_at: u64, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ListingPriceDocument { - pub amount: String, - pub currency: String, - pub unit: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ListingLocationDocument { - pub text: Option<String>, - pub geohash: Option<String>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ListingDetailDocument { - pub listing: ListingItemDocument, - pub raw_event: serde_json::Value, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ListingCommentsDocument { - pub items: Vec<CommentItemDocument>, - pub next_cursor: Option<String>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct CommentItemDocument { - pub event_id: String, - pub pubkey: String, - pub created_at: u64, - pub content: String, - pub root: CommentReferenceDocument, - pub parent: CommentReferenceDocument, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct CommentReferenceDocument { - pub target_type: String, - pub target_ref: String, - pub kind: String, - pub author: Option<String>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ReactionCountsDocument { - pub target_event_id: String, - pub target_kind: Option<String>, - pub like_count: u64, - pub dislike_count: u64, - pub emoji_count: u64, - pub text_count: u64, - pub total_count: u64, - pub updated_at: u64, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ForumThreadsDocument { - pub items: Vec<ForumThreadItemDocument>, - pub next_cursor: Option<String>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ForumThreadItemDocument { - pub event_id: String, - pub pubkey: String, - pub created_at: u64, - pub updated_at: u64, - pub title: Option<String>, - pub content: String, - pub tags: Vec<String>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ForumThreadDetailDocument { - pub thread: ForumThreadItemDocument, - pub raw_event: serde_json::Value, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct SellerDocument { - pub pubkey: String, - pub event_id: Option<String>, - pub name: Option<String>, - pub display_name: Option<String>, - pub about: Option<String>, - pub picture: Option<String>, - pub website: Option<String>, - pub nip05: Option<String>, - pub lud16: Option<String>, - pub regions: Vec<String>, - pub categories: Vec<String>, - pub trust_markers: Vec<String>, - pub approved: bool, - pub blocked: bool, - pub active_listing_count: u64, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct AdminPolicyDocument { - pub status: String, - pub target_type: String, - pub target_ref: String, -} - -impl AdminPolicyDocument { - pub fn new(status: &str, target_type: &str, target_ref: &str) -> Self { - Self { - status: status.to_owned(), - target_type: target_type.to_owned(), - target_ref: target_ref.to_owned(), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ModerationLabelsDocument { - pub items: Vec<ModerationLabelDocument>, - pub next_cursor: Option<String>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ModerationLabelDocument { - pub label_id: String, - pub event_id: String, - pub pubkey: String, - pub created_at: u64, - pub content: String, - pub namespace: String, - pub label: String, - pub target_type: String, - pub target_ref: String, - pub projected_at: u64, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ModerationReportsDocument { - pub items: Vec<ModerationReportDocument>, - pub next_cursor: Option<String>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ModerationReportDocument { - pub report_id: String, - pub event_id: String, - pub pubkey: String, - pub created_at: u64, - pub content: String, - pub target_type: String, - pub target_ref: String, - pub report_type: String, - pub reported_pubkeys: Vec<String>, - pub server_urls: Vec<String>, - pub projected_at: u64, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)] -pub struct AdminEventPolicyRequest { - pub reason: Option<String>, -} - -pub fn parse_listing_query( - query: &str, - limits: RuntimeLimits, -) -> Result<ListingHttpQuery, ApiError> { - let mut spec = MarketplaceQuerySpec { - statuses: vec![MarketplaceListingStatus::Active], - ..MarketplaceQuerySpec::default() - }; - let mut geohash = None; - let mut saw_status = false; - let mut saw_sort = false; - for (key, value) in form_urlencoded::parse(query.as_bytes()) { - let value = value.into_owned(); - match key.as_ref() { - "category" => push_text_values("category", &value, &mut spec.categories)?, - "seller" => set_once("seller", &mut spec.seller, parse_pubkey("seller", &value)?)?, - "status" => { - if !saw_status { - spec.statuses.clear(); - saw_status = true; - } - push_status_values(&value, &mut spec.statuses)?; - } - "currency" => push_text_values("currency", &value, &mut spec.currencies)?, - "unit" => push_unit_values(&value, &mut spec.units)?, - "min_price" => { - let value = required_value("min_price", &value)?; - set_once("min_price", &mut spec.min_price, value)?; - } - "max_price" => { - let value = required_value("max_price", &value)?; - set_once("max_price", &mut spec.max_price, value)?; - } - "fulfillment" => push_fulfillment_values(&value, &mut spec.fulfillment)?, - "delivery_only" => { - let value = parse_bool("delivery_only", &value)?; - set_once("delivery_only", &mut spec.delivery_only, value)?; - } - "pickup" => set_once("pickup", &mut spec.pickup, parse_bool("pickup", &value)?)?, - "geohash" => set_once("geohash", &mut geohash, parse_geohash_query_value(&value)?)?, - "lat" => { - let value = parse_microdegrees("lat", &value, -90_000_000, 90_000_000)?; - set_once("lat", &mut spec.latitude_microdegrees, value)?; - } - "lon" => { - let value = parse_microdegrees("lon", &value, -180_000_000, 180_000_000)?; - set_once("lon", &mut spec.longitude_microdegrees, value)?; - } - "radius_km" => { - let value = parse_radius_meters(&value)?; - set_once("radius_km", &mut spec.radius_meters, value)?; - } - "near" => set_once("near", &mut spec.near, required_value("near", &value)?)?, - "sort" => { - if saw_sort { - return Err(invalid_parameter("sort", "must not be repeated")); - } - saw_sort = true; - spec.sort = parse_sort(&value)?; - } - "limit" => set_once("limit", &mut spec.limit, parse_limit(&value)?)?, - "cursor" => { - return Err(invalid_parameter( - "cursor", - "signed cursor decoding is not implemented", - )); - } - unsupported => { - return Err(ApiError::invalid_request(format!( - "query parameter `{unsupported}` is unsupported" - ))); - } - } - } - let marketplace = MarketplaceQuery::from_spec(spec, limits).map_err(ApiError::from)?; - Ok(ListingHttpQuery { - marketplace, - geohash, - }) -} - -pub fn parse_marketplace_search_query( - query: &str, - limits: RuntimeLimits, -) -> Result<MarketplaceSearchHttpQuery, ApiError> { - let mut text = None; - let mut seller = None; - let mut status = None; - let mut sort = None; - let mut limit = None; - for (key, value) in form_urlencoded::parse(query.as_bytes()) { - let value = value.into_owned(); - match key.as_ref() { - "q" => set_once("q", &mut text, required_value("q", &value)?)?, - "seller" => set_once("seller", &mut seller, parse_pubkey("seller", &value)?)?, - "status" => set_once("status", &mut status, parse_status(&value)?)?, - "sort" => set_once("sort", &mut sort, parse_sort(&value)?)?, - "limit" => set_once("limit", &mut limit, parse_limit(&value)?)?, - "category" | "currency" | "unit" | "min_price" | "max_price" | "fulfillment" - | "delivery_only" | "pickup" | "lat" | "lon" | "radius_km" | "near" | "cursor" => { - return Err(ApiError::invalid_request(format!( - "{} is not supported by marketplace search", - key.as_ref() - ))); - } - unsupported => { - return Err(ApiError::invalid_request(format!( - "query parameter `{unsupported}` is unsupported" - ))); - } - } - } - limits - .validate_search_query(text.as_deref().unwrap_or_default()) - .map_err(|violation| ApiError::invalid_request(format!("runtime limit: {violation}")))?; - let status = status.unwrap_or(MarketplaceListingStatus::Active); - if status != MarketplaceListingStatus::Active { - return Err(invalid_parameter( - "status", - "must be active for marketplace search", - )); - } - let expected_sort = if text.is_some() { - MarketplaceSort::Relevance - } else { - MarketplaceSort::Freshness - }; - if sort.is_some_and(|sort| sort != expected_sort) { - return Err(invalid_parameter( - "sort", - "does not match marketplace search mode", - )); - } - let limit = limit.unwrap_or(MarketplaceQuery::DEFAULT_LIMIT); - if limit == 0 || limit > MarketplaceQuery::MAX_LIMIT { - return Err(invalid_parameter("limit", "must be between 1 and 100")); - } - Ok(MarketplaceSearchHttpQuery { - text, - seller, - limit, - }) -} - -pub fn health_router(readiness: ReadinessState) -> Router { - Router::new() - .route("/healthz", get(healthz)) - .route("/readyz", get(readyz)) - .with_state(readiness) -} - -pub fn metrics_router(state: MetricsHttpState) -> Router { - Router::new() - .route("/metrics", get(metrics)) - .with_state(state) -} - -pub fn relay_info_router(document: RelayInfoDocument) -> Router { - Router::new() - .route("/", get(relay_info)) - .with_state(document) -} - -pub fn listings_router(state: ListingsHttpState) -> Router { - Router::new() - .route("/api/listings", get(listings)) - .route("/api/listings/{pubkey}/{d}", get(listing_detail)) - .route("/api/listings/{pubkey}/{d}/comments", get(listing_comments)) - .route( - "/api/listings/{pubkey}/{d}/reactions", - get(listing_reactions), - ) - .route("/api/forum/threads", get(forum_threads)) - .route("/api/forum/threads/{event_id}", get(forum_thread_detail)) - .route( - "/api/forum/threads/{event_id}/comments", - get(forum_thread_comments), - ) - .route("/api/search", get(marketplace_search)) - .route("/api/sellers/{pubkey}", get(seller_detail)) - .with_state(state) -} - -async fn healthz() -> Json<HealthDocument> { - Json(HealthDocument { - status: "ok".to_owned(), - }) -} - -async fn readyz(State(readiness): State<ReadinessState>) -> (StatusCode, Json<ReadinessDocument>) { - let status = if readiness.is_ready() { - StatusCode::OK - } else { - StatusCode::SERVICE_UNAVAILABLE - }; - (status, Json(readiness.response())) -} - -async fn metrics(State(state): State<MetricsHttpState>) -> Result<Response, ApiError> { - let snapshot = state - .store - .metrics_snapshot() - .await - .map_err(|_| ApiError::internal())?; - Ok(( - StatusCode::OK, - [( - header::CONTENT_TYPE, - HeaderValue::from_static("text/plain; version=0.0.4"), - )], - metrics_text(snapshot), - ) - .into_response()) -} - -fn metrics_text(snapshot: SurrealMetricsSnapshot) -> String { - let mut output = String::new(); - output.push_str("# HELP tangle_info Tangle relay build information\n"); - output.push_str("# TYPE tangle_info gauge\n"); - output.push_str(&format!( - "tangle_info{{software=\"{}\",version=\"{}\"}} 1\n", - prometheus_label_value(TANGLE_RELAY_SOFTWARE), - prometheus_label_value(TANGLE_RELAY_VERSION) - )); - output.push_str("# HELP tangle_relay_ready Relay readiness gauge\n"); - output.push_str("# TYPE tangle_relay_ready gauge\n"); - output.push_str("tangle_relay_ready 1\n"); - output.push_str("# HELP tangle_store_events Stored Nostr event gauges\n"); - output.push_str("# TYPE tangle_store_events gauge\n"); - append_labeled_gauge( - &mut output, - "tangle_store_events", - "stored", - snapshot.stored_events(), - ); - append_labeled_gauge( - &mut output, - "tangle_store_events", - "visible", - snapshot.visible_events(), - ); - append_labeled_gauge( - &mut output, - "tangle_store_events", - "hidden", - snapshot.hidden_events(), - ); - append_labeled_gauge( - &mut output, - "tangle_store_events", - "deleted", - snapshot.deleted_events(), - ); - output.push_str("# HELP tangle_store_listings Current listing projection gauges\n"); - output.push_str("# TYPE tangle_store_listings gauge\n"); - append_labeled_gauge( - &mut output, - "tangle_store_listings", - "current", - snapshot.current_listings(), - ); - append_labeled_gauge( - &mut output, - "tangle_store_listings", - "active", - snapshot.active_listings(), - ); - output.push_str("# HELP tangle_store_seller_profiles Seller profile projection gauges\n"); - output.push_str("# TYPE tangle_store_seller_profiles gauge\n"); - append_labeled_gauge( - &mut output, - "tangle_store_seller_profiles", - "stored", - snapshot.seller_profiles(), - ); - append_labeled_gauge( - &mut output, - "tangle_store_seller_profiles", - "visible", - snapshot.visible_seller_profiles(), - ); - output.push_str("# HELP tangle_store_sellers Seller policy gauges\n"); - output.push_str("# TYPE tangle_store_sellers gauge\n"); - append_labeled_gauge( - &mut output, - "tangle_store_sellers", - "approved", - snapshot.approved_sellers(), - ); - output.push_str("# HELP tangle_store_pubkeys Relay pubkey policy gauges\n"); - output.push_str("# TYPE tangle_store_pubkeys gauge\n"); - append_labeled_gauge( - &mut output, - "tangle_store_pubkeys", - "blocked", - snapshot.blocked_pubkeys(), - ); - output -} - -fn append_labeled_gauge(output: &mut String, metric: &str, state: &str, value: u64) { - output.push_str(&format!( - "{metric}{{state=\"{}\"}} {value}\n", - prometheus_label_value(state) - )); -} - -fn prometheus_label_value(value: &str) -> String { - value - .replace('\\', r"\\") - .replace('"', r#"\""#) - .replace('\n', r"\n") -} - -async fn relay_info(State(relay_info): State<RelayInfoDocument>, headers: HeaderMap) -> Response { - if !accepts_nostr_json(headers.get(header::ACCEPT)) { - return ApiError::not_found("relay information requires application/nostr+json") - .into_response(); - } - ( - StatusCode::OK, - [( - header::CONTENT_TYPE, - HeaderValue::from_static("application/nostr+json"), - )], - Json(relay_info), - ) - .into_response() -} - -async fn listings( - State(state): State<ListingsHttpState>, - RawQuery(query): RawQuery, -) -> Result<Json<ListingsDocument>, ApiError> { - let parsed = parse_listing_query(query.as_deref().unwrap_or_default(), state.limits)?; - let store_query = listing_projection_query(&parsed)?; - let rows = state - .store - .query_current_listings(&store_query) - .await - .map_err(|_| ApiError::internal())?; - let items = rows - .iter() - .map(listing_item_document) - .collect::<Result<Vec<_>, _>>()?; - Ok(Json(ListingsDocument { - items, - next_cursor: None, - })) -} - -async fn listing_detail( - State(state): State<ListingsHttpState>, - Path((pubkey, d)): Path<(String, String)>, -) -> Result<Json<ListingDetailDocument>, ApiError> { - let pubkey = parse_pubkey("pubkey", &pubkey)?; - let d = required_value("d", &d)?; - let listing_key = format!("30402:{}:{d}", pubkey.as_str()); - let row = state - .store - .listing_current_row(&listing_key) - .await - .map_err(|_| ApiError::internal())? - .ok_or_else(|| ApiError::not_found("listing not found"))?; - if bool_field(&row, "hidden")? || bool_field(&row, "deleted")? { - return Err(ApiError::not_found("listing not found")); - } - let event_id = - EventId::new(&string_field(&row, "event_id")?).map_err(|_| ApiError::internal())?; - let raw_row = state - .store - .raw_event_row(&event_id) - .await - .map_err(|_| ApiError::internal())? - .ok_or_else(ApiError::internal)?; - if bool_field(&raw_row, "hidden")? || bool_field(&raw_row, "deleted")? { - return Err(ApiError::not_found("listing not found")); - } - let raw_event = serde_json::from_str(&string_field(&raw_row, "raw_json")?) - .map_err(|_| ApiError::internal())?; - Ok(Json(ListingDetailDocument { - listing: listing_item_document(&row)?, - raw_event, - })) -} - -async fn listing_comments( - State(state): State<ListingsHttpState>, - Path((pubkey, d)): Path<(String, String)>, - RawQuery(query): RawQuery, -) -> Result<Json<ListingCommentsDocument>, ApiError> { - let pubkey = parse_pubkey("pubkey", &pubkey)?; - let d = required_value("d", &d)?; - let limit = parse_comment_query(query.as_deref().unwrap_or_default())?; - let listing_key = format!("30402:{}:{d}", pubkey.as_str()); - let listing = state - .store - .listing_current_row(&listing_key) - .await - .map_err(|_| ApiError::internal())? - .ok_or_else(|| ApiError::not_found("listing not found"))?; - if bool_field(&listing, "hidden")? || bool_field(&listing, "deleted")? { - return Err(ApiError::not_found("listing not found")); - } - let rows = state - .store - .query_comment_projections( - &CommentProjectionQuery::new() - .with_root("address", &listing_key) - .with_limit(limit), - ) - .await - .map_err(|_| ApiError::internal())?; - let items = rows - .iter() - .map(comment_item_document) - .collect::<Result<Vec<_>, _>>()?; - Ok(Json(ListingCommentsDocument { - items, - next_cursor: None, - })) -} - -async fn listing_reactions( - State(state): State<ListingsHttpState>, - Path((pubkey, d)): Path<(String, String)>, -) -> Result<Json<ReactionCountsDocument>, ApiError> { - let pubkey = parse_pubkey("pubkey", &pubkey)?; - let d = required_value("d", &d)?; - let listing_key = format!("30402:{}:{d}", pubkey.as_str()); - let listing = state - .store - .listing_current_row(&listing_key) - .await - .map_err(|_| ApiError::internal())? - .ok_or_else(|| ApiError::not_found("listing not found"))?; - if bool_field(&listing, "hidden")? || bool_field(&listing, "deleted")? { - return Err(ApiError::not_found("listing not found")); - } - let event_id = - EventId::new(&string_field(&listing, "event_id")?).map_err(|_| ApiError::internal())?; - let row = state - .store - .reaction_count_row(&event_id) - .await - .map_err(|_| ApiError::internal())?; - let document = reaction_counts_document(row.as_ref(), event_id.as_str(), Some("30402"))?; - Ok(Json(document)) -} - -async fn forum_threads( - State(state): State<ListingsHttpState>, - RawQuery(query): RawQuery, -) -> Result<Json<ForumThreadsDocument>, ApiError> { - let query = forum_thread_query(query.as_deref().unwrap_or_default())?; - let rows = state - .store - .query_forum_threads(&query) - .await - .map_err(|_| ApiError::internal())?; - let items = rows - .iter() - .map(forum_thread_item_document) - .collect::<Result<Vec<_>, _>>()?; - Ok(Json(ForumThreadsDocument { - items, - next_cursor: None, - })) -} - -async fn forum_thread_detail( - State(state): State<ListingsHttpState>, - Path(event_id): Path<String>, -) -> Result<Json<ForumThreadDetailDocument>, ApiError> { - let event_id = - EventId::new(&event_id).map_err(|_| invalid_parameter("event_id", "is invalid"))?; - let row = state - .store - .forum_thread_row(&event_id) - .await - .map_err(|_| ApiError::internal())? - .ok_or_else(|| ApiError::not_found("forum thread not found"))?; - if bool_field(&row, "hidden")? || bool_field(&row, "deleted")? { - return Err(ApiError::not_found("forum thread not found")); - } - let raw_row = state - .store - .raw_event_row(&event_id) - .await - .map_err(|_| ApiError::internal())? - .ok_or_else(ApiError::internal)?; - if bool_field(&raw_row, "hidden")? || bool_field(&raw_row, "deleted")? { - return Err(ApiError::not_found("forum thread not found")); - } - let raw_event = serde_json::from_str(&string_field(&raw_row, "raw_json")?) - .map_err(|_| ApiError::internal())?; - Ok(Json(ForumThreadDetailDocument { - thread: forum_thread_item_document(&row)?, - raw_event, - })) -} - -async fn forum_thread_comments( - State(state): State<ListingsHttpState>, - Path(event_id): Path<String>, - RawQuery(query): RawQuery, -) -> Result<Json<ListingCommentsDocument>, ApiError> { - let event_id = - EventId::new(&event_id).map_err(|_| invalid_parameter("event_id", "is invalid"))?; - let limit = parse_comment_query(query.as_deref().unwrap_or_default())?; - let row = state - .store - .forum_thread_row(&event_id) - .await - .map_err(|_| ApiError::internal())? - .ok_or_else(|| ApiError::not_found("forum thread not found"))?; - if bool_field(&row, "hidden")? || bool_field(&row, "deleted")? { - return Err(ApiError::not_found("forum thread not found")); - } - let rows = state - .store - .query_comment_projections( - &CommentProjectionQuery::new() - .with_root("event", event_id.as_str()) - .with_limit(limit), - ) - .await - .map_err(|_| ApiError::internal())?; - let items = rows - .iter() - .map(comment_item_document) - .collect::<Result<Vec<_>, _>>()?; - Ok(Json(ListingCommentsDocument { - items, - next_cursor: None, - })) -} - -async fn marketplace_search( - State(state): State<ListingsHttpState>, - RawQuery(query): RawQuery, -) -> Result<Json<ListingsDocument>, ApiError> { - let parsed = - parse_marketplace_search_query(query.as_deref().unwrap_or_default(), state.limits)?; - let search_query = search_document_query(&parsed); - let docs = state - .store - .query_search_documents(&search_query) - .await - .map_err(|_| ApiError::internal())?; - let mut items = Vec::new(); - for doc in docs { - let Some(address_key) = optional_string_field(&doc, "address_key")? else { - continue; - }; - let Some(row) = state - .store - .listing_current_row(&address_key) - .await - .map_err(|_| ApiError::internal())? - else { - continue; - }; - if bool_field(&row, "hidden")? || bool_field(&row, "deleted")? { - continue; - } - items.push(listing_item_document(&row)?); - } - Ok(Json(ListingsDocument { - items, - next_cursor: None, - })) -} - -async fn seller_detail( - State(state): State<ListingsHttpState>, - Path(pubkey): Path<String>, -) -> Result<Json<SellerDocument>, ApiError> { - let pubkey = parse_pubkey("pubkey", &pubkey)?; - let seller = state - .store - .relay_user_row(pubkey.as_str()) - .await - .map_err(|_| ApiError::internal())?; - let profile = state - .store - .seller_profile_row(pubkey.as_str()) - .await - .map_err(|_| ApiError::internal())?; - let visible_profile = match profile.as_ref() { - Some(row) if !bool_field(row, "hidden")? && !bool_field(row, "deleted")? => Some(row), - _ => None, - }; - let listings = state - .store - .query_current_listings( - &ListingProjectionQuery::new() - .with_effective_status("active") - .with_seller_pubkey(pubkey.as_str()), - ) - .await - .map_err(|_| ApiError::internal())?; - Ok(Json(SellerDocument { - pubkey: pubkey.as_str().to_owned(), - event_id: visible_profile - .map(|row| string_field(row, "event_id")) - .transpose()?, - name: visible_profile - .map(|row| optional_string_field(row, "name")) - .transpose()? - .flatten(), - display_name: visible_profile - .map(|row| optional_string_field(row, "display_name")) - .transpose()? - .flatten(), - about: visible_profile - .map(|row| optional_string_field(row, "about")) - .transpose()? - .flatten(), - picture: visible_profile - .map(|row| optional_string_field(row, "picture")) - .transpose()? - .flatten(), - website: visible_profile - .map(|row| optional_string_field(row, "website")) - .transpose()? - .flatten(), - nip05: visible_profile - .map(|row| optional_string_field(row, "nip05")) - .transpose()? - .flatten(), - lud16: visible_profile - .map(|row| optional_string_field(row, "lud16")) - .transpose()? - .flatten(), - regions: visible_profile - .map(|row| string_array_field(row, "regions")) - .transpose()? - .unwrap_or_default(), - categories: visible_profile - .map(|row| string_array_field(row, "categories")) - .transpose()? - .unwrap_or_default(), - trust_markers: visible_profile - .map(|row| string_array_field(row, "trust_markers")) - .transpose()? - .unwrap_or_default(), - approved: seller - .as_ref() - .and_then(|row| row.get("seller_approved")) - .and_then(serde_json::Value::as_bool) - .or_else(|| { - visible_profile - .and_then(|row| row.get("seller_approved")) - .and_then(serde_json::Value::as_bool) - }) - .unwrap_or(false), - blocked: seller - .as_ref() - .and_then(|row| row.get("blocked")) - .and_then(serde_json::Value::as_bool) - .or_else(|| { - visible_profile - .and_then(|row| row.get("blocked")) - .and_then(serde_json::Value::as_bool) - }) - .unwrap_or(false), - active_listing_count: listings.len() as u64, - })) -} - -fn accepts_nostr_json(value: Option<&HeaderValue>) -> bool { - value - .and_then(|value| value.to_str().ok()) - .is_some_and(|value| { - value.split(',').any(|part| { - part.split(';').next().is_some_and(|media_type| { - media_type - .trim() - .eq_ignore_ascii_case("application/nostr+json") - }) - }) - }) -} - -fn require_admin_pubkey( - config: &TangleRuntimeConfig, - headers: &HeaderMap, -) -> Result<PublicKeyHex, ApiError> { - if config.admin_pubkeys().is_empty() { - return Err(ApiError::forbidden("admin policy api is disabled")); - } - let value = headers - .get("x-tangle-admin-pubkey") - .ok_or_else(|| ApiError::unauthorized("admin pubkey header is required"))? - .to_str() - .map_err(|_| ApiError::unauthorized("admin pubkey header is invalid"))?; - let pubkey = PublicKeyHex::new(value) - .map_err(|_| ApiError::unauthorized("admin pubkey header is invalid"))?; - if !config.admin_pubkeys().contains(&pubkey) { - return Err(ApiError::forbidden("admin pubkey is not authorized")); - } - Ok(pubkey) -} - -fn listing_projection_query(parsed: &ListingHttpQuery) -> Result<ListingProjectionQuery, ApiError> { - let query = parsed.marketplace(); - if !query.categories.is_empty() { - return Err(invalid_parameter( - "category", - "is not supported by the listings endpoint", - )); - } - if parsed.geohash().is_some() { - return Err(invalid_parameter( - "geohash", - "is not supported by the listings endpoint", - )); - } - if !query.fulfillment.is_empty() { - return Err(invalid_parameter( - "fulfillment", - "is not supported by the listings endpoint", - )); - } - if query.delivery_only.is_some() { - return Err(invalid_parameter( - "delivery_only", - "is not supported by the listings endpoint", - )); - } - if query.pickup.is_some() { - return Err(invalid_parameter( - "pickup", - "is not supported by the listings endpoint", - )); - } - if query.location.point.is_some() - || query.location.radius_meters.is_some() - || query.location.near.is_some() - { - return Err(invalid_parameter( - "location", - "is not supported by the listings endpoint", - )); - } - if !matches!( - query.sort, - MarketplaceSort::Relevance | MarketplaceSort::Freshness - ) { - return Err(invalid_parameter( - "sort", - "is not supported by the listings endpoint", - )); - } - if query.statuses.len() != 1 { - return Err(invalid_parameter( - "status", - "must contain exactly one value for the listings endpoint", - )); - } - if query.currencies.len() > 1 { - return Err(invalid_parameter( - "currency", - "must contain at most one value for the listings endpoint", - )); - } - if query.units.len() > 1 { - return Err(invalid_parameter( - "unit", - "must contain at most one value for the listings endpoint", - )); - } - let mut store_query = - ListingProjectionQuery::new().with_effective_status(query.statuses[0].as_str()); - if let Some(seller) = &query.seller { - store_query = store_query.with_seller_pubkey(seller.as_str()); - } - if let Some(unit) = query.units.first() { - store_query = store_query.with_unit(unit.canonical()); - } - if let Some(currency) = query.currencies.first() { - store_query = store_query.with_currency_norm(currency); - } - if let Some(price) = &query.min_price { - store_query = store_query.with_min_price_minor(price_minor_units(&price.raw)?); - } - if let Some(price) = &query.max_price { - store_query = store_query.with_max_price_minor(price_minor_units(&price.raw)?); - } - Ok(store_query.with_limit(query.limit)) -} - -fn search_document_query(parsed: &MarketplaceSearchHttpQuery) -> SearchDocumentQuery { - let mut query = SearchDocumentQuery::new() - .with_doc_type("listing") - .with_kind(30_402) - .with_visible(true) - .with_status("active") - .with_limit(parsed.limit()); - if let Some(text) = parsed.text() { - query = query.with_text(text); - } - if let Some(seller) = parsed.seller() { - query = query.with_pubkey(seller.as_str()); - } - query -} - -fn forum_thread_query(raw: &str) -> Result<ForumThreadProjectionQuery, ApiError> { - let mut pubkey = None; - let mut topic = None; - let mut limit = None; - for (key, value) in form_urlencoded::parse(raw.as_bytes()) { - let value = value.into_owned(); - match key.as_ref() { - "pubkey" => set_once("pubkey", &mut pubkey, parse_pubkey("pubkey", &value)?)?, - "topic" => set_once("topic", &mut topic, required_value("topic", &value)?)?, - "limit" => set_once("limit", &mut limit, parse_limit(&value)?)?, - "cursor" => { - return Err(invalid_parameter( - "cursor", - "signed cursor decoding is not implemented", - )); - } - "" => {} - unsupported => { - return Err(ApiError::invalid_request(format!( - "query parameter `{unsupported}` is unsupported" - ))); - } - } - } - let mut query = ForumThreadProjectionQuery::new().with_limit(limit.unwrap_or(50)); - if let Some(pubkey) = pubkey { - query = query.with_pubkey(pubkey.as_str()); - } - if let Some(topic) = topic { - query = query.with_topic(&topic); - } - Ok(query) -} - -fn label_projection_query(raw: &str) -> Result<LabelProjectionQuery, ApiError> { - let mut target_type = None; - let mut target_ref = None; - let mut namespace = None; - let mut label = None; - let mut pubkey = None; - let mut limit = None; - for (key, value) in form_urlencoded::parse(raw.as_bytes()) { - let value = value.into_owned(); - match key.as_ref() { - "target_type" => { - let value = required_value("target_type", &value)?; - set_once("target_type", &mut target_type, value)?; - } - "target_ref" => { - let value = required_value("target_ref", &value)?; - set_once("target_ref", &mut target_ref, value)?; - } - "namespace" => { - let value = required_value("namespace", &value)?; - set_once("namespace", &mut namespace, value)?; - } - "label" => set_once("label", &mut label, required_value("label", &value)?)?, - "pubkey" => set_once("pubkey", &mut pubkey, parse_pubkey("pubkey", &value)?)?, - "limit" => set_once("limit", &mut limit, parse_limit(&value)?)?, - "cursor" => { - return Err(invalid_parameter( - "cursor", - "signed cursor decoding is not implemented", - )); - } - "" => {} - unsupported => { - return Err(ApiError::invalid_request(format!( - "query parameter `{unsupported}` is unsupported" - ))); - } - } - } - if target_type.is_some() != target_ref.is_some() { - return Err(invalid_parameter( - "target", - "target_type and target_ref must be provided together", - )); - } - let mut query = LabelProjectionQuery::new().with_limit(limit.unwrap_or(50)); - if let (Some(target_type), Some(target_ref)) = (target_type, target_ref) { - query = query.with_target(&target_type, &target_ref); - } - if let Some(namespace) = namespace { - query = query.with_namespace(&namespace); - } - if let Some(label) = label { - query = query.with_label(&label); - } - if let Some(pubkey) = pubkey { - query = query.with_pubkey(pubkey.as_str()); - } - Ok(query) -} - -fn report_projection_query(raw: &str) -> Result<ReportProjectionQuery, ApiError> { - let mut target_type = None; - let mut target_ref = None; - let mut report_type = None; - let mut pubkey = None; - let mut limit = None; - for (key, value) in form_urlencoded::parse(raw.as_bytes()) { - let value = value.into_owned(); - match key.as_ref() { - "target_type" => { - let value = required_value("target_type", &value)?; - set_once("target_type", &mut target_type, value)?; - } - "target_ref" => { - let value = required_value("target_ref", &value)?; - set_once("target_ref", &mut target_ref, value)?; - } - "report_type" => { - let value = required_value("report_type", &value)?; - set_once("report_type", &mut report_type, value)?; - } - "pubkey" => set_once("pubkey", &mut pubkey, parse_pubkey("pubkey", &value)?)?, - "limit" => set_once("limit", &mut limit, parse_limit(&value)?)?, - "cursor" => { - return Err(invalid_parameter( - "cursor", - "signed cursor decoding is not implemented", - )); - } - "" => {} - unsupported => { - return Err(ApiError::invalid_request(format!( - "query parameter `{unsupported}` is unsupported" - ))); - } - } - } - if target_type.is_some() != target_ref.is_some() { - return Err(invalid_parameter( - "target", - "target_type and target_ref must be provided together", - )); - } - let mut query = ReportProjectionQuery::new().with_limit(limit.unwrap_or(50)); - if let (Some(target_type), Some(target_ref)) = (target_type, target_ref) { - query = query.with_target(&target_type, &target_ref); - } - if let Some(report_type) = report_type { - query = query.with_report_type(&report_type); - } - if let Some(pubkey) = pubkey { - query = query.with_pubkey(pubkey.as_str()); - } - Ok(query) -} - -fn parse_comment_query(raw: &str) -> Result<u64, ApiError> { - let mut limit = None; - for (key, value) in form_urlencoded::parse(raw.as_bytes()) { - match key.as_ref() { - "limit" => set_once("limit", &mut limit, parse_limit(value.as_ref())?)?, - "" => {} - _ => { - return Err(ApiError::invalid_request(format!( - "{} is not supported by the listing comments endpoint", - key - ))); - } - } - } - Ok(limit.unwrap_or(50)) -} - -fn listing_item_document(row: &serde_json::Value) -> Result<ListingItemDocument, ApiError> { - Ok(ListingItemDocument { - listing_key: string_field(row, "listing_key")?, - event_id: string_field(row, "event_id")?, - seller_pubkey: string_field(row, "seller_pubkey")?, - d: string_field(row, "d")?, - title: string_field(row, "title")?, - summary: optional_string_field(row, "summary")?, - price: ListingPriceDocument { - amount: string_field(row, "price_decimal")?, - currency: string_field(row, "currency_norm")?, - unit: string_field(row, "unit")?, - }, - location: ListingLocationDocument { - text: optional_string_field(row, "location_text")?, - geohash: optional_string_field(row, "geohash")?, - }, - fulfillment: fulfillment_document(row)?, - status: string_field(row, "effective_status")?, - updated_at: u64_field(row, "updated_at")?, - }) -} - -fn forum_thread_item_document( - row: &serde_json::Value, -) -> Result<ForumThreadItemDocument, ApiError> { - Ok(ForumThreadItemDocument { - event_id: string_field(row, "event_id")?, - pubkey: string_field(row, "pubkey")?, - created_at: u64_field(row, "created_at")?, - updated_at: u64_field(row, "updated_at")?, - title: optional_string_field(row, "title")?, - content: string_field(row, "content")?, - tags: string_array_field(row, "tags")?, - }) -} - -fn comment_item_document(row: &serde_json::Value) -> Result<CommentItemDocument, ApiError> { - Ok(CommentItemDocument { - event_id: string_field(row, "event_id")?, - pubkey: string_field(row, "pubkey")?, - created_at: u64_field(row, "created_at")?, - content: string_field(row, "content")?, - root: CommentReferenceDocument { - target_type: string_field(row, "root_target_type")?, - target_ref: string_field(row, "root_ref")?, - kind: string_field(row, "root_kind")?, - author: optional_string_field(row, "root_author")?, - }, - parent: CommentReferenceDocument { - target_type: string_field(row, "parent_target_type")?, - target_ref: string_field(row, "parent_ref")?, - kind: string_field(row, "parent_kind")?, - author: optional_string_field(row, "parent_author")?, - }, - }) -} - -fn moderation_label_document(row: &serde_json::Value) -> Result<ModerationLabelDocument, ApiError> { - Ok(ModerationLabelDocument { - label_id: string_field(row, "label_id")?, - event_id: string_field(row, "event_id")?, - pubkey: string_field(row, "pubkey")?, - created_at: u64_field(row, "created_at")?, - content: string_field(row, "content")?, - namespace: string_field(row, "namespace")?, - label: string_field(row, "label")?, - target_type: string_field(row, "target_type")?, - target_ref: string_field(row, "target_ref")?, - projected_at: u64_field(row, "projected_at")?, - }) -} - -fn moderation_report_document( - row: &serde_json::Value, -) -> Result<ModerationReportDocument, ApiError> { - Ok(ModerationReportDocument { - report_id: string_field(row, "report_id")?, - event_id: string_field(row, "event_id")?, - pubkey: string_field(row, "pubkey")?, - created_at: u64_field(row, "created_at")?, - content: string_field(row, "content")?, - target_type: string_field(row, "target_type")?, - target_ref: string_field(row, "target_ref")?, - report_type: string_field(row, "report_type")?, - reported_pubkeys: string_array_field(row, "reported_pubkeys")?, - server_urls: string_array_field(row, "server_urls")?, - projected_at: u64_field(row, "projected_at")?, - }) -} - -fn reaction_counts_document( - row: Option<&serde_json::Value>, - target_event_id: &str, - target_kind: Option<&str>, -) -> Result<ReactionCountsDocument, ApiError> { - match row { - Some(row) => Ok(ReactionCountsDocument { - target_event_id: string_field(row, "target_event_id")?, - target_kind: optional_string_field(row, "target_kind")?, - like_count: u64_field(row, "like_count")?, - dislike_count: u64_field(row, "dislike_count")?, - emoji_count: u64_field(row, "emoji_count")?, - text_count: u64_field(row, "text_count")?, - total_count: u64_field(row, "total_count")?, - updated_at: u64_field(row, "updated_at")?, - }), - None => Ok(ReactionCountsDocument { - target_event_id: target_event_id.to_owned(), - target_kind: target_kind.map(str::to_owned), - like_count: 0, - dislike_count: 0, - emoji_count: 0, - text_count: 0, - total_count: 0, - updated_at: 0, - }), - } -} - -fn fulfillment_document(row: &serde_json::Value) -> Result<Vec<String>, ApiError> { - let mut fulfillment = Vec::new(); - if bool_field(row, "pickup_available")? { - fulfillment.push("pickup".to_owned()); - } - if bool_field(row, "delivery_available")? { - fulfillment.push("delivery".to_owned()); - } - if bool_field(row, "shipping_available")? { - fulfillment.push("shipping".to_owned()); - } - Ok(fulfillment) -} - -fn price_minor_units(raw: &str) -> Result<i64, ApiError> { - let mut parts = raw.split('.'); - let whole = parts.next().unwrap_or_default(); - let fraction = parts.next(); - if parts.next().is_some() || whole.is_empty() { - return Err(invalid_parameter( - "price", - "must fit two decimal minor units", - )); - } - let whole = whole - .parse::<i64>() - .map_err(|_| invalid_parameter("price", "must fit two decimal minor units"))?; - let fraction = match fraction { - Some(value) if value.len() <= 2 => format!("{value:0<2}") - .parse::<i64>() - .map_err(|_| invalid_parameter("price", "must fit two decimal minor units"))?, - Some(_) => { - return Err(invalid_parameter( - "price", - "must fit two decimal minor units", - )); - } - None => 0, - }; - whole - .checked_mul(100) - .and_then(|whole| whole.checked_add(fraction)) - .ok_or_else(|| invalid_parameter("price", "must fit two decimal minor units")) -} - -fn string_field(row: &serde_json::Value, field: &'static str) -> Result<String, ApiError> { - row.get(field) - .and_then(serde_json::Value::as_str) - .map(str::to_owned) - .ok_or_else(ApiError::internal) -} - -fn optional_string_field( - row: &serde_json::Value, - field: &'static str, -) -> Result<Option<String>, ApiError> { - match row.get(field) { - Some(value) if value.is_null() => Ok(None), - Some(value) => value - .as_str() - .map(|value| Some(value.to_owned())) - .ok_or_else(ApiError::internal), - None => Ok(None), - } -} - -fn string_array_field( - row: &serde_json::Value, - field: &'static str, -) -> Result<Vec<String>, ApiError> { - row.get(field) - .and_then(serde_json::Value::as_array) - .ok_or_else(ApiError::internal)? - .iter() - .map(|value| { - value - .as_str() - .map(str::to_owned) - .ok_or_else(ApiError::internal) - }) - .collect() -} - -fn u64_field(row: &serde_json::Value, field: &'static str) -> Result<u64, ApiError> { - row.get(field) - .and_then(serde_json::Value::as_u64) - .ok_or_else(ApiError::internal) -} - -fn bool_field(row: &serde_json::Value, field: &'static str) -> Result<bool, ApiError> { - row.get(field) - .and_then(serde_json::Value::as_bool) - .ok_or_else(ApiError::internal) -} - -impl From<MarketplaceQueryError> for ApiError { - fn from(error: MarketplaceQueryError) -> Self { - Self::invalid_request(error.message()) - } -} - -fn set_once<T>(field: &'static str, target: &mut Option<T>, value: T) -> Result<(), ApiError> { - if target.replace(value).is_some() { - return Err(invalid_parameter(field, "must not be repeated")); - } - Ok(()) -} - -fn push_text_values( - field: &'static str, - value: &str, - target: &mut Vec<String>, -) -> Result<(), ApiError> { - for value in split_query_list(field, value)? { - target.push(value); - } - Ok(()) -} - -fn push_status_values( - value: &str, - target: &mut Vec<MarketplaceListingStatus>, -) -> Result<(), ApiError> { - for value in split_query_list("status", value)? { - target.push(parse_status(&value)?); - } - Ok(()) -} - -fn push_unit_values(value: &str, target: &mut Vec<ListingUnit>) -> Result<(), ApiError> { - for value in split_query_list("unit", value)? { - target.push(parse_unit(&value)?); - } - Ok(()) -} - -fn push_fulfillment_values( - value: &str, - target: &mut Vec<FulfillmentMethod>, -) -> Result<(), ApiError> { - for value in split_query_list("fulfillment", value)? { - target.push(parse_fulfillment(&value)?); - } - Ok(()) -} - -fn split_query_list(field: &'static str, value: &str) -> Result<Vec<String>, ApiError> { - value - .split(',') - .map(|value| required_value(field, value)) - .collect() -} - -fn required_value(field: &'static str, value: &str) -> Result<String, ApiError> { - let value = value.trim(); - if value.is_empty() { - return Err(invalid_parameter(field, "must not be empty")); - } - Ok(value.to_owned()) -} - -fn parse_pubkey(field: &'static str, value: &str) -> Result<PublicKeyHex, ApiError> { - let value = required_value(field, value)?; - PublicKeyHex::new(&value) - .map_err(|_| invalid_parameter(field, "must be a 64-character hex public key")) -} - -fn parse_status(value: &str) -> Result<MarketplaceListingStatus, ApiError> { - match value.trim().to_ascii_lowercase().as_str() { - "active" => Ok(MarketplaceListingStatus::Active), - "sold" => Ok(MarketplaceListingStatus::Sold), - "draft" => Ok(MarketplaceListingStatus::Draft), - "inactive" => Ok(MarketplaceListingStatus::Inactive), - "expired" => Ok(MarketplaceListingStatus::Expired), - "deleted" => Ok(MarketplaceListingStatus::Deleted), - "hidden" => Ok(MarketplaceListingStatus::Hidden), - "rejected" => Ok(MarketplaceListingStatus::Rejected), - _ => Err(invalid_parameter("status", "is unsupported")), - } -} - -fn parse_sort(value: &str) -> Result<MarketplaceSort, ApiError> { - match required_value("sort", value)?.to_ascii_lowercase().as_str() { - "relevance" => Ok(MarketplaceSort::Relevance), - "freshness" => Ok(MarketplaceSort::Freshness), - "price_asc" => Ok(MarketplaceSort::PriceAsc), - "price_desc" => Ok(MarketplaceSort::PriceDesc), - "distance" => Ok(MarketplaceSort::Distance), - "seller_trust" => Ok(MarketplaceSort::SellerTrust), - _ => Err(invalid_parameter("sort", "is unsupported")), - } -} - -fn parse_unit(value: &str) -> Result<ListingUnit, ApiError> { - match value.trim().to_ascii_lowercase().as_str() { - "lb" | "lbs" | "pound" | "pounds" => Ok(ListingUnit::Lb), - "oz" | "ounce" | "ounces" => Ok(ListingUnit::Oz), - "each" | "ea" => Ok(ListingUnit::Each), - "bunch" | "bunches" => Ok(ListingUnit::Bunch), - "dozen" => Ok(ListingUnit::Dozen), - "kg" | "kilogram" | "kilograms" => Ok(ListingUnit::Kg), - "g" | "gram" | "grams" => Ok(ListingUnit::G), - "share" | "shares" => Ok(ListingUnit::Share), - "pint" | "pints" => Ok(ListingUnit::Pint), - "quart" | "quarts" => Ok(ListingUnit::Quart), - "box" | "boxes" => Ok(ListingUnit::Box), - "crate" | "crates" => Ok(ListingUnit::Crate), - "flat" | "flats" => Ok(ListingUnit::Flat), - _ => Err(invalid_parameter("unit", "is unsupported")), - } -} - -fn parse_fulfillment(value: &str) -> Result<FulfillmentMethod, ApiError> { - match value.trim().to_ascii_lowercase().as_str() { - "pickup" => Ok(FulfillmentMethod::Pickup), - "delivery" => Ok(FulfillmentMethod::Delivery), - "shipping" => Ok(FulfillmentMethod::Shipping), - _ => Err(invalid_parameter("fulfillment", "is unsupported")), - } -} - -fn parse_bool(field: &'static str, value: &str) -> Result<bool, ApiError> { - match required_value(field, value)?.to_ascii_lowercase().as_str() { - "true" => Ok(true), - "false" => Ok(false), - _ => Err(invalid_parameter(field, "must be true or false")), - } -} - -fn parse_geohash_query_value(value: &str) -> Result<String, ApiError> { - let value = required_value("geohash", value)?.to_ascii_lowercase(); - if value - .bytes() - .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) - { - Ok(value) - } else { - Err(invalid_parameter( - "geohash", - "must be lowercase alphanumeric", - )) - } -} - -fn parse_microdegrees( - field: &'static str, - value: &str, - min: i64, - max: i64, -) -> Result<i32, ApiError> { - let value = required_value(field, value)?; - let (negative, value) = match value.as_bytes().first() { - Some(b'-') => (true, &value[1..]), - Some(b'+') => (false, &value[1..]), - _ => (false, value.as_str()), - }; - let unsigned = parse_unsigned_decimal_scaled(field, value, 6)? as i64; - let signed = if negative { -unsigned } else { unsigned }; - if !(min..=max).contains(&signed) { - return Err(invalid_parameter(field, "is out of range")); - } - Ok(signed as i32) -} - -fn parse_radius_meters(value: &str) -> Result<u64, ApiError> { - let kilometers = required_value("radius_km", value)?; - let meters = parse_unsigned_decimal_scaled("radius_km", &kilometers, 3)?; - if meters == 0 { - return Err(invalid_parameter("radius_km", "must be greater than zero")); - } - Ok(meters) -} - -fn parse_limit(value: &str) -> Result<u64, ApiError> { - required_value("limit", value)? - .parse::<u64>() - .map_err(|_| invalid_parameter("limit", "must be an unsigned integer")) -} - -fn parse_unsigned_decimal_scaled( - field: &'static str, - value: &str, - scale_digits: usize, -) -> Result<u64, ApiError> { - let mut parts = value.split('.'); - let whole = parts.next().unwrap_or_default(); - let fraction = parts.next().unwrap_or_default(); - if parts.next().is_some() - || whole.is_empty() - || !whole.bytes().all(|byte| byte.is_ascii_digit()) - || !fraction.bytes().all(|byte| byte.is_ascii_digit()) - || fraction.len() > scale_digits - { - return Err(invalid_parameter( - field, - "must be an exact unsigned decimal", - )); - } - let whole = whole - .parse::<u64>() - .map_err(|_| invalid_parameter(field, "must be an exact unsigned decimal"))?; - let mut fraction = fraction.to_owned(); - while fraction.len() < scale_digits { - fraction.push('0'); - } - let fraction = fraction - .parse::<u64>() - .map_err(|_| invalid_parameter(field, "must be an exact unsigned decimal"))?; - whole - .checked_mul(10_u64.pow(scale_digits as u32)) - .and_then(|whole| whole.checked_add(fraction)) - .ok_or_else(|| invalid_parameter(field, "must fit the supported range")) -} - -fn invalid_parameter(field: &'static str, requirement: &str) -> ApiError { - ApiError::invalid_request(format!("{field} {requirement}")) -} - -#[cfg(test)] -mod tests { - use super::{ - ApiError, ApiErrorBody, ApiErrorCode, ApiErrorEnvelope, AuthMessageHandler, ClientFrame, - ClientFrameOutcome, ClientMessageLoop, CloseMessageHandler, CloseMessageOutcome, - EventMessageHandler, GracefulShutdownSignal, ListingsHttpState, LiveEventFanout, - MetricsHttpState, ReadinessCheckStatus, ReadinessState, RelayConnection, - RelayConnectionConfig, RelayConnectionId, RelayInfoDocument, ReqMessageHandler, - RuntimeCommandError, RuntimeCommandErrorKind, RuntimeConfigErrorKind, - RuntimeEventImportOutcome, RuntimeProjectionRebuildOutcome, RuntimeServerReport, - RuntimeTracingFormat, TANGLE_RELAY_SOFTWARE, TANGLE_RELAY_VERSION, TANGLE_SUPPORTED_NIPS, - WebSocketHttpState, backup_runtime_store, health_router, listing_item_document, - listing_projection_query, listings_router, load_runtime_config, metrics_router, - migrate_runtime_database, parse_listing_query, parse_marketplace_search_query, - parse_runtime_config_json, relay_info_router, restore_runtime_store, - runtime_readiness_state, search_document_query, - }; - use axum::{body::Body, response::IntoResponse}; - use futures_util::{SinkExt, StreamExt}; - use http::{HeaderValue, Request, StatusCode, header}; - use tangle_core::{ - AdmissionContext, AdmissionEffect, AdmissionPolicy, EventValidator, - MarketplaceListingStatus, MarketplaceSort, NostrFilterCompiler, RateLimitConfig, - RuntimeLimits, - }; - use tangle_nips::{ - FulfillmentMethod, ListingUnit, NIP01_METADATA_KIND, parse_relay_auth_event, - }; - use tangle_protocol::{ - ClientMessage, EventId, PublicKeyHex, RelayMessage, SubscriptionId, UnixTimestamp, - event_to_value, filter_from_value, - }; - use tangle_store::{StoreEventOutcome, StoredEvent}; - use tangle_store_surreal::{ - SurrealConnectionConfig, SurrealConnectionMode, SurrealStore, base_migration_plan, - }; - use tangle_test_support::{ - FixtureKey, auth_event_spec, build_fixture_event, build_fixture_event_from_parts, - valid_public_listing_spec, - }; - use tokio_tungstenite::tungstenite::Message as TungsteniteMessage; - use tower::ServiceExt; - - #[test] - fn api_error_codes_have_stable_labels_and_statuses() { - let cases = [ - (ApiErrorCode::InvalidRequest, "invalid_request", 400), - (ApiErrorCode::Unauthorized, "unauthorized", 401), - (ApiErrorCode::Forbidden, "forbidden", 403), - (ApiErrorCode::NotFound, "not_found", 404), - (ApiErrorCode::Conflict, "conflict", 409), - (ApiErrorCode::Internal, "internal_error", 500), - ]; - for (code, label, status) in cases { - assert_eq!(code.as_str(), label); - assert_eq!(code.to_string(), label); - assert_eq!(code.http_status(), status); - } - } - - #[test] - fn api_error_constructors_preserve_public_envelope_shape() { - let errors = [ - ApiError::invalid_request("bad query"), - ApiError::unauthorized("authentication required"), - ApiError::forbidden("admin role required"), - ApiError::not_found("listing not found"), - ApiError::conflict("event already exists"), - ApiError::internal(), - ]; - assert_eq!(errors[0].http_status(), 400); - assert_eq!(errors[1].http_status(), 401); - assert_eq!(errors[2].http_status(), 403); - assert_eq!(errors[3].http_status(), 404); - assert_eq!(errors[4].http_status(), 409); - assert_eq!(errors[5].http_status(), 500); - assert_eq!(errors[0].code(), ApiErrorCode::InvalidRequest); - assert_eq!(errors[0].message(), "bad query"); - assert_eq!(errors[0].to_string(), "invalid_request: bad query"); - assert_eq!( - errors[5].envelope(), - ApiErrorEnvelope { - error: ApiErrorBody { - code: "internal_error".to_owned(), - message: "internal server error".to_owned() - } - } - ); - assert_eq!( - serde_json::to_value(errors[0].envelope()).expect("json"), - serde_json::json!({ - "error": { - "code": "invalid_request", - "message": "bad query" - } - }) - ); - assert_eq!( - serde_json::from_value::<ApiErrorEnvelope>(serde_json::json!({ - "error": { - "code": "invalid_request", - "message": "bad query" - } - })) - .expect("envelope"), - errors[0].envelope() - ); - } - - #[test] - fn relay_connection_id_validates_and_displays_stable_value() { - let id = RelayConnectionId::new(" conn-001 ").expect("id"); - - assert_eq!(id.as_str(), "conn-001"); - assert_eq!(id.to_string(), "conn-001"); - assert_eq!( - RelayConnectionId::new("").expect_err("empty"), - "connection id must not be empty" - ); - assert_eq!( - RelayConnectionId::new(&"x".repeat(RelayConnectionId::MAX_LENGTH + 1)) - .expect_err("too long"), - "connection id must be at most 128 bytes, got 129" - ); - } - - #[test] - fn relay_connection_config_normalizes_core_runtime_state() { - let rate_limit = RateLimitConfig::new(10, 60).expect("rate limit"); - let config = RelayConnectionConfig::new( - " wss://relay.radroots.test ", - 42, - rate_limit, - RuntimeLimits::default(), - ) - .expect("config"); - - assert_eq!(config.relay_url(), "wss://relay.radroots.test"); - assert_eq!(config.auth_ttl_seconds(), 42); - assert_eq!(config.message_rate_limit(), rate_limit); - assert_eq!(config.runtime_limits(), RuntimeLimits::default()); - assert_eq!( - RelayConnectionConfig::new("", 42, rate_limit, RuntimeLimits::default()) - .expect_err("relay"), - "relay url must not be empty" - ); - assert_eq!( - RelayConnectionConfig::new( - "wss://relay.radroots.test", - 0, - rate_limit, - RuntimeLimits::default() - ) - .expect_err("ttl"), - "auth challenge ttl must be greater than zero" - ); - } - - #[test] - fn relay_connection_composes_subscription_auth_and_rate_state() { - let config = RelayConnectionConfig::new( - "wss://relay.radroots.test", - 30, - RateLimitConfig::new(2, 60).expect("rate limit"), - RuntimeLimits::default(), - ) - .expect("config"); - let mut connection = - RelayConnection::new(RelayConnectionId::new("conn-a").expect("id"), config); - - assert_eq!(connection.id().as_str(), "conn-a"); - assert_eq!(connection.remote_addr(), None); - connection.set_remote_addr("127.0.0.1:7777"); - assert_eq!(connection.remote_addr(), Some("127.0.0.1:7777")); - assert_eq!(connection.subscriptions().active_count(), 0); - assert_eq!(connection.auth().relay_url(), "wss://relay.radroots.test"); - assert_eq!(connection.auth().ttl_seconds(), 30); - assert_eq!(connection.rate_limiter().tracked_key_count(), 0); - - let challenge = connection - .auth_mut() - .issue_challenge("challenge-a", UnixTimestamp::new(100)) - .expect("challenge"); - let decision = connection - .rate_limiter_mut() - .check("conn-a", UnixTimestamp::new(100), 1) - .expect("rate limit"); - - assert_eq!(challenge.value, "challenge-a"); - assert_eq!( - connection - .auth() - .active_challenge() - .expect("active") - .expires_at, - UnixTimestamp::new(130) - ); - assert!(decision.allowed()); - assert_eq!(decision.remaining(), 1); - assert_eq!(connection.rate_limiter().tracked_key_count(), 1); - assert_eq!(connection.subscriptions_mut().active_count(), 0); - } - - #[test] - fn websocket_state_uses_relay_connection_config() { - let config = RelayConnectionConfig::new( - "wss://relay.radroots.test", - 60, - RateLimitConfig::new(5, 10).expect("rate limit"), - RuntimeLimits::default(), - ) - .expect("config"); - let state = WebSocketHttpState::new(config.clone()); - let default_state = WebSocketHttpState::default(); - - assert_eq!(state.connection_config(), &config); - assert_eq!( - default_state.connection_config().relay_url(), - "wss://relay.radroots.test" - ); - assert!(!state.shutdown_signal().is_shutdown_requested()); - assert!(!default_state.shutdown_signal().is_shutdown_requested()); - } - - #[tokio::test] - async fn graceful_shutdown_signal_notifies_subscribers() { - let (shutdown, mut first) = GracefulShutdownSignal::new(); - let mut second = shutdown.subscribe(); - - assert!(!shutdown.is_shutdown_requested()); - assert!(!first.is_shutdown_requested()); - assert!(!second.is_shutdown_requested()); - - assert!(shutdown.request_shutdown()); - first.wait_for_shutdown().await; - second.wait_for_shutdown().await; - - assert!(shutdown.is_shutdown_requested()); - assert!(first.is_shutdown_requested()); - assert!(second.is_shutdown_requested()); - } - - #[tokio::test] - async fn graceful_shutdown_listener_returns_when_already_requested() { - let (shutdown, mut listener) = GracefulShutdownSignal::new(); - - assert!(shutdown.request_shutdown()); - listener.wait_for_shutdown().await; - - assert!(listener.is_shutdown_requested()); - } - - #[tokio::test] - async fn graceful_shutdown_listener_wakes_after_request() { - let (shutdown, mut listener) = GracefulShutdownSignal::new(); - let (ready_tx, ready_rx) = tokio::sync::oneshot::channel(); - let task = tokio::spawn(async move { - ready_tx.send(()).expect("ready signal"); - listener.wait_for_shutdown().await; - listener.is_shutdown_requested() - }); - - ready_rx.await.expect("listener ready"); - tokio::task::yield_now().await; - assert!(shutdown.request_shutdown()); - assert!(task.await.expect("listener task")); - } - - #[tokio::test] - async fn runtime_config_accessors_and_error_types_are_stable() { - let config = parse_runtime_config_json( - r#"{ - "server": { - "listen_addr": "127.0.0.1:7002", - "relay_url": "ws://127.0.0.1:7002" - }, - "database": { - "mode": "memory", - "namespace": "tangle_accessors", - "database": "relay" - }, - "auth": { - "challenge_ttl_seconds": 120 - }, - "limits": { - "message_rate_limit": { - "limit": 3, - "window_seconds": 5 - } - } - }"#, - ) - .expect("runtime config"); - let store = runtime_memory_store().await; - let listings_state = config.listings_state(store); - let config_error = super::RuntimeConfigError::read("missing config"); - let command_errors = [ - RuntimeCommandError::unsupported("not supported"), - RuntimeCommandError::input("bad input"), - RuntimeCommandError::store("store failed"), - ]; - let server_report = RuntimeServerReport::new("127.0.0.1:7002".parse().expect("addr")); - - assert_eq!(config.tracing_config().format().as_str(), "compact"); - assert_eq!(RuntimeTracingFormat::Json.as_str(), "json"); - assert_eq!(listings_state.limits, RuntimeLimits::default()); - assert_eq!(config_error.kind(), RuntimeConfigErrorKind::Read); - assert_eq!(config_error.message(), "missing config"); - assert_eq!(config_error.to_string(), "Read: missing config"); - assert_eq!( - command_errors[0].kind(), - RuntimeCommandErrorKind::Unsupported - ); - assert_eq!(command_errors[1].kind(), RuntimeCommandErrorKind::Input); - assert_eq!(command_errors[2].kind(), RuntimeCommandErrorKind::Store); - assert_eq!(command_errors[0].message(), "not supported"); - assert_eq!(command_errors[1].to_string(), "Input: bad input"); - assert_eq!(server_report.listen_addr().to_string(), "127.0.0.1:7002"); - } - - #[test] - fn runtime_config_loader_parses_memory_config() { - let config = parse_runtime_config_json( - r#"{ - "server": { - "listen_addr": "127.0.0.1:7000", - "relay_url": "ws://127.0.0.1:7000" - }, - "database": { - "mode": "memory", - "namespace": "tangle_test", - "database": "relay" - }, - "auth": { - "challenge_ttl_seconds": 120 - }, - "limits": { - "message_rate_limit": { - "limit": 3, - "window_seconds": 5 - }, - "runtime": { - "max_event_bytes": 2048, - "max_content_bytes": 1024, - "max_tags_per_event": 32, - "max_tag_values_per_tag": 8, - "max_tag_value_bytes": 256, - "max_filters_per_subscription": 4, - "max_subscriptions_per_connection": 8, - "max_search_query_bytes": 128, - "max_search_tokens": 6, - "max_filter_complexity": 64, - "max_future_seconds": 60, - "live_event_buffer": 128, - "pending_store_events": 256 - } - }, - "policy": { - "admin_pubkeys": [ - "1111111111111111111111111111111111111111111111111111111111111111" - ], - "write_rate_limit": { - "limit": 2, - "window_seconds": 60 - } - } - }"#, - ) - .expect("runtime config"); - let (shutdown, _) = GracefulShutdownSignal::new(); - let websocket_state = config.websocket_state(shutdown); - - assert_eq!(config.listen_addr().to_string(), "127.0.0.1:7000"); - assert_eq!( - config.relay_connection_config().relay_url(), - "ws://127.0.0.1:7000" - ); - assert_eq!(config.relay_connection_config().auth_ttl_seconds(), 120); - assert_eq!( - config.relay_connection_config().message_rate_limit(), - RateLimitConfig::new(3, 5).expect("rate") - ); - assert_eq!(config.limits().max_event_bytes(), 2048); - assert_eq!(config.limits().max_content_bytes(), 1024); - assert_eq!(config.limits().max_tags_per_event(), 32); - assert_eq!(config.limits().max_tag_values_per_tag(), 8); - assert_eq!(config.limits().max_tag_value_bytes(), 256); - assert_eq!(config.limits().max_filters_per_subscription(), 4); - assert_eq!(config.limits().max_subscriptions_per_connection(), 8); - assert_eq!(config.limits().max_search_query_bytes(), 128); - assert_eq!(config.limits().max_search_tokens(), 6); - assert_eq!(config.limits().max_filter_complexity(), 64); - assert_eq!(config.limits().max_future_seconds(), 60); - assert_eq!(config.limits().live_event_buffer(), 128); - assert_eq!(config.limits().pending_store_events(), 256); - assert_eq!( - config.durable_write_rate_limit(), - Some(RateLimitConfig::new(2, 60).expect("write limit")) - ); - assert!( - config.admin_pubkeys().contains( - &PublicKeyHex::new( - "1111111111111111111111111111111111111111111111111111111111111111" - ) - .expect("admin pubkey") - ) - ); - assert_eq!(config.database_config().namespace(), "tangle_test"); - assert_eq!(config.database_config().database(), "relay"); - assert_eq!( - config.database_config().mode(), - &SurrealConnectionMode::Memory - ); - assert_eq!( - websocket_state.connection_config(), - config.relay_connection_config() - ); - assert!(!config.tracing_config().enabled()); - assert_eq!( - config.tracing_config().filter(), - "info,tangle=info,tangle_runtime=info" - ); - assert_eq!( - config.tracing_config().format(), - RuntimeTracingFormat::Compact - ); - } - - #[test] - fn runtime_config_loader_parses_websocket_database_config() { - let config = parse_runtime_config_json( - r#"{ - "server": { - "listen_addr": "127.0.0.1:7100", - "relay_url": "wss://relay.radroots.test" - }, - "database": { - "mode": "web_socket", - "endpoint": "ws://127.0.0.1:8000", - "username": "root", - "password": "root", - "namespace": "tangle", - "database": "relay" - }, - "auth": { - "challenge_ttl_seconds": 300 - }, - "limits": { - "message_rate_limit": { - "limit": 120, - "window_seconds": 60 - } - } - }"#, - ) - .expect("runtime config"); - - assert_eq!( - config.database_config().mode(), - &SurrealConnectionMode::WebSocket { - endpoint: "ws://127.0.0.1:8000".to_owned() - } - ); - let credentials = config - .database_config() - .root_credentials() - .expect("root credentials"); - assert_eq!(credentials.username(), "root"); - assert_eq!(credentials.password(), "root"); - assert_eq!(config.limits(), RuntimeLimits::default()); - } - - #[test] - fn runtime_config_loader_parses_local_surrealdb_stack_config() { - let config = parse_runtime_config_json(include_str!( - "../../../ops/local/surrealdb/tangle-runtime.json" - )) - .expect("local stack config"); - - assert_eq!(config.listen_addr().to_string(), "127.0.0.1:7000"); - assert_eq!( - config.database_config().mode(), - &SurrealConnectionMode::Http { - endpoint: "http://127.0.0.1:8000".to_owned() - } - ); - let credentials = config - .database_config() - .root_credentials() - .expect("root credentials"); - assert_eq!(credentials.username(), "root"); - assert_eq!(credentials.password(), "root"); - assert_eq!(config.database_config().namespace(), "tangle_local"); - assert_eq!(config.database_config().database(), "relay"); - assert!(config.tracing_config().enabled()); - assert_eq!(config.tracing_config().format(), RuntimeTracingFormat::Json); - assert_eq!( - config.durable_write_rate_limit(), - Some(RateLimitConfig::new(60, 60).expect("write limit")) - ); - assert!(config.admission_policy().require_write_auth()); - } - - #[test] - fn runtime_config_loader_parses_production_config_example() { - let config = parse_runtime_config_json(include_str!( - "../../../ops/production/tangle-runtime.example.json" - )) - .expect("production example config"); - - assert_eq!(config.listen_addr().to_string(), "0.0.0.0:7000"); - assert_eq!( - config.database_config().mode(), - &SurrealConnectionMode::Http { - endpoint: "http://surrealdb:8000".to_owned() - } - ); - let credentials = config - .database_config() - .root_credentials() - .expect("root credentials"); - assert_eq!(credentials.username(), "replace_with_surreal_root_user"); - assert_eq!(credentials.password(), "replace_with_surreal_root_password"); - assert_eq!(config.database_config().namespace(), "tangle"); - assert_eq!(config.database_config().database(), "relay"); - assert_eq!(config.tracing_config().format(), RuntimeTracingFormat::Json); - assert_eq!( - config.durable_write_rate_limit(), - Some(RateLimitConfig::new(300, 60).expect("write limit")) - ); - assert!(config.admission_policy().require_write_auth()); - assert!(config.admin_pubkeys().contains( - &PublicKeyHex::new(&"a".repeat(PublicKeyHex::HEX_LENGTH)).expect("admin pubkey") - )); - } - - #[test] - fn runtime_config_loader_parses_observability_tracing_config() { - let config = parse_runtime_config_json( - r#"{ - "server": { - "listen_addr": "127.0.0.1:7101", - "relay_url": "wss://relay.radroots.test" - }, - "database": { - "mode": "memory", - "namespace": "tangle", - "database": "relay" - }, - "auth": { - "challenge_ttl_seconds": 300 - }, - "limits": { - "message_rate_limit": { - "limit": 120, - "window_seconds": 60 - } - }, - "observability": { - "tracing": { - "enabled": true, - "filter": "info,tangle=debug,tangle_runtime=debug", - "format": "json" - } - } - }"#, - ) - .expect("runtime config"); - - assert!(config.tracing_config().enabled()); - assert_eq!( - config.tracing_config().filter(), - "info,tangle=debug,tangle_runtime=debug" - ); - assert_eq!(config.tracing_config().format(), RuntimeTracingFormat::Json); - } - - #[test] - fn runtime_config_loader_rejects_invalid_documents() { - let parse_error = parse_runtime_config_json("{").expect_err("parse"); - let invalid_listen = parse_runtime_config_json( - r#"{ - "server": { - "listen_addr": "not a socket", - "relay_url": "ws://127.0.0.1:7000" - }, - "database": { - "mode": "memory", - "namespace": "tangle", - "database": "relay" - }, - "auth": { - "challenge_ttl_seconds": 300 - }, - "limits": { - "message_rate_limit": { - "limit": 120, - "window_seconds": 60 - } - } - }"#, - ) - .expect_err("listen"); - let missing_endpoint = parse_runtime_config_json( - r#"{ - "server": { - "listen_addr": "127.0.0.1:7000", - "relay_url": "ws://127.0.0.1:7000" - }, - "database": { - "mode": "http", - "namespace": "tangle", - "database": "relay" - }, - "auth": { - "challenge_ttl_seconds": 300 - }, - "limits": { - "message_rate_limit": { - "limit": 120, - "window_seconds": 60 - } - } - }"#, - ) - .expect_err("endpoint"); - let missing_credentials = parse_runtime_config_json( - r#"{ - "server": { - "listen_addr": "127.0.0.1:7000", - "relay_url": "ws://127.0.0.1:7000" - }, - "database": { - "mode": "http", - "endpoint": "http://127.0.0.1:8000", - "namespace": "tangle", - "database": "relay" - }, - "auth": { - "challenge_ttl_seconds": 300 - }, - "limits": { - "message_rate_limit": { - "limit": 120, - "window_seconds": 60 - } - } - }"#, - ) - .expect_err("credentials"); - let empty_tracing_filter = parse_runtime_config_json( - r#"{ - "server": { - "listen_addr": "127.0.0.1:7000", - "relay_url": "ws://127.0.0.1:7000" - }, - "database": { - "mode": "memory", - "namespace": "tangle", - "database": "relay" - }, - "auth": { - "challenge_ttl_seconds": 300 - }, - "limits": { - "message_rate_limit": { - "limit": 120, - "window_seconds": 60 - } - }, - "observability": { - "tracing": { - "enabled": true, - "filter": " " - } - } - }"#, - ) - .expect_err("tracing filter"); - - assert_eq!(parse_error.kind(), RuntimeConfigErrorKind::Parse); - assert!( - parse_error - .message() - .starts_with("runtime config JSON is invalid:") - ); - assert_eq!(invalid_listen.kind(), RuntimeConfigErrorKind::Invalid); - assert!( - invalid_listen - .message() - .starts_with("server.listen_addr is invalid:") - ); - assert_eq!(missing_endpoint.kind(), RuntimeConfigErrorKind::Invalid); - assert_eq!( - missing_endpoint.message(), - "database.endpoint is required for http mode" - ); - assert_eq!(missing_credentials.kind(), RuntimeConfigErrorKind::Invalid); - assert_eq!( - missing_credentials.message(), - "database.username is required for http mode" - ); - assert_eq!(empty_tracing_filter.kind(), RuntimeConfigErrorKind::Invalid); - assert_eq!( - empty_tracing_filter.message(), - "observability.tracing.filter must not be empty" - ); - } - - #[test] - fn runtime_config_loader_rejects_mode_specific_database_and_policy_edges() { - let cases = [ - ( - r#"{ - "server": {"listen_addr": "127.0.0.1:7102", "relay_url": "ws://127.0.0.1:7102"}, - "database": {"mode": "memory", "endpoint": "mem://ignored", "namespace": "tangle", "database": "relay"}, - "auth": {"challenge_ttl_seconds": 300}, - "limits": {"message_rate_limit": {"limit": 120, "window_seconds": 60}} - }"#, - "database.endpoint must be omitted for memory mode", - ), - ( - r#"{ - "server": {"listen_addr": "127.0.0.1:7109", "relay_url": "ws://127.0.0.1:7109"}, - "database": {"mode": "memory", "path": "db", "namespace": "tangle", "database": "relay"}, - "auth": {"challenge_ttl_seconds": 300}, - "limits": {"message_rate_limit": {"limit": 120, "window_seconds": 60}} - }"#, - "database.path must be omitted for memory mode", - ), - ( - r#"{ - "server": {"listen_addr": "127.0.0.1:7110", "relay_url": "ws://127.0.0.1:7110"}, - "database": {"mode": "memory", "username": "root", "namespace": "tangle", "database": "relay"}, - "auth": {"challenge_ttl_seconds": 300}, - "limits": {"message_rate_limit": {"limit": 120, "window_seconds": 60}} - }"#, - "database credentials must be omitted for memory mode", - ), - ( - r#"{ - "server": {"listen_addr": "127.0.0.1:7103", "relay_url": "ws://127.0.0.1:7103"}, - "database": {"mode": "rocks_db", "endpoint": "http://127.0.0.1:8000", "path": "db", "namespace": "tangle", "database": "relay"}, - "auth": {"challenge_ttl_seconds": 300}, - "limits": {"message_rate_limit": {"limit": 120, "window_seconds": 60}} - }"#, - "database.endpoint must be omitted for rocksdb mode", - ), - ( - r#"{ - "server": {"listen_addr": "127.0.0.1:7104", "relay_url": "ws://127.0.0.1:7104"}, - "database": {"mode": "rocks_db", "username": "root", "path": "db", "namespace": "tangle", "database": "relay"}, - "auth": {"challenge_ttl_seconds": 300}, - "limits": {"message_rate_limit": {"limit": 120, "window_seconds": 60}} - }"#, - "database credentials must be omitted for rocksdb mode", - ), - ( - r#"{ - "server": {"listen_addr": "127.0.0.1:7105", "relay_url": "ws://127.0.0.1:7105"}, - "database": {"mode": "rocks_db", "namespace": "tangle", "database": "relay"}, - "auth": {"challenge_ttl_seconds": 300}, - "limits": {"message_rate_limit": {"limit": 120, "window_seconds": 60}} - }"#, - "database.path is required for rocksdb mode", - ), - ( - r#"{ - "server": {"listen_addr": "127.0.0.1:7106", "relay_url": "ws://127.0.0.1:7106"}, - "database": {"mode": "http", "endpoint": "http://127.0.0.1:8000", "username": "root", "namespace": "tangle", "database": "relay"}, - "auth": {"challenge_ttl_seconds": 300}, - "limits": {"message_rate_limit": {"limit": 120, "window_seconds": 60}} - }"#, - "database.password is required for http mode", - ), - ( - r#"{ - "server": {"listen_addr": "127.0.0.1:7107", "relay_url": "ws://127.0.0.1:7107"}, - "database": {"mode": "web_socket", "username": "root", "password": "root", "namespace": "tangle", "database": "relay"}, - "auth": {"challenge_ttl_seconds": 300}, - "limits": {"message_rate_limit": {"limit": 120, "window_seconds": 60}} - }"#, - "database.endpoint is required for websocket mode", - ), - ( - r#"{ - "server": {"listen_addr": "127.0.0.1:7108", "relay_url": "ws://127.0.0.1:7108"}, - "database": {"mode": "memory", "namespace": "tangle", "database": "relay"}, - "auth": {"challenge_ttl_seconds": 300}, - "limits": {"message_rate_limit": {"limit": 120, "window_seconds": 60}}, - "policy": {"admin_pubkeys": ["bad"]} - }"#, - "policy.admin_pubkeys contains invalid pubkey: public key must be 64 characters, got 3", - ), - ( - r#"{ - "server": {"listen_addr": "127.0.0.1:7111", "relay_url": "ws://127.0.0.1:7111"}, - "database": {"mode": "memory", "namespace": "tangle", "database": "relay"}, - "auth": {"challenge_ttl_seconds": 300}, - "limits": {"message_rate_limit": {"limit": 120, "window_seconds": 60}}, - "policy": {"approved_sellers": ["bad"]} - }"#, - "policy.approved_sellers contains invalid pubkey: public key must be 64 characters, got 3", - ), - ( - r#"{ - "server": {"listen_addr": "127.0.0.1:7112", "relay_url": "ws://127.0.0.1:7112"}, - "database": {"mode": "memory", "namespace": "tangle", "database": "relay"}, - "auth": {"challenge_ttl_seconds": 300}, - "limits": {"message_rate_limit": {"limit": 120, "window_seconds": 60}}, - "policy": {"blocked_pubkeys": ["bad"]} - }"#, - "policy.blocked_pubkeys contains invalid pubkey: public key must be 64 characters, got 3", - ), - ]; - - for (raw, expected) in cases { - let error = parse_runtime_config_json(raw).expect_err(expected); - assert_eq!(error.kind(), RuntimeConfigErrorKind::Invalid); - assert_eq!(error.message(), expected); - } - } - - #[test] - fn runtime_config_loader_parses_compact_tracing_format() { - let config = parse_runtime_config_json( - r#"{ - "server": {"listen_addr": "127.0.0.1:7113", "relay_url": "ws://127.0.0.1:7113"}, - "database": {"mode": "memory", "namespace": "tangle", "database": "relay"}, - "auth": {"challenge_ttl_seconds": 300}, - "limits": {"message_rate_limit": {"limit": 120, "window_seconds": 60}}, - "observability": { - "tracing": { - "enabled": true, - "filter": "info", - "format": "compact" - } - } - }"#, - ) - .expect("runtime config"); - - assert!(config.tracing_config().enabled()); - assert_eq!( - config.tracing_config().format(), - RuntimeTracingFormat::Compact - ); - } - - #[test] - fn event_import_document_parser_accepts_json_and_jsonl_edges() { - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let raw = event_to_value(&listing).to_string(); - let mut report = super::RuntimeEventImportReport::default(); - - assert!( - super::parse_event_import_document(" \n ") - .expect("empty") - .is_empty() - ); - assert_eq!( - super::parse_event_import_document(&raw) - .expect("object") - .first() - .expect("event") - .id(), - listing.id() - ); - assert_eq!( - super::parse_event_import_document(&format!("[{raw}]")) - .expect("array") - .len(), - 1 - ); - assert_eq!( - super::parse_event_import_document(&format!("{raw}\n\n{raw}")) - .expect("jsonl") - .len(), - 2 - ); - assert_eq!( - super::parse_event_import_document("42") - .expect_err("scalar") - .message(), - "event import file must contain event objects" - ); - assert!( - super::parse_event_import_document(r#"[{"id":"bad"}]"#) - .expect_err("bad item") - .message() - .starts_with("event import item 1 is invalid:") - ); - assert!( - super::parse_event_import_document("{bad") - .expect_err("bad line") - .message() - .starts_with("event import line 1 is invalid:") - ); - - report.record(RuntimeEventImportOutcome::Inserted { projected: true }); - report.record(RuntimeEventImportOutcome::Inserted { projected: false }); - report.record(RuntimeEventImportOutcome::Duplicate); - report.record(RuntimeEventImportOutcome::Skipped); - assert_eq!(report.total(), 4); - assert_eq!(report.inserted(), 2); - assert_eq!(report.duplicate(), 1); - assert_eq!(report.projected(), 1); - assert_eq!(report.skipped(), 1); - } - - #[tokio::test] - async fn import_and_rebuild_helpers_record_skipped_event_outcomes() { - let store = runtime_memory_store().await; - let validator = EventValidator::new( - RuntimeLimits::default(), - AdmissionPolicy::new().approve_seller(FixtureKey::Seller.public_key()), - ); - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let auth = build_fixture_event_from_parts( - FixtureKey::Seller, - 1_714_124_435, - 22_242, - vec![ - vec!["relay".to_owned(), "ws://127.0.0.1:0".to_owned()], - vec!["challenge".to_owned(), "challenge-001".to_owned()], - ], - "", - ) - .expect("auth"); - let ephemeral = build_fixture_event_from_parts( - FixtureKey::Seller, - 1_714_124_440, - 20_000, - Vec::new(), - "ephemeral", - ) - .expect("ephemeral"); - let mut rebuild_report = super::RuntimeProjectionRebuildReport::default(); - assert_eq!( - validator - .validate( - &auth, - &AdmissionContext::unauthenticated(), - UnixTimestamp::new(1_714_124_500) - ) - .expect("auth validates") - .admission() - .effect(), - AdmissionEffect::AuthenticateOnly - ); - - assert_eq!( - super::import_single_event(&store, &validator, listing.clone(), UnixTimestamp::new(1)) - .await - .expect("invalid skip"), - RuntimeEventImportOutcome::Skipped - ); - assert_eq!( - super::import_single_event( - &store, - &validator, - auth.clone(), - UnixTimestamp::new(1_714_124_500) - ) - .await - .expect("auth skip"), - RuntimeEventImportOutcome::Skipped - ); - assert_eq!( - super::import_single_event( - &store, - &validator, - ephemeral.clone(), - UnixTimestamp::new(1_714_124_500) - ) - .await - .expect("ephemeral skip"), - RuntimeEventImportOutcome::Skipped - ); - assert_eq!( - super::rebuild_single_event_projection( - &store, - &validator, - listing, - UnixTimestamp::new(1) - ) - .await - .expect("invalid rebuild skip"), - RuntimeProjectionRebuildOutcome::Skipped - ); - assert_eq!( - super::rebuild_single_event_projection( - &store, - &validator, - auth, - UnixTimestamp::new(1_714_124_500) - ) - .await - .expect("auth rebuild skip"), - RuntimeProjectionRebuildOutcome::Skipped - ); - assert_eq!( - super::rebuild_single_event_projection( - &store, - &validator, - ephemeral, - UnixTimestamp::new(1_714_124_500) - ) - .await - .expect("ephemeral rebuild skip"), - RuntimeProjectionRebuildOutcome::Skipped - ); - - rebuild_report.record(RuntimeProjectionRebuildOutcome::Rebuilt { projected: true }); - rebuild_report.record(RuntimeProjectionRebuildOutcome::Rebuilt { projected: false }); - rebuild_report.record(RuntimeProjectionRebuildOutcome::Skipped); - assert_eq!(rebuild_report.scanned(), 3); - assert_eq!(rebuild_report.rebuilt(), 2); - assert_eq!(rebuild_report.projected(), 1); - assert_eq!(rebuild_report.skipped(), 1); - } - - #[test] - fn runtime_config_loader_reads_config_file() { - let path = std::env::temp_dir().join(format!( - "tangle-runtime-config-loader-{}.json", - std::process::id() - )); - std::fs::write( - &path, - r#"{ - "server": { - "listen_addr": "127.0.0.1:7200", - "relay_url": "ws://127.0.0.1:7200" - }, - "database": { - "mode": "memory", - "namespace": "tangle_file", - "database": "relay" - }, - "auth": { - "challenge_ttl_seconds": 300 - }, - "limits": { - "message_rate_limit": { - "limit": 120, - "window_seconds": 60 - } - } - }"#, - ) - .expect("write config"); - - let config = load_runtime_config(&path).expect("loaded config"); - std::fs::remove_file(&path).expect("remove config"); - - assert_eq!(config.listen_addr().to_string(), "127.0.0.1:7200"); - assert_eq!(config.database_config().namespace(), "tangle_file"); - assert_eq!( - load_runtime_config(&path).expect_err("missing").kind(), - RuntimeConfigErrorKind::Read - ); - } - - #[tokio::test] - async fn runtime_migration_command_applies_memory_database_plan() { - let config = parse_runtime_config_json( - r#"{ - "server": { - "listen_addr": "127.0.0.1:7300", - "relay_url": "ws://127.0.0.1:7300" - }, - "database": { - "mode": "memory", - "namespace": "tangle_migrate", - "database": "relay" - }, - "auth": { - "challenge_ttl_seconds": 300 - }, - "limits": { - "message_rate_limit": { - "limit": 120, - "window_seconds": 60 - } - } - }"#, - ) - .expect("runtime config"); - - let report = migrate_runtime_database(&config).await.expect("migrate"); - - assert_eq!( - report.applied(), - base_migration_plan().migrations().len() as u64 - ); - assert_eq!(report.already_applied(), 0); - assert_eq!( - report.total(), - base_migration_plan().migrations().len() as u64 - ); - } - - #[tokio::test] - async fn runtime_backup_command_writes_manifest_and_raw_event_jsonl() { - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let root = std::env::temp_dir().join(format!( - "tangle-runtime-backup-{}-{}", - std::process::id(), - &listing.id().as_str()[..8] - )); - let _ = std::fs::remove_dir_all(&root); - let db_path = root.join("db"); - let backup_path = root.join("backup"); - std::fs::create_dir_all(&root).expect("runtime root"); - let config_json = serde_json::json!({ - "server": { - "listen_addr": "127.0.0.1:7301", - "relay_url": "ws://127.0.0.1:7301" - }, - "database": { - "mode": "rocks_db", - "path": db_path.to_str().expect("db path"), - "namespace": "tangle_backup", - "database": "relay" - }, - "auth": { - "challenge_ttl_seconds": 300 - }, - "limits": { - "message_rate_limit": { - "limit": 120, - "window_seconds": 60 - } - }, - "policy": { - "approved_sellers": [FixtureKey::Seller.public_key().as_str()] - } - }); - let config = parse_runtime_config_json( - &serde_json::to_string(&config_json).expect("runtime config JSON"), - ) - .expect("runtime config"); - let store = SurrealStore::connect(config.database_config()) - .await - .expect("store"); - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - assert_eq!( - store - .store_raw_event(&StoredEvent::new( - listing.clone(), - UnixTimestamp::new(1_714_124_500) - )) - .await - .expect("store raw"), - StoreEventOutcome::Inserted - ); - let report = backup_runtime_store(&config, &store, &backup_path) - .await - .expect("backup"); - - assert_eq!(report.output_dir(), backup_path.as_path()); - assert_eq!( - report.raw_events_path(), - backup_path.join("raw-events.jsonl") - ); - assert_eq!(report.raw_event_count(), 1); - assert_eq!(report.raw_events_sha256().len(), 64); - assert_eq!(report.manifest_path(), backup_path.join("manifest.json")); - assert_eq!(report.manifest_sha256().len(), 64); - assert!(!report.surrealdb_export_available()); - assert_eq!( - std::fs::read_to_string(report.raw_events_path()).expect("raw events"), - format!("{}\n", event_to_value(&listing)) - ); - let manifest: serde_json::Value = serde_json::from_str( - &std::fs::read_to_string(report.manifest_path()).expect("manifest"), - ) - .expect("manifest JSON"); - assert_eq!(manifest["format"], "tangle-backup-v1"); - assert_eq!(manifest["database"]["namespace"], "tangle_backup"); - assert_eq!(manifest["database"]["database"], "relay"); - assert_eq!(manifest["raw_events"]["path"], "raw-events.jsonl"); - assert_eq!(manifest["raw_events"]["count"], 1); - assert_eq!(manifest["raw_events"]["sha256"], report.raw_events_sha256()); - assert_eq!(manifest["surrealdb_export"]["available"], false); - assert!(manifest["surrealdb_export"]["path"].is_null()); - assert!(manifest["surrealdb_export"]["sha256"].is_null()); - - assert!( - store - .raw_event_row(listing.id()) - .await - .expect("raw row") - .is_some() - ); - drop(store); - std::fs::remove_dir_all(&root).expect("remove runtime root"); - } - - #[tokio::test] - async fn runtime_restore_command_imports_backup_and_rebuilds_projection_state() { - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let root = std::env::temp_dir().join(format!( - "tangle-runtime-restore-{}-{}", - std::process::id(), - &listing.id().as_str()[..8] - )); - let _ = std::fs::remove_dir_all(&root); - let source_db_path = root.join("source-db"); - let restore_db_path = root.join("restore-db"); - let backup_path = root.join("backup"); - std::fs::create_dir_all(&root).expect("runtime root"); - let source_config_json = serde_json::json!({ - "server": { - "listen_addr": "127.0.0.1:7302", - "relay_url": "ws://127.0.0.1:7302" - }, - "database": { - "mode": "rocks_db", - "path": source_db_path.to_str().expect("source db path"), - "namespace": "tangle_restore_source", - "database": "relay" - }, - "auth": { - "challenge_ttl_seconds": 300 - }, - "limits": { - "message_rate_limit": { - "limit": 120, - "window_seconds": 60 - } - }, - "policy": { - "approved_sellers": [FixtureKey::Seller.public_key().as_str()] - } - }); - let restore_config_json = serde_json::json!({ - "server": { - "listen_addr": "127.0.0.1:7303", - "relay_url": "ws://127.0.0.1:7303" - }, - "database": { - "mode": "rocks_db", - "path": restore_db_path.to_str().expect("restore db path"), - "namespace": "tangle_restore_destination", - "database": "relay" - }, - "auth": { - "challenge_ttl_seconds": 300 - }, - "limits": { - "message_rate_limit": { - "limit": 120, - "window_seconds": 60 - } - }, - "policy": { - "approved_sellers": [FixtureKey::Seller.public_key().as_str()] - } - }); - let source_config = parse_runtime_config_json( - &serde_json::to_string(&source_config_json).expect("source config JSON"), - ) - .expect("source config"); - let restore_config = parse_runtime_config_json( - &serde_json::to_string(&restore_config_json).expect("restore config JSON"), - ) - .expect("restore config"); - let source_store = SurrealStore::connect(source_config.database_config()) - .await - .expect("source store"); - source_store - .apply_plan(&base_migration_plan()) - .await - .expect("source migrations"); - assert_eq!( - source_store - .store_raw_event(&StoredEvent::new( - listing.clone(), - UnixTimestamp::new(1_714_124_500) - )) - .await - .expect("source raw event"), - StoreEventOutcome::Inserted - ); - let backup_report = backup_runtime_store(&source_config, &source_store, &backup_path) - .await - .expect("backup"); - assert_eq!(backup_report.raw_event_count(), 1); - drop(source_store); - - let restore_store = SurrealStore::connect(restore_config.database_config()) - .await - .expect("restore store"); - let restore_report = restore_runtime_store(&restore_config, &restore_store, &backup_path) - .await - .expect("restore"); - assert_eq!(restore_report.raw_event_count(), 1); - assert_eq!(restore_report.import_report().inserted(), 1); - assert_eq!(restore_report.import_report().duplicate(), 0); - assert_eq!(restore_report.rebuild_report().rebuilt(), 1); - assert_eq!(restore_report.rebuild_report().projected(), 1); - assert_eq!( - restore_report.raw_events_sha256(), - backup_report.raw_events_sha256() - ); - let seller = FixtureKey::Seller.public_key(); - let listing_key = format!("30402:{}:listing-a", seller.as_str()); - assert!( - restore_store - .raw_event_row(listing.id()) - .await - .expect("raw row") - .is_some() - ); - assert!( - restore_store - .listing_current_row(&listing_key) - .await - .expect("listing row") - .is_some() - ); - assert!( - restore_store - .search_document_row(&listing_key) - .await - .expect("search row") - .is_some() - ); - drop(restore_store); - std::fs::remove_dir_all(&root).expect("remove runtime root"); - } - - #[test] - fn backup_manifest_validation_rejects_invalid_artifact_metadata() { - let manifest = |format: &str, path: &str| super::RuntimeBackupManifestDocument { - format: format.to_owned(), - database: super::RuntimeBackupDatabaseDocument { - namespace: "tangle".to_owned(), - database: "relay".to_owned(), - }, - raw_events: super::RuntimeBackupArtifactDocument { - path: path.to_owned(), - count: 0, - sha256: "0".repeat(64), - }, - surrealdb_export: super::RuntimeBackupOptionalArtifactDocument { - available: false, - path: None, - sha256: None, - }, - }; - - assert_eq!( - super::validate_backup_manifest(&manifest("old", "raw-events.jsonl")) - .expect_err("format") - .message(), - "backup manifest format is unsupported: old" - ); - assert_eq!( - super::validate_backup_manifest(&manifest("tangle-backup-v1", " ")) - .expect_err("path") - .message(), - "backup manifest raw_events.path must not be empty" - ); - assert_eq!( - super::backup_artifact_path(std::path::Path::new("backup"), "../raw-events.jsonl") - .expect_err("parent") - .message(), - "backup manifest artifact paths must be relative to the backup directory" - ); - assert!( - super::backup_artifact_path(std::path::Path::new("backup"), "/raw-events.jsonl") - .expect_err("absolute") - .message() - .contains("relative") - ); - assert_eq!( - super::backup_artifact_path(std::path::Path::new("backup"), "raw-events.jsonl") - .expect("path"), - std::path::Path::new("backup").join("raw-events.jsonl") - ); - assert_eq!( - super::runtime_row_string(&serde_json::json!({"raw_json": null}), "raw_json") - .expect_err("row") - .message(), - "stored row field `raw_json` is invalid" - ); - } - - #[tokio::test] - async fn runtime_file_commands_report_io_and_manifest_validation_failures() { - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let root = std::env::temp_dir().join(format!( - "tangle-runtime-file-errors-{}-{}", - std::process::id(), - &listing.id().as_str()[..8] - )); - let _ = std::fs::remove_dir_all(&root); - std::fs::create_dir_all(&root).expect("runtime root"); - let config = runtime_memory_config("tangle_file_errors"); - let store = SurrealStore::connect(config.database_config()) - .await - .expect("store"); - - assert!( - super::import_events_from_path(&config, root.join("missing.jsonl")) - .await - .expect_err("missing import") - .message() - .starts_with("failed to read event import file `") - ); - assert!( - super::export_events_to_path(&config, &root) - .await - .expect_err("export dir") - .message() - .starts_with("failed to write event export file `") - ); - - let file_output = root.join("file-output"); - std::fs::write(&file_output, "not a directory").expect("file output"); - assert!( - super::backup_runtime_store(&config, &store, &file_output) - .await - .expect_err("backup create dir") - .message() - .starts_with("failed to create backup directory `") - ); - - let raw_dir_output = root.join("raw-dir-output"); - std::fs::create_dir_all(raw_dir_output.join("raw-events.jsonl")).expect("raw dir"); - assert!( - super::backup_runtime_store(&config, &store, &raw_dir_output) - .await - .expect_err("backup raw write") - .message() - .starts_with("failed to write backup raw events file `") - ); - - let manifest_dir_output = root.join("manifest-dir-output"); - std::fs::create_dir_all(manifest_dir_output.join("manifest.json")).expect("manifest dir"); - assert!( - super::backup_runtime_store(&config, &store, &manifest_dir_output) - .await - .expect_err("backup manifest write") - .message() - .starts_with("failed to write backup manifest file `") - ); - - let missing_manifest = root.join("missing-manifest"); - std::fs::create_dir_all(&missing_manifest).expect("missing manifest dir"); - assert!( - super::restore_runtime_store(&config, &store, &missing_manifest) - .await - .expect_err("missing manifest") - .message() - .starts_with("failed to read backup manifest file `") - ); - - let invalid_manifest = root.join("invalid-manifest"); - std::fs::create_dir_all(&invalid_manifest).expect("invalid manifest dir"); - std::fs::write(invalid_manifest.join("manifest.json"), "{").expect("invalid manifest"); - assert!( - super::restore_runtime_store(&config, &store, &invalid_manifest) - .await - .expect_err("invalid manifest") - .message() - .starts_with("backup manifest JSON is invalid:") - ); - - let restore_case = |name: &str, raw_events: &str, manifest: serde_json::Value| { - let path = root.join(name); - std::fs::create_dir_all(&path).expect("restore case dir"); - std::fs::write(path.join("raw-events.jsonl"), raw_events).expect("raw events"); - std::fs::write( - path.join("manifest.json"), - serde_json::to_string_pretty(&manifest).expect("manifest JSON"), - ) - .expect("manifest"); - path - }; - let missing_raw = restore_case( - "missing-raw", - "", - serde_json::json!({ - "format": "tangle-backup-v1", - "database": {"namespace": "tangle", "database": "relay"}, - "raw_events": {"path": "absent.jsonl", "count": 0, "sha256": "0".repeat(64)}, - "surrealdb_export": {"available": false, "path": null, "sha256": null} - }), - ); - assert!( - super::restore_runtime_store(&config, &store, &missing_raw) - .await - .expect_err("missing raw") - .message() - .starts_with("failed to read backup raw events file `") - ); - let checksum = restore_case( - "checksum", - "", - serde_json::json!({ - "format": "tangle-backup-v1", - "database": {"namespace": "tangle", "database": "relay"}, - "raw_events": {"path": "raw-events.jsonl", "count": 0, "sha256": "1".repeat(64)}, - "surrealdb_export": {"available": false, "path": null, "sha256": null} - }), - ); - assert!( - super::restore_runtime_store(&config, &store, &checksum) - .await - .expect_err("checksum") - .message() - .starts_with("backup raw events checksum mismatch:") - ); - let raw = format!("{}\n", event_to_value(&listing)); - let count = restore_case( - "count", - &raw, - serde_json::json!({ - "format": "tangle-backup-v1", - "database": {"namespace": "tangle", "database": "relay"}, - "raw_events": {"path": "raw-events.jsonl", "count": 2, "sha256": super::sha256_hex(raw.as_bytes())}, - "surrealdb_export": {"available": false, "path": null, "sha256": null} - }), - ); - assert!( - super::restore_runtime_store(&config, &store, &count) - .await - .expect_err("count") - .message() - .starts_with("backup raw events count mismatch:") - ); - - std::fs::remove_dir_all(&root).expect("remove runtime root"); - } - - #[tokio::test] - async fn runtime_websocket_route_requires_upgrade_headers() { - let store = runtime_memory_store().await; - let (shutdown, _) = GracefulShutdownSignal::new(); - let response = - super::runtime_router(runtime_memory_config("ws_missing_upgrade"), store, shutdown) - .oneshot( - Request::builder() - .uri("/ws") - .body(Body::empty()) - .expect("request"), - ) - .await - .expect("response"); - - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - } - - #[tokio::test] - async fn runtime_server_reports_listener_bind_failures() { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind reserved listener"); - let address = listener.local_addr().expect("reserved listener address"); - let mut config = runtime_memory_config("runtime_listen_failure"); - config.listen_addr = address; - let (shutdown, _) = GracefulShutdownSignal::new(); - let error = super::RuntimeServer::new(config, shutdown) - .run() - .await - .expect_err("listen failure"); - - assert!(error.message().contains("listen failed:")); - drop(listener); - } - - #[tokio::test] - async fn runtime_websocket_route_handles_client_frame_edges() { - let store = runtime_memory_store().await; - let (shutdown, _) = GracefulShutdownSignal::new(); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind listener"); - let address = listener.local_addr().expect("listener address"); - let app = super::runtime_router(runtime_memory_config("ws_frame_edges"), store, shutdown); - let server = - tokio::spawn(async move { axum::serve(listener, app).await.expect("serve runtime") }); - - let (mut client, _) = tokio_tungstenite::connect_async(format!("ws://{address}/ws")) - .await - .expect("websocket connect"); - assert_eq!(next_ws_json(&mut client, "initial auth").await[0], "AUTH"); - let listing = listing_event_at(1_714_124_436); - client - .send(TungsteniteMessage::Text( - serde_json::json!([ - "REQ", - "sub-live", - { - "kinds": [30402], - "authors": [listing.unsigned().pubkey().as_str()] - } - ]) - .to_string() - .into(), - )) - .await - .expect("subscription send"); - assert_eq!( - next_ws_json(&mut client, "subscription eose").await[0], - "EOSE" - ); - - let auth = build_fixture_event_from_parts( - FixtureKey::Seller, - 1_714_124_435, - 22_242, - vec![ - vec!["relay".to_owned(), "ws://127.0.0.1:0".to_owned()], - vec!["challenge".to_owned(), "challenge-001".to_owned()], - ], - "", - ) - .expect("auth"); - let (mut publisher, _) = tokio_tungstenite::connect_async(format!("ws://{address}/ws")) - .await - .expect("publisher connect"); - assert_eq!( - next_ws_json(&mut publisher, "publisher auth").await[0], - "AUTH" - ); - publisher - .send(TungsteniteMessage::Text( - serde_json::json!(["AUTH", event_to_value(&auth)]) - .to_string() - .into(), - )) - .await - .expect("auth send"); - let auth_ok = next_ws_json(&mut publisher, "auth ok").await; - assert_eq!(auth_ok[0], "OK"); - assert_eq!(auth_ok[2], true, "{auth_ok:?}"); - publisher - .send(TungsteniteMessage::Text( - serde_json::json!(["EVENT", event_to_value(&listing)]) - .to_string() - .into(), - )) - .await - .expect("listing send"); - let listing_ok = next_ws_json(&mut publisher, "listing ok").await; - assert_eq!(listing_ok[0], "OK"); - assert_eq!(listing_ok[2], true); - let live = next_ws_json(&mut client, "live event").await; - assert_eq!(live[0], "EVENT"); - assert_eq!(live[1], "sub-live"); - assert_eq!(live[2]["id"], listing.id().as_str()); - client - .send(TungsteniteMessage::Ping(vec![1].into())) - .await - .expect("ping send"); - client - .send(TungsteniteMessage::Binary(vec![1].into())) - .await - .expect("binary send"); - let notice = next_ws_json(&mut client, "binary notice").await; - assert_eq!(notice[0], "NOTICE"); - assert!( - notice[1] - .as_str() - .expect("notice message") - .contains("binary websocket messages are not supported") - ); - client - .send(TungsteniteMessage::Close(None)) - .await - .expect("close send"); - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - server.abort(); - } - - #[test] - fn client_message_loop_dispatches_supported_text_messages() { - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let auth = build_fixture_event(&auth_event_spec()).expect("auth"); - let mut loop_state = runtime_client_message_loop(); - - let outcome = loop_state.handle_frame(ClientFrame::Text( - serde_json::json!(["EVENT", event_to_value(&listing)]).to_string(), - )); - assert!(matches!( - outcome, - ClientFrameOutcome::Message(ClientMessage::Event(_)) - )); - assert!(format!("{outcome:?}").contains(listing.id().as_str())); - let outcome = loop_state.handle_frame(ClientFrame::Text( - serde_json::json!(["AUTH", event_to_value(&auth)]).to_string(), - )); - assert!(matches!( - outcome, - ClientFrameOutcome::Message(ClientMessage::Auth(_)) - )); - assert!(format!("{outcome:?}").contains(auth.id().as_str())); - let outcome = loop_state.handle_frame(ClientFrame::Text( - r#"["REQ","sub-a",{"kinds":[30402],"limit":1}]"#.to_owned(), - )); - assert!(matches!( - outcome, - ClientFrameOutcome::Message(ClientMessage::Req { .. }) - )); - assert!(format!("{outcome:?}").contains("sub-a")); - assert!(format!("{outcome:?}").contains("limit: Some(1)")); - let outcome = loop_state.handle_frame(ClientFrame::Text(r#"["CLOSE","sub-a"]"#.to_owned())); - assert!(matches!( - outcome, - ClientFrameOutcome::Message(ClientMessage::Close(_)) - )); - assert!(format!("{outcome:?}").contains("sub-a")); - assert_eq!(loop_state.connection().id().as_str(), "client-loop"); - assert_eq!( - loop_state.connection_mut().remote_addr(), - Some("127.0.0.1:7777") - ); - } - - #[test] - fn client_message_loop_rejects_or_ignores_non_message_frames() { - let mut loop_state = runtime_client_message_loop(); - - let outcome = loop_state.handle_frame(ClientFrame::Text("not json".to_owned())); - assert!(matches!( - outcome, - ClientFrameOutcome::Reject(RelayMessage::Notice(_)) - )); - assert!(format!("{outcome:?}").contains("client message JSON is invalid")); - assert_eq!( - loop_state.handle_frame(ClientFrame::Binary(vec![1, 2, 3])), - ClientFrameOutcome::Reject(RelayMessage::Notice( - "unsupported: binary websocket messages are not supported".to_owned() - )) - ); - assert_eq!( - loop_state.handle_frame(ClientFrame::Ping(vec![1])), - ClientFrameOutcome::Ignore - ); - assert_eq!( - loop_state.handle_frame(ClientFrame::Pong(vec![2])), - ClientFrameOutcome::Ignore - ); - assert_eq!( - loop_state.handle_frame(ClientFrame::Close), - ClientFrameOutcome::Close - ); - } - - #[test] - fn websocket_messages_convert_to_client_frames() { - assert_eq!( - super::client_frame_from_message(axum::extract::ws::Message::Text("hi".into())), - ClientFrame::Text("hi".to_owned()) - ); - assert_eq!( - super::client_frame_from_message(axum::extract::ws::Message::Binary(vec![1].into())), - ClientFrame::Binary(vec![1]) - ); - assert_eq!( - super::client_frame_from_message(axum::extract::ws::Message::Ping(vec![2].into())), - ClientFrame::Ping(vec![2]) - ); - assert_eq!( - super::client_frame_from_message(axum::extract::ws::Message::Pong(vec![3].into())), - ClientFrame::Pong(vec![3]) - ); - assert_eq!( - super::client_frame_from_message(axum::extract::ws::Message::Close(None)), - ClientFrame::Close - ); - } - - #[test] - fn client_message_loop_enforces_backpressure_limits() { - let config = RelayConnectionConfig::new( - "wss://relay.radroots.test", - 300, - RateLimitConfig::new(2, 60).expect("rate limit"), - RuntimeLimits::default(), - ) - .expect("config"); - let connection = - RelayConnection::new(RelayConnectionId::new("backpressure").expect("id"), config); - let mut loop_state = ClientMessageLoop::new(connection); - let frame = || ClientFrame::Text(r#"["REQ","sub-a",{"kinds":[30402]}]"#.to_owned()); - - assert!(matches!( - loop_state.handle_frame_at(frame(), UnixTimestamp::new(100)), - ClientFrameOutcome::Message(ClientMessage::Req { .. }) - )); - assert!(matches!( - loop_state.handle_frame_at(frame(), UnixTimestamp::new(100)), - ClientFrameOutcome::Message(ClientMessage::Req { .. }) - )); - assert_eq!( - loop_state.handle_frame_at(frame(), UnixTimestamp::new(100)), - ClientFrameOutcome::Reject(RelayMessage::Notice( - "rate-limited: retry after 60 seconds".to_owned() - )) - ); - assert_eq!( - loop_state.handle_frame_at(ClientFrame::Ping(vec![1]), UnixTimestamp::new(100)), - ClientFrameOutcome::Ignore - ); - assert!(matches!( - loop_state.handle_frame_at(frame(), UnixTimestamp::new(160)), - ClientFrameOutcome::Message(ClientMessage::Req { .. }) - )); - } - - #[tokio::test] - async fn event_message_handler_stores_and_projects_authenticated_listing() { - let store = runtime_memory_store().await; - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let mut connection = authenticated_connection(); - let handler = EventMessageHandler::new( - store.clone(), - EventValidator::new( - RuntimeLimits::default(), - AdmissionPolicy::new().approve_seller(listing.unsigned().pubkey().clone()), - ), - ); - let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str()); - - let outcome = handler - .handle_event( - &connection, - listing.clone(), - UnixTimestamp::new(1_714_125_300), - UnixTimestamp::new(1_714_125_400), - ) - .await; - - assert_eq!( - outcome, - RelayMessage::Ok { - event_id: listing.id().clone(), - accepted: true, - message: String::new() - } - ); - assert_eq!( - handler - .store() - .raw_event_row(listing.id()) - .await - .expect("raw") - .expect("raw exists")["event_id"], - listing.id().as_str() - ); - assert_eq!( - store - .listing_current_row(&listing_key) - .await - .expect("current") - .expect("current exists")["event_id"], - listing.id().as_str() - ); - assert_eq!( - store - .search_document_row(&listing_key) - .await - .expect("search") - .expect("search exists")["event_id"], - listing.id().as_str() - ); - assert_eq!( - handler - .handle_event( - &connection, - listing.clone(), - UnixTimestamp::new(1_714_125_301), - UnixTimestamp::new(1_714_125_401), - ) - .await, - RelayMessage::Ok { - event_id: listing.id().clone(), - accepted: true, - message: String::new() - } - ); - assert_eq!(handler.validator().limits(), RuntimeLimits::default()); - connection.auth_mut().clear_authentication(); - let rejected = handler - .handle_event( - &connection, - listing.clone(), - UnixTimestamp::new(1_714_125_302), - UnixTimestamp::new(1_714_125_402), - ) - .await; - assert!(matches!( - rejected, - RelayMessage::Ok { - accepted: false, - .. - } - )); - assert!(format!("{rejected:?}").contains("write authentication required")); - } - - #[tokio::test] - async fn event_message_handler_persists_durable_write_rate_limits() { - let store = runtime_memory_store().await; - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let connection = authenticated_connection(); - let handler = EventMessageHandler::new( - store.clone(), - EventValidator::new( - RuntimeLimits::default(), - AdmissionPolicy::new().approve_seller(listing.unsigned().pubkey().clone()), - ), - ) - .with_durable_write_rate_limit(Some(RateLimitConfig::new(1, 60).expect("write rate"))); - - let accepted = handler - .handle_event( - &connection, - listing.clone(), - UnixTimestamp::new(1_714_125_500), - UnixTimestamp::new(1_714_125_500), - ) - .await; - let rejected = handler - .handle_event( - &connection, - listing.clone(), - UnixTimestamp::new(1_714_125_501), - UnixTimestamp::new(1_714_125_501), - ) - .await; - - assert_eq!( - handler.durable_write_rate_limit(), - Some(RateLimitConfig::new(1, 60).expect("write rate")) - ); - assert_eq!( - accepted, - RelayMessage::Ok { - event_id: listing.id().clone(), - accepted: true, - message: String::new() - } - ); - assert!(matches!( - rejected, - RelayMessage::Ok { - accepted: false, - .. - } - )); - assert!(format!("{rejected:?}").contains("rate-limited: retry after 59 seconds")); - let key = format!("event_write:{}", listing.unsigned().pubkey().as_str()); - let row = store - .rate_limit_state_row(&key) - .await - .expect("rate row") - .expect("rate row exists"); - assert_eq!(row["key"], key); - assert_eq!(row["expires_at"], 1_714_125_560_u64); - assert_eq!( - serde_json::from_str::<serde_json::Value>(row["state"].as_str().expect("state")) - .expect("state json"), - serde_json::json!({ - "started_at": 1714125500_u64, - "used": 1 - }) - ); - } - - #[tokio::test] - async fn event_message_handler_reports_store_policy_failures() { - let config = SurrealConnectionConfig::memory("tangle_runtime", "event_policy_failure") - .expect("memory config"); - let store = SurrealStore::connect_memory(&config) - .await - .expect("memory store"); - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let connection = authenticated_connection(); - let handler = EventMessageHandler::new( - store, - EventValidator::new( - RuntimeLimits::default(), - AdmissionPolicy::new().approve_seller(listing.unsigned().pubkey().clone()), - ), - ); - - assert!( - format!( - "{:?}", - handler - .handle_event( - &connection, - listing, - UnixTimestamp::new(1_714_125_600), - UnixTimestamp::new(1_714_125_601), - ) - .await - ) - .contains("policy unavailable") - ); - } - - #[tokio::test] - async fn event_message_handler_reports_rate_limit_store_and_projection_failures() { - let connection = authenticated_connection(); - let rate_limit_store = SurrealStore::connect_memory( - &SurrealConnectionConfig::memory("tangle_runtime", "rate_limit_failure") - .expect("rate limit config"), - ) - .await - .expect("rate limit store"); - rate_limit_store - .database() - .query( - "DEFINE TABLE rate_limit_state SCHEMAFULL; DEFINE FIELD key ON TABLE rate_limit_state TYPE int;", - ) - .await - .expect("rate limit schema") - .check() - .expect("rate limit schema check"); - let rate_limited = EventMessageHandler::new( - rate_limit_store, - EventValidator::new(RuntimeLimits::default(), AdmissionPolicy::new()), - ) - .with_durable_write_rate_limit(Some(RateLimitConfig::new(1, 60).expect("rate limit"))); - let outcome = rate_limited - .handle_event( - &connection, - note_event(1_714_125_610, "rate limit unavailable"), - UnixTimestamp::new(1_714_125_611), - UnixTimestamp::new(1_714_125_611), - ) - .await; - assert!(format!("{outcome:?}").contains("rate limit unavailable")); - - let store_failure = SurrealStore::connect_memory( - &SurrealConnectionConfig::memory("tangle_runtime", "store_failure") - .expect("store config"), - ) - .await - .expect("store failure store"); - store_failure - .database() - .query( - "DEFINE TABLE nostr_event SCHEMAFULL; DEFINE FIELD event_id ON TABLE nostr_event TYPE int;", - ) - .await - .expect("store schema") - .check() - .expect("store schema check"); - let store_handler = EventMessageHandler::new( - store_failure, - EventValidator::new(RuntimeLimits::default(), AdmissionPolicy::new()), - ); - let outcome = store_handler - .handle_event( - &connection, - note_event(1_714_125_620, "store unavailable"), - UnixTimestamp::new(1_714_125_621), - UnixTimestamp::new(1_714_125_621), - ) - .await; - assert!(format!("{outcome:?}").contains("store unavailable")); - - let projection_store = runtime_memory_store().await; - projection_store - .database() - .query( - "REMOVE TABLE event_tag_index; DEFINE TABLE event_tag_index SCHEMAFULL; DEFINE FIELD event_id ON TABLE event_tag_index TYPE int;", - ) - .await - .expect("projection schema") - .check() - .expect("projection schema check"); - let listing = listing_event_at(1_714_125_630); - let projection_handler = EventMessageHandler::new( - projection_store, - EventValidator::new( - RuntimeLimits::default(), - AdmissionPolicy::new().approve_seller(listing.unsigned().pubkey().clone()), - ), - ); - let outcome = projection_handler - .handle_event( - &connection, - listing, - UnixTimestamp::new(1_714_125_631), - UnixTimestamp::new(1_714_125_631), - ) - .await; - assert!(format!("{outcome:?}").contains("projection failed")); - } - - #[tokio::test] - async fn event_message_handler_accepts_ephemeral_events_without_persistence() { - let store = runtime_memory_store().await; - let event = build_fixture_event_from_parts( - FixtureKey::Seller, - 1_714_125_640, - 20_000, - Vec::new(), - "ephemeral", - ) - .expect("ephemeral event"); - let handler = EventMessageHandler::new( - store.clone(), - EventValidator::new(RuntimeLimits::default(), AdmissionPolicy::new()), - ); - - let outcome = handler - .handle_event( - &authenticated_connection(), - event.clone(), - UnixTimestamp::new(1_714_125_641), - UnixTimestamp::new(1_714_125_641), - ) - .await; - - assert_eq!( - outcome, - RelayMessage::Ok { - event_id: event.id().clone(), - accepted: true, - message: String::new() - } - ); - assert!( - store - .raw_event_row(event.id()) - .await - .expect("raw row") - .is_none() - ); - } - - #[tokio::test] - async fn event_message_handler_applies_dynamic_seller_policy_rows() { - let store = runtime_memory_store().await; - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str()); - let connection = authenticated_connection(); - let handler = EventMessageHandler::new( - store.clone(), - EventValidator::new(RuntimeLimits::default(), AdmissionPolicy::new()), - ); - - store - .set_seller_approved( - listing.unsigned().pubkey().as_str(), - true, - UnixTimestamp::new(1_714_126_200), - ) - .await - .expect("approve seller"); - let accepted = handler - .handle_event( - &connection, - listing.clone(), - UnixTimestamp::new(1_714_126_201), - UnixTimestamp::new(1_714_126_201), - ) - .await; - - assert_eq!( - accepted, - RelayMessage::Ok { - event_id: listing.id().clone(), - accepted: true, - message: String::new() - } - ); - assert!( - store - .listing_current_row(&listing_key) - .await - .expect("listing row") - .is_some() - ); - assert!( - store - .search_document_row(&listing_key) - .await - .expect("search row") - .is_some() - ); - - store - .set_pubkey_blocked( - listing.unsigned().pubkey().as_str(), - true, - UnixTimestamp::new(1_714_126_202), - ) - .await - .expect("block seller"); - let blocked_listing = listing_event_at(1_714_126_203); - let blocked = handler - .handle_event( - &connection, - blocked_listing.clone(), - UnixTimestamp::new(1_714_126_204), - UnixTimestamp::new(1_714_126_204), - ) - .await; - assert_eq!( - blocked, - RelayMessage::Ok { - event_id: blocked_listing.id().clone(), - accepted: true, - message: String::new() - } - ); - assert_ne!( - store - .listing_current_row(&listing_key) - .await - .expect("blocked current") - .expect("current row")["event_id"], - blocked_listing.id().as_str() - ); - - let fallback_store = runtime_memory_store().await; - fallback_store - .set_seller_approved( - listing.unsigned().pubkey().as_str(), - false, - UnixTimestamp::new(1_714_126_205), - ) - .await - .expect("fallback row"); - let fallback_handler = EventMessageHandler::new( - fallback_store.clone(), - EventValidator::new( - RuntimeLimits::default(), - AdmissionPolicy::new().approve_seller(listing.unsigned().pubkey().clone()), - ), - ); - let fallback_listing = listing_event_at(1_714_126_206); - let fallback = fallback_handler - .handle_event( - &connection, - fallback_listing.clone(), - UnixTimestamp::new(1_714_126_207), - UnixTimestamp::new(1_714_126_207), - ) - .await; - assert_eq!( - fallback, - RelayMessage::Ok { - event_id: fallback_listing.id().clone(), - accepted: true, - message: String::new() - } - ); - assert_eq!( - fallback_store - .listing_current_row(&listing_key) - .await - .expect("fallback current") - .expect("fallback current row")["event_id"], - fallback_listing.id().as_str() - ); - } - - #[test] - fn auth_message_handler_issues_and_accepts_auth_events() { - let handler = AuthMessageHandler; - let mut connection = RelayConnection::new( - RelayConnectionId::new("auth").expect("connection id"), - RelayConnectionConfig::default(), - ); - let auth = build_fixture_event(&auth_event_spec()).expect("auth"); - - assert_eq!( - handler.issue_challenge( - &mut connection, - " challenge-001 ", - UnixTimestamp::new(1_714_124_430) - ), - RelayMessage::Auth("challenge-001".to_owned()) - ); - assert_eq!( - handler.handle_auth( - &mut connection, - auth.clone(), - UnixTimestamp::new(1_714_124_435) - ), - RelayMessage::Ok { - event_id: auth.id().clone(), - accepted: true, - message: String::new() - } - ); - assert_eq!( - connection.auth().authenticated_pubkey(), - Some(auth.unsigned().pubkey()) - ); - assert_eq!( - handler.issue_challenge(&mut connection, " ", UnixTimestamp::new(1_714_124_436)), - RelayMessage::Notice("error: auth challenge must not be empty".to_owned()) - ); - } - - #[test] - fn auth_message_handler_rejects_invalid_auth_messages() { - let handler = AuthMessageHandler; - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let auth = build_fixture_event(&auth_event_spec()).expect("auth"); - let mut missing_challenge = RelayConnection::new( - RelayConnectionId::new("missing-challenge").expect("connection id"), - RelayConnectionConfig::default(), - ); - let mut wrong_kind = RelayConnection::new( - RelayConnectionId::new("wrong-kind").expect("connection id"), - RelayConnectionConfig::default(), - ); - - let outcome = handler.handle_auth( - &mut missing_challenge, - auth.clone(), - UnixTimestamp::new(1_714_124_435), - ); - assert!(matches!( - outcome, - RelayMessage::Ok { - accepted: false, - .. - } - )); - assert!(format!("{outcome:?}").contains("auth challenge is missing")); - let outcome = handler.handle_auth( - &mut wrong_kind, - listing.clone(), - UnixTimestamp::new(1_714_124_435), - ); - assert!(matches!( - outcome, - RelayMessage::Ok { - accepted: false, - .. - } - )); - assert!(format!("{outcome:?}").contains("AUTH message must contain kind 22242")); - let malformed_auth = build_fixture_event_from_parts( - FixtureKey::Relay, - 1_714_124_436, - 22_242, - Vec::new(), - "", - ) - .expect("malformed auth"); - let outcome = handler.handle_auth( - &mut wrong_kind, - malformed_auth, - UnixTimestamp::new(1_714_124_436), - ); - assert!(format!("{outcome:?}").contains("invalid:")); - } - - #[tokio::test] - async fn req_message_handler_returns_raw_events_and_eose() { - let store = runtime_memory_store().await; - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - store - .store_raw_event(&StoredEvent::new( - listing.clone(), - UnixTimestamp::new(1_714_125_300), - )) - .await - .expect("raw event"); - let handler = ReqMessageHandler::new(store, NostrFilterCompiler::default()); - let mut connection = runtime_connection("req-raw"); - let subscription_id = SubscriptionId::new("sub-raw").expect("subscription"); - let filter = filter_from_value(&serde_json::json!({ - "ids": [listing.id().as_str()], - "limit": 10 - })) - .expect("filter"); - - let messages = handler - .handle_req(&mut connection, subscription_id.clone(), vec![filter]) - .await; - - assert_eq!(messages.len(), 2); - assert!(matches!(&messages[0], RelayMessage::Event { .. })); - assert!(format!("{:?}", messages[0]).contains(subscription_id.as_str())); - assert!(format!("{:?}", messages[0]).contains(listing.id().as_str())); - assert_eq!(messages[1], RelayMessage::Eose(subscription_id.clone())); - assert!(connection.subscriptions().plan(&subscription_id).is_some()); - assert_eq!(handler.compiler(), NostrFilterCompiler::default()); - } - - #[tokio::test] - async fn req_message_handler_hydrates_search_results_and_closes_bad_requests() { - let store = runtime_memory_store().await; - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - store - .store_raw_event(&StoredEvent::new( - listing.clone(), - UnixTimestamp::new(1_714_125_300), - )) - .await - .expect("raw event"); - store - .index_listing_search_document(&listing) - .await - .expect("search"); - let handler = ReqMessageHandler::new(store.clone(), NostrFilterCompiler::default()); - let mut connection = runtime_connection("req-search"); - let search_id = SubscriptionId::new("sub-search").expect("subscription"); - let search_filter = filter_from_value(&serde_json::json!({ - "search": "carrot", - "kinds": [30402], - "authors": [listing.unsigned().pubkey().as_str()], - "limit": 5 - })) - .expect("filter"); - - let messages = handler - .handle_req(&mut connection, search_id.clone(), vec![search_filter]) - .await; - - assert_eq!(messages.len(), 2); - assert!(matches!(&messages[0], RelayMessage::Event { .. })); - assert!(format!("{:?}", messages[0]).contains(listing.id().as_str())); - assert_eq!(messages[1], RelayMessage::Eose(search_id)); - let bad_id = SubscriptionId::new("sub-bad").expect("subscription"); - let bad = handler - .handle_req(&mut connection, bad_id.clone(), Vec::new()) - .await; - assert_eq!( - bad, - vec![RelayMessage::Closed { - subscription_id: bad_id, - message: "unsupported: query plan: query plan must include at least one branch" - .to_owned() - }] - ); - assert!( - handler - .store() - .raw_event_row(listing.id()) - .await - .expect("raw") - .is_some() - ); - } - - #[tokio::test] - async fn req_message_handler_closes_when_store_query_fails() { - let config = SurrealConnectionConfig::memory("tangle_runtime", "req_store_failure") - .expect("memory config"); - let store = SurrealStore::connect_memory(&config) - .await - .expect("memory store"); - let handler = ReqMessageHandler::new(store, NostrFilterCompiler::default()); - let mut connection = runtime_connection("req-error"); - let subscription_id = SubscriptionId::new("sub-error").expect("subscription"); - let filter = - filter_from_value(&serde_json::json!({"kinds": [30402], "limit": 1})).expect("filter"); - - assert_eq!( - handler - .handle_req(&mut connection, subscription_id.clone(), vec![filter]) - .await, - vec![RelayMessage::Closed { - subscription_id, - message: "internal server error".to_owned() - }] - ); - } - - #[tokio::test] - async fn close_message_handler_removes_subscriptions() { - let store = runtime_memory_store().await; - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - store - .store_raw_event(&StoredEvent::new( - listing.clone(), - UnixTimestamp::new(1_714_125_300), - )) - .await - .expect("raw event"); - let req_handler = ReqMessageHandler::new(store, NostrFilterCompiler::default()); - let close_handler = CloseMessageHandler; - let mut connection = runtime_connection("close"); - let subscription_id = SubscriptionId::new("sub-close").expect("subscription"); - let filter = filter_from_value(&serde_json::json!({ - "ids": [listing.id().as_str()] - })) - .expect("filter"); - - req_handler - .handle_req(&mut connection, subscription_id.clone(), vec![filter]) - .await; - - assert_eq!(connection.subscriptions().active_count(), 1); - assert_eq!( - close_handler.handle_close(&mut connection, &subscription_id), - CloseMessageOutcome::Closed - ); - assert_eq!(connection.subscriptions().active_count(), 0); - assert_eq!( - close_handler.handle_close(&mut connection, &subscription_id), - CloseMessageOutcome::NotFound - ); - } - - #[tokio::test] - async fn live_event_fanout_delivers_matching_subscription_events() { - let store = runtime_memory_store().await; - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let handler = ReqMessageHandler::new(store, NostrFilterCompiler::default()); - let fanout = LiveEventFanout; - let mut connection = runtime_connection("fanout"); - let matching_id = SubscriptionId::new("sub-matching").expect("matching subscription"); - let miss_id = SubscriptionId::new("sub-miss").expect("miss subscription"); - let matching_filter = filter_from_value(&serde_json::json!({ - "kinds": [30402], - "authors": [listing.unsigned().pubkey().as_str()] - })) - .expect("matching filter"); - let miss_filter = filter_from_value(&serde_json::json!({ - "ids": ["cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"] - })) - .expect("miss filter"); - - handler - .handle_req(&mut connection, matching_id.clone(), vec![matching_filter]) - .await; - handler - .handle_req(&mut connection, miss_id, vec![miss_filter]) - .await; - - let messages = fanout.fanout(&connection, &listing); - - assert_eq!(messages.len(), 1); - assert_eq!( - messages, - vec![RelayMessage::Event { - subscription_id: matching_id, - event: listing - }] - ); - } - - #[test] - fn live_event_fanout_ignores_connections_without_matches() { - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let fanout = LiveEventFanout; - let connection = runtime_connection("fanout-empty"); - - assert_eq!(fanout.fanout(&connection, &listing), Vec::new()); - } - - #[tokio::test] - async fn api_error_into_response_keeps_public_envelope_shape() { - let response = ApiError::not_found("listing not found").into_response(); - assert_eq!(response.status(), StatusCode::NOT_FOUND); - let body = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("body"); - assert_eq!( - serde_json::from_slice::<serde_json::Value>(&body).expect("json"), - serde_json::json!({ - "error": { - "code": "not_found", - "message": "listing not found" - } - }) - ); - } - - #[tokio::test] - async fn health_endpoint_reports_liveness() { - let response = health_router(ReadinessState::ready()) - .oneshot( - Request::builder() - .uri("/healthz") - .body(Body::empty()) - .expect("request"), - ) - .await - .expect("response"); - assert_eq!(response.status(), StatusCode::OK); - let body = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("body"); - assert_eq!( - serde_json::from_slice::<serde_json::Value>(&body).expect("json"), - serde_json::json!({ "status": "ok" }) - ); - } - - #[tokio::test] - async fn readiness_endpoint_reports_ready_checks() { - let response = health_router(ReadinessState::ready()) - .oneshot( - Request::builder() - .uri("/readyz") - .body(Body::empty()) - .expect("request"), - ) - .await - .expect("response"); - assert_eq!(response.status(), StatusCode::OK); - let body = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("body"); - assert_eq!( - serde_json::from_slice::<serde_json::Value>(&body).expect("json"), - serde_json::json!({ - "status": "ready", - "checks": { - "database": "ready", - "migrations": "ready", - "repository": "ready" - } - }) - ); - } - - #[tokio::test] - async fn readiness_endpoint_reports_unavailable_checks() { - let response = health_router(ReadinessState::new( - ReadinessCheckStatus::NotReady, - ReadinessCheckStatus::Ready, - ReadinessCheckStatus::NotReady, - )) - .oneshot( - Request::builder() - .uri("/readyz") - .body(Body::empty()) - .expect("request"), - ) - .await - .expect("response"); - assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); - let body = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("body"); - assert_eq!( - serde_json::from_slice::<serde_json::Value>(&body).expect("json"), - serde_json::json!({ - "status": "not_ready", - "checks": { - "database": "not_ready", - "migrations": "ready", - "repository": "not_ready" - } - }) - ); - } - - #[tokio::test] - async fn runtime_readiness_checks_database_migrations_and_repository() { - let config = SurrealConnectionConfig::memory("tangle_runtime", "readiness_gates") - .expect("memory config"); - let store = SurrealStore::connect_memory(&config) - .await - .expect("memory store"); - - let missing = runtime_readiness_state(&store).await; - assert_eq!( - missing, - ReadinessState::new( - ReadinessCheckStatus::Ready, - ReadinessCheckStatus::NotReady, - ReadinessCheckStatus::NotReady - ) - ); - - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - assert_eq!( - runtime_readiness_state(&store).await, - ReadinessState::ready() - ); - } - - #[tokio::test] - async fn runtime_readiness_rejects_migration_checksum_mismatch() { - let store = runtime_memory_store().await; - - store - .database() - .query("UPDATE migration SET checksum = 'bad' WHERE name = '0001_migration_tracking';") - .await - .expect("checksum update") - .check() - .expect("checksum update check"); - - assert_eq!( - super::runtime_migrations_ready(&store) - .await - .expect_err("checksum mismatch") - .message(), - "runtime migrations do not match" - ); - assert_eq!( - runtime_readiness_state(&store).await, - ReadinessState::new( - ReadinessCheckStatus::Ready, - ReadinessCheckStatus::NotReady, - ReadinessCheckStatus::NotReady - ) - ); - } - - #[tokio::test] - async fn readiness_status_after_respects_dependency_gates() { - assert_eq!( - super::readiness_status_after(true, std::future::ready(Ok::<(), ()>(()))).await, - ReadinessCheckStatus::Ready - ); - assert_eq!( - super::readiness_status_after(true, std::future::ready(Err::<(), ()>(()))).await, - ReadinessCheckStatus::NotReady - ); - assert_eq!( - super::readiness_status_after(false, std::future::ready(Ok::<(), ()>(()))).await, - ReadinessCheckStatus::NotReady - ); - } - - #[tokio::test] - async fn metrics_endpoint_reports_store_snapshot() { - let store = runtime_memory_store().await; - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let profile = seller_profile(1_714_125_300, "radroots-market", Some("Radroots Market")); - let seller = listing.unsigned().pubkey().as_str().to_owned(); - store - .store_raw_event(&StoredEvent::new( - listing.clone(), - UnixTimestamp::new(1_714_125_301), - )) - .await - .expect("store listing"); - store - .store_raw_event(&StoredEvent::new( - profile.clone(), - UnixTimestamp::new(1_714_125_302), - )) - .await - .expect("store profile"); - store - .project_current_listing(&listing, UnixTimestamp::new(1_714_125_303)) - .await - .expect("project listing"); - store - .project_seller_profile(&profile, UnixTimestamp::new(1_714_125_304)) - .await - .expect("project profile"); - store - .set_seller_approved(seller.as_str(), true, UnixTimestamp::new(1_714_125_305)) - .await - .expect("approve seller"); - - let response = metrics_router(MetricsHttpState::new(store)) - .oneshot( - Request::builder() - .uri("/metrics") - .body(Body::empty()) - .expect("request"), - ) - .await - .expect("response"); - assert_eq!(response.status(), StatusCode::OK); - assert_eq!( - response.headers().get(header::CONTENT_TYPE), - Some(&HeaderValue::from_static("text/plain; version=0.0.4")) - ); - let body = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("body"); - let body = String::from_utf8(body.to_vec()).expect("utf8"); - assert!(body.contains(&format!( - "tangle_info{{software=\"{}\",version=\"{}\"}} 1", - TANGLE_RELAY_SOFTWARE, TANGLE_RELAY_VERSION - ))); - assert!(body.contains("tangle_relay_ready 1")); - assert!(body.contains("tangle_store_events{state=\"stored\"} 2")); - assert!(body.contains("tangle_store_events{state=\"visible\"} 2")); - assert!(body.contains("tangle_store_listings{state=\"active\"} 1")); - assert!(body.contains("tangle_store_seller_profiles{state=\"visible\"} 1")); - assert!(body.contains("tangle_store_sellers{state=\"approved\"} 1")); - } - - #[test] - fn admin_pubkey_requirement_rejects_disabled_and_unauthorized_access() { - let admin = FixtureKey::Relay.public_key(); - let seller = FixtureKey::Seller.public_key(); - let disabled = runtime_memory_config("admin_disabled"); - let enabled = runtime_admin_config("admin_enabled"); - let mut headers = http::HeaderMap::new(); - - assert_eq!( - super::require_admin_pubkey(&disabled, &headers) - .expect_err("disabled admin") - .message(), - "admin policy api is disabled" - ); - headers.insert( - "x-tangle-admin-pubkey", - HeaderValue::from_str(seller.as_str()).expect("seller header"), - ); - assert_eq!( - super::require_admin_pubkey(&enabled, &headers) - .expect_err("wrong admin") - .message(), - "admin pubkey is not authorized" - ); - headers.insert( - "x-tangle-admin-pubkey", - HeaderValue::from_str(admin.as_str()).expect("admin header"), - ); - assert_eq!( - super::require_admin_pubkey(&enabled, &headers).expect("admin"), - admin - ); - headers.insert( - "x-tangle-admin-pubkey", - HeaderValue::from_static("not-a-pubkey"), - ); - assert_eq!( - super::require_admin_pubkey(&enabled, &headers) - .expect_err("invalid admin") - .message(), - "admin pubkey header is invalid" - ); - } - - #[tokio::test] - async fn admin_event_policy_routes_report_missing_events() { - let store = runtime_memory_store().await; - let (shutdown, _) = GracefulShutdownSignal::new(); - let state = - super::RuntimeRelayState::new(runtime_admin_config("admin_missing"), store, shutdown); - let missing = "1".repeat(EventId::HEX_LENGTH); - let mut headers = http::HeaderMap::new(); - headers.insert( - "x-tangle-admin-pubkey", - HeaderValue::from_str(FixtureKey::Relay.public_key().as_str()).expect("admin header"), - ); - - assert_eq!( - super::runtime_admin_hide_event( - axum::extract::State(state.clone()), - headers.clone(), - axum::extract::Path(missing.clone()), - axum::Json(super::AdminEventPolicyRequest::default()), - ) - .await - .expect_err("hide missing") - .message(), - "event not found" - ); - assert_eq!( - super::runtime_admin_unhide_event( - axum::extract::State(state), - headers, - axum::extract::Path(missing), - axum::Json(super::AdminEventPolicyRequest::default()), - ) - .await - .expect_err("unhide missing") - .message(), - "event not found" - ); - } - - #[tokio::test] - async fn admin_policy_routes_reject_invalid_pubkey_paths() { - let store = runtime_memory_store().await; - let (shutdown, _) = GracefulShutdownSignal::new(); - let state = super::RuntimeRelayState::new( - runtime_admin_config("admin_invalid_path"), - store, - shutdown, - ); - let mut headers = http::HeaderMap::new(); - headers.insert( - "x-tangle-admin-pubkey", - HeaderValue::from_str(FixtureKey::Relay.public_key().as_str()).expect("admin header"), - ); - - assert_eq!( - super::runtime_admin_approve_seller( - axum::extract::State(state.clone()), - headers.clone(), - axum::extract::Path("not-a-pubkey".to_owned()), - ) - .await - .expect_err("approve invalid") - .message(), - "pubkey must be a 64-character hex public key" - ); - assert_eq!( - super::runtime_admin_block_pubkey( - axum::extract::State(state), - headers, - axum::extract::Path("not-a-pubkey".to_owned()), - ) - .await - .expect_err("block invalid") - .message(), - "pubkey must be a 64-character hex public key" - ); - } - - #[test] - fn relay_info_default_matches_production_v1_protocol_claims() { - let relay_info = RelayInfoDocument::tangle_default(); - assert_eq!(relay_info.name, "tangle"); - assert_eq!(relay_info.supported_nips, TANGLE_SUPPORTED_NIPS); - assert_eq!(relay_info.software, TANGLE_RELAY_SOFTWARE); - assert_eq!(relay_info.version, "0.1.0"); - assert!(!relay_info.limitation.payment_required); - assert!(relay_info.limitation.restricted_writes); - assert_eq!( - serde_json::to_value(relay_info).expect("json"), - serde_json::json!({ - "name": "tangle", - "description": "SurrealDB-backed Nostr relay for NIP-99 marketplaces", - "supported_nips": [1, 9, 11, 16, 22, 23, 25, 32, 33, 42, 50, 56, 99], - "software": "https://github.com/radrootslabs/tangle", - "version": "0.1.0", - "limitation": { - "payment_required": false, - "restricted_writes": true - } - }) - ); - } - - #[tokio::test] - async fn relay_info_endpoint_requires_nostr_accept_header() { - let response = relay_info_router(RelayInfoDocument::tangle_default()) - .oneshot( - Request::builder() - .uri("/") - .body(Body::empty()) - .expect("request"), - ) - .await - .expect("response"); - assert_eq!(response.status(), StatusCode::NOT_FOUND); - let body = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("body"); - assert_eq!( - serde_json::from_slice::<serde_json::Value>(&body).expect("json"), - serde_json::json!({ - "error": { - "code": "not_found", - "message": "relay information requires application/nostr+json" - } - }) - ); - } - - #[tokio::test] - async fn relay_info_endpoint_serves_nip11_document_for_nostr_accept() { - let response = relay_info_router(RelayInfoDocument::tangle_default()) - .oneshot( - Request::builder() - .uri("/") - .header( - header::ACCEPT, - "text/plain, APPLICATION/NOSTR+JSON; charset=utf-8", - ) - .body(Body::empty()) - .expect("request"), - ) - .await - .expect("response"); - assert_eq!(response.status(), StatusCode::OK); - assert_eq!( - response - .headers() - .get(header::CONTENT_TYPE) - .expect("content-type"), - HeaderValue::from_static("application/nostr+json") - ); - let body = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("body"); - assert_eq!( - serde_json::from_slice::<serde_json::Value>(&body).expect("json"), - serde_json::json!({ - "name": "tangle", - "description": "SurrealDB-backed Nostr relay for NIP-99 marketplaces", - "supported_nips": [1, 9, 11, 16, 22, 23, 25, 32, 33, 42, 50, 56, 99], - "software": "https://github.com/radrootslabs/tangle", - "version": "0.1.0", - "limitation": { - "payment_required": false, - "restricted_writes": true - } - }) - ); - } - - #[tokio::test] - async fn relay_info_endpoint_rejects_invalid_accept_header() { - let response = relay_info_router(RelayInfoDocument::tangle_default()) - .oneshot( - Request::builder() - .uri("/") - .header( - header::ACCEPT, - HeaderValue::from_bytes(b"\xff").expect("header"), - ) - .body(Body::empty()) - .expect("request"), - ) - .await - .expect("response"); - assert_eq!(response.status(), StatusCode::NOT_FOUND); - } - - #[test] - fn listing_query_parser_defaults_to_active_marketplace_query() { - let parsed = parse_listing_query("", RuntimeLimits::default()).expect("query"); - let query = parsed.marketplace(); - - assert_eq!(parsed.geohash(), None); - assert_eq!(query.statuses, [MarketplaceListingStatus::Active]); - assert_eq!(query.limit, 50); - assert_eq!(query.sort, MarketplaceSort::Relevance); - assert_eq!(query.categories, Vec::<String>::new()); - assert_eq!(query.currencies, Vec::<String>::new()); - assert_eq!(query.units, Vec::<ListingUnit>::new()); - assert_eq!(query.fulfillment, Vec::<FulfillmentMethod>::new()); - } - - #[test] - fn listing_query_parser_reads_supported_parameters() { - let seller = "1".repeat(64); - let query_string = format!( - "category=vegetables,csa&category=roots&seller={seller}&status=active,sold,draft,inactive,expired,deleted,hidden,rejected&currency=usd,cad&unit=lb,oz,each,bunch,dozen,kg,g,share,pint,quart,box,crate,flat&min_price=1.50&max_price=10&fulfillment=pickup,delivery,shipping&delivery_only=false&pickup=true&geohash=C23NB62&lat=%2B47.6062&lon=-122.332100&radius_km=25.5&near=Ballard&sort=distance&limit=25" - ); - let parsed = parse_listing_query(&query_string, RuntimeLimits::default()).expect("query"); - let query = parsed.marketplace(); - let point = query.location.point.expect("point"); - - assert_eq!(parsed.geohash(), Some("c23nb62")); - assert_eq!( - query.categories, - [ - "csa".to_owned(), - "roots".to_owned(), - "vegetables".to_owned() - ] - ); - assert_eq!(query.seller.as_ref().expect("seller").as_str(), seller); - assert_eq!( - query.statuses, - [ - MarketplaceListingStatus::Active, - MarketplaceListingStatus::Sold, - MarketplaceListingStatus::Draft, - MarketplaceListingStatus::Inactive, - MarketplaceListingStatus::Expired, - MarketplaceListingStatus::Deleted, - MarketplaceListingStatus::Hidden, - MarketplaceListingStatus::Rejected, - ] - ); - assert_eq!(query.currencies, ["CAD".to_owned(), "USD".to_owned()]); - assert_eq!( - query - .units - .iter() - .map(|unit| unit.canonical()) - .collect::<Vec<_>>(), - [ - "box", "bunch", "crate", "dozen", "each", "flat", "g", "kg", "lb", "oz", "pint", - "quart", "share", - ] - ); - assert_eq!(query.min_price.as_ref().expect("min").raw, "1.50"); - assert_eq!(query.max_price.as_ref().expect("max").raw, "10"); - assert_eq!( - query.fulfillment, - [ - FulfillmentMethod::Pickup, - FulfillmentMethod::Delivery, - FulfillmentMethod::Shipping, - ] - ); - assert_eq!(query.delivery_only, Some(false)); - assert_eq!(query.pickup, Some(true)); - assert_eq!(point.latitude_microdegrees, 47_606_200); - assert_eq!(point.longitude_microdegrees, -122_332_100); - assert_eq!(query.location.radius_meters, Some(25_500)); - assert_eq!(query.location.near.as_deref(), Some("ballard")); - assert_eq!(query.sort, MarketplaceSort::Distance); - assert_eq!(query.limit, 25); - } - - #[test] - fn listing_query_parser_accepts_all_sort_labels() { - let cases = [ - ("relevance", MarketplaceSort::Relevance, ""), - ("freshness", MarketplaceSort::Freshness, ""), - ("price_asc", MarketplaceSort::PriceAsc, ""), - ("price_desc", MarketplaceSort::PriceDesc, ""), - ("distance", MarketplaceSort::Distance, "&lat=+0&lon=0"), - ("seller_trust", MarketplaceSort::SellerTrust, ""), - ]; - for (label, expected, suffix) in cases { - let parsed = - parse_listing_query(&format!("sort={label}{suffix}"), RuntimeLimits::default()) - .expect("query"); - assert_eq!(parsed.marketplace().sort, expected); - } - } - - #[test] - fn listing_query_parser_rejects_invalid_parameters() { - let seller = "1".repeat(64); - let cases = [ - ( - "banana=1".to_owned(), - "query parameter `banana` is unsupported", - ), - ("category=,roots".to_owned(), "category must not be empty"), - ( - "seller=bad".to_owned(), - "seller must be a 64-character hex public key", - ), - ( - format!("seller={seller}&seller={seller}"), - "seller must not be repeated", - ), - ("status=bogus".to_owned(), "status is unsupported"), - ("currency=%20".to_owned(), "currency must not be empty"), - ("unit=bushel".to_owned(), "unit is unsupported"), - ("min_price=".to_owned(), "min_price must not be empty"), - ( - "min_price=2&max_price=1.99".to_owned(), - "min_price must not exceed max_price", - ), - ("fulfillment=drone".to_owned(), "fulfillment is unsupported"), - ( - "delivery_only=yes".to_owned(), - "delivery_only must be true or false", - ), - ("pickup=".to_owned(), "pickup must not be empty"), - ( - "geohash=c23-".to_owned(), - "geohash must be lowercase alphanumeric", - ), - ( - "geohash=c23&geohash=c24".to_owned(), - "geohash must not be repeated", - ), - ("lat=91".to_owned(), "lat is out of range"), - ("lon=181".to_owned(), "lon is out of range"), - ( - "lat=999999999999999999999999&lon=0".to_owned(), - "lat must be an exact unsigned decimal", - ), - ( - "lat=0&radius_km=1".to_owned(), - "lat and lon must be provided together", - ), - ( - "radius_km=0".to_owned(), - "radius_km must be greater than zero", - ), - ( - "radius_km=1.0000".to_owned(), - "radius_km must be an exact unsigned decimal", - ), - ( - "radius_km=18446744073709551615".to_owned(), - "radius_km must fit the supported range", - ), - ("near=%20".to_owned(), "near must not be empty"), - ( - "sort=relevance&sort=freshness".to_owned(), - "sort must not be repeated", - ), - ("sort=popular".to_owned(), "sort is unsupported"), - ( - "sort=distance".to_owned(), - "distance sort requires a point or near filter", - ), - ("limit=abc".to_owned(), "limit must be an unsigned integer"), - ("limit=0".to_owned(), "limit must be between 1 and 100"), - ( - "cursor=opaque".to_owned(), - "cursor signed cursor decoding is not implemented", - ), - ]; - for (query, expected) in cases { - let error = parse_listing_query(&query, RuntimeLimits::default()).expect_err(&query); - assert_eq!(error.code(), ApiErrorCode::InvalidRequest); - assert_eq!(error.message(), expected); - } - } - - #[test] - fn listing_projection_query_rejects_filters_store_cannot_apply() { - let cases = [ - ( - "category=vegetables", - "category is not supported by the listings endpoint", - ), - ( - "geohash=c22yzug", - "geohash is not supported by the listings endpoint", - ), - ( - "fulfillment=pickup", - "fulfillment is not supported by the listings endpoint", - ), - ( - "delivery_only=true", - "delivery_only is not supported by the listings endpoint", - ), - ( - "pickup=true", - "pickup is not supported by the listings endpoint", - ), - ( - "lat=0&lon=0", - "location is not supported by the listings endpoint", - ), - ( - "sort=price_asc", - "sort is not supported by the listings endpoint", - ), - ( - "status=active,sold", - "status must contain exactly one value for the listings endpoint", - ), - ( - "currency=usd,cad", - "currency must contain at most one value for the listings endpoint", - ), - ( - "unit=lb,kg", - "unit must contain at most one value for the listings endpoint", - ), - ("min_price=1.234", "price must fit two decimal minor units"), - ( - "min_price=999999999999999999999999999999", - "price must fit two decimal minor units", - ), - ( - "min_price=9223372036854775807", - "price must fit two decimal minor units", - ), - ]; - for (raw, expected) in cases { - let parsed = parse_listing_query(raw, RuntimeLimits::default()).expect("query"); - let error = listing_projection_query(&parsed).expect_err(raw); - assert_eq!(error.message(), expected); - } - } - - #[test] - fn marketplace_search_query_parser_accepts_supported_modes() { - let seller = "1".repeat(64); - let text = parse_marketplace_search_query( - &format!("q=carrot&seller={seller}&sort=relevance&limit=25"), - RuntimeLimits::default(), - ) - .expect("text search"); - assert_eq!(text.text(), Some("carrot")); - assert_eq!(text.seller().expect("seller").as_str(), seller); - assert_eq!(text.limit(), 25); - - let browse = parse_marketplace_search_query("sort=freshness", RuntimeLimits::default()) - .expect("browse"); - assert_eq!(browse.text(), None); - assert_eq!(browse.seller(), None); - assert_eq!(browse.limit(), 50); - - let query = search_document_query(&text); - assert!(format!("{query:?}").contains("SearchDocumentQuery")); - } - - #[test] - fn marketplace_search_query_parser_rejects_invalid_parameters() { - let seller = "1".repeat(64); - let long_query = format!("q={}", "a".repeat(300)); - let cases = [ - ("q=".to_owned(), "q must not be empty"), - ("q=carrot&q=roots".to_owned(), "q must not be repeated"), - ( - format!("seller={seller}&seller={seller}"), - "seller must not be repeated", - ), - ( - "status=active&status=active".to_owned(), - "status must not be repeated", - ), - ( - "sort=freshness&sort=freshness".to_owned(), - "sort must not be repeated", - ), - ("limit=1&limit=2".to_owned(), "limit must not be repeated"), - ( - long_query, - "runtime limit: search query bytes exceeded: 300 > 256", - ), - ( - "category=vegetables".to_owned(), - "category is not supported by marketplace search", - ), - ( - "cursor=opaque".to_owned(), - "cursor is not supported by marketplace search", - ), - ( - "status=sold".to_owned(), - "status must be active for marketplace search", - ), - ( - "q=carrot&sort=freshness".to_owned(), - "sort does not match marketplace search mode", - ), - ( - "sort=relevance".to_owned(), - "sort does not match marketplace search mode", - ), - ( - "sort=price_asc".to_owned(), - "sort does not match marketplace search mode", - ), - ("limit=0".to_owned(), "limit must be between 1 and 100"), - ( - "banana=1".to_owned(), - "query parameter `banana` is unsupported", - ), - ]; - for (raw, expected) in cases { - let error = - parse_marketplace_search_query(&raw, RuntimeLimits::default()).expect_err(&raw); - assert_eq!(error.code(), ApiErrorCode::InvalidRequest); - assert_eq!(error.message(), expected); - } - } - - #[test] - fn projection_query_parsers_reject_cursor_and_unsupported_comment_parameters() { - let seller = "1".repeat(PublicKeyHex::HEX_LENGTH); - let cases = [ - ( - super::forum_thread_query("cursor=opaque").expect_err("forum cursor"), - "cursor signed cursor decoding is not implemented", - ), - ( - super::forum_thread_query("banana=1").expect_err("forum unsupported"), - "query parameter `banana` is unsupported", - ), - ( - super::label_projection_query("cursor=opaque").expect_err("label cursor"), - "cursor signed cursor decoding is not implemented", - ), - ( - super::label_projection_query("target_type=event").expect_err("label target"), - "target target_type and target_ref must be provided together", - ), - ( - super::label_projection_query("banana=1").expect_err("label unsupported"), - "query parameter `banana` is unsupported", - ), - ( - super::report_projection_query("cursor=opaque").expect_err("report cursor"), - "cursor signed cursor decoding is not implemented", - ), - ( - super::report_projection_query("target_ref=abc").expect_err("report target"), - "target target_type and target_ref must be provided together", - ), - ( - super::report_projection_query("banana=1").expect_err("report unsupported"), - "query parameter `banana` is unsupported", - ), - ( - super::parse_comment_query("cursor=opaque").expect_err("comment cursor"), - "cursor is not supported by the listing comments endpoint", - ), - ]; - - assert!( - super::forum_thread_query(&format!("pubkey={seller}&topic=market&limit=2")).is_ok() - ); - assert!( - super::label_projection_query(&format!( - "target_type=event&target_ref={}&namespace=ugc&label=approve&pubkey={seller}&limit=2", - "2".repeat(EventId::HEX_LENGTH) - )) - .is_ok() - ); - assert!( - super::report_projection_query(&format!( - "target_type=event&target_ref={}&report_type=spam&pubkey={seller}&limit=2", - "2".repeat(EventId::HEX_LENGTH) - )) - .is_ok() - ); - assert!(super::label_projection_query("=1").is_ok()); - assert!(super::report_projection_query("=1").is_ok()); - assert_eq!(super::parse_comment_query("=1").expect("empty key"), 50); - - for (error, expected) in cases { - assert_eq!(error.code(), ApiErrorCode::InvalidRequest); - assert_eq!(error.message(), expected); - } - } - - #[test] - fn listing_item_document_maps_projection_rows_and_rejects_malformed_rows() { - let row = serde_json::json!({ - "listing_key": "30402:pubkey:listing-a", - "event_id": "event", - "seller_pubkey": "pubkey", - "d": "listing-a", - "title": "Carrot bunches", - "location_text": "Seattle", - "price_decimal": "12.50", - "currency_norm": "USD", - "unit": "lb", - "effective_status": "active", - "updated_at": 1714124433_u64, - "pickup_available": false, - "delivery_available": true, - "shipping_available": true - }); - let item = listing_item_document(&row).expect("item"); - - assert_eq!(item.summary, None); - assert_eq!(item.location.text.as_deref(), Some("Seattle")); - assert_eq!(item.location.geohash, None); - assert_eq!( - item.fulfillment, - ["delivery".to_owned(), "shipping".to_owned()] - ); - assert_eq!(item.price.amount, "12.50"); - - for row in [ - serde_json::json!({ - "event_id": "event", - "seller_pubkey": "pubkey", - "d": "listing-a", - "title": "Carrot bunches", - "price_decimal": "12.50", - "currency_norm": "USD", - "unit": "lb", - "effective_status": "active", - "updated_at": 1714124433_u64, - "pickup_available": false, - "delivery_available": true, - "shipping_available": true - }), - serde_json::json!({ - "listing_key": "30402:pubkey:listing-a", - "event_id": "event", - "seller_pubkey": "pubkey", - "d": "listing-a", - "title": "Carrot bunches", - "summary": 1, - "price_decimal": "12.50", - "currency_norm": "USD", - "unit": "lb", - "effective_status": "active", - "updated_at": 1714124433_u64, - "pickup_available": false, - "delivery_available": true, - "shipping_available": true - }), - serde_json::json!({ - "listing_key": "30402:pubkey:listing-a", - "event_id": "event", - "seller_pubkey": "pubkey", - "d": "listing-a", - "title": "Carrot bunches", - "price_decimal": "12.50", - "currency_norm": "USD", - "unit": "lb", - "effective_status": "active", - "updated_at": "bad", - "pickup_available": false, - "delivery_available": true, - "shipping_available": true - }), - serde_json::json!({ - "listing_key": "30402:pubkey:listing-a", - "event_id": "event", - "seller_pubkey": "pubkey", - "d": "listing-a", - "title": "Carrot bunches", - "price_decimal": "12.50", - "currency_norm": "USD", - "unit": "lb", - "effective_status": "active", - "updated_at": 1714124433_u64, - "pickup_available": "bad", - "delivery_available": true, - "shipping_available": true - }), - ] { - assert_eq!( - listing_item_document(&row).expect_err("malformed").code(), - ApiErrorCode::Internal - ); - } - assert_eq!( - super::price_minor_units("1.2.3") - .expect_err("invalid price") - .message(), - "price must fit two decimal minor units" - ); - assert_eq!( - super::fulfillment_document(&serde_json::json!({ - "pickup_available": true, - "delivery_available": false, - "shipping_available": false - })) - .expect("fulfillment"), - ["pickup".to_owned()] - ); - assert_eq!( - listing_item_document(&serde_json::json!({ - "listing_key": "30402:pubkey:listing-a", - "event_id": "event", - "seller_pubkey": "pubkey", - "d": "listing-a", - "title": "Carrot bunches", - "summary": null, - "geohash": null, - "price_decimal": "12.50", - "currency_norm": "USD", - "unit": "lb", - "effective_status": "active", - "updated_at": 1714124433_u64, - "pickup_available": false, - "delivery_available": false, - "shipping_available": false - })) - .expect("nullable listing") - .fulfillment, - Vec::<String>::new() - ); - } - - #[test] - fn read_model_document_helpers_reject_malformed_rows() { - let malformed = serde_json::json!({"event_id": "event"}); - - assert_eq!( - super::forum_thread_item_document(&malformed) - .expect_err("forum thread") - .code(), - ApiErrorCode::Internal - ); - assert_eq!( - super::comment_item_document(&malformed) - .expect_err("comment") - .code(), - ApiErrorCode::Internal - ); - assert_eq!( - super::moderation_label_document(&malformed) - .expect_err("label") - .code(), - ApiErrorCode::Internal - ); - assert_eq!( - super::moderation_report_document(&malformed) - .expect_err("report") - .code(), - ApiErrorCode::Internal - ); - assert_eq!( - super::reaction_counts_document( - Some(&serde_json::json!({ - "target_event_id": "event", - "like_count": "bad" - })), - "event", - Some("30402"), - ) - .expect_err("reaction count") - .code(), - ApiErrorCode::Internal - ); - } - - #[tokio::test] - async fn listings_endpoint_queries_projection_rows_and_excludes_hidden_rows() { - let store = runtime_memory_store().await; - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str()); - store - .project_current_listing(&listing, UnixTimestamp::new(1_714_125_400)) - .await - .expect("project listing"); - - let uri = format!( - "/api/listings?status=active&seller={}&unit=lb&currency=usd&min_price=1.5&max_price=20.25&limit=5", - listing.unsigned().pubkey().as_str() - ); - let response = listings_router(ListingsHttpState::new( - store.clone(), - RuntimeLimits::default(), - )) - .oneshot( - Request::builder() - .uri(uri.as_str()) - .body(Body::empty()) - .expect("request"), - ) - .await - .expect("response"); - assert_eq!(response.status(), StatusCode::OK); - let body = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("body"); - assert_eq!( - serde_json::from_slice::<serde_json::Value>(&body).expect("json"), - serde_json::json!({ - "items": [{ - "listing_key": listing_key, - "event_id": listing.id().as_str(), - "seller_pubkey": listing.unsigned().pubkey().as_str(), - "d": "listing-a", - "title": "Carrot bunches", - "summary": null, - "price": { - "amount": "12.50", - "currency": "USD", - "unit": "lb" - }, - "location": { - "text": null, - "geohash": "c22yzug" - }, - "fulfillment": ["pickup"], - "status": "active", - "updated_at": 1714124433 - }], - "next_cursor": null - }) - ); - - store - .database() - .query("UPDATE listing_current SET hidden = true WHERE listing_key = $listing_key;") - .bind(("listing_key", listing_key.as_str())) - .await - .expect("hide listing") - .check() - .expect("hide check"); - let response = listings_router(ListingsHttpState::new( - store.clone(), - RuntimeLimits::default(), - )) - .oneshot( - Request::builder() - .uri("/api/listings") - .body(Body::empty()) - .expect("request"), - ) - .await - .expect("response"); - assert_eq!(response.status(), StatusCode::OK); - let body = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("body"); - assert_eq!( - serde_json::from_slice::<serde_json::Value>(&body).expect("json"), - serde_json::json!({ - "items": [], - "next_cursor": null - }) - ); - } - - #[tokio::test] - async fn listing_detail_endpoint_returns_projection_and_raw_event() { - let store = runtime_memory_store().await; - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str()); - store - .store_raw_event(&StoredEvent::new( - listing.clone(), - UnixTimestamp::new(1_714_125_300), - )) - .await - .expect("raw event"); - store - .project_current_listing(&listing, UnixTimestamp::new(1_714_125_400)) - .await - .expect("project listing"); - - let uri = format!( - "/api/listings/{}/listing-a", - listing.unsigned().pubkey().as_str() - ); - let response = listings_router(ListingsHttpState::new( - store.clone(), - RuntimeLimits::default(), - )) - .oneshot( - Request::builder() - .uri(uri.as_str()) - .body(Body::empty()) - .expect("request"), - ) - .await - .expect("response"); - assert_eq!(response.status(), StatusCode::OK); - let body = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("body"); - let json = serde_json::from_slice::<serde_json::Value>(&body).expect("json"); - assert_eq!(json["listing"]["listing_key"], listing_key); - assert_eq!(json["listing"]["event_id"], listing.id().as_str()); - assert_eq!(json["raw_event"], event_to_value(&listing)); - - store - .database() - .query("UPDATE nostr_event SET hidden = true WHERE event_id = $event_id;") - .bind(("event_id", listing.id().as_str())) - .await - .expect("hide raw") - .check() - .expect("hide raw check"); - let response = listings_router(ListingsHttpState::new( - store.clone(), - RuntimeLimits::default(), - )) - .oneshot( - Request::builder() - .uri(uri.as_str()) - .body(Body::empty()) - .expect("request"), - ) - .await - .expect("response"); - assert_eq!(response.status(), StatusCode::NOT_FOUND); - - store - .database() - .query( - "UPDATE nostr_event SET hidden = false WHERE event_id = $event_id; - UPDATE listing_current SET hidden = true WHERE listing_key = $listing_key;", - ) - .bind(("event_id", listing.id().as_str())) - .bind(("listing_key", listing_key.as_str())) - .await - .expect("hide listing") - .check() - .expect("hide listing check"); - let response = listings_router(ListingsHttpState::new( - store.clone(), - RuntimeLimits::default(), - )) - .oneshot( - Request::builder() - .uri(uri) - .body(Body::empty()) - .expect("request"), - ) - .await - .expect("response"); - assert_eq!(response.status(), StatusCode::NOT_FOUND); - } - - #[tokio::test] - async fn listing_comments_endpoint_returns_visible_projected_comments() { - let store = runtime_memory_store().await; - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str()); - let comment = listing_comment(&listing, 1_714_125_410, "Can I pickup Saturday?"); - store - .project_current_listing(&listing, UnixTimestamp::new(1_714_125_409)) - .await - .expect("project listing"); - store - .project_comment(&comment, UnixTimestamp::new(1_714_125_411)) - .await - .expect("project comment"); - - let uri = format!( - "/api/listings/{}/listing-a/comments?limit=5", - listing.unsigned().pubkey().as_str() - ); - let response = listings_router(ListingsHttpState::new( - store.clone(), - RuntimeLimits::default(), - )) - .oneshot( - Request::builder() - .uri(uri.as_str()) - .body(Body::empty()) - .expect("request"), - ) - .await - .expect("response"); - assert_eq!(response.status(), StatusCode::OK); - let body = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("body"); - assert_eq!( - serde_json::from_slice::<serde_json::Value>(&body).expect("json"), - serde_json::json!({ - "items": [{ - "event_id": comment.id().as_str(), - "pubkey": FixtureKey::Buyer.public_key().as_str(), - "created_at": 1714125410_u64, - "content": "Can I pickup Saturday?", - "root": { - "target_type": "address", - "target_ref": listing_key, - "kind": "30402", - "author": listing.unsigned().pubkey().as_str() - }, - "parent": { - "target_type": "address", - "target_ref": listing_key, - "kind": "30402", - "author": listing.unsigned().pubkey().as_str() - } - }], - "next_cursor": null - }) - ); - - store - .database() - .query("UPDATE comment_projection SET hidden = true WHERE event_id = $event_id;") - .bind(("event_id", comment.id().as_str())) - .await - .expect("hide comment") - .check() - .expect("hide comment check"); - let response = listings_router(ListingsHttpState::new( - store.clone(), - RuntimeLimits::default(), - )) - .oneshot( - Request::builder() - .uri(uri.as_str()) - .body(Body::empty()) - .expect("request"), - ) - .await - .expect("response"); - assert_eq!(response.status(), StatusCode::OK); - let body = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("body"); - assert_eq!( - serde_json::from_slice::<serde_json::Value>(&body).expect("json"), - serde_json::json!({ - "items": [], - "next_cursor": null - }) - ); - store - .database() - .query("UPDATE listing_current SET hidden = true WHERE listing_key = $listing_key;") - .bind(("listing_key", listing_key.as_str())) - .await - .expect("hide listing") - .check() - .expect("hide listing check"); - let response = listings_router(ListingsHttpState::new( - store.clone(), - RuntimeLimits::default(), - )) - .oneshot( - Request::builder() - .uri(uri.as_str()) - .body(Body::empty()) - .expect("request"), - ) - .await - .expect("response"); - assert_eq!(response.status(), StatusCode::NOT_FOUND); - } - - #[tokio::test] - async fn listing_reactions_endpoint_returns_aggregate_counts() { - let store = runtime_memory_store().await; - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let reaction = listing_reaction(&listing, 1_714_125_420, "+"); - store - .project_current_listing(&listing, UnixTimestamp::new(1_714_125_419)) - .await - .expect("project listing"); - - let uri = format!( - "/api/listings/{}/listing-a/reactions", - listing.unsigned().pubkey().as_str() - ); - let empty = listings_router(ListingsHttpState::new( - store.clone(), - RuntimeLimits::default(), - )) - .oneshot( - Request::builder() - .uri(uri.as_str()) - .body(Body::empty()) - .expect("request"), - ) - .await - .expect("response"); - assert_eq!(empty.status(), StatusCode::OK); - let body = axum::body::to_bytes(empty.into_body(), usize::MAX) - .await - .expect("body"); - assert_eq!( - serde_json::from_slice::<serde_json::Value>(&body).expect("json"), - serde_json::json!({ - "target_event_id": listing.id().as_str(), - "target_kind": "30402", - "like_count": 0, - "dislike_count": 0, - "emoji_count": 0, - "text_count": 0, - "total_count": 0, - "updated_at": 0 - }) - ); - - store - .project_reaction(&reaction, UnixTimestamp::new(1_714_125_421)) - .await - .expect("project reaction"); - let response = listings_router(ListingsHttpState::new( - store.clone(), - RuntimeLimits::default(), - )) - .oneshot( - Request::builder() - .uri(uri.as_str()) - .body(Body::empty()) - .expect("request"), - ) - .await - .expect("response"); - assert_eq!(response.status(), StatusCode::OK); - let body = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("body"); - assert_eq!( - serde_json::from_slice::<serde_json::Value>(&body).expect("json"), - serde_json::json!({ - "target_event_id": listing.id().as_str(), - "target_kind": "30402", - "like_count": 1, - "dislike_count": 0, - "emoji_count": 0, - "text_count": 0, - "total_count": 1, - "updated_at": 1714125421 - }) - ); - let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str()); - store - .database() - .query("UPDATE listing_current SET hidden = true WHERE listing_key = $listing_key;") - .bind(("listing_key", listing_key.as_str())) - .await - .expect("hide listing") - .check() - .expect("hide listing check"); - let response = listings_router(ListingsHttpState::new(store, RuntimeLimits::default())) - .oneshot( - Request::builder() - .uri(uri.as_str()) - .body(Body::empty()) - .expect("request"), - ) - .await - .expect("response"); - assert_eq!(response.status(), StatusCode::NOT_FOUND); - } - - #[tokio::test] - async fn forum_threads_endpoint_returns_visible_projected_threads() { - let store = runtime_memory_store().await; - let thread = forum_thread(1_714_125_430, Some("Market day thread"), &["Market", "CSA"]); - store - .project_forum_thread(&thread, UnixTimestamp::new(1_714_125_431)) - .await - .expect("project thread"); - - let response = listings_router(ListingsHttpState::new( - store.clone(), - RuntimeLimits::default(), - )) - .oneshot( - Request::builder() - .uri("/api/forum/threads?topic=market&limit=5") - .body(Body::empty()) - .expect("request"), - ) - .await - .expect("response"); - assert_eq!(response.status(), StatusCode::OK); - let body = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("body"); - assert_eq!( - serde_json::from_slice::<serde_json::Value>(&body).expect("json"), - serde_json::json!({ - "items": [{ - "event_id": thread.id().as_str(), - "pubkey": FixtureKey::Buyer.public_key().as_str(), - "created_at": 1714125430_u64, - "updated_at": 1714125430_u64, - "title": "Market day thread", - "content": "What is everyone bringing this weekend?", - "tags": ["csa", "market"] - }], - "next_cursor": null - }) - ); - - store - .database() - .query("UPDATE forum_thread_projection SET hidden = true WHERE event_id = $event_id;") - .bind(("event_id", thread.id().as_str())) - .await - .expect("hide thread") - .check() - .expect("hide thread check"); - let response = listings_router(ListingsHttpState::new(store, RuntimeLimits::default())) - .oneshot( - Request::builder() - .uri("/api/forum/threads?topic=market&limit=5") - .body(Body::empty()) - .expect("request"), - ) - .await - .expect("response"); - assert_eq!(response.status(), StatusCode::OK); - let body = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("body"); - assert_eq!( - serde_json::from_slice::<serde_json::Value>(&body).expect("json"), - serde_json::json!({ - "items": [], - "next_cursor": null - }) - ); - } - - #[tokio::test] - async fn forum_thread_detail_and_comments_endpoints_return_visible_rows() { - let store = runtime_memory_store().await; - let thread = forum_thread(1_714_125_440, Some("Market day thread"), &["market"]); - let comment = forum_thread_comment(&thread, 1_714_125_441, "I can bring greens."); - store - .store_raw_event(&StoredEvent::new( - thread.clone(), - UnixTimestamp::new(1_714_125_442), - )) - .await - .expect("raw thread"); - store - .project_forum_thread(&thread, UnixTimestamp::new(1_714_125_443)) - .await - .expect("project thread"); - store - .project_comment(&comment, UnixTimestamp::new(1_714_125_444)) - .await - .expect("project comment"); - - let detail_uri = format!("/api/forum/threads/{}", thread.id().as_str()); - let response = listings_router(ListingsHttpState::new( - store.clone(), - RuntimeLimits::default(), - )) - .oneshot( - Request::builder() - .uri(detail_uri.as_str()) - .body(Body::empty()) - .expect("request"), - ) - .await - .expect("response"); - assert_eq!(response.status(), StatusCode::OK); - let body = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("body"); - let detail = serde_json::from_slice::<serde_json::Value>(&body).expect("json"); - assert_eq!(detail["thread"]["event_id"], thread.id().as_str()); - assert_eq!(detail["thread"]["title"], "Market day thread"); - assert_eq!(detail["raw_event"]["id"], thread.id().as_str()); - - let comments_uri = format!( - "/api/forum/threads/{}/comments?limit=5", - thread.id().as_str() - ); - let response = listings_router(ListingsHttpState::new( - store.clone(), - RuntimeLimits::default(), - )) - .oneshot( - Request::builder() - .uri(comments_uri.as_str()) - .body(Body::empty()) - .expect("request"), - ) - .await - .expect("response"); - assert_eq!(response.status(), StatusCode::OK); - let body = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("body"); - assert_eq!( - serde_json::from_slice::<serde_json::Value>(&body).expect("json"), - serde_json::json!({ - "items": [{ - "event_id": comment.id().as_str(), - "pubkey": FixtureKey::Seller.public_key().as_str(), - "created_at": 1714125441_u64, - "content": "I can bring greens.", - "root": { - "target_type": "event", - "target_ref": thread.id().as_str(), - "kind": "11", - "author": thread.unsigned().pubkey().as_str() - }, - "parent": { - "target_type": "event", - "target_ref": thread.id().as_str(), - "kind": "11", - "author": thread.unsigned().pubkey().as_str() - } - }], - "next_cursor": null - }) - ); - store - .database() - .query("UPDATE nostr_event SET hidden = true WHERE event_id = $event_id;") - .bind(("event_id", thread.id().as_str())) - .await - .expect("hide raw") - .check() - .expect("hide raw check"); - let response = listings_router(ListingsHttpState::new( - store.clone(), - RuntimeLimits::default(), - )) - .oneshot( - Request::builder() - .uri(detail_uri.as_str()) - .body(Body::empty()) - .expect("request"), - ) - .await - .expect("response"); - assert_eq!(response.status(), StatusCode::NOT_FOUND); - store - .database() - .query( - "UPDATE nostr_event SET hidden = false WHERE event_id = $event_id; - UPDATE forum_thread_projection SET hidden = true WHERE event_id = $event_id;", - ) - .bind(("event_id", thread.id().as_str())) - .await - .expect("hide thread") - .check() - .expect("hide thread check"); - let detail = listings_router(ListingsHttpState::new( - store.clone(), - RuntimeLimits::default(), - )) - .oneshot( - Request::builder() - .uri(detail_uri.as_str()) - .body(Body::empty()) - .expect("request"), - ) - .await - .expect("response"); - let comments = listings_router(ListingsHttpState::new(store, RuntimeLimits::default())) - .oneshot( - Request::builder() - .uri(comments_uri.as_str()) - .body(Body::empty()) - .expect("request"), - ) - .await - .expect("response"); - assert_eq!(detail.status(), StatusCode::NOT_FOUND); - assert_eq!(comments.status(), StatusCode::NOT_FOUND); - } - - #[tokio::test] - async fn listing_detail_endpoint_rejects_invalid_or_missing_listing() { - let store = runtime_memory_store().await; - let response = listings_router(ListingsHttpState::new( - store.clone(), - RuntimeLimits::default(), - )) - .oneshot( - Request::builder() - .uri("/api/listings/not-a-pubkey/listing-a") - .body(Body::empty()) - .expect("request"), - ) - .await - .expect("response"); - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - let body = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("body"); - assert_eq!( - serde_json::from_slice::<serde_json::Value>(&body).expect("json"), - serde_json::json!({ - "error": { - "code": "invalid_request", - "message": "pubkey must be a 64-character hex public key" - } - }) - ); - - let response = listings_router(ListingsHttpState::new(store, RuntimeLimits::default())) - .oneshot( - Request::builder() - .uri(format!("/api/listings/{}/missing", "1".repeat(64))) - .body(Body::empty()) - .expect("request"), - ) - .await - .expect("response"); - assert_eq!(response.status(), StatusCode::NOT_FOUND); - } - - #[tokio::test] - async fn marketplace_search_endpoint_queries_search_docs_and_hydrates_listings() { - let store = runtime_memory_store().await; - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str()); - store - .project_current_listing(&listing, UnixTimestamp::new(1_714_125_400)) - .await - .expect("project listing"); - store - .index_listing_search_document(&listing) - .await - .expect("index listing"); - store - .database() - .query( - "CREATE search_doc CONTENT { - doc_key: 'no-address', - event_id: 'no-address-event', - current_event_id: 'no-address-event', - doc_type: 'listing', - kind: 30402, - pubkey: $pubkey, - address_key: NONE, - title: 'carrot no address', - summary: NONE, - body: 'carrot', - category_text: 'carrot', - location_text: NONE, - tags: [], - categories: [], - created_at: 1, - updated_at: 1, - visible: true, - status: 'active', - seller_trust_score: NONE - }; - CREATE search_doc CONTENT { - doc_key: 'orphan-address', - event_id: 'orphan-address-event', - current_event_id: 'orphan-address-event', - doc_type: 'listing', - kind: 30402, - pubkey: $pubkey, - address_key: '30402:orphan:missing', - title: 'carrot orphan', - summary: NONE, - body: 'carrot', - category_text: 'carrot', - location_text: NONE, - tags: [], - categories: [], - created_at: 2, - updated_at: 2, - visible: true, - status: 'active', - seller_trust_score: NONE - };", - ) - .bind(("pubkey", listing.unsigned().pubkey().as_str())) - .await - .expect("extra search docs") - .check() - .expect("extra search docs check"); - - let uri = format!( - "/api/search?q=carrot&seller={}&sort=relevance&limit=5", - listing.unsigned().pubkey().as_str() - ); - let response = listings_router(ListingsHttpState::new( - store.clone(), - RuntimeLimits::default(), - )) - .oneshot( - Request::builder() - .uri(uri) - .body(Body::empty()) - .expect("request"), - ) - .await - .expect("response"); - assert_eq!(response.status(), StatusCode::OK); - let body = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("body"); - let json = serde_json::from_slice::<serde_json::Value>(&body).expect("json"); - assert_eq!(json["items"][0]["listing_key"], listing_key); - assert_eq!(json["items"][0]["title"], "Carrot bunches"); - assert_eq!(json["next_cursor"], serde_json::Value::Null); - - store - .database() - .query("UPDATE listing_current SET hidden = true WHERE listing_key = $listing_key;") - .bind(("listing_key", listing_key.as_str())) - .await - .expect("hide listing") - .check() - .expect("hide check"); - let response = listings_router(ListingsHttpState::new(store, RuntimeLimits::default())) - .oneshot( - Request::builder() - .uri("/api/search?q=carrot") - .body(Body::empty()) - .expect("request"), - ) - .await - .expect("response"); - assert_eq!(response.status(), StatusCode::OK); - let body = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("body"); - assert_eq!( - serde_json::from_slice::<serde_json::Value>(&body).expect("json"), - serde_json::json!({ - "items": [], - "next_cursor": null - }) - ); - } - - #[tokio::test] - async fn seller_endpoint_returns_policy_state_and_active_listing_count() { - let store = runtime_memory_store().await; - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let profile = seller_profile(1_714_125_300, "radroots-market", Some("Radroots Market")); - let seller = listing.unsigned().pubkey().as_str().to_owned(); - let listing_key = format!("30402:{seller}:listing-a"); - store - .store_raw_event(&StoredEvent::new( - profile.clone(), - UnixTimestamp::new(1_714_125_301), - )) - .await - .expect("store profile"); - store - .project_seller_profile(&profile, UnixTimestamp::new(1_714_125_302)) - .await - .expect("project profile"); - store - .project_current_listing(&listing, UnixTimestamp::new(1_714_125_400)) - .await - .expect("project listing"); - store - .set_seller_approved(seller.as_str(), true, UnixTimestamp::new(2)) - .await - .expect("seller row"); - - let response = listings_router(ListingsHttpState::new( - store.clone(), - RuntimeLimits::default(), - )) - .oneshot( - Request::builder() - .uri(format!("/api/sellers/{seller}")) - .body(Body::empty()) - .expect("request"), - ) - .await - .expect("response"); - assert_eq!(response.status(), StatusCode::OK); - let body = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("body"); - assert_eq!( - serde_json::from_slice::<serde_json::Value>(&body).expect("json"), - serde_json::json!({ - "pubkey": seller, - "event_id": profile.id().as_str(), - "name": "radroots-market", - "display_name": "Radroots Market", - "about": "Local food seller profile", - "picture": "https://fixtures.radroots.test/seller.png", - "website": "https://seller.radroots.test", - "nip05": "seller@radroots.test", - "lud16": "seller@pay.radroots.test", - "regions": ["cascadia", "pnw"], - "categories": ["produce"], - "trust_markers": ["csa", "regenerative"], - "approved": true, - "blocked": false, - "active_listing_count": 1 - }) - ); - - store - .database() - .query("UPDATE listing_current SET hidden = true WHERE listing_key = $listing_key;") - .bind(("listing_key", listing_key.as_str())) - .await - .expect("hide listing") - .check() - .expect("hide check"); - let response = listings_router(ListingsHttpState::new( - store.clone(), - RuntimeLimits::default(), - )) - .oneshot( - Request::builder() - .uri(format!("/api/sellers/{seller}")) - .body(Body::empty()) - .expect("request"), - ) - .await - .expect("response"); - assert_eq!(response.status(), StatusCode::OK); - let body = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("body"); - assert_eq!( - serde_json::from_slice::<serde_json::Value>(&body).expect("json")["active_listing_count"], - 0 - ); - - let admin_pubkey = "a".repeat(PublicKeyHex::HEX_LENGTH); - store - .hide_event( - profile.id(), - "profile moderation", - "admin_api", - admin_pubkey.as_str(), - UnixTimestamp::new(1_714_125_600), - ) - .await - .expect("hide profile"); - let response = listings_router(ListingsHttpState::new(store, RuntimeLimits::default())) - .oneshot( - Request::builder() - .uri(format!("/api/sellers/{seller}")) - .body(Body::empty()) - .expect("request"), - ) - .await - .expect("response"); - assert_eq!(response.status(), StatusCode::OK); - let body = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("body"); - let document = serde_json::from_slice::<serde_json::Value>(&body).expect("json"); - assert!(document["event_id"].is_null()); - assert!(document["name"].is_null()); - assert_eq!(document["regions"], serde_json::json!([])); - assert_eq!(document["approved"], true); - } - - #[tokio::test] - async fn seller_endpoint_defaults_missing_seller_and_rejects_invalid_pubkey() { - let store = runtime_memory_store().await; - let missing = "1".repeat(64); - let response = listings_router(ListingsHttpState::new( - store.clone(), - RuntimeLimits::default(), - )) - .oneshot( - Request::builder() - .uri(format!("/api/sellers/{missing}")) - .body(Body::empty()) - .expect("request"), - ) - .await - .expect("response"); - assert_eq!(response.status(), StatusCode::OK); - let body = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("body"); - assert_eq!( - serde_json::from_slice::<serde_json::Value>(&body).expect("json"), - serde_json::json!({ - "pubkey": missing, - "event_id": null, - "name": null, - "display_name": null, - "about": null, - "picture": null, - "website": null, - "nip05": null, - "lud16": null, - "regions": [], - "categories": [], - "trust_markers": [], - "approved": false, - "blocked": false, - "active_listing_count": 0 - }) - ); - - let response = listings_router(ListingsHttpState::new(store, RuntimeLimits::default())) - .oneshot( - Request::builder() - .uri("/api/sellers/not-a-pubkey") - .body(Body::empty()) - .expect("request"), - ) - .await - .expect("response"); - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - } - - async fn runtime_memory_store() -> SurrealStore { - let config = SurrealConnectionConfig::memory("tangle_runtime", "listings_endpoint") - .expect("memory config"); - let store = SurrealStore::connect_memory(&config) - .await - .expect("memory store"); - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - store - } - - fn runtime_memory_config(namespace: &str) -> super::TangleRuntimeConfig { - parse_runtime_config_json( - &serde_json::json!({ - "server": { - "listen_addr": "127.0.0.1:0", - "relay_url": "ws://127.0.0.1:0" - }, - "database": { - "mode": "memory", - "namespace": namespace, - "database": "relay" - }, - "auth": { - "challenge_ttl_seconds": 300 - }, - "limits": { - "message_rate_limit": { - "limit": 120, - "window_seconds": 60 - } - }, - "policy": { - "approved_sellers": [FixtureKey::Seller.public_key().as_str()] - } - }) - .to_string(), - ) - .expect("runtime memory config") - } - - fn runtime_admin_config(namespace: &str) -> super::TangleRuntimeConfig { - parse_runtime_config_json( - &serde_json::json!({ - "server": { - "listen_addr": "127.0.0.1:0", - "relay_url": "ws://127.0.0.1:0" - }, - "database": { - "mode": "memory", - "namespace": namespace, - "database": "relay" - }, - "auth": { - "challenge_ttl_seconds": 300 - }, - "limits": { - "message_rate_limit": { - "limit": 120, - "window_seconds": 60 - } - }, - "policy": { - "admin_pubkeys": [FixtureKey::Relay.public_key().as_str()], - "approved_sellers": [FixtureKey::Seller.public_key().as_str()] - } - }) - .to_string(), - ) - .expect("runtime admin config") - } - - fn seller_profile( - created_at: u64, - name: &str, - display_name: Option<&str>, - ) -> tangle_protocol::Event { - let mut content = serde_json::json!({ - "name": name, - "about": "Local food seller profile", - "picture": "https://fixtures.radroots.test/seller.png", - "website": "https://seller.radroots.test", - "nip05": "seller@radroots.test", - "lud16": "seller@pay.radroots.test" - }); - if let Some(display_name) = display_name { - content["display_name"] = serde_json::Value::String(display_name.to_owned()); - } - build_fixture_event_from_parts( - FixtureKey::Seller, - created_at, - u64::from(NIP01_METADATA_KIND), - vec![ - vec!["region".to_owned(), "PNW".to_owned()], - vec!["region".to_owned(), "Cascadia".to_owned()], - vec!["category".to_owned(), "Produce".to_owned()], - vec!["trust".to_owned(), "CSA".to_owned()], - vec!["trust".to_owned(), "regenerative".to_owned()], - ], - &content.to_string(), - ) - .expect("seller profile") - } - - fn listing_event_at(created_at: u64) -> tangle_protocol::Event { - let spec = valid_public_listing_spec(); - build_fixture_event_from_parts( - FixtureKey::Seller, - created_at, - spec.kind(), - spec.tags().to_vec(), - spec.content(), - ) - .expect("listing event") - } - - fn note_event(created_at: u64, content: &str) -> tangle_protocol::Event { - build_fixture_event_from_parts(FixtureKey::Seller, created_at, 1, Vec::new(), content) - .expect("note event") - } - - fn listing_comment( - listing: &tangle_protocol::Event, - created_at: u64, - content: &str, - ) -> tangle_protocol::Event { - let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str()); - build_fixture_event_from_parts( - FixtureKey::Buyer, - created_at, - 1_111, - vec![ - vec!["A".to_owned(), listing_key.clone()], - vec!["K".to_owned(), "30402".to_owned()], - vec![ - "P".to_owned(), - listing.unsigned().pubkey().as_str().to_owned(), - ], - vec!["a".to_owned(), listing_key], - vec!["k".to_owned(), "30402".to_owned()], - vec![ - "p".to_owned(), - listing.unsigned().pubkey().as_str().to_owned(), - ], - ], - content, - ) - .expect("comment event") - } - - fn listing_reaction( - listing: &tangle_protocol::Event, - created_at: u64, - content: &str, - ) -> tangle_protocol::Event { - let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str()); - build_fixture_event_from_parts( - FixtureKey::Buyer, - created_at, - 7, - vec![ - vec![ - "e".to_owned(), - listing.id().as_str().to_owned(), - "wss://relay.radroots.test".to_owned(), - listing.unsigned().pubkey().as_str().to_owned(), - ], - vec![ - "p".to_owned(), - listing.unsigned().pubkey().as_str().to_owned(), - ], - vec!["a".to_owned(), listing_key], - vec!["k".to_owned(), "30402".to_owned()], - ], - content, - ) - .expect("reaction event") - } - - fn forum_thread( - created_at: u64, - title: Option<&str>, - topics: &[&str], - ) -> tangle_protocol::Event { - let mut tags = vec![ - vec!["e".to_owned(), "5".repeat(EventId::HEX_LENGTH)], - vec![ - "p".to_owned(), - FixtureKey::Seller.public_key().as_str().to_owned(), - ], - ]; - if let Some(title) = title { - tags.push(vec!["title".to_owned(), title.to_owned()]); - } - tags.extend( - topics - .iter() - .map(|topic| vec!["t".to_owned(), (*topic).to_owned()]), - ); - build_fixture_event_from_parts( - FixtureKey::Buyer, - created_at, - 11, - tags, - "What is everyone bringing this weekend?", - ) - .expect("forum thread") - } - - fn forum_thread_comment( - thread: &tangle_protocol::Event, - created_at: u64, - content: &str, - ) -> tangle_protocol::Event { - build_fixture_event_from_parts( - FixtureKey::Seller, - created_at, - 1_111, - vec![ - vec![ - "E".to_owned(), - thread.id().as_str().to_owned(), - "wss://relay.radroots.test".to_owned(), - thread.unsigned().pubkey().as_str().to_owned(), - ], - vec!["K".to_owned(), "11".to_owned()], - vec![ - "P".to_owned(), - thread.unsigned().pubkey().as_str().to_owned(), - ], - vec![ - "e".to_owned(), - thread.id().as_str().to_owned(), - "wss://relay.radroots.test".to_owned(), - thread.unsigned().pubkey().as_str().to_owned(), - ], - vec!["k".to_owned(), "11".to_owned()], - vec![ - "p".to_owned(), - thread.unsigned().pubkey().as_str().to_owned(), - ], - ], - content, - ) - .expect("forum comment event") - } - - async fn next_ws_json( - client: &mut tokio_tungstenite::WebSocketStream< - tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>, - >, - phase: &'static str, - ) -> serde_json::Value { - loop { - let message = tokio::time::timeout(std::time::Duration::from_secs(2), client.next()) - .await - .expect(phase) - .expect("websocket message") - .expect("websocket frame"); - if let TungsteniteMessage::Text(raw) = message { - return serde_json::from_str(&raw).expect("websocket JSON"); - } - } - } - - fn runtime_client_message_loop() -> ClientMessageLoop { - let mut connection = runtime_connection("client-loop"); - connection.set_remote_addr("127.0.0.1:7777"); - ClientMessageLoop::new(connection) - } - - fn runtime_connection(id: &str) -> RelayConnection { - RelayConnection::new( - RelayConnectionId::new(id).expect("connection id"), - RelayConnectionConfig::default(), - ) - } - - fn authenticated_connection() -> RelayConnection { - let mut connection = runtime_connection("authenticated"); - let auth = build_fixture_event(&auth_event_spec()).expect("auth"); - connection - .auth_mut() - .issue_challenge("challenge-001", UnixTimestamp::new(1_714_124_430)) - .expect("challenge"); - let auth = parse_relay_auth_event(&auth) - .expect("auth parses") - .expect("auth event"); - connection - .auth_mut() - .authenticate(&auth, UnixTimestamp::new(1_714_124_435)) - .expect("authenticate"); - connection - } -} diff --git a/crates/tangle_store/Cargo.toml b/crates/tangle_store/Cargo.toml @@ -1,15 +0,0 @@ -[package] -name = "tangle_store" -version.workspace = true -edition.workspace = true -authors.workspace = true -rust-version.workspace = true -license.workspace = true -description = "Repository traits for tangle storage backends" - -[dependencies] -tangle_nips = { path = "../tangle_nips" } -tangle_protocol = { path = "../tangle_protocol" } - -[lints] -workspace = true diff --git a/crates/tangle_store/src/lib.rs b/crates/tangle_store/src/lib.rs @@ -1,237 +0,0 @@ -#![forbid(unsafe_code)] - -use core::fmt; -use tangle_nips::{DeletionTarget, ListingProjection}; -use tangle_protocol::{AddressCoordinate, Event, EventId, PublicKeyHex, UnixTimestamp}; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct StoredEvent { - event: Event, - received_at: UnixTimestamp, - content_len: usize, - tag_count: usize, -} - -impl StoredEvent { - pub fn new(event: Event, received_at: UnixTimestamp) -> Self { - let content_len = event.unsigned().content().len(); - let tag_count = event.unsigned().tags().len(); - Self { - event, - received_at, - content_len, - tag_count, - } - } - - pub fn event(&self) -> &Event { - &self.event - } - - pub fn received_at(&self) -> UnixTimestamp { - self.received_at - } - - pub fn content_len(&self) -> usize { - self.content_len - } - - pub fn tag_count(&self) -> usize { - self.tag_count - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum StoreEventOutcome { - Inserted, - Duplicate, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum StoreProjectionOutcome { - Inserted, - Replaced, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DeletionMarker { - deletion_event_id: EventId, - author_pubkey: PublicKeyHex, - target: DeletionTarget, - deleted_at: UnixTimestamp, -} - -impl DeletionMarker { - pub fn new( - deletion_event_id: EventId, - author_pubkey: PublicKeyHex, - target: DeletionTarget, - deleted_at: UnixTimestamp, - ) -> Self { - Self { - deletion_event_id, - author_pubkey, - target, - deleted_at, - } - } - - pub fn deletion_event_id(&self) -> &EventId { - &self.deletion_event_id - } - - pub fn author_pubkey(&self) -> &PublicKeyHex { - &self.author_pubkey - } - - pub fn target(&self) -> &DeletionTarget { - &self.target - } - - pub fn deleted_at(&self) -> UnixTimestamp { - self.deleted_at - } -} - -#[derive(Clone, PartialEq, Eq)] -pub struct RepositoryError { - message: String, -} - -impl RepositoryError { - pub fn new(message: &str) -> Self { - Self { - message: message.to_owned(), - } - } - - pub fn message(&self) -> &str { - &self.message - } -} - -impl fmt::Display for RepositoryError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(&self.message) - } -} - -impl fmt::Debug for RepositoryError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("RepositoryError") - .field("message", &self.message) - .finish() - } -} - -impl std::error::Error for RepositoryError {} - -pub trait RawEventRepository { - fn put_event(&mut self, record: StoredEvent) -> Result<StoreEventOutcome, RepositoryError>; - - fn event_by_id(&self, event_id: &EventId) -> Result<Option<StoredEvent>, RepositoryError>; - - fn events(&self) -> Result<Vec<StoredEvent>, RepositoryError>; -} - -pub trait ListingProjectionRepository { - fn put_listing_projection( - &mut self, - projection: ListingProjection, - ) -> Result<StoreProjectionOutcome, RepositoryError>; - - fn listing_projection( - &self, - address: &AddressCoordinate, - ) -> Result<Option<ListingProjection>, RepositoryError>; -} - -pub trait DeletionMarkerRepository { - fn put_deletion_marker(&mut self, marker: DeletionMarker) -> Result<(), RepositoryError>; - - fn deletion_markers(&self) -> Result<Vec<DeletionMarker>, RepositoryError>; -} - -#[cfg(test)] -mod tests { - use super::{ - DeletionMarker, RepositoryError, StoreEventOutcome, StoreProjectionOutcome, StoredEvent, - }; - use tangle_nips::DeletionTarget; - use tangle_protocol::{ - Event, EventId, Kind, PublicKeyHex, SignatureHex, Tag, UnixTimestamp, UnsignedEvent, - }; - - #[test] - fn stored_event_preserves_event_and_derived_metadata() { - let event = event_with_tags_and_content( - vec![ - Tag::from_parts("d", &["listing-a"]).expect("d"), - Tag::from_parts("t", &["carrots"]).expect("topic"), - ], - "fresh", - ); - let stored = StoredEvent::new(event.clone(), UnixTimestamp::new(100)); - - assert_eq!(stored.event(), &event); - assert_eq!(stored.received_at().as_u64(), 100); - assert_eq!(stored.content_len(), 5); - assert_eq!(stored.tag_count(), 2); - assert_eq!(StoreEventOutcome::Inserted, StoreEventOutcome::Inserted); - assert_eq!(StoreEventOutcome::Duplicate, StoreEventOutcome::Duplicate); - assert_eq!( - StoreProjectionOutcome::Inserted, - StoreProjectionOutcome::Inserted - ); - assert_eq!( - StoreProjectionOutcome::Replaced, - StoreProjectionOutcome::Replaced - ); - } - - #[test] - fn deletion_marker_preserves_typed_deletion_target() { - let deletion_event_id = EventId::new(&"2".repeat(EventId::HEX_LENGTH)).expect("id"); - let author_pubkey = PublicKeyHex::new(&"3".repeat(PublicKeyHex::HEX_LENGTH)).expect("pk"); - let target_id = EventId::new(&"4".repeat(EventId::HEX_LENGTH)).expect("target"); - let target = DeletionTarget::Event(target_id.clone()); - let marker = DeletionMarker::new( - deletion_event_id.clone(), - author_pubkey.clone(), - target, - UnixTimestamp::new(200), - ); - - assert_eq!(marker.deletion_event_id(), &deletion_event_id); - assert_eq!(marker.author_pubkey(), &author_pubkey); - assert_eq!(marker.target(), &DeletionTarget::Event(target_id)); - assert_eq!(marker.deleted_at().as_u64(), 200); - } - - #[test] - fn repository_error_has_stable_message_display_and_debug() { - let error = RepositoryError::new("store unavailable"); - - assert_eq!(error.message(), "store unavailable"); - assert_eq!(error.to_string(), "store unavailable"); - assert_eq!( - format!("{error:?}"), - "RepositoryError { message: \"store unavailable\" }" - ); - } - - fn event_with_tags_and_content(tags: Vec<Tag>, content: &str) -> Event { - Event::new( - EventId::new(&"a".repeat(EventId::HEX_LENGTH)).expect("id"), - UnsignedEvent::new( - PublicKeyHex::new(&"1".repeat(PublicKeyHex::HEX_LENGTH)).expect("pubkey"), - UnixTimestamp::new(1_714_124_433), - Kind::new(30_402).expect("kind"), - tags, - content, - ), - SignatureHex::new(&"b".repeat(SignatureHex::HEX_LENGTH)).expect("sig"), - ) - } -} diff --git a/crates/tangle_store_surreal/Cargo.toml b/crates/tangle_store_surreal/Cargo.toml @@ -1,23 +0,0 @@ -[package] -name = "tangle_store_surreal" -version.workspace = true -edition.workspace = true -authors.workspace = true -rust-version.workspace = true -license.workspace = true -description = "SurrealDB storage backend for tangle" - -[dependencies] -serde_json = "1" -sha2 = "0.10" -surrealdb = { version = "3.1.3", default-features = false, features = ["kv-mem", "kv-rocksdb", "protocol-http", "protocol-ws"] } -tangle_nips = { path = "../tangle_nips" } -tangle_protocol = { path = "../tangle_protocol" } -tangle_store = { path = "../tangle_store" } - -[dev-dependencies] -tangle_test_support = { path = "../tangle_test_support" } -tokio = { version = "1", features = ["macros", "rt"] } - -[lints] -workspace = true diff --git a/crates/tangle_store_surreal/src/lib.rs b/crates/tangle_store_surreal/src/lib.rs @@ -1,10526 +0,0 @@ -#![forbid(unsafe_code)] - -use core::fmt; -use sha2::{Digest, Sha256}; -use std::collections::BTreeSet; -use surrealdb::Surreal; -use surrealdb::engine::any::{Any, connect as connect_any}; -use surrealdb::opt::{Config as SurrealClientConfig, auth::Root}; -use tangle_nips::{ - CommentEvent, DeletionTarget, ForumThreadEvent, LabelEvent, ListingProjection, - ListingProjectionEvaluation, LongFormEvent, LongFormKind, NIP99_DRAFT_LISTING_KIND, - NIP99_PUBLIC_LISTING_KIND, ReactionEvent, ReactionValue, ReportEvent, ReportTarget, - SellerProfileEvent, evaluate_listing_projection, parse_comment_event, parse_deletion_request, - parse_forum_thread_event, parse_label_event, parse_long_form_event, parse_reaction_event, - parse_report_event, parse_seller_profile_event, -}; -use tangle_protocol::{ - AddressCoordinate, Event, EventId, Filter, RawEventJson, UnixTimestamp, event_to_value, - parse_event_json, -}; -use tangle_store::{StoreEventOutcome, StoredEvent}; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum SurrealConnectionMode { - Memory, - RocksDb { path: String }, - Http { endpoint: String }, - WebSocket { endpoint: String }, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SurrealConnectionConfig { - mode: SurrealConnectionMode, - namespace: String, - database: String, - root_credentials: Option<SurrealRootCredentials>, -} - -impl SurrealConnectionConfig { - pub fn memory(namespace: &str, database: &str) -> Result<Self, SurrealConfigError> { - Self::new(SurrealConnectionMode::Memory, namespace, database) - } - - pub fn rocksdb( - path: &str, - namespace: &str, - database: &str, - ) -> Result<Self, SurrealConfigError> { - let path = normalized_endpoint(path, "rocksdb path")?; - Self::new(SurrealConnectionMode::RocksDb { path }, namespace, database) - } - - pub fn http( - endpoint: &str, - namespace: &str, - database: &str, - ) -> Result<Self, SurrealConfigError> { - let endpoint = normalized_endpoint(endpoint, "http endpoint")?; - Self::new( - SurrealConnectionMode::Http { endpoint }, - namespace, - database, - ) - } - - pub fn websocket( - endpoint: &str, - namespace: &str, - database: &str, - ) -> Result<Self, SurrealConfigError> { - let endpoint = normalized_endpoint(endpoint, "websocket endpoint")?; - Self::new( - SurrealConnectionMode::WebSocket { endpoint }, - namespace, - database, - ) - } - - pub fn mode(&self) -> &SurrealConnectionMode { - &self.mode - } - - pub fn namespace(&self) -> &str { - &self.namespace - } - - pub fn database(&self) -> &str { - &self.database - } - - pub fn root_credentials(&self) -> Option<&SurrealRootCredentials> { - self.root_credentials.as_ref() - } - - pub fn with_root_credentials( - mut self, - username: &str, - password: &str, - ) -> Result<Self, SurrealConfigError> { - self.root_credentials = Some(SurrealRootCredentials::new(username, password)?); - Ok(self) - } - - fn new( - mode: SurrealConnectionMode, - namespace: &str, - database: &str, - ) -> Result<Self, SurrealConfigError> { - Ok(Self { - mode, - namespace: normalized_identifier(namespace, "namespace")?, - database: normalized_identifier(database, "database")?, - root_credentials: None, - }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SurrealRootCredentials { - username: String, - password: String, -} - -impl SurrealRootCredentials { - pub fn new(username: &str, password: &str) -> Result<Self, SurrealConfigError> { - Ok(Self { - username: normalized_secret(username, "username")?, - password: normalized_secret(password, "password")?, - }) - } - - pub fn username(&self) -> &str { - &self.username - } - - pub fn password(&self) -> &str { - &self.password - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SurrealConfigError { - message: String, -} - -impl SurrealConfigError { - fn new(message: &str) -> Self { - Self { - message: message.to_owned(), - } - } - - pub fn message(&self) -> &str { - &self.message - } -} - -impl fmt::Display for SurrealConfigError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(&self.message) - } -} - -impl std::error::Error for SurrealConfigError {} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct SurrealMetricsSnapshot { - stored_events: u64, - visible_events: u64, - hidden_events: u64, - deleted_events: u64, - current_listings: u64, - active_listings: u64, - seller_profiles: u64, - visible_seller_profiles: u64, - approved_sellers: u64, - blocked_pubkeys: u64, -} - -impl SurrealMetricsSnapshot { - pub fn stored_events(self) -> u64 { - self.stored_events - } - - pub fn visible_events(self) -> u64 { - self.visible_events - } - - pub fn hidden_events(self) -> u64 { - self.hidden_events - } - - pub fn deleted_events(self) -> u64 { - self.deleted_events - } - - pub fn current_listings(self) -> u64 { - self.current_listings - } - - pub fn active_listings(self) -> u64 { - self.active_listings - } - - pub fn seller_profiles(self) -> u64 { - self.seller_profiles - } - - pub fn visible_seller_profiles(self) -> u64 { - self.visible_seller_profiles - } - - pub fn approved_sellers(self) -> u64 { - self.approved_sellers - } - - pub fn blocked_pubkeys(self) -> u64 { - self.blocked_pubkeys - } -} - -fn normalized_identifier(value: &str, field: &str) -> Result<String, SurrealConfigError> { - let trimmed = value.trim(); - if trimmed.is_empty() { - return Err(SurrealConfigError::new(&format!( - "surreal {field} must not be empty" - ))); - } - if !trimmed - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') - { - return Err(SurrealConfigError::new(&format!( - "surreal {field} must use ASCII letters, digits, or underscore" - ))); - } - Ok(trimmed.to_owned()) -} - -fn normalized_endpoint(value: &str, field: &str) -> Result<String, SurrealConfigError> { - let trimmed = value.trim(); - if trimmed.is_empty() { - return Err(SurrealConfigError::new(&format!( - "surreal {field} must not be empty" - ))); - } - Ok(trimmed.to_owned()) -} - -fn normalized_secret(value: &str, field: &str) -> Result<String, SurrealConfigError> { - let trimmed = value.trim(); - if trimmed.is_empty() { - return Err(SurrealConfigError::new(&format!( - "surreal root {field} must not be empty" - ))); - } - Ok(trimmed.to_owned()) -} - -fn rocksdb_endpoint(path: &str) -> String { - format!("rocksdb://{path}") -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SurrealMigration { - name: String, - surql: &'static str, - checksum: String, -} - -impl SurrealMigration { - pub fn new(name: &str, surql: &'static str) -> Result<Self, SurrealMigrationError> { - let name = normalized_migration_name(name)?; - if surql.trim().is_empty() { - return Err(SurrealMigrationError::new( - "surreal migration body must not be empty", - )); - } - Ok(Self { - name, - surql, - checksum: checksum(surql), - }) - } - - pub fn name(&self) -> &str { - &self.name - } - - pub fn surql(&self) -> &'static str { - self.surql - } - - pub fn checksum(&self) -> &str { - &self.checksum - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SurrealMigrationPlan { - migrations: Vec<SurrealMigration>, -} - -impl SurrealMigrationPlan { - pub fn new(migrations: Vec<SurrealMigration>) -> Result<Self, SurrealMigrationError> { - for pair in migrations.windows(2) { - if pair[0].name() >= pair[1].name() { - return Err(SurrealMigrationError::new( - "surreal migrations must be strictly ordered by name", - )); - } - } - Ok(Self { migrations }) - } - - pub fn migrations(&self) -> &[SurrealMigration] { - &self.migrations - } - - pub fn names(&self) -> Vec<&str> { - self.migrations.iter().map(SurrealMigration::name).collect() - } - - pub fn find(&self, name: &str) -> Option<&SurrealMigration> { - self.migrations - .iter() - .find(|migration| migration.name() == name) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SurrealMigrationError { - message: String, -} - -impl SurrealMigrationError { - fn new(message: &str) -> Self { - Self { - message: message.to_owned(), - } - } - - pub fn message(&self) -> &str { - &self.message - } -} - -impl fmt::Display for SurrealMigrationError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(&self.message) - } -} - -impl std::error::Error for SurrealMigrationError {} - -fn normalized_migration_name(name: &str) -> Result<String, SurrealMigrationError> { - let trimmed = name.trim(); - if trimmed.is_empty() { - return Err(SurrealMigrationError::new( - "surreal migration name must not be empty", - )); - } - let mut parts = trimmed.splitn(2, '_'); - let version = parts.next().unwrap_or_default(); - let label = parts.next().unwrap_or_default(); - if version.len() != 4 || !version.bytes().all(|byte| byte.is_ascii_digit()) { - return Err(SurrealMigrationError::new( - "surreal migration name must start with four digits", - )); - } - if label.is_empty() - || !label - .bytes() - .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') - { - return Err(SurrealMigrationError::new( - "surreal migration label must use lowercase ASCII, digits, or underscore", - )); - } - Ok(trimmed.to_owned()) -} - -fn checksum(surql: &str) -> String { - let digest = Sha256::digest(surql.as_bytes()); - lower_hex(&digest) -} - -fn lower_hex(bytes: &[u8]) -> String { - const HEX: &[u8; 16] = b"0123456789abcdef"; - let mut output = String::with_capacity(bytes.len() * 2); - for byte in bytes { - output.push(char::from(HEX[usize::from(byte >> 4)])); - output.push(char::from(HEX[usize::from(byte & 0x0f)])); - } - output -} - -pub fn migration_tracking_schema() -> SurrealMigration { - SurrealMigration::new( - "0001_migration_tracking", - r#" -DEFINE TABLE IF NOT EXISTS migration SCHEMAFULL; -DEFINE FIELD IF NOT EXISTS name ON TABLE migration TYPE string; -DEFINE FIELD IF NOT EXISTS checksum ON TABLE migration TYPE string; -DEFINE FIELD IF NOT EXISTS applied_at ON TABLE migration TYPE datetime; -DEFINE INDEX IF NOT EXISTS migration_name_uid ON TABLE migration COLUMNS name UNIQUE; -"#, - ) - .expect("migration tracking schema is valid") -} - -pub fn base_migration_plan() -> SurrealMigrationPlan { - SurrealMigrationPlan::new(vec![ - migration_tracking_schema(), - raw_event_schema(), - event_tag_index_schema(), - current_event_schema(), - deletion_marker_schema(), - listing_revision_schema(), - listing_current_schema(), - listing_helper_schemas(), - search_document_schema(), - policy_schemas(), - comment_projection_schema(), - reaction_projection_schema(), - long_form_projection_schema(), - forum_thread_schema(), - label_projection_schema(), - report_projection_schema(), - seller_profile_schema(), - ]) - .expect("base migration plan is strictly ordered") -} - -pub fn raw_event_schema() -> SurrealMigration { - SurrealMigration::new( - "0002_raw_event", - r#" -DEFINE TABLE IF NOT EXISTS nostr_event SCHEMAFULL; -DEFINE FIELD IF NOT EXISTS event_id ON TABLE nostr_event TYPE string; -DEFINE FIELD IF NOT EXISTS pubkey ON TABLE nostr_event TYPE string; -DEFINE FIELD IF NOT EXISTS created_at ON TABLE nostr_event TYPE int; -DEFINE FIELD IF NOT EXISTS kind ON TABLE nostr_event TYPE int; -DEFINE FIELD IF NOT EXISTS tags ON TABLE nostr_event TYPE array; -DEFINE FIELD IF NOT EXISTS content ON TABLE nostr_event TYPE string; -DEFINE FIELD IF NOT EXISTS sig ON TABLE nostr_event TYPE string; -DEFINE FIELD IF NOT EXISTS raw_json ON TABLE nostr_event TYPE string; -DEFINE FIELD IF NOT EXISTS received_at ON TABLE nostr_event TYPE int; -DEFINE FIELD IF NOT EXISTS content_len ON TABLE nostr_event TYPE int; -DEFINE FIELD IF NOT EXISTS tag_count ON TABLE nostr_event TYPE int; -DEFINE FIELD IF NOT EXISTS d_tag ON TABLE nostr_event TYPE option<string>; -DEFINE FIELD IF NOT EXISTS address_key ON TABLE nostr_event TYPE option<string>; -DEFINE FIELD IF NOT EXISTS deleted ON TABLE nostr_event TYPE bool DEFAULT false; -DEFINE FIELD IF NOT EXISTS hidden ON TABLE nostr_event TYPE bool DEFAULT false; -DEFINE FIELD IF NOT EXISTS rejection_reason ON TABLE nostr_event TYPE option<string>; -DEFINE INDEX IF NOT EXISTS nostr_event_id_uid ON TABLE nostr_event COLUMNS event_id UNIQUE; -DEFINE INDEX IF NOT EXISTS nostr_event_author_created ON TABLE nostr_event COLUMNS pubkey, created_at, event_id; -DEFINE INDEX IF NOT EXISTS nostr_event_kind_created ON TABLE nostr_event COLUMNS kind, created_at, event_id; -DEFINE INDEX IF NOT EXISTS nostr_event_kind_author_created ON TABLE nostr_event COLUMNS kind, pubkey, created_at, event_id; -DEFINE INDEX IF NOT EXISTS nostr_event_address_created ON TABLE nostr_event COLUMNS address_key, created_at, event_id; -DEFINE INDEX IF NOT EXISTS nostr_event_created ON TABLE nostr_event COLUMNS created_at, event_id; -"#, - ) - .expect("raw event schema is valid") -} - -pub fn event_tag_index_schema() -> SurrealMigration { - SurrealMigration::new( - "0003_event_tag_index", - r#" -DEFINE TABLE IF NOT EXISTS event_tag_index SCHEMAFULL; -DEFINE FIELD IF NOT EXISTS event_id ON TABLE event_tag_index TYPE string; -DEFINE FIELD IF NOT EXISTS kind ON TABLE event_tag_index TYPE int; -DEFINE FIELD IF NOT EXISTS pubkey ON TABLE event_tag_index TYPE string; -DEFINE FIELD IF NOT EXISTS created_at ON TABLE event_tag_index TYPE int; -DEFINE FIELD IF NOT EXISTS tag ON TABLE event_tag_index TYPE string; -DEFINE FIELD IF NOT EXISTS value ON TABLE event_tag_index TYPE string; -DEFINE FIELD IF NOT EXISTS ordinal ON TABLE event_tag_index TYPE int; -DEFINE INDEX IF NOT EXISTS event_tag_lookup ON TABLE event_tag_index COLUMNS tag, value, created_at, event_id; -DEFINE INDEX IF NOT EXISTS event_tag_kind_lookup ON TABLE event_tag_index COLUMNS tag, value, kind, created_at, event_id; -DEFINE INDEX IF NOT EXISTS event_tag_event ON TABLE event_tag_index COLUMNS event_id; -"#, - ) - .expect("event tag index schema is valid") -} - -pub fn current_event_schema() -> SurrealMigration { - SurrealMigration::new( - "0004_current_event", - r#" -DEFINE TABLE IF NOT EXISTS event_current SCHEMAFULL; -DEFINE FIELD IF NOT EXISTS address_key ON TABLE event_current TYPE string; -DEFINE FIELD IF NOT EXISTS kind ON TABLE event_current TYPE int; -DEFINE FIELD IF NOT EXISTS pubkey ON TABLE event_current TYPE string; -DEFINE FIELD IF NOT EXISTS d ON TABLE event_current TYPE option<string>; -DEFINE FIELD IF NOT EXISTS event_id ON TABLE event_current TYPE string; -DEFINE FIELD IF NOT EXISTS created_at ON TABLE event_current TYPE int; -DEFINE FIELD IF NOT EXISTS tie_break_id ON TABLE event_current TYPE string; -DEFINE FIELD IF NOT EXISTS deleted ON TABLE event_current TYPE bool DEFAULT false; -DEFINE FIELD IF NOT EXISTS hidden ON TABLE event_current TYPE bool DEFAULT false; -DEFINE FIELD IF NOT EXISTS updated_at ON TABLE event_current TYPE int; -DEFINE INDEX IF NOT EXISTS event_current_address_uid ON TABLE event_current COLUMNS address_key UNIQUE; -DEFINE INDEX IF NOT EXISTS event_current_kind_pubkey ON TABLE event_current COLUMNS kind, pubkey; -DEFINE INDEX IF NOT EXISTS event_current_event ON TABLE event_current COLUMNS event_id; -"#, - ) - .expect("current event schema is valid") -} - -pub fn deletion_marker_schema() -> SurrealMigration { - SurrealMigration::new( - "0005_deletion_marker", - r#" -DEFINE TABLE IF NOT EXISTS deletion_marker SCHEMAFULL; -DEFINE FIELD IF NOT EXISTS deletion_event_id ON TABLE deletion_marker TYPE string; -DEFINE FIELD IF NOT EXISTS target_type ON TABLE deletion_marker TYPE string; -DEFINE FIELD IF NOT EXISTS target_ref ON TABLE deletion_marker TYPE string; -DEFINE FIELD IF NOT EXISTS author_pubkey ON TABLE deletion_marker TYPE string; -DEFINE FIELD IF NOT EXISTS deletion_created_at ON TABLE deletion_marker TYPE int; -DEFINE INDEX IF NOT EXISTS deletion_target ON TABLE deletion_marker COLUMNS target_type, target_ref, deletion_created_at; -DEFINE INDEX IF NOT EXISTS deletion_author_target ON TABLE deletion_marker COLUMNS author_pubkey, target_type, target_ref; -"#, - ) - .expect("deletion marker schema is valid") -} - -pub fn listing_revision_schema() -> SurrealMigration { - SurrealMigration::new( - "0006_listing_revision", - r#" -DEFINE TABLE IF NOT EXISTS listing_revision SCHEMAFULL; -DEFINE FIELD IF NOT EXISTS revision_key ON TABLE listing_revision TYPE string; -DEFINE FIELD IF NOT EXISTS listing_key ON TABLE listing_revision TYPE string; -DEFINE FIELD IF NOT EXISTS event_id ON TABLE listing_revision TYPE string; -DEFINE FIELD IF NOT EXISTS seller_pubkey ON TABLE listing_revision TYPE string; -DEFINE FIELD IF NOT EXISTS d ON TABLE listing_revision TYPE string; -DEFINE FIELD IF NOT EXISTS created_at ON TABLE listing_revision TYPE int; -DEFINE FIELD IF NOT EXISTS parsed_ok ON TABLE listing_revision TYPE bool; -DEFINE FIELD IF NOT EXISTS parse_errors ON TABLE listing_revision TYPE array; -DEFINE FIELD IF NOT EXISTS title ON TABLE listing_revision TYPE option<string>; -DEFINE FIELD IF NOT EXISTS summary ON TABLE listing_revision TYPE option<string>; -DEFINE FIELD IF NOT EXISTS price_decimal ON TABLE listing_revision TYPE option<string>; -DEFINE FIELD IF NOT EXISTS price_minor ON TABLE listing_revision TYPE option<int>; -DEFINE FIELD IF NOT EXISTS currency_raw ON TABLE listing_revision TYPE option<string>; -DEFINE FIELD IF NOT EXISTS currency_norm ON TABLE listing_revision TYPE option<string>; -DEFINE FIELD IF NOT EXISTS unit ON TABLE listing_revision TYPE option<string>; -DEFINE FIELD IF NOT EXISTS status_tag ON TABLE listing_revision TYPE option<string>; -DEFINE FIELD IF NOT EXISTS projected_at ON TABLE listing_revision TYPE int; -DEFINE INDEX IF NOT EXISTS listing_revision_event_uid ON TABLE listing_revision COLUMNS event_id UNIQUE; -DEFINE INDEX IF NOT EXISTS listing_revision_listing_created ON TABLE listing_revision COLUMNS listing_key, created_at, event_id; -DEFINE INDEX IF NOT EXISTS listing_revision_seller_created ON TABLE listing_revision COLUMNS seller_pubkey, created_at, event_id; -"#, - ) - .expect("listing revision schema is valid") -} - -pub fn listing_current_schema() -> SurrealMigration { - SurrealMigration::new( - "0007_listing_current", - r#" -DEFINE TABLE IF NOT EXISTS listing_current SCHEMAFULL; -DEFINE FIELD IF NOT EXISTS listing_key ON TABLE listing_current TYPE string; -DEFINE FIELD IF NOT EXISTS listing_key_hash ON TABLE listing_current TYPE string; -DEFINE FIELD IF NOT EXISTS event_id ON TABLE listing_current TYPE string; -DEFINE FIELD IF NOT EXISTS seller_pubkey ON TABLE listing_current TYPE string; -DEFINE FIELD IF NOT EXISTS d ON TABLE listing_current TYPE string; -DEFINE FIELD IF NOT EXISTS created_at ON TABLE listing_current TYPE int; -DEFINE FIELD IF NOT EXISTS updated_at ON TABLE listing_current TYPE int; -DEFINE FIELD IF NOT EXISTS published_at ON TABLE listing_current TYPE option<int>; -DEFINE FIELD IF NOT EXISTS title ON TABLE listing_current TYPE string; -DEFINE FIELD IF NOT EXISTS summary ON TABLE listing_current TYPE option<string>; -DEFINE FIELD IF NOT EXISTS content ON TABLE listing_current TYPE string; -DEFINE FIELD IF NOT EXISTS price_decimal ON TABLE listing_current TYPE string; -DEFINE FIELD IF NOT EXISTS price_minor ON TABLE listing_current TYPE int; -DEFINE FIELD IF NOT EXISTS currency_raw ON TABLE listing_current TYPE string; -DEFINE FIELD IF NOT EXISTS currency_norm ON TABLE listing_current TYPE string; -DEFINE FIELD IF NOT EXISTS price_frequency ON TABLE listing_current TYPE option<string>; -DEFINE FIELD IF NOT EXISTS unit ON TABLE listing_current TYPE string; -DEFINE FIELD IF NOT EXISTS unit_family ON TABLE listing_current TYPE string; -DEFINE FIELD IF NOT EXISTS location_text ON TABLE listing_current TYPE option<string>; -DEFINE FIELD IF NOT EXISTS geohash ON TABLE listing_current TYPE option<string>; -DEFINE FIELD IF NOT EXISTS geohash4 ON TABLE listing_current TYPE option<string>; -DEFINE FIELD IF NOT EXISTS geohash5 ON TABLE listing_current TYPE option<string>; -DEFINE FIELD IF NOT EXISTS geohash6 ON TABLE listing_current TYPE option<string>; -DEFINE FIELD IF NOT EXISTS geohash7 ON TABLE listing_current TYPE option<string>; -DEFINE FIELD IF NOT EXISTS point ON TABLE listing_current TYPE option<array>; -DEFINE FIELD IF NOT EXISTS status_tag ON TABLE listing_current TYPE option<string>; -DEFINE FIELD IF NOT EXISTS effective_status ON TABLE listing_current TYPE string; -DEFINE FIELD IF NOT EXISTS categories ON TABLE listing_current TYPE array; -DEFINE FIELD IF NOT EXISTS tags ON TABLE listing_current TYPE array; -DEFINE FIELD IF NOT EXISTS practices ON TABLE listing_current TYPE array; -DEFINE FIELD IF NOT EXISTS certifications ON TABLE listing_current TYPE array; -DEFINE FIELD IF NOT EXISTS image_urls ON TABLE listing_current TYPE array; -DEFINE FIELD IF NOT EXISTS pickup_available ON TABLE listing_current TYPE bool DEFAULT false; -DEFINE FIELD IF NOT EXISTS delivery_available ON TABLE listing_current TYPE bool DEFAULT false; -DEFINE FIELD IF NOT EXISTS shipping_available ON TABLE listing_current TYPE bool DEFAULT false; -DEFINE FIELD IF NOT EXISTS delivery_only ON TABLE listing_current TYPE bool DEFAULT false; -DEFINE FIELD IF NOT EXISTS seller_trust_score ON TABLE listing_current TYPE option<int>; -DEFINE FIELD IF NOT EXISTS hidden ON TABLE listing_current TYPE bool DEFAULT false; -DEFINE FIELD IF NOT EXISTS deleted ON TABLE listing_current TYPE bool DEFAULT false; -DEFINE FIELD IF NOT EXISTS projected_at ON TABLE listing_current TYPE int; -DEFINE INDEX IF NOT EXISTS listing_key_uid ON TABLE listing_current COLUMNS listing_key UNIQUE; -DEFINE INDEX IF NOT EXISTS listing_event_uid ON TABLE listing_current COLUMNS event_id UNIQUE; -DEFINE INDEX IF NOT EXISTS listing_status_updated ON TABLE listing_current COLUMNS effective_status, updated_at, event_id; -DEFINE INDEX IF NOT EXISTS listing_seller_status_updated ON TABLE listing_current COLUMNS seller_pubkey, effective_status, updated_at, event_id; -DEFINE INDEX IF NOT EXISTS listing_price_lookup ON TABLE listing_current COLUMNS effective_status, currency_norm, unit, price_minor, event_id; -DEFINE INDEX IF NOT EXISTS listing_geo4_status ON TABLE listing_current COLUMNS effective_status, geohash4, updated_at, event_id; -DEFINE INDEX IF NOT EXISTS listing_geo5_status ON TABLE listing_current COLUMNS effective_status, geohash5, updated_at, event_id; -DEFINE INDEX IF NOT EXISTS listing_geo6_status ON TABLE listing_current COLUMNS effective_status, geohash6, updated_at, event_id; -"#, - ) - .expect("listing current schema is valid") -} - -pub fn listing_helper_schemas() -> SurrealMigration { - SurrealMigration::new( - "0008_listing_helpers", - r#" -DEFINE TABLE IF NOT EXISTS listing_category SCHEMAFULL; -DEFINE FIELD IF NOT EXISTS listing_key ON TABLE listing_category TYPE string; -DEFINE FIELD IF NOT EXISTS category ON TABLE listing_category TYPE string; -DEFINE FIELD IF NOT EXISTS effective_status ON TABLE listing_category TYPE string; -DEFINE FIELD IF NOT EXISTS updated_at ON TABLE listing_category TYPE int; -DEFINE FIELD IF NOT EXISTS event_id ON TABLE listing_category TYPE string; -DEFINE INDEX IF NOT EXISTS listing_category_lookup ON TABLE listing_category COLUMNS category, effective_status, updated_at, listing_key; -DEFINE INDEX IF NOT EXISTS listing_category_listing ON TABLE listing_category COLUMNS listing_key; - -DEFINE TABLE IF NOT EXISTS listing_fulfillment SCHEMAFULL; -DEFINE FIELD IF NOT EXISTS listing_key ON TABLE listing_fulfillment TYPE string; -DEFINE FIELD IF NOT EXISTS mode ON TABLE listing_fulfillment TYPE string; -DEFINE FIELD IF NOT EXISTS effective_status ON TABLE listing_fulfillment TYPE string; -DEFINE FIELD IF NOT EXISTS updated_at ON TABLE listing_fulfillment TYPE int; -DEFINE FIELD IF NOT EXISTS event_id ON TABLE listing_fulfillment TYPE string; -DEFINE INDEX IF NOT EXISTS listing_fulfillment_lookup ON TABLE listing_fulfillment COLUMNS mode, effective_status, updated_at, listing_key; -DEFINE INDEX IF NOT EXISTS listing_fulfillment_listing ON TABLE listing_fulfillment COLUMNS listing_key; - -DEFINE TABLE IF NOT EXISTS listing_tag SCHEMAFULL; -DEFINE FIELD IF NOT EXISTS listing_key ON TABLE listing_tag TYPE string; -DEFINE FIELD IF NOT EXISTS tag_value ON TABLE listing_tag TYPE string; -DEFINE FIELD IF NOT EXISTS effective_status ON TABLE listing_tag TYPE string; -DEFINE FIELD IF NOT EXISTS updated_at ON TABLE listing_tag TYPE int; -DEFINE FIELD IF NOT EXISTS event_id ON TABLE listing_tag TYPE string; -DEFINE INDEX IF NOT EXISTS listing_tag_lookup ON TABLE listing_tag COLUMNS tag_value, effective_status, updated_at, listing_key; -DEFINE INDEX IF NOT EXISTS listing_tag_listing ON TABLE listing_tag COLUMNS listing_key; - -DEFINE TABLE IF NOT EXISTS listing_practice SCHEMAFULL; -DEFINE FIELD IF NOT EXISTS listing_key ON TABLE listing_practice TYPE string; -DEFINE FIELD IF NOT EXISTS practice ON TABLE listing_practice TYPE string; -DEFINE FIELD IF NOT EXISTS effective_status ON TABLE listing_practice TYPE string; -DEFINE FIELD IF NOT EXISTS updated_at ON TABLE listing_practice TYPE int; -DEFINE FIELD IF NOT EXISTS event_id ON TABLE listing_practice TYPE string; -DEFINE INDEX IF NOT EXISTS listing_practice_lookup ON TABLE listing_practice COLUMNS practice, effective_status, updated_at, listing_key; -DEFINE INDEX IF NOT EXISTS listing_practice_listing ON TABLE listing_practice COLUMNS listing_key; - -DEFINE TABLE IF NOT EXISTS listing_certification SCHEMAFULL; -DEFINE FIELD IF NOT EXISTS listing_key ON TABLE listing_certification TYPE string; -DEFINE FIELD IF NOT EXISTS certification ON TABLE listing_certification TYPE string; -DEFINE FIELD IF NOT EXISTS effective_status ON TABLE listing_certification TYPE string; -DEFINE FIELD IF NOT EXISTS updated_at ON TABLE listing_certification TYPE int; -DEFINE FIELD IF NOT EXISTS event_id ON TABLE listing_certification TYPE string; -DEFINE INDEX IF NOT EXISTS listing_certification_lookup ON TABLE listing_certification COLUMNS certification, effective_status, updated_at, listing_key; -DEFINE INDEX IF NOT EXISTS listing_certification_listing ON TABLE listing_certification COLUMNS listing_key; -"#, - ) - .expect("listing helper schemas are valid") -} - -pub fn search_document_schema() -> SurrealMigration { - SurrealMigration::new( - "0009_search_document", - r#" -DEFINE ANALYZER IF NOT EXISTS tangle_listing_search TOKENIZERS blank,class FILTERS lowercase,snowball(english); -DEFINE TABLE IF NOT EXISTS search_doc SCHEMAFULL; -DEFINE FIELD IF NOT EXISTS doc_key ON TABLE search_doc TYPE string; -DEFINE FIELD IF NOT EXISTS event_id ON TABLE search_doc TYPE string; -DEFINE FIELD IF NOT EXISTS current_event_id ON TABLE search_doc TYPE string; -DEFINE FIELD IF NOT EXISTS doc_type ON TABLE search_doc TYPE string; -DEFINE FIELD IF NOT EXISTS kind ON TABLE search_doc TYPE int; -DEFINE FIELD IF NOT EXISTS pubkey ON TABLE search_doc TYPE string; -DEFINE FIELD IF NOT EXISTS address_key ON TABLE search_doc TYPE option<string>; -DEFINE FIELD IF NOT EXISTS title ON TABLE search_doc TYPE string; -DEFINE FIELD IF NOT EXISTS summary ON TABLE search_doc TYPE option<string>; -DEFINE FIELD IF NOT EXISTS body ON TABLE search_doc TYPE string; -DEFINE FIELD IF NOT EXISTS category_text ON TABLE search_doc TYPE string; -DEFINE FIELD IF NOT EXISTS location_text ON TABLE search_doc TYPE option<string>; -DEFINE FIELD IF NOT EXISTS tags ON TABLE search_doc TYPE array; -DEFINE FIELD IF NOT EXISTS categories ON TABLE search_doc TYPE array; -DEFINE FIELD IF NOT EXISTS created_at ON TABLE search_doc TYPE int; -DEFINE FIELD IF NOT EXISTS updated_at ON TABLE search_doc TYPE int; -DEFINE FIELD IF NOT EXISTS visible ON TABLE search_doc TYPE bool; -DEFINE FIELD IF NOT EXISTS status ON TABLE search_doc TYPE string; -DEFINE FIELD IF NOT EXISTS seller_trust_score ON TABLE search_doc TYPE option<int>; -DEFINE INDEX IF NOT EXISTS search_doc_key_uid ON TABLE search_doc COLUMNS doc_key UNIQUE; -DEFINE INDEX IF NOT EXISTS search_doc_type_visible_updated ON TABLE search_doc COLUMNS doc_type, visible, updated_at, event_id; -DEFINE INDEX IF NOT EXISTS search_doc_kind_visible_updated ON TABLE search_doc COLUMNS visible, kind, updated_at, event_id; -DEFINE INDEX IF NOT EXISTS search_doc_kind_pubkey_updated ON TABLE search_doc COLUMNS kind, pubkey, updated_at, event_id; -DEFINE INDEX IF NOT EXISTS search_doc_title_ft ON TABLE search_doc FIELDS title FULLTEXT ANALYZER tangle_listing_search BM25 HIGHLIGHTS; -DEFINE INDEX IF NOT EXISTS search_doc_summary_ft ON TABLE search_doc FIELDS summary FULLTEXT ANALYZER tangle_listing_search BM25 HIGHLIGHTS; -DEFINE INDEX IF NOT EXISTS search_doc_body_ft ON TABLE search_doc FIELDS body FULLTEXT ANALYZER tangle_listing_search BM25 HIGHLIGHTS; -"#, - ) - .expect("search document schema is valid") -} - -pub fn policy_schemas() -> SurrealMigration { - SurrealMigration::new( - "0010_policy", - r#" -DEFINE TABLE IF NOT EXISTS relay_user SCHEMAFULL; -DEFINE FIELD IF NOT EXISTS pubkey ON TABLE relay_user TYPE string; -DEFINE FIELD IF NOT EXISTS role ON TABLE relay_user TYPE string; -DEFINE FIELD IF NOT EXISTS seller_approved ON TABLE relay_user TYPE bool DEFAULT false; -DEFINE FIELD IF NOT EXISTS blocked ON TABLE relay_user TYPE bool DEFAULT false; -DEFINE FIELD IF NOT EXISTS created_at ON TABLE relay_user TYPE int; -DEFINE FIELD IF NOT EXISTS updated_at ON TABLE relay_user TYPE int; -DEFINE INDEX IF NOT EXISTS relay_user_pubkey_uid ON TABLE relay_user COLUMNS pubkey UNIQUE; -DEFINE INDEX IF NOT EXISTS relay_user_role ON TABLE relay_user COLUMNS role; -DEFINE INDEX IF NOT EXISTS relay_user_seller_gate ON TABLE relay_user COLUMNS seller_approved, blocked; - -DEFINE TABLE IF NOT EXISTS hidden_event SCHEMAFULL; -DEFINE FIELD IF NOT EXISTS event_id ON TABLE hidden_event TYPE string; -DEFINE FIELD IF NOT EXISTS reason ON TABLE hidden_event TYPE string; -DEFINE FIELD IF NOT EXISTS source ON TABLE hidden_event TYPE string; -DEFINE FIELD IF NOT EXISTS created_at ON TABLE hidden_event TYPE int; -DEFINE FIELD IF NOT EXISTS admin_pubkey ON TABLE hidden_event TYPE string; -DEFINE INDEX IF NOT EXISTS hidden_event_uid ON TABLE hidden_event COLUMNS event_id UNIQUE; -DEFINE INDEX IF NOT EXISTS hidden_event_created ON TABLE hidden_event COLUMNS created_at; - -DEFINE TABLE IF NOT EXISTS moderation_action SCHEMAFULL; -DEFINE FIELD IF NOT EXISTS action_id ON TABLE moderation_action TYPE string; -DEFINE FIELD IF NOT EXISTS admin_pubkey ON TABLE moderation_action TYPE string; -DEFINE FIELD IF NOT EXISTS target_type ON TABLE moderation_action TYPE string; -DEFINE FIELD IF NOT EXISTS target_ref ON TABLE moderation_action TYPE string; -DEFINE FIELD IF NOT EXISTS action ON TABLE moderation_action TYPE string; -DEFINE FIELD IF NOT EXISTS reason ON TABLE moderation_action TYPE string; -DEFINE FIELD IF NOT EXISTS created_at ON TABLE moderation_action TYPE int; -DEFINE INDEX IF NOT EXISTS moderation_action_target ON TABLE moderation_action COLUMNS target_type, target_ref, created_at; -DEFINE INDEX IF NOT EXISTS moderation_action_admin ON TABLE moderation_action COLUMNS admin_pubkey, created_at; - -DEFINE TABLE IF NOT EXISTS rate_limit_state SCHEMAFULL; -DEFINE FIELD IF NOT EXISTS key ON TABLE rate_limit_state TYPE string; -DEFINE FIELD IF NOT EXISTS state ON TABLE rate_limit_state TYPE string; -DEFINE FIELD IF NOT EXISTS expires_at ON TABLE rate_limit_state TYPE option<int>; -DEFINE FIELD IF NOT EXISTS created_at ON TABLE rate_limit_state TYPE int; -DEFINE FIELD IF NOT EXISTS updated_at ON TABLE rate_limit_state TYPE int; -DEFINE INDEX IF NOT EXISTS rate_limit_state_key_uid ON TABLE rate_limit_state COLUMNS key UNIQUE; -DEFINE INDEX IF NOT EXISTS rate_limit_state_expires ON TABLE rate_limit_state COLUMNS expires_at; - -DEFINE TABLE IF NOT EXISTS import_checkpoint SCHEMAFULL; -DEFINE FIELD IF NOT EXISTS name ON TABLE import_checkpoint TYPE string; -DEFINE FIELD IF NOT EXISTS offset ON TABLE import_checkpoint TYPE int; -DEFINE FIELD IF NOT EXISTS event_id ON TABLE import_checkpoint TYPE option<string>; -DEFINE FIELD IF NOT EXISTS updated_at ON TABLE import_checkpoint TYPE int; -DEFINE INDEX IF NOT EXISTS import_checkpoint_name_uid ON TABLE import_checkpoint COLUMNS name UNIQUE; - -DEFINE TABLE IF NOT EXISTS projection_error SCHEMAFULL; -DEFINE FIELD IF NOT EXISTS event_id ON TABLE projection_error TYPE string; -DEFINE FIELD IF NOT EXISTS projector ON TABLE projection_error TYPE string; -DEFINE FIELD IF NOT EXISTS error ON TABLE projection_error TYPE string; -DEFINE FIELD IF NOT EXISTS created_at ON TABLE projection_error TYPE int; -DEFINE INDEX IF NOT EXISTS projection_error_event ON TABLE projection_error COLUMNS event_id; -DEFINE INDEX IF NOT EXISTS projection_error_projector_created ON TABLE projection_error COLUMNS projector, created_at; -"#, - ) - .expect("policy schemas are valid") -} - -pub fn comment_projection_schema() -> SurrealMigration { - SurrealMigration::new( - "0011_comment_projection", - r#" -DEFINE TABLE IF NOT EXISTS comment_projection SCHEMAFULL; -DEFINE FIELD IF NOT EXISTS comment_id ON TABLE comment_projection TYPE string; -DEFINE FIELD IF NOT EXISTS event_id ON TABLE comment_projection TYPE string; -DEFINE FIELD IF NOT EXISTS pubkey ON TABLE comment_projection TYPE string; -DEFINE FIELD IF NOT EXISTS created_at ON TABLE comment_projection TYPE int; -DEFINE FIELD IF NOT EXISTS content ON TABLE comment_projection TYPE string; -DEFINE FIELD IF NOT EXISTS root_target_type ON TABLE comment_projection TYPE string; -DEFINE FIELD IF NOT EXISTS root_ref ON TABLE comment_projection TYPE string; -DEFINE FIELD IF NOT EXISTS root_kind ON TABLE comment_projection TYPE string; -DEFINE FIELD IF NOT EXISTS root_author ON TABLE comment_projection TYPE option<string>; -DEFINE FIELD IF NOT EXISTS parent_target_type ON TABLE comment_projection TYPE string; -DEFINE FIELD IF NOT EXISTS parent_ref ON TABLE comment_projection TYPE string; -DEFINE FIELD IF NOT EXISTS parent_kind ON TABLE comment_projection TYPE string; -DEFINE FIELD IF NOT EXISTS parent_author ON TABLE comment_projection TYPE option<string>; -DEFINE FIELD IF NOT EXISTS hidden ON TABLE comment_projection TYPE bool DEFAULT false; -DEFINE FIELD IF NOT EXISTS deleted ON TABLE comment_projection TYPE bool DEFAULT false; -DEFINE FIELD IF NOT EXISTS projected_at ON TABLE comment_projection TYPE int; -DEFINE INDEX IF NOT EXISTS comment_projection_event_uid ON TABLE comment_projection COLUMNS event_id UNIQUE; -DEFINE INDEX IF NOT EXISTS comment_projection_root_lookup ON TABLE comment_projection COLUMNS root_target_type, root_ref, created_at, event_id; -DEFINE INDEX IF NOT EXISTS comment_projection_parent_lookup ON TABLE comment_projection COLUMNS parent_target_type, parent_ref, created_at, event_id; -DEFINE INDEX IF NOT EXISTS comment_projection_author_created ON TABLE comment_projection COLUMNS pubkey, created_at, event_id; -"#, - ) - .expect("comment projection schema is valid") -} - -pub fn reaction_projection_schema() -> SurrealMigration { - SurrealMigration::new( - "0012_reaction_projection", - r#" -DEFINE TABLE IF NOT EXISTS reaction_projection SCHEMAFULL; -DEFINE FIELD IF NOT EXISTS reaction_id ON TABLE reaction_projection TYPE string; -DEFINE FIELD IF NOT EXISTS event_id ON TABLE reaction_projection TYPE string; -DEFINE FIELD IF NOT EXISTS pubkey ON TABLE reaction_projection TYPE string; -DEFINE FIELD IF NOT EXISTS created_at ON TABLE reaction_projection TYPE int; -DEFINE FIELD IF NOT EXISTS content ON TABLE reaction_projection TYPE string; -DEFINE FIELD IF NOT EXISTS value_type ON TABLE reaction_projection TYPE string; -DEFINE FIELD IF NOT EXISTS value ON TABLE reaction_projection TYPE string; -DEFINE FIELD IF NOT EXISTS target_event_id ON TABLE reaction_projection TYPE string; -DEFINE FIELD IF NOT EXISTS target_pubkey ON TABLE reaction_projection TYPE option<string>; -DEFINE FIELD IF NOT EXISTS target_address ON TABLE reaction_projection TYPE option<string>; -DEFINE FIELD IF NOT EXISTS target_kind ON TABLE reaction_projection TYPE option<string>; -DEFINE FIELD IF NOT EXISTS hidden ON TABLE reaction_projection TYPE bool DEFAULT false; -DEFINE FIELD IF NOT EXISTS deleted ON TABLE reaction_projection TYPE bool DEFAULT false; -DEFINE FIELD IF NOT EXISTS projected_at ON TABLE reaction_projection TYPE int; -DEFINE INDEX IF NOT EXISTS reaction_projection_event_uid ON TABLE reaction_projection COLUMNS event_id UNIQUE; -DEFINE INDEX IF NOT EXISTS reaction_projection_target_created ON TABLE reaction_projection COLUMNS target_event_id, created_at, event_id; -DEFINE INDEX IF NOT EXISTS reaction_projection_author_created ON TABLE reaction_projection COLUMNS pubkey, created_at, event_id; -DEFINE INDEX IF NOT EXISTS reaction_projection_target_kind ON TABLE reaction_projection COLUMNS target_kind, target_event_id; - -DEFINE TABLE IF NOT EXISTS reaction_count SCHEMAFULL; -DEFINE FIELD IF NOT EXISTS target_event_id ON TABLE reaction_count TYPE string; -DEFINE FIELD IF NOT EXISTS target_kind ON TABLE reaction_count TYPE option<string>; -DEFINE FIELD IF NOT EXISTS like_count ON TABLE reaction_count TYPE int DEFAULT 0; -DEFINE FIELD IF NOT EXISTS dislike_count ON TABLE reaction_count TYPE int DEFAULT 0; -DEFINE FIELD IF NOT EXISTS emoji_count ON TABLE reaction_count TYPE int DEFAULT 0; -DEFINE FIELD IF NOT EXISTS text_count ON TABLE reaction_count TYPE int DEFAULT 0; -DEFINE FIELD IF NOT EXISTS total_count ON TABLE reaction_count TYPE int DEFAULT 0; -DEFINE FIELD IF NOT EXISTS updated_at ON TABLE reaction_count TYPE int; -DEFINE INDEX IF NOT EXISTS reaction_count_target_uid ON TABLE reaction_count COLUMNS target_event_id UNIQUE; -DEFINE INDEX IF NOT EXISTS reaction_count_kind_target ON TABLE reaction_count COLUMNS target_kind, target_event_id; -"#, - ) - .expect("reaction projection schema is valid") -} - -pub fn long_form_projection_schema() -> SurrealMigration { - SurrealMigration::new( - "0013_long_form_projection", - r#" -DEFINE TABLE IF NOT EXISTS long_form_current SCHEMAFULL; -DEFINE FIELD IF NOT EXISTS long_form_key ON TABLE long_form_current TYPE string; -DEFINE FIELD IF NOT EXISTS event_id ON TABLE long_form_current TYPE string; -DEFINE FIELD IF NOT EXISTS author_pubkey ON TABLE long_form_current TYPE string; -DEFINE FIELD IF NOT EXISTS d ON TABLE long_form_current TYPE string; -DEFINE FIELD IF NOT EXISTS created_at ON TABLE long_form_current TYPE int; -DEFINE FIELD IF NOT EXISTS updated_at ON TABLE long_form_current TYPE int; -DEFINE FIELD IF NOT EXISTS published_at ON TABLE long_form_current TYPE option<int>; -DEFINE FIELD IF NOT EXISTS title ON TABLE long_form_current TYPE option<string>; -DEFINE FIELD IF NOT EXISTS image ON TABLE long_form_current TYPE option<string>; -DEFINE FIELD IF NOT EXISTS summary ON TABLE long_form_current TYPE option<string>; -DEFINE FIELD IF NOT EXISTS content ON TABLE long_form_current TYPE string; -DEFINE FIELD IF NOT EXISTS tags ON TABLE long_form_current TYPE array; -DEFINE FIELD IF NOT EXISTS referenced_events ON TABLE long_form_current TYPE array; -DEFINE FIELD IF NOT EXISTS referenced_addresses ON TABLE long_form_current TYPE array; -DEFINE FIELD IF NOT EXISTS referenced_pubkeys ON TABLE long_form_current TYPE array; -DEFINE FIELD IF NOT EXISTS hidden ON TABLE long_form_current TYPE bool DEFAULT false; -DEFINE FIELD IF NOT EXISTS deleted ON TABLE long_form_current TYPE bool DEFAULT false; -DEFINE FIELD IF NOT EXISTS projected_at ON TABLE long_form_current TYPE int; -DEFINE INDEX IF NOT EXISTS long_form_current_key_uid ON TABLE long_form_current COLUMNS long_form_key UNIQUE; -DEFINE INDEX IF NOT EXISTS long_form_current_event_uid ON TABLE long_form_current COLUMNS event_id UNIQUE; -DEFINE INDEX IF NOT EXISTS long_form_current_author_updated ON TABLE long_form_current COLUMNS author_pubkey, updated_at, event_id; -DEFINE INDEX IF NOT EXISTS long_form_current_published_updated ON TABLE long_form_current COLUMNS published_at, updated_at, event_id; -DEFINE INDEX IF NOT EXISTS long_form_current_visibility ON TABLE long_form_current COLUMNS hidden, deleted, updated_at, event_id; - -DEFINE TABLE IF NOT EXISTS long_form_topic SCHEMAFULL; -DEFINE FIELD IF NOT EXISTS long_form_key ON TABLE long_form_topic TYPE string; -DEFINE FIELD IF NOT EXISTS topic ON TABLE long_form_topic TYPE string; -DEFINE FIELD IF NOT EXISTS updated_at ON TABLE long_form_topic TYPE int; -DEFINE FIELD IF NOT EXISTS event_id ON TABLE long_form_topic TYPE string; -DEFINE FIELD IF NOT EXISTS hidden ON TABLE long_form_topic TYPE bool DEFAULT false; -DEFINE FIELD IF NOT EXISTS deleted ON TABLE long_form_topic TYPE bool DEFAULT false; -DEFINE INDEX IF NOT EXISTS long_form_topic_lookup ON TABLE long_form_topic COLUMNS topic, hidden, deleted, updated_at, long_form_key; -DEFINE INDEX IF NOT EXISTS long_form_topic_long_form ON TABLE long_form_topic COLUMNS long_form_key; -"#, - ) - .expect("long-form projection schema is valid") -} - -pub fn forum_thread_schema() -> SurrealMigration { - SurrealMigration::new( - "0014_forum_thread_projection", - r#" -DEFINE TABLE IF NOT EXISTS forum_thread_projection SCHEMAFULL; -DEFINE FIELD IF NOT EXISTS thread_id ON TABLE forum_thread_projection TYPE string; -DEFINE FIELD IF NOT EXISTS event_id ON TABLE forum_thread_projection TYPE string; -DEFINE FIELD IF NOT EXISTS pubkey ON TABLE forum_thread_projection TYPE string; -DEFINE FIELD IF NOT EXISTS created_at ON TABLE forum_thread_projection TYPE int; -DEFINE FIELD IF NOT EXISTS updated_at ON TABLE forum_thread_projection TYPE int; -DEFINE FIELD IF NOT EXISTS title ON TABLE forum_thread_projection TYPE option<string>; -DEFINE FIELD IF NOT EXISTS content ON TABLE forum_thread_projection TYPE string; -DEFINE FIELD IF NOT EXISTS tags ON TABLE forum_thread_projection TYPE array; -DEFINE FIELD IF NOT EXISTS referenced_events ON TABLE forum_thread_projection TYPE array; -DEFINE FIELD IF NOT EXISTS referenced_pubkeys ON TABLE forum_thread_projection TYPE array; -DEFINE FIELD IF NOT EXISTS hidden ON TABLE forum_thread_projection TYPE bool DEFAULT false; -DEFINE FIELD IF NOT EXISTS deleted ON TABLE forum_thread_projection TYPE bool DEFAULT false; -DEFINE FIELD IF NOT EXISTS projected_at ON TABLE forum_thread_projection TYPE int; -DEFINE INDEX IF NOT EXISTS forum_thread_event_uid ON TABLE forum_thread_projection COLUMNS event_id UNIQUE; -DEFINE INDEX IF NOT EXISTS forum_thread_pubkey_updated ON TABLE forum_thread_projection COLUMNS pubkey, updated_at, event_id; -DEFINE INDEX IF NOT EXISTS forum_thread_visibility_updated ON TABLE forum_thread_projection COLUMNS hidden, deleted, updated_at, event_id; - -DEFINE TABLE IF NOT EXISTS forum_thread_topic SCHEMAFULL; -DEFINE FIELD IF NOT EXISTS thread_id ON TABLE forum_thread_topic TYPE string; -DEFINE FIELD IF NOT EXISTS topic ON TABLE forum_thread_topic TYPE string; -DEFINE FIELD IF NOT EXISTS updated_at ON TABLE forum_thread_topic TYPE int; -DEFINE FIELD IF NOT EXISTS event_id ON TABLE forum_thread_topic TYPE string; -DEFINE FIELD IF NOT EXISTS hidden ON TABLE forum_thread_topic TYPE bool DEFAULT false; -DEFINE FIELD IF NOT EXISTS deleted ON TABLE forum_thread_topic TYPE bool DEFAULT false; -DEFINE INDEX IF NOT EXISTS forum_thread_topic_lookup ON TABLE forum_thread_topic COLUMNS topic, hidden, deleted, updated_at, thread_id; -DEFINE INDEX IF NOT EXISTS forum_thread_topic_thread ON TABLE forum_thread_topic COLUMNS thread_id; -"#, - ) - .expect("forum thread schema is valid") -} - -pub fn label_projection_schema() -> SurrealMigration { - SurrealMigration::new( - "0015_label_projection", - r#" -DEFINE TABLE IF NOT EXISTS label_projection SCHEMAFULL; -DEFINE FIELD IF NOT EXISTS label_id ON TABLE label_projection TYPE string; -DEFINE FIELD IF NOT EXISTS event_id ON TABLE label_projection TYPE string; -DEFINE FIELD IF NOT EXISTS pubkey ON TABLE label_projection TYPE string; -DEFINE FIELD IF NOT EXISTS created_at ON TABLE label_projection TYPE int; -DEFINE FIELD IF NOT EXISTS content ON TABLE label_projection TYPE string; -DEFINE FIELD IF NOT EXISTS namespace ON TABLE label_projection TYPE string; -DEFINE FIELD IF NOT EXISTS label ON TABLE label_projection TYPE string; -DEFINE FIELD IF NOT EXISTS target_type ON TABLE label_projection TYPE string; -DEFINE FIELD IF NOT EXISTS target_ref ON TABLE label_projection TYPE string; -DEFINE FIELD IF NOT EXISTS hidden ON TABLE label_projection TYPE bool DEFAULT false; -DEFINE FIELD IF NOT EXISTS deleted ON TABLE label_projection TYPE bool DEFAULT false; -DEFINE FIELD IF NOT EXISTS projected_at ON TABLE label_projection TYPE int; -DEFINE INDEX IF NOT EXISTS label_projection_label_uid ON TABLE label_projection COLUMNS label_id UNIQUE; -DEFINE INDEX IF NOT EXISTS label_projection_event ON TABLE label_projection COLUMNS event_id; -DEFINE INDEX IF NOT EXISTS label_projection_target_lookup ON TABLE label_projection COLUMNS target_type, target_ref, namespace, label, created_at, event_id; -DEFINE INDEX IF NOT EXISTS label_projection_namespace_lookup ON TABLE label_projection COLUMNS namespace, label, created_at, event_id; -DEFINE INDEX IF NOT EXISTS label_projection_author_created ON TABLE label_projection COLUMNS pubkey, created_at, event_id; -DEFINE INDEX IF NOT EXISTS label_projection_visibility ON TABLE label_projection COLUMNS hidden, deleted, created_at, event_id; -"#, - ) - .expect("label projection schema is valid") -} - -pub fn report_projection_schema() -> SurrealMigration { - SurrealMigration::new( - "0016_report_projection", - r#" -DEFINE TABLE IF NOT EXISTS report_projection SCHEMAFULL; -DEFINE FIELD IF NOT EXISTS report_id ON TABLE report_projection TYPE string; -DEFINE FIELD IF NOT EXISTS event_id ON TABLE report_projection TYPE string; -DEFINE FIELD IF NOT EXISTS pubkey ON TABLE report_projection TYPE string; -DEFINE FIELD IF NOT EXISTS created_at ON TABLE report_projection TYPE int; -DEFINE FIELD IF NOT EXISTS content ON TABLE report_projection TYPE string; -DEFINE FIELD IF NOT EXISTS target_type ON TABLE report_projection TYPE string; -DEFINE FIELD IF NOT EXISTS target_ref ON TABLE report_projection TYPE string; -DEFINE FIELD IF NOT EXISTS report_type ON TABLE report_projection TYPE string; -DEFINE FIELD IF NOT EXISTS reported_pubkeys ON TABLE report_projection TYPE array; -DEFINE FIELD IF NOT EXISTS server_urls ON TABLE report_projection TYPE array; -DEFINE FIELD IF NOT EXISTS hidden ON TABLE report_projection TYPE bool DEFAULT false; -DEFINE FIELD IF NOT EXISTS deleted ON TABLE report_projection TYPE bool DEFAULT false; -DEFINE FIELD IF NOT EXISTS projected_at ON TABLE report_projection TYPE int; -DEFINE INDEX IF NOT EXISTS report_projection_report_uid ON TABLE report_projection COLUMNS report_id UNIQUE; -DEFINE INDEX IF NOT EXISTS report_projection_event ON TABLE report_projection COLUMNS event_id; -DEFINE INDEX IF NOT EXISTS report_projection_target_lookup ON TABLE report_projection COLUMNS target_type, target_ref, report_type, created_at, event_id; -DEFINE INDEX IF NOT EXISTS report_projection_type_created ON TABLE report_projection COLUMNS report_type, created_at, event_id; -DEFINE INDEX IF NOT EXISTS report_projection_author_created ON TABLE report_projection COLUMNS pubkey, created_at, event_id; -DEFINE INDEX IF NOT EXISTS report_projection_visibility ON TABLE report_projection COLUMNS hidden, deleted, created_at, event_id; -"#, - ) - .expect("report projection schema is valid") -} - -pub fn seller_profile_schema() -> SurrealMigration { - SurrealMigration::new( - "0017_seller_profile", - r#" -DEFINE TABLE IF NOT EXISTS seller_profile SCHEMAFULL; -DEFINE FIELD IF NOT EXISTS pubkey ON TABLE seller_profile TYPE string; -DEFINE FIELD IF NOT EXISTS event_id ON TABLE seller_profile TYPE string; -DEFINE FIELD IF NOT EXISTS created_at ON TABLE seller_profile TYPE int; -DEFINE FIELD IF NOT EXISTS updated_at ON TABLE seller_profile TYPE int; -DEFINE FIELD IF NOT EXISTS name ON TABLE seller_profile TYPE option<string>; -DEFINE FIELD IF NOT EXISTS display_name ON TABLE seller_profile TYPE option<string>; -DEFINE FIELD IF NOT EXISTS about ON TABLE seller_profile TYPE option<string>; -DEFINE FIELD IF NOT EXISTS picture ON TABLE seller_profile TYPE option<string>; -DEFINE FIELD IF NOT EXISTS website ON TABLE seller_profile TYPE option<string>; -DEFINE FIELD IF NOT EXISTS nip05 ON TABLE seller_profile TYPE option<string>; -DEFINE FIELD IF NOT EXISTS lud16 ON TABLE seller_profile TYPE option<string>; -DEFINE FIELD IF NOT EXISTS regions ON TABLE seller_profile TYPE array; -DEFINE FIELD IF NOT EXISTS categories ON TABLE seller_profile TYPE array; -DEFINE FIELD IF NOT EXISTS trust_markers ON TABLE seller_profile TYPE array; -DEFINE FIELD IF NOT EXISTS seller_approved ON TABLE seller_profile TYPE bool DEFAULT false; -DEFINE FIELD IF NOT EXISTS blocked ON TABLE seller_profile TYPE bool DEFAULT false; -DEFINE FIELD IF NOT EXISTS hidden ON TABLE seller_profile TYPE bool DEFAULT false; -DEFINE FIELD IF NOT EXISTS deleted ON TABLE seller_profile TYPE bool DEFAULT false; -DEFINE FIELD IF NOT EXISTS projected_at ON TABLE seller_profile TYPE int; -DEFINE INDEX IF NOT EXISTS seller_profile_pubkey_uid ON TABLE seller_profile COLUMNS pubkey UNIQUE; -DEFINE INDEX IF NOT EXISTS seller_profile_event_uid ON TABLE seller_profile COLUMNS event_id UNIQUE; -DEFINE INDEX IF NOT EXISTS seller_profile_updated ON TABLE seller_profile COLUMNS updated_at, pubkey; -DEFINE INDEX IF NOT EXISTS seller_profile_approved_blocked ON TABLE seller_profile COLUMNS seller_approved, blocked, updated_at, pubkey; -DEFINE INDEX IF NOT EXISTS seller_profile_visibility ON TABLE seller_profile COLUMNS hidden, deleted, updated_at, pubkey; -"#, - ) - .expect("seller profile schema is valid") -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AppliedMigration { - name: String, - checksum: String, -} - -impl AppliedMigration { - pub fn name(&self) -> &str { - &self.name - } - - pub fn checksum(&self) -> &str { - &self.checksum - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum MigrationApplyOutcome { - Applied, - AlreadyApplied, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CurrentEventOutcome { - NotCurrent, - Inserted, - Replaced, - Unchanged, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum DeletionMarkerOutcome { - NotDeletion, - Applied { targets: usize }, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ListingRevisionOutcome { - NotListing, - Stored { parsed_ok: bool }, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ListingCurrentOutcome { - NotListing, - Ineligible, - Projected, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ListingHelperOutcome { - NotListing, - Ineligible, - Projected, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SearchDocumentOutcome { - NotListing, - Ineligible, - Indexed, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CommentProjectionOutcome { - NotComment, - Ineligible, - Projected, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ReactionProjectionOutcome { - NotReaction, - Ineligible, - Projected, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum LongFormProjectionOutcome { - NotLongForm, - Ineligible, - Projected, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ForumThreadProjectionOutcome { - NotForumThread, - Ineligible, - Projected, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum LabelProjectionOutcome { - NotLabel, - Ineligible, - Projected, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ReportProjectionOutcome { - NotReport, - Ineligible, - Projected, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SellerProfileProjectionOutcome { - NotProfile, - Ineligible, - Projected, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum HiddenEventOutcome { - NotFound, - Hidden, - Unhidden, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DurableRateLimitDecision { - Accepted { - remaining: u64, - reset_at: UnixTimestamp, - }, - Rejected { - retry_after_seconds: u64, - reset_at: UnixTimestamp, - }, -} - -impl DurableRateLimitDecision { - pub fn allowed(self) -> bool { - matches!(self, Self::Accepted { .. }) - } - - pub fn remaining(self) -> u64 { - match self { - Self::Accepted { remaining, .. } => remaining, - Self::Rejected { .. } => 0, - } - } - - pub fn reset_at(self) -> UnixTimestamp { - match self { - Self::Accepted { reset_at, .. } | Self::Rejected { reset_at, .. } => reset_at, - } - } - - pub fn retry_after_seconds(self) -> Option<u64> { - match self { - Self::Accepted { .. } => None, - Self::Rejected { - retry_after_seconds, - .. - } => Some(retry_after_seconds), - } - } -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct ListingProjectionQuery { - effective_status: Option<String>, - seller_pubkey: Option<String>, - unit: Option<String>, - currency_norm: Option<String>, - min_price_minor: Option<i64>, - max_price_minor: Option<i64>, - limit: Option<u64>, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct SearchDocumentQuery { - text: Option<String>, - doc_type: Option<String>, - kind: Option<u32>, - pubkey: Option<String>, - visible: Option<bool>, - status: Option<String>, - limit: Option<u64>, -} - -impl ListingProjectionQuery { - pub fn new() -> Self { - Self::default() - } - - pub fn with_effective_status(mut self, value: &str) -> Self { - self.effective_status = Some(value.to_owned()); - self - } - - pub fn with_seller_pubkey(mut self, value: &str) -> Self { - self.seller_pubkey = Some(value.to_owned()); - self - } - - pub fn with_unit(mut self, value: &str) -> Self { - self.unit = Some(value.to_owned()); - self - } - - pub fn with_currency_norm(mut self, value: &str) -> Self { - self.currency_norm = Some(value.to_owned()); - self - } - - pub fn with_min_price_minor(mut self, value: i64) -> Self { - self.min_price_minor = Some(value); - self - } - - pub fn with_max_price_minor(mut self, value: i64) -> Self { - self.max_price_minor = Some(value); - self - } - - pub fn with_limit(mut self, value: u64) -> Self { - self.limit = Some(value); - self - } -} - -impl SearchDocumentQuery { - pub fn new() -> Self { - Self::default() - } - - pub fn with_text(mut self, value: &str) -> Self { - self.text = Some(value.to_owned()); - self - } - - pub fn with_doc_type(mut self, value: &str) -> Self { - self.doc_type = Some(value.to_owned()); - self - } - - pub fn with_kind(mut self, value: u32) -> Self { - self.kind = Some(value); - self - } - - pub fn with_pubkey(mut self, value: &str) -> Self { - self.pubkey = Some(value.to_owned()); - self - } - - pub fn with_visible(mut self, value: bool) -> Self { - self.visible = Some(value); - self - } - - pub fn with_status(mut self, value: &str) -> Self { - self.status = Some(value.to_owned()); - self - } - - pub fn with_limit(mut self, value: u64) -> Self { - self.limit = Some(value); - self - } -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct CommentProjectionQuery { - root_target_type: Option<String>, - root_ref: Option<String>, - parent_target_type: Option<String>, - parent_ref: Option<String>, - pubkey: Option<String>, - limit: Option<u64>, -} - -impl CommentProjectionQuery { - pub fn new() -> Self { - Self::default() - } - - pub fn with_root(mut self, target_type: &str, target_ref: &str) -> Self { - self.root_target_type = Some(target_type.to_owned()); - self.root_ref = Some(target_ref.to_owned()); - self - } - - pub fn with_parent(mut self, target_type: &str, target_ref: &str) -> Self { - self.parent_target_type = Some(target_type.to_owned()); - self.parent_ref = Some(target_ref.to_owned()); - self - } - - pub fn with_pubkey(mut self, pubkey: &str) -> Self { - self.pubkey = Some(pubkey.to_owned()); - self - } - - pub fn with_limit(mut self, limit: u64) -> Self { - self.limit = Some(limit); - self - } -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct LongFormProjectionQuery { - author_pubkey: Option<String>, - topic: Option<String>, - limit: Option<u64>, -} - -impl LongFormProjectionQuery { - pub fn new() -> Self { - Self::default() - } - - pub fn with_author_pubkey(mut self, pubkey: &str) -> Self { - self.author_pubkey = Some(pubkey.to_owned()); - self - } - - pub fn with_topic(mut self, topic: &str) -> Self { - self.topic = Some(topic.to_owned()); - self - } - - pub fn with_limit(mut self, limit: u64) -> Self { - self.limit = Some(limit); - self - } -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct ForumThreadProjectionQuery { - pubkey: Option<String>, - topic: Option<String>, - limit: Option<u64>, -} - -impl ForumThreadProjectionQuery { - pub fn new() -> Self { - Self::default() - } - - pub fn with_pubkey(mut self, pubkey: &str) -> Self { - self.pubkey = Some(pubkey.to_owned()); - self - } - - pub fn with_topic(mut self, topic: &str) -> Self { - self.topic = Some(topic.to_owned()); - self - } - - pub fn with_limit(mut self, limit: u64) -> Self { - self.limit = Some(limit); - self - } -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct LabelProjectionQuery { - target_type: Option<String>, - target_ref: Option<String>, - namespace: Option<String>, - label: Option<String>, - pubkey: Option<String>, - limit: Option<u64>, -} - -impl LabelProjectionQuery { - pub fn new() -> Self { - Self::default() - } - - pub fn with_target(mut self, target_type: &str, target_ref: &str) -> Self { - self.target_type = Some(target_type.to_owned()); - self.target_ref = Some(target_ref.to_owned()); - self - } - - pub fn with_namespace(mut self, namespace: &str) -> Self { - self.namespace = Some(namespace.to_owned()); - self - } - - pub fn with_label(mut self, label: &str) -> Self { - self.label = Some(label.to_owned()); - self - } - - pub fn with_pubkey(mut self, pubkey: &str) -> Self { - self.pubkey = Some(pubkey.to_owned()); - self - } - - pub fn with_limit(mut self, limit: u64) -> Self { - self.limit = Some(limit); - self - } -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct ReportProjectionQuery { - target_type: Option<String>, - target_ref: Option<String>, - report_type: Option<String>, - pubkey: Option<String>, - limit: Option<u64>, -} - -impl ReportProjectionQuery { - pub fn new() -> Self { - Self::default() - } - - pub fn with_target(mut self, target_type: &str, target_ref: &str) -> Self { - self.target_type = Some(target_type.to_owned()); - self.target_ref = Some(target_ref.to_owned()); - self - } - - pub fn with_report_type(mut self, report_type: &str) -> Self { - self.report_type = Some(report_type.to_owned()); - self - } - - pub fn with_pubkey(mut self, pubkey: &str) -> Self { - self.pubkey = Some(pubkey.to_owned()); - self - } - - pub fn with_limit(mut self, limit: u64) -> Self { - self.limit = Some(limit); - self - } -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct SellerProfileQuery { - pubkey: Option<String>, - approved: Option<bool>, - blocked: Option<bool>, - limit: Option<u64>, -} - -impl SellerProfileQuery { - pub fn new() -> Self { - Self::default() - } - - pub fn with_pubkey(mut self, pubkey: &str) -> Self { - self.pubkey = Some(pubkey.to_owned()); - self - } - - pub fn with_approved(mut self, approved: bool) -> Self { - self.approved = Some(approved); - self - } - - pub fn with_blocked(mut self, blocked: bool) -> Self { - self.blocked = Some(blocked); - self - } - - pub fn with_limit(mut self, limit: u64) -> Self { - self.limit = Some(limit); - self - } -} - -#[derive(Clone)] -pub struct SurrealStore { - db: Surreal<Any>, -} - -impl fmt::Debug for SurrealStore { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("SurrealStore") - .finish_non_exhaustive() - } -} - -impl SurrealStore { - pub async fn connect(config: &SurrealConnectionConfig) -> Result<Self, SurrealStoreError> { - match config.mode() { - SurrealConnectionMode::Memory | SurrealConnectionMode::RocksDb { .. } => { - Self::connect_local(config).await - } - SurrealConnectionMode::Http { endpoint } - | SurrealConnectionMode::WebSocket { endpoint } => { - Self::connect_remote(config, endpoint).await - } - } - } - - pub async fn connect_local( - config: &SurrealConnectionConfig, - ) -> Result<Self, SurrealStoreError> { - match config.mode() { - SurrealConnectionMode::Memory => Self::connect_memory(config).await, - SurrealConnectionMode::RocksDb { path } => { - let db = connect_any(rocksdb_endpoint(path)) - .await - .map_err(SurrealStoreError::from)?; - db.use_ns(config.namespace()) - .use_db(config.database()) - .await - .map_err(SurrealStoreError::from)?; - Ok(Self { db }) - } - SurrealConnectionMode::Http { .. } | SurrealConnectionMode::WebSocket { .. } => { - Err(SurrealStoreError::new( - "surreal local connection requires memory or rocksdb mode config", - )) - } - } - } - - pub async fn connect_memory( - config: &SurrealConnectionConfig, - ) -> Result<Self, SurrealStoreError> { - if config.mode() != &SurrealConnectionMode::Memory { - return Err(SurrealStoreError::new( - "surreal memory connection requires memory mode config", - )); - } - let db = connect_any("memory") - .await - .map_err(SurrealStoreError::from)?; - db.use_ns(config.namespace()) - .use_db(config.database()) - .await - .map_err(SurrealStoreError::from)?; - Ok(Self { db }) - } - - async fn connect_remote( - config: &SurrealConnectionConfig, - endpoint: &str, - ) -> Result<Self, SurrealStoreError> { - let credentials = config.root_credentials().ok_or_else(|| { - SurrealStoreError::new("surreal remote connection requires root credentials") - })?; - let root = Root { - username: credentials.username().to_owned(), - password: credentials.password().to_owned(), - }; - let db = connect_any(( - endpoint.to_owned(), - SurrealClientConfig::new().user(root.clone()), - )) - .await - .map_err(SurrealStoreError::from)?; - db.signin(root).await.map_err(SurrealStoreError::from)?; - db.use_ns(config.namespace()) - .use_db(config.database()) - .await - .map_err(SurrealStoreError::from)?; - Ok(Self { db }) - } - - pub fn database(&self) -> &Surreal<Any> { - &self.db - } - - pub async fn ping(&self) -> Result<(), SurrealStoreError> { - self.db - .query("RETURN true;") - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - Ok(()) - } - - pub async fn apply_plan( - &self, - plan: &SurrealMigrationPlan, - ) -> Result<Vec<MigrationApplyOutcome>, SurrealStoreError> { - let mut outcomes = Vec::with_capacity(plan.migrations().len()); - for migration in plan.migrations() { - outcomes.push(self.apply_migration(migration).await?); - } - Ok(outcomes) - } - - pub async fn apply_migration( - &self, - migration: &SurrealMigration, - ) -> Result<MigrationApplyOutcome, SurrealStoreError> { - if self.has_migration_table().await? - && let Some(applied) = self.applied_migration(migration.name()).await? - { - if applied.checksum() == migration.checksum() { - return Ok(MigrationApplyOutcome::AlreadyApplied); - } - return Err(SurrealStoreError::new(&format!( - "surreal migration `{}` checksum changed", - migration.name() - ))); - } - self.db - .query(migration.surql()) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - self.record_migration(migration).await?; - Ok(MigrationApplyOutcome::Applied) - } - - pub async fn applied_migrations(&self) -> Result<Vec<AppliedMigration>, SurrealStoreError> { - if !self.has_migration_table().await? { - return Ok(Vec::new()); - } - let mut response = self - .db - .query("SELECT VALUE name FROM migration ORDER BY name ASC;") - .query("SELECT VALUE checksum FROM migration ORDER BY name ASC;") - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - let names: Vec<String> = response.take(0).map_err(SurrealStoreError::from)?; - let checksums: Vec<String> = response.take(1).map_err(SurrealStoreError::from)?; - Ok(names - .into_iter() - .zip(checksums) - .map(|(name, checksum)| AppliedMigration { name, checksum }) - .collect()) - } - - pub async fn table_info(&self, table: &str) -> Result<String, SurrealStoreError> { - let table = normalized_identifier(table, "table").map_err(|error| { - SurrealStoreError::new(&format!("surreal table info target is invalid: {error}")) - })?; - let mut response = self - .db - .query(format!("INFO FOR TABLE {table};")) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - let info: surrealdb::types::Value = response.take(0).map_err(SurrealStoreError::from)?; - Ok(format!("{info:?}")) - } - - pub async fn metrics_snapshot(&self) -> Result<SurrealMetricsSnapshot, SurrealStoreError> { - Ok(SurrealMetricsSnapshot { - stored_events: self - .count_query("SELECT VALUE count() FROM nostr_event GROUP ALL;") - .await?, - visible_events: self - .count_query( - "SELECT VALUE count() FROM nostr_event WHERE deleted = false AND hidden = false GROUP ALL;", - ) - .await?, - hidden_events: self - .count_query("SELECT VALUE count() FROM nostr_event WHERE hidden = true GROUP ALL;") - .await?, - deleted_events: self - .count_query( - "SELECT VALUE count() FROM nostr_event WHERE deleted = true GROUP ALL;", - ) - .await?, - current_listings: self - .count_query("SELECT VALUE count() FROM listing_current GROUP ALL;") - .await?, - active_listings: self - .count_query( - "SELECT VALUE count() FROM listing_current WHERE effective_status = 'active' AND hidden = false AND deleted = false GROUP ALL;", - ) - .await?, - seller_profiles: self - .count_query("SELECT VALUE count() FROM seller_profile GROUP ALL;") - .await?, - visible_seller_profiles: self - .count_query( - "SELECT VALUE count() FROM seller_profile WHERE hidden = false AND deleted = false GROUP ALL;", - ) - .await?, - approved_sellers: self - .count_query( - "SELECT VALUE count() FROM relay_user WHERE seller_approved = true AND blocked = false GROUP ALL;", - ) - .await?, - blocked_pubkeys: self - .count_query("SELECT VALUE count() FROM relay_user WHERE blocked = true GROUP ALL;") - .await?, - }) - } - - pub async fn store_raw_event( - &self, - stored: &StoredEvent, - ) -> Result<StoreEventOutcome, SurrealStoreError> { - if self.raw_event_row(stored.event().id()).await?.is_some() { - return Ok(StoreEventOutcome::Duplicate); - } - let event = stored.event(); - self.db - .query( - r#" -CREATE type::record('nostr_event', $event_id) CONTENT { - event_id: $event_id, - pubkey: $pubkey, - created_at: $created_at, - kind: $kind, - tags: $tags, - content: $content, - sig: $sig, - raw_json: $raw_json, - received_at: $received_at, - content_len: $content_len, - tag_count: $tag_count, - d_tag: $d_tag, - address_key: $address_key, - deleted: false, - hidden: false, - rejection_reason: NONE -}; -"#, - ) - .bind(("event_id", event.id().as_str())) - .bind(("pubkey", event.unsigned().pubkey().as_str())) - .bind(("created_at", event.unsigned().created_at().as_u64())) - .bind(("kind", event.unsigned().kind().as_u32())) - .bind(("tags", event_tags_json(event))) - .bind(("content", event.unsigned().content())) - .bind(("sig", event.sig().as_str())) - .bind(("raw_json", event_to_value(event).to_string())) - .bind(("received_at", stored.received_at().as_u64())) - .bind(("content_len", stored.content_len() as u64)) - .bind(("tag_count", stored.tag_count() as u64)) - .bind(("d_tag", d_tag_value(event))) - .bind(("address_key", address_key_value(event)?)) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - Ok(StoreEventOutcome::Inserted) - } - - pub async fn raw_event_row( - &self, - event_id: &EventId, - ) -> Result<Option<serde_json::Value>, SurrealStoreError> { - let mut response = self - .db - .query("SELECT * FROM ONLY type::record('nostr_event', $event_id);") - .bind(("event_id", event_id.as_str())) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - response.take(0).map_err(SurrealStoreError::from) - } - - async fn count_query(&self, statement: &str) -> Result<u64, SurrealStoreError> { - let mut response = self - .db - .query(statement) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - let rows: Vec<serde_json::Value> = response.take(0).map_err(SurrealStoreError::from)?; - rows.into_iter().next().map(count_value).unwrap_or(Ok(0)) - } - - pub async fn query_raw_events( - &self, - filter: &Filter, - ) -> Result<Vec<serde_json::Value>, SurrealStoreError> { - let mut statement = - "SELECT * FROM nostr_event WHERE deleted = false AND hidden = false".to_owned(); - if !filter.ids().is_empty() { - statement.push_str(" AND event_id IN $ids"); - } - if !filter.authors().is_empty() { - statement.push_str(" AND pubkey IN $authors"); - } - if !filter.kinds().is_empty() { - statement.push_str(" AND kind IN $kinds"); - } - if filter.since().is_some() { - statement.push_str(" AND created_at >= $since"); - } - if filter.until().is_some() { - statement.push_str(" AND created_at <= $until"); - } - statement.push_str(" ORDER BY created_at DESC, event_id ASC"); - if filter.limit().is_some() { - statement.push_str(" LIMIT $limit"); - } - statement.push(';'); - let mut query = self.db.query(statement); - if !filter.ids().is_empty() { - query = query.bind(( - "ids", - filter - .ids() - .iter() - .map(|id| id.as_str().to_owned()) - .collect::<Vec<_>>(), - )); - } - if !filter.authors().is_empty() { - query = query.bind(( - "authors", - filter - .authors() - .iter() - .map(|pubkey| pubkey.as_str().to_owned()) - .collect::<Vec<_>>(), - )); - } - if !filter.kinds().is_empty() { - query = query.bind(( - "kinds", - filter - .kinds() - .iter() - .map(|kind| kind.as_u32()) - .collect::<Vec<_>>(), - )); - } - if let Some(since) = filter.since() { - query = query.bind(("since", since.as_u64())); - } - if let Some(until) = filter.until() { - query = query.bind(("until", until.as_u64())); - } - if let Some(limit) = filter.limit() { - query = query.bind(("limit", limit)); - } - let mut response = query - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - response.take(0).map_err(SurrealStoreError::from) - } - - pub async fn backup_raw_events(&self) -> Result<Vec<serde_json::Value>, SurrealStoreError> { - let mut response = self - .db - .query("SELECT * FROM nostr_event ORDER BY created_at ASC, event_id ASC;") - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - response.take(0).map_err(SurrealStoreError::from) - } - - pub async fn index_event_tags(&self, event: &Event) -> Result<(), SurrealStoreError> { - self.db - .query("DELETE event_tag_index WHERE event_id = $event_id;") - .bind(("event_id", event.id().as_str())) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - for (ordinal, tag) in event.unsigned().tags().iter().enumerate() { - let Some((name, value)) = tag.indexed_pair() else { - continue; - }; - self.db - .query( - r#" -CREATE event_tag_index CONTENT { - event_id: $event_id, - kind: $kind, - pubkey: $pubkey, - created_at: $created_at, - tag: $tag, - value: $value, - ordinal: $ordinal -}; -"#, - ) - .bind(("event_id", event.id().as_str())) - .bind(("kind", event.unsigned().kind().as_u32())) - .bind(("pubkey", event.unsigned().pubkey().as_str())) - .bind(("created_at", event.unsigned().created_at().as_u64())) - .bind(("tag", name)) - .bind(("value", value)) - .bind(("ordinal", ordinal as u64)) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - } - Ok(()) - } - - pub async fn tag_index_rows( - &self, - event_id: &EventId, - ) -> Result<Vec<serde_json::Value>, SurrealStoreError> { - let mut response = self - .db - .query("SELECT * FROM event_tag_index WHERE event_id = $event_id ORDER BY ordinal ASC;") - .bind(("event_id", event_id.as_str())) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - response.take(0).map_err(SurrealStoreError::from) - } - - pub async fn query_indexed_tag_event_ids( - &self, - filter: &Filter, - ) -> Result<Vec<String>, SurrealStoreError> { - if filter.tag_filters().is_empty() { - return Ok(Vec::new()); - } - let mut first_order = Vec::new(); - let mut intersection = None::<BTreeSet<String>>; - for (name, values) in filter.tag_filters() { - let ids = self - .query_single_indexed_tag_event_ids( - name.as_str(), - &values - .iter() - .map(|value| value.as_str().to_owned()) - .collect::<Vec<_>>(), - filter, - ) - .await?; - let ids = unique_in_order(ids); - if first_order.is_empty() { - first_order = ids.clone(); - } - let current = ids.into_iter().collect::<BTreeSet<_>>(); - intersection = Some(match intersection { - Some(previous) => previous.intersection(&current).cloned().collect(), - None => current, - }); - } - let intersection = intersection.unwrap_or_default(); - let mut result = first_order - .into_iter() - .filter(|event_id| intersection.contains(event_id)) - .collect::<Vec<_>>(); - if let Some(limit) = filter.limit() { - result.truncate(limit as usize); - } - Ok(result) - } - - pub async fn maintain_current_event( - &self, - event: &Event, - ) -> Result<CurrentEventOutcome, SurrealStoreError> { - let Some(current_key) = current_event_key(event)? else { - return Ok(CurrentEventOutcome::NotCurrent); - }; - let existing = self.current_event_row(&current_key.address_key).await?; - let outcome = existing - .as_ref() - .map(|row| current_event_replacement_outcome(event, row)) - .unwrap_or(CurrentEventOutcome::Inserted); - if outcome == CurrentEventOutcome::Unchanged { - return Ok(outcome); - } - self.db - .query( - r#" -UPSERT type::record('event_current', $address_key) CONTENT { - address_key: $address_key, - kind: $kind, - pubkey: $pubkey, - d: $d, - event_id: $event_id, - created_at: $created_at, - tie_break_id: $tie_break_id, - deleted: false, - hidden: false, - updated_at: $updated_at -}; -"#, - ) - .bind(("address_key", current_key.address_key)) - .bind(("kind", event.unsigned().kind().as_u32())) - .bind(("pubkey", event.unsigned().pubkey().as_str())) - .bind(("d", current_key.d)) - .bind(("event_id", event.id().as_str())) - .bind(("created_at", event.unsigned().created_at().as_u64())) - .bind(("tie_break_id", event.id().as_str())) - .bind(("updated_at", event.unsigned().created_at().as_u64())) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - Ok(outcome) - } - - pub async fn current_event_row( - &self, - address_key: &str, - ) -> Result<Option<serde_json::Value>, SurrealStoreError> { - let mut response = self - .db - .query("SELECT * FROM ONLY type::record('event_current', $address_key);") - .bind(("address_key", address_key)) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - response.take(0).map_err(SurrealStoreError::from) - } - - pub async fn query_current_events( - &self, - filter: &Filter, - ) -> Result<Vec<serde_json::Value>, SurrealStoreError> { - let mut statement = - "SELECT * FROM event_current WHERE deleted = false AND hidden = false".to_owned(); - if !filter.ids().is_empty() { - statement.push_str(" AND event_id IN $ids"); - } - if !filter.authors().is_empty() { - statement.push_str(" AND pubkey IN $authors"); - } - if !filter.kinds().is_empty() { - statement.push_str(" AND kind IN $kinds"); - } - if filter.since().is_some() { - statement.push_str(" AND created_at >= $since"); - } - if filter.until().is_some() { - statement.push_str(" AND created_at <= $until"); - } - statement.push_str(" ORDER BY created_at DESC, event_id ASC"); - statement.push(';'); - let mut query = self.db.query(statement); - if !filter.ids().is_empty() { - query = query.bind(( - "ids", - filter - .ids() - .iter() - .map(|id| id.as_str().to_owned()) - .collect::<Vec<_>>(), - )); - } - if !filter.authors().is_empty() { - query = query.bind(( - "authors", - filter - .authors() - .iter() - .map(|pubkey| pubkey.as_str().to_owned()) - .collect::<Vec<_>>(), - )); - } - if !filter.kinds().is_empty() { - query = query.bind(( - "kinds", - filter - .kinds() - .iter() - .map(|kind| kind.as_u32()) - .collect::<Vec<_>>(), - )); - } - if let Some(since) = filter.since() { - query = query.bind(("since", since.as_u64())); - } - if let Some(until) = filter.until() { - query = query.bind(("until", until.as_u64())); - } - let mut response = query - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - let rows: Vec<serde_json::Value> = response.take(0).map_err(SurrealStoreError::from)?; - let mut events = Vec::new(); - for row in rows { - let event_id = EventId::new(&string_row_field(&row, "event_id")?) - .map_err(|source| SurrealStoreError::new(&source))?; - let Some(raw_row) = self.raw_event_row(&event_id).await? else { - continue; - }; - let raw_json = string_row_field(&raw_row, "raw_json")?; - let raw = RawEventJson::new(&raw_json) - .map_err(|source| SurrealStoreError::new(&source.to_string()))?; - let event = parse_event_json(&raw) - .map_err(|source| SurrealStoreError::new(&source.to_string()))?; - if filter.matches(&event) { - events.push(raw_row); - } - if filter - .limit() - .is_some_and(|limit| events.len() >= limit as usize) - { - break; - } - } - Ok(events) - } - - pub async fn apply_deletion_markers( - &self, - event: &Event, - ) -> Result<DeletionMarkerOutcome, SurrealStoreError> { - let Some(request) = - parse_deletion_request(event).map_err(|message| SurrealStoreError::new(&message))? - else { - return Ok(DeletionMarkerOutcome::NotDeletion); - }; - for target in request.targets() { - let (target_type, target_ref) = deletion_target_parts(target); - let marker_id = format!("{}:{}:{}", event.id().as_str(), target_type, target_ref); - self.db - .query( - r#" -UPSERT type::record('deletion_marker', $marker_id) CONTENT { - deletion_event_id: $deletion_event_id, - target_type: $target_type, - target_ref: $target_ref, - author_pubkey: $author_pubkey, - deletion_created_at: $deletion_created_at -}; -"#, - ) - .bind(("marker_id", marker_id)) - .bind(("deletion_event_id", event.id().as_str())) - .bind(("target_type", target_type)) - .bind(("target_ref", target_ref.as_str())) - .bind(("author_pubkey", event.unsigned().pubkey().as_str())) - .bind(( - "deletion_created_at", - event.unsigned().created_at().as_u64(), - )) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - if target_type == "event" { - self.mark_raw_event_deleted( - &target_ref, - event.unsigned().pubkey().as_str(), - event.unsigned().created_at().as_u64(), - ) - .await?; - } else { - self.mark_address_deleted(&target_ref, event.unsigned().pubkey().as_str()) - .await?; - } - } - Ok(DeletionMarkerOutcome::Applied { - targets: request.targets().len(), - }) - } - - pub async fn deletion_marker_rows( - &self, - deletion_event_id: &EventId, - ) -> Result<Vec<serde_json::Value>, SurrealStoreError> { - let mut response = self - .db - .query( - "SELECT * FROM deletion_marker WHERE deletion_event_id = $deletion_event_id ORDER BY target_type ASC, target_ref ASC;", - ) - .bind(("deletion_event_id", deletion_event_id.as_str())) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - response.take(0).map_err(SurrealStoreError::from) - } - - pub async fn store_listing_revision( - &self, - event: &Event, - projected_at: UnixTimestamp, - ) -> Result<ListingRevisionOutcome, SurrealStoreError> { - if !is_listing_event(event) { - return Ok(ListingRevisionOutcome::NotListing); - } - let evaluation = evaluate_listing_projection(event); - let fields = listing_revision_fields(event, &evaluation)?; - self.db - .query( - r#" -UPSERT type::record('listing_revision', $event_id) CONTENT { - revision_key: $revision_key, - listing_key: $listing_key, - event_id: $event_id, - seller_pubkey: $seller_pubkey, - d: $d, - created_at: $created_at, - parsed_ok: $parsed_ok, - parse_errors: $parse_errors, - title: $title, - summary: $summary, - price_decimal: $price_decimal, - price_minor: $price_minor, - currency_raw: $currency_raw, - currency_norm: $currency_norm, - unit: $unit, - status_tag: $status_tag, - projected_at: $projected_at -}; -"#, - ) - .bind(("event_id", event.id().as_str())) - .bind(("revision_key", fields.revision_key)) - .bind(("listing_key", fields.listing_key)) - .bind(("seller_pubkey", fields.seller_pubkey)) - .bind(("d", fields.d)) - .bind(("created_at", event.unsigned().created_at().as_u64())) - .bind(("parsed_ok", fields.parsed_ok)) - .bind(("parse_errors", fields.parse_errors)) - .bind(("title", fields.title)) - .bind(("summary", fields.summary)) - .bind(("price_decimal", fields.price_decimal)) - .bind(("price_minor", fields.price_minor)) - .bind(("currency_raw", fields.currency_raw)) - .bind(("currency_norm", fields.currency_norm)) - .bind(("unit", fields.unit)) - .bind(("status_tag", fields.status_tag)) - .bind(("projected_at", projected_at.as_u64())) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - Ok(ListingRevisionOutcome::Stored { - parsed_ok: fields.parsed_ok, - }) - } - - pub async fn listing_revision_row( - &self, - event_id: &EventId, - ) -> Result<Option<serde_json::Value>, SurrealStoreError> { - let mut response = self - .db - .query("SELECT * FROM ONLY type::record('listing_revision', $event_id);") - .bind(("event_id", event_id.as_str())) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - response.take(0).map_err(SurrealStoreError::from) - } - - pub async fn project_current_listing( - &self, - event: &Event, - projected_at: UnixTimestamp, - ) -> Result<ListingCurrentOutcome, SurrealStoreError> { - let evaluation = evaluate_listing_projection(event); - let ListingProjectionEvaluation::Eligible(projection) = evaluation else { - return Ok( - if matches!(evaluation, ListingProjectionEvaluation::NotListing) { - ListingCurrentOutcome::NotListing - } else { - ListingCurrentOutcome::Ineligible - }, - ); - }; - let fields = listing_current_fields(&projection, event, projected_at)?; - self.db - .query( - r#" -UPSERT type::record('listing_current', $listing_key) CONTENT { - listing_key: $listing_key, - listing_key_hash: $listing_key_hash, - event_id: $event_id, - seller_pubkey: $seller_pubkey, - d: $d, - created_at: $created_at, - updated_at: $updated_at, - published_at: $published_at, - title: $title, - summary: $summary, - content: $content, - price_decimal: $price_decimal, - price_minor: $price_minor, - currency_raw: $currency_raw, - currency_norm: $currency_norm, - price_frequency: $price_frequency, - unit: $unit, - unit_family: $unit_family, - location_text: $location_text, - geohash: $geohash, - geohash4: $geohash4, - geohash5: $geohash5, - geohash6: $geohash6, - geohash7: $geohash7, - point: $point, - status_tag: $status_tag, - effective_status: $effective_status, - categories: $categories, - tags: $tags, - practices: $practices, - certifications: $certifications, - image_urls: $image_urls, - pickup_available: $pickup_available, - delivery_available: $delivery_available, - shipping_available: $shipping_available, - delivery_only: $delivery_only, - seller_trust_score: $seller_trust_score, - hidden: false, - deleted: false, - projected_at: $projected_at -}; -"#, - ) - .bind(("listing_key", fields.listing_key)) - .bind(("listing_key_hash", fields.listing_key_hash)) - .bind(("event_id", event.id().as_str())) - .bind(("seller_pubkey", fields.seller_pubkey)) - .bind(("d", fields.d)) - .bind(("created_at", event.unsigned().created_at().as_u64())) - .bind(("updated_at", event.unsigned().created_at().as_u64())) - .bind(("published_at", fields.published_at)) - .bind(("title", fields.title)) - .bind(("summary", fields.summary)) - .bind(("content", fields.content)) - .bind(("price_decimal", fields.price_decimal)) - .bind(("price_minor", fields.price_minor)) - .bind(("currency_raw", fields.currency_raw)) - .bind(("currency_norm", fields.currency_norm)) - .bind(("price_frequency", fields.price_frequency)) - .bind(("unit", fields.unit.clone())) - .bind(("unit_family", fields.unit)) - .bind(("location_text", fields.location_text)) - .bind(("geohash", fields.geohash)) - .bind(("geohash4", fields.geohash4)) - .bind(("geohash5", fields.geohash5)) - .bind(("geohash6", fields.geohash6)) - .bind(("geohash7", fields.geohash7)) - .bind(("point", Option::<Vec<serde_json::Value>>::None)) - .bind(("status_tag", fields.status_tag)) - .bind(("effective_status", fields.effective_status)) - .bind(("categories", fields.categories)) - .bind(("tags", fields.tags)) - .bind(("practices", fields.practices)) - .bind(("certifications", fields.certifications)) - .bind(("image_urls", fields.image_urls)) - .bind(("pickup_available", fields.pickup_available)) - .bind(("delivery_available", fields.delivery_available)) - .bind(("shipping_available", fields.shipping_available)) - .bind(("delivery_only", fields.delivery_only)) - .bind(("seller_trust_score", Option::<i64>::None)) - .bind(("projected_at", projected_at.as_u64())) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - Ok(ListingCurrentOutcome::Projected) - } - - pub async fn listing_current_row( - &self, - listing_key: &str, - ) -> Result<Option<serde_json::Value>, SurrealStoreError> { - let mut response = self - .db - .query("SELECT * FROM ONLY type::record('listing_current', $listing_key);") - .bind(("listing_key", listing_key)) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - response.take(0).map_err(SurrealStoreError::from) - } - - pub async fn query_current_listings( - &self, - query: &ListingProjectionQuery, - ) -> Result<Vec<serde_json::Value>, SurrealStoreError> { - let mut statement = - "SELECT * FROM listing_current WHERE hidden = false AND deleted = false".to_owned(); - if query.effective_status.is_some() { - statement.push_str(" AND effective_status = $effective_status"); - } - if query.seller_pubkey.is_some() { - statement.push_str(" AND seller_pubkey = $seller_pubkey"); - } - if query.unit.is_some() { - statement.push_str(" AND unit = $unit"); - } - if query.currency_norm.is_some() { - statement.push_str(" AND currency_norm = $currency_norm"); - } - if query.min_price_minor.is_some() { - statement.push_str(" AND price_minor >= $min_price_minor"); - } - if query.max_price_minor.is_some() { - statement.push_str(" AND price_minor <= $max_price_minor"); - } - statement.push_str(" ORDER BY updated_at DESC, event_id ASC"); - if query.limit.is_some() { - statement.push_str(" LIMIT $limit"); - } - statement.push(';'); - let mut surreal_query = self.db.query(statement); - if let Some(value) = &query.effective_status { - surreal_query = surreal_query.bind(("effective_status", value.as_str())); - } - if let Some(value) = &query.seller_pubkey { - surreal_query = surreal_query.bind(("seller_pubkey", value.as_str())); - } - if let Some(value) = &query.unit { - surreal_query = surreal_query.bind(("unit", value.as_str())); - } - if let Some(value) = &query.currency_norm { - surreal_query = surreal_query.bind(("currency_norm", value.as_str())); - } - if let Some(value) = query.min_price_minor { - surreal_query = surreal_query.bind(("min_price_minor", value)); - } - if let Some(value) = query.max_price_minor { - surreal_query = surreal_query.bind(("max_price_minor", value)); - } - if let Some(value) = query.limit { - surreal_query = surreal_query.bind(("limit", value)); - } - let mut response = surreal_query - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - response.take(0).map_err(SurrealStoreError::from) - } - - pub async fn project_comment( - &self, - event: &Event, - projected_at: UnixTimestamp, - ) -> Result<CommentProjectionOutcome, SurrealStoreError> { - let comment = match parse_comment_event(event) { - Ok(Some(comment)) => comment, - Ok(None) => return Ok(CommentProjectionOutcome::NotComment), - Err(_) => return Ok(CommentProjectionOutcome::Ineligible), - }; - let fields = comment_projection_fields(&comment, projected_at); - self.db - .query( - r#" -UPSERT type::record('comment_projection', $event_id) CONTENT { - comment_id: $comment_id, - event_id: $event_id, - pubkey: $pubkey, - created_at: $created_at, - content: $content, - root_target_type: $root_target_type, - root_ref: $root_ref, - root_kind: $root_kind, - root_author: $root_author, - parent_target_type: $parent_target_type, - parent_ref: $parent_ref, - parent_kind: $parent_kind, - parent_author: $parent_author, - hidden: false, - deleted: false, - projected_at: $projected_at -}; -"#, - ) - .bind(("event_id", event.id().as_str())) - .bind(("comment_id", fields.comment_id)) - .bind(("pubkey", fields.pubkey)) - .bind(("created_at", fields.created_at)) - .bind(("content", fields.content)) - .bind(("root_target_type", fields.root_target_type)) - .bind(("root_ref", fields.root_ref)) - .bind(("root_kind", fields.root_kind)) - .bind(("root_author", fields.root_author)) - .bind(("parent_target_type", fields.parent_target_type)) - .bind(("parent_ref", fields.parent_ref)) - .bind(("parent_kind", fields.parent_kind)) - .bind(("parent_author", fields.parent_author)) - .bind(("projected_at", fields.projected_at)) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - Ok(CommentProjectionOutcome::Projected) - } - - pub async fn comment_projection_row( - &self, - event_id: &EventId, - ) -> Result<Option<serde_json::Value>, SurrealStoreError> { - let mut response = self - .db - .query("SELECT * FROM ONLY type::record('comment_projection', $event_id);") - .bind(("event_id", event_id.as_str())) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - response.take(0).map_err(SurrealStoreError::from) - } - - pub async fn query_comment_projections( - &self, - query: &CommentProjectionQuery, - ) -> Result<Vec<serde_json::Value>, SurrealStoreError> { - let mut statement = - "SELECT * FROM comment_projection WHERE hidden = false AND deleted = false".to_owned(); - if query.root_target_type.is_some() { - statement.push_str(" AND root_target_type = $root_target_type"); - } - if query.root_ref.is_some() { - statement.push_str(" AND root_ref = $root_ref"); - } - if query.parent_target_type.is_some() { - statement.push_str(" AND parent_target_type = $parent_target_type"); - } - if query.parent_ref.is_some() { - statement.push_str(" AND parent_ref = $parent_ref"); - } - if query.pubkey.is_some() { - statement.push_str(" AND pubkey = $pubkey"); - } - statement.push_str(" ORDER BY created_at ASC, event_id ASC"); - if query.limit.is_some() { - statement.push_str(" LIMIT $limit"); - } - statement.push(';'); - let mut surreal_query = self.db.query(statement); - if let Some(value) = &query.root_target_type { - surreal_query = surreal_query.bind(("root_target_type", value.as_str())); - } - if let Some(value) = &query.root_ref { - surreal_query = surreal_query.bind(("root_ref", value.as_str())); - } - if let Some(value) = &query.parent_target_type { - surreal_query = surreal_query.bind(("parent_target_type", value.as_str())); - } - if let Some(value) = &query.parent_ref { - surreal_query = surreal_query.bind(("parent_ref", value.as_str())); - } - if let Some(value) = &query.pubkey { - surreal_query = surreal_query.bind(("pubkey", value.as_str())); - } - if let Some(value) = query.limit { - surreal_query = surreal_query.bind(("limit", value)); - } - let mut response = surreal_query - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - response.take(0).map_err(SurrealStoreError::from) - } - - pub async fn project_reaction( - &self, - event: &Event, - projected_at: UnixTimestamp, - ) -> Result<ReactionProjectionOutcome, SurrealStoreError> { - let reaction = match parse_reaction_event(event) { - Ok(Some(reaction)) => reaction, - Ok(None) => return Ok(ReactionProjectionOutcome::NotReaction), - Err(_) => return Ok(ReactionProjectionOutcome::Ineligible), - }; - let fields = reaction_projection_fields(&reaction, projected_at); - self.db - .query( - r#" -UPSERT type::record('reaction_projection', $event_id) CONTENT { - reaction_id: $reaction_id, - event_id: $event_id, - pubkey: $pubkey, - created_at: $created_at, - content: $content, - value_type: $value_type, - value: $value, - target_event_id: $target_event_id, - target_pubkey: $target_pubkey, - target_address: $target_address, - target_kind: $target_kind, - hidden: false, - deleted: false, - projected_at: $projected_at -}; -"#, - ) - .bind(("event_id", event.id().as_str())) - .bind(("reaction_id", fields.reaction_id)) - .bind(("pubkey", fields.pubkey)) - .bind(("created_at", fields.created_at)) - .bind(("content", fields.content)) - .bind(("value_type", fields.value_type)) - .bind(("value", fields.value)) - .bind(("target_event_id", fields.target_event_id.as_str())) - .bind(("target_pubkey", fields.target_pubkey)) - .bind(("target_address", fields.target_address)) - .bind(("target_kind", fields.target_kind.as_deref())) - .bind(("projected_at", fields.projected_at)) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - self.recompute_reaction_count( - &fields.target_event_id, - fields.target_kind, - projected_at.as_u64(), - ) - .await?; - Ok(ReactionProjectionOutcome::Projected) - } - - pub async fn reaction_projection_row( - &self, - event_id: &EventId, - ) -> Result<Option<serde_json::Value>, SurrealStoreError> { - let mut response = self - .db - .query("SELECT * FROM ONLY type::record('reaction_projection', $event_id);") - .bind(("event_id", event_id.as_str())) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - response.take(0).map_err(SurrealStoreError::from) - } - - pub async fn reaction_count_row( - &self, - target_event_id: &EventId, - ) -> Result<Option<serde_json::Value>, SurrealStoreError> { - let mut response = self - .db - .query("SELECT * FROM ONLY type::record('reaction_count', $target_event_id);") - .bind(("target_event_id", target_event_id.as_str())) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - response.take(0).map_err(SurrealStoreError::from) - } - - pub async fn project_long_form( - &self, - event: &Event, - projected_at: UnixTimestamp, - ) -> Result<LongFormProjectionOutcome, SurrealStoreError> { - let article = match parse_long_form_event(event) { - Ok(Some(article)) => article, - Ok(None) => return Ok(LongFormProjectionOutcome::NotLongForm), - Err(_) => return Ok(LongFormProjectionOutcome::Ineligible), - }; - if article.long_form_kind() != LongFormKind::Published { - return Ok(LongFormProjectionOutcome::Ineligible); - } - let fields = long_form_projection_fields(&article, projected_at); - if self - .long_form_current_row(&fields.long_form_key) - .await? - .as_ref() - .is_some_and(|row| !long_form_current_should_replace(&article, row)) - { - return Ok(LongFormProjectionOutcome::Ineligible); - } - self.db - .query( - r#" -UPSERT type::record('long_form_current', $long_form_key) CONTENT { - long_form_key: $long_form_key, - event_id: $event_id, - author_pubkey: $author_pubkey, - d: $d, - created_at: $created_at, - updated_at: $updated_at, - published_at: $published_at, - title: $title, - image: $image, - summary: $summary, - content: $content, - tags: $tags, - referenced_events: $referenced_events, - referenced_addresses: $referenced_addresses, - referenced_pubkeys: $referenced_pubkeys, - hidden: false, - deleted: false, - projected_at: $projected_at -}; -UPSERT type::record('search_doc', $long_form_key) CONTENT { - doc_key: $long_form_key, - event_id: $event_id, - current_event_id: $event_id, - doc_type: "long_form", - kind: $kind, - pubkey: $author_pubkey, - address_key: $long_form_key, - title: $search_title, - summary: $summary, - body: $content, - category_text: $category_text, - location_text: NONE, - tags: $tags, - categories: [], - created_at: $created_at, - updated_at: $updated_at, - visible: true, - status: "published", - seller_trust_score: NONE -}; -"#, - ) - .bind(("long_form_key", fields.long_form_key.as_str())) - .bind(("event_id", fields.event_id.as_str())) - .bind(("author_pubkey", fields.author_pubkey.as_str())) - .bind(("d", fields.d.as_str())) - .bind(("created_at", fields.created_at)) - .bind(("updated_at", fields.updated_at)) - .bind(("published_at", fields.published_at)) - .bind(("title", fields.title.as_deref())) - .bind(("image", fields.image.as_deref())) - .bind(("summary", fields.summary.as_deref())) - .bind(("content", fields.content.as_str())) - .bind(("tags", fields.tags.clone())) - .bind(("referenced_events", fields.referenced_events.clone())) - .bind(("referenced_addresses", fields.referenced_addresses.clone())) - .bind(("referenced_pubkeys", fields.referenced_pubkeys.clone())) - .bind(("projected_at", fields.projected_at)) - .bind(("kind", fields.kind)) - .bind(("search_title", fields.search_title.as_str())) - .bind(("category_text", fields.tags.join(" "))) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - self.replace_long_form_topic_rows(&fields).await?; - Ok(LongFormProjectionOutcome::Projected) - } - - pub async fn long_form_current_row( - &self, - long_form_key: &str, - ) -> Result<Option<serde_json::Value>, SurrealStoreError> { - let mut response = self - .db - .query("SELECT * FROM ONLY type::record('long_form_current', $long_form_key);") - .bind(("long_form_key", long_form_key)) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - response.take(0).map_err(SurrealStoreError::from) - } - - pub async fn long_form_topic_rows( - &self, - long_form_key: &str, - ) -> Result<Vec<serde_json::Value>, SurrealStoreError> { - let mut response = self - .db - .query( - "SELECT * FROM long_form_topic WHERE long_form_key = $long_form_key ORDER BY topic ASC;", - ) - .bind(("long_form_key", long_form_key)) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - response.take(0).map_err(SurrealStoreError::from) - } - - pub async fn query_long_form_projections( - &self, - query: &LongFormProjectionQuery, - ) -> Result<Vec<serde_json::Value>, SurrealStoreError> { - let topic_keys = match query.topic.as_deref() { - Some(topic) => { - let normalized = topic.trim().to_ascii_lowercase(); - let mut response = self - .db - .query( - "SELECT VALUE long_form_key FROM long_form_topic WHERE topic = $topic AND hidden = false AND deleted = false ORDER BY updated_at DESC, event_id ASC;", - ) - .bind(("topic", normalized.as_str())) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - let keys: Vec<String> = response.take(0).map_err(SurrealStoreError::from)?; - if keys.is_empty() { - return Ok(Vec::new()); - } - Some(keys) - } - None => None, - }; - let mut statement = - "SELECT * FROM long_form_current WHERE hidden = false AND deleted = false".to_owned(); - if query.author_pubkey.is_some() { - statement.push_str(" AND author_pubkey = $author_pubkey"); - } - if topic_keys.is_some() { - statement.push_str(" AND long_form_key IN $topic_keys"); - } - statement.push_str(" ORDER BY updated_at DESC, event_id ASC"); - if query.limit.is_some() { - statement.push_str(" LIMIT $limit"); - } - statement.push(';'); - let mut surreal_query = self.db.query(statement); - if let Some(value) = &query.author_pubkey { - surreal_query = surreal_query.bind(("author_pubkey", value.as_str())); - } - if let Some(keys) = topic_keys { - surreal_query = surreal_query.bind(("topic_keys", keys)); - } - if let Some(value) = query.limit { - surreal_query = surreal_query.bind(("limit", value)); - } - let mut response = surreal_query - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - response.take(0).map_err(SurrealStoreError::from) - } - - pub async fn project_forum_thread( - &self, - event: &Event, - projected_at: UnixTimestamp, - ) -> Result<ForumThreadProjectionOutcome, SurrealStoreError> { - let thread = match parse_forum_thread_event(event) { - Ok(Some(thread)) => thread, - Ok(None) => return Ok(ForumThreadProjectionOutcome::NotForumThread), - Err(_) => return Ok(ForumThreadProjectionOutcome::Ineligible), - }; - let fields = forum_thread_projection_fields(&thread, projected_at); - self.db - .query( - r#" -UPSERT type::record('forum_thread_projection', $thread_id) CONTENT { - thread_id: $thread_id, - event_id: $event_id, - pubkey: $pubkey, - created_at: $created_at, - updated_at: $updated_at, - title: $title, - content: $content, - tags: $tags, - referenced_events: $referenced_events, - referenced_pubkeys: $referenced_pubkeys, - hidden: false, - deleted: false, - projected_at: $projected_at -}; -UPSERT type::record('search_doc', $thread_id) CONTENT { - doc_key: $thread_id, - event_id: $event_id, - current_event_id: $event_id, - doc_type: "forum_thread", - kind: $kind, - pubkey: $pubkey, - address_key: NONE, - title: $search_title, - summary: $title, - body: $content, - category_text: $category_text, - location_text: NONE, - tags: $tags, - categories: [], - created_at: $created_at, - updated_at: $updated_at, - visible: true, - status: "open", - seller_trust_score: NONE -}; -"#, - ) - .bind(("thread_id", fields.thread_id.as_str())) - .bind(("event_id", fields.event_id.as_str())) - .bind(("pubkey", fields.pubkey.as_str())) - .bind(("created_at", fields.created_at)) - .bind(("updated_at", fields.updated_at)) - .bind(("title", fields.title.as_deref())) - .bind(("content", fields.content.as_str())) - .bind(("tags", fields.tags.clone())) - .bind(("referenced_events", fields.referenced_events.clone())) - .bind(("referenced_pubkeys", fields.referenced_pubkeys.clone())) - .bind(("projected_at", fields.projected_at)) - .bind(("kind", fields.kind)) - .bind(("search_title", fields.search_title.as_str())) - .bind(("category_text", fields.tags.join(" "))) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - self.replace_forum_thread_topic_rows(&fields).await?; - Ok(ForumThreadProjectionOutcome::Projected) - } - - pub async fn forum_thread_row( - &self, - thread_id: &EventId, - ) -> Result<Option<serde_json::Value>, SurrealStoreError> { - let mut response = self - .db - .query("SELECT * FROM ONLY type::record('forum_thread_projection', $thread_id);") - .bind(("thread_id", thread_id.as_str())) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - response.take(0).map_err(SurrealStoreError::from) - } - - pub async fn forum_thread_topic_rows( - &self, - thread_id: &EventId, - ) -> Result<Vec<serde_json::Value>, SurrealStoreError> { - let mut response = self - .db - .query( - "SELECT * FROM forum_thread_topic WHERE thread_id = $thread_id ORDER BY topic ASC;", - ) - .bind(("thread_id", thread_id.as_str())) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - response.take(0).map_err(SurrealStoreError::from) - } - - pub async fn query_forum_threads( - &self, - query: &ForumThreadProjectionQuery, - ) -> Result<Vec<serde_json::Value>, SurrealStoreError> { - let topic_thread_ids = match query.topic.as_deref() { - Some(topic) => { - let normalized = topic.trim().to_ascii_lowercase(); - let mut response = self - .db - .query( - "SELECT VALUE thread_id FROM forum_thread_topic WHERE topic = $topic AND hidden = false AND deleted = false ORDER BY updated_at DESC, event_id ASC;", - ) - .bind(("topic", normalized.as_str())) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - let ids: Vec<String> = response.take(0).map_err(SurrealStoreError::from)?; - if ids.is_empty() { - return Ok(Vec::new()); - } - Some(ids) - } - None => None, - }; - let mut statement = - "SELECT * FROM forum_thread_projection WHERE hidden = false AND deleted = false" - .to_owned(); - if query.pubkey.is_some() { - statement.push_str(" AND pubkey = $pubkey"); - } - if topic_thread_ids.is_some() { - statement.push_str(" AND thread_id IN $topic_thread_ids"); - } - statement.push_str(" ORDER BY updated_at DESC, event_id ASC"); - if query.limit.is_some() { - statement.push_str(" LIMIT $limit"); - } - statement.push(';'); - let mut surreal_query = self.db.query(statement); - if let Some(value) = &query.pubkey { - surreal_query = surreal_query.bind(("pubkey", value.as_str())); - } - if let Some(ids) = topic_thread_ids { - surreal_query = surreal_query.bind(("topic_thread_ids", ids)); - } - if let Some(value) = query.limit { - surreal_query = surreal_query.bind(("limit", value)); - } - let mut response = surreal_query - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - response.take(0).map_err(SurrealStoreError::from) - } - - pub async fn project_label( - &self, - event: &Event, - projected_at: UnixTimestamp, - ) -> Result<LabelProjectionOutcome, SurrealStoreError> { - let label = match parse_label_event(event) { - Ok(Some(label)) => label, - Ok(None) => return Ok(LabelProjectionOutcome::NotLabel), - Err(_) => return Ok(LabelProjectionOutcome::Ineligible), - }; - let fields = label_projection_fields(&label, projected_at); - self.db - .query("DELETE label_projection WHERE event_id = $event_id;") - .bind(("event_id", label.event_id().as_str())) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - for field in fields { - self.db - .query( - r#" -UPSERT type::record('label_projection', $label_id) CONTENT { - label_id: $label_id, - event_id: $event_id, - pubkey: $pubkey, - created_at: $created_at, - content: $content, - namespace: $namespace, - label: $label, - target_type: $target_type, - target_ref: $target_ref, - hidden: false, - deleted: false, - projected_at: $projected_at -}; -"#, - ) - .bind(("label_id", field.label_id.as_str())) - .bind(("event_id", field.event_id.as_str())) - .bind(("pubkey", field.pubkey.as_str())) - .bind(("created_at", field.created_at)) - .bind(("content", field.content.as_str())) - .bind(("namespace", field.namespace.as_str())) - .bind(("label", field.label.as_str())) - .bind(("target_type", field.target_type.as_str())) - .bind(("target_ref", field.target_ref.as_str())) - .bind(("projected_at", field.projected_at)) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - } - Ok(LabelProjectionOutcome::Projected) - } - - pub async fn label_projection_rows( - &self, - event_id: &EventId, - ) -> Result<Vec<serde_json::Value>, SurrealStoreError> { - let mut response = self - .db - .query( - "SELECT * FROM label_projection WHERE event_id = $event_id ORDER BY target_type ASC, target_ref ASC, namespace ASC, label ASC, label_id ASC;", - ) - .bind(("event_id", event_id.as_str())) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - response.take(0).map_err(SurrealStoreError::from) - } - - pub async fn query_label_projections( - &self, - query: &LabelProjectionQuery, - ) -> Result<Vec<serde_json::Value>, SurrealStoreError> { - let mut statement = - "SELECT * FROM label_projection WHERE hidden = false AND deleted = false".to_owned(); - if query.target_type.is_some() { - statement.push_str(" AND target_type = $target_type"); - } - if query.target_ref.is_some() { - statement.push_str(" AND target_ref = $target_ref"); - } - if query.namespace.is_some() { - statement.push_str(" AND namespace = $namespace"); - } - if query.label.is_some() { - statement.push_str(" AND label = $label"); - } - if query.pubkey.is_some() { - statement.push_str(" AND pubkey = $pubkey"); - } - statement.push_str(" ORDER BY created_at DESC, event_id ASC, label_id ASC"); - if query.limit.is_some() { - statement.push_str(" LIMIT $limit"); - } - statement.push(';'); - let mut surreal_query = self.db.query(statement); - if let Some(value) = &query.target_type { - surreal_query = surreal_query.bind(("target_type", value.as_str())); - } - if let Some(value) = &query.target_ref { - surreal_query = surreal_query.bind(("target_ref", value.as_str())); - } - if let Some(value) = &query.namespace { - surreal_query = surreal_query.bind(("namespace", value.as_str())); - } - if let Some(value) = &query.label { - surreal_query = surreal_query.bind(("label", value.as_str())); - } - if let Some(value) = &query.pubkey { - surreal_query = surreal_query.bind(("pubkey", value.as_str())); - } - if let Some(value) = query.limit { - surreal_query = surreal_query.bind(("limit", value)); - } - let mut response = surreal_query - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - response.take(0).map_err(SurrealStoreError::from) - } - - pub async fn project_report( - &self, - event: &Event, - projected_at: UnixTimestamp, - ) -> Result<ReportProjectionOutcome, SurrealStoreError> { - let report = match parse_report_event(event) { - Ok(Some(report)) => report, - Ok(None) => return Ok(ReportProjectionOutcome::NotReport), - Err(_) => return Ok(ReportProjectionOutcome::Ineligible), - }; - let fields = report_projection_fields(&report, projected_at); - self.db - .query("DELETE report_projection WHERE event_id = $event_id;") - .bind(("event_id", report.event_id().as_str())) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - for field in fields { - self.db - .query( - r#" -UPSERT type::record('report_projection', $report_id) CONTENT { - report_id: $report_id, - event_id: $event_id, - pubkey: $pubkey, - created_at: $created_at, - content: $content, - target_type: $target_type, - target_ref: $target_ref, - report_type: $report_type, - reported_pubkeys: $reported_pubkeys, - server_urls: $server_urls, - hidden: false, - deleted: false, - projected_at: $projected_at -}; -"#, - ) - .bind(("report_id", field.report_id.as_str())) - .bind(("event_id", field.event_id.as_str())) - .bind(("pubkey", field.pubkey.as_str())) - .bind(("created_at", field.created_at)) - .bind(("content", field.content.as_str())) - .bind(("target_type", field.target_type.as_str())) - .bind(("target_ref", field.target_ref.as_str())) - .bind(("report_type", field.report_type.as_str())) - .bind(("reported_pubkeys", field.reported_pubkeys)) - .bind(("server_urls", field.server_urls)) - .bind(("projected_at", field.projected_at)) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - } - Ok(ReportProjectionOutcome::Projected) - } - - pub async fn report_projection_rows( - &self, - event_id: &EventId, - ) -> Result<Vec<serde_json::Value>, SurrealStoreError> { - let mut response = self - .db - .query( - "SELECT * FROM report_projection WHERE event_id = $event_id ORDER BY target_type ASC, target_ref ASC, report_type ASC, report_id ASC;", - ) - .bind(("event_id", event_id.as_str())) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - response.take(0).map_err(SurrealStoreError::from) - } - - pub async fn query_report_projections( - &self, - query: &ReportProjectionQuery, - ) -> Result<Vec<serde_json::Value>, SurrealStoreError> { - let mut statement = - "SELECT * FROM report_projection WHERE hidden = false AND deleted = false".to_owned(); - if query.target_type.is_some() { - statement.push_str(" AND target_type = $target_type"); - } - if query.target_ref.is_some() { - statement.push_str(" AND target_ref = $target_ref"); - } - if query.report_type.is_some() { - statement.push_str(" AND report_type = $report_type"); - } - if query.pubkey.is_some() { - statement.push_str(" AND pubkey = $pubkey"); - } - statement.push_str(" ORDER BY created_at DESC, event_id ASC, report_id ASC"); - if query.limit.is_some() { - statement.push_str(" LIMIT $limit"); - } - statement.push(';'); - let mut surreal_query = self.db.query(statement); - if let Some(value) = &query.target_type { - surreal_query = surreal_query.bind(("target_type", value.as_str())); - } - if let Some(value) = &query.target_ref { - surreal_query = surreal_query.bind(("target_ref", value.as_str())); - } - if let Some(value) = &query.report_type { - surreal_query = surreal_query.bind(("report_type", value.as_str())); - } - if let Some(value) = &query.pubkey { - surreal_query = surreal_query.bind(("pubkey", value.as_str())); - } - if let Some(value) = query.limit { - surreal_query = surreal_query.bind(("limit", value)); - } - let mut response = surreal_query - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - response.take(0).map_err(SurrealStoreError::from) - } - - pub async fn project_seller_profile( - &self, - event: &Event, - projected_at: UnixTimestamp, - ) -> Result<SellerProfileProjectionOutcome, SurrealStoreError> { - let profile = match parse_seller_profile_event(event) { - Ok(Some(profile)) => profile, - Ok(None) => return Ok(SellerProfileProjectionOutcome::NotProfile), - Err(_) => return Ok(SellerProfileProjectionOutcome::Ineligible), - }; - let pubkey = profile.pubkey().as_str().to_owned(); - let existing = self.seller_profile_row(&pubkey).await?; - if existing - .as_ref() - .is_some_and(|row| !seller_profile_should_replace(&profile, row)) - { - return Ok(SellerProfileProjectionOutcome::Ineligible); - } - let policy = self - .seller_profile_policy_state(&pubkey, existing.as_ref()) - .await?; - let fields = seller_profile_fields( - &profile, - policy.seller_approved, - policy.blocked, - projected_at, - ); - self.db - .query( - r#" -UPSERT type::record('seller_profile', $pubkey) CONTENT { - pubkey: $pubkey, - event_id: $event_id, - created_at: $created_at, - updated_at: $updated_at, - name: $name, - display_name: $display_name, - about: $about, - picture: $picture, - website: $website, - nip05: $nip05, - lud16: $lud16, - regions: $regions, - categories: $categories, - trust_markers: $trust_markers, - seller_approved: $seller_approved, - blocked: $blocked, - hidden: false, - deleted: false, - projected_at: $projected_at -}; -"#, - ) - .bind(("pubkey", fields.pubkey.as_str())) - .bind(("event_id", fields.event_id.as_str())) - .bind(("created_at", fields.created_at)) - .bind(("updated_at", fields.updated_at)) - .bind(("name", fields.name.as_deref())) - .bind(("display_name", fields.display_name.as_deref())) - .bind(("about", fields.about.as_deref())) - .bind(("picture", fields.picture.as_deref())) - .bind(("website", fields.website.as_deref())) - .bind(("nip05", fields.nip05.as_deref())) - .bind(("lud16", fields.lud16.as_deref())) - .bind(("regions", fields.regions)) - .bind(("categories", fields.categories)) - .bind(("trust_markers", fields.trust_markers)) - .bind(("seller_approved", fields.seller_approved)) - .bind(("blocked", fields.blocked)) - .bind(("projected_at", fields.projected_at)) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - Ok(SellerProfileProjectionOutcome::Projected) - } - - pub async fn seller_profile_row( - &self, - pubkey: &str, - ) -> Result<Option<serde_json::Value>, SurrealStoreError> { - let pubkey = required_policy_text(pubkey, "seller profile pubkey")?; - let mut response = self - .db - .query("SELECT * FROM ONLY type::record('seller_profile', $pubkey);") - .bind(("pubkey", pubkey.as_str())) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - response.take(0).map_err(SurrealStoreError::from) - } - - pub async fn query_seller_profiles( - &self, - query: &SellerProfileQuery, - ) -> Result<Vec<serde_json::Value>, SurrealStoreError> { - let mut statement = - "SELECT * FROM seller_profile WHERE hidden = false AND deleted = false".to_owned(); - if query.pubkey.is_some() { - statement.push_str(" AND pubkey = $pubkey"); - } - if query.approved.is_some() { - statement.push_str(" AND seller_approved = $approved"); - } - if query.blocked.is_some() { - statement.push_str(" AND blocked = $blocked"); - } - statement.push_str(" ORDER BY updated_at DESC, pubkey ASC"); - if query.limit.is_some() { - statement.push_str(" LIMIT $limit"); - } - statement.push(';'); - let mut surreal_query = self.db.query(statement); - if let Some(value) = &query.pubkey { - surreal_query = surreal_query.bind(("pubkey", value.as_str())); - } - if let Some(value) = query.approved { - surreal_query = surreal_query.bind(("approved", value)); - } - if let Some(value) = query.blocked { - surreal_query = surreal_query.bind(("blocked", value)); - } - if let Some(value) = query.limit { - surreal_query = surreal_query.bind(("limit", value)); - } - let mut response = surreal_query - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - response.take(0).map_err(SurrealStoreError::from) - } - - pub async fn project_listing_helpers( - &self, - event: &Event, - ) -> Result<ListingHelperOutcome, SurrealStoreError> { - let evaluation = evaluate_listing_projection(event); - let ListingProjectionEvaluation::Eligible(projection) = evaluation else { - return Ok( - if matches!(evaluation, ListingProjectionEvaluation::NotListing) { - ListingHelperOutcome::NotListing - } else { - ListingHelperOutcome::Ineligible - }, - ); - }; - let listing_key = projection.identity().address().key().to_string(); - let effective_status = projection - .status() - .effective_status() - .canonical() - .to_owned(); - let updated_at = event.unsigned().created_at().as_u64(); - let event_id = event.id().as_str(); - let helper_context = ListingHelperProjectionContext { - listing_key: &listing_key, - effective_status: &effective_status, - updated_at, - event_id, - }; - self.replace_listing_helper_rows( - "listing_category", - "category", - projection.taxonomy().categories(), - &helper_context, - ) - .await?; - let fulfillment = projection - .fulfillment() - .methods() - .iter() - .map(|method| method.canonical().to_owned()) - .collect::<Vec<_>>(); - self.replace_listing_helper_rows( - "listing_fulfillment", - "mode", - &fulfillment, - &helper_context, - ) - .await?; - self.replace_listing_helper_rows( - "listing_tag", - "tag_value", - projection.taxonomy().topics(), - &helper_context, - ) - .await?; - self.replace_listing_helper_rows( - "listing_practice", - "practice", - projection.taxonomy().practices(), - &helper_context, - ) - .await?; - self.replace_listing_helper_rows( - "listing_certification", - "certification", - projection.taxonomy().certifications(), - &helper_context, - ) - .await?; - Ok(ListingHelperOutcome::Projected) - } - - pub async fn listing_category_rows( - &self, - listing_key: &str, - ) -> Result<Vec<serde_json::Value>, SurrealStoreError> { - self.listing_helper_rows("listing_category", "category", listing_key) - .await - } - - pub async fn listing_fulfillment_rows( - &self, - listing_key: &str, - ) -> Result<Vec<serde_json::Value>, SurrealStoreError> { - self.listing_helper_rows("listing_fulfillment", "mode", listing_key) - .await - } - - pub async fn listing_topic_rows( - &self, - listing_key: &str, - ) -> Result<Vec<serde_json::Value>, SurrealStoreError> { - self.listing_helper_rows("listing_tag", "tag_value", listing_key) - .await - } - - pub async fn listing_practice_rows( - &self, - listing_key: &str, - ) -> Result<Vec<serde_json::Value>, SurrealStoreError> { - self.listing_helper_rows("listing_practice", "practice", listing_key) - .await - } - - pub async fn listing_certification_rows( - &self, - listing_key: &str, - ) -> Result<Vec<serde_json::Value>, SurrealStoreError> { - self.listing_helper_rows("listing_certification", "certification", listing_key) - .await - } - - pub async fn index_listing_search_document( - &self, - event: &Event, - ) -> Result<SearchDocumentOutcome, SurrealStoreError> { - let evaluation = evaluate_listing_projection(event); - let ListingProjectionEvaluation::Eligible(projection) = evaluation else { - return Ok( - if matches!(evaluation, ListingProjectionEvaluation::NotListing) { - SearchDocumentOutcome::NotListing - } else { - SearchDocumentOutcome::Ineligible - }, - ); - }; - let fields = search_document_fields(&projection, event); - self.db - .query( - r#" -UPSERT type::record('search_doc', $doc_key) CONTENT { - doc_key: $doc_key, - event_id: $event_id, - current_event_id: $current_event_id, - doc_type: "listing", - kind: $kind, - pubkey: $pubkey, - address_key: $address_key, - title: $title, - summary: $summary, - body: $body, - category_text: $category_text, - location_text: $location_text, - tags: $tags, - categories: $categories, - created_at: $created_at, - updated_at: $updated_at, - visible: $visible, - status: $status, - seller_trust_score: $seller_trust_score -}; -"#, - ) - .bind(("doc_key", fields.doc_key)) - .bind(("event_id", event.id().as_str())) - .bind(("current_event_id", event.id().as_str())) - .bind(("kind", event.unsigned().kind().as_u32())) - .bind(("pubkey", event.unsigned().pubkey().as_str())) - .bind(("address_key", fields.address_key)) - .bind(("title", fields.title)) - .bind(("summary", fields.summary)) - .bind(("body", fields.body)) - .bind(("category_text", fields.category_text)) - .bind(("location_text", fields.location_text)) - .bind(("tags", fields.tags)) - .bind(("categories", fields.categories)) - .bind(("created_at", event.unsigned().created_at().as_u64())) - .bind(("updated_at", event.unsigned().created_at().as_u64())) - .bind(("visible", fields.visible)) - .bind(("status", fields.status)) - .bind(("seller_trust_score", Option::<i64>::None)) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - Ok(SearchDocumentOutcome::Indexed) - } - - pub async fn search_document_row( - &self, - doc_key: &str, - ) -> Result<Option<serde_json::Value>, SurrealStoreError> { - let mut response = self - .db - .query("SELECT * FROM ONLY type::record('search_doc', $doc_key);") - .bind(("doc_key", doc_key)) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - response.take(0).map_err(SurrealStoreError::from) - } - - pub async fn query_search_documents( - &self, - query: &SearchDocumentQuery, - ) -> Result<Vec<serde_json::Value>, SurrealStoreError> { - let mut statement = if query.text.is_some() { - "SELECT *, search::score(0) AS score FROM search_doc WHERE true".to_owned() - } else { - "SELECT * FROM search_doc WHERE true".to_owned() - }; - if query.text.is_some() { - statement.push_str(" AND (title @0@ $text OR summary @1@ $text OR body @2@ $text)"); - } - if query.doc_type.is_some() { - statement.push_str(" AND doc_type = $doc_type"); - } - if query.kind.is_some() { - statement.push_str(" AND kind = $kind"); - } - if query.pubkey.is_some() { - statement.push_str(" AND pubkey = $pubkey"); - } - if query.visible.is_some() { - statement.push_str(" AND visible = $visible"); - } - if query.status.is_some() { - statement.push_str(" AND status = $status"); - } - if query.text.is_some() { - statement.push_str(" ORDER BY score DESC, updated_at DESC, event_id ASC"); - } else { - statement.push_str(" ORDER BY updated_at DESC, event_id ASC"); - } - if query.limit.is_some() { - statement.push_str(" LIMIT $limit"); - } - statement.push(';'); - let mut surreal_query = self.db.query(statement); - if let Some(value) = &query.text { - surreal_query = surreal_query.bind(("text", value.as_str())); - } - if let Some(value) = &query.doc_type { - surreal_query = surreal_query.bind(("doc_type", value.as_str())); - } - if let Some(value) = query.kind { - surreal_query = surreal_query.bind(("kind", value)); - } - if let Some(value) = &query.pubkey { - surreal_query = surreal_query.bind(("pubkey", value.as_str())); - } - if let Some(value) = query.visible { - surreal_query = surreal_query.bind(("visible", value)); - } - if let Some(value) = &query.status { - surreal_query = surreal_query.bind(("status", value.as_str())); - } - if let Some(value) = query.limit { - surreal_query = surreal_query.bind(("limit", value)); - } - let mut response = surreal_query - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - response.take(0).map_err(SurrealStoreError::from) - } - - pub async fn hide_event( - &self, - event_id: &EventId, - reason: &str, - source: &str, - admin_pubkey: &str, - created_at: UnixTimestamp, - ) -> Result<HiddenEventOutcome, SurrealStoreError> { - if self.raw_event_row(event_id).await?.is_none() { - return Ok(HiddenEventOutcome::NotFound); - } - let reason = required_policy_text(reason, "hidden event reason")?; - let source = required_policy_text(source, "hidden event source")?; - let admin_pubkey = required_policy_text(admin_pubkey, "admin pubkey")?; - self.db - .query( - r#" -UPSERT type::record('hidden_event', $event_id) CONTENT { - event_id: $event_id, - reason: $reason, - source: $source, - created_at: $created_at, - admin_pubkey: $admin_pubkey -}; -CREATE moderation_action CONTENT { - action_id: $action_id, - admin_pubkey: $admin_pubkey, - target_type: "event", - target_ref: $event_id, - action: "hide", - reason: $reason, - created_at: $created_at -}; -UPDATE nostr_event SET hidden = true WHERE event_id = $event_id; -UPDATE event_current SET hidden = true WHERE event_id = $event_id; -UPDATE listing_current SET hidden = true WHERE event_id = $event_id; -UPDATE comment_projection SET hidden = true WHERE event_id = $event_id; -UPDATE reaction_projection SET hidden = true WHERE event_id = $event_id; -UPDATE long_form_current SET hidden = true WHERE event_id = $event_id; -UPDATE long_form_topic SET hidden = true WHERE event_id = $event_id; -UPDATE forum_thread_projection SET hidden = true WHERE event_id = $event_id; -UPDATE forum_thread_topic SET hidden = true WHERE event_id = $event_id; -UPDATE label_projection SET hidden = true WHERE event_id = $event_id; -UPDATE report_projection SET hidden = true WHERE event_id = $event_id; -UPDATE seller_profile SET hidden = true WHERE event_id = $event_id; -UPDATE search_doc SET visible = false WHERE event_id = $event_id OR current_event_id = $event_id; -"#, - ) - .bind(("event_id", event_id.as_str())) - .bind(("reason", reason.as_str())) - .bind(("source", source.as_str())) - .bind(("created_at", created_at.as_u64())) - .bind(("admin_pubkey", admin_pubkey.as_str())) - .bind(( - "action_id", - moderation_action_id("hide", event_id.as_str(), admin_pubkey.as_str(), created_at), - )) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - self.refresh_reaction_count_for_event(event_id.as_str(), created_at.as_u64()) - .await?; - Ok(HiddenEventOutcome::Hidden) - } - - pub async fn unhide_event( - &self, - event_id: &EventId, - reason: &str, - admin_pubkey: &str, - created_at: UnixTimestamp, - ) -> Result<HiddenEventOutcome, SurrealStoreError> { - if self.raw_event_row(event_id).await?.is_none() { - return Ok(HiddenEventOutcome::NotFound); - } - let reason = required_policy_text(reason, "hidden event reason")?; - let admin_pubkey = required_policy_text(admin_pubkey, "admin pubkey")?; - self.db - .query( - r#" -DELETE type::record('hidden_event', $event_id); -CREATE moderation_action CONTENT { - action_id: $action_id, - admin_pubkey: $admin_pubkey, - target_type: "event", - target_ref: $event_id, - action: "unhide", - reason: $reason, - created_at: $created_at -}; -UPDATE nostr_event SET hidden = false WHERE event_id = $event_id; -UPDATE event_current SET hidden = false WHERE event_id = $event_id; -UPDATE listing_current SET hidden = false WHERE event_id = $event_id; -UPDATE comment_projection SET hidden = false WHERE event_id = $event_id; -UPDATE reaction_projection SET hidden = false WHERE event_id = $event_id; -UPDATE long_form_current SET hidden = false WHERE event_id = $event_id; -UPDATE long_form_topic SET hidden = false WHERE event_id = $event_id; -UPDATE forum_thread_projection SET hidden = false WHERE event_id = $event_id; -UPDATE forum_thread_topic SET hidden = false WHERE event_id = $event_id; -UPDATE label_projection SET hidden = false WHERE event_id = $event_id; -UPDATE report_projection SET hidden = false WHERE event_id = $event_id; -UPDATE seller_profile SET hidden = false WHERE event_id = $event_id; -UPDATE search_doc SET visible = true WHERE (event_id = $event_id OR current_event_id = $event_id) AND (status = "active" OR status = "published" OR status = "open"); -UPDATE search_doc SET visible = false WHERE (event_id = $event_id OR current_event_id = $event_id) AND status != "active" AND status != "published" AND status != "open"; -"#, - ) - .bind(("event_id", event_id.as_str())) - .bind(("reason", reason.as_str())) - .bind(("created_at", created_at.as_u64())) - .bind(("admin_pubkey", admin_pubkey.as_str())) - .bind(( - "action_id", - moderation_action_id( - "unhide", - event_id.as_str(), - admin_pubkey.as_str(), - created_at, - ), - )) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - self.refresh_reaction_count_for_event(event_id.as_str(), created_at.as_u64()) - .await?; - Ok(HiddenEventOutcome::Unhidden) - } - - pub async fn hidden_event_row( - &self, - event_id: &EventId, - ) -> Result<Option<serde_json::Value>, SurrealStoreError> { - let mut response = self - .db - .query("SELECT * FROM ONLY type::record('hidden_event', $event_id);") - .bind(("event_id", event_id.as_str())) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - response.take(0).map_err(SurrealStoreError::from) - } - - pub async fn moderation_action_rows( - &self, - target_type: &str, - target_ref: &str, - ) -> Result<Vec<serde_json::Value>, SurrealStoreError> { - let mut response = self - .db - .query( - "SELECT * FROM moderation_action WHERE target_type = $target_type AND target_ref = $target_ref ORDER BY created_at ASC, action_id ASC;", - ) - .bind(("target_type", target_type)) - .bind(("target_ref", target_ref)) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - response.take(0).map_err(SurrealStoreError::from) - } - - pub async fn relay_user_row( - &self, - pubkey: &str, - ) -> Result<Option<serde_json::Value>, SurrealStoreError> { - let pubkey = required_policy_text(pubkey, "relay user pubkey")?; - let mut response = self - .db - .query("SELECT * FROM ONLY type::record('relay_user', $pubkey);") - .bind(("pubkey", pubkey.as_str())) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - response.take(0).map_err(SurrealStoreError::from) - } - - pub async fn set_seller_approved( - &self, - pubkey: &str, - approved: bool, - updated_at: UnixTimestamp, - ) -> Result<(), SurrealStoreError> { - let pubkey = required_policy_text(pubkey, "relay user pubkey")?; - let existing = self.relay_user_row(&pubkey).await?; - let blocked = existing - .as_ref() - .and_then(|row| row.get("blocked")) - .and_then(serde_json::Value::as_bool) - .unwrap_or(false); - let created_at = existing - .as_ref() - .and_then(|row| row.get("created_at")) - .and_then(serde_json::Value::as_u64) - .unwrap_or_else(|| updated_at.as_u64()); - self.upsert_relay_user(&pubkey, "seller", approved, blocked, created_at, updated_at) - .await - } - - pub async fn set_pubkey_blocked( - &self, - pubkey: &str, - blocked: bool, - updated_at: UnixTimestamp, - ) -> Result<(), SurrealStoreError> { - let pubkey = required_policy_text(pubkey, "relay user pubkey")?; - let existing = self.relay_user_row(&pubkey).await?; - let approved = existing - .as_ref() - .and_then(|row| row.get("seller_approved")) - .and_then(serde_json::Value::as_bool) - .unwrap_or(false); - let role = existing - .as_ref() - .and_then(|row| row.get("role")) - .and_then(serde_json::Value::as_str) - .unwrap_or("seller") - .to_owned(); - let created_at = existing - .as_ref() - .and_then(|row| row.get("created_at")) - .and_then(serde_json::Value::as_u64) - .unwrap_or_else(|| updated_at.as_u64()); - self.upsert_relay_user(&pubkey, &role, approved, blocked, created_at, updated_at) - .await - } - - pub async fn check_durable_rate_limit( - &self, - key: &str, - limit: u64, - window_seconds: u64, - cost: u64, - now: UnixTimestamp, - ) -> Result<DurableRateLimitDecision, SurrealStoreError> { - let key = required_policy_text(key, "rate limit key")?; - if limit == 0 { - return Err(SurrealStoreError::new( - "rate limit must be greater than zero", - )); - } - if window_seconds == 0 { - return Err(SurrealStoreError::new( - "rate limit window must be greater than zero seconds", - )); - } - if cost == 0 { - return Err(SurrealStoreError::new( - "rate limit cost must be greater than zero", - )); - } - if cost > limit { - return Err(SurrealStoreError::new(&format!( - "rate limit cost {cost} exceeds limit {limit}" - ))); - } - let row = self.rate_limit_state_row(&key).await?; - let created_at = row - .as_ref() - .and_then(|row| row.get("created_at")) - .and_then(serde_json::Value::as_u64) - .unwrap_or_else(|| now.as_u64()); - let mut state = row - .as_ref() - .map(rate_limit_window_state_from_row) - .transpose()? - .unwrap_or_else(|| DurableRateLimitWindowState::new(now)); - state.reset_if_elapsed(now, window_seconds); - let reset_at = state.reset_at(window_seconds); - if state.used.saturating_add(cost) > limit { - return Ok(DurableRateLimitDecision::Rejected { - retry_after_seconds: reset_at.as_u64().saturating_sub(now.as_u64()), - reset_at, - }); - } - state.used += cost; - self.upsert_rate_limit_state(&key, state, reset_at, created_at, now) - .await?; - Ok(DurableRateLimitDecision::Accepted { - remaining: limit - state.used, - reset_at, - }) - } - - pub async fn rate_limit_state_row( - &self, - key: &str, - ) -> Result<Option<serde_json::Value>, SurrealStoreError> { - let key = required_policy_text(key, "rate limit key")?; - let mut response = self - .db - .query("SELECT * FROM ONLY type::record('rate_limit_state', $key);") - .bind(("key", key.as_str())) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - response.take(0).map_err(SurrealStoreError::from) - } - - pub async fn prune_expired_rate_limit_state( - &self, - now: UnixTimestamp, - ) -> Result<u64, SurrealStoreError> { - let mut response = self - .db - .query( - "DELETE rate_limit_state WHERE expires_at != NONE AND expires_at <= $now RETURN BEFORE;", - ) - .bind(("now", now.as_u64())) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - let rows = response - .take::<Vec<serde_json::Value>>(0) - .map_err(SurrealStoreError::from)?; - Ok(rows.len() as u64) - } - - async fn replace_listing_helper_rows( - &self, - table: &str, - field: &str, - values: &[String], - context: &ListingHelperProjectionContext<'_>, - ) -> Result<(), SurrealStoreError> { - let delete_query = format!("DELETE {table} WHERE listing_key = $listing_key;"); - self.db - .query(delete_query) - .bind(("listing_key", context.listing_key)) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - let create_query = format!( - "CREATE {table} CONTENT {{ listing_key: $listing_key, {field}: $value, effective_status: $effective_status, updated_at: $updated_at, event_id: $event_id }};" - ); - for value in values { - self.db - .query(create_query.as_str()) - .bind(("listing_key", context.listing_key)) - .bind(("value", value.as_str())) - .bind(("effective_status", context.effective_status)) - .bind(("updated_at", context.updated_at)) - .bind(("event_id", context.event_id)) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - } - Ok(()) - } - - async fn listing_helper_rows( - &self, - table: &str, - field: &str, - listing_key: &str, - ) -> Result<Vec<serde_json::Value>, SurrealStoreError> { - let query = - format!("SELECT * FROM {table} WHERE listing_key = $listing_key ORDER BY {field} ASC;"); - let mut response = self - .db - .query(query) - .bind(("listing_key", listing_key)) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - response.take(0).map_err(SurrealStoreError::from) - } - - async fn replace_long_form_topic_rows( - &self, - fields: &LongFormProjectionFields, - ) -> Result<(), SurrealStoreError> { - self.db - .query("DELETE long_form_topic WHERE long_form_key = $long_form_key;") - .bind(("long_form_key", fields.long_form_key.as_str())) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - for topic in &fields.tags { - self.db - .query( - r#" -CREATE long_form_topic CONTENT { - long_form_key: $long_form_key, - topic: $topic, - updated_at: $updated_at, - event_id: $event_id, - hidden: false, - deleted: false -}; -"#, - ) - .bind(("long_form_key", fields.long_form_key.as_str())) - .bind(("topic", topic.as_str())) - .bind(("updated_at", fields.updated_at)) - .bind(("event_id", fields.event_id.as_str())) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - } - Ok(()) - } - - async fn replace_forum_thread_topic_rows( - &self, - fields: &ForumThreadProjectionFields, - ) -> Result<(), SurrealStoreError> { - self.db - .query("DELETE forum_thread_topic WHERE thread_id = $thread_id;") - .bind(("thread_id", fields.thread_id.as_str())) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - for topic in &fields.tags { - self.db - .query( - r#" -CREATE forum_thread_topic CONTENT { - thread_id: $thread_id, - topic: $topic, - updated_at: $updated_at, - event_id: $event_id, - hidden: false, - deleted: false -}; -"#, - ) - .bind(("thread_id", fields.thread_id.as_str())) - .bind(("topic", topic.as_str())) - .bind(("updated_at", fields.updated_at)) - .bind(("event_id", fields.event_id.as_str())) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - } - Ok(()) - } - - async fn upsert_rate_limit_state( - &self, - key: &str, - state: DurableRateLimitWindowState, - expires_at: UnixTimestamp, - created_at: u64, - updated_at: UnixTimestamp, - ) -> Result<(), SurrealStoreError> { - self.db - .query( - r#" -UPSERT type::record('rate_limit_state', $key) CONTENT { - key: $key, - state: $state, - expires_at: $expires_at, - created_at: $created_at, - updated_at: $updated_at -}; -"#, - ) - .bind(("key", key)) - .bind(("state", state.to_json_string())) - .bind(("expires_at", expires_at.as_u64())) - .bind(("created_at", created_at)) - .bind(("updated_at", updated_at.as_u64())) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - Ok(()) - } - - async fn upsert_relay_user( - &self, - pubkey: &str, - role: &str, - seller_approved: bool, - blocked: bool, - created_at: u64, - updated_at: UnixTimestamp, - ) -> Result<(), SurrealStoreError> { - self.db - .query( - r#" -UPSERT type::record('relay_user', $pubkey) CONTENT { - pubkey: $pubkey, - role: $role, - seller_approved: $seller_approved, - blocked: $blocked, - created_at: $created_at, - updated_at: $updated_at -}; -UPDATE seller_profile SET seller_approved = $seller_approved, blocked = $blocked WHERE pubkey = $pubkey; -"#, - ) - .bind(("pubkey", pubkey)) - .bind(("role", role)) - .bind(("seller_approved", seller_approved)) - .bind(("blocked", blocked)) - .bind(("created_at", created_at)) - .bind(("updated_at", updated_at.as_u64())) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - Ok(()) - } - - async fn seller_profile_policy_state( - &self, - pubkey: &str, - existing_profile: Option<&serde_json::Value>, - ) -> Result<SellerProfilePolicyState, SurrealStoreError> { - let relay_user = self.relay_user_row(pubkey).await?; - Ok(SellerProfilePolicyState { - seller_approved: relay_user - .as_ref() - .and_then(|row| row.get("seller_approved")) - .and_then(serde_json::Value::as_bool) - .or_else(|| { - existing_profile - .and_then(|row| row.get("seller_approved")) - .and_then(serde_json::Value::as_bool) - }) - .unwrap_or(false), - blocked: relay_user - .as_ref() - .and_then(|row| row.get("blocked")) - .and_then(serde_json::Value::as_bool) - .or_else(|| { - existing_profile - .and_then(|row| row.get("blocked")) - .and_then(serde_json::Value::as_bool) - }) - .unwrap_or(false), - }) - } - - async fn refresh_reaction_count_for_event( - &self, - event_id: &str, - updated_at: u64, - ) -> Result<(), SurrealStoreError> { - let mut response = self - .db - .query("SELECT * FROM ONLY type::record('reaction_projection', $event_id);") - .bind(("event_id", event_id)) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - let row: Option<serde_json::Value> = response.take(0).map_err(SurrealStoreError::from)?; - let Some(row) = row else { - return Ok(()); - }; - let target_event_id = string_row_field(&row, "target_event_id")?; - let target_kind = optional_string_row_field(&row, "target_kind")?; - self.recompute_reaction_count(&target_event_id, target_kind, updated_at) - .await - } - - async fn recompute_reaction_count( - &self, - target_event_id: &str, - target_kind: Option<String>, - updated_at: u64, - ) -> Result<(), SurrealStoreError> { - let mut response = self - .db - .query( - "SELECT value_type FROM reaction_projection WHERE target_event_id = $target_event_id AND hidden = false AND deleted = false;", - ) - .bind(("target_event_id", target_event_id)) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - let rows: Vec<serde_json::Value> = response.take(0).map_err(SurrealStoreError::from)?; - let mut like_count = 0_i64; - let mut dislike_count = 0_i64; - let mut emoji_count = 0_i64; - let mut text_count = 0_i64; - for row in rows { - match string_row_field(&row, "value_type")?.as_str() { - "like" => like_count += 1, - "dislike" => dislike_count += 1, - "emoji" => emoji_count += 1, - "text" => text_count += 1, - _ => {} - } - } - let total_count = like_count + dislike_count + emoji_count + text_count; - self.db - .query( - r#" -UPSERT type::record('reaction_count', $target_event_id) CONTENT { - target_event_id: $target_event_id, - target_kind: $target_kind, - like_count: $like_count, - dislike_count: $dislike_count, - emoji_count: $emoji_count, - text_count: $text_count, - total_count: $total_count, - updated_at: $updated_at -}; -"#, - ) - .bind(("target_event_id", target_event_id)) - .bind(("target_kind", target_kind.as_deref())) - .bind(("like_count", like_count)) - .bind(("dislike_count", dislike_count)) - .bind(("emoji_count", emoji_count)) - .bind(("text_count", text_count)) - .bind(("total_count", total_count)) - .bind(("updated_at", updated_at)) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - Ok(()) - } - - async fn query_single_indexed_tag_event_ids( - &self, - tag: &str, - values: &[String], - filter: &Filter, - ) -> Result<Vec<String>, SurrealStoreError> { - let mut statement = - "SELECT VALUE event_id FROM event_tag_index WHERE tag = $tag AND value IN $values" - .to_owned(); - if !filter.authors().is_empty() { - statement.push_str(" AND pubkey IN $authors"); - } - if !filter.kinds().is_empty() { - statement.push_str(" AND kind IN $kinds"); - } - if filter.since().is_some() { - statement.push_str(" AND created_at >= $since"); - } - if filter.until().is_some() { - statement.push_str(" AND created_at <= $until"); - } - statement.push_str(" ORDER BY created_at DESC, event_id ASC;"); - let mut query = self - .db - .query(statement) - .bind(("tag", tag)) - .bind(("values", values.to_vec())); - if !filter.authors().is_empty() { - query = query.bind(( - "authors", - filter - .authors() - .iter() - .map(|pubkey| pubkey.as_str().to_owned()) - .collect::<Vec<_>>(), - )); - } - if !filter.kinds().is_empty() { - query = query.bind(( - "kinds", - filter - .kinds() - .iter() - .map(|kind| kind.as_u32()) - .collect::<Vec<_>>(), - )); - } - if let Some(since) = filter.since() { - query = query.bind(("since", since.as_u64())); - } - if let Some(until) = filter.until() { - query = query.bind(("until", until.as_u64())); - } - let mut response = query - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - response.take(0).map_err(SurrealStoreError::from) - } - - async fn applied_migration( - &self, - name: &str, - ) -> Result<Option<AppliedMigration>, SurrealStoreError> { - Ok(self - .applied_migrations() - .await? - .into_iter() - .find(|migration| migration.name() == name)) - } - - async fn has_migration_table(&self) -> Result<bool, SurrealStoreError> { - let mut response = self - .db - .query("INFO FOR DB;") - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - let info: Option<surrealdb::types::Value> = - response.take(0).map_err(SurrealStoreError::from)?; - Ok(info - .map(|value| format!("{value:?}").contains("migration")) - .unwrap_or(false)) - } - - async fn mark_raw_event_deleted( - &self, - event_id: &str, - author_pubkey: &str, - deleted_at: u64, - ) -> Result<(), SurrealStoreError> { - self.db - .query( - r#" -UPDATE nostr_event SET deleted = true WHERE event_id = $event_id AND pubkey = $author_pubkey; -UPDATE comment_projection SET deleted = true WHERE event_id = $event_id AND pubkey = $author_pubkey; -UPDATE reaction_projection SET deleted = true WHERE event_id = $event_id AND pubkey = $author_pubkey; -UPDATE long_form_current SET deleted = true WHERE event_id = $event_id AND author_pubkey = $author_pubkey; -UPDATE long_form_topic SET deleted = true WHERE event_id = $event_id; -UPDATE forum_thread_projection SET deleted = true WHERE event_id = $event_id AND pubkey = $author_pubkey; -UPDATE forum_thread_topic SET deleted = true WHERE event_id = $event_id; -UPDATE label_projection SET deleted = true WHERE event_id = $event_id AND pubkey = $author_pubkey; -UPDATE report_projection SET deleted = true WHERE event_id = $event_id AND pubkey = $author_pubkey; -UPDATE seller_profile SET deleted = true WHERE event_id = $event_id AND pubkey = $author_pubkey; -UPDATE search_doc SET visible = false WHERE event_id = $event_id OR current_event_id = $event_id; -"#, - ) - .bind(("event_id", event_id)) - .bind(("author_pubkey", author_pubkey)) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - self.refresh_reaction_count_for_event(event_id, deleted_at) - .await?; - Ok(()) - } - - async fn mark_address_deleted( - &self, - address_key: &str, - author_pubkey: &str, - ) -> Result<(), SurrealStoreError> { - self.db - .query( - "UPDATE nostr_event SET deleted = true WHERE address_key = $address_key AND pubkey = $author_pubkey;", - ) - .query( - "UPDATE event_current SET deleted = true WHERE address_key = $address_key AND pubkey = $author_pubkey;", - ) - .query( - "UPDATE listing_current SET deleted = true WHERE listing_key = $address_key AND seller_pubkey = $author_pubkey;", - ) - .query( - "UPDATE long_form_current SET deleted = true WHERE long_form_key = $address_key AND author_pubkey = $author_pubkey;", - ) - .query("UPDATE long_form_topic SET deleted = true WHERE long_form_key = $address_key;") - .query("UPDATE search_doc SET visible = false WHERE address_key = $address_key;") - .bind(("address_key", address_key)) - .bind(("author_pubkey", author_pubkey)) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - Ok(()) - } - - async fn record_migration( - &self, - migration: &SurrealMigration, - ) -> Result<(), SurrealStoreError> { - self.db - .query( - "CREATE migration CONTENT { name: $name, checksum: $checksum, applied_at: time::now() };", - ) - .bind(("name", migration.name())) - .bind(("checksum", migration.checksum())) - .await - .map_err(SurrealStoreError::from)? - .check() - .map_err(SurrealStoreError::from)?; - Ok(()) - } -} - -fn event_tags_json(event: &Event) -> Vec<serde_json::Value> { - event - .unsigned() - .tags() - .iter() - .map(|tag| { - serde_json::Value::Array( - tag.values() - .iter() - .map(|value| serde_json::Value::String(value.clone())) - .collect(), - ) - }) - .collect() -} - -fn count_value(value: serde_json::Value) -> Result<u64, SurrealStoreError> { - if let Some(count) = value.as_u64() { - return Ok(count); - } - if let Some(count) = value.get("count").and_then(serde_json::Value::as_u64) { - return Ok(count); - } - Err(SurrealStoreError::new( - "surreal count query returned a non-numeric count", - )) -} - -fn required_policy_text(value: &str, field: &str) -> Result<String, SurrealStoreError> { - let value = value.trim(); - if value.is_empty() { - return Err(SurrealStoreError::new(&format!( - "{field} must not be empty" - ))); - } - Ok(value.to_owned()) -} - -fn string_row_field(row: &serde_json::Value, field: &str) -> Result<String, SurrealStoreError> { - row.get(field) - .and_then(serde_json::Value::as_str) - .map(str::to_owned) - .ok_or_else(|| SurrealStoreError::new(&format!("{field} row field must be a string"))) -} - -fn optional_string_row_field( - row: &serde_json::Value, - field: &str, -) -> Result<Option<String>, SurrealStoreError> { - match row.get(field) { - Some(value) if value.is_null() => Ok(None), - Some(value) => value - .as_str() - .map(|value| Some(value.to_owned())) - .ok_or_else(|| SurrealStoreError::new(&format!("{field} row field must be a string"))), - None => Ok(None), - } -} - -fn moderation_action_id( - action: &str, - target_ref: &str, - admin_pubkey: &str, - created_at: UnixTimestamp, -) -> String { - checksum(&format!( - "{action}:{target_ref}:{admin_pubkey}:{}", - created_at.as_u64() - )) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct DurableRateLimitWindowState { - started_at: UnixTimestamp, - used: u64, -} - -impl DurableRateLimitWindowState { - fn new(started_at: UnixTimestamp) -> Self { - Self { - started_at, - used: 0, - } - } - - fn reset_at(self, window_seconds: u64) -> UnixTimestamp { - UnixTimestamp::new(self.started_at.as_u64().saturating_add(window_seconds)) - } - - fn reset_if_elapsed(&mut self, now: UnixTimestamp, window_seconds: u64) { - if now >= self.reset_at(window_seconds) || now < self.started_at { - self.started_at = now; - self.used = 0; - } - } - - fn to_json_string(self) -> String { - serde_json::json!({ - "started_at": self.started_at.as_u64(), - "used": self.used - }) - .to_string() - } -} - -fn rate_limit_window_state_from_row( - row: &serde_json::Value, -) -> Result<DurableRateLimitWindowState, SurrealStoreError> { - let raw = row - .get("state") - .and_then(serde_json::Value::as_str) - .ok_or_else(|| SurrealStoreError::new("rate limit state is invalid"))?; - let value = serde_json::from_str::<serde_json::Value>(raw) - .map_err(|_| SurrealStoreError::new("rate limit state is invalid"))?; - let started_at = value - .get("started_at") - .and_then(serde_json::Value::as_u64) - .ok_or_else(|| SurrealStoreError::new("rate limit state is invalid"))?; - let used = value - .get("used") - .and_then(serde_json::Value::as_u64) - .ok_or_else(|| SurrealStoreError::new("rate limit state is invalid"))?; - Ok(DurableRateLimitWindowState { - started_at: UnixTimestamp::new(started_at), - used, - }) -} - -fn d_tag_value(event: &Event) -> Option<String> { - event - .unsigned() - .tags() - .iter() - .find_map(|tag| tag.indexed_pair()) - .and_then(|(name, value)| (name == "d").then(|| value.to_owned())) -} - -fn address_key_value(event: &Event) -> Result<Option<String>, SurrealStoreError> { - AddressCoordinate::from_event(event) - .map(|address| address.map(|address| address.key().to_string())) - .map_err(|message| SurrealStoreError::new(&message)) -} - -struct CurrentEventKey { - address_key: String, - d: Option<String>, -} - -fn current_event_key(event: &Event) -> Result<Option<CurrentEventKey>, SurrealStoreError> { - let kind = event.unsigned().kind(); - if kind.is_addressable() { - let coordinate = AddressCoordinate::from_event(event) - .map_err(|message| SurrealStoreError::new(&message))?; - return Ok(coordinate.map(|coordinate| CurrentEventKey { - address_key: coordinate.key().to_string(), - d: Some(coordinate.d().as_str().to_owned()), - })); - } - if kind.is_replaceable() { - return Ok(Some(CurrentEventKey { - address_key: format!("{}:{}", kind.as_u32(), event.unsigned().pubkey().as_str()), - d: None, - })); - } - Ok(None) -} - -fn current_event_replacement_outcome( - event: &Event, - row: &serde_json::Value, -) -> CurrentEventOutcome { - let incoming_created_at = event.unsigned().created_at().as_u64(); - let existing_created_at = row["created_at"].as_u64().unwrap_or_default(); - let existing_event_id = row["event_id"].as_str().unwrap_or_default(); - if incoming_created_at > existing_created_at - || (incoming_created_at == existing_created_at && event.id().as_str() > existing_event_id) - { - CurrentEventOutcome::Replaced - } else { - CurrentEventOutcome::Unchanged - } -} - -fn deletion_target_parts(target: &DeletionTarget) -> (&'static str, String) { - match target { - DeletionTarget::Event(event_id) => ("event", event_id.as_str().to_owned()), - DeletionTarget::Address(address) => ("address", address.key().to_string()), - } -} - -struct ListingRevisionFields { - revision_key: String, - listing_key: String, - seller_pubkey: String, - d: String, - parsed_ok: bool, - parse_errors: Vec<String>, - title: Option<String>, - summary: Option<String>, - price_decimal: Option<String>, - price_minor: Option<i64>, - currency_raw: Option<String>, - currency_norm: Option<String>, - unit: Option<String>, - status_tag: Option<String>, -} - -struct CommentProjectionFields { - comment_id: String, - pubkey: String, - created_at: u64, - content: String, - root_target_type: String, - root_ref: String, - root_kind: String, - root_author: Option<String>, - parent_target_type: String, - parent_ref: String, - parent_kind: String, - parent_author: Option<String>, - projected_at: u64, -} - -struct ReactionProjectionFields { - reaction_id: String, - pubkey: String, - created_at: u64, - content: String, - value_type: String, - value: String, - target_event_id: String, - target_pubkey: Option<String>, - target_address: Option<String>, - target_kind: Option<String>, - projected_at: u64, -} - -struct LongFormProjectionFields { - long_form_key: String, - event_id: String, - author_pubkey: String, - d: String, - created_at: u64, - updated_at: u64, - published_at: Option<u64>, - title: Option<String>, - image: Option<String>, - summary: Option<String>, - content: String, - tags: Vec<String>, - referenced_events: Vec<String>, - referenced_addresses: Vec<String>, - referenced_pubkeys: Vec<String>, - projected_at: u64, - kind: u32, - search_title: String, -} - -struct ForumThreadProjectionFields { - thread_id: String, - event_id: String, - pubkey: String, - created_at: u64, - updated_at: u64, - title: Option<String>, - content: String, - tags: Vec<String>, - referenced_events: Vec<String>, - referenced_pubkeys: Vec<String>, - projected_at: u64, - kind: u32, - search_title: String, -} - -struct LabelProjectionFields { - label_id: String, - event_id: String, - pubkey: String, - created_at: u64, - content: String, - namespace: String, - label: String, - target_type: String, - target_ref: String, - projected_at: u64, -} - -struct ReportProjectionFields { - report_id: String, - event_id: String, - pubkey: String, - created_at: u64, - content: String, - target_type: String, - target_ref: String, - report_type: String, - reported_pubkeys: Vec<String>, - server_urls: Vec<String>, - projected_at: u64, -} - -struct SellerProfilePolicyState { - seller_approved: bool, - blocked: bool, -} - -struct SellerProfileFields { - pubkey: String, - event_id: String, - created_at: u64, - updated_at: u64, - name: Option<String>, - display_name: Option<String>, - about: Option<String>, - picture: Option<String>, - website: Option<String>, - nip05: Option<String>, - lud16: Option<String>, - regions: Vec<String>, - categories: Vec<String>, - trust_markers: Vec<String>, - seller_approved: bool, - blocked: bool, - projected_at: u64, -} - -struct ListingCurrentFields { - listing_key: String, - listing_key_hash: String, - seller_pubkey: String, - d: String, - published_at: Option<u64>, - title: String, - summary: Option<String>, - content: String, - price_decimal: String, - price_minor: i64, - currency_raw: String, - currency_norm: String, - price_frequency: Option<String>, - unit: String, - location_text: Option<String>, - geohash: Option<String>, - geohash4: Option<String>, - geohash5: Option<String>, - geohash6: Option<String>, - geohash7: Option<String>, - status_tag: Option<String>, - effective_status: String, - categories: Vec<String>, - tags: Vec<String>, - practices: Vec<String>, - certifications: Vec<String>, - image_urls: Vec<String>, - pickup_available: bool, - delivery_available: bool, - shipping_available: bool, - delivery_only: bool, -} - -struct ListingHelperProjectionContext<'a> { - listing_key: &'a str, - effective_status: &'a str, - updated_at: u64, - event_id: &'a str, -} - -fn comment_projection_fields( - comment: &CommentEvent, - projected_at: UnixTimestamp, -) -> CommentProjectionFields { - CommentProjectionFields { - comment_id: comment.event_id().as_str().to_owned(), - pubkey: comment.pubkey().as_str().to_owned(), - created_at: comment.created_at().as_u64(), - content: comment.content().to_owned(), - root_target_type: comment.root().target().target_type().to_owned(), - root_ref: comment.root().target().target_ref(), - root_kind: comment.root().kind().to_owned(), - root_author: comment - .root() - .author() - .map(|pubkey| pubkey.as_str().to_owned()), - parent_target_type: comment.parent().target().target_type().to_owned(), - parent_ref: comment.parent().target().target_ref(), - parent_kind: comment.parent().kind().to_owned(), - parent_author: comment - .parent() - .author() - .map(|pubkey| pubkey.as_str().to_owned()), - projected_at: projected_at.as_u64(), - } -} - -fn reaction_projection_fields( - reaction: &ReactionEvent, - projected_at: UnixTimestamp, -) -> ReactionProjectionFields { - ReactionProjectionFields { - reaction_id: reaction.event_id().as_str().to_owned(), - pubkey: reaction.pubkey().as_str().to_owned(), - created_at: reaction.created_at().as_u64(), - content: reaction.content().to_owned(), - value_type: reaction.value().canonical().to_owned(), - value: reaction_value_string(reaction.value()), - target_event_id: reaction.target_event_id().as_str().to_owned(), - target_pubkey: reaction - .target_pubkey() - .map(|pubkey| pubkey.as_str().to_owned()), - target_address: reaction - .target_address() - .map(|address| address.key().to_string()), - target_kind: reaction.target_kind().map(str::to_owned), - projected_at: projected_at.as_u64(), - } -} - -fn reaction_value_string(value: &ReactionValue) -> String { - match value { - ReactionValue::Like => "like".to_owned(), - ReactionValue::Dislike => "dislike".to_owned(), - ReactionValue::Emoji(value) | ReactionValue::Text(value) => value.clone(), - } -} - -fn long_form_projection_fields( - article: &LongFormEvent, - projected_at: UnixTimestamp, -) -> LongFormProjectionFields { - let d = article.d().as_str().to_owned(); - LongFormProjectionFields { - long_form_key: article.address().key().to_string(), - event_id: article.event_id().as_str().to_owned(), - author_pubkey: article.pubkey().as_str().to_owned(), - d: d.clone(), - created_at: article.created_at().as_u64(), - updated_at: article.created_at().as_u64(), - published_at: article.published_at(), - title: article.title().map(str::to_owned), - image: article.image().map(str::to_owned), - summary: article.summary().map(str::to_owned), - content: article.content().to_owned(), - tags: article.topics().to_vec(), - referenced_events: article - .referenced_events() - .iter() - .map(|event_id| event_id.as_str().to_owned()) - .collect(), - referenced_addresses: article - .referenced_addresses() - .iter() - .map(|address| address.key().to_string()) - .collect(), - referenced_pubkeys: article - .referenced_pubkeys() - .iter() - .map(|pubkey| pubkey.as_str().to_owned()) - .collect(), - projected_at: projected_at.as_u64(), - kind: article.address().kind().as_u32(), - search_title: article.title().unwrap_or(&d).to_owned(), - } -} - -fn long_form_current_should_replace(article: &LongFormEvent, row: &serde_json::Value) -> bool { - let incoming_created_at = article.created_at().as_u64(); - let existing_created_at = row["updated_at"].as_u64().unwrap_or_default(); - let existing_event_id = row["event_id"].as_str().unwrap_or_default(); - incoming_created_at > existing_created_at - || (incoming_created_at == existing_created_at - && article.event_id().as_str() > existing_event_id) -} - -fn forum_thread_projection_fields( - thread: &ForumThreadEvent, - projected_at: UnixTimestamp, -) -> ForumThreadProjectionFields { - ForumThreadProjectionFields { - thread_id: thread.event_id().as_str().to_owned(), - event_id: thread.event_id().as_str().to_owned(), - pubkey: thread.pubkey().as_str().to_owned(), - created_at: thread.created_at().as_u64(), - updated_at: thread.created_at().as_u64(), - title: thread.title().map(str::to_owned), - content: thread.content().to_owned(), - tags: thread.topics().to_vec(), - referenced_events: thread - .referenced_events() - .iter() - .map(|event_id| event_id.as_str().to_owned()) - .collect(), - referenced_pubkeys: thread - .referenced_pubkeys() - .iter() - .map(|pubkey| pubkey.as_str().to_owned()) - .collect(), - projected_at: projected_at.as_u64(), - kind: 11, - search_title: thread - .title() - .map(str::to_owned) - .unwrap_or_else(|| fallback_thread_title(thread)), - } -} - -fn fallback_thread_title(thread: &ForumThreadEvent) -> String { - let fallback = thread.content().chars().take(80).collect::<String>(); - if fallback.is_empty() { - return thread.event_id().as_str().to_owned(); - } - fallback -} - -fn label_projection_fields( - label: &LabelEvent, - projected_at: UnixTimestamp, -) -> Vec<LabelProjectionFields> { - let mut pairs = BTreeSet::new(); - for target in label.targets() { - let target_type = target.target_type().to_owned(); - let target_ref = target.target_ref(); - for value in label.labels() { - pairs.insert(( - target_type.clone(), - target_ref.clone(), - value.namespace().to_owned(), - value.value().to_owned(), - )); - } - } - pairs - .into_iter() - .map(|(target_type, target_ref, namespace, value)| { - let event_id = label.event_id().as_str().to_owned(); - let pubkey = label.pubkey().as_str().to_owned(); - LabelProjectionFields { - label_id: label_projection_id( - &event_id, - &target_type, - &target_ref, - &namespace, - &value, - ), - event_id, - pubkey, - created_at: label.created_at().as_u64(), - content: label.content().to_owned(), - namespace, - label: value, - target_type, - target_ref, - projected_at: projected_at.as_u64(), - } - }) - .collect() -} - -fn label_projection_id( - event_id: &str, - target_type: &str, - target_ref: &str, - namespace: &str, - label: &str, -) -> String { - checksum( - &serde_json::to_string(&[event_id, target_type, target_ref, namespace, label]) - .expect("label projection identity serializes"), - ) -} - -fn report_projection_fields( - report: &ReportEvent, - projected_at: UnixTimestamp, -) -> Vec<ReportProjectionFields> { - let reported_pubkeys = report - .reported_pubkeys() - .iter() - .map(|pubkey| pubkey.as_str().to_owned()) - .collect::<Vec<_>>(); - let server_urls = report.server_urls().to_vec(); - let mut pairs = BTreeSet::new(); - for target in report.targets() { - pairs.insert(report_target_parts(target)); - } - pairs - .into_iter() - .map(|(target_type, target_ref, report_type)| { - let event_id = report.event_id().as_str().to_owned(); - let pubkey = report.pubkey().as_str().to_owned(); - ReportProjectionFields { - report_id: report_projection_id(&event_id, &target_type, &target_ref, &report_type), - event_id, - pubkey, - created_at: report.created_at().as_u64(), - content: report.content().to_owned(), - target_type, - target_ref, - report_type, - reported_pubkeys: reported_pubkeys.clone(), - server_urls: server_urls.clone(), - projected_at: projected_at.as_u64(), - } - }) - .collect() -} - -fn report_target_parts(target: &ReportTarget) -> (String, String, String) { - ( - target.target_type().to_owned(), - target.target_ref().to_owned(), - target.report_type().canonical().to_owned(), - ) -} - -fn report_projection_id( - event_id: &str, - target_type: &str, - target_ref: &str, - report_type: &str, -) -> String { - checksum( - &serde_json::to_string(&[event_id, target_type, target_ref, report_type]) - .expect("report projection identity serializes"), - ) -} - -fn seller_profile_fields( - profile: &SellerProfileEvent, - seller_approved: bool, - blocked: bool, - projected_at: UnixTimestamp, -) -> SellerProfileFields { - let metadata = profile.metadata(); - SellerProfileFields { - pubkey: profile.pubkey().as_str().to_owned(), - event_id: profile.event_id().as_str().to_owned(), - created_at: profile.created_at().as_u64(), - updated_at: profile.created_at().as_u64(), - name: metadata.name().map(str::to_owned), - display_name: metadata.display_name().map(str::to_owned), - about: metadata.about().map(str::to_owned), - picture: metadata.picture().map(str::to_owned), - website: metadata.website().map(str::to_owned), - nip05: metadata.nip05().map(str::to_owned), - lud16: metadata.lud16().map(str::to_owned), - regions: profile.regions().to_vec(), - categories: profile.categories().to_vec(), - trust_markers: profile.trust_markers().to_vec(), - seller_approved, - blocked, - projected_at: projected_at.as_u64(), - } -} - -fn seller_profile_should_replace(profile: &SellerProfileEvent, row: &serde_json::Value) -> bool { - let incoming_created_at = profile.created_at().as_u64(); - let existing_created_at = row["updated_at"].as_u64().unwrap_or_default(); - let existing_event_id = row["event_id"].as_str().unwrap_or_default(); - incoming_created_at > existing_created_at - || (incoming_created_at == existing_created_at - && profile.event_id().as_str() > existing_event_id) -} - -struct SearchDocumentFields { - doc_key: String, - address_key: Option<String>, - title: String, - summary: Option<String>, - body: String, - category_text: String, - location_text: Option<String>, - tags: Vec<String>, - categories: Vec<String>, - visible: bool, - status: String, -} - -fn search_document_fields(projection: &ListingProjection, _event: &Event) -> SearchDocumentFields { - let doc_key = projection.identity().address().key().to_string(); - let status = projection - .status() - .effective_status() - .canonical() - .to_owned(); - let categories = projection.taxonomy().categories().to_vec(); - SearchDocumentFields { - address_key: Some(doc_key.clone()), - doc_key, - title: projection.text().title().to_owned(), - summary: projection.text().summary().map(str::to_owned), - body: projection.text().body().to_owned(), - category_text: categories.join(" "), - location_text: projection.location().location_text().map(str::to_owned), - tags: projection.taxonomy().topics().to_vec(), - categories, - visible: status == "active", - status, - } -} - -fn listing_current_fields( - projection: &ListingProjection, - event: &Event, - _projected_at: UnixTimestamp, -) -> Result<ListingCurrentFields, SurrealStoreError> { - let listing_key = projection.identity().address().key().to_string(); - let price_decimal = projection.price().amount().raw().to_owned(); - let price_minor = price_minor(&price_decimal).ok_or_else(|| { - SurrealStoreError::new("listing price amount must fit two decimal minor units") - })?; - Ok(ListingCurrentFields { - listing_key_hash: checksum(&listing_key), - listing_key, - seller_pubkey: projection.identity().seller_pubkey().as_str().to_owned(), - d: projection.identity().d().as_str().to_owned(), - published_at: first_tag_value(event, "published_at").and_then(|value| value.parse().ok()), - title: projection.text().title().to_owned(), - summary: projection.text().summary().map(str::to_owned), - content: projection.text().body().to_owned(), - price_decimal, - price_minor, - currency_raw: projection.price().currency().to_owned(), - currency_norm: projection.price().display_currency().to_owned(), - price_frequency: projection.price().frequency().map(str::to_owned), - unit: projection.unit().canonical().to_owned(), - location_text: projection.location().location_text().map(str::to_owned), - geohash: projection.location().geohash().map(str::to_owned), - geohash4: projection.location().geohash4().map(str::to_owned), - geohash5: projection.location().geohash5().map(str::to_owned), - geohash6: projection.location().geohash6().map(str::to_owned), - geohash7: projection.location().geohash7().map(str::to_owned), - status_tag: projection.status().raw_status().map(str::to_owned), - effective_status: projection - .status() - .effective_status() - .canonical() - .to_owned(), - categories: projection.taxonomy().categories().to_vec(), - tags: projection.taxonomy().topics().to_vec(), - practices: projection.taxonomy().practices().to_vec(), - certifications: projection.taxonomy().certifications().to_vec(), - image_urls: tag_values(event, "image"), - pickup_available: projection.fulfillment().pickup_available(), - delivery_available: projection.fulfillment().delivery_available(), - shipping_available: projection.fulfillment().shipping_available(), - delivery_only: projection.fulfillment().delivery_only(), - }) -} - -fn listing_revision_fields( - event: &Event, - evaluation: &ListingProjectionEvaluation, -) -> Result<ListingRevisionFields, SurrealStoreError> { - let d = d_tag_value(event).unwrap_or_default(); - let fallback_listing_key = format!( - "{}:{}:{}", - event.unsigned().kind().as_u32(), - event.unsigned().pubkey().as_str(), - d - ); - let listing_key = address_key_value(event)?.unwrap_or(fallback_listing_key); - let base = ListingRevisionFields { - revision_key: event.id().as_str().to_owned(), - listing_key, - seller_pubkey: event.unsigned().pubkey().as_str().to_owned(), - d, - parsed_ok: false, - parse_errors: Vec::new(), - title: first_tag_value(event, "title"), - summary: first_tag_value(event, "summary"), - price_decimal: None, - price_minor: None, - currency_raw: None, - currency_norm: None, - unit: None, - status_tag: first_tag_value(event, "status"), - }; - match evaluation { - ListingProjectionEvaluation::Eligible(projection) => Ok(ListingRevisionFields { - parsed_ok: true, - title: Some(projection.text().title().to_owned()), - summary: projection.text().summary().map(str::to_owned), - price_decimal: Some(projection.price().amount().raw().to_owned()), - price_minor: price_minor(projection.price().amount().raw()), - currency_raw: Some(projection.price().currency().to_owned()), - currency_norm: Some(projection.price().display_currency().to_owned()), - unit: Some(projection.unit().canonical().to_owned()), - status_tag: projection.status().raw_status().map(str::to_owned), - ..base - }), - ListingProjectionEvaluation::Ineligible(rejection) => Ok(ListingRevisionFields { - parse_errors: rejection.reasons().to_vec(), - ..base - }), - ListingProjectionEvaluation::NotListing => Ok(base), - } -} - -fn is_listing_event(event: &Event) -> bool { - matches!( - event.unsigned().kind().as_u32(), - NIP99_PUBLIC_LISTING_KIND | NIP99_DRAFT_LISTING_KIND - ) -} - -fn first_tag_value(event: &Event, name: &str) -> Option<String> { - event - .unsigned() - .tags() - .iter() - .find(|tag| tag.name().as_str() == name) - .and_then(|tag| tag.value()) - .map(|value| value.as_str().to_owned()) -} - -fn tag_values(event: &Event, name: &str) -> Vec<String> { - event - .unsigned() - .tags() - .iter() - .filter(|tag| tag.name().as_str() == name) - .filter_map(|tag| tag.value()) - .map(|value| value.as_str().to_owned()) - .collect() -} - -fn unique_in_order(values: Vec<String>) -> Vec<String> { - let mut seen = BTreeSet::new(); - let mut unique = Vec::new(); - for value in values { - if seen.insert(value.clone()) { - unique.push(value); - } - } - unique -} - -fn price_minor(raw: &str) -> Option<i64> { - let mut parts = raw.split('.'); - let whole = parts.next()?.parse::<i64>().ok()?; - let fraction = parts.next(); - if parts.next().is_some() { - return None; - } - match fraction { - Some(value) if value.len() <= 2 => { - let padded = format!("{value:0<2}"); - Some(whole * 100 + padded.parse::<i64>().ok()?) - } - Some(_) => None, - None => Some(whole * 100), - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SurrealStoreError { - message: String, -} - -impl SurrealStoreError { - fn new(message: &str) -> Self { - Self { - message: message.to_owned(), - } - } - - pub fn message(&self) -> &str { - &self.message - } -} - -impl fmt::Display for SurrealStoreError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(&self.message) - } -} - -impl std::error::Error for SurrealStoreError {} - -impl From<surrealdb::Error> for SurrealStoreError { - fn from(source: surrealdb::Error) -> Self { - Self::new(&source.to_string()) - } -} - -#[cfg(test)] -mod tests { - use super::{ - CommentProjectionOutcome, CommentProjectionQuery, CurrentEventOutcome, - DeletionMarkerOutcome, DurableRateLimitDecision, ForumThreadProjectionOutcome, - ForumThreadProjectionQuery, HiddenEventOutcome, LabelProjectionOutcome, - LabelProjectionQuery, ListingCurrentOutcome, ListingHelperOutcome, ListingProjectionQuery, - ListingRevisionOutcome, LongFormProjectionOutcome, LongFormProjectionQuery, - MigrationApplyOutcome, ReactionProjectionOutcome, ReportProjectionOutcome, - ReportProjectionQuery, SearchDocumentOutcome, SearchDocumentQuery, - SellerProfileProjectionOutcome, SellerProfileQuery, SurrealConfigError, - SurrealConnectionConfig, SurrealConnectionMode, SurrealMigration, SurrealMigrationError, - SurrealMigrationPlan, SurrealStore, SurrealStoreError, base_migration_plan, count_value, - fallback_thread_title, long_form_current_should_replace, migration_tracking_schema, - optional_string_row_field, required_policy_text, seller_profile_should_replace, - }; - use tangle_nips::{ - ListingProjectionEvaluation, NIP01_METADATA_KIND, NIP7D_THREAD_KIND, - NIP23_LONG_FORM_DRAFT_KIND, NIP23_LONG_FORM_KIND, NIP32_LABEL_KIND, NIP56_REPORT_KIND, - parse_forum_thread_event, parse_long_form_event, parse_seller_profile_event, - }; - use tangle_protocol::{ - Event, EventId, Kind, PublicKeyHex, SignatureHex, Tag, UnixTimestamp, UnsignedEvent, - filter_from_value, - }; - use tangle_store::{StoreEventOutcome, StoredEvent}; - use tangle_test_support::{ - FixtureKey, build_fixture_event, build_fixture_event_from_parts, valid_public_listing_spec, - }; - - #[test] - fn memory_config_normalizes_namespace_and_database() { - let config = - SurrealConnectionConfig::memory(" tangle_test ", " relay_one ").expect("memory config"); - - assert_eq!(config.mode(), &SurrealConnectionMode::Memory); - assert_eq!(config.namespace(), "tangle_test"); - assert_eq!(config.database(), "relay_one"); - } - - #[test] - fn remote_config_preserves_trimmed_endpoints() { - let rocksdb = SurrealConnectionConfig::rocksdb(" /tmp/tangle-rocksdb ", "ns", "db") - .expect("rocksdb config"); - let http = SurrealConnectionConfig::http(" http://127.0.0.1:8000 ", "ns", "db") - .expect("http config") - .with_root_credentials(" root ", " root ") - .expect("http credentials"); - let websocket = SurrealConnectionConfig::websocket(" ws://127.0.0.1:8000 ", "ns", "db") - .expect("websocket config"); - - assert_eq!( - rocksdb.mode(), - &SurrealConnectionMode::RocksDb { - path: "/tmp/tangle-rocksdb".to_owned() - } - ); - assert_eq!( - http.mode(), - &SurrealConnectionMode::Http { - endpoint: "http://127.0.0.1:8000".to_owned() - } - ); - let credentials = http.root_credentials().expect("http credentials"); - assert_eq!(credentials.username(), "root"); - assert_eq!(credentials.password(), "root"); - assert_eq!( - websocket.mode(), - &SurrealConnectionMode::WebSocket { - endpoint: "ws://127.0.0.1:8000".to_owned() - } - ); - } - - #[test] - fn config_rejects_empty_namespace_database_and_endpoint() { - assert_eq!( - SurrealConnectionConfig::memory(" ", "db") - .expect_err("namespace error") - .to_string(), - "surreal namespace must not be empty" - ); - assert_eq!( - SurrealConnectionConfig::memory("ns", "") - .expect_err("database error") - .message(), - "surreal database must not be empty" - ); - assert_eq!( - SurrealConnectionConfig::http("", "ns", "db").expect_err("endpoint error"), - SurrealConfigError { - message: "surreal http endpoint must not be empty".to_owned() - } - ); - assert_eq!( - SurrealConnectionConfig::rocksdb("", "ns", "db").expect_err("path error"), - SurrealConfigError { - message: "surreal rocksdb path must not be empty".to_owned() - } - ); - assert_eq!( - SurrealConnectionConfig::websocket(" ", "ns", "db") - .expect_err("websocket endpoint error") - .to_string(), - "surreal websocket endpoint must not be empty" - ); - assert_eq!( - SurrealConnectionConfig::http("http://127.0.0.1:8000", "ns", "db") - .expect("http config") - .with_root_credentials("", "root") - .expect_err("username error") - .to_string(), - "surreal root username must not be empty" - ); - assert_eq!( - SurrealConnectionConfig::http("http://127.0.0.1:8000", "ns", "db") - .expect("http config") - .with_root_credentials("root", " ") - .expect_err("password error") - .to_string(), - "surreal root password must not be empty" - ); - } - - #[test] - fn config_rejects_non_portable_identifiers() { - assert_eq!( - SurrealConnectionConfig::memory("tangle-test", "db") - .expect_err("namespace syntax") - .to_string(), - "surreal namespace must use ASCII letters, digits, or underscore" - ); - assert_eq!( - SurrealConnectionConfig::memory("ns", "relay.db") - .expect_err("database syntax") - .to_string(), - "surreal database must use ASCII letters, digits, or underscore" - ); - } - - #[test] - fn migration_model_normalizes_names_and_hashes_surql() { - let migration = - SurrealMigration::new(" 0001_tracking ", "DEFINE TABLE migration SCHEMAFULL;") - .expect("migration"); - - assert_eq!(migration.name(), "0001_tracking"); - assert_eq!(migration.surql(), "DEFINE TABLE migration SCHEMAFULL;"); - assert_eq!( - migration.checksum(), - "ffedba540d84072a42d0e3f97bfdc054e688667e073b879e7409dd5253c8c896" - ); - } - - #[test] - fn migration_model_rejects_invalid_name_and_body() { - assert_eq!( - SurrealMigration::new("", "RETURN true;") - .expect_err("missing name") - .to_string(), - "surreal migration name must not be empty" - ); - assert_eq!( - SurrealMigration::new("001_tracking", "RETURN true;") - .expect_err("short version") - .message(), - "surreal migration name must start with four digits" - ); - assert_eq!( - SurrealMigration::new("0001_Tracking", "RETURN true;") - .expect_err("bad label") - .to_string(), - "surreal migration label must use lowercase ASCII, digits, or underscore" - ); - assert_eq!( - SurrealMigration::new("0001_tracking", " ") - .expect_err("empty body") - .to_string(), - "surreal migration body must not be empty" - ); - } - - #[test] - fn migration_plan_preserves_order_and_lookup() { - let first = SurrealMigration::new("0001_tracking", "RETURN true;").expect("first"); - let second = SurrealMigration::new("0002_events", "RETURN false;").expect("second"); - let plan = - SurrealMigrationPlan::new(vec![first.clone(), second.clone()]).expect("ordered plan"); - - assert_eq!(plan.migrations(), &[first, second]); - assert_eq!(plan.names(), vec!["0001_tracking", "0002_events"]); - assert_eq!( - plan.find("0002_events").expect("second migration").surql(), - "RETURN false;" - ); - assert_eq!(plan.find("9999_missing"), None); - } - - #[test] - fn migration_plan_rejects_duplicate_or_descending_names() { - let first = SurrealMigration::new("0002_events", "RETURN true;").expect("first"); - let duplicate = SurrealMigration::new("0002_events", "RETURN false;").expect("duplicate"); - let descending = SurrealMigration::new("0001_tracking", "RETURN false;").expect("older"); - - assert_eq!( - SurrealMigrationPlan::new(vec![first.clone(), duplicate]) - .expect_err("duplicate") - .to_string(), - "surreal migrations must be strictly ordered by name" - ); - assert_eq!( - SurrealMigrationPlan::new(vec![first, descending]).expect_err("descending"), - SurrealMigrationError { - message: "surreal migrations must be strictly ordered by name".to_owned() - } - ); - } - - #[tokio::test] - async fn memory_store_rejects_remote_config() { - let config = - SurrealConnectionConfig::websocket("ws://127.0.0.1:8000", "ns", "db").expect("config"); - let error = SurrealStore::connect_memory(&config) - .await - .expect_err("memory mismatch"); - - assert_eq!( - error.message(), - "surreal memory connection requires memory mode config" - ); - let local_error = SurrealStore::connect_local(&config) - .await - .expect_err("remote local mismatch"); - assert_eq!( - local_error.message(), - "surreal local connection requires memory or rocksdb mode config" - ); - } - - #[tokio::test] - async fn remote_connection_requires_root_credentials_before_network_use() { - let config = - SurrealConnectionConfig::http("http://127.0.0.1:8000", "ns", "db").expect("config"); - let error = SurrealStore::connect(&config) - .await - .expect_err("missing credentials"); - - assert_eq!( - error.message(), - "surreal remote connection requires root credentials" - ); - } - - #[tokio::test] - async fn remote_connection_uses_any_engine_endpoint_with_root_credentials() { - for config in [ - SurrealConnectionConfig::http("memory", "ns", "db") - .expect("http config") - .with_root_credentials("root", "root") - .expect("http credentials"), - SurrealConnectionConfig::websocket("memory", "ns", "db") - .expect("websocket config") - .with_root_credentials("root", "root") - .expect("websocket credentials"), - ] { - SurrealStore::connect(&config) - .await - .expect("remote any connection") - .ping() - .await - .expect("remote any ping"); - } - } - - #[tokio::test] - async fn remote_connection_converts_invalid_endpoint_errors() { - let config = SurrealConnectionConfig::http("definitely-not-a-surreal-engine", "ns", "db") - .expect("http config") - .with_root_credentials("root", "root") - .expect("credentials"); - - let error = SurrealStore::connect(&config) - .await - .expect_err("invalid endpoint"); - - assert!(!error.message().is_empty()); - } - - #[test] - fn row_helper_functions_accept_supported_shapes_and_reject_malformed_values() { - assert_eq!(count_value(serde_json::json!(3)).expect("numeric count"), 3); - assert_eq!( - count_value(serde_json::json!({"count": 4})).expect("object count"), - 4 - ); - assert_eq!( - count_value(serde_json::json!({"count": "4"})) - .expect_err("bad count") - .message(), - "surreal count query returned a non-numeric count" - ); - assert_eq!( - required_policy_text(" seller ", "seller profile pubkey").expect("policy text"), - "seller" - ); - assert_eq!( - required_policy_text(" ", "seller profile pubkey") - .expect_err("empty policy text") - .message(), - "seller profile pubkey must not be empty" - ); - assert_eq!( - optional_string_row_field(&serde_json::json!({"target_kind": null}), "target_kind") - .expect("null field"), - None - ); - assert_eq!( - optional_string_row_field(&serde_json::json!({"target_kind": "30402"}), "target_kind") - .expect("string field"), - Some("30402".to_owned()) - ); - assert_eq!( - optional_string_row_field(&serde_json::json!({}), "target_kind") - .expect("missing field"), - None - ); - assert_eq!( - optional_string_row_field(&serde_json::json!({"target_kind": 30402}), "target_kind") - .expect_err("numeric field") - .message(), - "target_kind row field must be a string" - ); - } - - #[test] - fn projection_helper_edges_cover_replacement_ties_and_empty_forum_titles() { - let article_event = long_form_article(1_714_125_010, "tie-break", "Tie break", "Body", &[]); - let article = parse_long_form_event(&article_event) - .expect("long form parse") - .expect("long form"); - assert!(long_form_current_should_replace( - &article, - &serde_json::json!({ - "updated_at": 1714125010_u64, - "event_id": "0".repeat(EventId::HEX_LENGTH) - }) - )); - - let empty_thread = build_fixture_event_from_parts( - FixtureKey::Buyer, - 1_714_125_011, - u64::from(NIP7D_THREAD_KIND), - Vec::new(), - "", - ) - .expect("empty thread"); - let thread = parse_forum_thread_event(&empty_thread) - .expect("thread parse") - .expect("thread"); - assert_eq!(fallback_thread_title(&thread), empty_thread.id().as_str()); - - let profile_event = seller_profile(1_714_125_012, "tie-market", None, &[], &[], &[]); - let profile = parse_seller_profile_event(&profile_event) - .expect("profile parse") - .expect("profile"); - assert!(seller_profile_should_replace( - &profile, - &serde_json::json!({ - "updated_at": 1714125012_u64, - "event_id": "0".repeat(EventId::HEX_LENGTH) - }) - )); - } - - #[tokio::test] - async fn durable_rate_limit_rejects_invalid_window_and_cost_inputs() { - let store = memory_store().await; - let now = UnixTimestamp::new(1_714_124_500); - let cases = [ - (0, 60, 1, "rate limit must be greater than zero"), - ( - 1, - 0, - 1, - "rate limit window must be greater than zero seconds", - ), - (1, 60, 0, "rate limit cost must be greater than zero"), - (1, 60, 2, "rate limit cost 2 exceeds limit 1"), - ]; - - for (limit, window, cost, expected) in cases { - assert_eq!( - store - .check_durable_rate_limit("key", limit, window, cost, now) - .await - .expect_err(expected) - .message(), - expected - ); - } - } - - #[tokio::test] - async fn durable_rate_limit_rejects_malformed_persisted_state_rows() { - let store = memory_store().await; - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - let key = "event_write:".to_owned() + &"4".repeat(PublicKeyHex::HEX_LENGTH); - let cases = [ - "not json".to_owned(), - serde_json::json!({"started_at": "100", "used": 1}).to_string(), - serde_json::json!({"started_at": 100, "used": "1"}).to_string(), - ]; - - for state in cases { - store - .database() - .query( - r#" -UPSERT type::record('rate_limit_state', $key) CONTENT { - key: $key, - state: $state, - expires_at: 160, - created_at: 100, - updated_at: 100 -}; -"#, - ) - .bind(("key", key.as_str())) - .bind(("state", state)) - .await - .expect("upsert malformed state") - .check() - .expect("upsert malformed state check"); - - assert_eq!( - store - .check_durable_rate_limit(&key, 3, 60, 1, UnixTimestamp::new(110)) - .await - .expect_err("malformed state") - .message(), - "rate limit state is invalid" - ); - } - } - - #[tokio::test] - async fn topic_projection_queries_short_circuit_when_topic_indexes_are_empty() { - let store = memory_store().await; - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - - assert_eq!( - store - .query_long_form_projections(&LongFormProjectionQuery::new().with_topic("missing")) - .await - .expect("long form query"), - Vec::<serde_json::Value>::new() - ); - assert_eq!( - store - .query_forum_threads(&ForumThreadProjectionQuery::new().with_topic("missing")) - .await - .expect("forum query"), - Vec::<serde_json::Value>::new() - ); - } - - #[tokio::test] - async fn unhide_event_reports_not_found_before_policy_validation() { - let store = memory_store().await; - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - let missing = EventId::new(&"1".repeat(EventId::HEX_LENGTH)).expect("event id"); - - assert_eq!( - store - .unhide_event(&missing, "", "", UnixTimestamp::new(1_714_124_500)) - .await - .expect("unhide missing"), - HiddenEventOutcome::NotFound - ); - } - - #[tokio::test] - async fn hidden_event_policy_validation_rejects_blank_fields_after_event_lookup() { - let store = memory_store().await; - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let admin_pubkey = "a".repeat(PublicKeyHex::HEX_LENGTH); - store - .store_raw_event(&StoredEvent::new( - listing.clone(), - UnixTimestamp::new(1_714_124_600), - )) - .await - .expect("raw listing"); - - assert_eq!( - store - .hide_event( - listing.id(), - " ", - "admin_api", - &admin_pubkey, - UnixTimestamp::new(1_714_124_601), - ) - .await - .expect_err("blank hide reason") - .message(), - "hidden event reason must not be empty" - ); - assert_eq!( - store - .hide_event( - listing.id(), - "reason", - "", - &admin_pubkey, - UnixTimestamp::new(1_714_124_602), - ) - .await - .expect_err("blank hide source") - .message(), - "hidden event source must not be empty" - ); - assert_eq!( - store - .hide_event( - listing.id(), - "reason", - "admin_api", - " ", - UnixTimestamp::new(1_714_124_603), - ) - .await - .expect_err("blank hide admin") - .message(), - "admin pubkey must not be empty" - ); - assert_eq!( - store - .unhide_event( - listing.id(), - "", - &admin_pubkey, - UnixTimestamp::new(1_714_124_604), - ) - .await - .expect_err("blank unhide reason") - .message(), - "hidden event reason must not be empty" - ); - assert_eq!( - store - .unhide_event( - listing.id(), - "reason", - "", - UnixTimestamp::new(1_714_124_605), - ) - .await - .expect_err("blank unhide admin") - .message(), - "admin pubkey must not be empty" - ); - assert!( - store - .moderation_action_rows("event", listing.id().as_str()) - .await - .expect("actions") - .is_empty() - ); - } - - #[tokio::test] - async fn migration_tracking_schema_applies_idempotently() { - let store = memory_store().await; - let plan = base_migration_plan(); - - assert_eq!( - store.applied_migrations().await.expect("no table yet"), - Vec::new() - ); - assert_eq!( - store.apply_plan(&plan).await.expect("apply"), - vec![MigrationApplyOutcome::Applied; plan.migrations().len()] - ); - assert_eq!( - store.apply_plan(&plan).await.expect("reapply"), - vec![MigrationApplyOutcome::AlreadyApplied; plan.migrations().len()] - ); - - let migrations = store.applied_migrations().await.expect("applied rows"); - assert_eq!(migrations.len(), plan.migrations().len()); - for (applied, expected) in migrations.iter().zip(plan.migrations()) { - assert_eq!(applied.name(), expected.name()); - assert_eq!(applied.checksum(), expected.checksum()); - } - assert!(format!("{:?}", store.database()).contains("Surreal")); - } - - #[tokio::test] - async fn store_ping_confirms_database_connectivity() { - let store = memory_store().await; - - store.ping().await.expect("ping"); - } - - #[tokio::test] - async fn migration_tracking_detects_checksum_changes() { - let store = memory_store().await; - let original = migration_tracking_schema(); - let changed = SurrealMigration::new(original.name(), "DEFINE TABLE migration SCHEMALESS;") - .expect("changed"); - - assert_eq!( - store.apply_migration(&original).await.expect("apply"), - MigrationApplyOutcome::Applied - ); - assert_eq!( - store - .apply_migration(&changed) - .await - .expect_err("checksum changed") - .to_string(), - "surreal migration `0001_migration_tracking` checksum changed" - ); - } - - async fn memory_store() -> SurrealStore { - let config = SurrealConnectionConfig::memory("tangle_test", "relay").expect("config"); - SurrealStore::connect_memory(&config).await.expect("store") - } - - #[tokio::test] - async fn raw_event_schema_defines_canonical_event_table() { - let store = memory_store().await; - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - let info = store.table_info("nostr_event").await.expect("table info"); - - for expected in [ - "event_id", - "pubkey", - "created_at", - "kind", - "tags", - "content", - "sig", - "raw_json", - "received_at", - "content_len", - "tag_count", - "d_tag", - "address_key", - "deleted", - "hidden", - "rejection_reason", - "nostr_event_id_uid", - "nostr_event_kind_author_created", - ] { - assert!(info.contains(expected), "missing {expected} in {info}"); - } - assert_eq!( - store - .table_info("nostr-event") - .await - .expect_err("invalid table") - .to_string(), - "surreal table info target is invalid: surreal table must use ASCII letters, digits, or underscore" - ); - } - - #[tokio::test] - async fn event_tag_index_schema_defines_single_letter_lookup_table() { - let store = memory_store().await; - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - let info = store - .table_info("event_tag_index") - .await - .expect("table info"); - - for expected in [ - "event_id", - "kind", - "pubkey", - "created_at", - "tag", - "value", - "ordinal", - "event_tag_lookup", - "event_tag_kind_lookup", - "event_tag_event", - ] { - assert!(info.contains(expected), "missing {expected} in {info}"); - } - } - - #[tokio::test] - async fn current_event_schema_defines_replaceable_pointer_table() { - let store = memory_store().await; - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - let info = store.table_info("event_current").await.expect("table info"); - - for expected in [ - "address_key", - "kind", - "pubkey", - "d", - "event_id", - "created_at", - "tie_break_id", - "deleted", - "hidden", - "updated_at", - "event_current_address_uid", - "event_current_kind_pubkey", - "event_current_event", - ] { - assert!(info.contains(expected), "missing {expected} in {info}"); - } - } - - #[tokio::test] - async fn deletion_marker_schema_defines_author_scoped_target_table() { - let store = memory_store().await; - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - let info = store - .table_info("deletion_marker") - .await - .expect("table info"); - - for expected in [ - "deletion_event_id", - "target_type", - "target_ref", - "author_pubkey", - "deletion_created_at", - "deletion_target", - "deletion_author_target", - ] { - assert!(info.contains(expected), "missing {expected} in {info}"); - } - } - - #[tokio::test] - async fn listing_revision_schema_defines_parse_audit_table() { - let store = memory_store().await; - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - let info = store - .table_info("listing_revision") - .await - .expect("table info"); - - for expected in [ - "revision_key", - "listing_key", - "event_id", - "seller_pubkey", - "d", - "created_at", - "parsed_ok", - "parse_errors", - "title", - "summary", - "price_decimal", - "price_minor", - "currency_raw", - "currency_norm", - "unit", - "status_tag", - "projected_at", - "listing_revision_event_uid", - "listing_revision_listing_created", - "listing_revision_seller_created", - ] { - assert!(info.contains(expected), "missing {expected} in {info}"); - } - } - - #[tokio::test] - async fn comment_projection_schema_defines_threaded_comment_table() { - let store = memory_store().await; - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - let info = store - .table_info("comment_projection") - .await - .expect("table info"); - - for expected in [ - "comment_id", - "event_id", - "pubkey", - "created_at", - "content", - "root_target_type", - "root_ref", - "root_kind", - "root_author", - "parent_target_type", - "parent_ref", - "parent_kind", - "parent_author", - "hidden", - "deleted", - "projected_at", - "comment_projection_event_uid", - "comment_projection_root_lookup", - "comment_projection_parent_lookup", - "comment_projection_author_created", - ] { - assert!(info.contains(expected), "missing {expected} in {info}"); - } - } - - #[tokio::test] - async fn reaction_projection_schema_defines_reaction_and_count_tables() { - let store = memory_store().await; - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - let projection = store - .table_info("reaction_projection") - .await - .expect("projection info"); - let count = store - .table_info("reaction_count") - .await - .expect("count info"); - - for expected in [ - "reaction_id", - "event_id", - "pubkey", - "created_at", - "content", - "value_type", - "value", - "target_event_id", - "target_pubkey", - "target_address", - "target_kind", - "hidden", - "deleted", - "projected_at", - "reaction_projection_event_uid", - "reaction_projection_target_created", - "reaction_projection_author_created", - "reaction_projection_target_kind", - ] { - assert!( - projection.contains(expected), - "missing {expected} in {projection}" - ); - } - for expected in [ - "target_event_id", - "target_kind", - "like_count", - "dislike_count", - "emoji_count", - "text_count", - "total_count", - "updated_at", - "reaction_count_target_uid", - "reaction_count_kind_target", - ] { - assert!(count.contains(expected), "missing {expected} in {count}"); - } - } - - #[tokio::test] - async fn long_form_projection_schema_defines_current_and_topic_tables() { - let store = memory_store().await; - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - let current = store - .table_info("long_form_current") - .await - .expect("current info"); - let topic = store - .table_info("long_form_topic") - .await - .expect("topic info"); - - for expected in [ - "long_form_key", - "event_id", - "author_pubkey", - "d", - "created_at", - "updated_at", - "published_at", - "title", - "image", - "summary", - "content", - "tags", - "referenced_events", - "referenced_addresses", - "referenced_pubkeys", - "hidden", - "deleted", - "projected_at", - "long_form_current_key_uid", - "long_form_current_event_uid", - "long_form_current_author_updated", - "long_form_current_published_updated", - "long_form_current_visibility", - ] { - assert!( - current.contains(expected), - "missing {expected} in {current}" - ); - } - for expected in [ - "long_form_key", - "topic", - "updated_at", - "event_id", - "hidden", - "deleted", - "long_form_topic_lookup", - "long_form_topic_long_form", - ] { - assert!(topic.contains(expected), "missing {expected} in {topic}"); - } - } - - #[tokio::test] - async fn forum_thread_schema_defines_projection_and_topic_tables() { - let store = memory_store().await; - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - let projection = store - .table_info("forum_thread_projection") - .await - .expect("projection info"); - let topic = store - .table_info("forum_thread_topic") - .await - .expect("topic info"); - - for expected in [ - "thread_id", - "event_id", - "pubkey", - "created_at", - "updated_at", - "title", - "content", - "tags", - "referenced_events", - "referenced_pubkeys", - "hidden", - "deleted", - "projected_at", - "forum_thread_event_uid", - "forum_thread_pubkey_updated", - "forum_thread_visibility_updated", - ] { - assert!( - projection.contains(expected), - "missing {expected} in {projection}" - ); - } - for expected in [ - "thread_id", - "topic", - "updated_at", - "event_id", - "hidden", - "deleted", - "forum_thread_topic_lookup", - "forum_thread_topic_thread", - ] { - assert!(topic.contains(expected), "missing {expected} in {topic}"); - } - } - - #[tokio::test] - async fn label_projection_schema_defines_reviewable_label_table() { - let store = memory_store().await; - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - let info = store - .table_info("label_projection") - .await - .expect("label info"); - - for expected in [ - "label_id", - "event_id", - "pubkey", - "created_at", - "content", - "namespace", - "label", - "target_type", - "target_ref", - "hidden", - "deleted", - "projected_at", - "label_projection_label_uid", - "label_projection_event", - "label_projection_target_lookup", - "label_projection_namespace_lookup", - "label_projection_author_created", - "label_projection_visibility", - ] { - assert!(info.contains(expected), "missing {expected} in {info}"); - } - } - - #[tokio::test] - async fn report_projection_schema_defines_reviewable_report_table() { - let store = memory_store().await; - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - let info = store - .table_info("report_projection") - .await - .expect("report info"); - - for expected in [ - "report_id", - "event_id", - "pubkey", - "created_at", - "content", - "target_type", - "target_ref", - "report_type", - "reported_pubkeys", - "server_urls", - "hidden", - "deleted", - "projected_at", - "report_projection_report_uid", - "report_projection_event", - "report_projection_target_lookup", - "report_projection_type_created", - "report_projection_author_created", - "report_projection_visibility", - ] { - assert!(info.contains(expected), "missing {expected} in {info}"); - } - } - - #[tokio::test] - async fn seller_profile_schema_defines_current_seller_profile_table() { - let store = memory_store().await; - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - let info = store - .table_info("seller_profile") - .await - .expect("seller profile info"); - - for expected in [ - "pubkey", - "event_id", - "created_at", - "updated_at", - "name", - "display_name", - "about", - "picture", - "website", - "nip05", - "lud16", - "regions", - "categories", - "trust_markers", - "seller_approved", - "blocked", - "hidden", - "deleted", - "projected_at", - "seller_profile_pubkey_uid", - "seller_profile_event_uid", - "seller_profile_updated", - "seller_profile_approved_blocked", - "seller_profile_visibility", - ] { - assert!(info.contains(expected), "missing {expected} in {info}"); - } - } - - #[tokio::test] - async fn listing_current_schema_defines_marketplace_projection_table() { - let store = memory_store().await; - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - let info = store - .table_info("listing_current") - .await - .expect("table info"); - - for expected in [ - "listing_key", - "listing_key_hash", - "event_id", - "seller_pubkey", - "d", - "created_at", - "updated_at", - "published_at", - "title", - "summary", - "content", - "price_decimal", - "price_minor", - "currency_raw", - "currency_norm", - "price_frequency", - "unit", - "unit_family", - "location_text", - "geohash", - "geohash4", - "geohash5", - "geohash6", - "geohash7", - "point", - "status_tag", - "effective_status", - "categories", - "tags", - "practices", - "certifications", - "image_urls", - "pickup_available", - "delivery_available", - "shipping_available", - "delivery_only", - "seller_trust_score", - "hidden", - "deleted", - "projected_at", - "listing_key_uid", - "listing_event_uid", - "listing_price_lookup", - "listing_geo6_status", - ] { - assert!(info.contains(expected), "missing {expected} in {info}"); - } - } - - #[tokio::test] - async fn listing_helper_schemas_define_discovery_tables() { - let store = memory_store().await; - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - - for (table, value_field, lookup_index, listing_index) in [ - ( - "listing_category", - "category", - "listing_category_lookup", - "listing_category_listing", - ), - ( - "listing_fulfillment", - "mode", - "listing_fulfillment_lookup", - "listing_fulfillment_listing", - ), - ( - "listing_tag", - "tag_value", - "listing_tag_lookup", - "listing_tag_listing", - ), - ( - "listing_practice", - "practice", - "listing_practice_lookup", - "listing_practice_listing", - ), - ( - "listing_certification", - "certification", - "listing_certification_lookup", - "listing_certification_listing", - ), - ] { - let info = store.table_info(table).await.expect("table info"); - for expected in [ - "listing_key", - value_field, - "effective_status", - "updated_at", - "event_id", - lookup_index, - listing_index, - ] { - assert!( - info.contains(expected), - "missing {expected} in {table} info {info}" - ); - } - } - } - - #[tokio::test] - async fn search_document_schema_defines_listing_search_surface() { - let store = memory_store().await; - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - let info = store.table_info("search_doc").await.expect("table info"); - - for expected in [ - "doc_key", - "event_id", - "current_event_id", - "doc_type", - "kind", - "pubkey", - "address_key", - "title", - "summary", - "body", - "category_text", - "location_text", - "tags", - "categories", - "created_at", - "updated_at", - "visible", - "status", - "seller_trust_score", - "search_doc_key_uid", - "search_doc_type_visible_updated", - "search_doc_kind_visible_updated", - "search_doc_title_ft", - "tangle_listing_search", - "FULLTEXT", - "BM25", - ] { - assert!(info.contains(expected), "missing {expected} in {info}"); - } - } - - #[tokio::test] - async fn policy_schemas_define_user_moderation_and_rebuild_tables() { - let store = memory_store().await; - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - - for (table, expected) in [ - ( - "relay_user", - vec![ - "pubkey", - "role", - "seller_approved", - "blocked", - "created_at", - "updated_at", - "relay_user_pubkey_uid", - "relay_user_seller_gate", - ], - ), - ( - "hidden_event", - vec![ - "event_id", - "reason", - "source", - "created_at", - "admin_pubkey", - "hidden_event_uid", - "hidden_event_created", - ], - ), - ( - "moderation_action", - vec![ - "action_id", - "admin_pubkey", - "target_type", - "target_ref", - "action", - "reason", - "created_at", - "moderation_action_target", - "moderation_action_admin", - ], - ), - ( - "rate_limit_state", - vec![ - "key", - "state", - "expires_at", - "created_at", - "updated_at", - "rate_limit_state_key_uid", - "rate_limit_state_expires", - ], - ), - ( - "import_checkpoint", - vec![ - "name", - "offset", - "event_id", - "updated_at", - "import_checkpoint_name_uid", - ], - ), - ( - "projection_error", - vec![ - "event_id", - "projector", - "error", - "created_at", - "projection_error_event", - "projection_error_projector_created", - ], - ), - ] { - let info = store.table_info(table).await.expect("table info"); - for field in expected { - assert!( - info.contains(field), - "missing {field} in {table} info {info}" - ); - } - } - } - - #[tokio::test] - async fn store_raw_event_persists_canonical_nostr_event_row() { - let store = memory_store().await; - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - let event = build_fixture_event(&valid_public_listing_spec()).expect("event"); - let stored = StoredEvent::new(event.clone(), UnixTimestamp::new(1_714_124_500)); - - assert_eq!( - store.store_raw_event(&stored).await.expect("insert"), - StoreEventOutcome::Inserted - ); - assert_eq!( - store.store_raw_event(&stored).await.expect("duplicate"), - StoreEventOutcome::Duplicate - ); - - let row = store - .raw_event_row(event.id()) - .await - .expect("row query") - .expect("row exists"); - assert_eq!(row["event_id"], event.id().as_str()); - assert_eq!(row["pubkey"], event.unsigned().pubkey().as_str()); - assert_eq!(row["created_at"], event.unsigned().created_at().as_u64()); - assert_eq!(row["kind"], event.unsigned().kind().as_u32()); - assert_eq!(row["content"], "Sweet storage carrots."); - assert_eq!(row["sig"], event.sig().as_str()); - assert_eq!(row["received_at"], 1_714_124_500_u64); - assert_eq!(row["content_len"], 22_u64); - assert_eq!(row["tag_count"], 10_u64); - assert_eq!(row["d_tag"], "listing-a"); - assert_eq!( - row["address_key"], - format!( - "{}:{}:{}", - event.unsigned().kind().as_u32(), - event.unsigned().pubkey().as_str(), - event.unsigned().tags()[0].values()[1] - ) - ); - assert_eq!(row["deleted"], false); - assert_eq!(row["hidden"], false); - assert_eq!( - row["raw_json"] - .as_str() - .expect("raw json string") - .parse::<serde_json::Value>() - .expect("raw json parses")["id"], - event.id().as_str() - ); - assert_eq!(row["tags"].as_array().expect("tags").len(), 10); - } - - #[tokio::test] - async fn query_raw_events_applies_core_filter_constraints() { - let store = memory_store().await; - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - let pubkey_a = "1".repeat(PublicKeyHex::HEX_LENGTH); - let pubkey_b = "2".repeat(PublicKeyHex::HEX_LENGTH); - let first = synthetic_event("1", "b", &pubkey_a, 100, 1, Vec::new(), "first"); - let second = synthetic_event("2", "c", &pubkey_a, 101, 1, Vec::new(), "second"); - let third = synthetic_event("3", "d", &pubkey_a, 102, 2, Vec::new(), "third"); - let fourth = synthetic_event("4", "e", &pubkey_b, 103, 1, Vec::new(), "fourth"); - for event in [&first, &second, &third, &fourth] { - assert_eq!( - store - .store_raw_event(&StoredEvent::new(event.clone(), UnixTimestamp::new(200))) - .await - .expect("insert"), - StoreEventOutcome::Inserted - ); - } - - let filtered = filter_from_value(&serde_json::json!({ - "authors": [pubkey_a], - "kinds": [1], - "since": 100, - "until": 101, - "limit": 1 - })) - .expect("filter"); - let rows = store - .query_raw_events(&filtered) - .await - .expect("filtered rows"); - assert_eq!(rows.len(), 1); - assert_eq!(rows[0]["event_id"], second.id().as_str()); - - let id_filter = filter_from_value(&serde_json::json!({ - "ids": [first.id().as_str()] - })) - .expect("id filter"); - assert_eq!( - store.query_raw_events(&id_filter).await.expect("id rows")[0]["event_id"], - first.id().as_str() - ); - - store - .database() - .query("UPDATE nostr_event SET deleted = true WHERE event_id = $event_id;") - .bind(("event_id", first.id().as_str())) - .await - .expect("delete marker") - .check() - .expect("delete check"); - assert!( - store - .query_raw_events(&id_filter) - .await - .expect("deleted rows") - .is_empty() - ); - let backup_rows = store.backup_raw_events().await.expect("backup rows"); - assert!( - backup_rows - .iter() - .any(|row| row["event_id"] == first.id().as_str()) - ); - } - - #[tokio::test] - async fn index_event_tags_persists_single_letter_tag_rows() { - let store = memory_store().await; - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - let event = Event::new( - EventId::new(&"c".repeat(EventId::HEX_LENGTH)).expect("id"), - UnsignedEvent::new( - PublicKeyHex::new(&"d".repeat(PublicKeyHex::HEX_LENGTH)).expect("pubkey"), - UnixTimestamp::new(1_714_124_600), - Kind::new(1).expect("kind"), - vec![ - Tag::from_parts("e", &["target-event"]).expect("e"), - Tag::from_parts("A", &["upper-tag"]).expect("A"), - Tag::from_parts("topic", &["ignored"]).expect("topic"), - Tag::from_parts("p", &["target-pubkey"]).expect("p"), - ], - "tagged event", - ), - SignatureHex::new(&"e".repeat(SignatureHex::HEX_LENGTH)).expect("sig"), - ); - - store.index_event_tags(&event).await.expect("index"); - store.index_event_tags(&event).await.expect("reindex"); - - let rows = store.tag_index_rows(event.id()).await.expect("rows"); - assert_eq!(rows.len(), 3); - assert_eq!(rows[0]["tag"], "e"); - assert_eq!(rows[0]["value"], "target-event"); - assert_eq!(rows[0]["ordinal"], 0_u64); - assert_eq!(rows[1]["tag"], "A"); - assert_eq!(rows[1]["value"], "upper-tag"); - assert_eq!(rows[1]["ordinal"], 1_u64); - assert_eq!(rows[2]["tag"], "p"); - assert_eq!(rows[2]["value"], "target-pubkey"); - assert_eq!(rows[2]["kind"], 1_u64); - assert_eq!(rows[2]["pubkey"], event.unsigned().pubkey().as_str()); - assert_eq!( - rows[2]["created_at"], - event.unsigned().created_at().as_u64() - ); - } - - #[tokio::test] - async fn query_indexed_tags_intersects_filter_tag_constraints() { - let store = memory_store().await; - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - let target_event = "a".repeat(EventId::HEX_LENGTH); - let other_event = "b".repeat(EventId::HEX_LENGTH); - let pubkey_a = "1".repeat(PublicKeyHex::HEX_LENGTH); - let pubkey_b = "2".repeat(PublicKeyHex::HEX_LENGTH); - let first = synthetic_event( - "1", - "b", - &pubkey_a, - 100, - 1, - vec![ - Tag::from_parts("e", &[&target_event]).expect("e tag"), - Tag::from_parts("p", &[&pubkey_a]).expect("p tag"), - ], - "first", - ); - let second = synthetic_event( - "2", - "c", - &pubkey_b, - 101, - 1, - vec![ - Tag::from_parts("e", &[&target_event]).expect("e tag"), - Tag::from_parts("p", &[&pubkey_b]).expect("p tag"), - ], - "second", - ); - let third = synthetic_event( - "3", - "d", - &pubkey_a, - 102, - 1, - vec![ - Tag::from_parts("e", &[&other_event]).expect("e tag"), - Tag::from_parts("p", &[&pubkey_a]).expect("p tag"), - ], - "third", - ); - for event in [&first, &second, &third] { - store.index_event_tags(event).await.expect("index"); - } - - let intersection = filter_from_value(&serde_json::json!({ - "#e": [target_event], - "#p": [pubkey_a], - "authors": [pubkey_a], - "kinds": [1], - "since": 100, - "until": 102 - })) - .expect("intersection filter"); - assert_eq!( - store - .query_indexed_tag_event_ids(&intersection) - .await - .expect("intersection"), - vec![first.id().as_str().to_owned()] - ); - - let ordered = filter_from_value(&serde_json::json!({ - "#e": ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"], - "limit": 1 - })) - .expect("ordered filter"); - assert_eq!( - store - .query_indexed_tag_event_ids(&ordered) - .await - .expect("ordered"), - vec![second.id().as_str().to_owned()] - ); - assert!( - store - .query_indexed_tag_event_ids( - &filter_from_value(&serde_json::json!({"kinds": [1]})).expect("no tag filter") - ) - .await - .expect("no tags") - .is_empty() - ); - } - - #[tokio::test] - async fn maintain_current_events_tracks_replaceable_and_addressable_winners() { - let store = memory_store().await; - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - let pubkey = "a".repeat(PublicKeyHex::HEX_LENGTH); - let replaceable_key = format!("3:{pubkey}"); - let first = synthetic_event( - "1", - "b", - &pubkey, - 1_714_124_700, - 3, - Vec::new(), - "profile one", - ); - let older = synthetic_event( - "2", - "c", - &pubkey, - 1_714_124_699, - 3, - Vec::new(), - "profile older", - ); - let newer = synthetic_event( - "3", - "d", - &pubkey, - 1_714_124_701, - 3, - Vec::new(), - "profile newer", - ); - let tied_lower = synthetic_event( - "2", - "e", - &pubkey, - 1_714_124_701, - 3, - Vec::new(), - "profile tied lower", - ); - let tied_higher = synthetic_event( - "4", - "f", - &pubkey, - 1_714_124_701, - 3, - Vec::new(), - "profile tied higher", - ); - let regular = synthetic_event( - "5", - "1", - &pubkey, - 1_714_124_702, - 1, - Vec::new(), - "regular note", - ); - - assert_eq!( - store.maintain_current_event(&first).await.expect("first"), - CurrentEventOutcome::Inserted - ); - assert_eq!( - store.maintain_current_event(&older).await.expect("older"), - CurrentEventOutcome::Unchanged - ); - assert_eq!( - store.maintain_current_event(&newer).await.expect("newer"), - CurrentEventOutcome::Replaced - ); - assert_eq!( - store - .maintain_current_event(&tied_lower) - .await - .expect("tied lower"), - CurrentEventOutcome::Unchanged - ); - assert_eq!( - store - .maintain_current_event(&tied_higher) - .await - .expect("tied higher"), - CurrentEventOutcome::Replaced - ); - assert_eq!( - store - .maintain_current_event(&regular) - .await - .expect("regular"), - CurrentEventOutcome::NotCurrent - ); - - let row = store - .current_event_row(&replaceable_key) - .await - .expect("replaceable row") - .expect("replaceable row exists"); - assert_eq!(row["address_key"], replaceable_key); - assert_eq!(row["kind"], 3_u64); - assert_eq!(row["pubkey"], pubkey); - assert_eq!(row["event_id"], tied_higher.id().as_str()); - assert_eq!(row["tie_break_id"], tied_higher.id().as_str()); - assert_eq!(row["created_at"], 1_714_124_701_u64); - assert!(row["d"].is_null()); - assert_eq!(row["deleted"], false); - assert_eq!(row["hidden"], false); - - let addressable = synthetic_event( - "6", - "2", - &pubkey, - 1_714_124_703, - 30_402, - vec![Tag::from_parts("d", &["listing-a"]).expect("d tag")], - "listing projection", - ); - let addressable_key = format!("30402:{pubkey}:listing-a"); - - assert_eq!( - store - .maintain_current_event(&addressable) - .await - .expect("addressable"), - CurrentEventOutcome::Inserted - ); - - let addressable_row = store - .current_event_row(&addressable_key) - .await - .expect("addressable row") - .expect("addressable row exists"); - assert_eq!(addressable_row["address_key"], addressable_key); - assert_eq!(addressable_row["kind"], 30_402_u64); - assert_eq!(addressable_row["d"], "listing-a"); - assert_eq!(addressable_row["event_id"], addressable.id().as_str()); - } - - #[tokio::test] - async fn query_current_events_returns_replaceable_winners() { - let store = memory_store().await; - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - let pubkey_a = "1".repeat(PublicKeyHex::HEX_LENGTH); - let pubkey_b = "2".repeat(PublicKeyHex::HEX_LENGTH); - let older = synthetic_event("1", "b", &pubkey_a, 100, 3, Vec::new(), "older"); - let newer = synthetic_event("2", "c", &pubkey_a, 101, 3, Vec::new(), "newer"); - let other = synthetic_event("3", "d", &pubkey_b, 102, 3, Vec::new(), "other"); - let addressable = synthetic_event( - "4", - "e", - &pubkey_a, - 103, - 30_402, - vec![Tag::from_parts("d", &["listing-current"]).expect("d tag")], - "listing", - ); - for event in [&older, &newer, &other, &addressable] { - store - .store_raw_event(&StoredEvent::new(event.clone(), UnixTimestamp::new(200))) - .await - .expect("raw event"); - store.maintain_current_event(event).await.expect("current"); - } - - let replaceable_filter = filter_from_value(&serde_json::json!({ - "authors": [pubkey_a], - "kinds": [3], - "since": 100, - "until": 101, - "limit": 1 - })) - .expect("replaceable filter"); - let rows = store - .query_current_events(&replaceable_filter) - .await - .expect("current rows"); - assert_eq!(rows.len(), 1); - assert_eq!(rows[0]["event_id"], newer.id().as_str()); - - let kind_filter = filter_from_value(&serde_json::json!({ - "kinds": [3] - })) - .expect("kind filter"); - let rows = store - .query_current_events(&kind_filter) - .await - .expect("kind rows"); - assert_eq!(rows.len(), 2); - assert_eq!(rows[0]["event_id"], other.id().as_str()); - assert_eq!(rows[1]["event_id"], newer.id().as_str()); - assert!( - rows[0]["raw_json"] - .as_str() - .expect("raw json") - .contains("other") - ); - - let address_filter = filter_from_value(&serde_json::json!({ - "kinds": [30402], - "authors": [pubkey_a], - "#d": ["listing-current"], - "limit": 1 - })) - .expect("address filter"); - let rows = store - .query_current_events(&address_filter) - .await - .expect("address rows"); - assert_eq!(rows.len(), 1); - assert_eq!(rows[0]["event_id"], addressable.id().as_str()); - store - .database() - .query("DELETE nostr_event WHERE event_id = $event_id;") - .bind(("event_id", addressable.id().as_str())) - .await - .expect("delete raw") - .check() - .expect("delete raw check"); - let orphan_filter = filter_from_value(&serde_json::json!({ - "ids": [addressable.id().as_str()] - })) - .expect("orphan filter"); - assert!( - store - .query_current_events(&orphan_filter) - .await - .expect("orphan current") - .is_empty() - ); - - store - .database() - .query("UPDATE event_current SET deleted = true WHERE event_id = $event_id;") - .bind(("event_id", newer.id().as_str())) - .await - .expect("delete current") - .check() - .expect("delete check"); - let id_filter = filter_from_value(&serde_json::json!({ - "ids": [newer.id().as_str()] - })) - .expect("id filter"); - assert!( - store - .query_current_events(&id_filter) - .await - .expect("deleted current") - .is_empty() - ); - } - - #[tokio::test] - async fn apply_deletion_markers_persists_markers_and_author_scoped_deletes() { - let store = memory_store().await; - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - let pubkey = "a".repeat(PublicKeyHex::HEX_LENGTH); - let other_pubkey = "b".repeat(PublicKeyHex::HEX_LENGTH); - let raw = synthetic_event("7", "3", &pubkey, 1_714_124_800, 1, Vec::new(), "delete me"); - let foreign_target = - synthetic_event("8", "4", &pubkey, 1_714_124_801, 1, Vec::new(), "keep me"); - let addressable_key = format!("30402:{pubkey}:listing-delete"); - let addressable = synthetic_event( - "9", - "5", - &pubkey, - 1_714_124_802, - 30_402, - vec![Tag::from_parts("d", &["listing-delete"]).expect("d tag")], - "delete listing", - ); - for event in [&raw, &foreign_target, &addressable] { - assert_eq!( - store - .store_raw_event(&StoredEvent::new( - event.clone(), - UnixTimestamp::new(1_714_124_900) - )) - .await - .expect("raw insert"), - StoreEventOutcome::Inserted - ); - } - assert_eq!( - store - .maintain_current_event(&addressable) - .await - .expect("current"), - CurrentEventOutcome::Inserted - ); - - let deletion = synthetic_event( - "b", - "6", - &pubkey, - 1_714_124_903, - 5, - vec![ - Tag::from_parts("e", &[raw.id().as_str()]).expect("e tag"), - Tag::from_parts("a", &[&addressable_key]).expect("a tag"), - ], - "remove stale events", - ); - let not_deletion = synthetic_event( - "c", - "7", - &pubkey, - 1_714_124_904, - 1, - Vec::new(), - "plain note", - ); - let unauthorized = synthetic_event( - "d", - "8", - &other_pubkey, - 1_714_124_905, - 5, - vec![Tag::from_parts("e", &[foreign_target.id().as_str()]).expect("foreign e")], - "foreign delete", - ); - - assert_eq!( - store - .apply_deletion_markers(&not_deletion) - .await - .expect("not deletion"), - DeletionMarkerOutcome::NotDeletion - ); - assert_eq!( - store - .apply_deletion_markers(&deletion) - .await - .expect("delete"), - DeletionMarkerOutcome::Applied { targets: 2 } - ); - assert_eq!( - store - .apply_deletion_markers(&unauthorized) - .await - .expect("unauthorized"), - DeletionMarkerOutcome::Applied { targets: 1 } - ); - - let markers = store - .deletion_marker_rows(deletion.id()) - .await - .expect("markers"); - assert_eq!(markers.len(), 2); - assert_eq!(markers[0]["target_type"], "address"); - assert_eq!(markers[0]["target_ref"], addressable_key); - assert_eq!(markers[0]["author_pubkey"], pubkey); - assert_eq!(markers[1]["target_type"], "event"); - assert_eq!(markers[1]["target_ref"], raw.id().as_str()); - assert_eq!(markers[1]["deletion_created_at"], 1_714_124_903_u64); - - let unauthorized_markers = store - .deletion_marker_rows(unauthorized.id()) - .await - .expect("unauthorized markers"); - assert_eq!(unauthorized_markers.len(), 1); - - assert_eq!( - store - .raw_event_row(raw.id()) - .await - .expect("raw row") - .expect("raw exists")["deleted"], - true - ); - assert_eq!( - store - .raw_event_row(addressable.id()) - .await - .expect("address row") - .expect("address exists")["deleted"], - true - ); - assert_eq!( - store - .current_event_row(&addressable_key) - .await - .expect("current row") - .expect("current exists")["deleted"], - true - ); - assert_eq!( - store - .raw_event_row(foreign_target.id()) - .await - .expect("foreign row") - .expect("foreign exists")["deleted"], - false - ); - } - - #[tokio::test] - async fn store_listing_revisions_persists_projection_audit_rows() { - let store = memory_store().await; - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - let projected_at = UnixTimestamp::new(1_714_125_000); - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - - assert_eq!( - store - .store_listing_revision(&listing, projected_at) - .await - .expect("valid revision"), - ListingRevisionOutcome::Stored { parsed_ok: true } - ); - - let row = store - .listing_revision_row(listing.id()) - .await - .expect("valid row") - .expect("valid row exists"); - assert_eq!(row["revision_key"], listing.id().as_str()); - assert_eq!( - row["listing_key"], - format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str()) - ); - assert_eq!(row["event_id"], listing.id().as_str()); - assert_eq!(row["seller_pubkey"], listing.unsigned().pubkey().as_str()); - assert_eq!(row["d"], "listing-a"); - assert_eq!(row["created_at"], 1_714_124_433_u64); - assert_eq!(row["parsed_ok"], true); - assert_eq!(row["parse_errors"].as_array().expect("errors").len(), 0); - assert_eq!(row["title"], "Carrot bunches"); - assert!(row["summary"].is_null()); - assert_eq!(row["price_decimal"], "12.50"); - assert_eq!(row["price_minor"], 1_250_u64); - assert_eq!(row["currency_raw"], "USD"); - assert_eq!(row["currency_norm"], "USD"); - assert_eq!(row["unit"], "lb"); - assert!(row["status_tag"].is_null()); - assert_eq!(row["projected_at"], projected_at.as_u64()); - - let pubkey = "c".repeat(PublicKeyHex::HEX_LENGTH); - let invalid = synthetic_event( - "e", - "9", - &pubkey, - 1_714_125_010, - 30_402, - vec![Tag::from_parts("d", &["listing-invalid"]).expect("d tag")], - "", - ); - let note = synthetic_event( - "f", - "a", - &pubkey, - 1_714_125_011, - 1, - Vec::new(), - "not a listing", - ); - - assert_eq!( - store - .store_listing_revision(&invalid, projected_at) - .await - .expect("invalid revision"), - ListingRevisionOutcome::Stored { parsed_ok: false } - ); - assert_eq!( - store - .store_listing_revision(&note, projected_at) - .await - .expect("note revision"), - ListingRevisionOutcome::NotListing - ); - - let invalid_row = store - .listing_revision_row(invalid.id()) - .await - .expect("invalid row") - .expect("invalid row exists"); - let errors = invalid_row["parse_errors"].as_array().expect("errors"); - assert_eq!( - invalid_row["listing_key"], - format!("30402:{pubkey}:listing-invalid") - ); - assert_eq!(invalid_row["parsed_ok"], false); - assert!(errors.contains(&serde_json::Value::String( - "tag `title` is required".to_owned() - ))); - assert!(errors.contains(&serde_json::Value::String( - "tag `price` is required".to_owned() - ))); - assert!(invalid_row["price_decimal"].is_null()); - assert!(invalid_row["unit"].is_null()); - assert!( - store - .listing_revision_row(note.id()) - .await - .expect("note row") - .is_none() - ); - } - - #[tokio::test] - async fn project_current_listings_persists_normalized_marketplace_rows() { - let store = memory_store().await; - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - let projected_at = UnixTimestamp::new(1_714_125_100); - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str()); - - assert_eq!( - store - .project_current_listing(&listing, projected_at) - .await - .expect("current listing"), - ListingCurrentOutcome::Projected - ); - - let row = store - .listing_current_row(&listing_key) - .await - .expect("listing row") - .expect("listing row exists"); - assert_eq!(row["listing_key"], listing_key); - assert_eq!(row["listing_key_hash"].as_str().expect("hash").len(), 64); - assert_eq!(row["event_id"], listing.id().as_str()); - assert_eq!(row["seller_pubkey"], listing.unsigned().pubkey().as_str()); - assert_eq!(row["d"], "listing-a"); - assert_eq!(row["created_at"], 1_714_124_433_u64); - assert_eq!(row["updated_at"], 1_714_124_433_u64); - assert!(row["published_at"].is_null()); - assert_eq!(row["title"], "Carrot bunches"); - assert!(row["summary"].is_null()); - assert_eq!(row["content"], "Sweet storage carrots."); - assert_eq!(row["price_decimal"], "12.50"); - assert_eq!(row["price_minor"], 1_250_u64); - assert_eq!(row["currency_raw"], "USD"); - assert_eq!(row["currency_norm"], "USD"); - assert!(row["price_frequency"].is_null()); - assert_eq!(row["unit"], "lb"); - assert_eq!(row["unit_family"], "lb"); - assert!(row["location_text"].is_null()); - assert_eq!(row["geohash"], "c22yzug"); - assert_eq!(row["geohash4"], "c22y"); - assert_eq!(row["geohash5"], "c22yz"); - assert_eq!(row["geohash6"], "c22yzu"); - assert_eq!(row["geohash7"], "c22yzug"); - assert!(row["point"].is_null()); - assert!(row["status_tag"].is_null()); - assert_eq!(row["effective_status"], "active"); - assert_eq!(row["categories"].as_array().expect("categories").len(), 1); - assert_eq!(row["categories"][0], "vegetables"); - assert_eq!(row["tags"].as_array().expect("tags").len(), 1); - assert_eq!(row["tags"][0], "carrots"); - assert_eq!(row["practices"].as_array().expect("practices").len(), 1); - assert_eq!(row["practices"][0], "no spray"); - assert_eq!( - row["certifications"] - .as_array() - .expect("certifications") - .len(), - 1 - ); - assert_eq!(row["certifications"][0], "organic"); - assert_eq!(row["image_urls"].as_array().expect("images").len(), 0); - assert_eq!(row["pickup_available"], true); - assert_eq!(row["delivery_available"], false); - assert_eq!(row["shipping_available"], false); - assert_eq!(row["delivery_only"], false); - assert!(row["seller_trust_score"].is_null()); - assert_eq!(row["hidden"], false); - assert_eq!(row["deleted"], false); - assert_eq!(row["projected_at"], projected_at.as_u64()); - - let media_listing = synthetic_event( - "6", - "8", - listing.unsigned().pubkey().as_str(), - 1_714_125_101, - 30_402, - vec![ - Tag::from_parts("d", &["listing-media"]).expect("d tag"), - Tag::from_parts("title", &["Media carrots"]).expect("title"), - Tag::from_parts("price", &["7.25", "USD"]).expect("price"), - Tag::from_parts("unit", &["lb"]).expect("unit"), - Tag::from_parts("fulfillment", &["pickup"]).expect("fulfillment"), - Tag::from_parts("published_at", &["1714125100"]).expect("published"), - Tag::from_parts( - "image", - &["https://fixtures.radroots.test/listing-media.png"], - ) - .expect("image"), - ], - "media listing", - ); - assert_eq!( - store - .project_current_listing(&media_listing, projected_at) - .await - .expect("media current"), - ListingCurrentOutcome::Projected - ); - let media_row = store - .listing_current_row(&format!( - "30402:{}:listing-media", - media_listing.unsigned().pubkey().as_str() - )) - .await - .expect("media row") - .expect("media row exists"); - assert_eq!(media_row["published_at"], 1_714_125_100_u64); - assert_eq!( - media_row["image_urls"][0], - "https://fixtures.radroots.test/listing-media.png" - ); - - let pubkey = "d".repeat(PublicKeyHex::HEX_LENGTH); - let invalid = synthetic_event( - "e", - "9", - &pubkey, - 1_714_125_110, - 30_402, - vec![Tag::from_parts("d", &["listing-invalid"]).expect("d tag")], - "", - ); - let note = synthetic_event( - "f", - "a", - &pubkey, - 1_714_125_111, - 1, - Vec::new(), - "not a listing", - ); - - assert_eq!( - store - .project_current_listing(&invalid, projected_at) - .await - .expect("invalid current"), - ListingCurrentOutcome::Ineligible - ); - assert_eq!( - store - .project_current_listing(&note, projected_at) - .await - .expect("note current"), - ListingCurrentOutcome::NotListing - ); - assert!( - store - .listing_current_row(&format!("30402:{pubkey}:listing-invalid")) - .await - .expect("invalid row") - .is_none() - ); - } - - #[tokio::test] - async fn query_current_listings_applies_projection_filters() { - let store = memory_store().await; - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str()); - store - .project_current_listing(&listing, UnixTimestamp::new(1_714_125_400)) - .await - .expect("project listing"); - - let query = ListingProjectionQuery::new() - .with_effective_status("active") - .with_seller_pubkey(listing.unsigned().pubkey().as_str()) - .with_unit("lb") - .with_currency_norm("USD") - .with_min_price_minor(1_000) - .with_max_price_minor(2_000) - .with_limit(5); - let rows = store - .query_current_listings(&query) - .await - .expect("listing query"); - assert_eq!(rows.len(), 1); - assert_eq!(rows[0]["listing_key"], listing_key); - - let no_match = ListingProjectionQuery::new() - .with_effective_status("active") - .with_min_price_minor(2_000); - assert!( - store - .query_current_listings(&no_match) - .await - .expect("no match") - .is_empty() - ); - - store - .database() - .query("UPDATE listing_current SET hidden = true WHERE listing_key = $listing_key;") - .bind(("listing_key", listing_key.as_str())) - .await - .expect("hide listing") - .check() - .expect("hide check"); - assert!( - store - .query_current_listings( - &ListingProjectionQuery::new().with_effective_status("active") - ) - .await - .expect("hidden query") - .is_empty() - ); - } - - #[tokio::test] - async fn project_listing_helpers_persists_discovery_tables() { - let store = memory_store().await; - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str()); - - assert_eq!( - store - .project_listing_helpers(&listing) - .await - .expect("helpers"), - ListingHelperOutcome::Projected - ); - assert_eq!( - store - .project_listing_helpers(&listing) - .await - .expect("helpers again"), - ListingHelperOutcome::Projected - ); - - let categories = store - .listing_category_rows(&listing_key) - .await - .expect("categories"); - let fulfillment = store - .listing_fulfillment_rows(&listing_key) - .await - .expect("fulfillment"); - let topics = store - .listing_topic_rows(&listing_key) - .await - .expect("topics"); - let practices = store - .listing_practice_rows(&listing_key) - .await - .expect("practices"); - let certifications = store - .listing_certification_rows(&listing_key) - .await - .expect("certifications"); - - assert_eq!(categories.len(), 1); - assert_eq!(categories[0]["category"], "vegetables"); - assert_eq!(categories[0]["effective_status"], "active"); - assert_eq!(categories[0]["updated_at"], 1_714_124_433_u64); - assert_eq!(categories[0]["event_id"], listing.id().as_str()); - assert_eq!(fulfillment.len(), 1); - assert_eq!(fulfillment[0]["mode"], "pickup"); - assert_eq!(topics.len(), 1); - assert_eq!(topics[0]["tag_value"], "carrots"); - assert_eq!(practices.len(), 1); - assert_eq!(practices[0]["practice"], "no spray"); - assert_eq!(certifications.len(), 1); - assert_eq!(certifications[0]["certification"], "organic"); - - let pubkey = "e".repeat(PublicKeyHex::HEX_LENGTH); - let invalid = synthetic_event( - "e", - "9", - &pubkey, - 1_714_125_210, - 30_402, - vec![Tag::from_parts("d", &["listing-invalid"]).expect("d tag")], - "", - ); - let note = synthetic_event( - "f", - "a", - &pubkey, - 1_714_125_211, - 1, - Vec::new(), - "not a listing", - ); - - assert_eq!( - store - .project_listing_helpers(&invalid) - .await - .expect("invalid helpers"), - ListingHelperOutcome::Ineligible - ); - assert_eq!( - store - .project_listing_helpers(&note) - .await - .expect("note helpers"), - ListingHelperOutcome::NotListing - ); - assert!( - store - .listing_category_rows(&format!("30402:{pubkey}:listing-invalid")) - .await - .expect("invalid categories") - .is_empty() - ); - } - - #[tokio::test] - async fn index_listing_search_documents_persists_listing_search_rows() { - let store = memory_store().await; - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let doc_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str()); - - assert_eq!( - store - .index_listing_search_document(&listing) - .await - .expect("search document"), - SearchDocumentOutcome::Indexed - ); - assert_eq!( - store - .index_listing_search_document(&listing) - .await - .expect("search document again"), - SearchDocumentOutcome::Indexed - ); - - let row = store - .search_document_row(&doc_key) - .await - .expect("search row") - .expect("search row exists"); - assert_eq!(row["doc_key"], doc_key); - assert_eq!(row["event_id"], listing.id().as_str()); - assert_eq!(row["current_event_id"], listing.id().as_str()); - assert_eq!(row["doc_type"], "listing"); - assert_eq!(row["kind"], 30_402_u64); - assert_eq!(row["pubkey"], listing.unsigned().pubkey().as_str()); - assert_eq!(row["address_key"], doc_key); - assert_eq!(row["title"], "Carrot bunches"); - assert!(row["summary"].is_null()); - assert_eq!(row["body"], "Sweet storage carrots."); - assert_eq!(row["category_text"], "vegetables"); - assert!(row["location_text"].is_null()); - assert_eq!(row["tags"].as_array().expect("tags").len(), 1); - assert_eq!(row["tags"][0], "carrots"); - assert_eq!(row["categories"].as_array().expect("categories").len(), 1); - assert_eq!(row["categories"][0], "vegetables"); - assert_eq!(row["created_at"], 1_714_124_433_u64); - assert_eq!(row["updated_at"], 1_714_124_433_u64); - assert_eq!(row["visible"], true); - assert_eq!(row["status"], "active"); - assert!(row["seller_trust_score"].is_null()); - - let pubkey = "f".repeat(PublicKeyHex::HEX_LENGTH); - let invalid = synthetic_event( - "e", - "9", - &pubkey, - 1_714_125_310, - 30_402, - vec![Tag::from_parts("d", &["listing-invalid"]).expect("d tag")], - "", - ); - let note = synthetic_event( - "f", - "a", - &pubkey, - 1_714_125_311, - 1, - Vec::new(), - "not a listing", - ); - - assert_eq!( - store - .index_listing_search_document(&invalid) - .await - .expect("invalid search"), - SearchDocumentOutcome::Ineligible - ); - assert_eq!( - store - .index_listing_search_document(&note) - .await - .expect("note search"), - SearchDocumentOutcome::NotListing - ); - assert!( - store - .search_document_row(&format!("30402:{pubkey}:listing-invalid")) - .await - .expect("invalid row") - .is_none() - ); - } - - #[tokio::test] - async fn query_search_documents_applies_full_text_and_structured_filters() { - let store = memory_store().await; - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let doc_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str()); - store - .index_listing_search_document(&listing) - .await - .expect("index search"); - - let query = SearchDocumentQuery::new() - .with_text("carrot") - .with_doc_type("listing") - .with_kind(30_402) - .with_pubkey(listing.unsigned().pubkey().as_str()) - .with_visible(true) - .with_status("active") - .with_limit(5); - let rows = store - .query_search_documents(&query) - .await - .expect("search rows"); - assert_eq!(rows.len(), 1); - assert_eq!(rows[0]["doc_key"], doc_key); - assert!(rows[0]["score"].is_number()); - - let miss = SearchDocumentQuery::new() - .with_text("turnip") - .with_visible(true); - assert!( - store - .query_search_documents(&miss) - .await - .expect("miss rows") - .is_empty() - ); - - let structured = SearchDocumentQuery::new() - .with_doc_type("listing") - .with_visible(true) - .with_status("active") - .with_limit(1); - assert_eq!( - store - .query_search_documents(&structured) - .await - .expect("structured rows")[0]["doc_key"], - doc_key - ); - - store - .database() - .query("UPDATE search_doc SET visible = false WHERE doc_key = $doc_key;") - .bind(("doc_key", doc_key.as_str())) - .await - .expect("hide search doc") - .check() - .expect("hide check"); - assert!( - store - .query_search_documents( - &SearchDocumentQuery::new() - .with_doc_type("listing") - .with_visible(true) - ) - .await - .expect("hidden rows") - .is_empty() - ); - } - - #[tokio::test] - async fn project_comments_persists_threaded_comment_rows() { - let store = memory_store().await; - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str()); - let comment = listing_comment(&listing, 1_714_125_010, "Is pickup open Friday?"); - let invalid_comment = build_fixture_event_from_parts( - FixtureKey::Buyer, - 1_714_125_009, - 1_111, - vec![vec!["K".to_owned(), "30402".to_owned()]], - "missing scoped targets", - ) - .expect("invalid comment"); - - assert_eq!( - store - .project_comment(&listing, UnixTimestamp::new(1_714_125_011)) - .await - .expect("not comment"), - CommentProjectionOutcome::NotComment - ); - assert_eq!( - store - .project_comment(&invalid_comment, UnixTimestamp::new(1_714_125_011)) - .await - .expect("invalid comment"), - CommentProjectionOutcome::Ineligible - ); - assert_eq!( - store - .project_comment(&comment, UnixTimestamp::new(1_714_125_011)) - .await - .expect("project comment"), - CommentProjectionOutcome::Projected - ); - - let row = store - .comment_projection_row(comment.id()) - .await - .expect("comment row") - .expect("comment row exists"); - assert_eq!(row["comment_id"], comment.id().as_str()); - assert_eq!(row["event_id"], comment.id().as_str()); - assert_eq!(row["pubkey"], FixtureKey::Buyer.public_key().as_str()); - assert_eq!(row["content"], "Is pickup open Friday?"); - assert_eq!(row["root_target_type"], "address"); - assert_eq!(row["root_ref"], listing_key); - assert_eq!(row["root_kind"], "30402"); - assert_eq!(row["root_author"], listing.unsigned().pubkey().as_str()); - assert_eq!(row["parent_target_type"], "address"); - assert_eq!(row["parent_ref"], listing_key); - assert_eq!(row["parent_kind"], "30402"); - assert_eq!(row["parent_author"], listing.unsigned().pubkey().as_str()); - assert_eq!(row["hidden"], false); - assert_eq!(row["deleted"], false); - assert_eq!(row["projected_at"], 1_714_125_011_u64); - - let rows = store - .query_comment_projections( - &CommentProjectionQuery::new() - .with_root("address", &listing_key) - .with_parent("address", &listing_key) - .with_pubkey(FixtureKey::Buyer.public_key().as_str()) - .with_limit(5), - ) - .await - .expect("comment query"); - assert_eq!(rows.len(), 1); - assert_eq!(rows[0]["event_id"], comment.id().as_str()); - } - - #[tokio::test] - async fn comment_projection_visibility_tracks_hidden_and_deleted_events() { - let store = memory_store().await; - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str()); - let comment = listing_comment(&listing, 1_714_125_020, "Do you offer bunch pricing?"); - let admin_pubkey = "a".repeat(PublicKeyHex::HEX_LENGTH); - store - .store_raw_event(&StoredEvent::new( - comment.clone(), - UnixTimestamp::new(1_714_125_021), - )) - .await - .expect("raw comment"); - store - .project_comment(&comment, UnixTimestamp::new(1_714_125_022)) - .await - .expect("project comment"); - - assert_eq!( - store - .query_comment_projections( - &CommentProjectionQuery::new().with_root("address", &listing_key) - ) - .await - .expect("visible comments") - .len(), - 1 - ); - assert_eq!( - store - .hide_event( - comment.id(), - "discussion moderation", - "admin_api", - &admin_pubkey, - UnixTimestamp::new(1_714_125_023), - ) - .await - .expect("hide comment"), - HiddenEventOutcome::Hidden - ); - assert!( - store - .query_comment_projections( - &CommentProjectionQuery::new().with_root("address", &listing_key) - ) - .await - .expect("hidden comments") - .is_empty() - ); - assert_eq!( - store - .comment_projection_row(comment.id()) - .await - .expect("comment row") - .expect("comment row exists")["hidden"], - true - ); - assert_eq!( - store - .unhide_event( - comment.id(), - "discussion restored", - &admin_pubkey, - UnixTimestamp::new(1_714_125_024), - ) - .await - .expect("unhide comment"), - HiddenEventOutcome::Unhidden - ); - assert_eq!( - store - .query_comment_projections( - &CommentProjectionQuery::new().with_root("address", &listing_key) - ) - .await - .expect("restored comments") - .len(), - 1 - ); - - let deletion = build_fixture_event_from_parts( - FixtureKey::Buyer, - 1_714_125_025, - 5, - vec![vec!["e".to_owned(), comment.id().as_str().to_owned()]], - "", - ) - .expect("deletion event"); - assert_eq!( - store - .apply_deletion_markers(&deletion) - .await - .expect("delete comment"), - DeletionMarkerOutcome::Applied { targets: 1 } - ); - assert!( - store - .query_comment_projections( - &CommentProjectionQuery::new().with_root("address", &listing_key) - ) - .await - .expect("deleted comments") - .is_empty() - ); - assert_eq!( - store - .comment_projection_row(comment.id()) - .await - .expect("comment row") - .expect("comment row exists")["deleted"], - true - ); - } - - #[tokio::test] - async fn project_reactions_persists_rows_and_aggregate_counts() { - let store = memory_store().await; - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let like = listing_reaction(&listing, 1_714_125_030, "+"); - let dislike = listing_reaction(&listing, 1_714_125_031, "-"); - let emoji = listing_reaction(&listing, 1_714_125_032, "⭐"); - let text = listing_reaction(&listing, 1_714_125_033, "fresh"); - let invalid = - build_fixture_event_from_parts(FixtureKey::Buyer, 1_714_125_029, 7, Vec::new(), "+") - .expect("invalid reaction"); - - assert_eq!( - store - .project_reaction(&listing, UnixTimestamp::new(1_714_125_033)) - .await - .expect("not reaction"), - ReactionProjectionOutcome::NotReaction - ); - assert_eq!( - store - .project_reaction(&invalid, UnixTimestamp::new(1_714_125_033)) - .await - .expect("invalid reaction"), - ReactionProjectionOutcome::Ineligible - ); - for reaction in [&like, &dislike, &emoji, &text] { - assert_eq!( - store - .project_reaction(reaction, UnixTimestamp::new(1_714_125_034)) - .await - .expect("project reaction"), - ReactionProjectionOutcome::Projected - ); - } - - let row = store - .reaction_projection_row(like.id()) - .await - .expect("reaction row") - .expect("reaction row exists"); - assert_eq!(row["reaction_id"], like.id().as_str()); - assert_eq!(row["event_id"], like.id().as_str()); - assert_eq!(row["pubkey"], FixtureKey::Buyer.public_key().as_str()); - assert_eq!(row["content"], "+"); - assert_eq!(row["value_type"], "like"); - assert_eq!(row["value"], "like"); - assert_eq!(row["target_event_id"], listing.id().as_str()); - assert_eq!(row["target_pubkey"], listing.unsigned().pubkey().as_str()); - assert_eq!( - row["target_address"], - format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str()) - ); - assert_eq!(row["target_kind"], "30402"); - assert_eq!(row["hidden"], false); - assert_eq!(row["deleted"], false); - - let count = store - .reaction_count_row(listing.id()) - .await - .expect("count row") - .expect("count row exists"); - assert_eq!(count["target_event_id"], listing.id().as_str()); - assert_eq!(count["target_kind"], "30402"); - assert_eq!(count["like_count"], 1_i64); - assert_eq!(count["dislike_count"], 1_i64); - assert_eq!(count["emoji_count"], 1_i64); - assert_eq!(count["text_count"], 1_i64); - assert_eq!(count["total_count"], 4_i64); - store - .database() - .query( - "UPDATE reaction_projection SET value_type = 'unknown' WHERE event_id = $event_id;", - ) - .bind(("event_id", text.id().as_str())) - .await - .expect("unknown reaction update") - .check() - .expect("unknown reaction update check"); - store - .refresh_reaction_count_for_event(text.id().as_str(), 1_714_125_035) - .await - .expect("refresh unknown reaction"); - let count = store - .reaction_count_row(listing.id()) - .await - .expect("unknown count row") - .expect("unknown count row exists"); - assert_eq!(count["like_count"], 1_i64); - assert_eq!(count["dislike_count"], 1_i64); - assert_eq!(count["emoji_count"], 1_i64); - assert_eq!(count["text_count"], 0_i64); - assert_eq!(count["total_count"], 3_i64); - } - - #[tokio::test] - async fn reaction_counts_track_hidden_restored_and_deleted_reactions() { - let store = memory_store().await; - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let reaction = listing_reaction(&listing, 1_714_125_040, "+"); - let admin_pubkey = "a".repeat(PublicKeyHex::HEX_LENGTH); - store - .store_raw_event(&StoredEvent::new( - reaction.clone(), - UnixTimestamp::new(1_714_125_041), - )) - .await - .expect("raw reaction"); - store - .project_reaction(&reaction, UnixTimestamp::new(1_714_125_042)) - .await - .expect("project reaction"); - store - .refresh_reaction_count_for_event(listing.id().as_str(), 1_714_125_042) - .await - .expect("refresh missing reaction row"); - - assert_eq!( - store - .reaction_count_row(listing.id()) - .await - .expect("count") - .expect("count row")["like_count"], - 1_i64 - ); - store - .hide_event( - reaction.id(), - "reaction moderation", - "admin_api", - &admin_pubkey, - UnixTimestamp::new(1_714_125_043), - ) - .await - .expect("hide reaction"); - assert_eq!( - store - .reaction_count_row(listing.id()) - .await - .expect("count") - .expect("count row")["total_count"], - 0_i64 - ); - store - .unhide_event( - reaction.id(), - "reaction restored", - &admin_pubkey, - UnixTimestamp::new(1_714_125_044), - ) - .await - .expect("unhide reaction"); - assert_eq!( - store - .reaction_count_row(listing.id()) - .await - .expect("count") - .expect("count row")["total_count"], - 1_i64 - ); - let deletion = build_fixture_event_from_parts( - FixtureKey::Buyer, - 1_714_125_045, - 5, - vec![vec!["e".to_owned(), reaction.id().as_str().to_owned()]], - "", - ) - .expect("deletion event"); - store - .apply_deletion_markers(&deletion) - .await - .expect("delete reaction"); - let row = store - .reaction_projection_row(reaction.id()) - .await - .expect("reaction row") - .expect("reaction row exists"); - assert_eq!(row["deleted"], true); - assert_eq!( - store - .reaction_count_row(listing.id()) - .await - .expect("count") - .expect("count row")["total_count"], - 0_i64 - ); - } - - #[tokio::test] - async fn project_long_form_posts_persists_current_topic_and_search_rows() { - let store = memory_store().await; - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - let article = long_form_article( - 1_714_125_060, - "harvest-notes", - "Harvest notes", - "The storage carrots held well.", - &["Carrots", "CSA"], - ); - let draft = build_fixture_event_from_parts( - FixtureKey::Seller, - 1_714_125_061, - u64::from(NIP23_LONG_FORM_DRAFT_KIND), - vec![vec!["d".to_owned(), "draft-a".to_owned()]], - "Draft body.", - ) - .expect("draft"); - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let long_form_key = format!( - "30023:{}:harvest-notes", - article.unsigned().pubkey().as_str() - ); - - assert_eq!( - store - .project_long_form(&listing, UnixTimestamp::new(1_714_125_062)) - .await - .expect("not long form"), - LongFormProjectionOutcome::NotLongForm - ); - assert_eq!( - store - .project_long_form(&draft, UnixTimestamp::new(1_714_125_062)) - .await - .expect("draft long form"), - LongFormProjectionOutcome::Ineligible - ); - assert_eq!( - store - .project_long_form(&article, UnixTimestamp::new(1_714_125_063)) - .await - .expect("project article"), - LongFormProjectionOutcome::Projected - ); - - let row = store - .long_form_current_row(&long_form_key) - .await - .expect("long-form row") - .expect("long-form row exists"); - assert_eq!(row["long_form_key"], long_form_key); - assert_eq!(row["event_id"], article.id().as_str()); - assert_eq!( - row["author_pubkey"], - FixtureKey::Seller.public_key().as_str() - ); - assert_eq!(row["d"], "harvest-notes"); - assert_eq!(row["created_at"], 1_714_125_060_u64); - assert_eq!(row["updated_at"], 1_714_125_060_u64); - assert_eq!(row["published_at"], 1_714_125_000_u64); - assert_eq!(row["title"], "Harvest notes"); - assert_eq!(row["image"], "https://radroots.test/harvest.jpg"); - assert_eq!(row["summary"], "Long-form harvest field notes."); - assert_eq!(row["content"], "The storage carrots held well."); - assert_eq!(row["tags"][0], "carrots"); - assert_eq!(row["tags"][1], "csa"); - assert_eq!(row["referenced_events"][0], "4".repeat(EventId::HEX_LENGTH)); - assert_eq!( - row["referenced_pubkeys"][0], - FixtureKey::Buyer.public_key().as_str() - ); - assert_eq!(row["hidden"], false); - assert_eq!(row["deleted"], false); - - let topics = store - .long_form_topic_rows(&long_form_key) - .await - .expect("topic rows"); - assert_eq!(topics.len(), 2); - assert_eq!(topics[0]["topic"], "carrots"); - assert_eq!(topics[1]["topic"], "csa"); - assert_eq!( - store - .query_long_form_projections( - &LongFormProjectionQuery::new() - .with_author_pubkey(FixtureKey::Seller.public_key().as_str()) - .with_topic("CSA") - .with_limit(5), - ) - .await - .expect("long-form query") - .len(), - 1 - ); - - let search = store - .search_document_row(&long_form_key) - .await - .expect("search row") - .expect("search row exists"); - assert_eq!(search["doc_type"], "long_form"); - assert_eq!(search["kind"], u64::from(NIP23_LONG_FORM_KIND)); - assert_eq!(search["title"], "Harvest notes"); - assert_eq!(search["body"], "The storage carrots held well."); - assert_eq!(search["status"], "published"); - assert_eq!(search["visible"], true); - assert_eq!( - store - .query_search_documents( - &SearchDocumentQuery::new() - .with_text("carrots") - .with_doc_type("long_form") - .with_visible(true), - ) - .await - .expect("search query") - .len(), - 1 - ); - } - - #[tokio::test] - async fn long_form_projection_tracks_replacement_moderation_and_deletion() { - let store = memory_store().await; - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - let first = long_form_article( - 1_714_125_070, - "harvest-notes", - "First harvest notes", - "First body.", - &["carrots"], - ); - let second = long_form_article( - 1_714_125_071, - "harvest-notes", - "Updated harvest notes", - "Updated body.", - &["storage"], - ); - let malformed = build_fixture_event_from_parts( - FixtureKey::Seller, - 1_714_125_072, - u64::from(NIP23_LONG_FORM_KIND), - Vec::new(), - "Missing d tag", - ) - .expect("malformed long form"); - let long_form_key = format!( - "30023:{}:harvest-notes", - second.unsigned().pubkey().as_str() - ); - let admin_pubkey = "a".repeat(PublicKeyHex::HEX_LENGTH); - store - .store_raw_event(&StoredEvent::new( - second.clone(), - UnixTimestamp::new(1_714_125_072), - )) - .await - .expect("raw article"); - - assert_eq!( - store - .project_long_form(&first, UnixTimestamp::new(1_714_125_073)) - .await - .expect("project first"), - LongFormProjectionOutcome::Projected - ); - assert_eq!( - store - .project_long_form(&malformed, UnixTimestamp::new(1_714_125_073)) - .await - .expect("malformed long form"), - LongFormProjectionOutcome::Ineligible - ); - assert_eq!( - store - .project_long_form(&second, UnixTimestamp::new(1_714_125_074)) - .await - .expect("project second"), - LongFormProjectionOutcome::Projected - ); - assert_eq!( - store - .project_long_form(&first, UnixTimestamp::new(1_714_125_075)) - .await - .expect("stale first"), - LongFormProjectionOutcome::Ineligible - ); - assert_eq!( - store - .long_form_current_row(&long_form_key) - .await - .expect("row") - .expect("row exists")["event_id"], - second.id().as_str() - ); - assert_eq!( - store - .long_form_topic_rows(&long_form_key) - .await - .expect("topics")[0]["topic"], - "storage" - ); - - assert_eq!( - store - .hide_event( - second.id(), - "long-form moderation", - "admin_api", - &admin_pubkey, - UnixTimestamp::new(1_714_125_076), - ) - .await - .expect("hide article"), - HiddenEventOutcome::Hidden - ); - assert!( - store - .query_long_form_projections(&LongFormProjectionQuery::new()) - .await - .expect("hidden query") - .is_empty() - ); - assert_eq!( - store - .long_form_current_row(&long_form_key) - .await - .expect("row") - .expect("row exists")["hidden"], - true - ); - assert_eq!( - store - .long_form_topic_rows(&long_form_key) - .await - .expect("topics")[0]["hidden"], - true - ); - assert_eq!( - store - .search_document_row(&long_form_key) - .await - .expect("search row") - .expect("search exists")["visible"], - false - ); - - assert_eq!( - store - .unhide_event( - second.id(), - "long-form restored", - &admin_pubkey, - UnixTimestamp::new(1_714_125_077), - ) - .await - .expect("unhide article"), - HiddenEventOutcome::Unhidden - ); - assert_eq!( - store - .query_long_form_projections(&LongFormProjectionQuery::new()) - .await - .expect("visible query") - .len(), - 1 - ); - assert_eq!( - store - .search_document_row(&long_form_key) - .await - .expect("search row") - .expect("search exists")["visible"], - true - ); - - let deletion = build_fixture_event_from_parts( - FixtureKey::Seller, - 1_714_125_078, - 5, - vec![vec!["e".to_owned(), second.id().as_str().to_owned()]], - "", - ) - .expect("deletion event"); - assert_eq!( - store - .apply_deletion_markers(&deletion) - .await - .expect("delete article"), - DeletionMarkerOutcome::Applied { targets: 1 } - ); - assert!( - store - .query_long_form_projections(&LongFormProjectionQuery::new()) - .await - .expect("deleted query") - .is_empty() - ); - assert_eq!( - store - .long_form_current_row(&long_form_key) - .await - .expect("row") - .expect("row exists")["deleted"], - true - ); - assert_eq!( - store - .long_form_topic_rows(&long_form_key) - .await - .expect("topics")[0]["deleted"], - true - ); - assert_eq!( - store - .search_document_row(&long_form_key) - .await - .expect("search row") - .expect("search exists")["visible"], - false - ); - } - - #[tokio::test] - async fn project_forum_threads_persists_projection_topic_and_search_rows() { - let store = memory_store().await; - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - let thread = forum_thread(1_714_125_080, Some("Market day thread"), &["market", "CSA"]); - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let invalid = build_fixture_event_from_parts( - FixtureKey::Buyer, - 1_714_125_079, - u64::from(NIP7D_THREAD_KIND), - vec![vec!["p".to_owned(), "bad".to_owned()]], - "Invalid thread.", - ) - .expect("invalid thread"); - - assert_eq!( - store - .project_forum_thread(&listing, UnixTimestamp::new(1_714_125_081)) - .await - .expect("not forum"), - ForumThreadProjectionOutcome::NotForumThread - ); - assert_eq!( - store - .project_forum_thread(&invalid, UnixTimestamp::new(1_714_125_081)) - .await - .expect("invalid forum"), - ForumThreadProjectionOutcome::Ineligible - ); - assert_eq!( - store - .project_forum_thread(&thread, UnixTimestamp::new(1_714_125_082)) - .await - .expect("project thread"), - ForumThreadProjectionOutcome::Projected - ); - - let row = store - .forum_thread_row(thread.id()) - .await - .expect("thread row") - .expect("thread row exists"); - assert_eq!(row["thread_id"], thread.id().as_str()); - assert_eq!(row["event_id"], thread.id().as_str()); - assert_eq!(row["pubkey"], FixtureKey::Buyer.public_key().as_str()); - assert_eq!(row["created_at"], 1_714_125_080_u64); - assert_eq!(row["updated_at"], 1_714_125_080_u64); - assert_eq!(row["title"], "Market day thread"); - assert_eq!(row["content"], "What is everyone bringing this weekend?"); - assert_eq!(row["tags"][0], "csa"); - assert_eq!(row["tags"][1], "market"); - assert_eq!(row["referenced_events"][0], "5".repeat(EventId::HEX_LENGTH)); - assert_eq!( - row["referenced_pubkeys"][0], - FixtureKey::Seller.public_key().as_str() - ); - assert_eq!(row["hidden"], false); - assert_eq!(row["deleted"], false); - - let topics = store - .forum_thread_topic_rows(thread.id()) - .await - .expect("topic rows"); - assert_eq!(topics.len(), 2); - assert_eq!(topics[0]["topic"], "csa"); - assert_eq!(topics[1]["topic"], "market"); - assert_eq!( - store - .query_forum_threads( - &ForumThreadProjectionQuery::new() - .with_pubkey(FixtureKey::Buyer.public_key().as_str()) - .with_topic("Market") - .with_limit(5), - ) - .await - .expect("forum query") - .len(), - 1 - ); - - let search = store - .search_document_row(thread.id().as_str()) - .await - .expect("search row") - .expect("search row exists"); - assert_eq!(search["doc_type"], "forum_thread"); - assert_eq!(search["kind"], u64::from(NIP7D_THREAD_KIND)); - assert_eq!(search["title"], "Market day thread"); - assert_eq!(search["body"], "What is everyone bringing this weekend?"); - assert_eq!(search["status"], "open"); - assert_eq!(search["visible"], true); - assert_eq!( - store - .query_search_documents( - &SearchDocumentQuery::new() - .with_text("bringing") - .with_doc_type("forum_thread") - .with_visible(true), - ) - .await - .expect("search query") - .len(), - 1 - ); - } - - #[tokio::test] - async fn forum_thread_projection_tracks_moderation_and_deletion() { - let store = memory_store().await; - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - let thread = forum_thread(1_714_125_090, None, &["market"]); - let admin_pubkey = "a".repeat(PublicKeyHex::HEX_LENGTH); - store - .store_raw_event(&StoredEvent::new( - thread.clone(), - UnixTimestamp::new(1_714_125_091), - )) - .await - .expect("raw thread"); - store - .project_forum_thread(&thread, UnixTimestamp::new(1_714_125_092)) - .await - .expect("project thread"); - - assert_eq!( - store - .search_document_row(thread.id().as_str()) - .await - .expect("search") - .expect("search row")["title"], - "What is everyone bringing this weekend?" - ); - assert_eq!( - store - .hide_event( - thread.id(), - "forum moderation", - "admin_api", - &admin_pubkey, - UnixTimestamp::new(1_714_125_093), - ) - .await - .expect("hide thread"), - HiddenEventOutcome::Hidden - ); - assert!( - store - .query_forum_threads(&ForumThreadProjectionQuery::new()) - .await - .expect("hidden query") - .is_empty() - ); - assert_eq!( - store - .forum_thread_row(thread.id()) - .await - .expect("row") - .expect("row exists")["hidden"], - true - ); - assert_eq!( - store - .forum_thread_topic_rows(thread.id()) - .await - .expect("topics")[0]["hidden"], - true - ); - assert_eq!( - store - .search_document_row(thread.id().as_str()) - .await - .expect("search") - .expect("search row")["visible"], - false - ); - - assert_eq!( - store - .unhide_event( - thread.id(), - "forum restored", - &admin_pubkey, - UnixTimestamp::new(1_714_125_094), - ) - .await - .expect("unhide thread"), - HiddenEventOutcome::Unhidden - ); - assert_eq!( - store - .query_forum_threads(&ForumThreadProjectionQuery::new()) - .await - .expect("visible query") - .len(), - 1 - ); - assert_eq!( - store - .search_document_row(thread.id().as_str()) - .await - .expect("search") - .expect("search row")["visible"], - true - ); - - let deletion = build_fixture_event_from_parts( - FixtureKey::Buyer, - 1_714_125_095, - 5, - vec![vec!["e".to_owned(), thread.id().as_str().to_owned()]], - "", - ) - .expect("deletion event"); - assert_eq!( - store - .apply_deletion_markers(&deletion) - .await - .expect("delete thread"), - DeletionMarkerOutcome::Applied { targets: 1 } - ); - assert!( - store - .query_forum_threads(&ForumThreadProjectionQuery::new()) - .await - .expect("deleted query") - .is_empty() - ); - assert_eq!( - store - .forum_thread_row(thread.id()) - .await - .expect("row") - .expect("row exists")["deleted"], - true - ); - assert_eq!( - store - .forum_thread_topic_rows(thread.id()) - .await - .expect("topics")[0]["deleted"], - true - ); - assert_eq!( - store - .search_document_row(thread.id().as_str()) - .await - .expect("search") - .expect("search row")["visible"], - false - ); - } - - #[tokio::test] - async fn project_labels_persists_deterministic_target_label_rows() { - let store = memory_store().await; - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str()); - let label = listing_label( - &listing, - 1_714_125_100, - &["reviewed", "market"], - "moderator labels listing", - ); - let invalid_label = build_fixture_event_from_parts( - FixtureKey::Buyer, - 1_714_125_099, - u64::from(NIP32_LABEL_KIND), - vec![vec!["l".to_owned(), "reviewed".to_owned()]], - "missing target", - ) - .expect("invalid label"); - - assert_eq!( - store - .project_label(&listing, UnixTimestamp::new(1_714_125_101)) - .await - .expect("not label"), - LabelProjectionOutcome::NotLabel - ); - assert_eq!( - store - .project_label(&invalid_label, UnixTimestamp::new(1_714_125_101)) - .await - .expect("invalid label"), - LabelProjectionOutcome::Ineligible - ); - assert_eq!( - store - .project_label(&label, UnixTimestamp::new(1_714_125_102)) - .await - .expect("project label"), - LabelProjectionOutcome::Projected - ); - assert_eq!( - store - .project_label(&label, UnixTimestamp::new(1_714_125_103)) - .await - .expect("reproject label"), - LabelProjectionOutcome::Projected - ); - - let rows = store - .label_projection_rows(label.id()) - .await - .expect("label rows"); - assert_eq!(rows.len(), 4); - assert!( - rows.iter() - .all(|row| row["label_id"].as_str().expect("label id").len() == 64) - ); - assert!( - rows.iter() - .all(|row| row["event_id"] == label.id().as_str()) - ); - assert!( - rows.iter() - .all(|row| row["pubkey"] == FixtureKey::Buyer.public_key().as_str()) - ); - assert!( - rows.iter() - .all(|row| row["created_at"] == 1_714_125_100_u64) - ); - assert!( - rows.iter() - .all(|row| row["content"] == "moderator labels listing") - ); - assert!( - rows.iter() - .all(|row| row["namespace"] == "com.radroots.moderation") - ); - assert!( - rows.iter() - .all(|row| row["projected_at"] == 1_714_125_103_u64) - ); - - let address_rows = store - .query_label_projections( - &LabelProjectionQuery::new() - .with_target("address", &listing_key) - .with_namespace("com.radroots.moderation") - .with_label("reviewed") - .with_pubkey(FixtureKey::Buyer.public_key().as_str()) - .with_limit(5), - ) - .await - .expect("label query"); - assert_eq!(address_rows.len(), 1); - assert_eq!(address_rows[0]["target_type"], "address"); - assert_eq!(address_rows[0]["target_ref"], listing_key); - assert_eq!(address_rows[0]["label"], "reviewed"); - assert_eq!(address_rows[0]["hidden"], false); - assert_eq!(address_rows[0]["deleted"], false); - - let event_rows = store - .query_label_projections( - &LabelProjectionQuery::new() - .with_target("event", listing.id().as_str()) - .with_namespace("com.radroots.moderation") - .with_limit(5), - ) - .await - .expect("event label query"); - assert_eq!(event_rows.len(), 2); - } - - #[tokio::test] - async fn label_projection_visibility_tracks_hidden_and_deleted_events() { - let store = memory_store().await; - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str()); - let label = listing_label(&listing, 1_714_125_110, &["reviewed"], "label under review"); - let admin_pubkey = "a".repeat(PublicKeyHex::HEX_LENGTH); - store - .store_raw_event(&StoredEvent::new( - label.clone(), - UnixTimestamp::new(1_714_125_111), - )) - .await - .expect("raw label"); - store - .project_label(&label, UnixTimestamp::new(1_714_125_112)) - .await - .expect("project label"); - - let query = LabelProjectionQuery::new() - .with_target("address", &listing_key) - .with_namespace("com.radroots.moderation") - .with_label("reviewed"); - assert_eq!( - store - .query_label_projections(&query) - .await - .expect("visible labels") - .len(), - 1 - ); - assert_eq!( - store - .hide_event( - label.id(), - "label moderation", - "admin_api", - &admin_pubkey, - UnixTimestamp::new(1_714_125_113), - ) - .await - .expect("hide label"), - HiddenEventOutcome::Hidden - ); - assert!( - store - .query_label_projections(&query) - .await - .expect("hidden labels") - .is_empty() - ); - assert!( - store - .label_projection_rows(label.id()) - .await - .expect("label rows") - .iter() - .all(|row| row["hidden"] == true) - ); - assert_eq!( - store - .unhide_event( - label.id(), - "label restored", - &admin_pubkey, - UnixTimestamp::new(1_714_125_114), - ) - .await - .expect("unhide label"), - HiddenEventOutcome::Unhidden - ); - assert_eq!( - store - .query_label_projections(&query) - .await - .expect("restored labels") - .len(), - 1 - ); - - let deletion = build_fixture_event_from_parts( - FixtureKey::Buyer, - 1_714_125_115, - 5, - vec![vec!["e".to_owned(), label.id().as_str().to_owned()]], - "", - ) - .expect("deletion event"); - assert_eq!( - store - .apply_deletion_markers(&deletion) - .await - .expect("delete label"), - DeletionMarkerOutcome::Applied { targets: 1 } - ); - assert!( - store - .query_label_projections(&query) - .await - .expect("deleted labels") - .is_empty() - ); - assert!( - store - .label_projection_rows(label.id()) - .await - .expect("label rows") - .iter() - .all(|row| row["deleted"] == true) - ); - } - - #[tokio::test] - async fn project_reports_persists_deterministic_target_report_rows() { - let store = memory_store().await; - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let report = listing_report( - &listing, - 1_714_125_120, - Some("impersonation"), - "spam", - "moderator report", - ); - let invalid_report = build_fixture_event_from_parts( - FixtureKey::Buyer, - 1_714_125_119, - u64::from(NIP56_REPORT_KIND), - vec![vec![ - "p".to_owned(), - listing.unsigned().pubkey().as_str().to_owned(), - ]], - "missing report type", - ) - .expect("invalid report"); - - assert_eq!( - store - .project_report(&listing, UnixTimestamp::new(1_714_125_121)) - .await - .expect("not report"), - ReportProjectionOutcome::NotReport - ); - assert_eq!( - store - .project_report(&invalid_report, UnixTimestamp::new(1_714_125_121)) - .await - .expect("invalid report"), - ReportProjectionOutcome::Ineligible - ); - assert_eq!( - store - .project_report(&report, UnixTimestamp::new(1_714_125_122)) - .await - .expect("project report"), - ReportProjectionOutcome::Projected - ); - assert_eq!( - store - .project_report(&report, UnixTimestamp::new(1_714_125_123)) - .await - .expect("reproject report"), - ReportProjectionOutcome::Projected - ); - - let rows = store - .report_projection_rows(report.id()) - .await - .expect("report rows"); - assert_eq!(rows.len(), 2); - assert!( - rows.iter() - .all(|row| row["report_id"].as_str().expect("report id").len() == 64) - ); - assert!( - rows.iter() - .all(|row| row["event_id"] == report.id().as_str()) - ); - assert!( - rows.iter() - .all(|row| row["pubkey"] == FixtureKey::Buyer.public_key().as_str()) - ); - assert!( - rows.iter() - .all(|row| row["created_at"] == 1_714_125_120_u64) - ); - assert!(rows.iter().all(|row| row["content"] == "moderator report")); - assert!( - rows.iter() - .all(|row| row["reported_pubkeys"][0] == listing.unsigned().pubkey().as_str()) - ); - assert!( - rows.iter() - .all(|row| row["server_urls"][0] == "https://media.radroots.test/report.jpg") - ); - assert!( - rows.iter() - .all(|row| row["projected_at"] == 1_714_125_123_u64) - ); - - let event_rows = store - .query_report_projections( - &ReportProjectionQuery::new() - .with_target("event", listing.id().as_str()) - .with_report_type("spam") - .with_pubkey(FixtureKey::Buyer.public_key().as_str()) - .with_limit(5), - ) - .await - .expect("report query"); - assert_eq!(event_rows.len(), 1); - assert_eq!(event_rows[0]["target_type"], "event"); - assert_eq!(event_rows[0]["target_ref"], listing.id().as_str()); - assert_eq!(event_rows[0]["report_type"], "spam"); - assert_eq!(event_rows[0]["hidden"], false); - assert_eq!(event_rows[0]["deleted"], false); - - let pubkey_rows = store - .query_report_projections( - &ReportProjectionQuery::new() - .with_target("pubkey", listing.unsigned().pubkey().as_str()) - .with_report_type("impersonation") - .with_limit(5), - ) - .await - .expect("profile report query"); - assert_eq!(pubkey_rows.len(), 1); - } - - #[tokio::test] - async fn report_projection_visibility_tracks_hidden_and_deleted_events() { - let store = memory_store().await; - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let report = listing_report( - &listing, - 1_714_125_130, - None, - "spam", - "listing should be reviewed", - ); - let admin_pubkey = "a".repeat(PublicKeyHex::HEX_LENGTH); - store - .store_raw_event(&StoredEvent::new( - report.clone(), - UnixTimestamp::new(1_714_125_131), - )) - .await - .expect("raw report"); - store - .project_report(&report, UnixTimestamp::new(1_714_125_132)) - .await - .expect("project report"); - - let query = ReportProjectionQuery::new() - .with_target("event", listing.id().as_str()) - .with_report_type("spam"); - assert_eq!( - store - .query_report_projections(&query) - .await - .expect("visible reports") - .len(), - 1 - ); - assert_eq!( - store - .hide_event( - report.id(), - "report moderation", - "admin_api", - &admin_pubkey, - UnixTimestamp::new(1_714_125_133), - ) - .await - .expect("hide report"), - HiddenEventOutcome::Hidden - ); - assert!( - store - .query_report_projections(&query) - .await - .expect("hidden reports") - .is_empty() - ); - assert!( - store - .report_projection_rows(report.id()) - .await - .expect("report rows") - .iter() - .all(|row| row["hidden"] == true) - ); - assert_eq!( - store - .unhide_event( - report.id(), - "report restored", - &admin_pubkey, - UnixTimestamp::new(1_714_125_134), - ) - .await - .expect("unhide report"), - HiddenEventOutcome::Unhidden - ); - assert_eq!( - store - .query_report_projections(&query) - .await - .expect("restored reports") - .len(), - 1 - ); - - let deletion = build_fixture_event_from_parts( - FixtureKey::Buyer, - 1_714_125_135, - 5, - vec![vec!["e".to_owned(), report.id().as_str().to_owned()]], - "", - ) - .expect("deletion event"); - assert_eq!( - store - .apply_deletion_markers(&deletion) - .await - .expect("delete report"), - DeletionMarkerOutcome::Applied { targets: 1 } - ); - assert!( - store - .query_report_projections(&query) - .await - .expect("deleted reports") - .is_empty() - ); - assert!( - store - .report_projection_rows(report.id()) - .await - .expect("report rows") - .iter() - .all(|row| row["deleted"] == true) - ); - } - - #[tokio::test] - async fn project_seller_profiles_persists_current_metadata_and_trust_state() { - let store = memory_store().await; - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let profile = seller_profile( - 1_714_125_140, - "radroots-market", - Some("Radroots Market"), - &["PNW", "pnw", " Cascadia "], - &["Produce", "produce"], - &["CSA", "regenerative"], - ); - let invalid_profile = build_fixture_event_from_parts( - FixtureKey::Seller, - 1_714_125_139, - u64::from(NIP01_METADATA_KIND), - Vec::new(), - "{\"name\":7}", - ) - .expect("invalid profile"); - - store - .set_seller_approved( - FixtureKey::Seller.public_key().as_str(), - true, - UnixTimestamp::new(1_714_125_138), - ) - .await - .expect("approve seller"); - assert_eq!( - store - .project_seller_profile(&listing, UnixTimestamp::new(1_714_125_141)) - .await - .expect("not profile"), - SellerProfileProjectionOutcome::NotProfile - ); - assert_eq!( - store - .project_seller_profile(&invalid_profile, UnixTimestamp::new(1_714_125_141)) - .await - .expect("invalid profile"), - SellerProfileProjectionOutcome::Ineligible - ); - assert_eq!( - store - .project_seller_profile(&profile, UnixTimestamp::new(1_714_125_142)) - .await - .expect("project profile"), - SellerProfileProjectionOutcome::Projected - ); - - let row = store - .seller_profile_row(FixtureKey::Seller.public_key().as_str()) - .await - .expect("profile row") - .expect("profile exists"); - assert_eq!(row["pubkey"], FixtureKey::Seller.public_key().as_str()); - assert_eq!(row["event_id"], profile.id().as_str()); - assert_eq!(row["created_at"], 1_714_125_140_u64); - assert_eq!(row["updated_at"], 1_714_125_140_u64); - assert_eq!(row["name"], "radroots-market"); - assert_eq!(row["display_name"], "Radroots Market"); - assert_eq!(row["about"], "Local food seller profile"); - assert_eq!(row["picture"], "https://fixtures.radroots.test/seller.png"); - assert_eq!(row["website"], "https://seller.radroots.test"); - assert_eq!(row["nip05"], "seller@radroots.test"); - assert_eq!(row["lud16"], "seller@pay.radroots.test"); - assert_eq!(row["regions"], serde_json::json!(["cascadia", "pnw"])); - assert_eq!(row["categories"], serde_json::json!(["produce"])); - assert_eq!( - row["trust_markers"], - serde_json::json!(["csa", "regenerative"]) - ); - assert_eq!(row["seller_approved"], true); - assert_eq!(row["blocked"], false); - assert_eq!(row["hidden"], false); - assert_eq!(row["deleted"], false); - assert_eq!(row["projected_at"], 1_714_125_142_u64); - - let rows = store - .query_seller_profiles( - &SellerProfileQuery::new() - .with_pubkey(FixtureKey::Seller.public_key().as_str()) - .with_approved(true) - .with_blocked(false) - .with_limit(5), - ) - .await - .expect("seller query"); - assert_eq!(rows.len(), 1); - assert_eq!(rows[0]["event_id"], profile.id().as_str()); - } - - #[tokio::test] - async fn metrics_snapshot_counts_projected_store_state() { - let store = memory_store().await; - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let profile = seller_profile( - 1_714_125_145, - "radroots-market", - Some("Radroots Market"), - &["PNW"], - &["Produce"], - &["CSA"], - ); - let blocked_pubkey = "b".repeat(PublicKeyHex::HEX_LENGTH); - - store - .store_raw_event(&StoredEvent::new( - listing.clone(), - UnixTimestamp::new(1_714_125_146), - )) - .await - .expect("store listing"); - store - .store_raw_event(&StoredEvent::new( - profile.clone(), - UnixTimestamp::new(1_714_125_147), - )) - .await - .expect("store profile"); - store - .project_current_listing(&listing, UnixTimestamp::new(1_714_125_148)) - .await - .expect("project listing"); - store - .project_seller_profile(&profile, UnixTimestamp::new(1_714_125_149)) - .await - .expect("project profile"); - store - .set_seller_approved( - FixtureKey::Seller.public_key().as_str(), - true, - UnixTimestamp::new(1_714_125_150), - ) - .await - .expect("approve seller"); - store - .set_pubkey_blocked(&blocked_pubkey, true, UnixTimestamp::new(1_714_125_151)) - .await - .expect("block pubkey"); - - let snapshot = store.metrics_snapshot().await.expect("snapshot"); - assert_eq!(snapshot.stored_events(), 2); - assert_eq!(snapshot.visible_events(), 2); - assert_eq!(snapshot.hidden_events(), 0); - assert_eq!(snapshot.deleted_events(), 0); - assert_eq!(snapshot.current_listings(), 1); - assert_eq!(snapshot.active_listings(), 1); - assert_eq!(snapshot.seller_profiles(), 1); - assert_eq!(snapshot.visible_seller_profiles(), 1); - assert_eq!(snapshot.approved_sellers(), 1); - assert_eq!(snapshot.blocked_pubkeys(), 1); - } - - #[tokio::test] - async fn seller_profile_projection_tracks_replacement_moderation_and_deletion() { - let store = memory_store().await; - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - let older = seller_profile( - 1_714_125_150, - "older-market", - None, - &["pnw"], - &["produce"], - &["csa"], - ); - let newer = seller_profile( - 1_714_125_151, - "newer-market", - Some("Newer Market"), - &["cascadia"], - &["fruit"], - &["inspected"], - ); - let admin_pubkey = "a".repeat(PublicKeyHex::HEX_LENGTH); - - assert_eq!( - store - .project_seller_profile(&older, UnixTimestamp::new(1_714_125_152)) - .await - .expect("older profile"), - SellerProfileProjectionOutcome::Projected - ); - assert_eq!( - store - .project_seller_profile(&newer, UnixTimestamp::new(1_714_125_153)) - .await - .expect("newer profile"), - SellerProfileProjectionOutcome::Projected - ); - assert_eq!( - store - .project_seller_profile(&older, UnixTimestamp::new(1_714_125_154)) - .await - .expect("stale profile"), - SellerProfileProjectionOutcome::Ineligible - ); - let current = store - .seller_profile_row(FixtureKey::Seller.public_key().as_str()) - .await - .expect("profile row") - .expect("profile exists"); - assert_eq!(current["event_id"], newer.id().as_str()); - assert_eq!(current["name"], "newer-market"); - assert_eq!(current["categories"], serde_json::json!(["fruit"])); - - store - .store_raw_event(&StoredEvent::new( - newer.clone(), - UnixTimestamp::new(1_714_125_155), - )) - .await - .expect("raw profile"); - assert_eq!( - store - .hide_event( - newer.id(), - "profile moderation", - "admin_api", - &admin_pubkey, - UnixTimestamp::new(1_714_125_156), - ) - .await - .expect("hide profile"), - HiddenEventOutcome::Hidden - ); - assert!( - store - .query_seller_profiles(&SellerProfileQuery::new()) - .await - .expect("hidden profiles") - .is_empty() - ); - assert_eq!( - store - .seller_profile_row(FixtureKey::Seller.public_key().as_str()) - .await - .expect("profile row") - .expect("profile exists")["hidden"], - true - ); - assert_eq!( - store - .unhide_event( - newer.id(), - "profile restored", - &admin_pubkey, - UnixTimestamp::new(1_714_125_157), - ) - .await - .expect("unhide profile"), - HiddenEventOutcome::Unhidden - ); - assert_eq!( - store - .query_seller_profiles(&SellerProfileQuery::new()) - .await - .expect("visible profiles") - .len(), - 1 - ); - - store - .set_pubkey_blocked( - FixtureKey::Seller.public_key().as_str(), - true, - UnixTimestamp::new(1_714_125_158), - ) - .await - .expect("block seller"); - assert_eq!( - store - .seller_profile_row(FixtureKey::Seller.public_key().as_str()) - .await - .expect("profile row") - .expect("profile exists")["blocked"], - true - ); - - let deletion = build_fixture_event_from_parts( - FixtureKey::Seller, - 1_714_125_159, - 5, - vec![vec!["e".to_owned(), newer.id().as_str().to_owned()]], - "", - ) - .expect("deletion event"); - assert_eq!( - store - .apply_deletion_markers(&deletion) - .await - .expect("delete profile"), - DeletionMarkerOutcome::Applied { targets: 1 } - ); - assert!( - store - .query_seller_profiles(&SellerProfileQuery::new()) - .await - .expect("deleted profiles") - .is_empty() - ); - assert_eq!( - store - .seller_profile_row(FixtureKey::Seller.public_key().as_str()) - .await - .expect("profile row") - .expect("profile exists")["deleted"], - true - ); - } - - #[tokio::test] - async fn hidden_event_overlay_excludes_events_from_public_read_models() { - let store = memory_store().await; - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); - let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str()); - let admin_pubkey = "a".repeat(PublicKeyHex::HEX_LENGTH); - let id_filter = filter_from_value(&serde_json::json!({ - "ids": [listing.id().as_str()] - })) - .expect("id filter"); - - store - .store_raw_event(&StoredEvent::new( - listing.clone(), - UnixTimestamp::new(1_714_125_500), - )) - .await - .expect("raw event"); - store - .maintain_current_event(&listing) - .await - .expect("current event"); - store - .project_current_listing(&listing, UnixTimestamp::new(1_714_125_501)) - .await - .expect("listing projection"); - store - .index_listing_search_document(&listing) - .await - .expect("search document"); - - assert_eq!( - store - .query_raw_events(&id_filter) - .await - .expect("raw query") - .len(), - 1 - ); - assert_eq!( - store - .query_current_events(&id_filter) - .await - .expect("current query") - .len(), - 1 - ); - assert_eq!( - store - .query_current_listings( - &ListingProjectionQuery::new().with_effective_status("active") - ) - .await - .expect("listing query") - .len(), - 1 - ); - assert_eq!( - store - .query_search_documents( - &SearchDocumentQuery::new() - .with_text("carrot") - .with_doc_type("listing") - .with_visible(true) - ) - .await - .expect("search query") - .len(), - 1 - ); - - assert_eq!( - store - .hide_event( - listing.id(), - "policy proof", - "admin_api", - &admin_pubkey, - UnixTimestamp::new(1_714_125_600), - ) - .await - .expect("hide"), - HiddenEventOutcome::Hidden - ); - - let hidden = store - .hidden_event_row(listing.id()) - .await - .expect("hidden row") - .expect("hidden row exists"); - assert_eq!(hidden["event_id"], listing.id().as_str()); - assert_eq!(hidden["reason"], "policy proof"); - assert_eq!(hidden["source"], "admin_api"); - assert_eq!(hidden["created_at"], 1_714_125_600_u64); - assert_eq!(hidden["admin_pubkey"], admin_pubkey); - assert_eq!( - store - .raw_event_row(listing.id()) - .await - .expect("raw row") - .expect("raw row exists")["hidden"], - true - ); - assert_eq!( - store - .current_event_row(&listing_key) - .await - .expect("current row") - .expect("current row exists")["hidden"], - true - ); - assert_eq!( - store - .listing_current_row(&listing_key) - .await - .expect("listing row") - .expect("listing row exists")["hidden"], - true - ); - assert_eq!( - store - .search_document_row(&listing_key) - .await - .expect("search row") - .expect("search row exists")["visible"], - false - ); - assert!( - store - .query_raw_events(&id_filter) - .await - .expect("hidden raw query") - .is_empty() - ); - assert!( - store - .query_current_events(&id_filter) - .await - .expect("hidden current query") - .is_empty() - ); - assert!( - store - .query_current_listings( - &ListingProjectionQuery::new().with_effective_status("active") - ) - .await - .expect("hidden listing query") - .is_empty() - ); - assert!( - store - .query_search_documents( - &SearchDocumentQuery::new() - .with_text("carrot") - .with_doc_type("listing") - .with_visible(true) - ) - .await - .expect("hidden search query") - .is_empty() - ); - let actions = store - .moderation_action_rows("event", listing.id().as_str()) - .await - .expect("actions"); - assert_eq!(actions.len(), 1); - assert_eq!(actions[0]["action"], "hide"); - assert_eq!(actions[0]["target_ref"], listing.id().as_str()); - - assert_eq!( - store - .unhide_event( - listing.id(), - "policy proof complete", - &admin_pubkey, - UnixTimestamp::new(1_714_125_700), - ) - .await - .expect("unhide"), - HiddenEventOutcome::Unhidden - ); - assert!( - store - .hidden_event_row(listing.id()) - .await - .expect("hidden row removed") - .is_none() - ); - assert_eq!( - store - .raw_event_row(listing.id()) - .await - .expect("raw row") - .expect("raw row exists")["hidden"], - false - ); - assert_eq!( - store - .search_document_row(&listing_key) - .await - .expect("search row") - .expect("search row exists")["visible"], - true - ); - assert_eq!( - store - .query_search_documents( - &SearchDocumentQuery::new() - .with_text("carrot") - .with_doc_type("listing") - .with_visible(true) - ) - .await - .expect("visible search query") - .len(), - 1 - ); - let actions = store - .moderation_action_rows("event", listing.id().as_str()) - .await - .expect("actions"); - assert_eq!(actions.len(), 2); - assert_eq!(actions[1]["action"], "unhide"); - assert_eq!( - store - .hide_event( - &EventId::new(&"b".repeat(EventId::HEX_LENGTH)).expect("missing id"), - "missing", - "admin_api", - &admin_pubkey, - UnixTimestamp::new(1_714_125_800), - ) - .await - .expect("missing hide"), - HiddenEventOutcome::NotFound - ); - } - - #[tokio::test] - async fn durable_rate_limit_state_persists_fixed_windows() { - let store = memory_store().await; - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - let key = "event_write:".to_owned() + &"1".repeat(PublicKeyHex::HEX_LENGTH); - - let first = store - .check_durable_rate_limit(&key, 3, 60, 1, UnixTimestamp::new(100)) - .await - .expect("first"); - let second = store - .check_durable_rate_limit(&key, 3, 60, 2, UnixTimestamp::new(110)) - .await - .expect("second"); - let rejected = store - .check_durable_rate_limit(&key, 3, 60, 1, UnixTimestamp::new(120)) - .await - .expect("rejected"); - - assert_eq!( - first, - DurableRateLimitDecision::Accepted { - remaining: 2, - reset_at: UnixTimestamp::new(160) - } - ); - assert!(first.allowed()); - assert_eq!(first.remaining(), 2); - assert_eq!(first.reset_at(), UnixTimestamp::new(160)); - assert_eq!(first.retry_after_seconds(), None); - assert_eq!( - second, - DurableRateLimitDecision::Accepted { - remaining: 0, - reset_at: UnixTimestamp::new(160) - } - ); - assert_eq!( - rejected, - DurableRateLimitDecision::Rejected { - retry_after_seconds: 40, - reset_at: UnixTimestamp::new(160) - } - ); - assert!(!rejected.allowed()); - assert_eq!(rejected.remaining(), 0); - assert_eq!(rejected.reset_at(), UnixTimestamp::new(160)); - assert_eq!(rejected.retry_after_seconds(), Some(40)); - let row = store - .rate_limit_state_row(&key) - .await - .expect("rate row") - .expect("rate row exists"); - assert_eq!(row["key"], key); - assert_eq!(row["expires_at"], 160_u64); - assert_eq!(row["created_at"], 100_u64); - assert_eq!(row["updated_at"], 110_u64); - assert_eq!( - serde_json::from_str::<serde_json::Value>(row["state"].as_str().expect("state")) - .expect("state json"), - serde_json::json!({ - "started_at": 100, - "used": 3 - }) - ); - - let reset = store - .check_durable_rate_limit(&key, 3, 60, 1, UnixTimestamp::new(160)) - .await - .expect("reset"); - assert_eq!( - reset, - DurableRateLimitDecision::Accepted { - remaining: 2, - reset_at: UnixTimestamp::new(220) - } - ); - let row = store - .rate_limit_state_row(&key) - .await - .expect("rate row") - .expect("rate row exists"); - assert_eq!(row["expires_at"], 220_u64); - assert_eq!(row["created_at"], 100_u64); - assert_eq!(row["updated_at"], 160_u64); - assert_eq!( - store - .prune_expired_rate_limit_state(UnixTimestamp::new(221)) - .await - .expect("prune"), - 1 - ); - assert!( - store - .rate_limit_state_row(&key) - .await - .expect("pruned row") - .is_none() - ); - } - - #[tokio::test] - async fn relay_user_policy_rows_persist_approval_and_block_state() { - let store = memory_store().await; - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - let seller = "2".repeat(PublicKeyHex::HEX_LENGTH); - - store - .set_seller_approved(&seller, true, UnixTimestamp::new(1_714_125_900)) - .await - .expect("approve seller"); - let approved = store - .relay_user_row(&seller) - .await - .expect("relay user") - .expect("relay user exists"); - assert_eq!(approved["pubkey"], seller); - assert_eq!(approved["role"], "seller"); - assert_eq!(approved["seller_approved"], true); - assert_eq!(approved["blocked"], false); - assert_eq!(approved["created_at"], 1_714_125_900_u64); - assert_eq!(approved["updated_at"], 1_714_125_900_u64); - - store - .set_pubkey_blocked(&seller, true, UnixTimestamp::new(1_714_126_000)) - .await - .expect("block seller"); - let blocked = store - .relay_user_row(&seller) - .await - .expect("relay user") - .expect("relay user exists"); - assert_eq!(blocked["seller_approved"], true); - assert_eq!(blocked["blocked"], true); - assert_eq!(blocked["created_at"], 1_714_125_900_u64); - assert_eq!(blocked["updated_at"], 1_714_126_000_u64); - - store - .set_seller_approved(&seller, false, UnixTimestamp::new(1_714_126_100)) - .await - .expect("unapprove seller"); - let unapproved = store - .relay_user_row(&seller) - .await - .expect("relay user") - .expect("relay user exists"); - assert_eq!(unapproved["seller_approved"], false); - assert_eq!(unapproved["blocked"], true); - assert_eq!(unapproved["created_at"], 1_714_125_900_u64); - assert_eq!(unapproved["updated_at"], 1_714_126_100_u64); - } - - #[tokio::test] - async fn private_helpers_cover_debug_errors_and_decimal_edges() { - let store = memory_store().await; - assert!(format!("{store:?}").contains("SurrealStore")); - let source = store - .database() - .query("THIS IS NOT VALID SURQL") - .await - .expect_err("surreal error"); - assert!(!SurrealStoreError::from(source).message().is_empty()); - let note = synthetic_event( - "1", - "b", - &"1".repeat(PublicKeyHex::HEX_LENGTH), - 1, - 1, - Vec::new(), - "note", - ); - let fields = - super::listing_revision_fields(&note, &ListingProjectionEvaluation::NotListing) - .expect("not listing fields"); - assert_eq!(fields.revision_key, note.id().as_str()); - assert!(!fields.parsed_ok); - let pubkey = "2".repeat(PublicKeyHex::HEX_LENGTH); - let addressable_without_d = - synthetic_event("2", "c", &pubkey, 2, 30_402, Vec::new(), "addressless"); - assert_eq!( - super::address_key_value(&addressable_without_d) - .expect_err("address key error") - .message(), - "addressable event must include a d tag" - ); - assert_eq!( - store - .maintain_current_event(&addressable_without_d) - .await - .expect_err("current key error") - .message(), - "addressable event must include a d tag" - ); - let malformed_deletion = synthetic_event( - "3", - "d", - &pubkey, - 3, - 5, - vec![Tag::from_parts("e", &["not-hex"]).expect("e tag")], - "bad deletion", - ); - assert_eq!( - store - .apply_deletion_markers(&malformed_deletion) - .await - .expect_err("malformed deletion") - .message(), - "event id must be 64 characters, got 7" - ); - assert_eq!( - super::tag_values( - &synthetic_event( - "4", - "e", - &pubkey, - 4, - 1, - vec![ - Tag::from_parts("image", &["https://fixtures.radroots.test/helper.png"]) - .expect("image tag") - ], - "image helper", - ), - "image" - ), - vec!["https://fixtures.radroots.test/helper.png".to_owned()] - ); - assert_eq!( - super::unique_in_order(vec![ - "first".to_owned(), - "first".to_owned(), - "second".to_owned() - ]), - vec!["first".to_owned(), "second".to_owned()] - ); - assert_eq!(super::price_minor("12"), Some(1_200)); - assert_eq!(super::price_minor("1.2.3"), None); - assert_eq!(super::price_minor("1.234"), None); - } - - #[tokio::test] - async fn project_current_listing_rejects_prices_without_minor_unit_representation() { - let store = memory_store().await; - store - .apply_plan(&base_migration_plan()) - .await - .expect("apply plan"); - let pubkey = "1".repeat(PublicKeyHex::HEX_LENGTH); - let listing = synthetic_event( - "2", - "c", - &pubkey, - 1_714_125_500, - 30_402, - vec![ - Tag::from_parts("d", &["listing-fractional"]).expect("d tag"), - Tag::from_parts("title", &["Fractional carrots"]).expect("title"), - Tag::from_parts("price", &["1.234", "USD"]).expect("price"), - Tag::from_parts("unit", &["lb"]).expect("unit"), - Tag::from_parts("fulfillment", &["pickup"]).expect("fulfillment"), - ], - "fractional listing", - ); - - assert_eq!( - store - .store_listing_revision(&listing, UnixTimestamp::new(1_714_125_501)) - .await - .expect("revision"), - ListingRevisionOutcome::Stored { parsed_ok: true } - ); - assert_eq!( - store - .listing_revision_row(listing.id()) - .await - .expect("revision row") - .expect("revision exists")["price_minor"], - serde_json::Value::Null - ); - let error = store - .project_current_listing(&listing, UnixTimestamp::new(1_714_125_501)) - .await - .expect_err("minor unit error"); - assert_eq!( - error.message(), - "listing price amount must fit two decimal minor units" - ); - } - - fn seller_profile( - created_at: u64, - name: &str, - display_name: Option<&str>, - regions: &[&str], - categories: &[&str], - trust_markers: &[&str], - ) -> Event { - let mut tags = Vec::new(); - tags.extend( - regions - .iter() - .map(|region| vec!["region".to_owned(), (*region).to_owned()]), - ); - tags.extend( - categories - .iter() - .map(|category| vec!["category".to_owned(), (*category).to_owned()]), - ); - tags.extend( - trust_markers - .iter() - .map(|trust| vec!["trust".to_owned(), (*trust).to_owned()]), - ); - let mut content = serde_json::json!({ - "name": name, - "about": "Local food seller profile", - "picture": "https://fixtures.radroots.test/seller.png", - "website": "https://seller.radroots.test", - "nip05": "seller@radroots.test", - "lud16": "seller@pay.radroots.test" - }); - if let Some(display_name) = display_name { - content["display_name"] = serde_json::Value::String(display_name.to_owned()); - } - build_fixture_event_from_parts( - FixtureKey::Seller, - created_at, - u64::from(NIP01_METADATA_KIND), - tags, - &content.to_string(), - ) - .expect("seller profile") - } - - fn listing_comment(listing: &Event, created_at: u64, content: &str) -> Event { - let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str()); - build_fixture_event_from_parts( - FixtureKey::Buyer, - created_at, - 1_111, - vec![ - vec!["A".to_owned(), listing_key.clone()], - vec!["K".to_owned(), "30402".to_owned()], - vec![ - "P".to_owned(), - listing.unsigned().pubkey().as_str().to_owned(), - ], - vec!["a".to_owned(), listing_key], - vec!["k".to_owned(), "30402".to_owned()], - vec![ - "p".to_owned(), - listing.unsigned().pubkey().as_str().to_owned(), - ], - ], - content, - ) - .expect("comment event") - } - - fn listing_reaction(listing: &Event, created_at: u64, content: &str) -> Event { - let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str()); - build_fixture_event_from_parts( - FixtureKey::Buyer, - created_at, - 7, - vec![ - vec![ - "e".to_owned(), - listing.id().as_str().to_owned(), - "wss://relay.radroots.test".to_owned(), - listing.unsigned().pubkey().as_str().to_owned(), - ], - vec![ - "p".to_owned(), - listing.unsigned().pubkey().as_str().to_owned(), - ], - vec!["a".to_owned(), listing_key], - vec!["k".to_owned(), "30402".to_owned()], - ], - content, - ) - .expect("reaction event") - } - - fn listing_label(listing: &Event, created_at: u64, labels: &[&str], content: &str) -> Event { - let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str()); - let namespace = "com.radroots.moderation"; - let mut tags = vec![ - vec!["L".to_owned(), namespace.to_owned()], - vec!["e".to_owned(), listing.id().as_str().to_owned()], - vec!["a".to_owned(), listing_key], - ]; - tags.extend( - labels - .iter() - .map(|label| vec!["l".to_owned(), (*label).to_owned(), namespace.to_owned()]), - ); - build_fixture_event_from_parts( - FixtureKey::Buyer, - created_at, - u64::from(NIP32_LABEL_KIND), - tags, - content, - ) - .expect("label event") - } - - fn listing_report( - listing: &Event, - created_at: u64, - profile_report_type: Option<&str>, - event_report_type: &str, - content: &str, - ) -> Event { - let mut pubkey_tag = vec![ - "p".to_owned(), - listing.unsigned().pubkey().as_str().to_owned(), - ]; - if let Some(report_type) = profile_report_type { - pubkey_tag.push(report_type.to_owned()); - } - build_fixture_event_from_parts( - FixtureKey::Buyer, - created_at, - u64::from(NIP56_REPORT_KIND), - vec![ - pubkey_tag, - vec![ - "e".to_owned(), - listing.id().as_str().to_owned(), - event_report_type.to_owned(), - ], - vec![ - "server".to_owned(), - "https://media.radroots.test/report.jpg".to_owned(), - ], - ], - content, - ) - .expect("report event") - } - - fn long_form_article( - created_at: u64, - d: &str, - title: &str, - content: &str, - topics: &[&str], - ) -> Event { - let buyer_pubkey = FixtureKey::Buyer.public_key().as_str().to_owned(); - let referenced_address = format!("30023:{buyer_pubkey}:soil-notes"); - let mut tags = vec![ - vec!["d".to_owned(), d.to_owned()], - vec!["title".to_owned(), title.to_owned()], - vec![ - "summary".to_owned(), - "Long-form harvest field notes.".to_owned(), - ], - vec![ - "image".to_owned(), - "https://radroots.test/harvest.jpg".to_owned(), - ], - vec!["published_at".to_owned(), "1714125000".to_owned()], - vec!["e".to_owned(), "4".repeat(EventId::HEX_LENGTH)], - vec!["a".to_owned(), referenced_address], - vec!["p".to_owned(), buyer_pubkey], - ]; - tags.extend( - topics - .iter() - .map(|topic| vec!["t".to_owned(), (*topic).to_owned()]), - ); - build_fixture_event_from_parts( - FixtureKey::Seller, - created_at, - u64::from(NIP23_LONG_FORM_KIND), - tags, - content, - ) - .expect("long-form article") - } - - fn forum_thread(created_at: u64, title: Option<&str>, topics: &[&str]) -> Event { - let mut tags = vec![ - vec!["e".to_owned(), "5".repeat(EventId::HEX_LENGTH)], - vec![ - "p".to_owned(), - FixtureKey::Seller.public_key().as_str().to_owned(), - ], - ]; - if let Some(title) = title { - tags.push(vec!["title".to_owned(), title.to_owned()]); - } - tags.extend( - topics - .iter() - .map(|topic| vec!["t".to_owned(), (*topic).to_owned()]), - ); - build_fixture_event_from_parts( - FixtureKey::Buyer, - created_at, - u64::from(NIP7D_THREAD_KIND), - tags, - "What is everyone bringing this weekend?", - ) - .expect("forum thread") - } - - fn synthetic_event( - id_digit: &str, - sig_digit: &str, - pubkey: &str, - created_at: u64, - kind: u64, - tags: Vec<Tag>, - content: &str, - ) -> Event { - Event::new( - EventId::new(&id_digit.repeat(EventId::HEX_LENGTH)).expect("id"), - UnsignedEvent::new( - PublicKeyHex::new(pubkey).expect("pubkey"), - UnixTimestamp::new(created_at), - Kind::new(kind).expect("kind"), - tags, - content, - ), - SignatureHex::new(&sig_digit.repeat(SignatureHex::HEX_LENGTH)).expect("sig"), - ) - } -} diff --git a/crates/tangle_test_support/Cargo.toml b/crates/tangle_test_support/Cargo.toml @@ -9,13 +9,10 @@ description = "Deterministic fixtures and event builders for tangle tests" [dependencies] k256 = { version = "0.13", features = ["schnorr"] } -serde = { version = "1", features = ["derive"] } serde_json = "1" tangle_crypto = { path = "../tangle_crypto" } tangle_groups = { path = "../tangle_groups" } -tangle_nips = { path = "../tangle_nips" } tangle_protocol = { path = "../tangle_protocol" } -tangle_store = { path = "../tangle_store" } [lints] workspace = true diff --git a/crates/tangle_test_support/src/lib.rs b/crates/tangle_test_support/src/lib.rs @@ -3,8 +3,6 @@ use core::fmt; use k256::schnorr::signature::Signer; use k256::schnorr::{Signature, SigningKey}; -use serde::Deserialize; -use std::collections::BTreeMap; use tangle_crypto::{RelaySigner, compute_event_id}; use tangle_groups::{ CanonicalRelayUrl, GroupGeneratedEventBuilder, GroupLimitsConfig, GroupOutboxPayload, @@ -12,29 +10,13 @@ use tangle_groups::{ KIND_GROUP_DELETE_GROUP, KIND_GROUP_EDIT_METADATA, KIND_GROUP_JOIN_REQUEST, KIND_GROUP_LEAVE_REQUEST, KIND_GROUP_PUT_USER, KIND_GROUP_REMOVE_USER, RelaySecret, }; -use tangle_nips::ListingProjection; use tangle_protocol::{ - AddressCoordinate, Event, EventId, Kind, PublicKeyHex, SignatureHex, Tag, UnixTimestamp, - UnsignedEvent, event_to_value, + Event, EventId, Kind, PublicKeyHex, SignatureHex, Tag, UnixTimestamp, UnsignedEvent, + event_to_value, }; -use tangle_store::{ - DeletionMarker, DeletionMarkerRepository, ListingProjectionRepository, RawEventRepository, - RepositoryError, StoreEventOutcome, StoreProjectionOutcome, StoredEvent, -}; - -const VALID_PUBLIC_LISTING_JSON: &str = - include_str!("../../../testing/fixtures/canonical/nostr/valid_public_listing.json"); -const PROJECTION_INELIGIBLE_LISTING_JSON: &str = - include_str!("../../../testing/fixtures/canonical/nostr/projection_ineligible_listing.json"); -const AUTH_EVENT_JSON: &str = - include_str!("../../../testing/fixtures/canonical/nostr/auth_event.json"); -const DELETION_EVENT_JSON: &str = - include_str!("../../../testing/fixtures/canonical/nostr/deletion_event.json"); #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FixtureKey { - Seller, - Buyer, Relay, Owner, Admin, @@ -51,8 +33,6 @@ impl FixtureKey { fn signing_key(self) -> SigningKey { match self { - Self::Seller => SigningKey::from_bytes(&[7_u8; 32]).expect("seller fixture key"), - Self::Buyer => SigningKey::from_bytes(&[8_u8; 32]).expect("buyer fixture key"), Self::Relay => SigningKey::from_bytes(&[9_u8; 32]).expect("relay fixture key"), Self::Owner => SigningKey::from_bytes(&[10_u8; 32]).expect("owner fixture key"), Self::Admin => SigningKey::from_bytes(&[11_u8; 32]).expect("admin fixture key"), @@ -65,8 +45,6 @@ impl FixtureKey { impl fmt::Display for FixtureKey { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str(match self { - Self::Seller => "seller", - Self::Buyer => "buyer", Self::Relay => "relay", Self::Owner => "owner", Self::Admin => "admin", @@ -76,87 +54,6 @@ impl fmt::Display for FixtureKey { } } -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -pub struct FixtureEventSpec { - name: String, - key: String, - created_at: u64, - kind: u64, - tags: Vec<Vec<String>>, - content: String, -} - -impl FixtureEventSpec { - pub fn name(&self) -> &str { - &self.name - } - - pub fn key(&self) -> &str { - &self.key - } - - pub fn created_at(&self) -> u64 { - self.created_at - } - - pub fn kind(&self) -> u64 { - self.kind - } - - pub fn tags(&self) -> &[Vec<String>] { - &self.tags - } - - pub fn content(&self) -> &str { - &self.content - } - - pub fn fixture_key(&self) -> Result<FixtureKey, String> { - match self.key.as_str() { - "seller" => Ok(FixtureKey::Seller), - "buyer" => Ok(FixtureKey::Buyer), - "relay" => Ok(FixtureKey::Relay), - "owner" => Ok(FixtureKey::Owner), - "admin" => Ok(FixtureKey::Admin), - "member" => Ok(FixtureKey::Member), - "outsider" => Ok(FixtureKey::Outsider), - value => Err(format!("fixture key `{value}` is unsupported")), - } - } -} - -pub fn valid_public_listing_spec() -> FixtureEventSpec { - fixture_spec_from_json(VALID_PUBLIC_LISTING_JSON).expect("valid listing fixture parses") -} - -pub fn projection_ineligible_listing_spec() -> FixtureEventSpec { - fixture_spec_from_json(PROJECTION_INELIGIBLE_LISTING_JSON) - .expect("projection-ineligible listing fixture parses") -} - -pub fn auth_event_spec() -> FixtureEventSpec { - fixture_spec_from_json(AUTH_EVENT_JSON).expect("auth event fixture parses") -} - -pub fn deletion_event_spec() -> FixtureEventSpec { - fixture_spec_from_json(DELETION_EVENT_JSON).expect("deletion event fixture parses") -} - -pub fn fixture_spec_from_json(raw: &str) -> Result<FixtureEventSpec, String> { - serde_json::from_str(raw).map_err(|source| format!("fixture JSON is invalid: {source}")) -} - -pub fn build_fixture_event(spec: &FixtureEventSpec) -> Result<Event, String> { - let fixture_key = spec.fixture_key()?; - build_fixture_event_from_parts( - fixture_key, - spec.created_at, - spec.kind, - spec.tags.clone(), - &spec.content, - ) -} - pub fn build_fixture_event_from_parts( fixture_key: FixtureKey, created_at: u64, @@ -425,69 +322,6 @@ pub fn fixture_event_json(event: &Event) -> serde_json::Value { event_to_value(event) } -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct InMemoryRepository { - events: BTreeMap<EventId, StoredEvent>, - listing_projections: BTreeMap<AddressCoordinate, ListingProjection>, - deletion_markers: Vec<DeletionMarker>, -} - -impl InMemoryRepository { - pub fn new() -> Self { - Self::default() - } -} - -impl RawEventRepository for InMemoryRepository { - fn put_event(&mut self, record: StoredEvent) -> Result<StoreEventOutcome, RepositoryError> { - let event_id = record.event().id().clone(); - if self.events.contains_key(&event_id) { - return Ok(StoreEventOutcome::Duplicate); - } - self.events.insert(event_id, record); - Ok(StoreEventOutcome::Inserted) - } - - fn event_by_id(&self, event_id: &EventId) -> Result<Option<StoredEvent>, RepositoryError> { - Ok(self.events.get(event_id).cloned()) - } - - fn events(&self) -> Result<Vec<StoredEvent>, RepositoryError> { - Ok(self.events.values().cloned().collect()) - } -} - -impl ListingProjectionRepository for InMemoryRepository { - fn put_listing_projection( - &mut self, - projection: ListingProjection, - ) -> Result<StoreProjectionOutcome, RepositoryError> { - let address = projection.identity().address().clone(); - match self.listing_projections.insert(address, projection) { - Some(_) => Ok(StoreProjectionOutcome::Replaced), - None => Ok(StoreProjectionOutcome::Inserted), - } - } - - fn listing_projection( - &self, - address: &AddressCoordinate, - ) -> Result<Option<ListingProjection>, RepositoryError> { - Ok(self.listing_projections.get(address).cloned()) - } -} - -impl DeletionMarkerRepository for InMemoryRepository { - fn put_deletion_marker(&mut self, marker: DeletionMarker) -> Result<(), RepositoryError> { - self.deletion_markers.push(marker); - Ok(()) - } - - fn deletion_markers(&self) -> Result<Vec<DeletionMarker>, RepositoryError> { - Ok(self.deletion_markers.clone()) - } -} - fn sign_unsigned_event(fixture_key: FixtureKey, unsigned: UnsignedEvent) -> Result<Event, String> { let signing_key = fixture_key.signing_key(); let event_id = compute_event_id(&unsigned); @@ -534,60 +368,29 @@ fn lower_hex(bytes: &[u8]) -> String { #[cfg(test)] mod tests { use super::{ - FixtureKey, InMemoryRepository, auth_event_spec, build_fixture_event, deletion_event_spec, - fixed_hex_bytes, fixture_event_json, fixture_spec_from_json, - projection_ineligible_listing_spec, tangle_v2_auth_event, tangle_v2_generated_event, - tangle_v2_group_config, tangle_v2_group_create_event, tangle_v2_group_event, - tangle_v2_group_metadata_event, tangle_v2_join_event, tangle_v2_put_user_event, - valid_public_listing_spec, + FixtureKey, build_fixture_event_from_parts, fixed_hex_bytes, fixture_event_json, + tangle_v2_auth_event, tangle_v2_generated_event, tangle_v2_group_config, + tangle_v2_group_create_event, tangle_v2_group_event, tangle_v2_group_metadata_event, + tangle_v2_join_event, tangle_v2_put_user_event, }; use tangle_crypto::{event_id_matches, verify_event_signature}; use tangle_groups::{GroupOutboxPayload, KIND_GROUP_CREATE_GROUP, KIND_GROUP_METADATA}; - use tangle_nips::{ - DeletionTarget, ListingProjectionEvaluation, evaluate_listing_projection, - parse_deletion_request, parse_relay_auth_event, - }; use tangle_protocol::UnixTimestamp; - use tangle_protocol::{EventId, PublicKeyHex}; - use tangle_store::{ - DeletionMarker, DeletionMarkerRepository, ListingProjectionRepository, RawEventRepository, - StoreEventOutcome, StoreProjectionOutcome, StoredEvent, - }; - - #[test] - fn fixture_specs_load_from_canonical_json() { - let listing = valid_public_listing_spec(); - let ineligible = projection_ineligible_listing_spec(); - let auth = auth_event_spec(); - let deletion = deletion_event_spec(); - - assert_eq!(listing.name(), "valid_public_listing"); - assert_eq!(listing.key(), "seller"); - assert_eq!(listing.created_at(), 1_714_124_433); - assert_eq!(listing.kind(), 30_402); - assert_eq!(listing.content(), "Sweet storage carrots."); - assert_eq!(listing.tags().len(), 10); - assert_eq!(ineligible.name(), "projection_ineligible_listing"); - assert_eq!(auth.name(), "auth_event"); - assert_eq!(deletion.name(), "deletion_event"); - } #[test] fn fixture_keys_have_stable_synthetic_public_keys() { - assert_eq!(FixtureKey::Seller.to_string(), "seller"); - assert_eq!(FixtureKey::Buyer.to_string(), "buyer"); assert_eq!(FixtureKey::Relay.to_string(), "relay"); assert_eq!(FixtureKey::Owner.to_string(), "owner"); assert_eq!(FixtureKey::Admin.to_string(), "admin"); assert_eq!(FixtureKey::Member.to_string(), "member"); assert_eq!(FixtureKey::Outsider.to_string(), "outsider"); assert_eq!( - FixtureKey::Seller.public_key().as_str(), - "989c0b76cb563971fdc9bef31ec06c3560f3249d6ee9e5d83c57625596e05f6f" + FixtureKey::Owner.public_key().as_str(), + "f76a39d05686e34a4420897e359371836145dd3973e3982568b60f8433adde6e" ); assert_ne!( - FixtureKey::Seller.public_key(), - FixtureKey::Buyer.public_key() + FixtureKey::Owner.public_key(), + FixtureKey::Admin.public_key() ); } @@ -605,14 +408,9 @@ mod tests { assert_eq!(first.id(), second.id()); assert_eq!(verify_event_signature(&first), Ok(())); assert_eq!(verify_event_signature(&auth), Ok(())); + assert!(event_id_matches(&first)); assert_eq!(first.unsigned().kind().as_u32(), KIND_GROUP_CREATE_GROUP); - assert_eq!( - parse_relay_auth_event(&auth) - .expect("auth parse") - .expect("auth") - .challenge(), - "challenge-001" - ); + assert_eq!(auth.unsigned().kind().as_u32(), 22_242); } #[test] @@ -653,214 +451,39 @@ mod tests { } #[test] - fn fixture_builder_signs_verifiable_public_listing_events() { - let event = build_fixture_event(&valid_public_listing_spec()).expect("event"); + fn fixture_json_uses_signed_event_shape() { + let event = + build_fixture_event_from_parts(FixtureKey::Member, 1_714_124_440, 1, Vec::new(), "hi") + .expect("event"); let json = fixture_event_json(&event); - assert!(event_id_matches(&event)); - assert_eq!(verify_event_signature(&event), Ok(())); - assert_eq!(json["kind"], 30_402); - assert_eq!(json["content"], "Sweet storage carrots."); - assert_eq!( - evaluate_listing_projection(&event), - ListingProjectionEvaluation::Eligible(Box::new( - evaluate_listing_projection(&event) - .projection() - .expect("projection") - .clone() - )) - ); - } - - #[test] - fn projection_ineligible_fixture_is_signed_but_not_projectable() { - let event = build_fixture_event(&projection_ineligible_listing_spec()).expect("event"); - let evaluation = evaluate_listing_projection(&event); - assert_eq!(verify_event_signature(&event), Ok(())); - assert_eq!( - evaluation.rejection().expect("rejection").reasons(), - &["tag `title` is required".to_owned()] - ); - } - - #[test] - fn auth_and_deletion_fixtures_build_protocol_specific_events() { - let auth = build_fixture_event(&auth_event_spec()).expect("auth"); - let deletion = build_fixture_event(&deletion_event_spec()).expect("deletion"); - let auth = parse_relay_auth_event(&auth) - .expect("auth parse") - .expect("auth event"); - let deletion = parse_deletion_request(&deletion) - .expect("deletion parse") - .expect("deletion event"); - let target = EventId::new(&"a".repeat(EventId::HEX_LENGTH)).expect("target"); - - assert_eq!(auth.relay(), "wss://relay.radroots.test"); - assert_eq!(auth.challenge(), "challenge-001"); - assert_eq!(deletion.targets(), &[DeletionTarget::Event(target)]); - } - - #[test] - fn fixture_spec_parser_rejects_invalid_json_and_keys() { - let invalid = fixture_spec_from_json("{").expect_err("json"); - let unsupported = fixture_spec_from_json( - r#"{"name":"bad","key":"unknown","created_at":1,"kind":1,"tags":[],"content":""}"#, - ) - .expect("fixture"); - - assert!(invalid.starts_with("fixture JSON is invalid")); - assert_eq!( - unsupported.fixture_key().expect_err("key"), - "fixture key `unknown` is unsupported" - ); - assert_eq!( - FixtureKey::Buyer, - fixture_spec_from_json( - r#"{"name":"buyer","key":"buyer","created_at":1,"kind":1,"tags":[],"content":""}"#, - ) - .expect("buyer") - .fixture_key() - .expect("buyer") - ); - assert_eq!( - FixtureKey::Relay, - fixture_spec_from_json( - r#"{"name":"relay","key":"relay","created_at":1,"kind":1,"tags":[],"content":""}"#, - ) - .expect("relay") - .fixture_key() - .expect("relay") - ); - } - - #[test] - fn fixture_builder_rejects_malformed_fixture_shapes() { - let bad_key = fixture_spec_from_json( - r#"{"name":"bad","key":"unknown","created_at":1,"kind":1,"tags":[],"content":""}"#, - ) - .expect("bad key"); - let bad_tag = fixture_spec_from_json( - r#"{"name":"bad","key":"seller","created_at":1,"kind":1,"tags":[[]],"content":""}"#, - ) - .expect("bad tag"); - let bad_kind = fixture_spec_from_json( - r#"{"name":"bad","key":"seller","created_at":1,"kind":4294967296,"tags":[],"content":""}"#, - ) - .expect("bad kind"); - - assert_eq!( - build_fixture_event(&bad_tag).expect_err("tag"), - "tag must not be empty" - ); - assert_eq!( - build_fixture_event(&bad_kind).expect_err("kind"), - "kind must fit in u32, got 4294967296" - ); - assert_eq!( - PublicKeyHex::HEX_LENGTH, - FixtureKey::Relay.public_key().as_str().len() - ); - assert_eq!( - build_fixture_event(&bad_key).expect_err("key"), - "fixture key `unknown` is unsupported" - ); + assert_eq!(json["kind"], 1); + assert_eq!(json["content"], "hi"); } #[test] - fn fixed_hex_decoder_rejects_bad_lengths_and_characters() { - assert_eq!( - fixed_hex_bytes("abc", 2, "fixture").expect_err("length"), - "fixture must decode to 2 bytes, got 3 hex characters" - ); - assert_eq!( - fixed_hex_bytes("0G", 1, "fixture").expect_err("low"), - "fixture must be lowercase hex" - ); - assert_eq!( - fixed_hex_bytes("G0", 1, "fixture").expect_err("high"), - "fixture must be lowercase hex" - ); - } - - #[test] - fn in_memory_repository_stores_raw_events_by_id_and_preserves_first_insert() { - let mut repository = InMemoryRepository::new(); - let event = build_fixture_event(&valid_public_listing_spec()).expect("event"); - let stored = StoredEvent::new(event.clone(), UnixTimestamp::new(100)); - let duplicate = StoredEvent::new(event.clone(), UnixTimestamp::new(200)); - let missing = EventId::new(&"f".repeat(EventId::HEX_LENGTH)).expect("missing"); + fn fixture_builder_rejects_invalid_parts() { + let bad_tag = build_fixture_event_from_parts(FixtureKey::Owner, 1, 1, vec![Vec::new()], "") + .expect_err("tag"); + let bad_kind = + build_fixture_event_from_parts(FixtureKey::Owner, 1, 4_294_967_296, Vec::new(), "") + .expect_err("kind"); - assert_eq!( - repository.put_event(stored.clone()).expect("insert"), - StoreEventOutcome::Inserted - ); - assert_eq!( - repository.put_event(duplicate).expect("duplicate"), - StoreEventOutcome::Duplicate - ); - assert_eq!( - repository.event_by_id(event.id()).expect("lookup"), - Some(stored.clone()) - ); - assert_eq!(repository.event_by_id(&missing).expect("missing"), None); - assert_eq!(repository.events().expect("events"), vec![stored]); + assert_eq!(bad_tag, "tag must not be empty"); + assert_eq!(bad_kind, "kind must fit in u32, got 4294967296"); } #[test] - fn in_memory_repository_stores_listing_projections_by_address() { - let mut repository = InMemoryRepository::new(); - let event = build_fixture_event(&valid_public_listing_spec()).expect("event"); - let projection = evaluate_listing_projection(&event) - .projection() - .expect("projection") - .clone(); - let address = projection.identity().address().clone(); - - assert_eq!( - repository - .put_listing_projection(projection.clone()) - .expect("insert"), - StoreProjectionOutcome::Inserted - ); + fn fixed_hex_bytes_validates_expected_width_and_lowercase() { + assert_eq!(fixed_hex_bytes("0a", 1, "value"), Ok(vec![10])); assert_eq!( - repository - .listing_projection(&address) - .expect("projection lookup"), - Some(projection.clone()) + fixed_hex_bytes("0a", 2, "value").expect_err("width"), + "value must decode to 2 bytes, got 2 hex characters" ); assert_eq!( - repository - .put_listing_projection(projection) - .expect("replace"), - StoreProjectionOutcome::Replaced - ); - } - - #[test] - fn in_memory_repository_appends_deletion_markers() { - let mut repository = InMemoryRepository::new(); - let deletion = build_fixture_event(&deletion_event_spec()).expect("deletion"); - let request = parse_deletion_request(&deletion) - .expect("deletion parse") - .expect("deletion request"); - let marker = DeletionMarker::new( - deletion.id().clone(), - deletion.unsigned().pubkey().clone(), - request.targets()[0].clone(), - UnixTimestamp::new(300), - ); - - repository - .put_deletion_marker(marker.clone()) - .expect("marker"); - repository - .put_deletion_marker(marker.clone()) - .expect("marker again"); - - assert_eq!( - repository.deletion_markers().expect("markers"), - vec![marker.clone(), marker] + fixed_hex_bytes("0G", 1, "value").expect_err("case"), + "value must be lowercase hex" ); } } diff --git a/scripts/local-surrealdb-down.sh b/scripts/local-surrealdb-down.sh @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -docker compose -f ops/local/surrealdb/compose.yml down diff --git a/scripts/local-surrealdb-up.sh b/scripts/local-surrealdb-up.sh @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -docker compose -f ops/local/surrealdb/compose.yml up -d --wait diff --git a/scripts/release_acceptance.sh b/scripts/release_acceptance.sh @@ -4,17 +4,9 @@ set -euo pipefail scripts/check.sh scripts/test.sh cargo nextest run --workspace -cargo test -p tangle --test nip01_conformance -cargo test -p tangle --test nip09_conformance -cargo test -p tangle --test nip42_conformance -cargo test -p tangle --test nip50_conformance -cargo test -p tangle --test nip99_conformance -cargo test -p tangle --test discussion_conformance -cargo test -p tangle --test moderation_conformance -cargo test -p tangle --test commerce_privacy_conformance -cargo test -p tangle --test abuse_conformance -cargo test -p tangle --test run_integration -cargo test -p tangle_runtime runtime_restore_command_imports_backup_and_rebuilds_projection_state +cargo test -p tangle_runtime --test base_relay_v2 +cargo test -p tangle_groups +cargo test -p tangle_store_pocket cargo test -p tangle_bench scripts/benchmark_report.sh cargo test -p tangle --test source_comments diff --git a/testing/fixtures/canonical/nostr/auth_event.json b/testing/fixtures/canonical/nostr/auth_event.json @@ -1,11 +0,0 @@ -{ - "name": "auth_event", - "key": "seller", - "created_at": 1714124435, - "kind": 22242, - "tags": [ - ["relay", "wss://relay.radroots.test"], - ["challenge", "challenge-001"] - ], - "content": "" -} diff --git a/testing/fixtures/canonical/nostr/deletion_event.json b/testing/fixtures/canonical/nostr/deletion_event.json @@ -1,10 +0,0 @@ -{ - "name": "deletion_event", - "key": "seller", - "created_at": 1714124436, - "kind": 5, - "tags": [ - ["e", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"] - ], - "content": "" -} diff --git a/testing/fixtures/canonical/nostr/projection_ineligible_listing.json b/testing/fixtures/canonical/nostr/projection_ineligible_listing.json @@ -1,13 +0,0 @@ -{ - "name": "projection_ineligible_listing", - "key": "seller", - "created_at": 1714124434, - "kind": 30402, - "tags": [ - ["d", "listing-missing-title"], - ["price", "8.00", "USD"], - ["unit", "each"], - ["fulfillment", "pickup"] - ], - "content": "This event is valid Nostr data but not projection eligible." -} diff --git a/testing/fixtures/canonical/nostr/valid_public_listing.json b/testing/fixtures/canonical/nostr/valid_public_listing.json @@ -1,19 +0,0 @@ -{ - "name": "valid_public_listing", - "key": "seller", - "created_at": 1714124433, - "kind": 30402, - "tags": [ - ["d", "listing-a"], - ["title", "Carrot bunches"], - ["price", "12.50", "USD"], - ["unit", "lb"], - ["fulfillment", "pickup"], - ["g", "c22yzug"], - ["category", "vegetables"], - ["t", "carrots"], - ["practice", "no spray"], - ["certification", "organic"] - ], - "content": "Sweet storage carrots." -}