lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit ec4fcf15cf05bf29e572760af7cf132cb0050ede
parent 2398cc0605ad6fbb5729b9f1867cc53e3e8bbede
Author: triesap <tyson@radroots.org>
Date:   Mon,  3 Aug 2026 06:57:51 +0000

transport-nostr: remove storage and outbox dependencies

- delete superseded fetch, outbox, publish, relay-document, and outcome implementations
- constrain the adapter source inventory to signer-free transport responsibilities
- realign architecture witnesses with the final split source and sink interfaces
- refresh governed source-maintenance artifacts after validator authority changes

Diffstat:
Mcrates/event_store/contracts/source_maintenance_v1.manifest.json | 4++--
Mcrates/event_store/contracts/source_maintenance_v1.manifest.sha256 | 2+-
Mcrates/event_store/src/generated/source_maintenance_manifest.rs | 4++--
Mcrates/nostr/tests/package_boundary.rs | 18+++++++++++-------
Mcrates/transport/tests/source_boundary.rs | 62+++++++++++++++++++++++++-------------------------------------
Mcrates/transport_nostr/README.md | 48+++++++++---------------------------------------
Dcrates/transport_nostr/src/fetch.rs | 1584-------------------------------------------------------------------------------
Dcrates/transport_nostr/src/nip11.rs | 26--------------------------
Dcrates/transport_nostr/src/outbox.rs | 1685-------------------------------------------------------------------------------
Dcrates/transport_nostr/src/outcome.rs | 248-------------------------------------------------------------------------------
Dcrates/transport_nostr/src/publish.rs | 1120-------------------------------------------------------------------------------
Dcrates/transport_nostr/src/relays.rs | 21---------------------
Mcrates/transport_nostr/tests/package_boundary.rs | 43+++++++++++++++++++++++++++++++++++++++++++
Mtools/xtask/src/contract.rs | 14++++----------
14 files changed, 97 insertions(+), 4782 deletions(-)

diff --git a/crates/event_store/contracts/source_maintenance_v1.manifest.json b/crates/event_store/contracts/source_maintenance_v1.manifest.json @@ -873,8 +873,8 @@ { "role": "contract_command_authority", "path": "tools/xtask/src/contract.rs", - "byte_length": 508073, - "sha256": "26d1825a0d7a4a3bb983478f2ef87dc228cbfb68f794bb52864e34a62ef1eead", + "byte_length": 507854, + "sha256": "92b1fd2cea9f3015285000550a75ca123ffe21f46556e8a4d81b4e3c180f0586", "hash_algorithm": "sha256_bytes_v1" }, { diff --git a/crates/event_store/contracts/source_maintenance_v1.manifest.sha256 b/crates/event_store/contracts/source_maintenance_v1.manifest.sha256 @@ -1 +1 @@ -194c8175537655f006e1bc778b6588703001cb4fec382cbdbc5fb1d011ee6500 +ba95f802e05eb85c5b2ffb65da747e503a7f8d58849096bc7835a30cee0c392f diff --git a/crates/event_store/src/generated/source_maintenance_manifest.rs b/crates/event_store/src/generated/source_maintenance_manifest.rs @@ -1,8 +1,8 @@ // @generated by `cargo xtask contract source-maintenance-manifest --write`; do not edit. -pub(crate) const SOURCE_MAINTENANCE_MANIFEST_JSON: &str = "{\n \"schema_version\": 1,\n \"contract_id\": \"radroots_event_store.source_maintenance_v1\",\n \"hook_id\": \"source_maintenance_v1\",\n \"manifest_schema\": {\n \"path\": \"crates/event_store/contracts/source_maintenance_v1.manifest.schema.json\",\n \"byte_length\": 12315,\n \"sha256\": \"96ea98a5abf3348b61565954cd34f2c622411d8dd34205ef04134bbe0e3d3f2b\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n \"predecessor\": {\n \"hook_id\": \"food_availability_projection_v1\",\n \"manifest\": {\n \"path\": \"crates/event_store/contracts/food_availability_projection_v1.manifest.json\",\n \"byte_length\": 17455,\n \"sha256\": \"02dfe1b450fbdac16e718888215b4dd5c85d8975440fa21e8f439fb24c2b2990\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n }\n },\n \"migration\": {\n \"version\": 4,\n \"name\": \"source_maintenance\",\n \"up\": {\n \"path\": \"crates/event_store/migrations/0004_source_maintenance.up.sql\",\n \"byte_length\": 19841,\n \"sha256\": \"ab2724188f8d08c897eebea2533a635e7c74282a25e84e4c0c37e78b08837a43\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n \"down\": {\n \"path\": \"crates/event_store/migrations/0004_source_maintenance.down.sql\",\n \"byte_length\": 5172,\n \"sha256\": \"fe44fd53c51545c08ea479b385e6781079dab70fc63da2a3c205d727a00ce860\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n \"schema_sha256\": \"074f85b663444ac150239ecd8441ea4a96ad83a798a55e22d2e5e2f7ee943a8c\",\n \"catalog\": {\n \"objects\": [\n \"radroots_event_store_source_capacity_delete_guard\",\n \"radroots_event_store_source_capacity_insert_guard\",\n \"radroots_event_store_source_capacity_marker_close_guard\",\n \"radroots_event_store_source_capacity_update_guard\",\n \"radroots_event_store_source_capacity_v1\",\n \"radroots_event_store_source_generation_capacity_advance\",\n \"radroots_event_store_source_generation_capacity_guard\"\n ],\n \"replaced_objects\": [\n \"radroots_event_store_food_availability_image_delete_guard\",\n \"radroots_event_store_food_availability_projection_delete_guard\",\n \"radroots_event_store_source_rebuild_marker_insert_guard\"\n ],\n \"tables\": [\n \"radroots_event_store_source_capacity_v1\"\n ],\n \"fts5_tables\": []\n }\n },\n \"source_maintenance\": {\n \"version\": 1,\n \"event_contract_registry_version\": 7,\n \"capacity_authority_id\": \"radroots_event_store_source_capacity_v1\",\n \"accounting\": {\n \"algorithm\": \"sqlite_cast_blob_octet_sum_v1\",\n \"raw_event_columns\": [\n \"event_id\",\n \"pubkey\",\n \"tags_json\",\n \"content\",\n \"sig\",\n \"raw_json\"\n ],\n \"raw_tag_columns\": [\n \"event_id\",\n \"tag_name\",\n \"tag_value\",\n \"tag_json\"\n ],\n \"nullable_raw_tag_columns\": [\n \"tag_value\"\n ]\n },\n \"limits\": {\n \"raw_events\": 25000,\n \"raw_tags\": 250000,\n \"raw_event_text_bytes\": 67108864,\n \"raw_tag_text_bytes\": 33554432,\n \"retained_source_generations\": 8\n },\n \"reopen_validation\": {\n \"mode\": \"bounded_full_raw_recount_v1\",\n \"raw_event_rejection_scan_bound\": 25001,\n \"raw_tag_rejection_scan_bound\": 250001,\n \"generation_history_validation\": \"bounded_count_plus_active_ordinal_v1\",\n \"retained_generation_rejection_scan_bound\": 9\n },\n \"rebuild_seal\": {\n \"nip09_hook_id\": \"nip09_reconciliation_v1\",\n \"nip09_manifest_sha256\": \"74af832420ffbaa9805e89df3c0b34f126a443e1598f757e3372f407f9003b77\",\n \"food_hook_id\": \"food_availability_projection_v1\",\n \"food_manifest_sha256\": \"02dfe1b450fbdac16e718888215b4dd5c85d8975440fa21e8f439fb24c2b2990\",\n \"food_scope_fingerprint_sha256\": \"8b63c5ddc48a2cc7db69295238b96d5f814dba50427c80b4d0079f061e6d3de0\",\n \"active_generation_authority\": \"radroots_event_store_source_state\",\n \"marker_close_authority\": \"radroots_event_store_source_capacity_marker_close_guard\"\n }\n },\n \"entry_points\": [\n {\n \"role\": \"migration_registry\",\n \"rust_path\": \"radroots_event_store::migrations::EVENT_STORE_MIGRATIONS[3]\"\n },\n {\n \"role\": \"migration_apply_hook\",\n \"rust_path\": \"radroots_event_store::schema::apply_migration_hook\"\n },\n {\n \"role\": \"migration_validation_hook\",\n \"rust_path\": \"radroots_event_store::schema::validate_migration_hook_state\"\n },\n {\n \"role\": \"capacity_query\",\n \"rust_path\": \"radroots_event_store::RadrootsEventStore::source_capacity_v1\"\n },\n {\n \"role\": \"raw_append_preflight\",\n \"rust_path\": \"radroots_event_store::source_maintenance_v1::preflight_unique_raw_source_append_v1\"\n },\n {\n \"role\": \"raw_append_advance\",\n \"rust_path\": \"radroots_event_store::source_maintenance_v1::advance_source_capacity_after_insert_v1\"\n },\n {\n \"role\": \"generation_append_preflight\",\n \"rust_path\": \"radroots_event_store::source_maintenance_v1::preflight_source_generation_append_v1\"\n },\n {\n \"role\": \"generation_rebuild_bind\",\n \"rust_path\": \"radroots_event_store::source_maintenance_v1::bind_source_capacity_to_generation_v1\"\n },\n {\n \"role\": \"sqlite_encoding_preflight\",\n \"rust_path\": \"radroots_event_store::store::validate_main_database_encoding\"\n },\n {\n \"role\": \"source_generation_history_rollback_guard\",\n \"rust_path\": \"radroots_event_store::schema::validate_rollback_preserves_source_generation_history\"\n },\n {\n \"role\": \"result_vector_executor\",\n \"rust_path\": \"source_maintenance_v1_result_vector\"\n }\n ],\n \"source_files\": [\n {\n \"role\": \"workspace_dependency_authority\",\n \"path\": \"Cargo.toml\",\n \"byte_length\": 12440,\n \"sha256\": \"8981f78d3ea85454a8cf7be156b3eef5659b399f1631d7c1068e7c336988070d\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"core_currency_value_authority\",\n \"path\": \"crates/core/src/currency.rs\",\n \"byte_length\": 4142,\n \"sha256\": \"42e6da2d8d2fdd6955dc2d83e98dd00266e02bc910b69923b117662c05089d14\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"core_decimal_value_authority\",\n \"path\": \"crates/core/src/decimal.rs\",\n \"byte_length\": 7839,\n \"sha256\": \"4b1d681a92d7a9e074bee7e1f20eb1499bb05bfc6528a6c9ba69fa7d00416550\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"core_money_value_authority\",\n \"path\": \"crates/core/src/money.rs\",\n \"byte_length\": 8025,\n \"sha256\": \"3f30bc21e21951a62fdc5d4033736ed42a883df590e8403940775bd2edfeebc5\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"core_percent_value_authority\",\n \"path\": \"crates/core/src/percent.rs\",\n \"byte_length\": 2652,\n \"sha256\": \"42ceab109881329f3539d06a0ee0381ab4359985fe185a18d58a9c9558c5eb7f\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"core_quantity_value_authority\",\n \"path\": \"crates/core/src/quantity.rs\",\n \"byte_length\": 6785,\n \"sha256\": \"d14e619da3829cdaf3ca7385fb892d330c286c1e21455c135c37518cbb588b5f\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"core_quantity_price_value_authority\",\n \"path\": \"crates/core/src/quantity_price.rs\",\n \"byte_length\": 7061,\n \"sha256\": \"d60f70377099470ed283f45868a5119d7bb46143cb250b5082a4c038ba7f6cdc\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"core_unit_value_authority\",\n \"path\": \"crates/core/src/unit.rs\",\n \"byte_length\": 10023,\n \"sha256\": \"550e9582b6a5290aac3f637b83853b21fefcbab92c432d067c83448f4b2fe3e9\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"blossom_public_surface\",\n \"path\": \"crates/blossom/src/lib.rs\",\n \"byte_length\": 510,\n \"sha256\": \"a4dfcbd193457c50c1b0fbfc91bb547981ecd3a6e23cfc6f1030edfb511dc58c\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"blossom_authorization_authority\",\n \"path\": \"crates/blossom/src/authorization.rs\",\n \"byte_length\": 39360,\n \"sha256\": \"461947b30516315a342b3b5697599f701d27050888f29f67f5d7a7ad4afa4c28\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"blossom_descriptor_authority\",\n \"path\": \"crates/blossom/src/descriptor.rs\",\n \"byte_length\": 13702,\n \"sha256\": \"b2a4ffa760256e1316f70e012d200e2b5f4afd8ede771b85a5147f96b247f599\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"blossom_error_authority\",\n \"path\": \"crates/blossom/src/error.rs\",\n \"byte_length\": 17229,\n \"sha256\": \"02af55beacf437040be17d1fc3271d6e2a62915f656ec2dbe06247deef83f95a\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"blossom_hash_authority\",\n \"path\": \"crates/blossom/src/hash.rs\",\n \"byte_length\": 11006,\n \"sha256\": \"45eca0d81ac0f46c305a32f81aafcdd90b14679d0614f72862289a6e5d4cb08f\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"blossom_media_type_authority\",\n \"path\": \"crates/blossom/src/media_type.rs\",\n \"byte_length\": 2368,\n \"sha256\": \"69db5c0f9fcdd7dbe1f22daa499f65603ae4ce198083dab2c73a36813fa18a46\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"blossom_url_authority\",\n \"path\": \"crates/blossom/src/url.rs\",\n \"byte_length\": 14580,\n \"sha256\": \"342c995fcf620e5fb32461a7d8276f5668ba2662890bd32d12780cc9121c6451\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_public_surface\",\n \"path\": \"crates/event/src/lib.rs\",\n \"byte_length\": 1892,\n \"sha256\": \"5d609b963a9b8db18ef96a10f394617413734d2631774d3296e6d8fc029cb02c\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_contract_facade\",\n \"path\": \"crates/event/src/contract.rs\",\n \"byte_length\": 208,\n \"sha256\": \"27466bf36461071931f391c60646a13f7781d19584583166cc54c0eece76d010\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_contract_registry_v7_authority\",\n \"path\": \"crates/event/src/contract/registry_v7.rs\",\n \"byte_length\": 145805,\n \"sha256\": \"0a62603f6fc05dc9f758561cf7da258c676ada810ceaeca7fb151364a5c83d62\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_envelope_authority\",\n \"path\": \"crates/event/src/envelope.rs\",\n \"byte_length\": 29310,\n \"sha256\": \"29f8d8c4b17c01cb5a8c7f59e4ce52e134180e0e6f37d265063d278b8f2ff26e\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_verification_typestate_authority\",\n \"path\": \"crates/event/src/verification.rs\",\n \"byte_length\": 9400,\n \"sha256\": \"417acb2ce670d266b1fa4fcafd6b48a910218deab38f57fb8b942056db76f94d\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_admission_typestate_authority\",\n \"path\": \"crates/event/src/admission.rs\",\n \"byte_length\": 7052,\n \"sha256\": \"62bd2ceb0f434946fdfc2b81f4efb13738676a29a6936c23a427e43aadf2d9cb\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_head_facade\",\n \"path\": \"crates/event/src/event_head.rs\",\n \"byte_length\": 97,\n \"sha256\": \"f761ff3e74c4f5e1e28381db00ce698c633ff048669b22984d14a587482e8e83\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_head_v1_authority\",\n \"path\": \"crates/event/src/event_head/v1.rs\",\n \"byte_length\": 6849,\n \"sha256\": \"9f4144d8d240023cf493ce0b538ade4c16dc44204d6b5f2797152caf6f9e7dba\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_ids_authority\",\n \"path\": \"crates/event/src/id.rs\",\n \"byte_length\": 48381,\n \"sha256\": \"a08fe3873815453a03318a706e2fa98a8a6728ec69d24368a87ec60025566fe0\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_trade_authority\",\n \"path\": \"crates/event/src/trade.rs\",\n \"byte_length\": 65549,\n \"sha256\": \"f818a7287ed82de3fb9ef1d973f0fb18d84b30eb4827ddc576ee502dd980145f\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_kinds_authority\",\n \"path\": \"crates/event/src/kinds.rs\",\n \"byte_length\": 34961,\n \"sha256\": \"8b3ce6193cab1f7e1587d0c1b2880a81b0aa43c77d89671b602772db37edcebf\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_tags_authority\",\n \"path\": \"crates/event/src/tags.rs\",\n \"byte_length\": 4530,\n \"sha256\": \"cb8f12a639fc72d9238e32495d9e67e928e6627fb8c94ec0a58c5d95e7373cd6\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_draft_authority\",\n \"path\": \"crates/event/src/draft.rs\",\n \"byte_length\": 51150,\n \"sha256\": \"ecc619aae28d688d13f35467398f17766b60f26c2bf3616d772ce65b7fa4fb53\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_calendar_authority\",\n \"path\": \"crates/event/src/calendar.rs\",\n \"byte_length\": 97992,\n \"sha256\": \"73e79d61ee175cb46e6123e1d28239252ddf0ad094dc1113bdb1286b63b88d57\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_classified_listing_authority\",\n \"path\": \"crates/event/src/classified_listing.rs\",\n \"byte_length\": 7956,\n \"sha256\": \"ac58484b87c7712c81f50f884d1c0dd60938571aa5f6f05d555191f529a4c2b0\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_profile_authority\",\n \"path\": \"crates/event/src/profile.rs\",\n \"byte_length\": 17011,\n \"sha256\": \"572bfe3b6f1711d109db445077f0a8453cad3f021cc0638f79201b618e16b978\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_post_authority\",\n \"path\": \"crates/event/src/post.rs\",\n \"byte_length\": 19968,\n \"sha256\": \"a822cd51eff9cbaa39db998cc5c90d117f7a983f0fe2d42d42118cb00e95982c\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_comment_authority\",\n \"path\": \"crates/event/src/comment.rs\",\n \"byte_length\": 34745,\n \"sha256\": \"24b7fda13229f21cee5cef2a9941e3aaafdbc486b19ed42731b0b6ee37c28703\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_food_availability_authority\",\n \"path\": \"crates/event/src/food_availability.rs\",\n \"byte_length\": 42829,\n \"sha256\": \"52472c07eb0855d884c60755dc3a3e1a963793451d707e51f769f2d78384f318\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_deletion_authority\",\n \"path\": \"crates/event/src/deletion.rs\",\n \"byte_length\": 30022,\n \"sha256\": \"8c3f2e32407520a8e8242c1293af32613c648fb76218b8003a18929ac0b3811f\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_dto_authority\",\n \"path\": \"crates/event/src/dto.rs\",\n \"byte_length\": 5038,\n \"sha256\": \"9e228c95ff6ff33f99441d8682781fd0a1c4d3f1db24284a43985dcbac3ba136\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_farm_crdt_authority\",\n \"path\": \"crates/event/src/farm_crdt.rs\",\n \"byte_length\": 21118,\n \"sha256\": \"cec265ac9c42b59a14be4eb96924d26a83477c27291f667f23e6e47a64b6b2c7\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_knowledge_authority\",\n \"path\": \"crates/event/src/knowledge.rs\",\n \"byte_length\": 54624,\n \"sha256\": \"3d55ff88a4e30d5d605f9ae4b2e96b9df6978d6c14e52f9aeb0b8039cfc200d7\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_operational_listing_authority\",\n \"path\": \"crates/event/src/operational_listing.rs\",\n \"byte_length\": 10307,\n \"sha256\": \"52e710db816c89c8d4b87c86541987f7447f2f7091c394c68d9cb98a27f90159\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_order_authority\",\n \"path\": \"crates/event/src/order.rs\",\n \"byte_length\": 60218,\n \"sha256\": \"4203fcc469612cac5c705884564e140b9fb076cd57079e22ed85275a8b936e50\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_reply_authority\",\n \"path\": \"crates/event/src/reply.rs\",\n \"byte_length\": 15581,\n \"sha256\": \"b0b83a1e96125c0892d932e9314519242a715fa8f46334aff765ffd248c91d9a\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_trade_validation_authority\",\n \"path\": \"crates/event/src/trade_validation.rs\",\n \"byte_length\": 4669,\n \"sha256\": \"1486f79bd1d0eadea6cc1b7646ce1f7f57ccade18334a3ea37286426676b9b98\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_relay_hint_authority\",\n \"path\": \"crates/event/src/relay_hint.rs\",\n \"byte_length\": 13598,\n \"sha256\": \"fb8b026eeccf51d78f20771c44223e7b48825aa0e3c760d6b3cb90148021a674\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_media_authority\",\n \"path\": \"crates/event/src/media.rs\",\n \"byte_length\": 3455,\n \"sha256\": \"10678af3a202d5367dbcf3bcdc0bf9fdf8d8fb89b045d7d3fb96b3cc195ab6f9\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_social_authority\",\n \"path\": \"crates/event/src/social.rs\",\n \"byte_length\": 6076,\n \"sha256\": \"a2dab19caad46eab14fac86e7c878a801a450d8ac374b0e728b71a5a224eef19\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_wire_facade\",\n \"path\": \"crates/event/src/wire.rs\",\n \"byte_length\": 68,\n \"sha256\": \"cb52f6006f7ecd862707d6b048f9fc407e0cd5ebcd3091b3c54e195ffa5cba64\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_wire_v1_authority\",\n \"path\": \"crates/event/src/wire/v1.rs\",\n \"byte_length\": 22198,\n \"sha256\": \"c818bc1f67e215948ba253c8c62b09ab788db9aeec9b6df8096014b55565cb8c\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_public_surface\",\n \"path\": \"crates/event_codec/src/lib.rs\",\n \"byte_length\": 2448,\n \"sha256\": \"7d4b0040ad3971f34395b17cb1aa40617e9c2c9e1b4222676c388c3adaa47e07\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_verification_facade\",\n \"path\": \"crates/event_codec/src/verification.rs\",\n \"byte_length\": 253,\n \"sha256\": \"b49a32df605035c87f295c0a151140d43d2e588c5e11b05183e5fc92993dae0e\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_verification_v1_authority\",\n \"path\": \"crates/event_codec/src/verification/v1.rs\",\n \"byte_length\": 6584,\n \"sha256\": \"72f64615bd6b6dc3b051579e3069b2213f3781dcc28e12f0b533c7d81b81b9b1\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_registry_v7_admission_authority\",\n \"path\": \"crates/event_codec/src/admission/registry_v7.rs\",\n \"byte_length\": 5228,\n \"sha256\": \"62da30cb6ef7d0ed2d43715a73bd529283e8ae8c12571bc5c68722cb400f17b8\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_admission_facade\",\n \"path\": \"crates/event_codec/src/admission.rs\",\n \"byte_length\": 21251,\n \"sha256\": \"25928e734e7b3688f92af806a12a13458c48fb47e94249f60f0226300d5da2b4\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_profile_inbound_facade\",\n \"path\": \"crates/event_codec/src/profile/inbound.rs\",\n \"byte_length\": 86,\n \"sha256\": \"9994cbca9dcc7450ad133b6b927e7c06bee08ef813652452cc1e56a284c1be10\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_profile_registry_v7_authority\",\n \"path\": \"crates/event_codec/src/profile/inbound/registry_v7.rs\",\n \"byte_length\": 10347,\n \"sha256\": \"32111c0e0592229c11a93a3a8054e7b124b039451aa696a9abcb56df2a6608d3\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_post_inbound_facade\",\n \"path\": \"crates/event_codec/src/post/inbound.rs\",\n \"byte_length\": 86,\n \"sha256\": \"9994cbca9dcc7450ad133b6b927e7c06bee08ef813652452cc1e56a284c1be10\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_post_registry_v7_authority\",\n \"path\": \"crates/event_codec/src/post/inbound/registry_v7.rs\",\n \"byte_length\": 15985,\n \"sha256\": \"e17083b0596e3a55994c399bc6272c0ebf04cabaa513260b3d45dd546f7f50ef\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_reply_inbound_facade\",\n \"path\": \"crates/event_codec/src/reply/inbound.rs\",\n \"byte_length\": 86,\n \"sha256\": \"9994cbca9dcc7450ad133b6b927e7c06bee08ef813652452cc1e56a284c1be10\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_reply_registry_v7_authority\",\n \"path\": \"crates/event_codec/src/reply/inbound/registry_v7.rs\",\n \"byte_length\": 26662,\n \"sha256\": \"d1d11116ca27801b7e2f17da60900e6a9ca0b6f27ea9ee0bf19e97dbf24c0c32\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_comment_inbound_facade\",\n \"path\": \"crates/event_codec/src/comment/inbound.rs\",\n \"byte_length\": 86,\n \"sha256\": \"9994cbca9dcc7450ad133b6b927e7c06bee08ef813652452cc1e56a284c1be10\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_comment_registry_v7_authority\",\n \"path\": \"crates/event_codec/src/comment/inbound/registry_v7.rs\",\n \"byte_length\": 53311,\n \"sha256\": \"c63fe853536b17bded6073f32b53434b60fc5123f911d01c642a6b3982f09c70\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_deletion_facade\",\n \"path\": \"crates/event_codec/src/deletion/mod.rs\",\n \"byte_length\": 115,\n \"sha256\": \"17d56f82ddb0a86bc97abbcf3e037fe460ea31fff2548f527be4bf004dac3a95\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_deletion_reconciliation_v1_authority\",\n \"path\": \"crates/event_codec/src/deletion/reconciliation_v1.rs\",\n \"byte_length\": 38028,\n \"sha256\": \"c6901f559e83700610595720c47a4d062078ae8411e1fc2fb952dd215b19dd90\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_error_authority\",\n \"path\": \"crates/event_codec/src/error.rs\",\n \"byte_length\": 3666,\n \"sha256\": \"174ce982bca37fac28d016c3ab3236441c9b31b2169ec94db6c9288b36c72849\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_food_admission_authority\",\n \"path\": \"crates/event_codec/src/food_availability/admission.rs\",\n \"byte_length\": 5681,\n \"sha256\": \"99809859090f5295572688d06bbb30dbe50524ba2c996de7ec5b26f6dd390643\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_food_authored_authority\",\n \"path\": \"crates/event_codec/src/food_availability/authored.rs\",\n \"byte_length\": 9724,\n \"sha256\": \"8673ec62ed3fc47b691efd9ca828643719803971e72b756c5b4603d409d0fba5\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_food_inbound_facade\",\n \"path\": \"crates/event_codec/src/food_availability/inbound.rs\",\n \"byte_length\": 86,\n \"sha256\": \"9994cbca9dcc7450ad133b6b927e7c06bee08ef813652452cc1e56a284c1be10\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_food_registry_v7_authority\",\n \"path\": \"crates/event_codec/src/food_availability/inbound/registry_v7.rs\",\n \"byte_length\": 26037,\n \"sha256\": \"92a7e75da3293967cec17a514479144c3c948a64542577dd58c7f07992968e4f\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_job_traits_authority\",\n \"path\": \"crates/event_codec/src/job/traits.rs\",\n \"byte_length\": 4946,\n \"sha256\": \"1b3558a3196744005978dceddd33d3a72ad1ab8f50fb1cbfd997dcfe98bb9e39\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_job_encode_authority\",\n \"path\": \"crates/event_codec/src/job/encode.rs\",\n \"byte_length\": 1670,\n \"sha256\": \"5d6cae6309fcdd02deb7c751b5c451d257cf1900986391cb39cbb2bfd1ddb577\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_knowledge_verification_authority\",\n \"path\": \"crates/event_codec/src/knowledge/verification.rs\",\n \"byte_length\": 6986,\n \"sha256\": \"6f8a10ed262ce37f05acd9c89a1e37ca2fa99ce7da18407891e8abe228ee2349\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_operational_listing_tags_authority\",\n \"path\": \"crates/event_codec/src/operational_listing/tags.rs\",\n \"byte_length\": 50222,\n \"sha256\": \"55b6860bc6f4699dd47a49f8336764e9db78aafb7c32849102dedeb96804b373\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_order_decode_authority\",\n \"path\": \"crates/event_codec/src/order/decode.rs\",\n \"byte_length\": 42667,\n \"sha256\": \"033d9465bc041d205eab3f820572b719a35651340dcb50bdce7c60fabee5f6e1\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_profile_facade\",\n \"path\": \"crates/event_codec/src/profile/mod.rs\",\n \"byte_length\": 1456,\n \"sha256\": \"06b890da54580e2ec68f7e729281b7c3b650ac0a95a4fcb0db3c6cdaa591a7ef\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_tag_builders_authority\",\n \"path\": \"crates/event_codec/src/tag_builders.rs\",\n \"byte_length\": 9232,\n \"sha256\": \"bd81ccf04fd72358331b48eddcde087a95e115c5bd90c05fd9332644979ad5e6\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_trade_facade\",\n \"path\": \"crates/event_codec/src/trade/mod.rs\",\n \"byte_length\": 21472,\n \"sha256\": \"10cee4b6cd6429ac9eb00327a521bcd0982fae4de98bda77e4728fda002965d3\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_store_dependency_authority\",\n \"path\": \"crates/event_store/Cargo.toml\",\n \"byte_length\": 1797,\n \"sha256\": \"e79f4b69500553835e2cde28e7d9139788bebe6e2cd4e0b4ae72cc150491daf4\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_store_error_and_limits\",\n \"path\": \"crates/event_store/src/error.rs\",\n \"byte_length\": 19458,\n \"sha256\": \"3d87df984af6ae5decf7ca8c3a8d5422be6c647e6a05490225b6b2037bf3701b\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"generated_descriptor_registration\",\n \"path\": \"crates/event_store/src/generated.rs\",\n \"byte_length\": 144,\n \"sha256\": \"6b0a8d6f249bd4fc3f878d37cb5e418680f0f1be2d9eec2518dedf03efc47121\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"public_surface\",\n \"path\": \"crates/event_store/src/lib.rs\",\n \"byte_length\": 3860,\n \"sha256\": \"de1c5cc1ab36e1166d23e2b76aeae1d5f0f401cf07ce243590741c0126d02132\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"migration_registry\",\n \"path\": \"crates/event_store/src/migrations.rs\",\n \"byte_length\": 73585,\n \"sha256\": \"a47477d04759ec6e71d14d3d05459864eae78fce0e3c2481a1b03147f04637f7\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"predecessor_model_public_surface\",\n \"path\": \"crates/event_store/src/model.rs\",\n \"byte_length\": 32070,\n \"sha256\": \"2cdbf6d1a4e0fa6680f45f02758513509339a7cf3a2387e824fb73b0edf749a4\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_store_reconciliation_v1_model\",\n \"path\": \"crates/event_store/src/model/reconciliation_v1.rs\",\n \"byte_length\": 10446,\n \"sha256\": \"475b7b840bffdae7e3f7a31f5b940b9dfa579eaa1cac640729aaaeadabf83ba6\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_store_reconciliation_v1_ingest_model\",\n \"path\": \"crates/event_store/src/model/ingest_reconciliation_v1.rs\",\n \"byte_length\": 1588,\n \"sha256\": \"6117e797674c35bb1ccebffbc6a8c0108bfc38c7c9f82607066dfd802b8f0734\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"addressable_transition_feed_model\",\n \"path\": \"crates/event_store/src/model/addressable_transition_feed_v1.rs\",\n \"byte_length\": 22229,\n \"sha256\": \"e651952d73ed0a29d2b137076f56f699beada205ac84ed8a96834e86e343fa78\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"current_visibility_model\",\n \"path\": \"crates/event_store/src/model/current_visibility_v1.rs\",\n \"byte_length\": 5634,\n \"sha256\": \"d64429bc7985231e923c1c3e5295d94270a817dd5dc96693f35dfdf2dc398e4f\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"food_projection_model\",\n \"path\": \"crates/event_store/src/model/food_availability_projection_v1.rs\",\n \"byte_length\": 17108,\n \"sha256\": \"67861a966d674efbc40f0b0433db2ec006048ff923f6261df4161e2ddba717b5\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"source_generation_rebuild_authority\",\n \"path\": \"crates/event_store/src/nip09/reconciliation_v1.rs\",\n \"byte_length\": 184833,\n \"sha256\": \"3ca802cb7a8444c8512b2be1390a019281704316a430fb9b75a8ad2cad358a07\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"nip09_successor_result_vector_executor\",\n \"path\": \"crates/event_store/tests/support/nip09_reconciliation_v1_result_vector_v2.rs\",\n \"byte_length\": 18205,\n \"sha256\": \"c632beee70dc777b8dd2a4f88cadb55296fee7aa5742e6e2c9f6d49f26ae2c78\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"schema_migration_and_reopen_authority\",\n \"path\": \"crates/event_store/src/schema.rs\",\n \"byte_length\": 146146,\n \"sha256\": \"93b060e80d3edd73f86208e4bf698fa9d53eaf1eeb04526c9261fb8b5726fb0d\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"public_store_and_transaction_authority\",\n \"path\": \"crates/event_store/src/store.rs\",\n \"byte_length\": 393218,\n \"sha256\": \"64565c3bc043779e1c2f26d9761901b109b4acab0f48c2021102c0e4e1f1f726\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"addressable_transition_feed_store\",\n \"path\": \"crates/event_store/src/store/addressable_transition_feed_v1.rs\",\n \"byte_length\": 40209,\n \"sha256\": \"2cebcca9602633652a79e7041d18d3a7d3feb1388a98f4f68137b9814efcedfa\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"current_visibility_store\",\n \"path\": \"crates/event_store/src/store/current_visibility_v1.rs\",\n \"byte_length\": 15737,\n \"sha256\": \"aa5d1bff7a5368cbac2b37906bb40f54bfc8aa6906bab34369569fdd105bf899\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"food_projection_store\",\n \"path\": \"crates/event_store/src/store/food_availability_projection_v1.rs\",\n \"byte_length\": 48919,\n \"sha256\": \"3a30da308ef863b806ed63d1cb65ac80f6a946a71821af84a0c595330491757e\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"predecessor_post_core_v1_extension\",\n \"path\": \"crates/event_store/src/store/post_core_extensions_v1.rs\",\n \"byte_length\": 6569,\n \"sha256\": \"f5bd8ddb45e1b2144895bd8da737fc92db051be1844ce7e8047a32b47376ce37\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"predecessor_post_core_v1_storage\",\n \"path\": \"crates/event_store/src/store/post_core_storage_v1.rs\",\n \"byte_length\": 16538,\n \"sha256\": \"fcd6546c23a6cba12b70d92728d19b6ee5d43174b83495f820348b77efa33aab\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"raw_ingest_capacity_authority\",\n \"path\": \"crates/event_store/src/store/protocol_reconciliation_v1.rs\",\n \"byte_length\": 29950,\n \"sha256\": \"c72aaff06e4f35d0b4523c6625e83345879f9e15b9fe9563dccf589e5d01e277\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"predecessor_protocol_storage\",\n \"path\": \"crates/event_store/src/store/protocol_storage_v1.rs\",\n \"byte_length\": 10835,\n \"sha256\": \"c82179b9f57968191f3e71dd008cc701787c62715ada74090eeecaa3b213076d\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"source_maintenance_runtime\",\n \"path\": \"crates/event_store/src/source_maintenance_v1.rs\",\n \"byte_length\": 51849,\n \"sha256\": \"db43df5849c811d767a87bfa7884ce499400d0b169734e18242542a4415b3a51\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"predecessor_food_result_vector_executor\",\n \"path\": \"crates/event_store/tests/food_availability_projection_v1_result_vector.rs\",\n \"byte_length\": 34046,\n \"sha256\": \"776903c6431ff07f26c6ad6b713db5628efce1a72f34d86de6e47d4404fa3a6d\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"artifact_transaction_authority\",\n \"path\": \"tools/xtask/src/contract/artifact_bundle.rs\",\n \"byte_length\": 39149,\n \"sha256\": \"3d1bba3980b80698d2a21e26caf7af6fe4d9dc4a7fbabc6e406b5d33928b457f\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"predecessor_successor_governance\",\n \"path\": \"tools/xtask/src/contract/food_availability_projection.rs\",\n \"byte_length\": 198991,\n \"sha256\": \"36fd3d8b5dda3b0855a06a47dae14cae7f4cc84aaca9902c31477c218859fccc\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"transitive_predecessor_membership_governance\",\n \"path\": \"tools/xtask/src/contract/nip09_reconciliation.rs\",\n \"byte_length\": 842433,\n \"sha256\": \"c5361d9da74ac6cbec1baa890818d5b00c935c5015c8e0cfaa9036a94fe2b69e\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"source_maintenance_governance\",\n \"path\": \"tools/xtask/src/contract/source_maintenance.rs\",\n \"byte_length\": 199270,\n \"sha256\": \"65b83a3ee1a700527d15c7403ca042cf2aaa4cd8935a2f8d2ab190c491a815bd\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"contract_command_authority\",\n \"path\": \"tools/xtask/src/contract.rs\",\n \"byte_length\": 508073,\n \"sha256\": \"26d1825a0d7a4a3bb983478f2ef87dc228cbfb68f794bb52864e34a62ef1eead\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"dto_root_generation_authority\",\n \"path\": \"tools/xtask/src/dto_roots.rs\",\n \"byte_length\": 35940,\n \"sha256\": \"a899a5695d7533c151d03477c4bd697a6e55c47a4980549724ac42046af694f5\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"xtask_dispatch_and_release_preflight\",\n \"path\": \"tools/xtask/src/main.rs\",\n \"byte_length\": 16426,\n \"sha256\": \"abc24896c6c740c10811a3e003ec4be8ebdb376a7b6b01445bfa7a5889f094a7\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n }\n ],\n \"public_api\": {\n \"inherited_predecessor_symbols\": [\n \"RADROOTS_ADDRESSABLE_TRANSITION_CURSOR_JSON_MAX_BYTES_V1\",\n \"RADROOTS_ADDRESSABLE_TRANSITION_D_TAG_MAX_BYTES_V1\",\n \"RADROOTS_ADDRESSABLE_TRANSITION_FEED_VERSION_V1\",\n \"RADROOTS_ADDRESSABLE_TRANSITION_PAGE_LIMIT_MAX_V1\",\n \"RADROOTS_ADDRESSABLE_TRANSITION_PAGE_RAW_JSON_MAX_BYTES_V1\",\n \"RADROOTS_ADDRESSABLE_TRANSITION_PAGE_SCAN_MAX_V1\",\n \"RADROOTS_ADDRESSABLE_TRANSITION_SCOPE_KIND_MAX_V1\",\n \"RADROOTS_FOOD_AVAILABILITY_PROJECTION_APPLY_PAGE_LIMIT_V1\",\n \"RADROOTS_FOOD_AVAILABILITY_PROJECTION_VERSION_V1\",\n \"RADROOTS_FOOD_AVAILABILITY_SEARCH_QUERY_MAX_BYTES_V1\",\n \"RADROOTS_FOOD_AVAILABILITY_SEARCH_QUERY_MAX_TERMS_V1\",\n \"RadrootsAddressableTransitionCauseV1\",\n \"RadrootsAddressableTransitionCoordinateV1\",\n \"RadrootsAddressableTransitionCursorV1\",\n \"RadrootsAddressableTransitionEventReferenceV1\",\n \"RadrootsAddressableTransitionOriginV1\",\n \"RadrootsAddressableTransitionPageV1\",\n \"RadrootsAddressableTransitionRawHeadDecisionV1\",\n \"RadrootsAddressableTransitionScopeFingerprintV1\",\n \"RadrootsAddressableTransitionScopeV1\",\n \"RadrootsAddressableTransitionV1\",\n \"RadrootsAddressableTransitionVisibilityV1\",\n \"RadrootsCurrentEventVisibilityV1\",\n \"RadrootsCurrentVisibilityDecisionV1\",\n \"RadrootsFoodAvailabilitySearchQueryV1\",\n \"RadrootsFoodAvailabilityStatusFilterV1\",\n \"RadrootsNip09SuppressionEvidenceV1\",\n \"RadrootsNip09SuppressionOutcome\",\n \"RadrootsNip09SuppressionReason\",\n \"RadrootsStoreProducedCanonicalEventV1\",\n \"RadrootsStoredFoodAvailabilityImageV1\",\n \"RadrootsStoredFoodAvailabilityV1\"\n ],\n \"added_symbols\": [\n \"RADROOTS_EVENT_STORE_RAW_EVENT_COUNT_LIMIT_V1\",\n \"RADROOTS_EVENT_STORE_RAW_EVENT_TEXT_BYTES_LIMIT_V1\",\n \"RADROOTS_EVENT_STORE_RAW_TAG_COUNT_LIMIT_V1\",\n \"RADROOTS_EVENT_STORE_RAW_TAG_TEXT_BYTES_LIMIT_V1\",\n \"RADROOTS_EVENT_STORE_RETAINED_SOURCE_GENERATION_LIMIT_V1\",\n \"RadrootsEventStoreSourceCapacityResourceV1\",\n \"RadrootsEventStoreSourceCapacityV1\"\n ],\n \"methods\": [\n \"RadrootsEventStore::source_capacity_v1\",\n \"RadrootsEventStoreSourceCapacityResourceV1::as_str\",\n \"RadrootsEventStoreSourceCapacityV1::source_generation\",\n \"RadrootsEventStoreSourceCapacityV1::raw_event_count\",\n \"RadrootsEventStoreSourceCapacityV1::raw_tag_count\",\n \"RadrootsEventStoreSourceCapacityV1::raw_event_text_bytes\",\n \"RadrootsEventStoreSourceCapacityV1::raw_tag_text_bytes\",\n \"RadrootsEventStoreSourceCapacityV1::raw_high_water_seq\",\n \"RadrootsEventStoreSourceCapacityV1::retained_generation_count\",\n \"RadrootsEventStoreSourceCapacityV1::retained_generation_limit\"\n ],\n \"error_variants\": [\n \"SourceCapacityExceeded\",\n \"SourceGenerationHistoryLimitReached\",\n \"PersistedEphemeralRawEvent\",\n \"SourceCapacityStateDrift\",\n \"SqliteMainDatabaseEncodingNotUtf8\",\n \"RollbackWouldDiscardSourceGenerationHistory\"\n ],\n \"removed_symbols\": [\n \"RadrootsEventStoreReconciliationResource\",\n \"RadrootsEventStoreError::ReconciliationCapacityExceeded\"\n ],\n \"breaking_replacements\": [\n {\n \"removed\": \"RadrootsEventStoreReconciliationResource\",\n \"replacement\": \"RadrootsEventStoreSourceCapacityResourceV1\"\n },\n {\n \"removed\": \"RadrootsEventStoreError::ReconciliationCapacityExceeded\",\n \"replacement\": \"RadrootsEventStoreError::SourceCapacityExceeded\"\n }\n ]\n },\n \"result_vector\": {\n \"canonical_path\": \"contracts/conformance/vectors/event_store/source_maintenance.v1.json\",\n \"mirror_path\": \"crates/event_store/tests/fixtures/source_maintenance.v1.json\",\n \"byte_length\": 16253,\n \"sha256\": \"997aba2604a2b9d199fb87dc9d07942ca50d91863aeadcf3eeacf16d191dd71f\",\n \"hash_algorithm\": \"sha256_bytes_v1\",\n \"executor_id\": \"radroots_event_store.source_maintenance_v1.result_vector_executor.v1\",\n \"executor_path\": \"crates/event_store/tests/source_maintenance_v1_result_vector.rs\",\n \"executor_test\": \"source_maintenance_v1_result_vector\",\n \"executor_byte_length\": 23510,\n \"executor_sha256\": \"a7487afdfe19fc5fc794811d0f0e6035203e1aabcf0a33a1d398f6b3555d38f3\",\n \"executor_hash_algorithm\": \"sha256_bytes_v1\"\n }\n}\n"; +pub(crate) const SOURCE_MAINTENANCE_MANIFEST_JSON: &str = "{\n \"schema_version\": 1,\n \"contract_id\": \"radroots_event_store.source_maintenance_v1\",\n \"hook_id\": \"source_maintenance_v1\",\n \"manifest_schema\": {\n \"path\": \"crates/event_store/contracts/source_maintenance_v1.manifest.schema.json\",\n \"byte_length\": 12315,\n \"sha256\": \"96ea98a5abf3348b61565954cd34f2c622411d8dd34205ef04134bbe0e3d3f2b\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n \"predecessor\": {\n \"hook_id\": \"food_availability_projection_v1\",\n \"manifest\": {\n \"path\": \"crates/event_store/contracts/food_availability_projection_v1.manifest.json\",\n \"byte_length\": 17455,\n \"sha256\": \"02dfe1b450fbdac16e718888215b4dd5c85d8975440fa21e8f439fb24c2b2990\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n }\n },\n \"migration\": {\n \"version\": 4,\n \"name\": \"source_maintenance\",\n \"up\": {\n \"path\": \"crates/event_store/migrations/0004_source_maintenance.up.sql\",\n \"byte_length\": 19841,\n \"sha256\": \"ab2724188f8d08c897eebea2533a635e7c74282a25e84e4c0c37e78b08837a43\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n \"down\": {\n \"path\": \"crates/event_store/migrations/0004_source_maintenance.down.sql\",\n \"byte_length\": 5172,\n \"sha256\": \"fe44fd53c51545c08ea479b385e6781079dab70fc63da2a3c205d727a00ce860\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n \"schema_sha256\": \"074f85b663444ac150239ecd8441ea4a96ad83a798a55e22d2e5e2f7ee943a8c\",\n \"catalog\": {\n \"objects\": [\n \"radroots_event_store_source_capacity_delete_guard\",\n \"radroots_event_store_source_capacity_insert_guard\",\n \"radroots_event_store_source_capacity_marker_close_guard\",\n \"radroots_event_store_source_capacity_update_guard\",\n \"radroots_event_store_source_capacity_v1\",\n \"radroots_event_store_source_generation_capacity_advance\",\n \"radroots_event_store_source_generation_capacity_guard\"\n ],\n \"replaced_objects\": [\n \"radroots_event_store_food_availability_image_delete_guard\",\n \"radroots_event_store_food_availability_projection_delete_guard\",\n \"radroots_event_store_source_rebuild_marker_insert_guard\"\n ],\n \"tables\": [\n \"radroots_event_store_source_capacity_v1\"\n ],\n \"fts5_tables\": []\n }\n },\n \"source_maintenance\": {\n \"version\": 1,\n \"event_contract_registry_version\": 7,\n \"capacity_authority_id\": \"radroots_event_store_source_capacity_v1\",\n \"accounting\": {\n \"algorithm\": \"sqlite_cast_blob_octet_sum_v1\",\n \"raw_event_columns\": [\n \"event_id\",\n \"pubkey\",\n \"tags_json\",\n \"content\",\n \"sig\",\n \"raw_json\"\n ],\n \"raw_tag_columns\": [\n \"event_id\",\n \"tag_name\",\n \"tag_value\",\n \"tag_json\"\n ],\n \"nullable_raw_tag_columns\": [\n \"tag_value\"\n ]\n },\n \"limits\": {\n \"raw_events\": 25000,\n \"raw_tags\": 250000,\n \"raw_event_text_bytes\": 67108864,\n \"raw_tag_text_bytes\": 33554432,\n \"retained_source_generations\": 8\n },\n \"reopen_validation\": {\n \"mode\": \"bounded_full_raw_recount_v1\",\n \"raw_event_rejection_scan_bound\": 25001,\n \"raw_tag_rejection_scan_bound\": 250001,\n \"generation_history_validation\": \"bounded_count_plus_active_ordinal_v1\",\n \"retained_generation_rejection_scan_bound\": 9\n },\n \"rebuild_seal\": {\n \"nip09_hook_id\": \"nip09_reconciliation_v1\",\n \"nip09_manifest_sha256\": \"74af832420ffbaa9805e89df3c0b34f126a443e1598f757e3372f407f9003b77\",\n \"food_hook_id\": \"food_availability_projection_v1\",\n \"food_manifest_sha256\": \"02dfe1b450fbdac16e718888215b4dd5c85d8975440fa21e8f439fb24c2b2990\",\n \"food_scope_fingerprint_sha256\": \"8b63c5ddc48a2cc7db69295238b96d5f814dba50427c80b4d0079f061e6d3de0\",\n \"active_generation_authority\": \"radroots_event_store_source_state\",\n \"marker_close_authority\": \"radroots_event_store_source_capacity_marker_close_guard\"\n }\n },\n \"entry_points\": [\n {\n \"role\": \"migration_registry\",\n \"rust_path\": \"radroots_event_store::migrations::EVENT_STORE_MIGRATIONS[3]\"\n },\n {\n \"role\": \"migration_apply_hook\",\n \"rust_path\": \"radroots_event_store::schema::apply_migration_hook\"\n },\n {\n \"role\": \"migration_validation_hook\",\n \"rust_path\": \"radroots_event_store::schema::validate_migration_hook_state\"\n },\n {\n \"role\": \"capacity_query\",\n \"rust_path\": \"radroots_event_store::RadrootsEventStore::source_capacity_v1\"\n },\n {\n \"role\": \"raw_append_preflight\",\n \"rust_path\": \"radroots_event_store::source_maintenance_v1::preflight_unique_raw_source_append_v1\"\n },\n {\n \"role\": \"raw_append_advance\",\n \"rust_path\": \"radroots_event_store::source_maintenance_v1::advance_source_capacity_after_insert_v1\"\n },\n {\n \"role\": \"generation_append_preflight\",\n \"rust_path\": \"radroots_event_store::source_maintenance_v1::preflight_source_generation_append_v1\"\n },\n {\n \"role\": \"generation_rebuild_bind\",\n \"rust_path\": \"radroots_event_store::source_maintenance_v1::bind_source_capacity_to_generation_v1\"\n },\n {\n \"role\": \"sqlite_encoding_preflight\",\n \"rust_path\": \"radroots_event_store::store::validate_main_database_encoding\"\n },\n {\n \"role\": \"source_generation_history_rollback_guard\",\n \"rust_path\": \"radroots_event_store::schema::validate_rollback_preserves_source_generation_history\"\n },\n {\n \"role\": \"result_vector_executor\",\n \"rust_path\": \"source_maintenance_v1_result_vector\"\n }\n ],\n \"source_files\": [\n {\n \"role\": \"workspace_dependency_authority\",\n \"path\": \"Cargo.toml\",\n \"byte_length\": 12440,\n \"sha256\": \"8981f78d3ea85454a8cf7be156b3eef5659b399f1631d7c1068e7c336988070d\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"core_currency_value_authority\",\n \"path\": \"crates/core/src/currency.rs\",\n \"byte_length\": 4142,\n \"sha256\": \"42e6da2d8d2fdd6955dc2d83e98dd00266e02bc910b69923b117662c05089d14\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"core_decimal_value_authority\",\n \"path\": \"crates/core/src/decimal.rs\",\n \"byte_length\": 7839,\n \"sha256\": \"4b1d681a92d7a9e074bee7e1f20eb1499bb05bfc6528a6c9ba69fa7d00416550\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"core_money_value_authority\",\n \"path\": \"crates/core/src/money.rs\",\n \"byte_length\": 8025,\n \"sha256\": \"3f30bc21e21951a62fdc5d4033736ed42a883df590e8403940775bd2edfeebc5\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"core_percent_value_authority\",\n \"path\": \"crates/core/src/percent.rs\",\n \"byte_length\": 2652,\n \"sha256\": \"42ceab109881329f3539d06a0ee0381ab4359985fe185a18d58a9c9558c5eb7f\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"core_quantity_value_authority\",\n \"path\": \"crates/core/src/quantity.rs\",\n \"byte_length\": 6785,\n \"sha256\": \"d14e619da3829cdaf3ca7385fb892d330c286c1e21455c135c37518cbb588b5f\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"core_quantity_price_value_authority\",\n \"path\": \"crates/core/src/quantity_price.rs\",\n \"byte_length\": 7061,\n \"sha256\": \"d60f70377099470ed283f45868a5119d7bb46143cb250b5082a4c038ba7f6cdc\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"core_unit_value_authority\",\n \"path\": \"crates/core/src/unit.rs\",\n \"byte_length\": 10023,\n \"sha256\": \"550e9582b6a5290aac3f637b83853b21fefcbab92c432d067c83448f4b2fe3e9\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"blossom_public_surface\",\n \"path\": \"crates/blossom/src/lib.rs\",\n \"byte_length\": 510,\n \"sha256\": \"a4dfcbd193457c50c1b0fbfc91bb547981ecd3a6e23cfc6f1030edfb511dc58c\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"blossom_authorization_authority\",\n \"path\": \"crates/blossom/src/authorization.rs\",\n \"byte_length\": 39360,\n \"sha256\": \"461947b30516315a342b3b5697599f701d27050888f29f67f5d7a7ad4afa4c28\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"blossom_descriptor_authority\",\n \"path\": \"crates/blossom/src/descriptor.rs\",\n \"byte_length\": 13702,\n \"sha256\": \"b2a4ffa760256e1316f70e012d200e2b5f4afd8ede771b85a5147f96b247f599\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"blossom_error_authority\",\n \"path\": \"crates/blossom/src/error.rs\",\n \"byte_length\": 17229,\n \"sha256\": \"02af55beacf437040be17d1fc3271d6e2a62915f656ec2dbe06247deef83f95a\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"blossom_hash_authority\",\n \"path\": \"crates/blossom/src/hash.rs\",\n \"byte_length\": 11006,\n \"sha256\": \"45eca0d81ac0f46c305a32f81aafcdd90b14679d0614f72862289a6e5d4cb08f\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"blossom_media_type_authority\",\n \"path\": \"crates/blossom/src/media_type.rs\",\n \"byte_length\": 2368,\n \"sha256\": \"69db5c0f9fcdd7dbe1f22daa499f65603ae4ce198083dab2c73a36813fa18a46\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"blossom_url_authority\",\n \"path\": \"crates/blossom/src/url.rs\",\n \"byte_length\": 14580,\n \"sha256\": \"342c995fcf620e5fb32461a7d8276f5668ba2662890bd32d12780cc9121c6451\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_public_surface\",\n \"path\": \"crates/event/src/lib.rs\",\n \"byte_length\": 1892,\n \"sha256\": \"5d609b963a9b8db18ef96a10f394617413734d2631774d3296e6d8fc029cb02c\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_contract_facade\",\n \"path\": \"crates/event/src/contract.rs\",\n \"byte_length\": 208,\n \"sha256\": \"27466bf36461071931f391c60646a13f7781d19584583166cc54c0eece76d010\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_contract_registry_v7_authority\",\n \"path\": \"crates/event/src/contract/registry_v7.rs\",\n \"byte_length\": 145805,\n \"sha256\": \"0a62603f6fc05dc9f758561cf7da258c676ada810ceaeca7fb151364a5c83d62\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_envelope_authority\",\n \"path\": \"crates/event/src/envelope.rs\",\n \"byte_length\": 29310,\n \"sha256\": \"29f8d8c4b17c01cb5a8c7f59e4ce52e134180e0e6f37d265063d278b8f2ff26e\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_verification_typestate_authority\",\n \"path\": \"crates/event/src/verification.rs\",\n \"byte_length\": 9400,\n \"sha256\": \"417acb2ce670d266b1fa4fcafd6b48a910218deab38f57fb8b942056db76f94d\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_admission_typestate_authority\",\n \"path\": \"crates/event/src/admission.rs\",\n \"byte_length\": 7052,\n \"sha256\": \"62bd2ceb0f434946fdfc2b81f4efb13738676a29a6936c23a427e43aadf2d9cb\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_head_facade\",\n \"path\": \"crates/event/src/event_head.rs\",\n \"byte_length\": 97,\n \"sha256\": \"f761ff3e74c4f5e1e28381db00ce698c633ff048669b22984d14a587482e8e83\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_head_v1_authority\",\n \"path\": \"crates/event/src/event_head/v1.rs\",\n \"byte_length\": 6849,\n \"sha256\": \"9f4144d8d240023cf493ce0b538ade4c16dc44204d6b5f2797152caf6f9e7dba\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_ids_authority\",\n \"path\": \"crates/event/src/id.rs\",\n \"byte_length\": 48381,\n \"sha256\": \"a08fe3873815453a03318a706e2fa98a8a6728ec69d24368a87ec60025566fe0\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_trade_authority\",\n \"path\": \"crates/event/src/trade.rs\",\n \"byte_length\": 65549,\n \"sha256\": \"f818a7287ed82de3fb9ef1d973f0fb18d84b30eb4827ddc576ee502dd980145f\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_kinds_authority\",\n \"path\": \"crates/event/src/kinds.rs\",\n \"byte_length\": 34961,\n \"sha256\": \"8b3ce6193cab1f7e1587d0c1b2880a81b0aa43c77d89671b602772db37edcebf\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_tags_authority\",\n \"path\": \"crates/event/src/tags.rs\",\n \"byte_length\": 4530,\n \"sha256\": \"cb8f12a639fc72d9238e32495d9e67e928e6627fb8c94ec0a58c5d95e7373cd6\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_draft_authority\",\n \"path\": \"crates/event/src/draft.rs\",\n \"byte_length\": 51150,\n \"sha256\": \"ecc619aae28d688d13f35467398f17766b60f26c2bf3616d772ce65b7fa4fb53\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_calendar_authority\",\n \"path\": \"crates/event/src/calendar.rs\",\n \"byte_length\": 97992,\n \"sha256\": \"73e79d61ee175cb46e6123e1d28239252ddf0ad094dc1113bdb1286b63b88d57\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_classified_listing_authority\",\n \"path\": \"crates/event/src/classified_listing.rs\",\n \"byte_length\": 7956,\n \"sha256\": \"ac58484b87c7712c81f50f884d1c0dd60938571aa5f6f05d555191f529a4c2b0\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_profile_authority\",\n \"path\": \"crates/event/src/profile.rs\",\n \"byte_length\": 17011,\n \"sha256\": \"572bfe3b6f1711d109db445077f0a8453cad3f021cc0638f79201b618e16b978\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_post_authority\",\n \"path\": \"crates/event/src/post.rs\",\n \"byte_length\": 19968,\n \"sha256\": \"a822cd51eff9cbaa39db998cc5c90d117f7a983f0fe2d42d42118cb00e95982c\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_comment_authority\",\n \"path\": \"crates/event/src/comment.rs\",\n \"byte_length\": 34745,\n \"sha256\": \"24b7fda13229f21cee5cef2a9941e3aaafdbc486b19ed42731b0b6ee37c28703\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_food_availability_authority\",\n \"path\": \"crates/event/src/food_availability.rs\",\n \"byte_length\": 42829,\n \"sha256\": \"52472c07eb0855d884c60755dc3a3e1a963793451d707e51f769f2d78384f318\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_deletion_authority\",\n \"path\": \"crates/event/src/deletion.rs\",\n \"byte_length\": 30022,\n \"sha256\": \"8c3f2e32407520a8e8242c1293af32613c648fb76218b8003a18929ac0b3811f\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_dto_authority\",\n \"path\": \"crates/event/src/dto.rs\",\n \"byte_length\": 5038,\n \"sha256\": \"9e228c95ff6ff33f99441d8682781fd0a1c4d3f1db24284a43985dcbac3ba136\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_farm_crdt_authority\",\n \"path\": \"crates/event/src/farm_crdt.rs\",\n \"byte_length\": 21118,\n \"sha256\": \"cec265ac9c42b59a14be4eb96924d26a83477c27291f667f23e6e47a64b6b2c7\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_knowledge_authority\",\n \"path\": \"crates/event/src/knowledge.rs\",\n \"byte_length\": 54624,\n \"sha256\": \"3d55ff88a4e30d5d605f9ae4b2e96b9df6978d6c14e52f9aeb0b8039cfc200d7\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_operational_listing_authority\",\n \"path\": \"crates/event/src/operational_listing.rs\",\n \"byte_length\": 10307,\n \"sha256\": \"52e710db816c89c8d4b87c86541987f7447f2f7091c394c68d9cb98a27f90159\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_order_authority\",\n \"path\": \"crates/event/src/order.rs\",\n \"byte_length\": 60218,\n \"sha256\": \"4203fcc469612cac5c705884564e140b9fb076cd57079e22ed85275a8b936e50\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_reply_authority\",\n \"path\": \"crates/event/src/reply.rs\",\n \"byte_length\": 15581,\n \"sha256\": \"b0b83a1e96125c0892d932e9314519242a715fa8f46334aff765ffd248c91d9a\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_trade_validation_authority\",\n \"path\": \"crates/event/src/trade_validation.rs\",\n \"byte_length\": 4669,\n \"sha256\": \"1486f79bd1d0eadea6cc1b7646ce1f7f57ccade18334a3ea37286426676b9b98\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_relay_hint_authority\",\n \"path\": \"crates/event/src/relay_hint.rs\",\n \"byte_length\": 13598,\n \"sha256\": \"fb8b026eeccf51d78f20771c44223e7b48825aa0e3c760d6b3cb90148021a674\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_media_authority\",\n \"path\": \"crates/event/src/media.rs\",\n \"byte_length\": 3455,\n \"sha256\": \"10678af3a202d5367dbcf3bcdc0bf9fdf8d8fb89b045d7d3fb96b3cc195ab6f9\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_social_authority\",\n \"path\": \"crates/event/src/social.rs\",\n \"byte_length\": 6076,\n \"sha256\": \"a2dab19caad46eab14fac86e7c878a801a450d8ac374b0e728b71a5a224eef19\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_wire_facade\",\n \"path\": \"crates/event/src/wire.rs\",\n \"byte_length\": 68,\n \"sha256\": \"cb52f6006f7ecd862707d6b048f9fc407e0cd5ebcd3091b3c54e195ffa5cba64\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_wire_v1_authority\",\n \"path\": \"crates/event/src/wire/v1.rs\",\n \"byte_length\": 22198,\n \"sha256\": \"c818bc1f67e215948ba253c8c62b09ab788db9aeec9b6df8096014b55565cb8c\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_public_surface\",\n \"path\": \"crates/event_codec/src/lib.rs\",\n \"byte_length\": 2448,\n \"sha256\": \"7d4b0040ad3971f34395b17cb1aa40617e9c2c9e1b4222676c388c3adaa47e07\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_verification_facade\",\n \"path\": \"crates/event_codec/src/verification.rs\",\n \"byte_length\": 253,\n \"sha256\": \"b49a32df605035c87f295c0a151140d43d2e588c5e11b05183e5fc92993dae0e\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_verification_v1_authority\",\n \"path\": \"crates/event_codec/src/verification/v1.rs\",\n \"byte_length\": 6584,\n \"sha256\": \"72f64615bd6b6dc3b051579e3069b2213f3781dcc28e12f0b533c7d81b81b9b1\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_registry_v7_admission_authority\",\n \"path\": \"crates/event_codec/src/admission/registry_v7.rs\",\n \"byte_length\": 5228,\n \"sha256\": \"62da30cb6ef7d0ed2d43715a73bd529283e8ae8c12571bc5c68722cb400f17b8\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_admission_facade\",\n \"path\": \"crates/event_codec/src/admission.rs\",\n \"byte_length\": 21251,\n \"sha256\": \"25928e734e7b3688f92af806a12a13458c48fb47e94249f60f0226300d5da2b4\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_profile_inbound_facade\",\n \"path\": \"crates/event_codec/src/profile/inbound.rs\",\n \"byte_length\": 86,\n \"sha256\": \"9994cbca9dcc7450ad133b6b927e7c06bee08ef813652452cc1e56a284c1be10\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_profile_registry_v7_authority\",\n \"path\": \"crates/event_codec/src/profile/inbound/registry_v7.rs\",\n \"byte_length\": 10347,\n \"sha256\": \"32111c0e0592229c11a93a3a8054e7b124b039451aa696a9abcb56df2a6608d3\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_post_inbound_facade\",\n \"path\": \"crates/event_codec/src/post/inbound.rs\",\n \"byte_length\": 86,\n \"sha256\": \"9994cbca9dcc7450ad133b6b927e7c06bee08ef813652452cc1e56a284c1be10\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_post_registry_v7_authority\",\n \"path\": \"crates/event_codec/src/post/inbound/registry_v7.rs\",\n \"byte_length\": 15985,\n \"sha256\": \"e17083b0596e3a55994c399bc6272c0ebf04cabaa513260b3d45dd546f7f50ef\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_reply_inbound_facade\",\n \"path\": \"crates/event_codec/src/reply/inbound.rs\",\n \"byte_length\": 86,\n \"sha256\": \"9994cbca9dcc7450ad133b6b927e7c06bee08ef813652452cc1e56a284c1be10\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_reply_registry_v7_authority\",\n \"path\": \"crates/event_codec/src/reply/inbound/registry_v7.rs\",\n \"byte_length\": 26662,\n \"sha256\": \"d1d11116ca27801b7e2f17da60900e6a9ca0b6f27ea9ee0bf19e97dbf24c0c32\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_comment_inbound_facade\",\n \"path\": \"crates/event_codec/src/comment/inbound.rs\",\n \"byte_length\": 86,\n \"sha256\": \"9994cbca9dcc7450ad133b6b927e7c06bee08ef813652452cc1e56a284c1be10\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_comment_registry_v7_authority\",\n \"path\": \"crates/event_codec/src/comment/inbound/registry_v7.rs\",\n \"byte_length\": 53311,\n \"sha256\": \"c63fe853536b17bded6073f32b53434b60fc5123f911d01c642a6b3982f09c70\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_deletion_facade\",\n \"path\": \"crates/event_codec/src/deletion/mod.rs\",\n \"byte_length\": 115,\n \"sha256\": \"17d56f82ddb0a86bc97abbcf3e037fe460ea31fff2548f527be4bf004dac3a95\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_deletion_reconciliation_v1_authority\",\n \"path\": \"crates/event_codec/src/deletion/reconciliation_v1.rs\",\n \"byte_length\": 38028,\n \"sha256\": \"c6901f559e83700610595720c47a4d062078ae8411e1fc2fb952dd215b19dd90\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_error_authority\",\n \"path\": \"crates/event_codec/src/error.rs\",\n \"byte_length\": 3666,\n \"sha256\": \"174ce982bca37fac28d016c3ab3236441c9b31b2169ec94db6c9288b36c72849\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_food_admission_authority\",\n \"path\": \"crates/event_codec/src/food_availability/admission.rs\",\n \"byte_length\": 5681,\n \"sha256\": \"99809859090f5295572688d06bbb30dbe50524ba2c996de7ec5b26f6dd390643\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_food_authored_authority\",\n \"path\": \"crates/event_codec/src/food_availability/authored.rs\",\n \"byte_length\": 9724,\n \"sha256\": \"8673ec62ed3fc47b691efd9ca828643719803971e72b756c5b4603d409d0fba5\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_food_inbound_facade\",\n \"path\": \"crates/event_codec/src/food_availability/inbound.rs\",\n \"byte_length\": 86,\n \"sha256\": \"9994cbca9dcc7450ad133b6b927e7c06bee08ef813652452cc1e56a284c1be10\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_food_registry_v7_authority\",\n \"path\": \"crates/event_codec/src/food_availability/inbound/registry_v7.rs\",\n \"byte_length\": 26037,\n \"sha256\": \"92a7e75da3293967cec17a514479144c3c948a64542577dd58c7f07992968e4f\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_job_traits_authority\",\n \"path\": \"crates/event_codec/src/job/traits.rs\",\n \"byte_length\": 4946,\n \"sha256\": \"1b3558a3196744005978dceddd33d3a72ad1ab8f50fb1cbfd997dcfe98bb9e39\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_job_encode_authority\",\n \"path\": \"crates/event_codec/src/job/encode.rs\",\n \"byte_length\": 1670,\n \"sha256\": \"5d6cae6309fcdd02deb7c751b5c451d257cf1900986391cb39cbb2bfd1ddb577\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_knowledge_verification_authority\",\n \"path\": \"crates/event_codec/src/knowledge/verification.rs\",\n \"byte_length\": 6986,\n \"sha256\": \"6f8a10ed262ce37f05acd9c89a1e37ca2fa99ce7da18407891e8abe228ee2349\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_operational_listing_tags_authority\",\n \"path\": \"crates/event_codec/src/operational_listing/tags.rs\",\n \"byte_length\": 50222,\n \"sha256\": \"55b6860bc6f4699dd47a49f8336764e9db78aafb7c32849102dedeb96804b373\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_order_decode_authority\",\n \"path\": \"crates/event_codec/src/order/decode.rs\",\n \"byte_length\": 42667,\n \"sha256\": \"033d9465bc041d205eab3f820572b719a35651340dcb50bdce7c60fabee5f6e1\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_profile_facade\",\n \"path\": \"crates/event_codec/src/profile/mod.rs\",\n \"byte_length\": 1456,\n \"sha256\": \"06b890da54580e2ec68f7e729281b7c3b650ac0a95a4fcb0db3c6cdaa591a7ef\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_tag_builders_authority\",\n \"path\": \"crates/event_codec/src/tag_builders.rs\",\n \"byte_length\": 9232,\n \"sha256\": \"bd81ccf04fd72358331b48eddcde087a95e115c5bd90c05fd9332644979ad5e6\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_codec_trade_facade\",\n \"path\": \"crates/event_codec/src/trade/mod.rs\",\n \"byte_length\": 21472,\n \"sha256\": \"10cee4b6cd6429ac9eb00327a521bcd0982fae4de98bda77e4728fda002965d3\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_store_dependency_authority\",\n \"path\": \"crates/event_store/Cargo.toml\",\n \"byte_length\": 1797,\n \"sha256\": \"e79f4b69500553835e2cde28e7d9139788bebe6e2cd4e0b4ae72cc150491daf4\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_store_error_and_limits\",\n \"path\": \"crates/event_store/src/error.rs\",\n \"byte_length\": 19458,\n \"sha256\": \"3d87df984af6ae5decf7ca8c3a8d5422be6c647e6a05490225b6b2037bf3701b\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"generated_descriptor_registration\",\n \"path\": \"crates/event_store/src/generated.rs\",\n \"byte_length\": 144,\n \"sha256\": \"6b0a8d6f249bd4fc3f878d37cb5e418680f0f1be2d9eec2518dedf03efc47121\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"public_surface\",\n \"path\": \"crates/event_store/src/lib.rs\",\n \"byte_length\": 3860,\n \"sha256\": \"de1c5cc1ab36e1166d23e2b76aeae1d5f0f401cf07ce243590741c0126d02132\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"migration_registry\",\n \"path\": \"crates/event_store/src/migrations.rs\",\n \"byte_length\": 73585,\n \"sha256\": \"a47477d04759ec6e71d14d3d05459864eae78fce0e3c2481a1b03147f04637f7\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"predecessor_model_public_surface\",\n \"path\": \"crates/event_store/src/model.rs\",\n \"byte_length\": 32070,\n \"sha256\": \"2cdbf6d1a4e0fa6680f45f02758513509339a7cf3a2387e824fb73b0edf749a4\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_store_reconciliation_v1_model\",\n \"path\": \"crates/event_store/src/model/reconciliation_v1.rs\",\n \"byte_length\": 10446,\n \"sha256\": \"475b7b840bffdae7e3f7a31f5b940b9dfa579eaa1cac640729aaaeadabf83ba6\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_store_reconciliation_v1_ingest_model\",\n \"path\": \"crates/event_store/src/model/ingest_reconciliation_v1.rs\",\n \"byte_length\": 1588,\n \"sha256\": \"6117e797674c35bb1ccebffbc6a8c0108bfc38c7c9f82607066dfd802b8f0734\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"addressable_transition_feed_model\",\n \"path\": \"crates/event_store/src/model/addressable_transition_feed_v1.rs\",\n \"byte_length\": 22229,\n \"sha256\": \"e651952d73ed0a29d2b137076f56f699beada205ac84ed8a96834e86e343fa78\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"current_visibility_model\",\n \"path\": \"crates/event_store/src/model/current_visibility_v1.rs\",\n \"byte_length\": 5634,\n \"sha256\": \"d64429bc7985231e923c1c3e5295d94270a817dd5dc96693f35dfdf2dc398e4f\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"food_projection_model\",\n \"path\": \"crates/event_store/src/model/food_availability_projection_v1.rs\",\n \"byte_length\": 17108,\n \"sha256\": \"67861a966d674efbc40f0b0433db2ec006048ff923f6261df4161e2ddba717b5\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"source_generation_rebuild_authority\",\n \"path\": \"crates/event_store/src/nip09/reconciliation_v1.rs\",\n \"byte_length\": 184833,\n \"sha256\": \"3ca802cb7a8444c8512b2be1390a019281704316a430fb9b75a8ad2cad358a07\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"nip09_successor_result_vector_executor\",\n \"path\": \"crates/event_store/tests/support/nip09_reconciliation_v1_result_vector_v2.rs\",\n \"byte_length\": 18205,\n \"sha256\": \"c632beee70dc777b8dd2a4f88cadb55296fee7aa5742e6e2c9f6d49f26ae2c78\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"schema_migration_and_reopen_authority\",\n \"path\": \"crates/event_store/src/schema.rs\",\n \"byte_length\": 146146,\n \"sha256\": \"93b060e80d3edd73f86208e4bf698fa9d53eaf1eeb04526c9261fb8b5726fb0d\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"public_store_and_transaction_authority\",\n \"path\": \"crates/event_store/src/store.rs\",\n \"byte_length\": 393218,\n \"sha256\": \"64565c3bc043779e1c2f26d9761901b109b4acab0f48c2021102c0e4e1f1f726\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"addressable_transition_feed_store\",\n \"path\": \"crates/event_store/src/store/addressable_transition_feed_v1.rs\",\n \"byte_length\": 40209,\n \"sha256\": \"2cebcca9602633652a79e7041d18d3a7d3feb1388a98f4f68137b9814efcedfa\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"current_visibility_store\",\n \"path\": \"crates/event_store/src/store/current_visibility_v1.rs\",\n \"byte_length\": 15737,\n \"sha256\": \"aa5d1bff7a5368cbac2b37906bb40f54bfc8aa6906bab34369569fdd105bf899\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"food_projection_store\",\n \"path\": \"crates/event_store/src/store/food_availability_projection_v1.rs\",\n \"byte_length\": 48919,\n \"sha256\": \"3a30da308ef863b806ed63d1cb65ac80f6a946a71821af84a0c595330491757e\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"predecessor_post_core_v1_extension\",\n \"path\": \"crates/event_store/src/store/post_core_extensions_v1.rs\",\n \"byte_length\": 6569,\n \"sha256\": \"f5bd8ddb45e1b2144895bd8da737fc92db051be1844ce7e8047a32b47376ce37\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"predecessor_post_core_v1_storage\",\n \"path\": \"crates/event_store/src/store/post_core_storage_v1.rs\",\n \"byte_length\": 16538,\n \"sha256\": \"fcd6546c23a6cba12b70d92728d19b6ee5d43174b83495f820348b77efa33aab\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"raw_ingest_capacity_authority\",\n \"path\": \"crates/event_store/src/store/protocol_reconciliation_v1.rs\",\n \"byte_length\": 29950,\n \"sha256\": \"c72aaff06e4f35d0b4523c6625e83345879f9e15b9fe9563dccf589e5d01e277\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"predecessor_protocol_storage\",\n \"path\": \"crates/event_store/src/store/protocol_storage_v1.rs\",\n \"byte_length\": 10835,\n \"sha256\": \"c82179b9f57968191f3e71dd008cc701787c62715ada74090eeecaa3b213076d\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"source_maintenance_runtime\",\n \"path\": \"crates/event_store/src/source_maintenance_v1.rs\",\n \"byte_length\": 51849,\n \"sha256\": \"db43df5849c811d767a87bfa7884ce499400d0b169734e18242542a4415b3a51\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"predecessor_food_result_vector_executor\",\n \"path\": \"crates/event_store/tests/food_availability_projection_v1_result_vector.rs\",\n \"byte_length\": 34046,\n \"sha256\": \"776903c6431ff07f26c6ad6b713db5628efce1a72f34d86de6e47d4404fa3a6d\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"artifact_transaction_authority\",\n \"path\": \"tools/xtask/src/contract/artifact_bundle.rs\",\n \"byte_length\": 39149,\n \"sha256\": \"3d1bba3980b80698d2a21e26caf7af6fe4d9dc4a7fbabc6e406b5d33928b457f\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"predecessor_successor_governance\",\n \"path\": \"tools/xtask/src/contract/food_availability_projection.rs\",\n \"byte_length\": 198991,\n \"sha256\": \"36fd3d8b5dda3b0855a06a47dae14cae7f4cc84aaca9902c31477c218859fccc\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"transitive_predecessor_membership_governance\",\n \"path\": \"tools/xtask/src/contract/nip09_reconciliation.rs\",\n \"byte_length\": 842433,\n \"sha256\": \"c5361d9da74ac6cbec1baa890818d5b00c935c5015c8e0cfaa9036a94fe2b69e\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"source_maintenance_governance\",\n \"path\": \"tools/xtask/src/contract/source_maintenance.rs\",\n \"byte_length\": 199270,\n \"sha256\": \"65b83a3ee1a700527d15c7403ca042cf2aaa4cd8935a2f8d2ab190c491a815bd\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"contract_command_authority\",\n \"path\": \"tools/xtask/src/contract.rs\",\n \"byte_length\": 507854,\n \"sha256\": \"92b1fd2cea9f3015285000550a75ca123ffe21f46556e8a4d81b4e3c180f0586\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"dto_root_generation_authority\",\n \"path\": \"tools/xtask/src/dto_roots.rs\",\n \"byte_length\": 35940,\n \"sha256\": \"a899a5695d7533c151d03477c4bd697a6e55c47a4980549724ac42046af694f5\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"xtask_dispatch_and_release_preflight\",\n \"path\": \"tools/xtask/src/main.rs\",\n \"byte_length\": 16426,\n \"sha256\": \"abc24896c6c740c10811a3e003ec4be8ebdb376a7b6b01445bfa7a5889f094a7\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n }\n ],\n \"public_api\": {\n \"inherited_predecessor_symbols\": [\n \"RADROOTS_ADDRESSABLE_TRANSITION_CURSOR_JSON_MAX_BYTES_V1\",\n \"RADROOTS_ADDRESSABLE_TRANSITION_D_TAG_MAX_BYTES_V1\",\n \"RADROOTS_ADDRESSABLE_TRANSITION_FEED_VERSION_V1\",\n \"RADROOTS_ADDRESSABLE_TRANSITION_PAGE_LIMIT_MAX_V1\",\n \"RADROOTS_ADDRESSABLE_TRANSITION_PAGE_RAW_JSON_MAX_BYTES_V1\",\n \"RADROOTS_ADDRESSABLE_TRANSITION_PAGE_SCAN_MAX_V1\",\n \"RADROOTS_ADDRESSABLE_TRANSITION_SCOPE_KIND_MAX_V1\",\n \"RADROOTS_FOOD_AVAILABILITY_PROJECTION_APPLY_PAGE_LIMIT_V1\",\n \"RADROOTS_FOOD_AVAILABILITY_PROJECTION_VERSION_V1\",\n \"RADROOTS_FOOD_AVAILABILITY_SEARCH_QUERY_MAX_BYTES_V1\",\n \"RADROOTS_FOOD_AVAILABILITY_SEARCH_QUERY_MAX_TERMS_V1\",\n \"RadrootsAddressableTransitionCauseV1\",\n \"RadrootsAddressableTransitionCoordinateV1\",\n \"RadrootsAddressableTransitionCursorV1\",\n \"RadrootsAddressableTransitionEventReferenceV1\",\n \"RadrootsAddressableTransitionOriginV1\",\n \"RadrootsAddressableTransitionPageV1\",\n \"RadrootsAddressableTransitionRawHeadDecisionV1\",\n \"RadrootsAddressableTransitionScopeFingerprintV1\",\n \"RadrootsAddressableTransitionScopeV1\",\n \"RadrootsAddressableTransitionV1\",\n \"RadrootsAddressableTransitionVisibilityV1\",\n \"RadrootsCurrentEventVisibilityV1\",\n \"RadrootsCurrentVisibilityDecisionV1\",\n \"RadrootsFoodAvailabilitySearchQueryV1\",\n \"RadrootsFoodAvailabilityStatusFilterV1\",\n \"RadrootsNip09SuppressionEvidenceV1\",\n \"RadrootsNip09SuppressionOutcome\",\n \"RadrootsNip09SuppressionReason\",\n \"RadrootsStoreProducedCanonicalEventV1\",\n \"RadrootsStoredFoodAvailabilityImageV1\",\n \"RadrootsStoredFoodAvailabilityV1\"\n ],\n \"added_symbols\": [\n \"RADROOTS_EVENT_STORE_RAW_EVENT_COUNT_LIMIT_V1\",\n \"RADROOTS_EVENT_STORE_RAW_EVENT_TEXT_BYTES_LIMIT_V1\",\n \"RADROOTS_EVENT_STORE_RAW_TAG_COUNT_LIMIT_V1\",\n \"RADROOTS_EVENT_STORE_RAW_TAG_TEXT_BYTES_LIMIT_V1\",\n \"RADROOTS_EVENT_STORE_RETAINED_SOURCE_GENERATION_LIMIT_V1\",\n \"RadrootsEventStoreSourceCapacityResourceV1\",\n \"RadrootsEventStoreSourceCapacityV1\"\n ],\n \"methods\": [\n \"RadrootsEventStore::source_capacity_v1\",\n \"RadrootsEventStoreSourceCapacityResourceV1::as_str\",\n \"RadrootsEventStoreSourceCapacityV1::source_generation\",\n \"RadrootsEventStoreSourceCapacityV1::raw_event_count\",\n \"RadrootsEventStoreSourceCapacityV1::raw_tag_count\",\n \"RadrootsEventStoreSourceCapacityV1::raw_event_text_bytes\",\n \"RadrootsEventStoreSourceCapacityV1::raw_tag_text_bytes\",\n \"RadrootsEventStoreSourceCapacityV1::raw_high_water_seq\",\n \"RadrootsEventStoreSourceCapacityV1::retained_generation_count\",\n \"RadrootsEventStoreSourceCapacityV1::retained_generation_limit\"\n ],\n \"error_variants\": [\n \"SourceCapacityExceeded\",\n \"SourceGenerationHistoryLimitReached\",\n \"PersistedEphemeralRawEvent\",\n \"SourceCapacityStateDrift\",\n \"SqliteMainDatabaseEncodingNotUtf8\",\n \"RollbackWouldDiscardSourceGenerationHistory\"\n ],\n \"removed_symbols\": [\n \"RadrootsEventStoreReconciliationResource\",\n \"RadrootsEventStoreError::ReconciliationCapacityExceeded\"\n ],\n \"breaking_replacements\": [\n {\n \"removed\": \"RadrootsEventStoreReconciliationResource\",\n \"replacement\": \"RadrootsEventStoreSourceCapacityResourceV1\"\n },\n {\n \"removed\": \"RadrootsEventStoreError::ReconciliationCapacityExceeded\",\n \"replacement\": \"RadrootsEventStoreError::SourceCapacityExceeded\"\n }\n ]\n },\n \"result_vector\": {\n \"canonical_path\": \"contracts/conformance/vectors/event_store/source_maintenance.v1.json\",\n \"mirror_path\": \"crates/event_store/tests/fixtures/source_maintenance.v1.json\",\n \"byte_length\": 16253,\n \"sha256\": \"997aba2604a2b9d199fb87dc9d07942ca50d91863aeadcf3eeacf16d191dd71f\",\n \"hash_algorithm\": \"sha256_bytes_v1\",\n \"executor_id\": \"radroots_event_store.source_maintenance_v1.result_vector_executor.v1\",\n \"executor_path\": \"crates/event_store/tests/source_maintenance_v1_result_vector.rs\",\n \"executor_test\": \"source_maintenance_v1_result_vector\",\n \"executor_byte_length\": 23510,\n \"executor_sha256\": \"a7487afdfe19fc5fc794811d0f0e6035203e1aabcf0a33a1d398f6b3555d38f3\",\n \"executor_hash_algorithm\": \"sha256_bytes_v1\"\n }\n}\n"; pub(crate) const SOURCE_MAINTENANCE_MANIFEST_BYTE_LENGTH: usize = 38397; pub(crate) const SOURCE_MAINTENANCE_MANIFEST_SHA256: &str = - "194c8175537655f006e1bc778b6588703001cb4fec382cbdbc5fb1d011ee6500"; + "ba95f802e05eb85c5b2ffb65da747e503a7f8d58849096bc7835a30cee0c392f"; pub(crate) const SOURCE_MAINTENANCE_MANIFEST_SCHEMA_VERSION: u32 = 1; pub(crate) const SOURCE_MAINTENANCE_CONTRACT_ID: &str = "radroots_event_store.source_maintenance_v1"; diff --git a/crates/nostr/tests/package_boundary.rs b/crates/nostr/tests/package_boundary.rs @@ -246,18 +246,21 @@ fn live_client_and_http_ownership_belongs_to_transport_nostr() { ); } - for required in ["nostr-sdk", "reqwest", "nip11 = [\"client\""] { + for required in ["nostr-sdk", "radroots_transport", "radroots_event_codec"] { assert!( TRANSPORT_MANIFEST.contains(required), "transport manifest is missing live-client ownership marker `{required}`" ); } for required in [ + "mod auth;", "mod client;", - "mod relays;", - "mod nip11;", - "pub use client::{", - "pub use nip11::fetch_nip11;", + "mod relay;", + "mod sink;", + "mod source;", + "mod status;", + "pub use client::{Config, NostrTransport};", + "pub use relay::{RelayUrl, RelayUrlPolicy};", ] { assert!( TRANSPORT_ROOT.contains(required), @@ -275,15 +278,16 @@ fn live_client_and_http_ownership_belongs_to_transport_nostr() { .exists() ); assert!( - manifest_dir + !manifest_dir .join("../transport_nostr/src/relays.rs") .exists() ); assert!( - manifest_dir + !manifest_dir .join("../transport_nostr/src/nip11.rs") .exists() ); + assert!(!TRANSPORT_MANIFEST.contains("reqwest")); let forbidden_source = [ "nostr_sdk", diff --git a/crates/transport/tests/source_boundary.rs b/crates/transport/tests/source_boundary.rs @@ -567,14 +567,19 @@ fn workspace_consumers_use_split_transport_spis_with_one_runtime_shim() { "Reticulum preview must not implement the predecessor monolithic SPI" ); - let nostr_source = read_source(crates_root.join("transport_nostr/src/publish.rs").as_path()); + let nostr_sink = read_source(crates_root.join("transport_nostr/src/sink.rs").as_path()); + let nostr_source = read_source(crates_root.join("transport_nostr/src/source.rs").as_path()); assert!( - nostr_source.contains("impl<A> EventSink for RadrootsNostrTransport<A>"), - "Nostr adapter must implement the final sink-only SPI" + nostr_sink.contains("impl EventSink for NostrTransport"), + "Nostr adapter must implement the final sink SPI" ); assert!( - !nostr_source - .contains("impl<A> RadrootsRuntimeTransportShim for RadrootsNostrTransport<A>"), + nostr_source.contains("impl EventSource for NostrTransport"), + "Nostr adapter must implement the final source SPI" + ); + assert!( + !nostr_sink.contains("RadrootsRuntimeTransportShim") + && !nostr_source.contains("RadrootsRuntimeTransportShim"), "Nostr adapter must not implement the predecessor monolithic SPI" ); } @@ -735,12 +740,15 @@ fn transport_target_identity_sources_reject_silent_dedupe() { ); let relay_source = read_source(crates_root.join("transport_nostr/src/relay.rs").as_path()); - for required in [ - "Target::new(TransportId::NOSTR, original)", - "RadrootsRelayTransportError::DuplicateRelayUrl", - ] { + let client_source = read_source(crates_root.join("transport_nostr/src/client.rs").as_path()); + for required in ["Target::nostr_relay(original)", "Error::DuplicateRelayUrl"] { + let source = if required.contains("Duplicate") { + client_source.as_str() + } else { + relay_source.as_str() + }; assert!( - relay_source.contains(required), + source.contains(required), "Nostr relay target source must retain canonical identity witness `{required}`" ); } @@ -813,39 +821,19 @@ fn required_target_semantics_stay_fingerprint_exact() { } let nostr_publish_source = - read_source(crates_root.join("transport_nostr/src/publish.rs").as_path()); + read_source(crates_root.join("transport_nostr/src/sink.rs").as_path()); for required in [ - "RadrootsTransportSatisfactionPolicy::RequiredTargets { class, targets } =>", - "let mut satisfied_required_targets = BTreeSet::new();", - "targets.contains(target.fingerprint())", - "counts_as_satisfied(*class)", - "targets\n .iter()\n .all(|target| satisfied_required_targets.contains(target))", + "DeliveryReceipt::for_request(&request, receipts)", + "DeliveryTargetReceipt::attempted(target, outcome)", + "DeliveryTargetReceipt::skipped(", ] { assert!( nostr_publish_source.contains(required), - "direct Nostr publish must retain exact required-target witness `{required}`" - ); - } - - let nostr_outbox_source = - read_source(crates_root.join("transport_nostr/src/outbox.rs").as_path()); - let publishable_relays_source = source_between( - nostr_outbox_source.as_str(), - "let required_targets = match &plan.satisfaction_policy", - "Ok(PublishableRelays {", - ); - for required in [ - "RadrootsTransportSatisfactionPolicy::RequiredTargets { targets, .. } =>", - ".is_none_or(|required| required.contains(&target.endpoint_fingerprint))", - ".is_some_and(|required| required.contains(&target.endpoint_fingerprint))", - "required_targets.is_none() || required_for_satisfaction", - "required_targets.is_some()", - ] { - assert!( - publishable_relays_source.contains(required), - "direct Nostr outbox publish must retain exact required-target witness `{required}`" + "Nostr sink must delegate exact target satisfaction to the generic receipt contract `{required}`" ); } + assert!(!crates_root.join("transport_nostr/src/outbox.rs").exists()); + assert!(!crates_root.join("transport_nostr/src/publish.rs").exists()); } #[test] diff --git a/crates/transport_nostr/README.md b/crates/transport_nostr/README.md @@ -1,43 +1,13 @@ # radroots_transport_nostr -Deterministic Nostr relay transport substrate for exact signed-event publish, -fetch ingest, and outbox delivery target coordination. +`radroots_transport_nostr` is the concrete, signer-free Nostr implementation +of the generic `radroots_transport` event source and sink interfaces. -Every fetch path verifies the NIP-01 id and signature before filter matching, -unique-event budgeting, or returning an event. Repeated event ids preserve -per-relay observation evidence without consuming the unique-event limit. The -unique-event limit is bounded at 1,000 so final stored-event visibility can be -evaluated in one coherent event-store snapshot. A fetch scans at most 64,000 -raw events and 64 MiB of aggregate raw JSON, and rejects any individual raw -event over 256 KiB before Radroots parses adapter raw JSON. Count and byte -budgets are charged globally, in adapter order, before Radroots parsing, -filtering, deduplication, or accepted-event limiting, so malformed and otherwise -rejected events cannot bypass them. The official SDK adapter enforces the same -retained-prefix budgets after SDK frame/event decoding and before retaining its -serialized JSON; the connector's upstream frame parser remains responsible for -its own first-pass network limits. `Truncated` is distinct from relay `EOSE`, -including for later target relays that were not queried after a global count or -byte budget was reached. +The crate validates relay configuration and network policy, performs bounded +fetch and delivery attempts, exposes explicit host-mediated NIP-42 +authentication, and normalizes relay outcomes and passive status. It does not +own event ingestion, persistence, outbox claiming, projection refresh, retry +scheduling, SDK profiles, or a process runtime. Those policies belong to +`radroots_sync` and host applications. -Fetch-ingest receipts report verification, contract admission, immutable -valid-stream eligibility, and current visibility as independent exhaustive -enums. Verification failure is not a contract-invalid result, and unsupported -admission does not hide whether the stored event is current, suppressed, or not -admitted. Persisted events obtain visibility from the event store's central -authority after the complete accepted fetch batch has been ingested, so event -receipts and aggregate visibility counts describe final post-batch state rather -than transient per-item state. Repeated receipt IDs are deduplicated before the -single snapshot lookup and then mapped back to every receipt. Ephemeral events -use the explicit `not_persisted` visibility result. -`admission_code` carries the stable classifier diagnostic when classification -produces one. Inserted, duplicate, and not-persisted persistence outcomes retain -separate flags and aggregate counts. Local event-store failures abort the -operation and remain typed transport errors, so callers can retry without -confusing storage failure with bad relay input. - -`RadrootsRelayUrlPolicy::Public` is for trusted relay configuration. It rejects -non-canonical and known non-global literal destinations, but hostname checks do -not pin DNS resolution in the SDK connector and are not an SSRF boundary for -attacker-controlled relay hostnames. Validate resolved addresses at the network -boundary or use a connector that pins approved resolutions before accepting -untrusted relay configuration. +Publication remains disabled during the `0.1.0-alpha` refactor. diff --git a/crates/transport_nostr/src/fetch.rs b/crates/transport_nostr/src/fetch.rs @@ -1,1584 +0,0 @@ -#![forbid(unsafe_code)] - -use crate::RadrootsNostrClient; -use crate::error::ensure_nonnegative_timestamp; -use crate::{RadrootsRelayOutcome, RadrootsRelayTargetSet, RadrootsRelayTransportError}; -use core::time::Duration; -use futures::{StreamExt, future::BoxFuture}; -use nostr::{JsonUtil, filter::MatchEventOptions}; -use radroots_event::wire::v1::DEFAULT_RAW_JSON_MAX_BYTES; -use radroots_event_store::{ - RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX, RadrootsEventAdmissionStatus, RadrootsEventIngest, - RadrootsEventPersistence, RadrootsEventStore, RadrootsEventVisibility, - RadrootsTransportObservation, RadrootsTransportObservationType, -}; -use radroots_nostr::event::Event as RadrootsNostrEvent; -use radroots_nostr::filter::Filter as RadrootsNostrFilter; -use radroots_transport::{Target, TransportId}; -use serde::{Deserialize, Serialize}; -use std::collections::{BTreeMap, BTreeSet}; -use std::sync::{Arc, Mutex, PoisonError}; - -const DEFAULT_RELAY_FETCH_TIMEOUT_MS: u64 = 10_000; -const DEFAULT_RELAY_FETCH_RAW_SCAN_MULTIPLIER: usize = 64; -pub const RADROOTS_RELAY_FETCH_EVENT_LIMIT_MAX: usize = - RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX as usize; -pub const RADROOTS_RELAY_FETCH_RAW_EVENT_LIMIT_MAX: usize = - RADROOTS_RELAY_FETCH_EVENT_LIMIT_MAX * DEFAULT_RELAY_FETCH_RAW_SCAN_MULTIPLIER; -pub const RADROOTS_RELAY_FETCH_RAW_JSON_BYTE_LIMIT_MAX: usize = 64 * 1024 * 1024; - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub enum RadrootsRelayFetchMode { - Fetch, - Subscription, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsRelayFetchFilters { - filters: Vec<RadrootsNostrFilter>, -} - -impl RadrootsRelayFetchFilters { - pub fn new<I>(filters: I) -> Result<Self, RadrootsRelayTransportError> - where - I: IntoIterator<Item = RadrootsNostrFilter>, - { - let filters = filters.into_iter().collect::<Vec<_>>(); - if filters.is_empty() { - return Err(RadrootsRelayTransportError::EmptyFetchFilters); - } - Ok(Self { filters }) - } - - pub fn as_slice(&self) -> &[RadrootsNostrFilter] { - &self.filters - } -} - -impl AsRef<[RadrootsNostrFilter]> for RadrootsRelayFetchFilters { - fn as_ref(&self) -> &[RadrootsNostrFilter] { - self.as_slice() - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsRelayFetchRequest { - mode: RadrootsRelayFetchMode, - observed_at_ms: i64, - max_events: usize, - max_raw_events: usize, - max_raw_json_bytes: usize, - relay_targets: RadrootsRelayTargetSet, - filters: RadrootsRelayFetchFilters, - timeout_ms: u64, -} - -impl RadrootsRelayFetchRequest { - pub fn fetch<I>( - observed_at_ms: i64, - max_events: usize, - relay_targets: RadrootsRelayTargetSet, - filters: I, - ) -> Result<Self, RadrootsRelayTransportError> - where - I: IntoIterator<Item = RadrootsNostrFilter>, - { - Self::new( - RadrootsRelayFetchMode::Fetch, - observed_at_ms, - max_events, - relay_targets, - filters, - ) - } - - pub fn subscription<I>( - observed_at_ms: i64, - max_events: usize, - relay_targets: RadrootsRelayTargetSet, - filters: I, - ) -> Result<Self, RadrootsRelayTransportError> - where - I: IntoIterator<Item = RadrootsNostrFilter>, - { - Self::new( - RadrootsRelayFetchMode::Subscription, - observed_at_ms, - max_events, - relay_targets, - filters, - ) - } - - fn new<I>( - mode: RadrootsRelayFetchMode, - observed_at_ms: i64, - max_events: usize, - relay_targets: RadrootsRelayTargetSet, - filters: I, - ) -> Result<Self, RadrootsRelayTransportError> - where - I: IntoIterator<Item = RadrootsNostrFilter>, - { - ensure_nonnegative_timestamp("observed_at_ms", observed_at_ms)?; - ensure_event_limit("max_events", max_events)?; - Ok(Self { - mode, - observed_at_ms, - max_events, - max_raw_events: default_raw_event_scan_limit(max_events), - max_raw_json_bytes: RADROOTS_RELAY_FETCH_RAW_JSON_BYTE_LIMIT_MAX, - relay_targets, - filters: RadrootsRelayFetchFilters::new(filters)?, - timeout_ms: DEFAULT_RELAY_FETCH_TIMEOUT_MS, - }) - } - - pub fn with_timeout_ms(mut self, timeout_ms: u64) -> Result<Self, RadrootsRelayTransportError> { - ensure_positive_timeout("timeout_ms", timeout_ms)?; - self.timeout_ms = timeout_ms; - Ok(self) - } - - pub fn with_raw_event_scan_limit( - mut self, - max_raw_events: usize, - ) -> Result<Self, RadrootsRelayTransportError> { - ensure_bounded_fetch_limit( - "max_raw_events", - max_raw_events, - RADROOTS_RELAY_FETCH_RAW_EVENT_LIMIT_MAX, - )?; - self.max_raw_events = max_raw_events; - Ok(self) - } - - pub fn with_raw_json_byte_limit( - mut self, - max_raw_json_bytes: usize, - ) -> Result<Self, RadrootsRelayTransportError> { - ensure_bounded_fetch_limit( - "max_raw_json_bytes", - max_raw_json_bytes, - RADROOTS_RELAY_FETCH_RAW_JSON_BYTE_LIMIT_MAX, - )?; - self.max_raw_json_bytes = max_raw_json_bytes; - Ok(self) - } - - pub fn mode(&self) -> RadrootsRelayFetchMode { - self.mode - } - - pub fn observed_at_ms(&self) -> i64 { - self.observed_at_ms - } - - pub fn max_events(&self) -> usize { - self.max_events - } - - pub fn max_raw_events(&self) -> usize { - self.max_raw_events - } - - pub fn max_raw_json_bytes(&self) -> usize { - self.max_raw_json_bytes - } - - pub fn relay_targets(&self) -> &RadrootsRelayTargetSet { - &self.relay_targets - } - - pub fn filters(&self) -> &[RadrootsNostrFilter] { - self.filters.as_slice() - } - - pub fn timeout_ms(&self) -> u64 { - self.timeout_ms - } -} - -fn default_raw_event_scan_limit(max_events: usize) -> usize { - max_events - .saturating_mul(DEFAULT_RELAY_FETCH_RAW_SCAN_MULTIPLIER) - .max(max_events) - .min(RADROOTS_RELAY_FETCH_RAW_EVENT_LIMIT_MAX) -} - -fn ensure_positive_limit( - field: &'static str, - value: usize, -) -> Result<(), RadrootsRelayTransportError> { - if value == 0 { - return Err(RadrootsRelayTransportError::InvalidFetchLimit { field }); - } - Ok(()) -} - -fn ensure_event_limit( - field: &'static str, - value: usize, -) -> Result<(), RadrootsRelayTransportError> { - ensure_bounded_fetch_limit(field, value, RADROOTS_RELAY_FETCH_EVENT_LIMIT_MAX) -} - -fn ensure_bounded_fetch_limit( - field: &'static str, - value: usize, - max: usize, -) -> Result<(), RadrootsRelayTransportError> { - ensure_positive_limit(field, value)?; - if value > max { - return Err(RadrootsRelayTransportError::FetchLimitTooLarge { - field, - max, - actual: value, - }); - } - Ok(()) -} - -fn ensure_positive_timeout( - field: &'static str, - value: u64, -) -> Result<(), RadrootsRelayTransportError> { - if value == 0 { - return Err(RadrootsRelayTransportError::InvalidFetchLimit { field }); - } - Ok(()) -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum RadrootsRelayFetchItem { - Event { relay_url: String, raw_json: String }, - Eose { relay_url: String }, - Truncated { relay_url: String, message: String }, - Closed { relay_url: String, message: String }, - Notice { relay_url: String, message: String }, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub enum RadrootsRelayFetchOutcomeKind { - Eose, - Truncated, - Closed, - Notice, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct RadrootsRelayFetchRelayOutcome { - pub relay_url: String, - pub kind: RadrootsRelayFetchOutcomeKind, - pub relay_outcome: Option<RadrootsRelayOutcome>, - pub message: Option<String>, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum RadrootsRelayFetchEventVerification { - NotEvaluated, - Verified, - Failed, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum RadrootsRelayFetchEventAdmission { - NotEvaluated, - Admitted, - Unsupported, - Invalid, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum RadrootsRelayFetchEventValidStream { - NotEvaluated, - Eligible, - Ineligible, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum RadrootsRelayFetchEventVisibility { - NotEvaluated, - NotPersisted, - Visible, - NotAdmitted, - NotCurrent, - Suppressed, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct RadrootsRelayFetchEventReceipt { - pub relay_url: String, - pub event_id: Option<String>, - pub inserted: bool, - pub duplicate: bool, - pub not_persisted: bool, - pub malformed: bool, - pub out_of_filter: bool, - pub skipped_over_limit: bool, - pub verification: RadrootsRelayFetchEventVerification, - pub admission: RadrootsRelayFetchEventAdmission, - pub admission_code: Option<String>, - pub valid_stream: RadrootsRelayFetchEventValidStream, - pub visibility: RadrootsRelayFetchEventVisibility, - pub message: Option<String>, -} - -#[derive(Clone, Debug)] -pub struct RadrootsRelayFetchedEvent { - pub relay_url: String, - pub event: RadrootsNostrEvent, - pub raw_json: String, - pub observed_at_ms: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct RadrootsRelayFetchFailure { - pub relay_url: String, - pub reason: String, -} - -#[derive(Clone, Debug)] -pub struct RadrootsRelayFetchedEventsReceipt { - pub target_relays: Vec<String>, - pub connected_relays: Vec<String>, - pub failed_relays: Vec<RadrootsRelayFetchFailure>, - pub events: Vec<RadrootsRelayFetchedEvent>, - pub event_receipts: Vec<RadrootsRelayFetchEventReceipt>, - pub duplicate_count: usize, - pub verification_failed_count: usize, - pub malformed_count: usize, - pub out_of_filter_count: usize, - pub skipped_over_limit_count: usize, - pub eose_count: usize, - pub truncated_count: usize, - pub closed_count: usize, - pub notice_count: usize, - pub relay_outcomes: Vec<RadrootsRelayFetchRelayOutcome>, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct RadrootsRelayFetchReceipt { - pub inserted_count: usize, - pub duplicate_count: usize, - pub not_persisted_count: usize, - pub malformed_count: usize, - pub out_of_filter_count: usize, - pub skipped_over_limit_count: usize, - pub verification_failed_count: usize, - pub admission_unsupported_count: usize, - pub admission_invalid_count: usize, - pub valid_stream_eligible_count: usize, - pub visible_count: usize, - pub not_admitted_count: usize, - pub not_current_count: usize, - pub suppressed_count: usize, - pub eose_count: usize, - pub truncated_count: usize, - pub closed_count: usize, - pub notice_count: usize, - pub events: Vec<RadrootsRelayFetchEventReceipt>, - pub relay_outcomes: Vec<RadrootsRelayFetchRelayOutcome>, -} - -pub trait RadrootsRelayFetchAdapter: Send + Sync { - fn fetch<'a>( - &'a self, - request: RadrootsRelayFetchRequest, - ) -> BoxFuture<'a, Result<Vec<RadrootsRelayFetchItem>, RadrootsRelayTransportError>>; -} - -pub async fn fetch_relay_events<A>( - adapter: &A, - request: RadrootsRelayFetchRequest, -) -> Result<RadrootsRelayFetchedEventsReceipt, RadrootsRelayTransportError> -where - A: RadrootsRelayFetchAdapter, -{ - let target_relays = request.relay_targets.relay_strings(); - let observed_at_ms = request.observed_at_ms; - let max_events = request.max_events; - let max_raw_events = request.max_raw_events; - let max_raw_json_bytes = request.max_raw_json_bytes; - let filters = request.filters.as_slice().to_vec(); - let items = adapter.fetch(request).await?; - Ok(process_relay_fetch_items( - target_relays, - filters, - observed_at_ms, - max_events, - max_raw_events, - max_raw_json_bytes, - items, - )? - .into_fetched_events_receipt()) -} - -#[cfg(feature = "runtime-tokio")] -#[cfg_attr(coverage_nightly, coverage(off))] -pub fn fetch_relay_events_blocking<A>( - adapter: &A, - request: RadrootsRelayFetchRequest, -) -> Result<RadrootsRelayFetchedEventsReceipt, RadrootsRelayTransportError> -where - A: RadrootsRelayFetchAdapter, -{ - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .map_err(|error| RadrootsRelayTransportError::Transport(error.to_string()))?; - runtime.block_on(fetch_relay_events(adapter, request)) -} - -pub async fn fetch_and_ingest_relay_events<A>( - adapter: &A, - event_store: &RadrootsEventStore, - request: RadrootsRelayFetchRequest, -) -> Result<RadrootsRelayFetchReceipt, RadrootsRelayTransportError> -where - A: RadrootsRelayFetchAdapter, -{ - let mode = request.mode; - let target_relays = request.relay_targets.relay_strings(); - let observed_at_ms = request.observed_at_ms; - let max_events = request.max_events; - let max_raw_events = request.max_raw_events; - let max_raw_json_bytes = request.max_raw_json_bytes; - let filters = request.filters.as_slice().to_vec(); - let items = adapter.fetch(request).await?; - let processed = process_relay_fetch_items( - target_relays, - filters, - observed_at_ms, - max_events, - max_raw_events, - max_raw_json_bytes, - items, - )?; - let mut receipt = RadrootsRelayFetchReceipt::from_processed_counts(&processed); - for item in processed.items { - match item { - RadrootsRelayProcessedFetchItem::Receipt(event_receipt) => { - receipt.events.push(event_receipt); - } - RadrootsRelayProcessedFetchItem::Accepted(RadrootsRelayFetchedEvent { - relay_url, - event: raw_event, - raw_json, - observed_at_ms, - }) - | RadrootsRelayProcessedFetchItem::Duplicate(RadrootsRelayFetchedEvent { - relay_url, - event: raw_event, - raw_json, - observed_at_ms, - }) => { - let observation_type = match mode { - RadrootsRelayFetchMode::Fetch => RadrootsTransportObservationType::Fetch, - RadrootsRelayFetchMode::Subscription => { - RadrootsTransportObservationType::Subscription - } - }; - let observation = RadrootsTransportObservation::new( - TransportId::NOSTR, - relay_url.clone(), - observation_type, - observed_at_ms, - )?; - let ingest = match RadrootsEventIngest::from_raw_json(raw_json, observed_at_ms) { - Ok(ingest) => ingest.with_observation(observation), - Err(error) => { - receipt.verification_failed_count += 1; - receipt.events.push(RadrootsRelayFetchEventReceipt { - relay_url, - event_id: Some(raw_event.id.to_hex()), - inserted: false, - duplicate: false, - not_persisted: false, - malformed: false, - out_of_filter: false, - skipped_over_limit: false, - verification: RadrootsRelayFetchEventVerification::Failed, - admission: RadrootsRelayFetchEventAdmission::NotEvaluated, - admission_code: None, - valid_stream: RadrootsRelayFetchEventValidStream::NotEvaluated, - visibility: RadrootsRelayFetchEventVisibility::NotEvaluated, - message: Some(error.to_string()), - }); - continue; - } - }; - let store_receipt = event_store.ingest_event(ingest).await?; - let admission = relay_fetch_admission(store_receipt.admission_status); - let valid_stream = if store_receipt.valid_stream_eligible { - RadrootsRelayFetchEventValidStream::Eligible - } else { - RadrootsRelayFetchEventValidStream::Ineligible - }; - let (inserted, duplicate, not_persisted) = match store_receipt.persistence { - RadrootsEventPersistence::Inserted { .. } => { - receipt.inserted_count += 1; - (true, false, false) - } - RadrootsEventPersistence::Duplicate { .. } => { - receipt.duplicate_count += 1; - (false, true, false) - } - RadrootsEventPersistence::NotPersisted => { - receipt.not_persisted_count += 1; - (false, false, true) - } - }; - let visibility = if not_persisted { - RadrootsRelayFetchEventVisibility::NotPersisted - } else { - RadrootsRelayFetchEventVisibility::NotEvaluated - }; - let event_receipt = RadrootsRelayFetchEventReceipt { - relay_url, - event_id: Some(store_receipt.event_id), - inserted, - duplicate, - not_persisted, - malformed: false, - out_of_filter: false, - skipped_over_limit: false, - verification: RadrootsRelayFetchEventVerification::Verified, - admission, - admission_code: store_receipt.admission_code, - valid_stream, - visibility, - message: None, - }; - receipt.events.push(event_receipt); - } - } - } - receipt.refresh_final_semantic_outcomes(event_store).await?; - Ok(receipt) -} - -fn relay_fetch_admission( - admission: RadrootsEventAdmissionStatus, -) -> RadrootsRelayFetchEventAdmission { - match admission { - RadrootsEventAdmissionStatus::Admitted => RadrootsRelayFetchEventAdmission::Admitted, - RadrootsEventAdmissionStatus::Unsupported => RadrootsRelayFetchEventAdmission::Unsupported, - RadrootsEventAdmissionStatus::Invalid => RadrootsRelayFetchEventAdmission::Invalid, - } -} - -fn relay_fetch_visibility( - event_id: &str, - visibility: RadrootsEventVisibility, -) -> Result<RadrootsRelayFetchEventVisibility, RadrootsRelayTransportError> { - match visibility { - RadrootsEventVisibility::Visible => Ok(RadrootsRelayFetchEventVisibility::Visible), - RadrootsEventVisibility::NotAdmitted => Ok(RadrootsRelayFetchEventVisibility::NotAdmitted), - RadrootsEventVisibility::NotCurrent { .. } => { - Ok(RadrootsRelayFetchEventVisibility::NotCurrent) - } - RadrootsEventVisibility::Suppressed { .. } => { - Ok(RadrootsRelayFetchEventVisibility::Suppressed) - } - _ => Err( - RadrootsRelayTransportError::UnsupportedStoredEventVisibility { - event_id: event_id.to_owned(), - }, - ), - } -} - -fn required_persisted_fetch_receipt_event_id( - event_id: Option<&str>, -) -> Result<&str, RadrootsRelayTransportError> { - event_id.ok_or(RadrootsRelayTransportError::MissingPersistedFetchReceiptEventId) -} - -#[derive(Clone, Debug)] -enum RadrootsRelayProcessedFetchItem { - Accepted(RadrootsRelayFetchedEvent), - Duplicate(RadrootsRelayFetchedEvent), - Receipt(RadrootsRelayFetchEventReceipt), -} - -#[derive(Clone, Debug)] -struct RadrootsRelayProcessedFetch { - target_relays: Vec<String>, - items: Vec<RadrootsRelayProcessedFetchItem>, - duplicate_count: usize, - verification_failed_count: usize, - malformed_count: usize, - out_of_filter_count: usize, - skipped_over_limit_count: usize, - eose_count: usize, - truncated_count: usize, - closed_count: usize, - notice_count: usize, - relay_outcomes: Vec<RadrootsRelayFetchRelayOutcome>, -} - -impl RadrootsRelayProcessedFetch { - fn into_fetched_events_receipt(self) -> RadrootsRelayFetchedEventsReceipt { - let mut events = Vec::new(); - let mut event_receipts = Vec::new(); - for item in self.items { - match item { - RadrootsRelayProcessedFetchItem::Accepted(event) => { - event_receipts.push(accepted_fetch_event_receipt(&event)); - events.push(event); - } - RadrootsRelayProcessedFetchItem::Duplicate(event) => { - event_receipts.push(duplicate_fetch_event_receipt(&event)); - } - RadrootsRelayProcessedFetchItem::Receipt(receipt) => event_receipts.push(receipt), - } - } - let connected_relays = self - .relay_outcomes - .iter() - .filter(|outcome| outcome.kind == RadrootsRelayFetchOutcomeKind::Eose) - .map(|outcome| outcome.relay_url.clone()) - .collect(); - let failed_relays = self - .relay_outcomes - .iter() - .filter(|outcome| outcome.kind == RadrootsRelayFetchOutcomeKind::Closed) - .map(|outcome| RadrootsRelayFetchFailure { - relay_url: outcome.relay_url.clone(), - reason: outcome.message.clone().unwrap_or_default(), - }) - .collect(); - RadrootsRelayFetchedEventsReceipt { - target_relays: self.target_relays, - connected_relays, - failed_relays, - events, - event_receipts, - duplicate_count: self.duplicate_count, - verification_failed_count: self.verification_failed_count, - malformed_count: self.malformed_count, - out_of_filter_count: self.out_of_filter_count, - skipped_over_limit_count: self.skipped_over_limit_count, - eose_count: self.eose_count, - truncated_count: self.truncated_count, - closed_count: self.closed_count, - notice_count: self.notice_count, - relay_outcomes: self.relay_outcomes, - } - } -} - -impl RadrootsRelayFetchReceipt { - fn from_processed_counts(processed: &RadrootsRelayProcessedFetch) -> Self { - Self { - inserted_count: 0, - duplicate_count: 0, - not_persisted_count: 0, - malformed_count: processed.malformed_count, - out_of_filter_count: processed.out_of_filter_count, - skipped_over_limit_count: processed.skipped_over_limit_count, - verification_failed_count: processed.verification_failed_count, - admission_unsupported_count: 0, - admission_invalid_count: 0, - valid_stream_eligible_count: 0, - visible_count: 0, - not_admitted_count: 0, - not_current_count: 0, - suppressed_count: 0, - eose_count: processed.eose_count, - truncated_count: processed.truncated_count, - closed_count: processed.closed_count, - notice_count: processed.notice_count, - events: Vec::new(), - relay_outcomes: processed.relay_outcomes.clone(), - } - } - - async fn refresh_final_semantic_outcomes( - &mut self, - event_store: &RadrootsEventStore, - ) -> Result<(), RadrootsRelayTransportError> { - let mut event_ids = Vec::new(); - let mut seen_event_ids = BTreeSet::new(); - for event in &self.events { - if event.not_persisted || (!event.inserted && !event.duplicate) { - continue; - } - let event_id = required_persisted_fetch_receipt_event_id(event.event_id.as_deref())?; - if seen_event_ids.insert(event_id) { - event_ids.push(event_id.to_owned()); - } - } - let visibilities = event_store.event_visibilities(event_ids.iter()).await?; - let visibilities_by_event_id = event_ids - .into_iter() - .zip(visibilities) - .collect::<BTreeMap<_, _>>(); - - for event in &mut self.events { - if event.not_persisted || (!event.inserted && !event.duplicate) { - continue; - } - let event_id = required_persisted_fetch_receipt_event_id(event.event_id.as_deref())?; - let visibility = visibilities_by_event_id - .get(event_id) - .cloned() - .flatten() - .ok_or_else( - || RadrootsRelayTransportError::MissingStoredEventVisibility { - event_id: event_id.to_owned(), - }, - )?; - event.visibility = relay_fetch_visibility(event_id, visibility)?; - } - - self.admission_unsupported_count = 0; - self.admission_invalid_count = 0; - self.valid_stream_eligible_count = 0; - self.visible_count = 0; - self.not_admitted_count = 0; - self.not_current_count = 0; - self.suppressed_count = 0; - for event in &self.events { - match event.admission { - RadrootsRelayFetchEventAdmission::Unsupported => { - self.admission_unsupported_count += 1; - } - RadrootsRelayFetchEventAdmission::Invalid => self.admission_invalid_count += 1, - RadrootsRelayFetchEventAdmission::NotEvaluated - | RadrootsRelayFetchEventAdmission::Admitted => {} - } - if event.valid_stream == RadrootsRelayFetchEventValidStream::Eligible { - self.valid_stream_eligible_count += 1; - } - match event.visibility { - RadrootsRelayFetchEventVisibility::Visible => self.visible_count += 1, - RadrootsRelayFetchEventVisibility::NotAdmitted => { - self.not_admitted_count += 1; - } - RadrootsRelayFetchEventVisibility::NotCurrent => self.not_current_count += 1, - RadrootsRelayFetchEventVisibility::Suppressed => self.suppressed_count += 1, - RadrootsRelayFetchEventVisibility::NotEvaluated - | RadrootsRelayFetchEventVisibility::NotPersisted => {} - } - } - Ok(()) - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum RadrootsRelayFetchRawBudgetExhaustion { - RawEvents, - RawJsonBytes, -} - -impl RadrootsRelayFetchRawBudgetExhaustion { - const fn current_relay_message(self) -> &'static str { - match self { - Self::RawEvents => "raw event scan limit reached before relay EOSE", - Self::RawJsonBytes => "aggregate raw JSON byte limit reached before relay EOSE", - } - } - - const fn unqueried_relay_message(self) -> &'static str { - match self { - Self::RawEvents => { - "relay was not queried because the global raw event scan limit was reached" - } - Self::RawJsonBytes => { - "relay was not queried because the global aggregate raw JSON byte limit was reached" - } - } - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct RadrootsRelayFetchRawBudget { - remaining_events: usize, - remaining_json_bytes: usize, - exhausted: Option<RadrootsRelayFetchRawBudgetExhaustion>, -} - -impl RadrootsRelayFetchRawBudget { - const fn new(max_raw_events: usize, max_raw_json_bytes: usize) -> Self { - Self { - remaining_events: max_raw_events, - remaining_json_bytes: max_raw_json_bytes, - exhausted: None, - } - } - - fn charge( - &mut self, - raw_json_bytes: usize, - ) -> Result<(), RadrootsRelayFetchRawBudgetExhaustion> { - if let Some(reason) = self.exhausted { - return Err(reason); - } - let Some(remaining_events) = self.remaining_events.checked_sub(1) else { - self.exhausted = Some(RadrootsRelayFetchRawBudgetExhaustion::RawEvents); - return Err(RadrootsRelayFetchRawBudgetExhaustion::RawEvents); - }; - let Some(remaining_json_bytes) = self.remaining_json_bytes.checked_sub(raw_json_bytes) - else { - self.exhausted = Some(RadrootsRelayFetchRawBudgetExhaustion::RawJsonBytes); - return Err(RadrootsRelayFetchRawBudgetExhaustion::RawJsonBytes); - }; - self.remaining_events = remaining_events; - self.remaining_json_bytes = remaining_json_bytes; - self.exhausted = if remaining_events == 0 { - Some(RadrootsRelayFetchRawBudgetExhaustion::RawEvents) - } else if remaining_json_bytes == 0 { - Some(RadrootsRelayFetchRawBudgetExhaustion::RawJsonBytes) - } else { - None - }; - Ok(()) - } - - const fn exhaustion_reason(self) -> Option<RadrootsRelayFetchRawBudgetExhaustion> { - self.exhausted - } -} - -fn process_relay_fetch_items( - target_relays: Vec<String>, - filters: Vec<RadrootsNostrFilter>, - observed_at_ms: i64, - max_events: usize, - max_raw_events: usize, - max_raw_json_bytes: usize, - items: Vec<RadrootsRelayFetchItem>, -) -> Result<RadrootsRelayProcessedFetch, RadrootsRelayTransportError> { - if target_relays.is_empty() { - return Err(RadrootsRelayTransportError::EmptyTargetSet); - } - let mut processed = RadrootsRelayProcessedFetch { - target_relays, - items: Vec::new(), - duplicate_count: 0, - verification_failed_count: 0, - malformed_count: 0, - out_of_filter_count: 0, - skipped_over_limit_count: 0, - eose_count: 0, - truncated_count: 0, - closed_count: 0, - notice_count: 0, - relay_outcomes: Vec::new(), - }; - let mut raw_budget = RadrootsRelayFetchRawBudget::new(max_raw_events, max_raw_json_bytes); - let mut accepted_events = 0usize; - let mut seen_event_ids = BTreeSet::new(); - let mut terminal_outcomes = BTreeMap::new(); - for item in items { - let item_relay_url = match &item { - RadrootsRelayFetchItem::Event { relay_url, .. } - | RadrootsRelayFetchItem::Eose { relay_url } - | RadrootsRelayFetchItem::Truncated { relay_url, .. } - | RadrootsRelayFetchItem::Closed { relay_url, .. } - | RadrootsRelayFetchItem::Notice { relay_url, .. } => relay_url, - }; - let relay_url = canonical_requested_fetch_relay( - processed.target_relays.as_slice(), - item_relay_url.as_str(), - )?; - if let Some(next) = fetch_terminal_outcome_label(&item) { - if let Some(first) = terminal_outcomes.get(relay_url.as_str()).copied() { - if first == next { - return Err( - RadrootsRelayTransportError::DuplicateFetchTerminalRelayUrl { - url: relay_url, - }, - ); - } - return Err( - RadrootsRelayTransportError::ConflictingFetchTerminalRelayUrl { - url: relay_url, - first, - next, - }, - ); - } - terminal_outcomes.insert(relay_url.clone(), next); - } - match item { - RadrootsRelayFetchItem::Event { raw_json, .. } => { - if raw_budget.charge(raw_json.len()).is_err() { - processed.skipped_over_limit_count += 1; - continue; - } - if raw_json.len() > DEFAULT_RAW_JSON_MAX_BYTES { - processed.verification_failed_count += 1; - processed - .items - .push(RadrootsRelayProcessedFetchItem::Receipt( - RadrootsRelayFetchEventReceipt { - relay_url, - event_id: None, - inserted: false, - duplicate: false, - not_persisted: false, - malformed: false, - out_of_filter: false, - skipped_over_limit: false, - verification: RadrootsRelayFetchEventVerification::Failed, - admission: RadrootsRelayFetchEventAdmission::NotEvaluated, - admission_code: None, - valid_stream: RadrootsRelayFetchEventValidStream::NotEvaluated, - visibility: RadrootsRelayFetchEventVisibility::NotEvaluated, - message: Some(format!( - "event raw JSON exceeds {DEFAULT_RAW_JSON_MAX_BYTES} byte limit" - )), - }, - )); - continue; - } - let parsed = RadrootsNostrEvent::from_json(raw_json.as_str()); - let Ok(raw_event) = parsed else { - processed.malformed_count += 1; - processed - .items - .push(RadrootsRelayProcessedFetchItem::Receipt( - RadrootsRelayFetchEventReceipt { - relay_url, - event_id: None, - inserted: false, - duplicate: false, - not_persisted: false, - malformed: true, - out_of_filter: false, - skipped_over_limit: false, - verification: RadrootsRelayFetchEventVerification::NotEvaluated, - admission: RadrootsRelayFetchEventAdmission::NotEvaluated, - admission_code: None, - valid_stream: RadrootsRelayFetchEventValidStream::NotEvaluated, - visibility: RadrootsRelayFetchEventVisibility::NotEvaluated, - message: Some("event JSON parse failed".to_owned()), - }, - )); - continue; - }; - if let Err(error) = - RadrootsEventIngest::from_raw_json(raw_json.clone(), observed_at_ms) - { - processed.verification_failed_count += 1; - processed - .items - .push(RadrootsRelayProcessedFetchItem::Receipt( - RadrootsRelayFetchEventReceipt { - relay_url, - event_id: Some(raw_event.id.to_hex()), - inserted: false, - duplicate: false, - not_persisted: false, - malformed: false, - out_of_filter: false, - skipped_over_limit: false, - verification: RadrootsRelayFetchEventVerification::Failed, - admission: RadrootsRelayFetchEventAdmission::NotEvaluated, - admission_code: None, - valid_stream: RadrootsRelayFetchEventValidStream::NotEvaluated, - visibility: RadrootsRelayFetchEventVisibility::NotEvaluated, - message: Some(error.to_string()), - }, - )); - continue; - } - if !relay_fetch_event_matches_filters(&filters, &raw_event) { - processed.out_of_filter_count += 1; - processed - .items - .push(RadrootsRelayProcessedFetchItem::Receipt( - RadrootsRelayFetchEventReceipt { - relay_url, - event_id: Some(raw_event.id.to_hex()), - inserted: false, - duplicate: false, - not_persisted: false, - malformed: false, - out_of_filter: true, - skipped_over_limit: false, - verification: RadrootsRelayFetchEventVerification::Verified, - admission: RadrootsRelayFetchEventAdmission::NotEvaluated, - admission_code: None, - valid_stream: RadrootsRelayFetchEventValidStream::NotEvaluated, - visibility: RadrootsRelayFetchEventVisibility::NotEvaluated, - message: Some("event did not match relay fetch filters".to_owned()), - }, - )); - continue; - } - let event_id = raw_event.id.to_hex(); - if !seen_event_ids.insert(event_id) { - processed.duplicate_count += 1; - processed - .items - .push(RadrootsRelayProcessedFetchItem::Duplicate( - RadrootsRelayFetchedEvent { - relay_url, - event: raw_event, - raw_json, - observed_at_ms, - }, - )); - continue; - } - if accepted_events >= max_events { - processed.skipped_over_limit_count += 1; - processed - .items - .push(RadrootsRelayProcessedFetchItem::Receipt( - RadrootsRelayFetchEventReceipt { - relay_url, - event_id: Some(raw_event.id.to_hex()), - inserted: false, - duplicate: false, - not_persisted: false, - malformed: false, - out_of_filter: false, - skipped_over_limit: true, - verification: RadrootsRelayFetchEventVerification::Verified, - admission: RadrootsRelayFetchEventAdmission::NotEvaluated, - admission_code: None, - valid_stream: RadrootsRelayFetchEventValidStream::NotEvaluated, - visibility: RadrootsRelayFetchEventVisibility::NotEvaluated, - message: Some( - "accepted relay fetch event limit reached".to_owned(), - ), - }, - )); - continue; - } - accepted_events += 1; - processed - .items - .push(RadrootsRelayProcessedFetchItem::Accepted( - RadrootsRelayFetchedEvent { - relay_url, - event: raw_event, - raw_json, - observed_at_ms, - }, - )); - } - RadrootsRelayFetchItem::Eose { .. } => { - processed.eose_count += 1; - processed - .relay_outcomes - .push(RadrootsRelayFetchRelayOutcome { - relay_url, - kind: RadrootsRelayFetchOutcomeKind::Eose, - relay_outcome: None, - message: None, - }); - } - RadrootsRelayFetchItem::Truncated { message, .. } => { - processed.truncated_count += 1; - processed - .relay_outcomes - .push(RadrootsRelayFetchRelayOutcome { - relay_url, - kind: RadrootsRelayFetchOutcomeKind::Truncated, - relay_outcome: None, - message: Some(message), - }); - } - RadrootsRelayFetchItem::Closed { message, .. } => { - processed.closed_count += 1; - processed - .relay_outcomes - .push(RadrootsRelayFetchRelayOutcome { - relay_url, - kind: RadrootsRelayFetchOutcomeKind::Closed, - relay_outcome: Some(RadrootsRelayOutcome::classify(message.as_str())), - message: Some(message), - }); - } - RadrootsRelayFetchItem::Notice { message, .. } => { - processed.notice_count += 1; - processed - .relay_outcomes - .push(RadrootsRelayFetchRelayOutcome { - relay_url, - kind: RadrootsRelayFetchOutcomeKind::Notice, - relay_outcome: None, - message: Some(message), - }); - } - } - } - Ok(processed) -} - -fn fetch_terminal_outcome_label(item: &RadrootsRelayFetchItem) -> Option<&'static str> { - match item { - RadrootsRelayFetchItem::Eose { .. } => Some("eose"), - RadrootsRelayFetchItem::Truncated { .. } => Some("truncated"), - RadrootsRelayFetchItem::Closed { .. } => Some("closed"), - RadrootsRelayFetchItem::Event { .. } | RadrootsRelayFetchItem::Notice { .. } => None, - } -} - -fn canonical_requested_fetch_relay( - target_relays: &[String], - relay_url: &str, -) -> Result<String, RadrootsRelayTransportError> { - let target = Target::new(TransportId::NOSTR, relay_url).map_err(|error| { - RadrootsRelayTransportError::InvalidFetchItemRelayUrl { - url: relay_url.to_owned(), - reason: error.to_string(), - } - })?; - let canonical = target.uri().as_str(); - if !target_relays.iter().any(|requested| requested == canonical) { - return Err(RadrootsRelayTransportError::UnexpectedFetchItemRelayUrl { - url: canonical.to_owned(), - }); - } - Ok(canonical.to_owned()) -} - -fn accepted_fetch_event_receipt( - event: &RadrootsRelayFetchedEvent, -) -> RadrootsRelayFetchEventReceipt { - RadrootsRelayFetchEventReceipt { - relay_url: event.relay_url.clone(), - event_id: Some(event.event.id.to_hex()), - inserted: false, - duplicate: false, - not_persisted: false, - malformed: false, - out_of_filter: false, - skipped_over_limit: false, - verification: RadrootsRelayFetchEventVerification::Verified, - admission: RadrootsRelayFetchEventAdmission::NotEvaluated, - admission_code: None, - valid_stream: RadrootsRelayFetchEventValidStream::NotEvaluated, - visibility: RadrootsRelayFetchEventVisibility::NotEvaluated, - message: Some("event accepted by relay fetch filters".to_owned()), - } -} - -fn duplicate_fetch_event_receipt( - event: &RadrootsRelayFetchedEvent, -) -> RadrootsRelayFetchEventReceipt { - RadrootsRelayFetchEventReceipt { - relay_url: event.relay_url.clone(), - event_id: Some(event.event.id.to_hex()), - inserted: false, - duplicate: true, - not_persisted: false, - malformed: false, - out_of_filter: false, - skipped_over_limit: false, - verification: RadrootsRelayFetchEventVerification::Verified, - admission: RadrootsRelayFetchEventAdmission::NotEvaluated, - admission_code: None, - valid_stream: RadrootsRelayFetchEventValidStream::NotEvaluated, - visibility: RadrootsRelayFetchEventVisibility::NotEvaluated, - message: Some("event ID was already observed in this relay fetch".to_owned()), - } -} - -#[cfg_attr(coverage_nightly, coverage(off))] -fn relay_fetch_event_matches_filters( - filters: &[RadrootsNostrFilter], - event: &RadrootsNostrEvent, -) -> bool { - !filters.is_empty() - && filters - .iter() - .any(|filter| filter.match_event(event, MatchEventOptions::new())) -} - -#[derive(Clone, Copy, Debug, Default)] -pub struct RadrootsNostrClientFetchAdapter; - -impl RadrootsRelayFetchAdapter for RadrootsNostrClientFetchAdapter { - #[cfg_attr(coverage_nightly, coverage(off))] - fn fetch<'a>( - &'a self, - request: RadrootsRelayFetchRequest, - ) -> BoxFuture<'a, Result<Vec<RadrootsRelayFetchItem>, RadrootsRelayTransportError>> { - Box::pin(async move { fetch_from_nostr_relays(request).await }) - } -} - -#[cfg_attr(coverage_nightly, coverage(off))] -async fn fetch_from_nostr_relays( - request: RadrootsRelayFetchRequest, -) -> Result<Vec<RadrootsRelayFetchItem>, RadrootsRelayTransportError> { - if request.filters.as_slice().is_empty() { - return Err(RadrootsRelayTransportError::EmptyFetchFilters); - } - let timeout = Duration::from_millis(request.timeout_ms); - let filters = request.filters.as_slice().to_vec(); - let mut raw_budget = - RadrootsRelayFetchRawBudget::new(request.max_raw_events, request.max_raw_json_bytes); - let mut items = Vec::new(); - let relay_urls = request.relay_targets.relay_strings(); - for (relay_index, relay_url) in relay_urls.iter().cloned().enumerate() { - if let Some(reason) = raw_budget.exhaustion_reason() { - items.extend(relay_urls[relay_index..].iter().cloned().map(|relay_url| { - RadrootsRelayFetchItem::Truncated { - relay_url, - message: reason.unqueried_relay_message().to_owned(), - } - })); - break; - } - let client = RadrootsNostrClient::new_signerless(); - if let Err(error) = client.add_read_relay(relay_url.as_str()).await { - items.push(RadrootsRelayFetchItem::Closed { - relay_url, - message: error.to_string(), - }); - continue; - } - let connection_output = client.try_connect(timeout).await; - if connection_output.success.is_empty() { - items.push(RadrootsRelayFetchItem::Closed { - relay_url, - message: summarize_nostr_output_failures(&connection_output.failed), - }); - continue; - } - let mut closed = false; - let mut truncated_message = None; - for filter in filters.iter().cloned() { - if let Some(reason) = raw_budget.exhaustion_reason() { - truncated_message = Some(reason.current_relay_message().to_owned()); - break; - } - let filter_limit = filter - .limit - .unwrap_or(raw_budget.remaining_events) - .min(raw_budget.remaining_events); - match client - .stream_events(filter.limit(filter_limit), timeout) - .await - { - Ok(mut events) => { - loop { - if let Some(reason) = raw_budget.exhaustion_reason() { - truncated_message = Some(reason.current_relay_message().to_owned()); - break; - } - let Some(event) = events.next().await else { - break; - }; - let raw_json = event.as_json(); - if let Err(reason) = raw_budget.charge(raw_json.len()) { - truncated_message = Some(reason.current_relay_message().to_owned()); - break; - } - if raw_json.len() > DEFAULT_RAW_JSON_MAX_BYTES { - truncated_message = Some(format!( - "relay event raw JSON exceeds {DEFAULT_RAW_JSON_MAX_BYTES} byte limit" - )); - break; - } - items.push(RadrootsRelayFetchItem::Event { - relay_url: relay_url.clone(), - raw_json, - }); - } - if truncated_message.is_some() { - break; - } - } - Err(error) => { - items.push(RadrootsRelayFetchItem::Closed { - relay_url: relay_url.clone(), - message: error.to_string(), - }); - closed = true; - break; - } - } - } - if let Some(message) = truncated_message { - items.push(RadrootsRelayFetchItem::Truncated { relay_url, message }); - } else if !closed { - items.push(unproven_relay_stream_completion(relay_url)); - } - } - Ok(items) -} - -fn unproven_relay_stream_completion(relay_url: String) -> RadrootsRelayFetchItem { - RadrootsRelayFetchItem::Closed { - relay_url, - message: "relay stream ended before EOSE could be observed".to_owned(), - } -} - -fn summarize_nostr_output_failures<K, E>(failed: &std::collections::HashMap<K, E>) -> String -where - K: std::fmt::Display + Eq + std::hash::Hash, - E: std::fmt::Display, -{ - if failed.is_empty() { - return "no relay acknowledged the operation".to_owned(); - } - failed - .iter() - .map(|(relay, error)| format!("{relay}: {error}")) - .collect::<Vec<_>>() - .join("; ") -} - -#[derive(Clone, Default)] -pub struct RadrootsMockRelayFetchAdapter { - items: Arc<Mutex<Vec<RadrootsRelayFetchItem>>>, -} - -impl RadrootsMockRelayFetchAdapter { - pub fn new(items: Vec<RadrootsRelayFetchItem>) -> Self { - Self { - items: Arc::new(Mutex::new(items)), - } - } -} - -impl RadrootsRelayFetchAdapter for RadrootsMockRelayFetchAdapter { - #[cfg_attr(coverage_nightly, coverage(off))] - fn fetch<'a>( - &'a self, - _request: RadrootsRelayFetchRequest, - ) -> BoxFuture<'a, Result<Vec<RadrootsRelayFetchItem>, RadrootsRelayTransportError>> { - Box::pin(async move { Ok(self.items.lock().map_err(fetch_item_lock_error)?.clone()) }) - } -} - -#[cfg_attr(coverage_nightly, coverage(off))] -fn fetch_item_lock_error<T>(_error: PoisonError<T>) -> RadrootsRelayTransportError { - RadrootsRelayTransportError::Transport("fetch item lock poisoned".to_owned()) -} - -#[cfg(test)] -mod tests { - use super::{ - RadrootsNostrEvent, RadrootsRelayFetchEventAdmission, RadrootsRelayFetchEventValidStream, - RadrootsRelayFetchEventVerification, RadrootsRelayFetchEventVisibility, - RadrootsRelayFetchItem, RadrootsRelayFetchRawBudget, RadrootsRelayFetchRawBudgetExhaustion, - RadrootsRelayTransportError, relay_fetch_event_matches_filters, relay_fetch_visibility, - required_persisted_fetch_receipt_event_id, summarize_nostr_output_failures, - unproven_relay_stream_completion, - }; - use nostr::JsonUtil; - use nostr::{Keys as RadrootsNostrKeys, SecretKey as RadrootsNostrSecretKey}; - use radroots_event_store::{RadrootsEventVisibility, RadrootsNip09SuppressionReason}; - use radroots_nostr::event::Kind as RadrootsNostrKind; - use radroots_nostr::filter::Filter as RadrootsNostrFilter; - use std::collections::HashMap; - - const FIXTURE_ALICE_SECRET_KEY_HEX: &str = - "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5"; - - fn signed_raw_event() -> RadrootsNostrEvent { - let secret_key = - RadrootsNostrSecretKey::from_hex(FIXTURE_ALICE_SECRET_KEY_HEX).expect("secret key"); - let keys = RadrootsNostrKeys::new(secret_key); - let event = nostr::EventBuilder::new(nostr::Kind::TextNote, "hello") - .sign_with_keys(&keys) - .expect("signed event"); - RadrootsNostrEvent::from_json(event.as_json().as_str()).expect("raw event") - } - - #[test] - fn relay_fetch_filter_helper_rejects_empty_filter_set() { - let event = signed_raw_event(); - assert!(!relay_fetch_event_matches_filters(&[], &event)); - assert!(relay_fetch_event_matches_filters( - &[RadrootsNostrFilter::new().kind(RadrootsNostrKind::TextNote)], - &event - )); - } - - #[test] - fn nostr_output_failure_summary_covers_empty_and_reported_failures() { - assert_eq!( - summarize_nostr_output_failures::<String, String>(&HashMap::new()), - "no relay acknowledged the operation" - ); - - let mut failures = HashMap::new(); - failures.insert("wss://relay.example.com".to_owned(), "timeout".to_owned()); - failures.insert("wss://relay-2.example.com".to_owned(), "denied".to_owned()); - - let summary = summarize_nostr_output_failures(&failures); - assert!(summary.contains("wss://relay.example.com: timeout")); - assert!(summary.contains("wss://relay-2.example.com: denied")); - assert!(summary.contains("; ")); - } - - #[test] - fn unproven_sdk_stream_completion_never_claims_eose() { - assert_eq!( - unproven_relay_stream_completion("wss://relay.example".to_owned()), - RadrootsRelayFetchItem::Closed { - relay_url: "wss://relay.example".to_owned(), - message: "relay stream ended before EOSE could be observed".to_owned(), - } - ); - } - - #[test] - fn raw_fetch_budget_allows_exact_boundaries_and_sticks_after_exhaustion() { - let mut count_budget = RadrootsRelayFetchRawBudget::new(1, 10); - assert_eq!(count_budget.charge(1), Ok(())); - assert_eq!( - count_budget.exhaustion_reason(), - Some(RadrootsRelayFetchRawBudgetExhaustion::RawEvents) - ); - assert_eq!( - count_budget.charge(1), - Err(RadrootsRelayFetchRawBudgetExhaustion::RawEvents) - ); - - let mut byte_budget = RadrootsRelayFetchRawBudget::new(2, 5); - assert_eq!(byte_budget.charge(5), Ok(())); - assert_eq!( - byte_budget.exhaustion_reason(), - Some(RadrootsRelayFetchRawBudgetExhaustion::RawJsonBytes) - ); - assert_eq!( - byte_budget.charge(0), - Err(RadrootsRelayFetchRawBudgetExhaustion::RawJsonBytes) - ); - } - - #[test] - fn raw_fetch_budget_rejects_crossing_without_partial_charge() { - let mut budget = RadrootsRelayFetchRawBudget::new(2, 4); - assert_eq!( - budget.charge(usize::MAX), - Err(RadrootsRelayFetchRawBudgetExhaustion::RawJsonBytes) - ); - assert_eq!(budget.remaining_events, 2); - assert_eq!(budget.remaining_json_bytes, 4); - assert_eq!( - budget.charge(1), - Err(RadrootsRelayFetchRawBudgetExhaustion::RawJsonBytes) - ); - assert_eq!( - RadrootsRelayFetchRawBudgetExhaustion::RawEvents.current_relay_message(), - "raw event scan limit reached before relay EOSE" - ); - assert!( - RadrootsRelayFetchRawBudgetExhaustion::RawJsonBytes - .unqueried_relay_message() - .contains("aggregate raw JSON byte limit") - ); - } - - #[test] - fn event_store_visibility_maps_without_admission_collapse() { - let cases = [ - ( - RadrootsEventVisibility::Visible, - RadrootsRelayFetchEventVisibility::Visible, - ), - ( - RadrootsEventVisibility::NotAdmitted, - RadrootsRelayFetchEventVisibility::NotAdmitted, - ), - ( - RadrootsEventVisibility::NotCurrent { - raw_head_event_id: "head".to_owned(), - }, - RadrootsRelayFetchEventVisibility::NotCurrent, - ), - ( - RadrootsEventVisibility::Suppressed { - reason: RadrootsNip09SuppressionReason::EventIdReference, - event_reference_request_id: None, - address_reference_request_id: None, - address_reference_cutoff: None, - }, - RadrootsRelayFetchEventVisibility::Suppressed, - ), - ]; - for (source, expected) in cases { - assert_eq!( - relay_fetch_visibility("event", source).expect("known visibility"), - expected - ); - } - } - - #[test] - fn persisted_fetch_receipts_require_an_event_id() { - assert_eq!( - required_persisted_fetch_receipt_event_id(Some("event")).expect("event id"), - "event" - ); - assert!(matches!( - required_persisted_fetch_receipt_event_id(None), - Err(RadrootsRelayTransportError::MissingPersistedFetchReceiptEventId) - )); - } - - #[test] - fn event_processing_outcomes_have_stable_exhaustive_wire_values() { - let cases = [ - serde_json::to_string(&RadrootsRelayFetchEventVerification::NotEvaluated) - .expect("verification"), - serde_json::to_string(&RadrootsRelayFetchEventVerification::Verified) - .expect("verification"), - serde_json::to_string(&RadrootsRelayFetchEventVerification::Failed) - .expect("verification"), - serde_json::to_string(&RadrootsRelayFetchEventAdmission::NotEvaluated) - .expect("admission"), - serde_json::to_string(&RadrootsRelayFetchEventAdmission::Admitted).expect("admission"), - serde_json::to_string(&RadrootsRelayFetchEventAdmission::Unsupported) - .expect("admission"), - serde_json::to_string(&RadrootsRelayFetchEventAdmission::Invalid).expect("admission"), - serde_json::to_string(&RadrootsRelayFetchEventValidStream::NotEvaluated) - .expect("valid stream"), - serde_json::to_string(&RadrootsRelayFetchEventValidStream::Eligible) - .expect("valid stream"), - serde_json::to_string(&RadrootsRelayFetchEventValidStream::Ineligible) - .expect("valid stream"), - serde_json::to_string(&RadrootsRelayFetchEventVisibility::NotEvaluated) - .expect("visibility"), - serde_json::to_string(&RadrootsRelayFetchEventVisibility::NotPersisted) - .expect("visibility"), - serde_json::to_string(&RadrootsRelayFetchEventVisibility::Visible).expect("visibility"), - serde_json::to_string(&RadrootsRelayFetchEventVisibility::NotAdmitted) - .expect("visibility"), - serde_json::to_string(&RadrootsRelayFetchEventVisibility::NotCurrent) - .expect("visibility"), - serde_json::to_string(&RadrootsRelayFetchEventVisibility::Suppressed) - .expect("visibility"), - ]; - assert_eq!( - cases, - [ - "\"not_evaluated\"", - "\"verified\"", - "\"failed\"", - "\"not_evaluated\"", - "\"admitted\"", - "\"unsupported\"", - "\"invalid\"", - "\"not_evaluated\"", - "\"eligible\"", - "\"ineligible\"", - "\"not_evaluated\"", - "\"not_persisted\"", - "\"visible\"", - "\"not_admitted\"", - "\"not_current\"", - "\"suppressed\"", - ] - ); - } -} diff --git a/crates/transport_nostr/src/nip11.rs b/crates/transport_nostr/src/nip11.rs @@ -1,26 +0,0 @@ -use radroots_event::social::relay_document::RelayDocument; -pub async fn fetch_nip11(ws_url: &str) -> Option<RelayDocument> { - let http_url = ws_to_http(ws_url)?; - let client = reqwest::Client::new(); - client - .get(&http_url) - .header("Accept", "application/nostr+json") - .send() - .await - .ok()? - .json::<RelayDocument>() - .await - .ok() -} - -fn ws_to_http(ws: &str) -> Option<String> { - let mut url = reqwest::Url::parse(ws).ok()?; - let scheme = url.scheme().to_owned(); - let replacement = match scheme.as_str() { - "wss" => "https", - "ws" => "http", - other => other, - }; - url.set_scheme(replacement).ok()?; - Some(url.into()) -} diff --git a/crates/transport_nostr/src/outbox.rs b/crates/transport_nostr/src/outbox.rs @@ -1,1685 +0,0 @@ -#![forbid(unsafe_code)] - -use crate::error::ensure_nonnegative_timestamp; -use crate::{ - RadrootsRelayOutcome, RadrootsRelayPublishAdapter, RadrootsRelayPublishReceipt, - RadrootsRelayPublishRelayReceipt, RadrootsRelayPublishRequest, RadrootsRelayTargetSet, - RadrootsRelayTransportError, RadrootsRelayUrlPolicy, publish_signed_event, -}; -use radroots_event::draft::SignedEvent; -use radroots_event_store::{ - RadrootsEventIngest, RadrootsEventStore, RadrootsTransportObservation, - RadrootsTransportObservationType, -}; -use radroots_outbox::{ - RadrootsOutbox, RadrootsOutboxClaimedEvent, RadrootsOutboxDeliveryTargetRecord, - RadrootsOutboxDeliveryTargetStatus, RadrootsOutboxEventStoreIngestReceipt, -}; -#[cfg(test)] -use radroots_transport::RadrootsTransportDeliveryReceipt; -use radroots_transport::outcome::{DeliveryOutcomeKind, Retryability}; -use radroots_transport::policy::{ - SatisfactionClass, SatisfactionPolicy as SinkSatisfactionPolicy, TargetPolicy, -}; -use radroots_transport::sink::{DeliveryPayload, DeliveryReceipt, DeliveryRequest, EventSink}; -use radroots_transport::target::{TargetFingerprint, TargetLabel, TargetScope}; -use radroots_transport::{ - RadrootsTransportDeliveryTargetStatus, RadrootsTransportError, - RadrootsTransportSatisfactionClass, RadrootsTransportSatisfactionPolicy, Target, TargetSet, - TransportId, -}; -#[cfg(test)] -use radroots_transport::{RadrootsTransportOutcome, RadrootsTransportOutcomeKind}; - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsOutboxPublishPolicy { - pub next_attempt_after_ms: i64, - pub republish_accepted_relays: bool, - pub relay_url_policy: RadrootsRelayUrlPolicy, -} - -impl RadrootsOutboxPublishPolicy { - pub fn new(next_attempt_after_ms: i64) -> Self { - Self { - next_attempt_after_ms, - republish_accepted_relays: false, - relay_url_policy: RadrootsRelayUrlPolicy::Public, - } - } - - pub fn republish_accepted_relays(mut self, enabled: bool) -> Self { - self.republish_accepted_relays = enabled; - self - } - - pub fn relay_url_policy(mut self, policy: RadrootsRelayUrlPolicy) -> Self { - self.relay_url_policy = policy; - self - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsOutboxPublishReceipt { - pub local_ingest: RadrootsOutboxEventStoreIngestReceipt, - pub event_id: String, - pub attempted_count: usize, - pub accepted_count: usize, - pub retryable_count: usize, - pub terminal_count: usize, - pub quorum: usize, - pub quorum_met: bool, - pub target_receipts: Vec<RadrootsOutboxPublishTargetReceipt>, - pub relay_receipts: Vec<RadrootsRelayPublishRelayReceipt>, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsOutboxPublishTargetReceipt { - pub delivery_target_id: i64, - pub endpoint_uri: String, - pub endpoint_fingerprint: TargetFingerprint, - pub target_scope: Option<String>, - pub target_label: Option<String>, - pub attempted: bool, - pub transport_status: RadrootsTransportDeliveryTargetStatus, - pub outcome: RadrootsRelayOutcome, -} - -pub async fn publish_claimed_outbox_event<A>( - outbox: &RadrootsOutbox, - event_store: &RadrootsEventStore, - adapter: &A, - claimed: &RadrootsOutboxClaimedEvent, - policy: RadrootsOutboxPublishPolicy, - now_ms: i64, -) -> Result<RadrootsOutboxPublishReceipt, RadrootsRelayTransportError> -where - A: RadrootsRelayPublishAdapter, -{ - ensure_nonnegative_timestamp("now_ms", now_ms)?; - ensure_nonnegative_timestamp("next_attempt_after_ms", policy.next_attempt_after_ms)?; - let signed_event = claimed.signed_event.clone().ok_or( - RadrootsRelayTransportError::MissingSignedOutboxEvent(claimed.outbox_event_id), - )?; - let local_ingest = outbox - .ingest_signed_event_local( - event_store, - claimed.outbox_event_id, - claimed.claim_token.as_str(), - now_ms, - ) - .await?; - let publishable = publishable_relays(outbox, claimed, policy.republish_accepted_relays).await?; - if publishable.relays.is_empty() { - outbox - .complete_publish_attempt( - claimed.outbox_event_id, - claimed.claim_token.as_str(), - "relay publish incomplete", - "relay publish terminal", - policy.next_attempt_after_ms, - now_ms, - ) - .await?; - return Ok(RadrootsOutboxPublishReceipt { - local_ingest, - event_id: signed_event.id_str().to_owned(), - attempted_count: 0, - accepted_count: publishable.accepted_count, - retryable_count: 0, - terminal_count: 0, - quorum: publishable.remaining_satisfaction_count, - quorum_met: publishable.satisfied_count >= publishable.satisfaction_required_count, - target_receipts: Vec::new(), - relay_receipts: Vec::new(), - }); - } - let targets = RadrootsRelayTargetSet::new( - unique_publishable_relay_urls(&publishable), - policy.relay_url_policy, - )?; - let target_strings = targets.relay_strings(); - let active_delivery_plan_id = publishable.active_delivery_plan_id; - let request = RadrootsRelayPublishRequest::new(signed_event.clone(), targets, now_ms)? - .with_satisfaction_policy(RadrootsTransportSatisfactionPolicy::NoWait) - .try_with_idempotency_key(outbox_publish_idempotency_key( - claimed.outbox_event_id, - claimed.attempt_count, - signed_event.id_str(), - active_delivery_plan_id, - ))?; - let publish = match publish_signed_event(adapter, request).await { - Ok(receipt) => receipt, - Err(RadrootsRelayTransportError::Transport(message)) => adapter_transport_failure_receipt( - signed_event.id_str().to_owned(), - target_strings, - 0, - message, - ), - Err(error) => return Err(error), - }; - let target_receipts = target_receipts_from_relay_receipts(&publishable, &publish.relays); - - for target_receipt in &target_receipts { - complete_outbox_delivery_target(outbox, claimed, target_receipt, now_ms).await?; - } - - for relay in &publish.relays { - if relay - .outcome - .to_transport_outcome() - .status - .counts_as_satisfied(RadrootsTransportSatisfactionClass::Accepted) - && publishable - .targets_for_relay(relay.relay_url.as_str()) - .next() - .is_some() - { - ingest_publish_observation( - event_store, - &signed_event, - relay.relay_url.as_str(), - now_ms, - ) - .await?; - } - } - - outbox - .complete_publish_attempt( - claimed.outbox_event_id, - claimed.claim_token.as_str(), - "relay publish incomplete", - "relay publish terminal", - policy.next_attempt_after_ms, - now_ms, - ) - .await?; - - Ok(RadrootsOutboxPublishReceipt { - local_ingest, - event_id: publish.event_id, - attempted_count: target_receipts - .iter() - .filter(|receipt| receipt.attempted) - .count(), - accepted_count: target_receipts - .iter() - .filter(|receipt| receipt.outcome.counts_toward_quorum()) - .count(), - retryable_count: target_receipts - .iter() - .filter(|receipt| receipt.outcome.is_retryable()) - .count(), - terminal_count: target_receipts - .iter() - .filter(|receipt| receipt.outcome.is_terminal_failure()) - .count(), - quorum: publishable.remaining_satisfaction_count, - quorum_met: publishable.satisfied_count_after_receipts(&target_receipts) - >= publishable.satisfaction_required_count, - target_receipts, - relay_receipts: publish.relays, - }) -} - -pub async fn publish_claimed_outbox_event_with_transport<T>( - outbox: &RadrootsOutbox, - event_store: &RadrootsEventStore, - transport: &T, - claimed: &RadrootsOutboxClaimedEvent, - policy: RadrootsOutboxPublishPolicy, - now_ms: i64, -) -> Result<RadrootsOutboxPublishReceipt, RadrootsRelayTransportError> -where - T: EventSink + ?Sized, -{ - ensure_nonnegative_timestamp("now_ms", now_ms)?; - ensure_nonnegative_timestamp("next_attempt_after_ms", policy.next_attempt_after_ms)?; - let status = transport - .status() - .await - .map_err(transport_error_to_relay_error)?; - if status.transport_id() != TransportId::NOSTR { - return Err(RadrootsRelayTransportError::UnexpectedTransportKind { - expected: "nostr", - actual: status.transport_id().canonical_label(), - }); - } - let signed_event = claimed.signed_event.clone().ok_or( - RadrootsRelayTransportError::MissingSignedOutboxEvent(claimed.outbox_event_id), - )?; - let local_ingest = outbox - .ingest_signed_event_local( - event_store, - claimed.outbox_event_id, - claimed.claim_token.as_str(), - now_ms, - ) - .await?; - let publishable = publishable_relays(outbox, claimed, policy.republish_accepted_relays).await?; - if publishable.relays.is_empty() { - outbox - .complete_publish_attempt( - claimed.outbox_event_id, - claimed.claim_token.as_str(), - "relay publish incomplete", - "relay publish terminal", - policy.next_attempt_after_ms, - now_ms, - ) - .await?; - return Ok(RadrootsOutboxPublishReceipt { - local_ingest, - event_id: signed_event.id_str().to_owned(), - attempted_count: 0, - accepted_count: publishable.accepted_count, - retryable_count: 0, - terminal_count: 0, - quorum: publishable.remaining_satisfaction_count, - quorum_met: publishable.satisfied_count >= publishable.satisfaction_required_count, - target_receipts: Vec::new(), - relay_receipts: Vec::new(), - }); - } - RadrootsRelayTargetSet::new( - unique_publishable_relay_urls(&publishable), - policy.relay_url_policy, - )?; - let transport_targets = publishable_transport_targets(&publishable)?; - let target_set = TargetSet::new(transport_targets)?; - let request_id = outbox_publish_idempotency_key( - claimed.outbox_event_id, - claimed.attempt_count, - signed_event.id_str(), - publishable.active_delivery_plan_id, - ); - let deadline_unix_ms = - u64::try_from(policy.next_attempt_after_ms.max(now_ms).max(1)).map_err(|_| { - RadrootsRelayTransportError::TransportContract( - "outbox delivery deadline is outside the transport range".to_owned(), - ) - })?; - let delivery_request = DeliveryRequest::new( - request_id, - DeliveryPayload::new(signed_event.clone()), - target_set, - SinkSatisfactionPolicy::new(SatisfactionClass::Accepted, TargetPolicy::all()), - deadline_unix_ms, - ) - .map_err(transport_error_to_relay_error)?; - let delivery = transport - .deliver(delivery_request.clone()) - .await - .map_err(transport_error_to_relay_error)?; - delivery - .validate_for_request(&delivery_request) - .map_err(transport_error_to_relay_error)?; - let relay_receipts = relay_receipts_from_delivery_receipts(&delivery)?; - let target_receipts = target_receipts_from_delivery_receipts(&publishable, &delivery); - - for target_receipt in &target_receipts { - complete_outbox_delivery_target(outbox, claimed, target_receipt, now_ms).await?; - } - - for relay in &relay_receipts { - if relay - .outcome - .to_transport_outcome() - .status - .counts_as_satisfied(RadrootsTransportSatisfactionClass::Accepted) - && publishable - .targets_for_relay(relay.relay_url.as_str()) - .next() - .is_some() - { - ingest_publish_observation( - event_store, - &signed_event, - relay.relay_url.as_str(), - now_ms, - ) - .await?; - } - } - - outbox - .complete_publish_attempt( - claimed.outbox_event_id, - claimed.claim_token.as_str(), - "relay publish incomplete", - "relay publish terminal", - policy.next_attempt_after_ms, - now_ms, - ) - .await?; - - Ok(RadrootsOutboxPublishReceipt { - local_ingest, - event_id: signed_event.id_str().to_owned(), - attempted_count: target_receipts - .iter() - .filter(|receipt| receipt.attempted) - .count(), - accepted_count: target_receipts - .iter() - .filter(|receipt| receipt.outcome.counts_toward_quorum()) - .count(), - retryable_count: target_receipts - .iter() - .filter(|receipt| receipt.outcome.is_retryable()) - .count(), - terminal_count: target_receipts - .iter() - .filter(|receipt| receipt.outcome.is_terminal_failure()) - .count(), - quorum: publishable.remaining_satisfaction_count, - quorum_met: publishable.satisfied_count_after_receipts(&target_receipts) - >= publishable.satisfaction_required_count, - target_receipts, - relay_receipts, - }) -} - -fn adapter_transport_failure_receipt( - event_id: String, - relay_urls: Vec<String>, - quorum: usize, - message: String, -) -> RadrootsRelayPublishReceipt { - let relays = relay_urls - .into_iter() - .map(|relay_url| { - RadrootsRelayPublishRelayReceipt::attempted( - relay_url, - RadrootsRelayOutcome::connection_failed(message.clone()), - ) - }) - .collect::<Vec<_>>(); - RadrootsRelayPublishReceipt { - event_id, - attempted_count: relays.len(), - accepted_count: 0, - retryable_count: relays.len(), - terminal_count: 0, - quorum, - quorum_met: false, - relays, - } -} - -struct PublishableRelays { - active_delivery_plan_id: i64, - relays: Vec<PublishableRelay>, - accepted_count: usize, - satisfied_count: usize, - satisfaction_required_count: usize, - remaining_satisfaction_count: usize, - satisfaction_class: RadrootsTransportSatisfactionClass, - required_targets: Option<Vec<TargetFingerprint>>, - #[cfg_attr(not(test), allow(dead_code))] - remaining_required_targets: Option<Vec<TargetFingerprint>>, -} - -impl PublishableRelays { - fn targets_for_relay<'a>( - &'a self, - relay_url: &'a str, - ) -> impl Iterator<Item = &'a PublishableRelay> + 'a { - let canonical_relay_url = Target::new(TransportId::NOSTR, relay_url) - .ok() - .map(|target| target.uri().as_str().to_owned()); - self.relays.iter().filter(move |target| { - canonical_relay_url - .as_deref() - .is_some_and(|relay_url| target.relay_url == relay_url) - }) - } - - fn satisfied_count_after_receipts( - &self, - target_receipts: &[RadrootsOutboxPublishTargetReceipt], - ) -> usize { - self.satisfied_count - + target_receipts - .iter() - .filter(|receipt| { - receipt - .transport_status - .counts_as_satisfied(self.satisfaction_class) - && self - .required_targets - .as_ref() - .is_none_or(|required| required.contains(&receipt.endpoint_fingerprint)) - }) - .count() - } -} - -struct PublishableRelay { - delivery_target_id: i64, - relay_url: String, - endpoint_fingerprint: TargetFingerprint, - target_scope: Option<String>, - target_label: Option<String>, -} - -fn unique_publishable_relay_urls(publishable: &PublishableRelays) -> Vec<&str> { - let mut relay_urls = Vec::new(); - for target in &publishable.relays { - let relay_url = target.relay_url.as_str(); - if !relay_urls.contains(&relay_url) { - relay_urls.push(relay_url); - } - } - relay_urls -} - -fn target_receipts_from_relay_receipts( - publishable: &PublishableRelays, - relay_receipts: &[RadrootsRelayPublishRelayReceipt], -) -> Vec<RadrootsOutboxPublishTargetReceipt> { - let mut target_receipts = Vec::new(); - for relay_receipt in relay_receipts { - for target in publishable.targets_for_relay(relay_receipt.relay_url.as_str()) { - target_receipts.push(RadrootsOutboxPublishTargetReceipt { - delivery_target_id: target.delivery_target_id, - endpoint_uri: target.relay_url.clone(), - endpoint_fingerprint: target.endpoint_fingerprint.clone(), - target_scope: target.target_scope.clone(), - target_label: target.target_label.clone(), - attempted: relay_receipt.attempted, - transport_status: relay_receipt.outcome.to_transport_outcome().status, - outcome: relay_receipt.outcome.clone(), - }); - } - } - target_receipts -} - -fn target_receipts_from_delivery_receipts( - publishable: &PublishableRelays, - delivery: &DeliveryReceipt, -) -> Vec<RadrootsOutboxPublishTargetReceipt> { - delivery - .target_receipts() - .iter() - .filter_map(|receipt| { - publishable - .relays - .iter() - .find(|target| target.endpoint_fingerprint == *receipt.target().fingerprint()) - .map(|target| RadrootsOutboxPublishTargetReceipt { - delivery_target_id: target.delivery_target_id, - endpoint_uri: target.relay_url.clone(), - endpoint_fingerprint: target.endpoint_fingerprint.clone(), - target_scope: target.target_scope.clone(), - target_label: target.target_label.clone(), - attempted: receipt.was_attempted(), - transport_status: delivery_target_status(receipt.outcome()), - outcome: relay_outcome_from_delivery_outcome(receipt.outcome()), - }) - }) - .collect() -} - -async fn complete_outbox_delivery_target( - outbox: &RadrootsOutbox, - claimed: &RadrootsOutboxClaimedEvent, - receipt: &RadrootsOutboxPublishTargetReceipt, - now_ms: i64, -) -> Result<(), RadrootsRelayTransportError> { - match receipt.transport_status { - RadrootsTransportDeliveryTargetStatus::Pending => { - return Err(RadrootsRelayTransportError::TransportContract( - "outbox publish receipt cannot complete a target with pending transport status" - .to_owned(), - )); - } - RadrootsTransportDeliveryTargetStatus::Accepted => { - outbox - .mark_delivery_target_accepted( - claimed.outbox_event_id, - claimed.claim_token.as_str(), - receipt.delivery_target_id, - now_ms, - ) - .await?; - } - RadrootsTransportDeliveryTargetStatus::Delivered => { - outbox - .mark_delivery_target_delivered( - claimed.outbox_event_id, - claimed.claim_token.as_str(), - receipt.delivery_target_id, - now_ms, - ) - .await?; - } - RadrootsTransportDeliveryTargetStatus::Forwarded => { - outbox - .mark_delivery_target_forwarded( - claimed.outbox_event_id, - claimed.claim_token.as_str(), - receipt.delivery_target_id, - now_ms, - ) - .await?; - } - RadrootsTransportDeliveryTargetStatus::StoredByGateway => { - outbox - .mark_delivery_target_stored_by_gateway( - claimed.outbox_event_id, - claimed.claim_token.as_str(), - receipt.delivery_target_id, - now_ms, - ) - .await?; - } - RadrootsTransportDeliveryTargetStatus::Seen => { - outbox - .mark_delivery_target_seen( - claimed.outbox_event_id, - claimed.claim_token.as_str(), - receipt.delivery_target_id, - now_ms, - ) - .await?; - } - RadrootsTransportDeliveryTargetStatus::DeferredUntilImplemented => { - outbox - .mark_delivery_target_deferred_until_implemented( - claimed.outbox_event_id, - claimed.claim_token.as_str(), - receipt.delivery_target_id, - receipt - .outcome - .message - .as_deref() - .unwrap_or("relay publish deferred until implemented"), - now_ms, - ) - .await?; - } - RadrootsTransportDeliveryTargetStatus::SkippedPolicyDenied => { - outbox - .mark_delivery_target_skipped_policy_denied( - claimed.outbox_event_id, - claimed.claim_token.as_str(), - receipt.delivery_target_id, - receipt - .outcome - .message - .as_deref() - .unwrap_or("relay publish skipped by policy"), - now_ms, - ) - .await?; - } - RadrootsTransportDeliveryTargetStatus::FailedRetryable => { - outbox - .mark_delivery_target_failed_retryable( - claimed.outbox_event_id, - claimed.claim_token.as_str(), - receipt.delivery_target_id, - receipt - .outcome - .message - .as_deref() - .unwrap_or("relay publish retryable"), - now_ms, - ) - .await?; - } - RadrootsTransportDeliveryTargetStatus::FailedTerminal => { - outbox - .mark_delivery_target_failed_terminal( - claimed.outbox_event_id, - claimed.claim_token.as_str(), - receipt.delivery_target_id, - receipt - .outcome - .message - .as_deref() - .unwrap_or("relay publish terminal"), - now_ms, - ) - .await?; - } - } - Ok(()) -} - -fn relay_receipts_from_delivery_receipts( - delivery: &DeliveryReceipt, -) -> Result<Vec<RadrootsRelayPublishRelayReceipt>, RadrootsRelayTransportError> { - let mut relay_receipts: Vec<RadrootsRelayPublishRelayReceipt> = Vec::new(); - for receipt in delivery.target_receipts() { - let outcome = relay_outcome_from_delivery_outcome(receipt.outcome()); - let relay_receipt = if receipt.was_attempted() { - RadrootsRelayPublishRelayReceipt::attempted(receipt.target().uri().as_str(), outcome) - } else { - RadrootsRelayPublishRelayReceipt::skipped(receipt.target().uri().as_str(), outcome) - }; - if let Some(existing) = relay_receipts - .iter() - .find(|existing| existing.relay_url == relay_receipt.relay_url) - { - if existing != &relay_receipt { - return Err( - RadrootsRelayTransportError::ConflictingTransportReceiptRelayUrl { - url: relay_receipt.relay_url, - }, - ); - } - continue; - } - relay_receipts.push(relay_receipt); - } - Ok(relay_receipts) -} - -fn delivery_target_status( - outcome: &radroots_transport::outcome::DeliveryOutcome, -) -> RadrootsTransportDeliveryTargetStatus { - match outcome.kind() { - DeliveryOutcomeKind::Accepted => RadrootsTransportDeliveryTargetStatus::Accepted, - DeliveryOutcomeKind::Delivered => RadrootsTransportDeliveryTargetStatus::Delivered, - DeliveryOutcomeKind::Rejected => RadrootsTransportDeliveryTargetStatus::FailedTerminal, - DeliveryOutcomeKind::Unavailable => RadrootsTransportDeliveryTargetStatus::FailedRetryable, - DeliveryOutcomeKind::Failed => match outcome.retryability() { - Retryability::Retryable => RadrootsTransportDeliveryTargetStatus::FailedRetryable, - Retryability::Terminal | Retryability::NotApplicable => { - RadrootsTransportDeliveryTargetStatus::FailedTerminal - } - }, - } -} - -fn relay_outcome_from_delivery_outcome( - outcome: &radroots_transport::outcome::DeliveryOutcome, -) -> RadrootsRelayOutcome { - let kind = outcome - .code() - .and_then(relay_outcome_kind_from_code) - .unwrap_or(match outcome.kind() { - DeliveryOutcomeKind::Accepted => crate::RadrootsRelayOutcomeKind::Accepted, - DeliveryOutcomeKind::Delivered => crate::RadrootsRelayOutcomeKind::Accepted, - DeliveryOutcomeKind::Rejected => crate::RadrootsRelayOutcomeKind::Invalid, - DeliveryOutcomeKind::Unavailable => crate::RadrootsRelayOutcomeKind::Error, - DeliveryOutcomeKind::Failed if outcome.is_retryable() => { - crate::RadrootsRelayOutcomeKind::Error - } - DeliveryOutcomeKind::Failed => crate::RadrootsRelayOutcomeKind::Invalid, - }); - RadrootsRelayOutcome { - kind, - message: outcome.message().map(str::to_owned), - } -} - -#[cfg(test)] -fn relay_outcome_from_transport_outcome( - outcome: &RadrootsTransportOutcome, -) -> RadrootsRelayOutcome { - let kind = outcome - .code - .as_deref() - .and_then(relay_outcome_kind_from_code) - .unwrap_or_else(|| relay_outcome_kind_from_transport_outcome(outcome.kind)); - RadrootsRelayOutcome { - kind, - message: outcome.message.clone(), - } -} - -#[cfg(test)] -fn target_receipts_from_transport_receipts( - publishable: &PublishableRelays, - delivery: &RadrootsTransportDeliveryReceipt, -) -> Vec<RadrootsOutboxPublishTargetReceipt> { - delivery - .target_receipts() - .iter() - .filter_map(|receipt| { - publishable - .relays - .iter() - .find(|target| target.endpoint_fingerprint == *receipt.target.fingerprint()) - .map(|target| RadrootsOutboxPublishTargetReceipt { - delivery_target_id: target.delivery_target_id, - endpoint_uri: target.relay_url.clone(), - endpoint_fingerprint: target.endpoint_fingerprint.clone(), - target_scope: target.target_scope.clone(), - target_label: target.target_label.clone(), - attempted: receipt.attempted, - transport_status: receipt.status, - outcome: relay_outcome_from_transport_outcome(&receipt.outcome), - }) - }) - .collect() -} - -#[cfg(test)] -fn relay_receipts_from_transport_receipts( - delivery: &RadrootsTransportDeliveryReceipt, -) -> Result<Vec<RadrootsRelayPublishRelayReceipt>, RadrootsRelayTransportError> { - let mut relay_receipts: Vec<RadrootsRelayPublishRelayReceipt> = Vec::new(); - for receipt in delivery.target_receipts() { - let outcome = relay_outcome_from_transport_outcome(&receipt.outcome); - let relay_receipt = if receipt.attempted { - RadrootsRelayPublishRelayReceipt::attempted(receipt.target.uri().as_str(), outcome) - } else { - RadrootsRelayPublishRelayReceipt::skipped(receipt.target.uri().as_str(), outcome) - }; - if let Some(existing) = relay_receipts - .iter() - .find(|existing| existing.relay_url == relay_receipt.relay_url) - { - if existing != &relay_receipt { - return Err( - RadrootsRelayTransportError::ConflictingTransportReceiptRelayUrl { - url: relay_receipt.relay_url, - }, - ); - } - continue; - } - relay_receipts.push(relay_receipt); - } - Ok(relay_receipts) -} - -fn relay_outcome_kind_from_code(code: &str) -> Option<crate::RadrootsRelayOutcomeKind> { - Some(match code { - "accepted" => crate::RadrootsRelayOutcomeKind::Accepted, - "duplicate_accepted" => crate::RadrootsRelayOutcomeKind::DuplicateAccepted, - "blocked" => crate::RadrootsRelayOutcomeKind::Blocked, - "rate_limited" => crate::RadrootsRelayOutcomeKind::RateLimited, - "invalid" => crate::RadrootsRelayOutcomeKind::Invalid, - "pow_required" => crate::RadrootsRelayOutcomeKind::PowRequired, - "restricted" => crate::RadrootsRelayOutcomeKind::Restricted, - "auth_required" => crate::RadrootsRelayOutcomeKind::AuthRequired, - "muted" => crate::RadrootsRelayOutcomeKind::Muted, - "unsupported" => crate::RadrootsRelayOutcomeKind::Unsupported, - "payment_required" => crate::RadrootsRelayOutcomeKind::PaymentRequired, - "error" => crate::RadrootsRelayOutcomeKind::Error, - "timeout" => crate::RadrootsRelayOutcomeKind::Timeout, - "connection_failed" => crate::RadrootsRelayOutcomeKind::ConnectionFailed, - "relay_url_rejected" => crate::RadrootsRelayOutcomeKind::RelayUrlRejected, - "skipped_already_accepted" => crate::RadrootsRelayOutcomeKind::SkippedAlreadyAccepted, - "unknown" => crate::RadrootsRelayOutcomeKind::Unknown, - _ => return None, - }) -} - -#[cfg(test)] -fn relay_outcome_kind_from_transport_outcome( - kind: RadrootsTransportOutcomeKind, -) -> crate::RadrootsRelayOutcomeKind { - match kind { - RadrootsTransportOutcomeKind::Accepted => crate::RadrootsRelayOutcomeKind::Accepted, - RadrootsTransportOutcomeKind::DuplicateAccepted => { - crate::RadrootsRelayOutcomeKind::DuplicateAccepted - } - RadrootsTransportOutcomeKind::Rejected => crate::RadrootsRelayOutcomeKind::Invalid, - RadrootsTransportOutcomeKind::RouteUnavailable => { - crate::RadrootsRelayOutcomeKind::RelayUrlRejected - } - RadrootsTransportOutcomeKind::PolicyDenied => crate::RadrootsRelayOutcomeKind::Restricted, - RadrootsTransportOutcomeKind::Timeout => crate::RadrootsRelayOutcomeKind::Timeout, - RadrootsTransportOutcomeKind::ConnectionFailed => { - crate::RadrootsRelayOutcomeKind::ConnectionFailed - } - RadrootsTransportOutcomeKind::TransportUnavailable => { - crate::RadrootsRelayOutcomeKind::Error - } - RadrootsTransportOutcomeKind::PayloadTooLarge => crate::RadrootsRelayOutcomeKind::Invalid, - RadrootsTransportOutcomeKind::Delivered - | RadrootsTransportOutcomeKind::Forwarded - | RadrootsTransportOutcomeKind::StoredByGateway - | RadrootsTransportOutcomeKind::Seen => crate::RadrootsRelayOutcomeKind::Accepted, - RadrootsTransportOutcomeKind::DeferredUntilImplemented => { - crate::RadrootsRelayOutcomeKind::Unsupported - } - } -} - -fn publishable_transport_targets( - publishable: &PublishableRelays, -) -> Result<Vec<Target>, RadrootsRelayTransportError> { - publishable - .relays - .iter() - .map(|relay| { - Target::new_with_metadata( - TransportId::NOSTR, - relay.relay_url.as_str(), - relay - .target_scope - .as_deref() - .map(TargetScope::parse) - .transpose() - .map_err(transport_error_to_relay_error)?, - relay - .target_label - .as_deref() - .map(TargetLabel::parse) - .transpose() - .map_err(transport_error_to_relay_error)?, - ) - .map_err(transport_error_to_relay_error) - }) - .collect() -} - -#[cfg(test)] -fn transport_satisfaction_policy_for_publishable( - publishable: &PublishableRelays, -) -> RadrootsTransportSatisfactionPolicy { - satisfaction_policy_for_remaining_count( - publishable.satisfaction_class, - publishable.remaining_satisfaction_count, - publishable.relays.len(), - publishable.remaining_required_targets.as_deref(), - ) -} - -fn transport_error_to_relay_error(error: RadrootsTransportError) -> RadrootsRelayTransportError { - match error { - RadrootsTransportError::UnsupportedOperation - | RadrootsTransportError::EmptyTransportKind - | RadrootsTransportError::InvalidTransportKind - | RadrootsTransportError::EmptyTargetScope - | RadrootsTransportError::InvalidTargetScope - | RadrootsTransportError::EmptyTargetLabel - | RadrootsTransportError::InvalidTargetLabel - | RadrootsTransportError::InvalidSatisfactionPolicy - | RadrootsTransportError::EmptyRequiredTargetSet - | RadrootsTransportError::DuplicateRequiredTargetFingerprint - | RadrootsTransportError::RequiredTargetNotRequested - | RadrootsTransportError::EmptyDeliveryRequestId - | RadrootsTransportError::InvalidDeliveryRequestId - | RadrootsTransportError::InvalidDeliveryTimestamp => { - RadrootsRelayTransportError::Transport(error.to_string()) - } - RadrootsTransportError::EmptyTargetUri - | RadrootsTransportError::InvalidTargetUri - | RadrootsTransportError::EmptyTargetSet - | RadrootsTransportError::TargetSetTooLarge - | RadrootsTransportError::DuplicateTargetFingerprint - | RadrootsTransportError::InvalidTargetFingerprint - | RadrootsTransportError::EmptyFetchRequestId - | RadrootsTransportError::InvalidFetchRequestId - | RadrootsTransportError::InvalidFetchLimit - | RadrootsTransportError::InvalidFetchDeadline - | RadrootsTransportError::EmptyFetchCursor - | RadrootsTransportError::InvalidFetchCursor - | RadrootsTransportError::InvalidObservedAt - | RadrootsTransportError::UnexpectedFetchProvenance - | RadrootsTransportError::UnexpectedFetchTargetOutcome - | RadrootsTransportError::DuplicateFetchTargetOutcome - | RadrootsTransportError::FetchPageLimitExceeded - | RadrootsTransportError::FetchPageRequestMismatch - | RadrootsTransportError::InvalidDeliveryDeadline - | RadrootsTransportError::InvalidDeliveryOutcome - | RadrootsTransportError::UnexpectedDeliveryTargetReceipt - | RadrootsTransportError::DuplicateDeliveryTargetReceipt - | RadrootsTransportError::MissingDeliveryTargetReceipt - | RadrootsTransportError::DeliveryTargetReceiptStatusMismatch - | RadrootsTransportError::DeliveryTargetReceiptAttemptMismatch - | RadrootsTransportError::TransportOutcomeStatusMismatch - | RadrootsTransportError::DeliveryReceiptRequestIdMismatch - | RadrootsTransportError::DeliveryReceiptTargetSetMismatch => { - RadrootsRelayTransportError::TransportContract(error.to_string()) - } - RadrootsTransportError::EmptyPayloadId - | RadrootsTransportError::InvalidPayloadId - | RadrootsTransportError::EmptyPayloadLabel - | RadrootsTransportError::InvalidPayloadLabel - | RadrootsTransportError::EmptyPayloadBytes - | RadrootsTransportError::InvalidPayloadBytes - | RadrootsTransportError::InvalidPayloadDigest - | RadrootsTransportError::PayloadDigestMismatch => { - RadrootsRelayTransportError::NostrEventJson(error.to_string()) - } - } -} - -async fn publishable_relays( - outbox: &RadrootsOutbox, - claimed: &RadrootsOutboxClaimedEvent, - republish_accepted_relays: bool, -) -> Result<PublishableRelays, RadrootsRelayTransportError> { - let active_delivery_plan_id = claimed.active_delivery_plan_id.ok_or_else(|| { - RadrootsRelayTransportError::Transport(format!( - "outbox event {} has no active delivery plan for Nostr publish", - claimed.outbox_event_id - )) - })?; - let targets = outbox.delivery_targets(claimed.outbox_event_id).await?; - let plans = outbox.delivery_plans(claimed.outbox_event_id).await?; - let plan = plans - .iter() - .find(|plan| plan.delivery_plan_id == active_delivery_plan_id) - .ok_or_else(|| { - RadrootsRelayTransportError::Transport(format!( - "outbox event {} active delivery plan {} was not found for Nostr publish", - claimed.outbox_event_id, active_delivery_plan_id - )) - })?; - let satisfaction_required_count = plan.required_success_count as usize; - let required_targets = match &plan.satisfaction_policy { - RadrootsTransportSatisfactionPolicy::RequiredTargets { targets, .. } => { - Some(targets.clone()) - } - RadrootsTransportSatisfactionPolicy::NoWait - | RadrootsTransportSatisfactionPolicy::Any { .. } - | RadrootsTransportSatisfactionPolicy::All { .. } - | RadrootsTransportSatisfactionPolicy::Quorum { .. } => None, - }; - let active_targets = targets - .iter() - .filter(|target| target.delivery_plan_id == active_delivery_plan_id) - .collect::<Vec<_>>(); - let satisfaction_class = plan - .satisfaction_policy - .target_satisfaction_class() - .unwrap_or(RadrootsTransportSatisfactionClass::Accepted); - let satisfied_count = active_targets - .iter() - .filter(|target| { - required_targets - .as_ref() - .is_none_or(|required| required.contains(&target.endpoint_fingerprint)) - && target - .status - .counts_as_transport_satisfaction(satisfaction_class) - }) - .count(); - let remaining_satisfaction_count = - (plan.required_success_count as usize).saturating_sub(satisfied_count); - let remaining_required_targets = required_targets.as_ref().map(|required_targets| { - required_targets - .iter() - .filter(|required| { - !active_targets.iter().any(|target| { - target.endpoint_fingerprint == **required - && target - .status - .counts_as_transport_satisfaction(satisfaction_class) - }) - }) - .cloned() - .collect::<Vec<_>>() - }); - let mut relays = Vec::new(); - let mut accepted_count = 0usize; - for target in &active_targets { - if !is_nostr_target(target) { - continue; - } - let required_for_satisfaction = required_targets - .as_ref() - .is_some_and(|required| required.contains(&target.endpoint_fingerprint)); - if counts_as_accepted_for_plan( - target.status, - required_targets.is_some(), - required_for_satisfaction, - ) { - accepted_count += 1; - } - let can_contribute_to_satisfaction = - required_targets.is_none() || required_for_satisfaction; - if remaining_satisfaction_count > 0 - && can_contribute_to_satisfaction - && is_publishable_delivery_status(target.status, republish_accepted_relays) - { - relays.push(PublishableRelay { - delivery_target_id: target.delivery_target_id, - relay_url: target.endpoint_uri.as_str().to_owned(), - endpoint_fingerprint: target.endpoint_fingerprint.clone(), - target_scope: target - .target_scope - .as_ref() - .map(|scope| scope.as_str().to_owned()), - target_label: target - .target_label - .as_ref() - .map(|label| label.as_str().to_owned()), - }); - } - } - if required_targets.is_some() { - let selected_relay_urls = relays - .iter() - .map(|relay| relay.relay_url.clone()) - .collect::<Vec<_>>(); - for target in &active_targets { - if !is_nostr_target(target) - || !selected_relay_urls - .iter() - .any(|relay_url| relay_url == target.endpoint_uri.as_str()) - || relays - .iter() - .any(|relay| relay.delivery_target_id == target.delivery_target_id) - || !is_publishable_delivery_status(target.status, republish_accepted_relays) - { - continue; - } - relays.push(PublishableRelay { - delivery_target_id: target.delivery_target_id, - relay_url: target.endpoint_uri.as_str().to_owned(), - endpoint_fingerprint: target.endpoint_fingerprint.clone(), - target_scope: target - .target_scope - .as_ref() - .map(|scope| scope.as_str().to_owned()), - target_label: target - .target_label - .as_ref() - .map(|label| label.as_str().to_owned()), - }); - } - } - Ok(PublishableRelays { - active_delivery_plan_id, - relays, - accepted_count, - satisfied_count, - satisfaction_required_count, - remaining_satisfaction_count, - satisfaction_class, - required_targets, - remaining_required_targets, - }) -} - -fn outbox_publish_idempotency_key( - outbox_event_id: i64, - attempt_count: i64, - event_id: &str, - active_delivery_plan_id: i64, -) -> String { - format!( - "radroots-nostr-outbox-{outbox_event_id}-{attempt_count}-{event_id}-{active_delivery_plan_id}" - ) -} - -fn counts_as_accepted_for_plan( - status: RadrootsOutboxDeliveryTargetStatus, - has_required_targets: bool, - required_for_satisfaction: bool, -) -> bool { - status.counts_as_transport_satisfaction(RadrootsTransportSatisfactionClass::Accepted) - && (!has_required_targets || required_for_satisfaction) -} - -fn is_publishable_delivery_status( - status: RadrootsOutboxDeliveryTargetStatus, - republish_accepted_relays: bool, -) -> bool { - status.is_ready_for_attempt() - || (republish_accepted_relays && status == RadrootsOutboxDeliveryTargetStatus::Accepted) -} - -fn is_nostr_target(target: &RadrootsOutboxDeliveryTargetRecord) -> bool { - target.transport_kind == TransportId::NOSTR -} - -#[cfg(test)] -fn satisfaction_policy_for_remaining_count( - satisfaction_class: RadrootsTransportSatisfactionClass, - remaining_satisfaction_count: usize, - target_count: usize, - exact_required_targets: Option<&[TargetFingerprint]>, -) -> RadrootsTransportSatisfactionPolicy { - if let Some(targets) = exact_required_targets { - return RadrootsTransportSatisfactionPolicy::RequiredTargets { - class: satisfaction_class, - targets: targets.to_vec(), - }; - } - if remaining_satisfaction_count >= target_count { - return RadrootsTransportSatisfactionPolicy::All { - class: satisfaction_class, - }; - } - if remaining_satisfaction_count == 0 { - return RadrootsTransportSatisfactionPolicy::NoWait; - } - if remaining_satisfaction_count == 1 { - return RadrootsTransportSatisfactionPolicy::Any { - class: satisfaction_class, - }; - } - let Ok(count) = u16::try_from(remaining_satisfaction_count) else { - return RadrootsTransportSatisfactionPolicy::All { - class: satisfaction_class, - }; - }; - RadrootsTransportSatisfactionPolicy::Quorum { - class: satisfaction_class, - threshold: count, - } -} - -async fn ingest_publish_observation( - event_store: &RadrootsEventStore, - signed_event: &SignedEvent, - relay_url: &str, - observed_at_ms: i64, -) -> Result<(), RadrootsRelayTransportError> { - let observation = RadrootsTransportObservation::new( - TransportId::NOSTR, - relay_url, - RadrootsTransportObservationType::PublishAck, - observed_at_ms, - )?; - let ingest = RadrootsEventIngest::from_signed_event(signed_event.clone(), observed_at_ms)? - .with_observation(observation); - event_store.ingest_event(ingest).await?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::{ - PublishableRelay, PublishableRelays, RadrootsOutboxDeliveryTargetStatus, - adapter_transport_failure_receipt, counts_as_accepted_for_plan, - is_publishable_delivery_status, publishable_transport_targets, - relay_outcome_from_transport_outcome, relay_outcome_kind_from_code, - relay_outcome_kind_from_transport_outcome, relay_receipts_from_transport_receipts, - satisfaction_policy_for_remaining_count, target_receipts_from_relay_receipts, - target_receipts_from_transport_receipts, transport_error_to_relay_error, - transport_satisfaction_policy_for_publishable, - }; - use crate::{ - RadrootsRelayOutcome, RadrootsRelayOutcomeKind, RadrootsRelayPublishRelayReceipt, - RadrootsRelayTransportError, - }; - use radroots_transport::target::TargetScope; - use radroots_transport::{ - RadrootsTransportDeliveryReceipt, RadrootsTransportDeliveryTargetStatus, - RadrootsTransportError, RadrootsTransportOutcome, RadrootsTransportOutcomeKind, - RadrootsTransportSatisfactionClass, RadrootsTransportSatisfactionPolicy, - RadrootsTransportTargetReceipt, Target, TargetSet, TransportId, - }; - - #[test] - fn internal_outbox_publish_helpers_cover_policy_edges() { - assert_eq!( - satisfaction_policy_for_remaining_count( - RadrootsTransportSatisfactionClass::Accepted, - 2, - 2, - None - ), - RadrootsTransportSatisfactionPolicy::all_accepted() - ); - assert_eq!( - satisfaction_policy_for_remaining_count( - RadrootsTransportSatisfactionClass::Accepted, - 1, - 3, - None - ), - RadrootsTransportSatisfactionPolicy::any_accepted() - ); - assert_eq!( - satisfaction_policy_for_remaining_count( - RadrootsTransportSatisfactionClass::Delivered, - 2, - 3, - None - ), - RadrootsTransportSatisfactionPolicy::quorum_delivered(2) - ); - let required_target = - Target::new(TransportId::NOSTR, "wss://relay.example").expect("required target"); - assert_eq!( - satisfaction_policy_for_remaining_count( - RadrootsTransportSatisfactionClass::Delivered, - 1, - 3, - Some(core::slice::from_ref(required_target.fingerprint())) - ), - RadrootsTransportSatisfactionPolicy::required_targets( - RadrootsTransportSatisfactionClass::Delivered, - vec![required_target.fingerprint().clone()] - ) - .expect("required target policy") - ); - assert_eq!( - satisfaction_policy_for_remaining_count( - RadrootsTransportSatisfactionClass::Accepted, - usize::from(u16::MAX) + 1, - usize::from(u16::MAX) + 2, - None, - ), - RadrootsTransportSatisfactionPolicy::All { - class: RadrootsTransportSatisfactionClass::Accepted, - } - ); - assert_eq!( - satisfaction_policy_for_remaining_count( - RadrootsTransportSatisfactionClass::Accepted, - 0, - 3, - None, - ), - RadrootsTransportSatisfactionPolicy::NoWait - ); - - assert!(counts_as_accepted_for_plan( - RadrootsOutboxDeliveryTargetStatus::Accepted, - false, - false, - )); - assert!(counts_as_accepted_for_plan( - RadrootsOutboxDeliveryTargetStatus::Accepted, - true, - true, - )); - assert!(!counts_as_accepted_for_plan( - RadrootsOutboxDeliveryTargetStatus::Accepted, - true, - false, - )); - assert!(!counts_as_accepted_for_plan( - RadrootsOutboxDeliveryTargetStatus::FailedRetryable, - false, - false, - )); - - assert!(is_publishable_delivery_status( - RadrootsOutboxDeliveryTargetStatus::Pending, - false, - )); - assert!(is_publishable_delivery_status( - RadrootsOutboxDeliveryTargetStatus::FailedRetryable, - false, - )); - assert!(is_publishable_delivery_status( - RadrootsOutboxDeliveryTargetStatus::Accepted, - true, - )); - assert!(!is_publishable_delivery_status( - RadrootsOutboxDeliveryTargetStatus::Accepted, - false, - )); - assert!(!is_publishable_delivery_status( - RadrootsOutboxDeliveryTargetStatus::FailedTerminal, - true, - )); - } - - #[test] - fn outbox_publish_satisfaction_counts_use_active_transport_class() { - let target = Target::new(TransportId::NOSTR, "wss://relay.example").expect("target"); - let publishable = PublishableRelays { - active_delivery_plan_id: 7, - relays: vec![PublishableRelay { - delivery_target_id: 11, - relay_url: target.uri().as_str().to_owned(), - endpoint_fingerprint: target.fingerprint().clone(), - target_scope: None, - target_label: None, - }], - accepted_count: 0, - satisfied_count: 0, - satisfaction_required_count: 1, - remaining_satisfaction_count: 1, - satisfaction_class: RadrootsTransportSatisfactionClass::Delivered, - required_targets: None, - remaining_required_targets: None, - }; - - let accepted_relay_receipts = target_receipts_from_relay_receipts( - &publishable, - &[RadrootsRelayPublishRelayReceipt::attempted( - target.uri().as_str(), - RadrootsRelayOutcome::accepted(), - )], - ); - assert_eq!( - accepted_relay_receipts[0].transport_status, - RadrootsTransportDeliveryTargetStatus::Accepted - ); - assert_eq!( - publishable.satisfied_count_after_receipts(&accepted_relay_receipts), - 0 - ); - - let delivery = RadrootsTransportDeliveryReceipt::new( - "request-1", - TargetSet::new(vec![target.clone()]).expect("target set"), - vec![RadrootsTransportTargetReceipt::new( - target, - RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Delivered), - )], - ) - .expect("delivery receipt"); - let delivered_transport_receipts = - target_receipts_from_transport_receipts(&publishable, &delivery); - assert_eq!( - delivered_transport_receipts[0].transport_status, - RadrootsTransportDeliveryTargetStatus::Delivered - ); - assert_eq!( - publishable.satisfied_count_after_receipts(&delivered_transport_receipts), - 1 - ); - } - - #[test] - fn adapter_transport_failure_receipts_preserve_each_target() { - let receipt = adapter_transport_failure_receipt( - "event-1".to_owned(), - vec![ - "wss://relay-a.example".to_owned(), - "wss://relay-b.example".to_owned(), - ], - 2, - "offline".to_owned(), - ); - - assert_eq!(receipt.event_id, "event-1"); - assert_eq!(receipt.attempted_count, 2); - assert_eq!(receipt.retryable_count, 2); - assert_eq!(receipt.terminal_count, 0); - assert_eq!(receipt.quorum, 2); - assert!(!receipt.quorum_met); - assert!(receipt.relays.iter().all(|relay| relay.attempted)); - } - - #[test] - fn transport_outcomes_preserve_relay_semantics() { - let cases = [ - ( - RadrootsTransportOutcomeKind::Accepted, - RadrootsRelayOutcomeKind::Accepted, - ), - ( - RadrootsTransportOutcomeKind::DuplicateAccepted, - RadrootsRelayOutcomeKind::DuplicateAccepted, - ), - ( - RadrootsTransportOutcomeKind::Delivered, - RadrootsRelayOutcomeKind::Accepted, - ), - ( - RadrootsTransportOutcomeKind::Forwarded, - RadrootsRelayOutcomeKind::Accepted, - ), - ( - RadrootsTransportOutcomeKind::StoredByGateway, - RadrootsRelayOutcomeKind::Accepted, - ), - ( - RadrootsTransportOutcomeKind::Seen, - RadrootsRelayOutcomeKind::Accepted, - ), - ( - RadrootsTransportOutcomeKind::DeferredUntilImplemented, - RadrootsRelayOutcomeKind::Unsupported, - ), - ( - RadrootsTransportOutcomeKind::Rejected, - RadrootsRelayOutcomeKind::Invalid, - ), - ( - RadrootsTransportOutcomeKind::RouteUnavailable, - RadrootsRelayOutcomeKind::RelayUrlRejected, - ), - ( - RadrootsTransportOutcomeKind::PayloadTooLarge, - RadrootsRelayOutcomeKind::Invalid, - ), - ( - RadrootsTransportOutcomeKind::PolicyDenied, - RadrootsRelayOutcomeKind::Restricted, - ), - ( - RadrootsTransportOutcomeKind::Timeout, - RadrootsRelayOutcomeKind::Timeout, - ), - ( - RadrootsTransportOutcomeKind::ConnectionFailed, - RadrootsRelayOutcomeKind::ConnectionFailed, - ), - ( - RadrootsTransportOutcomeKind::TransportUnavailable, - RadrootsRelayOutcomeKind::Error, - ), - ]; - for (transport_kind, relay_kind) in cases { - assert_eq!( - relay_outcome_kind_from_transport_outcome(transport_kind), - relay_kind - ); - let outcome = RadrootsTransportOutcome::new(transport_kind) - .with_message(format!("{transport_kind:?}")); - let relay_outcome = relay_outcome_from_transport_outcome(&outcome); - assert_eq!(relay_outcome.kind, relay_kind); - assert_eq!(relay_outcome.message, outcome.message); - } - - let code_cases = [ - ("accepted", RadrootsRelayOutcomeKind::Accepted), - ( - "duplicate_accepted", - RadrootsRelayOutcomeKind::DuplicateAccepted, - ), - ("blocked", RadrootsRelayOutcomeKind::Blocked), - ("rate_limited", RadrootsRelayOutcomeKind::RateLimited), - ("invalid", RadrootsRelayOutcomeKind::Invalid), - ("pow_required", RadrootsRelayOutcomeKind::PowRequired), - ("restricted", RadrootsRelayOutcomeKind::Restricted), - ("auth_required", RadrootsRelayOutcomeKind::AuthRequired), - ("muted", RadrootsRelayOutcomeKind::Muted), - ("unsupported", RadrootsRelayOutcomeKind::Unsupported), - ( - "payment_required", - RadrootsRelayOutcomeKind::PaymentRequired, - ), - ("error", RadrootsRelayOutcomeKind::Error), - ("timeout", RadrootsRelayOutcomeKind::Timeout), - ( - "connection_failed", - RadrootsRelayOutcomeKind::ConnectionFailed, - ), - ( - "relay_url_rejected", - RadrootsRelayOutcomeKind::RelayUrlRejected, - ), - ( - "skipped_already_accepted", - RadrootsRelayOutcomeKind::SkippedAlreadyAccepted, - ), - ("unknown", RadrootsRelayOutcomeKind::Unknown), - ]; - for (code, relay_kind) in code_cases { - assert_eq!(relay_outcome_kind_from_code(code), Some(relay_kind)); - assert_eq!( - relay_outcome_from_transport_outcome( - &RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Rejected) - .with_code(code) - ) - .kind, - relay_kind - ); - } - assert_eq!(relay_outcome_kind_from_code("unrecognized"), None); - assert_eq!( - relay_outcome_from_transport_outcome( - &RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Seen) - .with_code("unrecognized") - ) - .kind, - RadrootsRelayOutcomeKind::Accepted - ); - - for kind in [ - RadrootsRelayOutcomeKind::RateLimited, - RadrootsRelayOutcomeKind::Error, - RadrootsRelayOutcomeKind::Unknown, - ] { - assert_eq!( - kind.transport_outcome_kind(), - RadrootsTransportOutcomeKind::TransportUnavailable - ); - } - } - - #[test] - fn transport_target_and_error_adapters_preserve_contract_categories() { - let target = Target::new(TransportId::NOSTR, "wss://relay.example").expect("target"); - let publishable = PublishableRelays { - active_delivery_plan_id: 7, - relays: vec![PublishableRelay { - delivery_target_id: 11, - relay_url: target.uri().as_str().to_owned(), - endpoint_fingerprint: target.fingerprint().clone(), - target_scope: Some("foodshed.west".to_owned()), - target_label: Some("primary relay".to_owned()), - }], - accepted_count: 0, - satisfied_count: 0, - satisfaction_required_count: 1, - remaining_satisfaction_count: 1, - satisfaction_class: RadrootsTransportSatisfactionClass::Accepted, - required_targets: None, - remaining_required_targets: None, - }; - let targets = publishable_transport_targets(&publishable).expect("transport targets"); - assert_eq!(targets.len(), 1); - assert_eq!(targets[0].scope().expect("scope").as_str(), "foodshed.west"); - assert_eq!(targets[0].label().expect("label").as_str(), "primary relay"); - assert_eq!( - transport_satisfaction_policy_for_publishable(&publishable), - RadrootsTransportSatisfactionPolicy::all_accepted() - ); - - let mut invalid = publishable; - invalid.relays[0].target_scope = Some("bad scope".to_owned()); - assert!(matches!( - publishable_transport_targets(&invalid), - Err(RadrootsRelayTransportError::Transport(_)) - )); - invalid.relays[0].target_scope = Some("foodshed.west".to_owned()); - invalid.relays[0].target_label = Some("bad\0label".to_owned()); - assert!(matches!( - publishable_transport_targets(&invalid), - Err(RadrootsRelayTransportError::Transport(_)) - )); - invalid.relays[0].target_label = Some("primary relay".to_owned()); - invalid.relays[0].relay_url = "not-a-relay".to_owned(); - assert!(matches!( - publishable_transport_targets(&invalid), - Err(RadrootsRelayTransportError::TransportContract(_)) - )); - - let generic_errors = [ - RadrootsTransportError::UnsupportedOperation, - RadrootsTransportError::EmptyTransportKind, - RadrootsTransportError::InvalidTransportKind, - RadrootsTransportError::EmptyTargetScope, - RadrootsTransportError::InvalidTargetScope, - RadrootsTransportError::EmptyTargetLabel, - RadrootsTransportError::InvalidTargetLabel, - RadrootsTransportError::InvalidSatisfactionPolicy, - RadrootsTransportError::EmptyRequiredTargetSet, - RadrootsTransportError::DuplicateRequiredTargetFingerprint, - ]; - for error in generic_errors { - assert!(matches!( - transport_error_to_relay_error(error), - RadrootsRelayTransportError::Transport(_) - )); - } - let target_errors = [ - RadrootsTransportError::EmptyTargetUri, - RadrootsTransportError::InvalidTargetUri, - RadrootsTransportError::EmptyTargetSet, - RadrootsTransportError::DuplicateTargetFingerprint, - RadrootsTransportError::InvalidTargetFingerprint, - ]; - for error in target_errors { - assert!(matches!( - transport_error_to_relay_error(error), - RadrootsRelayTransportError::TransportContract(_) - )); - } - let payload_errors = [ - RadrootsTransportError::EmptyPayloadId, - RadrootsTransportError::InvalidPayloadId, - RadrootsTransportError::EmptyPayloadLabel, - RadrootsTransportError::InvalidPayloadLabel, - RadrootsTransportError::EmptyPayloadBytes, - RadrootsTransportError::InvalidPayloadBytes, - RadrootsTransportError::InvalidPayloadDigest, - RadrootsTransportError::PayloadDigestMismatch, - ]; - for error in payload_errors { - assert!(matches!( - transport_error_to_relay_error(error), - RadrootsRelayTransportError::NostrEventJson(_) - )); - } - - let unknown = - Target::new(TransportId::NOSTR, "wss://unknown.example").expect("unknown target"); - let delivery = RadrootsTransportDeliveryReceipt::new( - "unknown", - TargetSet::new(vec![unknown.clone()]).expect("target set"), - vec![RadrootsTransportTargetReceipt::new( - unknown, - RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Accepted), - )], - ) - .expect("delivery receipt"); - assert!(target_receipts_from_transport_receipts(&invalid, &delivery).is_empty()); - assert_eq!( - relay_receipts_from_transport_receipts(&delivery) - .expect("relay receipts") - .len(), - 1 - ); - - let west = Target::new_with_metadata( - TransportId::NOSTR, - "wss://scoped.example", - Some(TargetScope::parse("foodshed.west").expect("west scope")), - None, - ) - .expect("west target"); - let east = Target::new_with_metadata( - TransportId::NOSTR, - "wss://scoped.example", - Some(TargetScope::parse("foodshed.east").expect("east scope")), - None, - ) - .expect("east target"); - let scoped_relay_uri = west.uri().as_str().to_owned(); - let conflicting = RadrootsTransportDeliveryReceipt::new( - "conflicting", - TargetSet::new(vec![west.clone(), east.clone()]).expect("scoped target set"), - vec![ - RadrootsTransportTargetReceipt::new( - west, - RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Accepted), - ), - RadrootsTransportTargetReceipt::new( - east, - RadrootsTransportOutcome::new(RadrootsTransportOutcomeKind::Timeout), - ), - ], - ) - .expect("conflicting delivery receipt"); - assert!(matches!( - relay_receipts_from_transport_receipts(&conflicting), - Err(RadrootsRelayTransportError::ConflictingTransportReceiptRelayUrl { url }) - if url == scoped_relay_uri - )); - } -} diff --git a/crates/transport_nostr/src/outcome.rs b/crates/transport_nostr/src/outcome.rs @@ -1,248 +0,0 @@ -#![forbid(unsafe_code)] - -use radroots_transport::outcome::DeliveryOutcome; -use radroots_transport::{RadrootsTransportOutcome, RadrootsTransportOutcomeKind}; -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub enum RadrootsRelayOutcomeKind { - Accepted, - DuplicateAccepted, - Blocked, - RateLimited, - Invalid, - PowRequired, - Restricted, - AuthRequired, - Muted, - Unsupported, - PaymentRequired, - Error, - Timeout, - ConnectionFailed, - RelayUrlRejected, - SkippedAlreadyAccepted, - Unknown, -} - -impl RadrootsRelayOutcomeKind { - pub fn as_str(self) -> &'static str { - match self { - Self::Accepted => "accepted", - Self::DuplicateAccepted => "duplicate_accepted", - Self::Blocked => "blocked", - Self::RateLimited => "rate_limited", - Self::Invalid => "invalid", - Self::PowRequired => "pow_required", - Self::Restricted => "restricted", - Self::AuthRequired => "auth_required", - Self::Muted => "muted", - Self::Unsupported => "unsupported", - Self::PaymentRequired => "payment_required", - Self::Error => "error", - Self::Timeout => "timeout", - Self::ConnectionFailed => "connection_failed", - Self::RelayUrlRejected => "relay_url_rejected", - Self::SkippedAlreadyAccepted => "skipped_already_accepted", - Self::Unknown => "unknown", - } - } - - pub fn counts_toward_quorum(self) -> bool { - matches!( - self, - Self::Accepted | Self::DuplicateAccepted | Self::SkippedAlreadyAccepted - ) - } - - pub fn is_retryable(self) -> bool { - matches!( - self, - Self::RateLimited - | Self::PowRequired - | Self::AuthRequired - | Self::Error - | Self::Timeout - | Self::ConnectionFailed - | Self::Unknown - ) - } - - pub fn is_terminal_failure(self) -> bool { - matches!( - self, - Self::Blocked - | Self::Invalid - | Self::Restricted - | Self::Muted - | Self::Unsupported - | Self::PaymentRequired - | Self::RelayUrlRejected - ) - } - - pub fn transport_outcome_kind(self) -> RadrootsTransportOutcomeKind { - match self { - Self::Accepted => RadrootsTransportOutcomeKind::Accepted, - Self::DuplicateAccepted | Self::SkippedAlreadyAccepted => { - RadrootsTransportOutcomeKind::DuplicateAccepted - } - Self::Blocked | Self::Invalid | Self::Restricted | Self::Muted | Self::Unsupported => { - RadrootsTransportOutcomeKind::Rejected - } - Self::RelayUrlRejected => RadrootsTransportOutcomeKind::RouteUnavailable, - Self::PaymentRequired | Self::PowRequired | Self::AuthRequired => { - RadrootsTransportOutcomeKind::PolicyDenied - } - Self::RateLimited | Self::Error | Self::Unknown => { - RadrootsTransportOutcomeKind::TransportUnavailable - } - Self::Timeout => RadrootsTransportOutcomeKind::Timeout, - Self::ConnectionFailed => RadrootsTransportOutcomeKind::ConnectionFailed, - } - } -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct RadrootsRelayOutcome { - pub kind: RadrootsRelayOutcomeKind, - pub message: Option<String>, -} - -impl RadrootsRelayOutcome { - pub fn accepted() -> Self { - Self { - kind: RadrootsRelayOutcomeKind::Accepted, - message: None, - } - } - - pub fn duplicate_accepted(message: impl Into<String>) -> Self { - Self { - kind: RadrootsRelayOutcomeKind::DuplicateAccepted, - message: Some(message.into()), - } - } - - pub fn connection_failed(message: impl Into<String>) -> Self { - Self { - kind: RadrootsRelayOutcomeKind::ConnectionFailed, - message: Some(message.into()), - } - } - - pub fn unknown(message: impl Into<String>) -> Self { - Self { - kind: RadrootsRelayOutcomeKind::Unknown, - message: Some(message.into()), - } - } - - pub fn timeout(message: impl Into<String>) -> Self { - Self { - kind: RadrootsRelayOutcomeKind::Timeout, - message: Some(message.into()), - } - } - - pub fn relay_url_rejected(message: impl Into<String>) -> Self { - Self { - kind: RadrootsRelayOutcomeKind::RelayUrlRejected, - message: Some(message.into()), - } - } - - pub fn skipped_already_accepted(message: impl Into<String>) -> Self { - Self { - kind: RadrootsRelayOutcomeKind::SkippedAlreadyAccepted, - message: Some(message.into()), - } - } - - pub fn classify(message: impl AsRef<str>) -> Self { - let message = message.as_ref().trim(); - let lower = message.to_ascii_lowercase(); - let kind = if lower.starts_with("duplicate:") { - RadrootsRelayOutcomeKind::DuplicateAccepted - } else if lower.starts_with("blocked:") { - RadrootsRelayOutcomeKind::Blocked - } else if lower.starts_with("rate-limited:") { - RadrootsRelayOutcomeKind::RateLimited - } else if lower.starts_with("invalid:") { - RadrootsRelayOutcomeKind::Invalid - } else if lower.starts_with("pow:") { - RadrootsRelayOutcomeKind::PowRequired - } else if lower.starts_with("restricted:") { - RadrootsRelayOutcomeKind::Restricted - } else if lower.starts_with("auth-required:") { - RadrootsRelayOutcomeKind::AuthRequired - } else if lower.starts_with("mute:") { - RadrootsRelayOutcomeKind::Muted - } else if lower.starts_with("unsupported:") { - RadrootsRelayOutcomeKind::Unsupported - } else if lower.starts_with("payment-required:") { - RadrootsRelayOutcomeKind::PaymentRequired - } else if lower.starts_with("error:") { - RadrootsRelayOutcomeKind::Error - } else if lower.starts_with("timeout:") { - RadrootsRelayOutcomeKind::Timeout - } else { - RadrootsRelayOutcomeKind::Unknown - }; - Self { - kind, - message: Some(message.to_owned()), - } - } - - pub fn counts_toward_quorum(&self) -> bool { - self.kind.counts_toward_quorum() - } - - pub fn is_retryable(&self) -> bool { - self.kind.is_retryable() - } - - pub fn is_terminal_failure(&self) -> bool { - self.kind.is_terminal_failure() - } - - pub fn to_transport_outcome(&self) -> RadrootsTransportOutcome { - let mut outcome = RadrootsTransportOutcome::new(self.kind.transport_outcome_kind()) - .with_code(self.kind.as_str()); - if let Some(message) = &self.message { - outcome = outcome.with_message(message.clone()); - } - outcome - } - - pub(crate) fn to_delivery_outcome(&self) -> DeliveryOutcome { - let outcome = match self.kind { - RadrootsRelayOutcomeKind::Accepted - | RadrootsRelayOutcomeKind::DuplicateAccepted - | RadrootsRelayOutcomeKind::SkippedAlreadyAccepted => DeliveryOutcome::accepted(), - RadrootsRelayOutcomeKind::Blocked - | RadrootsRelayOutcomeKind::Invalid - | RadrootsRelayOutcomeKind::Restricted - | RadrootsRelayOutcomeKind::Muted - | RadrootsRelayOutcomeKind::Unsupported - | RadrootsRelayOutcomeKind::PaymentRequired - | RadrootsRelayOutcomeKind::RelayUrlRejected => DeliveryOutcome::rejected(), - RadrootsRelayOutcomeKind::RateLimited - | RadrootsRelayOutcomeKind::PowRequired - | RadrootsRelayOutcomeKind::AuthRequired - | RadrootsRelayOutcomeKind::Error - | RadrootsRelayOutcomeKind::Timeout - | RadrootsRelayOutcomeKind::ConnectionFailed - | RadrootsRelayOutcomeKind::Unknown => DeliveryOutcome::unavailable(), - }; - if let Some(message) = &self.message { - outcome - .clone() - .with_detail(self.kind.as_str(), message.clone()) - .unwrap_or(outcome) - } else { - outcome - } - } -} diff --git a/crates/transport_nostr/src/publish.rs b/crates/transport_nostr/src/publish.rs @@ -1,1120 +0,0 @@ -#![forbid(unsafe_code)] - -use crate::error::ensure_nonnegative_timestamp; -use crate::{RadrootsRelayOutcome, RadrootsRelayTargetSet, RadrootsRelayTransportError}; -#[cfg(feature = "client")] -use core::time::Duration; -use futures::future::BoxFuture; -use radroots_event::draft::SignedEvent; -use radroots_transport::capability::{Availability, Maturity, SinkCapabilities}; -use radroots_transport::outcome::DeliveryOutcome; -use radroots_transport::sink::{ - DeliveryReceipt, DeliveryRequest, DeliveryTargetReceipt, EventSink, SinkStatus, -}; -use radroots_transport::{ - RadrootsTransportError, RadrootsTransportPayload, RadrootsTransportSatisfactionPolicy, Target, - TransportId, -}; -use serde::{Deserialize, Serialize}; -use std::collections::{BTreeMap, BTreeSet}; -use std::sync::{Arc, Mutex, PoisonError}; - -#[cfg(feature = "client")] -use crate::RadrootsNostrClient; -use crate::RadrootsRelayOutcomeKind; -#[cfg(feature = "client")] -use nostr::JsonUtil; -#[cfg(feature = "client")] -use radroots_nostr::event::Event as RadrootsNostrEvent; - -#[cfg(feature = "client")] -const RELAY_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); -pub const RADROOTS_RELAY_PUBLISH_IDEMPOTENCY_KEY_MAX_BYTES: usize = 256; - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsRelayPublishRequest { - signed_event: SignedEvent, - targets: RadrootsRelayTargetSet, - satisfaction_policy: RadrootsTransportSatisfactionPolicy, - idempotency_key: Option<String>, - now_ms: i64, -} - -impl RadrootsRelayPublishRequest { - pub fn new( - signed_event: SignedEvent, - targets: RadrootsRelayTargetSet, - now_ms: i64, - ) -> Result<Self, RadrootsRelayTransportError> { - ensure_nonnegative_timestamp("now_ms", now_ms)?; - Ok(Self { - signed_event, - targets, - satisfaction_policy: RadrootsTransportSatisfactionPolicy::all_accepted(), - idempotency_key: None, - now_ms, - }) - } - - pub fn with_satisfaction_policy( - mut self, - satisfaction_policy: RadrootsTransportSatisfactionPolicy, - ) -> Self { - self.satisfaction_policy = satisfaction_policy; - self - } - - pub fn try_with_idempotency_key( - mut self, - idempotency_key: impl Into<String>, - ) -> Result<Self, RadrootsRelayTransportError> { - let idempotency_key = idempotency_key.into(); - validate_publish_idempotency_key(idempotency_key.as_str())?; - self.idempotency_key = Some(idempotency_key); - Ok(self) - } - - pub fn signed_event(&self) -> &SignedEvent { - &self.signed_event - } - - pub fn targets(&self) -> &RadrootsRelayTargetSet { - &self.targets - } - - pub fn satisfaction_policy(&self) -> &RadrootsTransportSatisfactionPolicy { - &self.satisfaction_policy - } - - pub fn idempotency_key(&self) -> Option<&str> { - self.idempotency_key.as_deref() - } - - pub fn now_ms(&self) -> i64 { - self.now_ms - } - - fn validate(&self) -> Result<(), RadrootsRelayTransportError> { - ensure_nonnegative_timestamp("now_ms", self.now_ms)?; - if let Some(idempotency_key) = self.idempotency_key.as_deref() { - validate_publish_idempotency_key(idempotency_key)?; - } - let target_count = self.targets.len(); - self.satisfaction_policy - .required_target_count(target_count)?; - if let Some(required_targets) = self.satisfaction_policy.required_target_fingerprints() { - let requested = self - .targets - .relays() - .iter() - .map(|relay| Target::new(TransportId::NOSTR, relay.as_str())) - .collect::<Result<Vec<_>, _>>()?; - for required in required_targets { - if !requested - .iter() - .any(|target| target.fingerprint() == required) - { - return Err(RadrootsRelayTransportError::RequiredTargetNotRequested { - fingerprint: required.as_str().to_owned(), - }); - } - } - } - Ok(()) - } -} - -fn validate_publish_idempotency_key(value: &str) -> Result<(), RadrootsRelayTransportError> { - let reason = if value.is_empty() { - Some("key must not be empty") - } else if value != value.trim() { - Some("key must not have surrounding whitespace") - } else if value.chars().any(char::is_control) { - Some("key must not contain control characters") - } else if value.len() > RADROOTS_RELAY_PUBLISH_IDEMPOTENCY_KEY_MAX_BYTES { - Some("key exceeds the UTF-8 byte limit") - } else { - None - }; - if let Some(reason) = reason { - return Err(RadrootsRelayTransportError::InvalidIdempotencyKey { - reason, - actual_bytes: value.len(), - max_bytes: RADROOTS_RELAY_PUBLISH_IDEMPOTENCY_KEY_MAX_BYTES, - }); - } - Ok(()) -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct RadrootsRelayPublishRelayReceipt { - pub relay_url: String, - pub outcome: RadrootsRelayOutcome, - pub attempted: bool, -} - -impl RadrootsRelayPublishRelayReceipt { - pub fn attempted(relay_url: impl Into<String>, outcome: RadrootsRelayOutcome) -> Self { - Self { - relay_url: relay_url.into(), - outcome, - attempted: true, - } - } - - pub fn skipped(relay_url: impl Into<String>, outcome: RadrootsRelayOutcome) -> Self { - Self { - relay_url: relay_url.into(), - outcome, - attempted: false, - } - } -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct RadrootsRelayPublishReceipt { - pub event_id: String, - pub attempted_count: usize, - pub accepted_count: usize, - pub retryable_count: usize, - pub terminal_count: usize, - pub quorum: usize, - pub quorum_met: bool, - pub relays: Vec<RadrootsRelayPublishRelayReceipt>, -} - -pub trait RadrootsRelayPublishAdapter: Send + Sync { - fn publish<'a>( - &'a self, - request: RadrootsRelayPublishRequest, - ) -> BoxFuture<'a, Result<Vec<RadrootsRelayPublishRelayReceipt>, RadrootsRelayTransportError>>; -} - -pub fn verified_signed_event_payload( - signed_event: &SignedEvent, -) -> Result<RadrootsTransportPayload, RadrootsTransportError> { - RadrootsTransportPayload::unchecked_signed_event_json( - signed_event.id_str(), - signed_event.raw_json(), - ) -} - -impl<A> RadrootsRelayPublishAdapter for &A -where - A: RadrootsRelayPublishAdapter + ?Sized, -{ - fn publish<'a>( - &'a self, - request: RadrootsRelayPublishRequest, - ) -> BoxFuture<'a, Result<Vec<RadrootsRelayPublishRelayReceipt>, RadrootsRelayTransportError>> - { - (*self).publish(request) - } -} - -#[derive(Clone)] -pub struct RadrootsNostrTransport<A> { - adapter: A, - status: SinkStatus, -} - -impl<A> RadrootsNostrTransport<A> { - pub fn new(adapter: A) -> Self { - Self { - adapter, - status: SinkStatus::new( - TransportId::NOSTR, - true, - Maturity::Stable, - Availability::Available, - SinkCapabilities::DELIVER, - "ready", - ), - } - } - - pub fn with_status(mut self, status: SinkStatus) -> Self { - self.status = status; - self - } - - pub fn adapter(&self) -> &A { - &self.adapter - } -} - -impl<A> EventSink for RadrootsNostrTransport<A> -where - A: RadrootsRelayPublishAdapter, -{ - fn status( - &self, - ) -> radroots_transport::BoxFuture<'_, Result<SinkStatus, RadrootsTransportError>> { - Box::pin(async move { Ok(self.status.clone()) }) - } - - fn deliver( - &self, - request: DeliveryRequest, - ) -> radroots_transport::BoxFuture<'_, Result<DeliveryReceipt, RadrootsTransportError>> { - Box::pin(async move { - let signed_event = request.payload().event().clone(); - let targets = relay_targets_from_transport_targets(request.target_set().targets())?; - let legacy_attempt_time_ms = i64::try_from(request.deadline_unix_ms()) - .map_err(|_| RadrootsTransportError::InvalidDeliveryDeadline)?; - let publish_request = - RadrootsRelayPublishRequest::new(signed_event, targets, legacy_attempt_time_ms) - .map_err(nostr_error_to_transport_error)? - .with_satisfaction_policy(RadrootsTransportSatisfactionPolicy::NoWait) - .try_with_idempotency_key(request.request_id().as_str()) - .map_err(nostr_error_to_transport_error)?; - let relay_receipts = match publish_signed_event(&self.adapter, publish_request).await { - Ok(receipt) => receipt.relays, - Err(RadrootsRelayTransportError::Transport(message)) => { - return DeliveryReceipt::for_request( - &request, - transport_failure_target_receipts( - request.target_set().targets(), - message.as_str(), - )?, - ); - } - Err(error) => return Err(nostr_error_to_transport_error(error)), - }; - DeliveryReceipt::for_request( - &request, - target_receipts_from_relay_receipts( - request.target_set().targets(), - relay_receipts.as_slice(), - )?, - ) - }) - } -} - -fn nostr_error_to_transport_error(error: RadrootsRelayTransportError) -> RadrootsTransportError { - match error { - RadrootsRelayTransportError::TransportContract(_) => { - RadrootsTransportError::InvalidPayloadBytes - } - RadrootsRelayTransportError::ConflictingTransportReceiptRelayUrl { .. } => { - RadrootsTransportError::DuplicateDeliveryTargetReceipt - } - RadrootsRelayTransportError::UnexpectedTransportKind { .. } - | RadrootsRelayTransportError::DuplicateFetchTerminalRelayUrl { .. } - | RadrootsRelayTransportError::ConflictingFetchTerminalRelayUrl { .. } => { - RadrootsTransportError::InvalidTransportKind - } - RadrootsRelayTransportError::RelayUrlParse { .. } - | RadrootsRelayTransportError::WsRequiresLocalhostPolicy { .. } - | RadrootsRelayTransportError::UnsupportedRelayScheme { .. } - | RadrootsRelayTransportError::EmptyRelayHost { .. } - | RadrootsRelayTransportError::RelayUrlUserinfo { .. } - | RadrootsRelayTransportError::RelayUrlQueryOrFragment { .. } - | RadrootsRelayTransportError::RelayUrlForbiddenDestination { .. } - | RadrootsRelayTransportError::RelayUrlResolvedForbiddenDestination { .. } - | RadrootsRelayTransportError::RelayUrlResolvedNoAddresses { .. } - | RadrootsRelayTransportError::EmptyTargetSet - | RadrootsRelayTransportError::DuplicateRelayUrl { .. } - | RadrootsRelayTransportError::InvalidFetchItemRelayUrl { .. } - | RadrootsRelayTransportError::UnexpectedFetchItemRelayUrl { .. } - | RadrootsRelayTransportError::InvalidPublishReceiptRelayUrl { .. } - | RadrootsRelayTransportError::UnexpectedPublishReceiptRelayUrl { .. } - | RadrootsRelayTransportError::DuplicatePublishReceiptRelayUrl { .. } - | RadrootsRelayTransportError::InvalidPublishReceiptAttemptState { .. } => { - RadrootsTransportError::InvalidTargetUri - } - #[cfg(feature = "client")] - RadrootsRelayTransportError::NostrEvent(_) - | RadrootsRelayTransportError::NostrEventJson(_) - | RadrootsRelayTransportError::Json(_) => RadrootsTransportError::InvalidPayloadBytes, - #[cfg(not(feature = "client"))] - RadrootsRelayTransportError::NostrEventJson(_) | RadrootsRelayTransportError::Json(_) => { - RadrootsTransportError::InvalidPayloadBytes - } - #[cfg(feature = "client")] - RadrootsRelayTransportError::Client(_) - | RadrootsRelayTransportError::ClientDatabase(_) - | RadrootsRelayTransportError::ClientConfig(_) - | RadrootsRelayTransportError::EventNotFound(_) => { - RadrootsTransportError::InvalidTransportKind - } - RadrootsRelayTransportError::Transport(_) => RadrootsTransportError::InvalidTransportKind, - RadrootsRelayTransportError::EmptyFetchFilters - | RadrootsRelayTransportError::InvalidFetchLimit { .. } - | RadrootsRelayTransportError::FetchLimitTooLarge { .. } - | RadrootsRelayTransportError::InvalidTimestamp { .. } - | RadrootsRelayTransportError::InvalidIdempotencyKey { .. } - | RadrootsRelayTransportError::RequiredTargetNotRequested { .. } => { - RadrootsTransportError::InvalidTransportKind - } - #[cfg(feature = "storage")] - RadrootsRelayTransportError::EventStore(_) - | RadrootsRelayTransportError::Outbox(_) - | RadrootsRelayTransportError::MissingSignedOutboxEvent(_) - | RadrootsRelayTransportError::MissingPersistedFetchReceiptEventId - | RadrootsRelayTransportError::MissingStoredEventVisibility { .. } - | RadrootsRelayTransportError::UnsupportedStoredEventVisibility { .. } => { - RadrootsTransportError::InvalidTransportKind - } - } -} - -#[cfg(test)] -mod contract_tests { - use super::nostr_error_to_transport_error; - use crate::RadrootsRelayTransportError; - use radroots_transport::RadrootsTransportError; - - #[test] - fn relay_errors_map_to_stable_transport_contract_categories() { - assert_eq!( - nostr_error_to_transport_error(RadrootsRelayTransportError::TransportContract( - "contract".to_owned(), - )), - RadrootsTransportError::InvalidPayloadBytes - ); - assert_eq!( - nostr_error_to_transport_error( - RadrootsRelayTransportError::ConflictingTransportReceiptRelayUrl { - url: "wss://relay.example".to_owned(), - }, - ), - RadrootsTransportError::DuplicateDeliveryTargetReceipt - ); - for error in [ - RadrootsRelayTransportError::UnexpectedTransportKind { - expected: "nostr", - actual: "reticulum".to_owned(), - }, - RadrootsRelayTransportError::DuplicateFetchTerminalRelayUrl { - url: "wss://relay.example".to_owned(), - }, - RadrootsRelayTransportError::ConflictingFetchTerminalRelayUrl { - url: "wss://relay.example".to_owned(), - first: "eose", - next: "closed", - }, - ] { - assert_eq!( - nostr_error_to_transport_error(error), - RadrootsTransportError::InvalidTransportKind - ); - } - - let target_errors = [ - RadrootsRelayTransportError::RelayUrlParse { - url: "bad".to_owned(), - reason: "parse".to_owned(), - }, - RadrootsRelayTransportError::WsRequiresLocalhostPolicy { - url: "ws://relay.example".to_owned(), - }, - RadrootsRelayTransportError::UnsupportedRelayScheme { - url: "https://relay.example".to_owned(), - scheme: "https".to_owned(), - }, - RadrootsRelayTransportError::EmptyRelayHost { - url: "wss://".to_owned(), - }, - RadrootsRelayTransportError::RelayUrlUserinfo { - url: "wss://user@relay.example".to_owned(), - }, - RadrootsRelayTransportError::RelayUrlQueryOrFragment { - url: "wss://relay.example?x=1".to_owned(), - }, - RadrootsRelayTransportError::RelayUrlForbiddenDestination { - url: "wss://127.0.0.1".to_owned(), - reason: "loopback".to_owned(), - }, - RadrootsRelayTransportError::RelayUrlResolvedForbiddenDestination { - url: "wss://relay.example".to_owned(), - address: "127.0.0.1".to_owned(), - reason: "loopback".to_owned(), - }, - RadrootsRelayTransportError::RelayUrlResolvedNoAddresses { - url: "wss://relay.example".to_owned(), - }, - RadrootsRelayTransportError::EmptyTargetSet, - RadrootsRelayTransportError::DuplicateRelayUrl { - url: "wss://relay.example".to_owned(), - }, - RadrootsRelayTransportError::InvalidFetchItemRelayUrl { - url: "bad".to_owned(), - reason: "parse".to_owned(), - }, - RadrootsRelayTransportError::UnexpectedFetchItemRelayUrl { - url: "wss://other.example".to_owned(), - }, - RadrootsRelayTransportError::InvalidPublishReceiptRelayUrl { - url: "bad".to_owned(), - reason: "parse".to_owned(), - }, - RadrootsRelayTransportError::UnexpectedPublishReceiptRelayUrl { - url: "wss://other.example".to_owned(), - }, - RadrootsRelayTransportError::DuplicatePublishReceiptRelayUrl { - url: "wss://relay.example".to_owned(), - }, - ]; - for error in target_errors { - assert_eq!( - nostr_error_to_transport_error(error), - RadrootsTransportError::InvalidTargetUri - ); - } - - assert_eq!( - nostr_error_to_transport_error(RadrootsRelayTransportError::NostrEventJson( - "event".to_owned(), - )), - RadrootsTransportError::InvalidPayloadBytes - ); - let json_error = serde_json::from_str::<serde_json::Value>("{").expect_err("invalid json"); - assert_eq!( - nostr_error_to_transport_error(RadrootsRelayTransportError::Json(json_error)), - RadrootsTransportError::InvalidPayloadBytes - ); - assert_eq!( - nostr_error_to_transport_error(RadrootsRelayTransportError::Transport( - "offline".to_owned(), - )), - RadrootsTransportError::InvalidTransportKind - ); - assert_eq!( - nostr_error_to_transport_error(RadrootsRelayTransportError::EmptyFetchFilters), - RadrootsTransportError::InvalidTransportKind - ); - assert_eq!( - nostr_error_to_transport_error(RadrootsRelayTransportError::InvalidFetchLimit { - field: "max_events", - }), - RadrootsTransportError::InvalidTransportKind - ); - assert_eq!( - nostr_error_to_transport_error(RadrootsRelayTransportError::FetchLimitTooLarge { - field: "max_events", - max: 1_000, - actual: 1_001, - }), - RadrootsTransportError::InvalidTransportKind - ); - assert_eq!( - nostr_error_to_transport_error(RadrootsRelayTransportError::InvalidTimestamp { - field: "now_ms", - value: -1, - }), - RadrootsTransportError::InvalidTransportKind - ); - assert_eq!( - nostr_error_to_transport_error(RadrootsRelayTransportError::InvalidIdempotencyKey { - reason: "empty", - actual_bytes: 0, - max_bytes: 256, - }), - RadrootsTransportError::InvalidTransportKind - ); - assert_eq!( - nostr_error_to_transport_error( - RadrootsRelayTransportError::RequiredTargetNotRequested { - fingerprint: "sha256:missing".to_owned(), - }, - ), - RadrootsTransportError::InvalidTransportKind - ); - - #[cfg(feature = "storage")] - { - assert_eq!( - nostr_error_to_transport_error(RadrootsRelayTransportError::EventStore( - radroots_event_store::RadrootsEventStoreError::MissingEvent( - "missing".to_owned(), - ), - )), - RadrootsTransportError::InvalidTransportKind - ); - assert_eq!( - nostr_error_to_transport_error(RadrootsRelayTransportError::Outbox( - radroots_outbox::RadrootsOutboxError::EventNotFound(1), - )), - RadrootsTransportError::InvalidTransportKind - ); - assert_eq!( - nostr_error_to_transport_error( - RadrootsRelayTransportError::MissingSignedOutboxEvent(1), - ), - RadrootsTransportError::InvalidTransportKind - ); - assert_eq!( - nostr_error_to_transport_error( - RadrootsRelayTransportError::MissingPersistedFetchReceiptEventId, - ), - RadrootsTransportError::InvalidTransportKind - ); - assert_eq!( - nostr_error_to_transport_error( - RadrootsRelayTransportError::MissingStoredEventVisibility { - event_id: "missing".to_owned(), - }, - ), - RadrootsTransportError::InvalidTransportKind - ); - assert_eq!( - nostr_error_to_transport_error( - RadrootsRelayTransportError::UnsupportedStoredEventVisibility { - event_id: "unsupported".to_owned(), - }, - ), - RadrootsTransportError::InvalidTransportKind - ); - } - } -} - -fn relay_targets_from_transport_targets( - targets: &[Target], -) -> Result<RadrootsRelayTargetSet, RadrootsTransportError> { - let mut relays = Vec::new(); - for target in targets { - if target.kind() != &TransportId::NOSTR { - return Err(RadrootsTransportError::InvalidTargetUri); - } - let policy = if target.uri().as_str().starts_with("ws://") { - crate::RadrootsRelayUrlPolicy::Localhost - } else { - crate::RadrootsRelayUrlPolicy::Public - }; - let relay = crate::RelayUrl::parse(target.uri().as_str(), policy) - .map_err(nostr_error_to_transport_error)?; - if !relays.contains(&relay) { - relays.push(relay); - } - } - RadrootsRelayTargetSet::from_urls(relays).map_err(nostr_error_to_transport_error) -} - -fn target_receipts_from_relay_receipts( - targets: &[Target], - relay_receipts: &[RadrootsRelayPublishRelayReceipt], -) -> Result<Vec<DeliveryTargetReceipt>, RadrootsTransportError> { - targets - .iter() - .cloned() - .map(|target| { - let relay_receipt = relay_receipts - .iter() - .find(|receipt| relay_receipt_matches_target(receipt, &target)); - match relay_receipt { - Some(receipt) if receipt.attempted => Ok(DeliveryTargetReceipt::attempted( - target, - receipt.outcome.to_delivery_outcome(), - )), - Some(receipt) => { - DeliveryTargetReceipt::skipped(target, receipt.outcome.to_delivery_outcome()) - } - None => DeliveryTargetReceipt::skipped( - target, - DeliveryOutcome::unavailable() - .with_detail("missing_receipt", "relay adapter omitted target receipt")?, - ), - } - }) - .collect() -} - -fn transport_failure_target_receipts( - targets: &[Target], - message: &str, -) -> Result<Vec<DeliveryTargetReceipt>, RadrootsTransportError> { - targets - .iter() - .cloned() - .map(|target| { - Ok(DeliveryTargetReceipt::attempted( - target, - DeliveryOutcome::unavailable() - .with_detail("connection_failed", message.to_owned()) - .unwrap_or_else(|_| DeliveryOutcome::unavailable()), - )) - }) - .collect() -} - -pub async fn publish_signed_event<A>( - adapter: &A, - request: RadrootsRelayPublishRequest, -) -> Result<RadrootsRelayPublishReceipt, RadrootsRelayTransportError> -where - A: RadrootsRelayPublishAdapter, -{ - request.validate()?; - let event_id = request.signed_event.id_str().to_owned(); - let satisfaction_policy = request.satisfaction_policy.clone(); - let requested_relays = request.targets.relay_strings(); - let target_count = request.targets.len(); - let quorum = satisfaction_policy.required_target_count(target_count)?; - let relays = - normalize_publish_receipts(requested_relays.as_slice(), adapter.publish(request).await?)?; - let attempted_count = relays.iter().filter(|receipt| receipt.attempted).count(); - let accepted_count = relays - .iter() - .filter(|receipt| relay_receipt_counts_toward_quorum(receipt)) - .count(); - let retryable_count = relays - .iter() - .filter(|receipt| receipt.outcome.is_retryable()) - .count(); - let terminal_count = relays - .iter() - .filter(|receipt| receipt.outcome.is_terminal_failure()) - .count(); - let quorum_met = relay_publish_satisfies_policy(&satisfaction_policy, target_count, &relays)?; - Ok(RadrootsRelayPublishReceipt { - event_id, - attempted_count, - accepted_count, - retryable_count, - terminal_count, - quorum, - quorum_met, - relays, - }) -} - -fn normalize_publish_receipts( - requested_relays: &[String], - receipts: Vec<RadrootsRelayPublishRelayReceipt>, -) -> Result<Vec<RadrootsRelayPublishRelayReceipt>, RadrootsRelayTransportError> { - let requested = requested_relays.iter().cloned().collect::<BTreeSet<_>>(); - let mut by_relay = BTreeMap::new(); - for mut receipt in receipts { - let target = - Target::new(TransportId::NOSTR, receipt.relay_url.as_str()).map_err(|error| { - RadrootsRelayTransportError::InvalidPublishReceiptRelayUrl { - url: receipt.relay_url.clone(), - reason: error.to_string(), - } - })?; - let canonical = target.uri().as_str().to_owned(); - if !requested.contains(&canonical) { - return Err( - RadrootsRelayTransportError::UnexpectedPublishReceiptRelayUrl { url: canonical }, - ); - } - receipt.relay_url.clone_from(&canonical); - if (!receipt.attempted - && matches!( - receipt.outcome.kind, - RadrootsRelayOutcomeKind::Accepted | RadrootsRelayOutcomeKind::DuplicateAccepted - )) - || (receipt.attempted - && receipt.outcome.kind == RadrootsRelayOutcomeKind::SkippedAlreadyAccepted) - { - return Err( - RadrootsRelayTransportError::InvalidPublishReceiptAttemptState { url: canonical }, - ); - } - if by_relay.insert(canonical.clone(), receipt).is_some() { - return Err( - RadrootsRelayTransportError::DuplicatePublishReceiptRelayUrl { url: canonical }, - ); - } - } - Ok(requested_relays - .iter() - .map(|relay_url| { - by_relay.remove(relay_url).unwrap_or_else(|| { - RadrootsRelayPublishRelayReceipt::skipped( - relay_url, - RadrootsRelayOutcome::unknown("relay adapter omitted target receipt"), - ) - }) - }) - .collect()) -} - -fn relay_receipt_counts_toward_quorum(receipt: &RadrootsRelayPublishRelayReceipt) -> bool { - receipt.outcome.counts_toward_quorum() - && (receipt.attempted - || receipt.outcome.kind == RadrootsRelayOutcomeKind::SkippedAlreadyAccepted) -} - -fn relay_publish_satisfies_policy( - policy: &RadrootsTransportSatisfactionPolicy, - target_count: usize, - relays: &[RadrootsRelayPublishRelayReceipt], -) -> Result<bool, RadrootsRelayTransportError> { - match policy { - RadrootsTransportSatisfactionPolicy::NoWait => Ok(true), - RadrootsTransportSatisfactionPolicy::Any { class } - | RadrootsTransportSatisfactionPolicy::All { class } - | RadrootsTransportSatisfactionPolicy::Quorum { class, .. } => { - let satisfied_count = relays - .iter() - .filter(|receipt| { - relay_receipt_counts_toward_quorum(receipt) - && receipt - .outcome - .to_transport_outcome() - .status - .counts_as_satisfied(*class) - }) - .count(); - Ok(policy.is_satisfied_by(target_count, satisfied_count)?) - } - RadrootsTransportSatisfactionPolicy::RequiredTargets { class, targets } => { - policy.required_target_count(target_count)?; - let mut satisfied_required_targets = BTreeSet::new(); - for receipt in relays { - let target = Target::new(TransportId::NOSTR, &receipt.relay_url)?; - if targets.contains(target.fingerprint()) - && relay_receipt_counts_toward_quorum(receipt) - && receipt - .outcome - .to_transport_outcome() - .status - .counts_as_satisfied(*class) - { - satisfied_required_targets.insert(target.fingerprint().clone()); - } - } - Ok(targets - .iter() - .all(|target| satisfied_required_targets.contains(target))) - } - } -} - -fn relay_receipt_matches_target( - receipt: &RadrootsRelayPublishRelayReceipt, - target: &Target, -) -> bool { - Target::new(TransportId::NOSTR, receipt.relay_url.as_str()) - .is_ok_and(|receipt_target| receipt_target.uri() == target.uri()) -} - -#[derive(Clone, Default)] -pub struct RadrootsMockRelayPublishAdapter { - outcomes: BTreeMap<String, RadrootsRelayOutcome>, - captured_raw_events: Arc<Mutex<Vec<String>>>, -} - -impl RadrootsMockRelayPublishAdapter { - pub fn new() -> Self { - Self::default() - } - - pub fn with_outcome( - mut self, - relay_url: impl Into<String>, - outcome: RadrootsRelayOutcome, - ) -> Self { - self.outcomes.insert(relay_url.into(), outcome); - self - } - - pub fn captured_raw_events(&self) -> Vec<String> { - self.captured_raw_events - .lock() - .expect("captured raw event lock") - .clone() - } -} - -impl RadrootsRelayPublishAdapter for RadrootsMockRelayPublishAdapter { - fn publish<'a>( - &'a self, - request: RadrootsRelayPublishRequest, - ) -> BoxFuture<'a, Result<Vec<RadrootsRelayPublishRelayReceipt>, RadrootsRelayTransportError>> - { - Box::pin(async move { - self.captured_raw_events - .lock() - .map_err(captured_raw_event_lock_error)? - .push(request.signed_event.raw_json().to_owned()); - Ok(request - .targets - .relays() - .iter() - .map(|relay| { - let outcome = self - .outcomes - .get(relay.as_str()) - .cloned() - .unwrap_or_else(RadrootsRelayOutcome::accepted); - RadrootsRelayPublishRelayReceipt::attempted(relay.as_str(), outcome) - }) - .collect()) - }) - } -} - -#[cfg_attr(coverage_nightly, coverage(off))] -fn captured_raw_event_lock_error<T>(_error: PoisonError<T>) -> RadrootsRelayTransportError { - RadrootsRelayTransportError::Transport("captured raw event lock poisoned".to_owned()) -} - -#[cfg(feature = "client")] -#[derive(Clone)] -pub struct RadrootsNostrClientPublishAdapter { - client: RadrootsNostrClient, -} - -#[cfg(feature = "client")] -impl RadrootsNostrClientPublishAdapter { - #[cfg_attr(coverage_nightly, coverage(off))] - pub fn new(client: RadrootsNostrClient) -> Self { - Self { client } - } -} - -#[cfg(feature = "client")] -impl RadrootsRelayPublishAdapter for RadrootsNostrClientPublishAdapter { - #[cfg_attr(coverage_nightly, coverage(off))] - fn publish<'a>( - &'a self, - request: RadrootsRelayPublishRequest, - ) -> BoxFuture<'a, Result<Vec<RadrootsRelayPublishRelayReceipt>, RadrootsRelayTransportError>> - { - Box::pin(async move { - let event = RadrootsNostrEvent::from_json(request.signed_event.raw_json()) - .map_err(|error| RadrootsRelayTransportError::NostrEventJson(error.to_string()))?; - ensure_raw_event_matches_signed_event(&event, &request.signed_event)?; - let target_strings = request.targets.relay_strings(); - for relay_url in &target_strings { - self.client - .add_write_relay(relay_url.as_str()) - .await - .map_err(|error| RadrootsRelayTransportError::Transport(error.to_string()))?; - } - let connection_output = self.client.try_connect(RELAY_CONNECT_TIMEOUT).await; - let target_url_set = target_strings - .iter() - .map(|relay_url| relay_url.trim_end_matches('/').to_owned()) - .collect::<BTreeSet<_>>(); - let connected_strings = self - .client - .relays() - .await - .into_values() - .filter(|relay| relay.is_connected()) - .map(|relay| relay.url().to_string()) - .filter(|relay_url| target_url_set.contains(relay_url.trim_end_matches('/'))) - .collect::<Vec<_>>(); - let connection_failures = connection_output - .failed - .iter() - .map(|(relay, reason)| { - ( - relay.to_string().trim_end_matches('/').to_owned(), - reason.clone(), - ) - }) - .collect::<BTreeMap<_, _>>(); - if connected_strings.is_empty() { - return Ok(target_strings - .into_iter() - .map(|relay_url| { - let target_url = relay_url.trim_end_matches('/'); - let reason = connection_failures - .get(target_url) - .cloned() - .unwrap_or_else(|| "relay did not connect".to_owned()); - RadrootsRelayPublishRelayReceipt::attempted( - relay_url, - RadrootsRelayOutcome::connection_failed(reason), - ) - }) - .collect()); - } - let output = match self.client.send_event_to(connected_strings, &event).await { - Ok(output) => output, - Err(error) => { - let message = error.to_string(); - return Ok(target_strings - .into_iter() - .map(|relay_url| { - RadrootsRelayPublishRelayReceipt::attempted( - relay_url, - RadrootsRelayOutcome::connection_failed(message.clone()), - ) - }) - .collect()); - } - }; - let mut receipts = Vec::new(); - for relay_url in &target_strings { - let target_url = relay_url.trim_end_matches('/'); - let success = output - .success - .iter() - .any(|success_url| success_url.to_string().trim_end_matches('/') == target_url); - if success { - receipts.push(RadrootsRelayPublishRelayReceipt::attempted( - relay_url, - RadrootsRelayOutcome { - kind: RadrootsRelayOutcomeKind::Accepted, - message: Some( - "nostr-relay-pool-success-ok-message-unavailable".to_owned(), - ), - }, - )); - continue; - } - if let Some(reason) = connection_failures.get(target_url) { - receipts.push(RadrootsRelayPublishRelayReceipt::attempted( - relay_url, - RadrootsRelayOutcome::connection_failed(reason.clone()), - )); - continue; - } - let failed = output.failed.iter().find_map(|(failed_url, message)| { - if failed_url.to_string().trim_end_matches('/') == target_url { - Some(message.clone()) - } else { - None - } - }); - let outcome = failed - .map(RadrootsRelayOutcome::classify) - .unwrap_or_else(|| { - RadrootsRelayOutcome::classify("error: relay output omitted target") - }); - receipts.push(RadrootsRelayPublishRelayReceipt::attempted( - relay_url, outcome, - )); - } - Ok(receipts) - }) - } -} - -#[cfg(feature = "client")] -fn ensure_raw_event_matches_signed_event( - event: &RadrootsNostrEvent, - signed_event: &SignedEvent, -) -> Result<(), RadrootsRelayTransportError> { - let mismatches = [ - ("id", event.id.to_hex(), signed_event.id_str().to_owned()), - ( - "pubkey", - event.pubkey.to_hex(), - signed_event.pubkey().to_hex().to_owned(), - ), - ( - "created_at", - event.created_at.as_secs().to_string(), - signed_event.created_at().to_string(), - ), - ( - "kind", - (event.kind.as_u16() as u32).to_string(), - signed_event.kind().to_string(), - ), - ( - "content", - event.content.clone(), - signed_event.content().to_owned(), - ), - ( - "sig", - event.sig.to_string(), - signed_event.sig_str().to_owned(), - ), - ]; - for (field, raw, wrapped) in mismatches { - if raw != wrapped { - return Err(RadrootsRelayTransportError::NostrEventJson(format!( - "raw event JSON {field} does not match signed event {field}" - ))); - } - } - let raw_tags = event - .tags - .iter() - .map(|tag| tag.as_slice().to_vec()) - .collect::<Vec<_>>(); - if raw_tags != signed_event.tags_as_vec() { - return Err(RadrootsRelayTransportError::NostrEventJson( - "raw event JSON tags do not match signed event tags".to_owned(), - )); - } - Ok(()) -} - -#[cfg(all(test, feature = "client"))] -mod tests { - use super::{RadrootsNostrEvent, ensure_raw_event_matches_signed_event}; - use nostr::JsonUtil; - use nostr::{Keys as RadrootsNostrKeys, SecretKey as RadrootsNostrSecretKey}; - use radroots_event::draft::{EventDraft, SignedEvent}; - use radroots_event::envelope::kind::KIND_GEOCHAT; - use radroots_event::wire::Nip01EventWire; - use radroots_nostr::signing::sign_frozen_draft; - - const FIXTURE_ALICE_SECRET_KEY_HEX: &str = - "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5"; - const FIXTURE_ALICE_PUBLIC_KEY_HEX: &str = - "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; - - fn signed_post(content: &str) -> (RadrootsNostrEvent, SignedEvent) { - let secret_key = - RadrootsNostrSecretKey::from_hex(FIXTURE_ALICE_SECRET_KEY_HEX).expect("secret key"); - let keys = RadrootsNostrKeys::new(secret_key); - let draft = EventDraft::new( - "radroots.social.geochat.v1", - KIND_GEOCHAT, - 1_700_000_000, - vec![vec!["t".to_owned(), "soil".to_owned()]], - content, - FIXTURE_ALICE_PUBLIC_KEY_HEX, - ) - .expect("draft"); - let signed_event = sign_frozen_draft(&keys, &draft).expect("signed event"); - let raw_event = RadrootsNostrEvent::from_json(signed_event.raw_json()).expect("raw event"); - (raw_event, signed_event) - } - - fn assert_mismatch(raw_event: RadrootsNostrEvent, signed_event: &SignedEvent) { - assert!(ensure_raw_event_matches_signed_event(&raw_event, signed_event).is_err()); - } - - fn raw_event_from_wire(wire: Nip01EventWire) -> RadrootsNostrEvent { - RadrootsNostrEvent::from_json(serde_json::to_string(&wire).expect("raw event json")) - .expect("raw event") - } - - #[test] - fn raw_event_match_guard_accepts_exact_event_and_rejects_field_mismatches() { - let (raw_event, signed_event) = signed_post("matched"); - ensure_raw_event_matches_signed_event(&raw_event, &signed_event).expect("matching event"); - - let mut wire = signed_event.wire().clone(); - wire.id = "00".repeat(32); - assert_mismatch(raw_event_from_wire(wire), &signed_event); - - let mut wire = signed_event.wire().clone(); - wire.pubkey = "11".repeat(32); - assert_mismatch(raw_event_from_wire(wire), &signed_event); - - let mut wire = signed_event.wire().clone(); - wire.created_at += 1; - assert_mismatch(raw_event_from_wire(wire), &signed_event); - - let mut wire = signed_event.wire().clone(); - wire.kind += 1; - assert_mismatch(raw_event_from_wire(wire), &signed_event); - - let mut wire = signed_event.wire().clone(); - wire.content.push_str(" changed"); - assert_mismatch(raw_event_from_wire(wire), &signed_event); - - let mut wire = signed_event.wire().clone(); - wire.sig = "22".repeat(64); - assert_mismatch(raw_event_from_wire(wire), &signed_event); - - let mut wire = signed_event.wire().clone(); - wire.tags.push(vec!["t".to_owned(), "compost".to_owned()]); - assert_mismatch(raw_event_from_wire(wire), &signed_event); - } -} diff --git a/crates/transport_nostr/src/relays.rs b/crates/transport_nostr/src/relays.rs @@ -1,21 +0,0 @@ -use crate::{RadrootsNostrClient, RadrootsRelayTransportError}; - -pub async fn radroots_nostr_add_relay( - client: &RadrootsNostrClient, - url: &str, -) -> Result<(), RadrootsRelayTransportError> { - client.add_relay(url).await?; - Ok(()) -} - -pub async fn radroots_nostr_remove_relay( - client: &RadrootsNostrClient, - url: &str, -) -> Result<(), RadrootsRelayTransportError> { - client.remove_relay(url).await?; - Ok(()) -} - -pub async fn radroots_nostr_connect(client: &RadrootsNostrClient) { - client.connect().await; -} diff --git a/crates/transport_nostr/tests/package_boundary.rs b/crates/transport_nostr/tests/package_boundary.rs @@ -1,4 +1,6 @@ use std::collections::BTreeSet; +use std::fs; +use std::path::Path; const MANIFEST: &str = include_str!("../Cargo.toml"); const ROOT: &str = include_str!("../src/lib.rs"); @@ -67,3 +69,44 @@ fn private_modules(root: &str) -> BTreeSet<&str> { .filter_map(|module| module.strip_suffix(';')) .collect() } + +#[test] +fn adapter_owns_no_storage_outbox_or_orchestration_surface() { + for forbidden in [ + "radroots_event_store", + "radroots_outbox", + "radroots_storage", + "publish_claimed", + "fetch_and_ingest", + "projection_refresh", + "retry_schedule", + ] { + assert!(!MANIFEST.contains(forbidden)); + assert!(!ROOT.contains(forbidden)); + } + + let source_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let source_files = fs::read_dir(source_root) + .expect("source directory") + .map(|entry| { + entry + .expect("source entry") + .file_name() + .into_string() + .expect("utf-8 source name") + }) + .collect::<BTreeSet<_>>(); + assert_eq!( + source_files, + BTreeSet::from([ + "auth.rs".to_owned(), + "client.rs".to_owned(), + "error.rs".to_owned(), + "lib.rs".to_owned(), + "relay.rs".to_owned(), + "sink.rs".to_owned(), + "source.rs".to_owned(), + "status.rs".to_owned(), + ]) + ); +} diff --git a/tools/xtask/src/contract.rs b/tools/xtask/src/contract.rs @@ -2597,16 +2597,10 @@ const TRADE_VALIDATION_RECEIPT_WITNESSES: [EventBoundarySourceWitness; 2] = [ }, ]; -const RELAY_DOC_WITNESSES: [EventBoundarySourceWitness; 2] = [ - EventBoundarySourceWitness { - relative_path: "crates/event/src/relay_document.rs", - required_fragments: &["pub struct RelayDocument"], - }, - EventBoundarySourceWitness { - relative_path: "crates/transport_nostr/src/nip11.rs", - required_fragments: &["pub async fn fetch_nip11(ws_url: &str) -> Option<RelayDocument>"], - }, -]; +const RELAY_DOC_WITNESSES: [EventBoundarySourceWitness; 1] = [EventBoundarySourceWitness { + relative_path: "crates/event/src/relay_document.rs", + required_fragments: &["pub struct RelayDocument"], +}]; const CANONICAL_EVENT_BOUNDARY_EXPECTATIONS: [EventBoundaryExpectation; 44] = [ EventBoundaryExpectation {