lib

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

commit 7088fc112295e5510995fea25df09db590a6c81c
parent 39c2302b3cd6d095c32cbe06feb598779e5f044d
Author: triesap <tyson@radroots.org>
Date:   Sat, 18 Jul 2026 14:31:30 +0000

events: add strict kind-1 product profiles

- add byte-verified authored Update, PhotoUpdate, and Ask states
- gate tolerant Ask, PhotoUpdate, and Update projection on NIP-01 verification
- remove permissive post authoring and direct media publication bypasses
- govern signed vectors, operations, registry entries, and release metadata

Diffstat:
MCHANGELOG.md | 13+++++++++++++
Acontracts/conformance/vectors/post/verified_profiles.v1.json | 306+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcontracts/conformance/vectors/social/mvp.v1.json | 73-------------------------------------------------------------------------
Mcontracts/event_boundary_matrix.md | 13++++++++++++-
Mcontracts/events/social-events.md | 55++++++++++++++++++++++++++++++++++++++++++++++++-------
Mcontracts/operations.toml | 148+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mcontracts/releases/1.0.0-alpha.1.toml | 14++++++++++++++
Mcrates/event/README | 9+++++++++
Mcrates/event/src/contract.rs | 124++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/event/src/post.rs | 378+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/event_codec/README | 11+++++++++++
Mcrates/event_codec/src/manifest.rs | 4++++
Acrates/event_codec/src/post/admission.rs | 116+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/event_codec/src/post/authored.rs | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dcrates/event_codec/src/post/encode.rs | 254-------------------------------------------------------------------------------
Acrates/event_codec/src/post/inbound.rs | 790+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/event_codec/src/post/mod.rs | 4+++-
Mcrates/event_codec/src/tag_builders.rs | 15+++------------
Acrates/event_codec/tests/fixtures/post_verified_profiles.v1.json | 306+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/event_codec/tests/post.rs | 901+++++++++++++++++++------------------------------------------------------------
Mcrates/event_codec/tests/tag_builders.rs | 12------------
Acrates/event_codec/tests/verified_post_conformance.rs | 167+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/net/Cargo.toml | 2+-
Mcrates/net/src/nostr_client/events/post.rs | 15+++++++++------
Mcrates/nostr/Cargo.toml | 5+++++
Mcrates/nostr/README | 9+++++++++
Mcrates/nostr/src/event_adapters.rs | 5+++++
Mcrates/nostr/src/events/post.rs | 44++++++++++++++++++++++++++++++++++++++++++--
Mcrates/nostr/src/lib.rs | 11+++++++----
Mcrates/nostr/tests/coverage.rs | 6+-----
Acrates/nostr/tests/post_profile.rs | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtools/xtask/src/contract.rs | 6+++++-
32 files changed, 2901 insertions(+), 1079 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md @@ -20,6 +20,9 @@ publish policy both pass for the same source revision. `D` values from its validated time range. - Authored profile and calendar media now share the byte-verified Blossom image proof type; unverified URLs remain inbound data and cannot enter authoring APIs. +- Root kind-`1` product admission now verifies NIP-01 identity and signatures, + excludes replies before product classification, and deterministically projects + Ask, PhotoUpdate, or Update while preserving malformed media diagnostics. - Workspace packages declare one governed version explicitly so mounted path consumers preserve it, and every internal root dependency requires that exact pre-release version. @@ -33,6 +36,13 @@ publish policy both pass for the same source revision. - Verified Profile admission binds a signed exact kind-`0` envelope to the tolerant metadata projection, accepts standard tagless events, and exposes deterministic equal-time lowest-id replacement vectors. +- Strict authored Update, PhotoUpdate, and Ask types emit deterministic kind-`1` + wire parts. Photo and Ask media require byte-verified Blossom image + descriptors, exact ordered NIP-92 metadata, bounded nonzero fields, and + same-digest approved fallback URLs. +- Raw signed kind-`1` conformance vectors prove signature-gated profile + admission, reply exclusion, classifier precedence, tolerant metadata + retention, and stable rejection codes. ### Removed @@ -47,6 +57,9 @@ publish policy both pass for the same source revision. containing the removed `include_profiles` option is rejected. - `RadrootsNostrClient` no longer implicitly dereferences to the upstream SDK client. Narrow client operations and the explicit ownership bridge remain. +- Permissive `RadrootsPost` tag authoring, the free-form Nostr post builder, and + the generic net post publisher were removed. Publication now requires one of + the strict authored Update, PhotoUpdate, or Ask states. ### Compatibility diff --git a/contracts/conformance/vectors/post/verified_profiles.v1.json b/contracts/conformance/vectors/post/verified_profiles.v1.json @@ -0,0 +1,306 @@ +{ + "contract_version": "1.0.0", + "suite": "post_profiles", + "vectors": [ + { + "expected": { + "ask_marker": null, + "classification": "update", + "contract_id": "radroots.social.update.v1", + "diagnostics": [], + "imeta": [] + }, + "id": "signed_update", + "input": { + "event_json": "{\"id\":\"fb3f42caf9db337a7f1c0d49cd8ba5191f08dc1c419ed0640f7ea48a924e3bf3\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781632860,\"kind\":1,\"tags\":[],\"content\":\"The first strawberries are ready.\",\"sig\":\"dba0a86fee54304c2b419742f186e74d7edca5fc7234c8aa294651de9bc2f16bf829d46f36ec759a767c4ccd1841a73243eae89afd5f6c89b2243491bfbb5f50\"}" + }, + "kind": "post.verify_and_admit.valid" + }, + { + "expected": { + "ask_marker": null, + "classification": "update", + "contract_id": "radroots.social.update.v1", + "diagnostics": [], + "imeta": [] + }, + "id": "signed_empty_inbound_update", + "input": { + "event_json": "{\"id\":\"769b1b4e4428b1ffc57121e673c4b1131134c71ccb70120f382a76503e3c8634\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781632861,\"kind\":1,\"tags\":[],\"content\":\"\\t\",\"sig\":\"ae96f0c6cbbfe83b80bb92684f9f2a9930ee556f379bc03c77e61149b42441fca670251c17aacbfb9e38f159d08707999e4ffc6bfd0c38b7fa213f5053acd308\"}" + }, + "kind": "post.verify_and_admit.valid" + }, + { + "expected": { + "ask_marker": null, + "classification": "photo_update", + "contract_id": "radroots.social.photo_update.v1", + "diagnostics": [], + "imeta": [ + { + "diagnostics": [], + "fallbacks": [], + "qualifies_photo": true, + "raw_fields": [ + "url https://cdn.example/harvest.webp", + "x aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "m image/webp", + "dim 1200x900", + "size 12345", + "alt Harvest" + ], + "unknown_fields": [] + } + ] + }, + "id": "signed_structural_photo", + "input": { + "event_json": "{\"id\":\"f06df29688089218b10424a85a070ecd9fe0e7143bf24622fdd5f031fbe00029\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781635400,\"kind\":1,\"tags\":[[\"imeta\",\"url https://cdn.example/harvest.webp\",\"x aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"m image/webp\",\"dim 1200x900\",\"size 12345\",\"alt Harvest\"]],\"content\":\"Harvest https://cdn.example/harvest.webp\",\"sig\":\"2f0863959b972f639d028c65d9ca0c2b62d5ad57530ad4c810e67941d1d50ab249488f570b9905095db2df18440aac399907dda5ca0e8289c49e625ce8b0b28b\"}" + }, + "kind": "post.verify_and_admit.valid" + }, + { + "expected": { + "ask_marker": null, + "classification": "photo_update", + "contract_id": "radroots.social.photo_update.v1", + "diagnostics": [], + "imeta": [ + { + "diagnostics": [], + "fallbacks": [ + "https://cache-one.example/harvest.webp", + "https://cache-two.example/harvest.webp" + ], + "qualifies_photo": true, + "raw_fields": [ + "url https://cdn.example/harvest.webp", + "x aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "m image/webp", + "dim 1200x900", + "size 12345", + "alt Harvest", + "fallback https://cache-one.example/harvest.webp", + "x-farm cultivar-strawberry", + "fallback https://cache-two.example/harvest.webp", + "future-field retained value" + ], + "unknown_fields": [ + "x-farm cultivar-strawberry", + "future-field retained value" + ] + } + ] + }, + "id": "signed_photo_preserves_fallbacks_and_unknown_fields", + "input": { + "event_json": "{\"id\":\"c0b5925be0ec524708ab73002b7c1c8aa4269cf1aa63fc7ca96f86d2b458e12b\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781635402,\"kind\":1,\"tags\":[[\"imeta\",\"url https://cdn.example/harvest.webp\",\"x aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"m image/webp\",\"dim 1200x900\",\"size 12345\",\"alt Harvest\",\"fallback https://cache-one.example/harvest.webp\",\"x-farm cultivar-strawberry\",\"fallback https://cache-two.example/harvest.webp\",\"future-field retained value\"]],\"content\":\"Harvest https://cdn.example/harvest.webp\",\"sig\":\"fb20b6c59a7e0fd41d2ffd5c238d580d1d4d0a1d44db0a44efa1a82eb9497b963ef1bef3dcdbb1463f6229db60ccfd3fd915ae098c14261d4b33e2478a93e451\"}" + }, + "kind": "post.verify_and_admit.valid" + }, + { + "expected": { + "ask_marker": [ + "t", + " RADROOTS-ASK " + ], + "classification": "ask", + "contract_id": "radroots.social.ask.v1", + "diagnostics": [ + "imeta_metadata_missing", + "imeta_hash_invalid" + ], + "imeta": [ + { + "diagnostics": [ + "imeta_metadata_missing", + "imeta_hash_invalid" + ], + "fallbacks": [], + "qualifies_photo": false, + "raw_fields": [ + "url https://cdn.example/leaf.webp", + "x malformed" + ], + "unknown_fields": [] + } + ] + }, + "id": "signed_normalized_ask_precedes_malformed_media", + "input": { + "event_json": "{\"id\":\"5d15a6d516260b6d6cf4a7f2a22fcd349c2bee302fda2c92fa1679996290a1ac\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781635220,\"kind\":1,\"tags\":[[\"t\",\" RADROOTS-ASK \"],[\"imeta\",\"url https://cdn.example/leaf.webp\",\"x malformed\"]],\"content\":\"Question https://cdn.example/leaf.webp\",\"sig\":\"538636b2d163d1a392f4c3fced234ba6af5c9b3f1fc66e3bdbbe374287cf4e62adec6369056a3f096be1b268451c2fb25040ae8e0d67188284c2da18832c20d1\"}" + }, + "kind": "post.verify_and_admit.valid" + }, + { + "expected": { + "ask_marker": null, + "classification": "update", + "contract_id": "radroots.social.update.v1", + "diagnostics": [ + "imeta_metadata_missing", + "imeta_hash_invalid" + ], + "imeta": [ + { + "diagnostics": [ + "imeta_metadata_missing", + "imeta_hash_invalid" + ], + "fallbacks": [], + "qualifies_photo": false, + "raw_fields": [ + "url https://cdn.example/leaf.webp", + "x malformed" + ], + "unknown_fields": [] + } + ] + }, + "id": "signed_malformed_imeta_is_update", + "input": { + "event_json": "{\"id\":\"522177f3d46d3cefb674037b50a7512338ed8a5170154dcc5bf2576a36179bcd\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781635401,\"kind\":1,\"tags\":[[\"imeta\",\"url https://cdn.example/leaf.webp\",\"x malformed\"]],\"content\":\"Leaf https://cdn.example/leaf.webp\",\"sig\":\"4b60c2bc2019797978bdb77ad1534a253e829eb1b0baff9be4f4e482ed771ba146b2a4885366163760fe44a6840c6ab31b777deffb9c5306a974a25cd7e5aefa\"}" + }, + "kind": "post.verify_and_admit.valid" + }, + { + "expected": { + "ask_marker": null, + "classification": "update", + "contract_id": "radroots.social.update.v1", + "diagnostics": [ + "imeta_singleton_duplicate" + ], + "imeta": [ + { + "diagnostics": [ + "imeta_singleton_duplicate" + ], + "fallbacks": [], + "qualifies_photo": false, + "raw_fields": [ + "url https://cdn.example/harvest.webp", + "x aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "x bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "m image/webp", + "dim 1200x900", + "size 12345", + "alt Harvest" + ], + "unknown_fields": [] + } + ] + }, + "id": "signed_duplicate_singleton_is_update", + "input": { + "event_json": "{\"id\":\"62e2fa87b57ed7ed453ffb28a72ea9ae73c7473561c4ec35504b9576e52da8cb\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781635403,\"kind\":1,\"tags\":[[\"imeta\",\"url https://cdn.example/harvest.webp\",\"x aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"x bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"m image/webp\",\"dim 1200x900\",\"size 12345\",\"alt Harvest\"]],\"content\":\"Harvest https://cdn.example/harvest.webp\",\"sig\":\"07e94ffa547a84aa3fc07649d45020e8e2057fb7b65783e87a62c04cb04a3a5a873cce66f019e18610e28f6393b4f5fcfcb378bc823063d6d3fffd06e40a4ad1\"}" + }, + "kind": "post.verify_and_admit.valid" + }, + { + "expected": { + "ask_marker": null, + "classification": "update", + "contract_id": "radroots.social.update.v1", + "diagnostics": [ + "imeta_metadata_missing", + "imeta_hash_invalid" + ], + "imeta": [ + { + "diagnostics": [], + "fallbacks": [], + "qualifies_photo": true, + "raw_fields": [ + "url https://cdn.example/harvest.webp", + "x aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "m image/webp", + "dim 1200x900", + "size 12345", + "alt Harvest" + ], + "unknown_fields": [] + }, + { + "diagnostics": [ + "imeta_metadata_missing", + "imeta_hash_invalid" + ], + "fallbacks": [], + "qualifies_photo": false, + "raw_fields": [ + "url https://cdn.example/leaf.webp", + "x malformed" + ], + "unknown_fields": [] + } + ] + }, + "id": "signed_mixed_imeta_is_update", + "input": { + "event_json": "{\"id\":\"9316dda070247a72979c3146845ff0e8e5309505c8e922276552546d65d420d8\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781635404,\"kind\":1,\"tags\":[[\"imeta\",\"url https://cdn.example/harvest.webp\",\"x aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"m image/webp\",\"dim 1200x900\",\"size 12345\",\"alt Harvest\"],[\"imeta\",\"url https://cdn.example/leaf.webp\",\"x malformed\"]],\"content\":\"Harvest https://cdn.example/harvest.webp and https://cdn.example/leaf.webp\",\"sig\":\"e6fe6f76ad608d82da58b0f5ea4bb43676a11b3785789aea52c5041b6c72a732330a0883f9e3f384862758809cf5150b8ebd9392ff1907869daa9081e01791bb\"}" + }, + "kind": "post.verify_and_admit.valid" + }, + { + "expected": { + "ask_marker": null, + "classification": "reply", + "contract_id": "radroots.social.post.v1", + "diagnostics": [], + "imeta": [] + }, + "id": "signed_reply_precedes_ask_and_media", + "input": { + "event_json": "{\"id\":\"b3c2d97629ba09946a241cf44702e5fafd98c059ab7fccfd654db0fb642c3b40\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781635405,\"kind\":1,\"tags\":[[\"e\",\"ask-event-id\"],[\"p\",\"bob\"],[\"t\",\"radroots-ask\"],[\"imeta\",\"url https://cdn.example/leaf.webp\",\"x malformed\"]],\"content\":\"Reply https://cdn.example/leaf.webp\",\"sig\":\"cfdb2b7e04f49c1c5794d81bdffc38f6393ae85b3432c1737545b5fd83f76748d5a82514736f550624e569c7a5f1ef2e6ec759396668222f3204554bb5cbc042\"}" + }, + "kind": "post.verify_and_admit.valid" + }, + { + "expected": { + "ask_marker": null, + "classification": "update", + "contract_id": "radroots.social.update.v1", + "diagnostics": [ + "ask_marker_shape" + ], + "imeta": [] + }, + "id": "signed_malformed_ask_marker_is_update", + "input": { + "event_json": "{\"id\":\"8f003700904a568e00c603605545f455766033fdabeaf76d7762c54c52a568ca\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781635406,\"kind\":1,\"tags\":[[\"t\",\"RADROOTS-ASK\",\"extra\"]],\"content\":\"Question\",\"sig\":\"74716474d09a6aaf9b0301af77602567b87ab0761f753d4225e2f72ee671d58f69b5dbba3cfe7be44a15e98768674224bf9a03423c4e47f1ee88d3b9d8a63329\"}" + }, + "kind": "post.verify_and_admit.valid" + }, + { + "expected": { + "error": "ask_marker_count" + }, + "id": "signed_duplicate_normalized_ask_marker", + "input": { + "event_json": "{\"id\":\"076e0147f35e244daf5578b67a6cf0747d09ddaa7563fb3d195cf06be3057b86\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781635460,\"kind\":1,\"tags\":[[\"t\",\"radroots-ask\"],[\"t\",\" RADROOTS-ASK \"]],\"content\":\"Question\",\"sig\":\"873e2e9f6aa4443c83e51866bc87f0ed4a0388a37187a155c03104d6ad551a2f2017cd841855796a73f89cdf7ed0910946f8a7fa8fa1e678ced8b3865b95d55d\"}" + }, + "kind": "post.verify_and_admit.invalid" + }, + { + "expected": { + "error": "invalid_kind" + }, + "id": "signed_kind_20_is_not_photo_update", + "input": { + "event_json": "{\"id\":\"09e20ba068fcbfb682ba0a496c5bfaade0f2240cf2874f23af51125bb701f5b1\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781635580,\"kind\":20,\"tags\":[],\"content\":\"photo\",\"sig\":\"0aab6b82c08fa75db4b729b76a5fd2d1e726d103c436939a67888fe81bc21f93c875beedcf521e99a6cb0323382fb277be20e8982641f49dfa053ea54d165bb1\"}" + }, + "kind": "post.verify_and_admit.invalid" + }, + { + "expected": { + "error": "signature_invalid" + }, + "id": "signed_invalid_signature", + "input": { + "event_json": "{\"content\":\"Tamper signature\",\"created_at\":1781635600,\"id\":\"1b921b22caf6f648e9992773e1f680ffcb5b3e12d61cc33bc74a3d329dfdfa10\",\"kind\":1,\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"sig\":\"051c75db06b0a8b2383b947408b3f704990a74a1ccf0d7c35b438c88bb9ffda97604be8df3c677468814abb516b0a0d5e0dfbdb010d00fb2efb1d91c694a5b7f\",\"tags\":[]}" + }, + "kind": "post.verify_and_admit.invalid" + } + ] +} diff --git a/contracts/conformance/vectors/social/mvp.v1.json b/contracts/conformance/vectors/social/mvp.v1.json @@ -3,79 +3,6 @@ "contract_version": "1.0.0", "vectors": [ { - "id": "social_post_tags_with_metadata_valid_001", - "kind": "social.post.build_tags.valid", - "input": { - "post": { - "content": "field update", - "farm": { - "farm": { - "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "d_tag": "AAAAAAAAAAAAAAAAAAAAAA" - }, - "relays": [ - "wss://relay.example.test" - ] - }, - "topics": [ - "soil" - ], - "media": [ - { - "url": "https://media.example.test/field.jpg", - "mime_type": "image/jpeg", - "sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" - } - ] - } - }, - "expected": { - "result": "ok", - "required_tags": [ - "a", - "t", - "imeta" - ] - } - }, - { - "id": "social_post_tags_empty_content_valid_002", - "kind": "social.post.build_tags.valid", - "input": { - "post": { - "content": "" - } - }, - "expected": { - "result": "ok", - "required_tags": [] - } - }, - { - "id": "social_post_tags_malformed_imeta_invalid_003", - "kind": "social.post.build_tags.invalid", - "input": { - "post": { - "content": "field update", - "media": [ - { - "imeta": [ - [ - "url https://media.example.test/field.jpg", - "" - ] - ] - } - ] - } - }, - "expected": { - "result": "error", - "error_class": "encode_error", - "field": "imeta" - } - }, - { "id": "social_comment_event_root_address_parent_valid_004", "kind": "social.comment.build_tags.valid", "input": { diff --git a/contracts/event_boundary_matrix.md b/contracts/event_boundary_matrix.md @@ -31,13 +31,24 @@ an envelope whose id and signature it has independently verified and whose kind the corresponding parser accepted. Outbound authored models produce unsigned wire parts and require runtime signing and transport. +## Kind-1 post boundary rule + +Ordinary kind-1 events remain interoperable at the generic +`radroots.social.post.v1` read boundary. Product projection first requires a +`RadrootsSignatureVerifiedEvent`; any `e` tag excludes the event as a reply, +then root-card precedence is Ask, PhotoUpdate, Update. Exact subtype registry +contracts are admission-only and cannot be selected by unsigned kind/tag +matching. New publication uses the strict authored types and deterministic wire +builders. The legacy mutable `RadrootsPost` decoder is compatibility-only and +has no authored encoder or tag-builder implementation. + ## Coverage matrix | Domain | Kind | Radroots Type | RPC Methods | Notes | | --- | --- | --- | --- | --- | | profile | 0 | RadrootsAuthoredProfile / RadrootsInboundProfileMetadata | events.profile.publish, events.profile.list, events.profile.get | publish must use `profile.build_authored_draft`; inbound projection must use `profile.parse_inbound_metadata`; authored output is deterministic JSON with no marker tag | | follow | 3 | RadrootsFollow | events.follow.publish, events.follow.list, events.follow.get | replaceable event | -| post | 1 | RadrootsPost | events.post.publish, events.post.list, events.post.get | plaintext content | +| post | 1 | RadrootsAuthoredUpdate / RadrootsAuthoredPhotoUpdate / RadrootsAuthoredAsk / RadrootsInboundPostProjection | events.post.publish, events.post.list, events.post.get | ordinary kind-1 reads remain generic; exact root-card subtypes require verified admission; replies are excluded before Ask/media projection | | comment | 1111 | RadrootsComment | events.comment.publish, events.comment.list, events.comment.get | requires root and parent tags | | reaction | 7 | RadrootsReaction | events.reaction.publish, events.reaction.list, events.reaction.get | requires event, pubkey, or address tags | | repost | 6 | RadrootsRepost | events.repost.publish, events.repost.list, events.repost.get | NIP-18 kind-1 repost surface | diff --git a/contracts/events/social-events.md b/contracts/events/social-events.md @@ -26,7 +26,7 @@ authoring and admission profile are separate contract layers. ## Implementation Inventory -The repository implements public social support for kind `1` `RadrootsPost`, kind `1111` +The repository implements strict authored and verified-projected kind `1` post profiles, kind `1111` `RadrootsComment`, kind `7` `RadrootsReaction`, generic `RadrootsList` entries, stable listing records through `RadrootsListing`, articles, generic public file metadata, calendar date events, calendar time events, reposts, generic reposts, calendar collections, RSVP events, and reports. @@ -35,7 +35,7 @@ The closeout contract requires: - complete model and codec coverage for the approved public social event families - kind and tag constants for the approved NIP surface -- `RadrootsPost` preservation for optional social metadata +- ordinary kind-1 compatibility reads plus strict Update, PhotoUpdate, and Ask authoring - strict NIP-22 `RadrootsComment` behavior without legacy `e_root` or `e_prev` fallback tags - strict NIP-25 `RadrootsReaction` behavior where empty content is a valid like - explicit optional `published_at` support for NIP-99 listing parity @@ -46,7 +46,9 @@ The closeout contract requires: The MVP public social substrate includes: -- `RadrootsPost` for ordinary NIP-01 kind `1` notes plus optional Radroots social metadata +- strict `RadrootsAuthoredUpdate`, `RadrootsAuthoredPhotoUpdate`, and + `RadrootsAuthoredAsk` publication types plus verified tolerant projection for + ordinary NIP-01 kind `1` events - `RadrootsArticle` for NIP-23 kind `30023` long-form content - generic public `RadrootsFileMetadata` for NIP-94 kind `1063` - strict authored `RadrootsAuthoredCalendarDateEvent`, tolerant @@ -72,10 +74,49 @@ The production-v1 public social substrate includes: ## Contract Decisions -`RadrootsPost` remains compatible with ordinary kind `1` text notes. Content-only notes must remain -valid. Optional farm or address references, media metadata, geohash, topics, and quote references -must be preserved when present and must use serde defaults so existing simple JSON fixtures remain -valid. +`RadrootsPost` remains a compatibility read projection for ordinary kind `1` +text notes and older optional social metadata. It is not an authored boundary. +The public raw `imeta` encoder and its generic tag-builder implementation are +removed so callers cannot turn mutable strings into purported strict media. + +### Kind-1 Post Trust Layers + +Strict authored root posts are private-field typestates. Update and Ask content +must be non-whitespace and every profile is bounded to 131072 UTF-8 bytes. +PhotoUpdate and optional Ask media use between one and 64 NIP-92 `imeta` tags. +Each image emits exactly `url`, `x`, `m`, `dim`, `size`, and `alt`, in that +order, followed by ordered repeatable `fallback` fields. Primary URLs are +unique and occur as exact substrings of content. MIME is parameter-free +canonical lowercase `image/*`; dimensions are nonzero `u32` values; size is a +nonzero `u64`; alt text is non-whitespace and at most 4092 UTF-8 bytes. + +Every authored primary image is a `RadrootsAuthoredImage` backed by an approved, +byte-verified Blossom descriptor. Every authored fallback is an approved +Blossom hash-path URL with the same digest. This typestate proves local +descriptor-to-byte agreement only. Successful BUD-02 upload completion remains +a separate runtime precondition before signing. + +Ask is kind `1` and deterministically emits exactly +`["t","radroots-ask"]`. PhotoUpdate is also kind `1`; kind `20` is outside +this contract. Update emits neither the Ask marker nor `imeta`. + +Inbound projection accepts only a `RadrootsSignatureVerifiedEvent`. Any `e` +tag selects the reply exclusion before Ask or media inspection; strict NIP-10 +reply parsing remains separately owned. For roots, exactly one two-element Ask +marker after ASCII whitespace trim and ASCII case folding selects Ask. Multiple +normalized markers fail projection, while a malformed marker shape is retained +as an ordered diagnostic. Ask precedes PhotoUpdate even when attached media is +malformed. PhotoUpdate requires one through 64 wholly qualifying `imeta` +entries; a malformed or mixed set becomes Update with ordered diagnostics. +Unknown fields and repeatable fallbacks preserve wire order, while duplicate +known singletons disqualify media. + +Inbound HTTP(S) media references remain unverified structural strings. The +projection performs no retrieval and makes no Blossom, byte, upload, +reachability, image-decoding, or safety claim. The registry therefore keeps +ordinary unsigned kind-1 identification on `radroots.social.post.v1`; exact +Update, PhotoUpdate, and Ask contracts use `AdmissionOnly` and are returned only +by the verified projection/admission boundary. `RadrootsComment` uses strict NIP-22 semantics. The target and scope model must support event-id, address, and external roots or parents through `E`/`e`, `A`/`a`, and `I`/`i` tags with matching diff --git a/contracts/operations.toml b/contracts/operations.toml @@ -71,6 +71,19 @@ public = [ "RadrootsFarm", "RadrootsListing", "RadrootsPost", + "RadrootsAuthoredPostError", + "RadrootsPostImageDimensions", + "RadrootsAuthoredPostImage", + "RadrootsAuthoredUpdate", + "RadrootsAuthoredPhotoUpdate", + "RadrootsAuthoredAsk", + "RadrootsPostDiagnostic", + "RadrootsPostClassification", + "RadrootsInboundPostImeta", + "RadrootsInboundPostProjection", + "RadrootsPostProjectionError", + "RadrootsAdmittedPostEvent", + "RadrootsPostAdmissionError", "RadrootsComment", "RadrootsReaction", "RadrootsArticle", @@ -563,23 +576,136 @@ rust_types = [ [operations.listing_parse_event.conformance] vector = "contracts/conformance/vectors/listing/parse_event.v1.json" -[operations.social_post_build_tags] +[operations.social_update_build_authored_draft] domain = "social" -id = "social.post.build_tags" +id = "social.update.build_authored_draft" stability = "beta" -inputs = ["RadrootsPost"] -outputs = ["NostrTags"] +inputs = ["RadrootsAuthoredUpdate"] +outputs = ["RadrootsNip01EventWireParts"] error_class = "encode_error" deterministic = true -signing = "native" -transport = "native" +signing = "none" +transport = "none" + +[operations.social_update_build_authored_draft.implementation] +rust_modules = [ + "crates/event/src/post.rs", + "crates/event_codec/src/post/authored.rs", +] +rust_types = [ + "radroots_event::post::RadrootsAuthoredPostError", + "radroots_event::post::RadrootsAuthoredUpdate", +] -[operations.social_post_build_tags.implementation] -rust_modules = ["crates/event_codec/src/post/encode.rs"] -rust_types = ["radroots_event::post::RadrootsPost"] +[operations.social_update_build_authored_draft.conformance] +vector = "contracts/conformance/vectors/post/verified_profiles.v1.json" -[operations.social_post_build_tags.conformance] -vector = "contracts/conformance/vectors/social/mvp.v1.json" +[operations.social_photo_update_build_authored_draft] +domain = "social" +id = "social.photo_update.build_authored_draft" +stability = "beta" +inputs = ["RadrootsAuthoredPhotoUpdate"] +outputs = ["RadrootsNip01EventWireParts"] +error_class = "encode_error" +deterministic = true +signing = "none" +transport = "none" + +[operations.social_photo_update_build_authored_draft.implementation] +rust_modules = [ + "crates/event/src/post.rs", + "crates/event_codec/src/post/authored.rs", +] +rust_types = [ + "radroots_blossom::RadrootsBlossomApprovedBlobUrl", + "radroots_blossom::RadrootsBlossomByteVerifiedDescriptor", + "radroots_event::media::RadrootsAuthoredImage", + "radroots_event::post::RadrootsAuthoredPhotoUpdate", + "radroots_event::post::RadrootsAuthoredPostError", + "radroots_event::post::RadrootsAuthoredPostImage", + "radroots_event::post::RadrootsPostImageDimensions", +] + +[operations.social_photo_update_build_authored_draft.conformance] +vector = "contracts/conformance/vectors/post/verified_profiles.v1.json" + +[operations.social_ask_build_authored_draft] +domain = "social" +id = "social.ask.build_authored_draft" +stability = "beta" +inputs = ["RadrootsAuthoredAsk"] +outputs = ["RadrootsNip01EventWireParts"] +error_class = "encode_error" +deterministic = true +signing = "none" +transport = "none" + +[operations.social_ask_build_authored_draft.implementation] +rust_modules = [ + "crates/event/src/post.rs", + "crates/event_codec/src/post/authored.rs", +] +rust_types = [ + "radroots_event::post::RadrootsAuthoredAsk", + "radroots_event::post::RadrootsAuthoredPostError", + "radroots_event::post::RadrootsAuthoredPostImage", +] + +[operations.social_ask_build_authored_draft.conformance] +vector = "contracts/conformance/vectors/post/verified_profiles.v1.json" + +[operations.social_post_project_verified_event] +domain = "social" +id = "social.post.project_verified_event" +stability = "beta" +inputs = ["RadrootsSignatureVerifiedEvent"] +outputs = ["RadrootsInboundPostProjection"] +error_class = "parse_error" +deterministic = true +signing = "none" +transport = "none" + +[operations.social_post_project_verified_event.implementation] +rust_modules = ["crates/event_codec/src/post/inbound.rs"] +rust_types = [ + "radroots_event_codec::post::inbound::RadrootsInboundPostImeta", + "radroots_event_codec::post::inbound::RadrootsInboundPostProjection", + "radroots_event_codec::post::inbound::RadrootsPostClassification", + "radroots_event_codec::post::inbound::RadrootsPostDiagnostic", + "radroots_event_codec::post::inbound::RadrootsPostProjectionError", + "radroots_event_codec::verification::RadrootsSignatureVerifiedEvent", +] + +[operations.social_post_project_verified_event.conformance] +vector = "contracts/conformance/vectors/post/verified_profiles.v1.json" + +[operations.social_post_verify_and_admit_event] +domain = "social" +id = "social.post.verify_and_admit_event" +stability = "beta" +inputs = ["RadrootsEventEnvelope"] +outputs = ["RadrootsAdmittedPostEvent"] +error_class = "admission_error" +deterministic = true +signing = "nip01" +transport = "none" + +[operations.social_post_verify_and_admit_event.implementation] +rust_modules = [ + "crates/event_codec/src/post/admission.rs", + "crates/event_codec/src/post/inbound.rs", + "crates/event_codec/src/verification.rs", +] +rust_types = [ + "radroots_event::RadrootsEventEnvelope", + "radroots_event_codec::post::admission::RadrootsAdmittedPostEvent", + "radroots_event_codec::post::admission::RadrootsPostAdmissionError", + "radroots_event_codec::post::inbound::RadrootsInboundPostProjection", + "radroots_event_codec::verification::RadrootsSignatureVerifiedEvent", +] + +[operations.social_post_verify_and_admit_event.conformance] +vector = "contracts/conformance/vectors/post/verified_profiles.v1.json" [operations.social_comment_build_tags] domain = "social" diff --git a/contracts/releases/1.0.0-alpha.1.toml b/contracts/releases/1.0.0-alpha.1.toml @@ -88,3 +88,17 @@ semver_impacts = [ "change_exported_algorithm_behavior", ] summary = "Expose knowledge-independent NIP-01 verification and verified Profile admission while rejecting out-of-range Nostr kinds and malformed secp256k1 author keys." + +[[changes]] +id = "strict-kind-one-product-profiles" +classification = "breaking" +semver_impacts = [ + "add_exported_type", + "add_enum_variant", + "add_conformance_vector", + "remove_exported_function", + "remove_exported_module", + "remove_exported_trait_impl", + "change_exported_algorithm_behavior", +] +summary = "Replace permissive kind-1 post authoring with strict Update, PhotoUpdate, and Ask states plus signature-gated tolerant product admission." diff --git a/crates/event/README b/crates/event/README @@ -21,6 +21,15 @@ syntax without making a network identity claim. The legacy read projection is not serializable or exported as a DTO. Tolerant reads use `RadrootsInboundProfileMetadata` from `radroots_event_codec`. +The post module keeps the legacy mutable `RadrootsPost` model as a compatibility +read projection only. New root kind-1 publication uses private-field +`RadrootsAuthoredUpdate`, `RadrootsAuthoredPhotoUpdate`, and +`RadrootsAuthoredAsk` types. Photo and optional Ask media require nonzero +dimensions, bounded alt text, approved same-digest fallbacks, and an +image-typed byte-verified Blossom descriptor. That descriptor state is not an +upload receipt; BUD-02 completion remains a runtime prerequisite before +signing. + The calendar module keeps three different states explicit for NIP-52 kinds `31922`, `31923`, `31924`, and `31925`: the complete structural event envelope, a tolerant baseline NIP-52 projection, and a strict Radroots-admitted diff --git a/crates/event/src/contract.rs b/crates/event/src/contract.rs @@ -199,6 +199,8 @@ pub struct RadrootsTagContract { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum RadrootsEventDiscriminator { KindOnly, + /// Exact profile selection is owned by a verified admission algorithm. + AdmissionOnly, DTagExact(&'static str), DTagPrefix(&'static str), DTagSuffix(&'static str), @@ -252,6 +254,9 @@ pub enum RadrootsContractValidationError { UnknownContract { contract_id: String, }, + AdmissionRequired { + contract_id: &'static str, + }, ContractMatch { error: RadrootsContractMatchError, }, @@ -298,6 +303,7 @@ impl RadrootsContractValidationError { pub const fn code(&self) -> &'static str { match self { Self::UnknownContract { .. } => "unknown_contract", + Self::AdmissionRequired { .. } => "admission_required", Self::ContractMatch { .. } => "contract_match", Self::KindMismatch { .. } => "kind_mismatch", Self::ContentMustBeEmpty { .. } => "content_must_be_empty", @@ -754,6 +760,27 @@ const TAG_TOPIC_MANY: RadrootsTagContract = tag( RadrootsTagValueType::Text, true, ); +const TAG_ASK_MARKER: RadrootsTagContract = tag( + "t", + RadrootsTagCardinality::RequiredOne, + RadrootsTagSemantic::Topic, + RadrootsTagValueType::Text, + true, +); +const TAG_IMETA_REQUIRED_MANY: RadrootsTagContract = tag( + "imeta", + RadrootsTagCardinality::RequiredMany, + RadrootsTagSemantic::Image, + RadrootsTagValueType::Text, + false, +); +const TAG_IMETA_OPTIONAL_MANY: RadrootsTagContract = tag( + "imeta", + RadrootsTagCardinality::OptionalMany, + RadrootsTagSemantic::Image, + RadrootsTagValueType::Text, + false, +); const TAG_CALENDAR_REFERENCE: RadrootsTagContract = tag( "r", RadrootsTagCardinality::OptionalMany, @@ -926,6 +953,8 @@ const EVIDENCE_BOUNTY_TAGS: &[RadrootsTagContract] = &[ ]; const SOCIAL_REDUCERS: &[RadrootsReducer] = &[RadrootsReducer::SocialProjection]; +const PHOTO_UPDATE_TAGS: &[RadrootsTagContract] = &[TAG_IMETA_REQUIRED_MANY]; +const ASK_TAGS: &[RadrootsTagContract] = &[TAG_ASK_MARKER, TAG_IMETA_OPTIONAL_MANY]; const PROFILE_REDUCERS: &[RadrootsReducer] = &[RadrootsReducer::ProfileProjection]; const FARM_OPS_REDUCERS: &[RadrootsReducer] = &[RadrootsReducer::FarmOpsProjection]; const GROUP_REDUCERS: &[RadrootsReducer] = &[RadrootsReducer::GroupProjection]; @@ -1086,7 +1115,12 @@ static ALL_KIND_CONTRACTS: &[RadrootsKindContract] = &[ "Short Text Note", RadrootsEventClass::Regular, RadrootsNostrStandard::Nip01, - ["radroots.social.post.v1"] + [ + "radroots.social.post.v1", + "radroots.social.update.v1", + "radroots.social.photo_update.v1", + "radroots.social.ask.v1" + ] ), kind_contract!( KIND_FOLLOW, @@ -1845,6 +1879,45 @@ static ALL_EVENT_CONTRACTS: &[RadrootsEventContract] = &[ SOCIAL_REDUCERS ), event_contract!( + "radroots.social.update.v1", + KIND_POST, + "Root Text Update", + "RadrootsAuthoredUpdate / RadrootsInboundPostProjection", + RadrootsEventClass::Regular, + RadrootsEventPrivacy::Public, + RadrootsActorRole::Any, + RadrootsContentSchema::PlainText, + RadrootsEventDiscriminator::AdmissionOnly, + NO_TAGS, + SOCIAL_REDUCERS + ), + event_contract!( + "radroots.social.photo_update.v1", + KIND_POST, + "NIP-92 Photo Update", + "RadrootsAuthoredPhotoUpdate / RadrootsInboundPostProjection", + RadrootsEventClass::Regular, + RadrootsEventPrivacy::Public, + RadrootsActorRole::Any, + RadrootsContentSchema::PlainText, + RadrootsEventDiscriminator::AdmissionOnly, + PHOTO_UPDATE_TAGS, + SOCIAL_REDUCERS + ), + event_contract!( + "radroots.social.ask.v1", + KIND_POST, + "Root Ask", + "RadrootsAuthoredAsk / RadrootsInboundPostProjection", + RadrootsEventClass::Regular, + RadrootsEventPrivacy::Public, + RadrootsActorRole::Any, + RadrootsContentSchema::PlainText, + RadrootsEventDiscriminator::AdmissionOnly, + ASK_TAGS, + SOCIAL_REDUCERS + ), + event_contract!( "radroots.social.follow_list.v1", KIND_FOLLOW, "Contact List", @@ -3293,6 +3366,14 @@ pub fn validate_event_contract_parts( actual: kind, }); } + if matches!( + contract.discriminator, + RadrootsEventDiscriminator::AdmissionOnly + ) { + return Err(RadrootsContractValidationError::AdmissionRequired { + contract_id: contract.id, + }); + } validate_content_shape_parts(content, contract)?; validate_contract_tags_parts(tags, contract)?; validate_discriminator_parts(content, contract)?; @@ -3354,6 +3435,8 @@ fn contract_family_for_id(id: &str) -> Option<RadrootsContractFamily> { Some(RadrootsContractFamily::Profile) } else if id.starts_with("radroots.relay.") { Some(RadrootsContractFamily::Relay) + } else if id.starts_with("radroots.social.") { + Some(RadrootsContractFamily::Social) } else if id.starts_with("radroots.trade.") { Some(RadrootsContractFamily::Trade) } else { @@ -4169,6 +4252,14 @@ fn validate_discriminator_parts( content: &str, contract: &RadrootsEventContract, ) -> Result<(), RadrootsContractValidationError> { + if matches!( + contract.discriminator, + RadrootsEventDiscriminator::AdmissionOnly + ) { + return Err(RadrootsContractValidationError::AdmissionRequired { + contract_id: contract.id, + }); + } let (field, value) = match &contract.discriminator { RadrootsEventDiscriminator::ContentJsonFieldEquals { field, value } => (*field, *value), RadrootsEventDiscriminator::EnvelopeType(value) => ("type", *value), @@ -4244,6 +4335,7 @@ fn discriminator_matches( ) -> bool { match discriminator { RadrootsEventDiscriminator::KindOnly => true, + RadrootsEventDiscriminator::AdmissionOnly => false, RadrootsEventDiscriminator::DTagExact(expected) => tag_value(tags, "d") == Some(*expected), RadrootsEventDiscriminator::DTagPrefix(prefix) => tag_value(tags, "d") .map(|value| value.starts_with(prefix)) @@ -5033,6 +5125,30 @@ mod tests { } #[test] + fn post_subtype_contracts_require_verified_admission() { + let tags = vec![vec!["t".to_owned(), "radroots-ask".to_owned()]]; + let generic = identify_event_contract(KIND_POST, &tags, "Question") + .expect("unsigned kind-1 identification remains generic"); + assert_eq!(generic.id, "radroots.social.post.v1"); + + for id in [ + "radroots.social.update.v1", + "radroots.social.photo_update.v1", + "radroots.social.ask.v1", + ] { + let contract = event_contract(id).expect(id); + assert_eq!( + event_contract_family(contract), + Some(RadrootsContractFamily::Social) + ); + assert_eq!( + validate_event_contract_parts(KIND_POST, &tags, "Question", id), + Err(RadrootsContractValidationError::AdmissionRequired { contract_id: id }) + ); + } + } + + #[test] fn identifies_exact_list_set_shape() { let tags = vec![vec!["d".to_owned(), "member_of.farms".to_owned()]]; let contract = identify_event_contract(KIND_LIST_SET_GENERIC, &tags, "{}") @@ -5838,6 +5954,12 @@ mod tests { "unknown_contract", ), ( + RadrootsContractValidationError::AdmissionRequired { + contract_id: "radroots.social.ask.v1", + }, + "admission_required", + ), + ( RadrootsContractValidationError::ContractMatch { error: RadrootsContractMatchError::UnsupportedKind(999_999), }, diff --git a/crates/event/src/post.rs b/crates/event/src/post.rs @@ -1,11 +1,22 @@ #[cfg(not(feature = "std"))] use alloc::{string::String, vec::Vec}; +use core::fmt; +use radroots_blossom::{RadrootsBlossomApprovedBlobUrl, RadrootsBlossomMediaType}; +use url_nostd::Url; + +use crate::media::RadrootsAuthoredImage; use crate::social::{ RadrootsSocialFarmAnchor, RadrootsSocialLocation, RadrootsSocialMediaMetadata, RadrootsSocialTarget, }; +pub const RADROOTS_POST_CONTENT_MAX_BYTES: usize = crate::wire::DEFAULT_CONTENT_MAX_BYTES; +pub const RADROOTS_POST_IMETA_MAX_COUNT: usize = 64; +pub const RADROOTS_POST_ALT_MAX_BYTES: usize = (4 * 1024) - "alt ".len(); +pub const RADROOTS_ASK_MARKER_TAG_KEY: &str = "t"; +pub const RADROOTS_ASK_MARKER_TAG_VALUE: &str = "radroots-ask"; + #[cfg_attr( any(feature = "serde", test), derive(serde::Serialize, serde::Deserialize) @@ -13,6 +24,11 @@ use crate::social::{ #[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))] #[cfg_attr(feature = "dto-bindgen", dto(export))] #[derive(Clone, Debug)] +/// Compatibility projection for the legacy social post decoder. +/// +/// This mutable model is not an authored event boundary. New publication code +/// must use `RadrootsAuthoredUpdate`, `RadrootsAuthoredPhotoUpdate`, or +/// `RadrootsAuthoredAsk` so raw `imeta` cannot bypass the strict profile. pub struct RadrootsPost { pub content: String, #[cfg_attr( @@ -47,6 +63,368 @@ pub struct RadrootsPost { pub media: Option<Vec<RadrootsSocialMediaMetadata>>, } +#[non_exhaustive] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RadrootsAuthoredPostError { + ContentMissing, + ContentTooLarge { max: usize, actual: usize }, + ImageMissing, + ImageCountExceeded { max: usize, actual: usize }, + ImageUrlMissingFromContent, + DuplicateImageUrl, + ImageMediaTypeInvalid, + ImageSizeInvalid, + ImageDimensionsInvalid, + ImageAltInvalid, + ImageAltTooLarge { max: usize, actual: usize }, + ImageFallbackHashMismatch, +} + +impl RadrootsAuthoredPostError { + pub const fn code(&self) -> &'static str { + match self { + Self::ContentMissing => "post_content_missing", + Self::ContentTooLarge { .. } => "post_content_too_large", + Self::ImageMissing => "photo_imeta_missing", + Self::ImageCountExceeded { .. } => "imeta_count_exceeded", + Self::ImageUrlMissingFromContent => "imeta_url_missing_from_content", + Self::DuplicateImageUrl => "duplicate_imeta_url", + Self::ImageMediaTypeInvalid => "imeta_mime_invalid", + Self::ImageSizeInvalid => "imeta_size_invalid", + Self::ImageDimensionsInvalid => "imeta_dimensions_invalid", + Self::ImageAltInvalid => "imeta_alt_invalid", + Self::ImageAltTooLarge { .. } => "imeta_alt_too_large", + Self::ImageFallbackHashMismatch => "imeta_fallback_hash_mismatch", + } + } +} + +impl fmt::Display for RadrootsAuthoredPostError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ContentMissing => { + formatter.write_str("authored post content must be non-whitespace") + } + Self::ContentTooLarge { max, actual } => { + write!( + formatter, + "authored post content is {actual} bytes; max is {max}" + ) + } + Self::ImageMissing => { + formatter.write_str("authored PhotoUpdate requires at least one image") + } + Self::ImageCountExceeded { max, actual } => { + write!(formatter, "authored post has {actual} images; max is {max}") + } + Self::ImageUrlMissingFromContent => { + formatter.write_str("each authored image URL must occur exactly in post content") + } + Self::DuplicateImageUrl => { + formatter.write_str("authored post image URLs must be unique") + } + Self::ImageMediaTypeInvalid => formatter.write_str( + "authored post image media type must be parameter-free canonical lowercase image/*", + ), + Self::ImageSizeInvalid => { + formatter.write_str("authored post image size must be nonzero") + } + Self::ImageDimensionsInvalid => { + formatter.write_str("authored post image dimensions must be nonzero u32 values") + } + Self::ImageAltInvalid => { + formatter.write_str("authored post image alt text must be non-whitespace") + } + Self::ImageAltTooLarge { max, actual } => { + write!( + formatter, + "authored post image alt text is {actual} bytes; max is {max}" + ) + } + Self::ImageFallbackHashMismatch => formatter.write_str( + "authored post image fallback URL must contain the primary image digest", + ), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for RadrootsAuthoredPostError {} + +/// Nonzero pixel dimensions for one strict authored NIP-92 image. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RadrootsPostImageDimensions { + width: u32, + height: u32, +} + +impl RadrootsPostImageDimensions { + pub const fn new(width: u32, height: u32) -> Result<Self, RadrootsAuthoredPostError> { + if width == 0 || height == 0 { + return Err(RadrootsAuthoredPostError::ImageDimensionsInvalid); + } + Ok(Self { width, height }) + } + + pub const fn width(self) -> u32 { + self.width + } + + pub const fn height(self) -> u32 { + self.height + } +} + +/// Strict authored NIP-92 image metadata. +/// +/// The primary image can only enter through a byte-verified Blossom +/// descriptor. This proves descriptor/byte agreement, not upload completion or +/// network availability. Publication runtimes must separately require a +/// successful BUD-02 upload before signing. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsAuthoredPostImage { + image: RadrootsAuthoredImage, + dimensions: RadrootsPostImageDimensions, + alt: String, + fallbacks: Vec<RadrootsBlossomApprovedBlobUrl>, +} + +impl RadrootsAuthoredPostImage { + pub fn new( + image: RadrootsAuthoredImage, + dimensions: RadrootsPostImageDimensions, + alt: impl Into<String>, + ) -> Result<Self, RadrootsAuthoredPostError> { + let descriptor = image.descriptor(); + if descriptor.size() == 0 { + return Err(RadrootsAuthoredPostError::ImageSizeInvalid); + } + if !post_image_media_type_is_valid(descriptor.media_type().as_str()) { + return Err(RadrootsAuthoredPostError::ImageMediaTypeInvalid); + } + let alt = alt.into(); + if alt.trim().is_empty() { + return Err(RadrootsAuthoredPostError::ImageAltInvalid); + } + if alt.len() > RADROOTS_POST_ALT_MAX_BYTES { + return Err(RadrootsAuthoredPostError::ImageAltTooLarge { + max: RADROOTS_POST_ALT_MAX_BYTES, + actual: alt.len(), + }); + } + Ok(Self { + image, + dimensions, + alt, + fallbacks: Vec::new(), + }) + } + + pub fn try_with_fallback( + mut self, + fallback: RadrootsBlossomApprovedBlobUrl, + ) -> Result<Self, RadrootsAuthoredPostError> { + if fallback.as_blob_url().hash_path().hash() != self.image.descriptor().sha256() { + return Err(RadrootsAuthoredPostError::ImageFallbackHashMismatch); + } + self.fallbacks.push(fallback); + Ok(self) + } + + pub fn image(&self) -> &RadrootsAuthoredImage { + &self.image + } + + pub const fn dimensions(&self) -> RadrootsPostImageDimensions { + self.dimensions + } + + pub fn alt(&self) -> &str { + &self.alt + } + + pub fn fallbacks(&self) -> &[RadrootsBlossomApprovedBlobUrl] { + &self.fallbacks + } + + pub fn url(&self) -> &str { + self.image.descriptor().url().as_str() + } +} + +/// Strict authored root kind-1 Update without Ask or media tags. +/// +/// ```compile_fail +/// let _: radroots_event::post::RadrootsAuthoredUpdate = +/// serde_json::from_str(r#"{"content":"harvest"}"#).unwrap(); +/// ``` +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsAuthoredUpdate { + content: String, +} + +impl RadrootsAuthoredUpdate { + pub fn new(content: impl Into<String>) -> Result<Self, RadrootsAuthoredPostError> { + let content = content.into(); + validate_authored_root_content(&content)?; + Ok(Self { content }) + } + + pub fn content(&self) -> &str { + &self.content + } +} + +/// Strict authored root kind-1 PhotoUpdate with deterministic NIP-92 tags. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsAuthoredPhotoUpdate { + content: String, + images: Vec<RadrootsAuthoredPostImage>, +} + +impl RadrootsAuthoredPhotoUpdate { + pub fn new( + content: impl Into<String>, + images: Vec<RadrootsAuthoredPostImage>, + ) -> Result<Self, RadrootsAuthoredPostError> { + let content = content.into(); + validate_content_size(&content)?; + validate_authored_images(&content, &images)?; + Ok(Self { content, images }) + } + + pub fn content(&self) -> &str { + &self.content + } + + pub fn images(&self) -> &[RadrootsAuthoredPostImage] { + &self.images + } +} + +/// Strict authored root kind-1 Ask with its exact product marker. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsAuthoredAsk { + content: String, + images: Vec<RadrootsAuthoredPostImage>, +} + +impl RadrootsAuthoredAsk { + pub fn new( + content: impl Into<String>, + images: Vec<RadrootsAuthoredPostImage>, + ) -> Result<Self, RadrootsAuthoredPostError> { + let content = content.into(); + validate_authored_root_content(&content)?; + if images.len() > RADROOTS_POST_IMETA_MAX_COUNT { + return Err(RadrootsAuthoredPostError::ImageCountExceeded { + max: RADROOTS_POST_IMETA_MAX_COUNT, + actual: images.len(), + }); + } + if !images.is_empty() { + validate_authored_images(&content, &images)?; + } + Ok(Self { content, images }) + } + + pub fn content(&self) -> &str { + &self.content + } + + pub fn images(&self) -> &[RadrootsAuthoredPostImage] { + &self.images + } +} + +fn validate_authored_root_content(content: &str) -> Result<(), RadrootsAuthoredPostError> { + validate_content_size(content)?; + if content.trim().is_empty() { + return Err(RadrootsAuthoredPostError::ContentMissing); + } + Ok(()) +} + +fn validate_content_size(content: &str) -> Result<(), RadrootsAuthoredPostError> { + if content.len() > RADROOTS_POST_CONTENT_MAX_BYTES { + return Err(RadrootsAuthoredPostError::ContentTooLarge { + max: RADROOTS_POST_CONTENT_MAX_BYTES, + actual: content.len(), + }); + } + Ok(()) +} + +fn validate_authored_images( + content: &str, + images: &[RadrootsAuthoredPostImage], +) -> Result<(), RadrootsAuthoredPostError> { + if images.is_empty() { + return Err(RadrootsAuthoredPostError::ImageMissing); + } + if images.len() > RADROOTS_POST_IMETA_MAX_COUNT { + return Err(RadrootsAuthoredPostError::ImageCountExceeded { + max: RADROOTS_POST_IMETA_MAX_COUNT, + actual: images.len(), + }); + } + for (index, image) in images.iter().enumerate() { + if !content.contains(image.url()) { + return Err(RadrootsAuthoredPostError::ImageUrlMissingFromContent); + } + if images[..index] + .iter() + .any(|candidate| candidate.url() == image.url()) + { + return Err(RadrootsAuthoredPostError::DuplicateImageUrl); + } + } + Ok(()) +} + +pub fn post_image_media_type_is_valid(value: &str) -> bool { + !value.contains(';') + && value.starts_with("image/") + && RadrootsBlossomMediaType::parse(value) + .is_ok_and(|media_type| media_type.as_str() == value) +} + +/// Returns whether an inbound media reference is a structural HTTP(S) URL. +/// +/// This is intentionally broader than strict authored Blossom policy and does +/// not make a reachability, byte-verification, or upload claim. +pub fn post_media_http_url_is_valid(value: &str) -> bool { + if value.is_empty() + || value + .chars() + .any(|character| character.is_control() || character.is_whitespace()) + { + return false; + } + let Some((scheme, remainder)) = value.split_once("://") else { + return false; + }; + if !(scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")) { + return false; + } + let authority_end = remainder.find(['/', '?', '#']).unwrap_or(remainder.len()); + let authority = &remainder[..authority_end]; + let raw_path = remainder[authority_end..] + .split(['?', '#']) + .next() + .unwrap_or_default(); + let Ok(parsed) = Url::parse(value) else { + return false; + }; + matches!(parsed.scheme(), "http" | "https") + && parsed.host_str().is_some_and(|host| !host.is_empty()) + && parsed.username().is_empty() + && parsed.password().is_none() + && !authority.contains('@') + && raw_path.starts_with('/') + && !raw_path.is_empty() +} + #[cfg(all(test, feature = "std", feature = "serde"))] mod tests { use super::*; diff --git a/crates/event_codec/README b/crates/event_codec/README @@ -30,6 +30,17 @@ is independent of the optional `knowledge` decoder. The `nostr` feature exposes `RadrootsSignatureVerifiedEvent` and rejects event kinds above `u16::MAX` instead of truncating them. +The post codec exposes deterministic authored wire builders and a separate +verified-event projection. Update emits no profile tags, PhotoUpdate emits +strict ordered NIP-92 `imeta`, and Ask emits one exact `t=radroots-ask` marker +before optional strict media. The former mutable post encoder and generic tag +builder are removed. Inbound projection preserves ordinary kind-1 reads, +excludes any `e`-tagged reply before product classification, and applies Ask, +PhotoUpdate, Update precedence. Unknown media fields and repeatable fallbacks +remain ordered; malformed media becomes diagnostic Update unless a valid Ask +marker takes precedence. Structural inbound URLs remain unverified and no +network retrieval occurs. + The NIP-52 calendar codecs expose a deliberate three-stage boundary for kinds `31922`, `31923`, `31924`, and `31925`: diff --git a/crates/event_codec/src/manifest.rs b/crates/event_codec/src/manifest.rs @@ -73,6 +73,7 @@ pub struct RadrootsKnowledgeContractManifestEntry { #[serde(tag = "type", rename_all = "snake_case")] pub enum RadrootsKnowledgeManifestDiscriminator { KindOnly, + AdmissionOnly, DTagExact { value: String, }, @@ -215,6 +216,9 @@ fn discriminator_manifest( ) -> RadrootsKnowledgeManifestDiscriminator { match discriminator { RadrootsEventDiscriminator::KindOnly => RadrootsKnowledgeManifestDiscriminator::KindOnly, + RadrootsEventDiscriminator::AdmissionOnly => { + RadrootsKnowledgeManifestDiscriminator::AdmissionOnly + } RadrootsEventDiscriminator::DTagExact(value) => { RadrootsKnowledgeManifestDiscriminator::DTagExact { value: (*value).to_string(), diff --git a/crates/event_codec/src/post/admission.rs b/crates/event_codec/src/post/admission.rs @@ -0,0 +1,116 @@ +use core::fmt; + +use radroots_event::{RadrootsEventEnvelope, contract::RadrootsEventContract}; + +use crate::{ + post::inbound::{ + RadrootsInboundPostProjection, RadrootsPostProjectionError, project_verified_post_event, + }, + verification::{ + RadrootsNip01VerificationError, RadrootsSignatureVerifiedEvent, verify_nip01_event, + }, +}; + +/// A signature-and-id verified kind-1 event bound to its tolerant projection. +/// +/// Admission here means admission to the public kind-1 post boundary. Reply is +/// preserved as an exclusion classification; this type does not claim strict +/// NIP-10 reply validity or relay-policy acceptance. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsAdmittedPostEvent { + verified_event: RadrootsSignatureVerifiedEvent, + projection: RadrootsInboundPostProjection, +} + +impl RadrootsAdmittedPostEvent { + pub fn verified_event(&self) -> &RadrootsSignatureVerifiedEvent { + &self.verified_event + } + + pub fn event(&self) -> &RadrootsEventEnvelope { + self.verified_event.event() + } + + pub fn projection(&self) -> &RadrootsInboundPostProjection { + &self.projection + } + + pub fn contract(&self) -> &'static RadrootsEventContract { + radroots_event::contract::event_contract(self.projection.classification().contract_id()) + .expect("post projection contract IDs are registry-owned") + } + + pub fn into_parts( + self, + ) -> ( + RadrootsSignatureVerifiedEvent, + RadrootsInboundPostProjection, + ) { + (self.verified_event, self.projection) + } +} + +#[non_exhaustive] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RadrootsPostAdmissionError { + Nip01Verification(RadrootsNip01VerificationError), + Projection(RadrootsPostProjectionError), +} + +impl RadrootsPostAdmissionError { + pub const fn code(&self) -> &'static str { + match self { + Self::Nip01Verification(error) => error.code(), + Self::Projection(error) => error.code(), + } + } +} + +impl fmt::Display for RadrootsPostAdmissionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Nip01Verification(error) => write!(formatter, "{error}"), + Self::Projection(error) => write!(formatter, "{error}"), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for RadrootsPostAdmissionError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Nip01Verification(error) => Some(error), + Self::Projection(error) => Some(error), + } + } +} + +impl From<RadrootsNip01VerificationError> for RadrootsPostAdmissionError { + fn from(value: RadrootsNip01VerificationError) -> Self { + Self::Nip01Verification(value) + } +} + +impl From<RadrootsPostProjectionError> for RadrootsPostAdmissionError { + fn from(value: RadrootsPostProjectionError) -> Self { + Self::Projection(value) + } +} + +/// Admits an already verified kind-1 event and binds its tolerant projection. +pub fn admit_verified_post_event( + verified_event: RadrootsSignatureVerifiedEvent, +) -> Result<RadrootsAdmittedPostEvent, RadrootsPostAdmissionError> { + let projection = project_verified_post_event(&verified_event)?; + Ok(RadrootsAdmittedPostEvent { + verified_event, + projection, + }) +} + +/// Verifies NIP-01 identifier/signature state before kind-1 projection. +pub fn verify_and_admit_post_event( + event: RadrootsEventEnvelope, +) -> Result<RadrootsAdmittedPostEvent, RadrootsPostAdmissionError> { + admit_verified_post_event(verify_nip01_event(event)?) +} diff --git a/crates/event_codec/src/post/authored.rs b/crates/event_codec/src/post/authored.rs @@ -0,0 +1,89 @@ +#[cfg(not(feature = "std"))] +use alloc::{ + format, + string::{String, ToString}, + vec, + vec::Vec, +}; + +use radroots_event::{ + kinds::KIND_POST, + post::{ + RADROOTS_ASK_MARKER_TAG_KEY, RADROOTS_ASK_MARKER_TAG_VALUE, RadrootsAuthoredAsk, + RadrootsAuthoredPhotoUpdate, RadrootsAuthoredPostImage, RadrootsAuthoredUpdate, + }, + tags::TAG_IMETA, + wire::RadrootsNip01EventWireParts, +}; + +/// Builds deterministic unsigned kind-1 wire parts for a strict Update. +pub fn authored_update_to_wire_parts( + update: &RadrootsAuthoredUpdate, +) -> RadrootsNip01EventWireParts { + RadrootsNip01EventWireParts { + kind: KIND_POST, + content: update.content().to_string(), + tags: Vec::new(), + } +} + +/// Builds deterministic unsigned kind-1 wire parts for a strict PhotoUpdate. +/// +/// The caller must separately establish successful BUD-02 upload completion +/// for every image before passing these parts to a signing boundary. +pub fn authored_photo_update_to_wire_parts( + photo: &RadrootsAuthoredPhotoUpdate, +) -> RadrootsNip01EventWireParts { + RadrootsNip01EventWireParts { + kind: KIND_POST, + content: photo.content().to_string(), + tags: image_tags(photo.images()), + } +} + +/// Builds deterministic unsigned kind-1 wire parts for a strict Ask. +/// +/// The exact Ask marker is emitted first. Optional media uses the same strict +/// NIP-92 profile as PhotoUpdate. Upload completion remains a separate runtime +/// precondition before signing. +pub fn authored_ask_to_wire_parts(ask: &RadrootsAuthoredAsk) -> RadrootsNip01EventWireParts { + let mut tags = Vec::with_capacity(1 + ask.images().len()); + tags.push(vec![ + RADROOTS_ASK_MARKER_TAG_KEY.to_string(), + RADROOTS_ASK_MARKER_TAG_VALUE.to_string(), + ]); + tags.extend(image_tags(ask.images())); + RadrootsNip01EventWireParts { + kind: KIND_POST, + content: ask.content().to_string(), + tags, + } +} + +fn image_tags(images: &[RadrootsAuthoredPostImage]) -> Vec<Vec<String>> { + images.iter().map(image_tag).collect() +} + +fn image_tag(image: &RadrootsAuthoredPostImage) -> Vec<String> { + let descriptor = image.image().descriptor(); + let dimensions = image.dimensions(); + let mut tag = Vec::with_capacity(7 + image.fallbacks().len()); + tag.push(TAG_IMETA.to_string()); + tag.push(format!("url {}", descriptor.url())); + tag.push(format!("x {}", descriptor.sha256())); + tag.push(format!("m {}", descriptor.media_type())); + tag.push(format!( + "dim {}x{}", + dimensions.width(), + dimensions.height() + )); + tag.push(format!("size {}", descriptor.size())); + tag.push(format!("alt {}", image.alt())); + tag.extend( + image + .fallbacks() + .iter() + .map(|fallback| format!("fallback {fallback}")), + ); + tag +} diff --git a/crates/event_codec/src/post/encode.rs b/crates/event_codec/src/post/encode.rs @@ -1,254 +0,0 @@ -#[cfg(not(feature = "std"))] -use alloc::{ - format, - string::{String, ToString}, - vec, - vec::Vec, -}; - -use radroots_event::{ - kinds::{KIND_FARM, KIND_POST}, - post::RadrootsPost, - social::{RadrootsSocialFarmAnchor, RadrootsSocialMediaMetadata, RadrootsSocialTarget}, - tags::{TAG_A, TAG_IMETA, TAG_Q, TAG_T}, -}; - -use crate::error::EventEncodeError; -use crate::field_helpers::{parse_address_tag, validate_lowercase_hex_64}; -use crate::social_helpers::{dimensions_tag, push_location_tags}; -use radroots_event::wire::RadrootsNip01EventWireParts; - -const DEFAULT_KIND: u32 = KIND_POST; - -pub fn post_build_tags(post: &RadrootsPost) -> Result<Vec<Vec<String>>, EventEncodeError> { - let mut tags = Vec::new(); - if let Some(farm) = post.farm.as_ref() { - push_farm_anchor(&mut tags, farm)?; - } - if let Some(refs) = post.address_refs.as_ref() { - for target in refs { - push_address_ref(&mut tags, target)?; - } - } - if let Some(location) = post.location.as_ref() { - push_location_tags(&mut tags, location); - } - if let Some(topics) = post.topics.as_ref() { - for topic in topics { - if !topic.trim().is_empty() { - tags.push(vec![TAG_T.to_string(), topic.clone()]); - } - } - } - if let Some(quote_refs) = post.quote_refs.as_ref() { - for target in quote_refs { - push_quote_ref(&mut tags, target)?; - } - } - if let Some(media) = post.media.as_ref() { - for item in media { - push_media_tags(&mut tags, item)?; - } - } - Ok(tags) -} - -pub fn to_wire_parts(post: &RadrootsPost) -> Result<RadrootsNip01EventWireParts, EventEncodeError> { - to_wire_parts_with_kind(post, DEFAULT_KIND) -} - -pub fn to_wire_parts_with_kind( - post: &RadrootsPost, - kind: u32, -) -> Result<RadrootsNip01EventWireParts, EventEncodeError> { - if kind != DEFAULT_KIND { - return Err(EventEncodeError::InvalidKind(kind)); - } - if post.content.trim().is_empty() { - return Err(EventEncodeError::EmptyRequiredField("content")); - } - let tags = post_build_tags(post)?; - Ok(RadrootsNip01EventWireParts { - kind, - content: post.content.clone(), - tags, - }) -} - -fn push_farm_anchor( - tags: &mut Vec<Vec<String>>, - farm: &RadrootsSocialFarmAnchor, -) -> Result<(), EventEncodeError> { - if farm.farm.pubkey.trim().is_empty() { - return Err(EventEncodeError::EmptyRequiredField("farm.pubkey")); - } - if farm.farm.d_tag.trim().is_empty() { - return Err(EventEncodeError::EmptyRequiredField("farm.d_tag")); - } - let address = format!("{}:{}:{}", KIND_FARM, farm.farm.pubkey, farm.farm.d_tag); - parse_address_tag(&address, "farm").map_err(|_| EventEncodeError::InvalidField("farm"))?; - let mut tag = Vec::with_capacity(2 + farm.relays.as_ref().map_or(0, Vec::len)); - tag.push(TAG_A.to_string()); - tag.push(address); - if let Some(relays) = farm.relays.as_ref() { - tag.extend(relays.iter().cloned()); - } - tags.push(tag); - Ok(()) -} - -fn push_address_ref( - tags: &mut Vec<Vec<String>>, - target: &RadrootsSocialTarget, -) -> Result<(), EventEncodeError> { - let RadrootsSocialTarget::Address { - address, - author, - event_kind, - relays, - } = target - else { - return Err(EventEncodeError::InvalidField("address_refs")); - }; - let parsed = parse_address_tag(address, "address_refs") - .map_err(|_| EventEncodeError::InvalidField("address_refs"))?; - if parsed.kind == KIND_FARM { - return Err(EventEncodeError::InvalidField("address_refs")); - } - if let Some(kind) = event_kind - && *kind != parsed.kind - { - return Err(EventEncodeError::InvalidField("address_refs")); - } - if let Some(author) = author.as_deref() - && author != parsed.pubkey - { - return Err(EventEncodeError::InvalidField("address_refs")); - } - let mut tag = Vec::with_capacity(2 + relays.as_ref().map_or(0, Vec::len)); - tag.push(TAG_A.to_string()); - tag.push(format!( - "{}:{}:{}", - parsed.kind, parsed.pubkey, parsed.d_tag - )); - if let Some(relays) = relays { - tag.extend(relays.iter().cloned()); - } - tags.push(tag); - Ok(()) -} - -fn push_quote_ref( - tags: &mut Vec<Vec<String>>, - target: &RadrootsSocialTarget, -) -> Result<(), EventEncodeError> { - match target { - RadrootsSocialTarget::Event { id, relays, .. } => { - validate_lowercase_hex_64(id, "quote_refs")?; - let mut tag = Vec::with_capacity(2 + relays.as_ref().map_or(0, Vec::len)); - tag.push(TAG_Q.to_string()); - tag.push(id.clone()); - if let Some(relays) = relays { - tag.extend(relays.iter().cloned()); - } - tags.push(tag); - Ok(()) - } - RadrootsSocialTarget::Address { - address, - event_kind, - relays, - .. - } => { - let parsed = parse_address_tag(address, "quote_refs") - .map_err(|_| EventEncodeError::InvalidField("quote_refs"))?; - if let Some(kind) = event_kind - && *kind != parsed.kind - { - return Err(EventEncodeError::InvalidField("quote_refs")); - } - let mut tag = Vec::with_capacity(2 + relays.as_ref().map_or(0, Vec::len)); - tag.push(TAG_Q.to_string()); - tag.push(format!( - "{}:{}:{}", - parsed.kind, parsed.pubkey, parsed.d_tag - )); - if let Some(relays) = relays { - tag.extend(relays.iter().cloned()); - } - tags.push(tag); - Ok(()) - } - RadrootsSocialTarget::External { .. } => Err(EventEncodeError::InvalidField("quote_refs")), - } -} - -fn push_media_tags( - tags: &mut Vec<Vec<String>>, - media: &RadrootsSocialMediaMetadata, -) -> Result<(), EventEncodeError> { - if let Some(raw_tags) = media.imeta.as_ref() { - for raw in raw_tags { - if raw.is_empty() || raw.iter().any(|value| value.trim().is_empty()) { - return Err(EventEncodeError::InvalidField("imeta")); - } - let mut tag = Vec::with_capacity(1 + raw.len()); - tag.push(TAG_IMETA.to_string()); - tag.extend(raw.iter().cloned()); - tags.push(tag); - } - return Ok(()); - } - - let mut fields = Vec::new(); - push_imeta_field(&mut fields, "url", media.url.as_deref()); - push_imeta_field(&mut fields, "m", media.mime_type.as_deref()); - push_imeta_field(&mut fields, "x", media.sha256.as_deref()); - push_imeta_field(&mut fields, "ox", media.original_sha256.as_deref()); - if let Some(size) = media.size { - fields.push(format!("size {size}")); - } - if let Some(dimensions) = media.dimensions.as_ref() { - fields.push(format!("dim {}", dimensions_tag(dimensions))); - } - push_imeta_field(&mut fields, "blurhash", media.blurhash.as_deref()); - if let Some(thumbnails) = media.thumbnails.as_ref() { - for thumbnail in thumbnails { - if thumbnail.url.trim().is_empty() { - return Err(EventEncodeError::InvalidField("imeta")); - } - fields.push(format!("thumb {}", thumbnail.url)); - if let Some(dimensions) = thumbnail.dimensions.as_ref() { - fields.push(format!("dim {}", dimensions_tag(dimensions))); - } - } - } - push_imeta_field(&mut fields, "image", media.image.as_deref()); - push_imeta_field(&mut fields, "summary", media.summary.as_deref()); - push_imeta_field(&mut fields, "alt", media.alt.as_deref()); - push_imeta_field(&mut fields, "fallback", media.fallback.as_deref()); - push_imeta_field(&mut fields, "magnet", media.magnet.as_deref()); - if let Some(values) = media.content_hashes.as_ref() { - for value in values { - push_imeta_field(&mut fields, "i", Some(value.as_str())); - } - } - if let Some(values) = media.services.as_ref() { - for value in values { - push_imeta_field(&mut fields, "service", Some(value.as_str())); - } - } - if !fields.is_empty() { - let mut tag = Vec::with_capacity(1 + fields.len()); - tag.push(TAG_IMETA.to_string()); - tag.extend(fields); - tags.push(tag); - } - Ok(()) -} - -fn push_imeta_field(fields: &mut Vec<String>, key: &str, value: Option<&str>) { - if let Some(value) = value.filter(|value| !value.trim().is_empty()) { - fields.push(format!("{key} {value}")); - } -} diff --git a/crates/event_codec/src/post/inbound.rs b/crates/event_codec/src/post/inbound.rs @@ -0,0 +1,790 @@ +#[cfg(not(feature = "std"))] +use alloc::{ + collections::{BTreeMap, BTreeSet}, + string::{String, ToString}, + vec, + vec::Vec, +}; +use core::fmt; +#[cfg(feature = "std")] +use std::collections::{BTreeMap, BTreeSet}; + +use radroots_event::{ + kinds::KIND_POST, + post::{ + RADROOTS_ASK_MARKER_TAG_VALUE, RADROOTS_POST_ALT_MAX_BYTES, + RADROOTS_POST_CONTENT_MAX_BYTES, RADROOTS_POST_IMETA_MAX_COUNT, + RadrootsPostImageDimensions, post_image_media_type_is_valid, post_media_http_url_is_valid, + }, +}; + +use crate::verification::RadrootsSignatureVerifiedEvent; + +const REQUIRED_IMETA_FIELDS: [&str; 6] = ["url", "x", "m", "dim", "size", "alt"]; + +#[non_exhaustive] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RadrootsPostDiagnostic { + AskMarkerShape, + ImetaCountExceeded, + ImetaFieldInvalid, + ImetaUrlMissing, + ImetaMetadataMissing, + ImetaSingletonDuplicate, + ImetaUrlMissingFromContent, + DuplicateImetaUrl, + ImetaUrlInvalid, + ImetaHashInvalid, + ImetaMimeInvalid, + ImetaDimensionsInvalid, + ImetaSizeInvalid, + ImetaAltInvalid, + ImetaAltTooLarge, + ImetaFallbackUrlInvalid, +} + +impl RadrootsPostDiagnostic { + pub const fn code(self) -> &'static str { + match self { + Self::AskMarkerShape => "ask_marker_shape", + Self::ImetaCountExceeded => "imeta_count_exceeded", + Self::ImetaFieldInvalid => "imeta_field_invalid", + Self::ImetaUrlMissing => "imeta_url_missing", + Self::ImetaMetadataMissing => "imeta_metadata_missing", + Self::ImetaSingletonDuplicate => "imeta_singleton_duplicate", + Self::ImetaUrlMissingFromContent => "imeta_url_missing_from_content", + Self::DuplicateImetaUrl => "duplicate_imeta_url", + Self::ImetaUrlInvalid => "imeta_url_invalid", + Self::ImetaHashInvalid => "imeta_hash_invalid", + Self::ImetaMimeInvalid => "imeta_mime_invalid", + Self::ImetaDimensionsInvalid => "imeta_dimensions_invalid", + Self::ImetaSizeInvalid => "imeta_size_invalid", + Self::ImetaAltInvalid => "imeta_alt_invalid", + Self::ImetaAltTooLarge => "imeta_alt_too_large", + Self::ImetaFallbackUrlInvalid => "imeta_fallback_url_invalid", + } + } +} + +impl fmt::Display for RadrootsPostDiagnostic { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.code()) + } +} + +/// Product projection for a verified kind-1 event. +/// +/// Reply is an exclusion classification only; strict NIP-10 parsing remains a +/// separate contract. Update, PhotoUpdate, and Ask are root-card profiles. +#[non_exhaustive] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RadrootsPostClassification { + Reply, + Update, + PhotoUpdate, + Ask, +} + +impl RadrootsPostClassification { + pub const fn contract_id(self) -> &'static str { + match self { + Self::Reply => "radroots.social.post.v1", + Self::Update => "radroots.social.update.v1", + Self::PhotoUpdate => "radroots.social.photo_update.v1", + Self::Ask => "radroots.social.ask.v1", + } + } + + pub const fn is_root_card(self) -> bool { + !matches!(self, Self::Reply) + } +} + +/// One raw inbound NIP-92 `imeta` projection. +/// +/// URLs and metadata remain unverified even when the entry qualifies for +/// PhotoUpdate classification. Classification is structural and performs no +/// network request or blob verification. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsInboundPostImeta { + raw_fields: Vec<String>, + url: Option<String>, + sha256: Option<String>, + media_type: Option<String>, + dimensions: Option<RadrootsPostImageDimensions>, + size: Option<u64>, + alt: Option<String>, + fallbacks: Vec<String>, + unknown_fields: Vec<String>, + diagnostics: Vec<RadrootsPostDiagnostic>, +} + +impl RadrootsInboundPostImeta { + pub fn raw_fields(&self) -> &[String] { + &self.raw_fields + } + + pub fn url(&self) -> Option<&str> { + self.url.as_deref() + } + + pub fn sha256(&self) -> Option<&str> { + self.sha256.as_deref() + } + + pub fn media_type(&self) -> Option<&str> { + self.media_type.as_deref() + } + + pub const fn dimensions(&self) -> Option<RadrootsPostImageDimensions> { + self.dimensions + } + + pub const fn size(&self) -> Option<u64> { + self.size + } + + pub fn alt(&self) -> Option<&str> { + self.alt.as_deref() + } + + pub fn fallbacks(&self) -> &[String] { + &self.fallbacks + } + + pub fn unknown_fields(&self) -> &[String] { + &self.unknown_fields + } + + pub fn diagnostics(&self) -> &[RadrootsPostDiagnostic] { + &self.diagnostics + } + + pub fn qualifies_photo(&self) -> bool { + self.diagnostics.is_empty() + } +} + +/// Tolerant, ordered product projection of one verified kind-1 event. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsInboundPostProjection { + classification: RadrootsPostClassification, + ask_marker: Option<Vec<String>>, + imeta: Vec<RadrootsInboundPostImeta>, + diagnostics: Vec<RadrootsPostDiagnostic>, +} + +impl RadrootsInboundPostProjection { + pub const fn classification(&self) -> RadrootsPostClassification { + self.classification + } + + pub fn ask_marker(&self) -> Option<&[String]> { + self.ask_marker.as_deref() + } + + pub fn imeta(&self) -> &[RadrootsInboundPostImeta] { + &self.imeta + } + + pub fn diagnostics(&self) -> &[RadrootsPostDiagnostic] { + &self.diagnostics + } +} + +#[non_exhaustive] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RadrootsPostProjectionError { + InvalidKind { expected: u32, actual: u32 }, + ContentTooLarge { max: usize, actual: usize }, + AskMarkerCount, +} + +impl RadrootsPostProjectionError { + pub const fn code(&self) -> &'static str { + match self { + Self::InvalidKind { .. } => "invalid_kind", + Self::ContentTooLarge { .. } => "post_content_too_large", + Self::AskMarkerCount => "ask_marker_count", + } + } +} + +impl fmt::Display for RadrootsPostProjectionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidKind { expected, actual } => { + write!( + formatter, + "post event kind must be {expected}, got {actual}" + ) + } + Self::ContentTooLarge { max, actual } => { + write!(formatter, "post content is {actual} bytes; max is {max}") + } + Self::AskMarkerCount => { + formatter.write_str("post event must not contain multiple normalized Ask markers") + } + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for RadrootsPostProjectionError {} + +/// Projects a signature-and-id verified kind-1 event without admitting it to a +/// relay or claiming media verification. +/// +/// Any `e` tag selects Reply before Ask or media inspection. This function does +/// not implement strict NIP-10 reply validation; that belongs to the dedicated +/// reply contract. +pub fn project_verified_post_event( + verified_event: &RadrootsSignatureVerifiedEvent, +) -> Result<RadrootsInboundPostProjection, RadrootsPostProjectionError> { + let event = verified_event.event(); + project_inbound_post_parts(event.kind_u32(), &event.tags_as_vec(), event.content()) +} + +pub(crate) fn project_inbound_post_parts( + kind: u32, + tags: &[Vec<String>], + content: &str, +) -> Result<RadrootsInboundPostProjection, RadrootsPostProjectionError> { + if kind != KIND_POST { + return Err(RadrootsPostProjectionError::InvalidKind { + expected: KIND_POST, + actual: kind, + }); + } + if content.len() > RADROOTS_POST_CONTENT_MAX_BYTES { + return Err(RadrootsPostProjectionError::ContentTooLarge { + max: RADROOTS_POST_CONTENT_MAX_BYTES, + actual: content.len(), + }); + } + if tags + .iter() + .any(|tag| tag.first().is_some_and(|key| key == "e")) + { + return Ok(RadrootsInboundPostProjection { + classification: RadrootsPostClassification::Reply, + ask_marker: None, + imeta: Vec::new(), + diagnostics: Vec::new(), + }); + } + + let (ask_marker, marker_diagnostics) = project_ask_marker(tags)?; + let imeta_tags = tags + .iter() + .filter(|tag| tag.first().is_some_and(|key| key == "imeta")) + .collect::<Vec<_>>(); + let mut diagnostics = marker_diagnostics; + if imeta_tags.len() > RADROOTS_POST_IMETA_MAX_COUNT { + diagnostics.push(RadrootsPostDiagnostic::ImetaCountExceeded); + } + let imeta = project_imeta(imeta_tags, content); + diagnostics.extend( + imeta + .iter() + .flat_map(|item| item.diagnostics.iter().copied()), + ); + let classification = if ask_marker.is_some() { + RadrootsPostClassification::Ask + } else if !imeta.is_empty() && diagnostics.is_empty() { + RadrootsPostClassification::PhotoUpdate + } else { + RadrootsPostClassification::Update + }; + Ok(RadrootsInboundPostProjection { + classification, + ask_marker, + imeta, + diagnostics, + }) +} + +fn project_ask_marker( + tags: &[Vec<String>], +) -> Result<(Option<Vec<String>>, Vec<RadrootsPostDiagnostic>), RadrootsPostProjectionError> { + let candidates = tags + .iter() + .filter(|tag| { + tag.first().is_some_and(|key| key == "t") + && tag.get(1).is_some_and(|value| normalized_ask_marker(value)) + }) + .collect::<Vec<_>>(); + if candidates.len() > 1 { + return Err(RadrootsPostProjectionError::AskMarkerCount); + } + let Some(candidate) = candidates.first() else { + return Ok((None, Vec::new())); + }; + if candidate.len() != 2 { + return Ok((None, vec![RadrootsPostDiagnostic::AskMarkerShape])); + } + Ok((Some((*candidate).clone()), Vec::new())) +} + +fn normalized_ask_marker(value: &str) -> bool { + value + .trim_matches(|character| { + matches!( + character, + ' ' | '\t' | '\n' | '\r' | '\u{000b}' | '\u{000c}' + ) + }) + .eq_ignore_ascii_case(RADROOTS_ASK_MARKER_TAG_VALUE) +} + +fn project_imeta(tags: Vec<&Vec<String>>, content: &str) -> Vec<RadrootsInboundPostImeta> { + let mut projections = Vec::with_capacity(tags.len()); + let mut seen_urls = BTreeSet::new(); + for tag in tags { + let raw_fields = tag[1..].to_vec(); + let mut fields = BTreeMap::new(); + let mut fallbacks = Vec::new(); + let mut unknown_fields = Vec::new(); + let mut diagnostics = Vec::new(); + + for raw_field in &raw_fields { + let Some((key, value)) = raw_field.split_once(' ') else { + diagnostics.push(RadrootsPostDiagnostic::ImetaFieldInvalid); + continue; + }; + if key.is_empty() || value.is_empty() { + diagnostics.push(RadrootsPostDiagnostic::ImetaFieldInvalid); + } else if key == "fallback" { + fallbacks.push(value.to_string()); + } else if imeta_singleton_field(key) { + if fields.contains_key(key) { + diagnostics.push(RadrootsPostDiagnostic::ImetaSingletonDuplicate); + } else { + fields.insert(key.to_string(), value.to_string()); + } + } else { + unknown_fields.push(raw_field.clone()); + } + } + + let url = fields.get("url").cloned(); + if url.is_none() { + diagnostics.push(RadrootsPostDiagnostic::ImetaUrlMissing); + } else if REQUIRED_IMETA_FIELDS + .iter() + .any(|required| !fields.contains_key(*required)) + { + diagnostics.push(RadrootsPostDiagnostic::ImetaMetadataMissing); + } + if let Some(url) = &url { + if !content.contains(url) { + diagnostics.push(RadrootsPostDiagnostic::ImetaUrlMissingFromContent); + } + if !seen_urls.insert(url.clone()) { + diagnostics.push(RadrootsPostDiagnostic::DuplicateImetaUrl); + } + if !post_media_http_url_is_valid(url) { + diagnostics.push(RadrootsPostDiagnostic::ImetaUrlInvalid); + } + } + + let sha256 = fields.get("x").cloned(); + if sha256.as_deref().is_some_and(|value| !lower_hex_64(value)) { + diagnostics.push(RadrootsPostDiagnostic::ImetaHashInvalid); + } + let media_type = fields.get("m").cloned(); + if media_type + .as_deref() + .is_some_and(|value| !post_image_media_type_is_valid(value)) + { + diagnostics.push(RadrootsPostDiagnostic::ImetaMimeInvalid); + } + let dimensions = fields.get("dim").and_then(|value| { + parse_dimensions(value).or_else(|| { + diagnostics.push(RadrootsPostDiagnostic::ImetaDimensionsInvalid); + None + }) + }); + let size = fields.get("size").and_then(|value| { + parse_nonzero_u64(value).or_else(|| { + diagnostics.push(RadrootsPostDiagnostic::ImetaSizeInvalid); + None + }) + }); + let alt = fields.get("alt").cloned(); + if let Some(alt) = &alt { + if alt.trim().is_empty() { + diagnostics.push(RadrootsPostDiagnostic::ImetaAltInvalid); + } else if alt.len() > RADROOTS_POST_ALT_MAX_BYTES { + diagnostics.push(RadrootsPostDiagnostic::ImetaAltTooLarge); + } + } + for fallback in &fallbacks { + if !post_media_http_url_is_valid(fallback) { + diagnostics.push(RadrootsPostDiagnostic::ImetaFallbackUrlInvalid); + } + } + + projections.push(RadrootsInboundPostImeta { + raw_fields, + url, + sha256, + media_type, + dimensions, + size, + alt, + fallbacks, + unknown_fields, + diagnostics, + }); + } + projections +} + +fn imeta_singleton_field(value: &str) -> bool { + matches!( + value, + "url" + | "m" + | "x" + | "ox" + | "size" + | "dim" + | "magnet" + | "i" + | "blurhash" + | "thumb" + | "image" + | "summary" + | "alt" + | "service" + ) +} + +fn lower_hex_64(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) +} + +fn parse_dimensions(value: &str) -> Option<RadrootsPostImageDimensions> { + let (width, height) = value.split_once('x')?; + if !canonical_nonzero_decimal(width) || !canonical_nonzero_decimal(height) { + return None; + } + RadrootsPostImageDimensions::new(width.parse().ok()?, height.parse().ok()?).ok() +} + +fn parse_nonzero_u64(value: &str) -> Option<u64> { + canonical_nonzero_decimal(value) + .then(|| value.parse().ok()) + .flatten() +} + +fn canonical_nonzero_decimal(value: &str) -> bool { + value + .as_bytes() + .first() + .is_some_and(|byte| matches!(byte, b'1'..=b'9')) + && value.bytes().all(|byte| byte.is_ascii_digit()) +} + +#[cfg(test)] +mod tests { + use super::*; + use radroots_event::post::RadrootsAuthoredUpdate; + + #[test] + fn reply_exclusion_precedes_ask_and_media_projection() { + let projection = project_inbound_post_parts( + KIND_POST, + &[ + vec!["e".to_string(), "parent".to_string()], + vec!["t".to_string(), "radroots-ask".to_string()], + vec!["imeta".to_string(), "x malformed".to_string()], + ], + "reply", + ) + .unwrap(); + + assert_eq!( + projection.classification(), + RadrootsPostClassification::Reply + ); + assert!(projection.imeta().is_empty()); + assert!(projection.diagnostics().is_empty()); + } + + #[test] + fn normalized_ask_precedes_malformed_media_and_retains_diagnostics() { + let projection = project_inbound_post_parts( + KIND_POST, + &[ + vec!["t".to_string(), " RADROOTS-ASK ".to_string()], + vec![ + "imeta".to_string(), + "url https://cdn.example/leaf.webp".to_string(), + "x malformed".to_string(), + ], + ], + "Question https://cdn.example/leaf.webp", + ) + .unwrap(); + + assert_eq!(projection.classification(), RadrootsPostClassification::Ask); + assert_eq!( + diagnostic_codes(projection.diagnostics()), + ["imeta_metadata_missing", "imeta_hash_invalid"] + ); + assert_eq!( + projection.ask_marker().unwrap(), + ["t".to_string(), " RADROOTS-ASK ".to_string()] + ); + } + + #[test] + fn photo_preserves_repeatable_fallbacks_and_ordered_unknown_fields() { + let projection = project_inbound_post_parts( + KIND_POST, + &[qualifying_imeta(vec![ + "fallback https://cache-one.example/harvest.webp", + "x-farm cultivar-strawberry", + "fallback https://cache-two.example/harvest.webp", + "future-field retained value", + ])], + "Harvest https://cdn.example/harvest.webp", + ) + .unwrap(); + let media = &projection.imeta()[0]; + + assert_eq!( + projection.classification(), + RadrootsPostClassification::PhotoUpdate + ); + assert_eq!( + media.fallbacks(), + [ + "https://cache-one.example/harvest.webp".to_string(), + "https://cache-two.example/harvest.webp".to_string(), + ] + ); + assert_eq!( + media.unknown_fields(), + [ + "x-farm cultivar-strawberry".to_string(), + "future-field retained value".to_string(), + ] + ); + assert!(media.qualifies_photo()); + } + + #[test] + fn duplicate_singletons_and_mixed_imeta_downgrade_to_update() { + let mut duplicate = qualifying_imeta(Vec::new()); + duplicate.insert( + 3, + "x bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(), + ); + let malformed = vec![ + "imeta".to_string(), + "url https://cdn.example/leaf.webp".to_string(), + "x malformed".to_string(), + ]; + let projection = project_inbound_post_parts( + KIND_POST, + &[duplicate, malformed], + "Harvest https://cdn.example/harvest.webp and https://cdn.example/leaf.webp", + ) + .unwrap(); + + assert_eq!( + projection.classification(), + RadrootsPostClassification::Update + ); + assert_eq!( + diagnostic_codes(projection.diagnostics()), + [ + "imeta_singleton_duplicate", + "imeta_metadata_missing", + "imeta_hash_invalid", + ] + ); + } + + #[test] + fn duplicate_urls_and_excess_imeta_downgrade_to_update() { + let duplicate = qualifying_imeta(Vec::new()); + let projection = project_inbound_post_parts( + KIND_POST, + &[duplicate.clone(), duplicate], + "Harvest https://cdn.example/harvest.webp", + ) + .unwrap(); + assert_eq!( + projection.classification(), + RadrootsPostClassification::Update + ); + assert_eq!( + diagnostic_codes(projection.diagnostics()), + ["duplicate_imeta_url"] + ); + + let imeta = qualifying_imeta(Vec::new()); + let tags = vec![imeta; RADROOTS_POST_IMETA_MAX_COUNT + 1]; + let projection = project_inbound_post_parts( + KIND_POST, + &tags, + "Harvest https://cdn.example/harvest.webp", + ) + .unwrap(); + assert_eq!( + projection.classification(), + RadrootsPostClassification::Update + ); + assert_eq!( + projection.diagnostics().first(), + Some(&RadrootsPostDiagnostic::ImetaCountExceeded) + ); + } + + #[test] + fn invalid_imeta_fields_report_stable_ordered_diagnostics() { + let projection = project_inbound_post_parts( + KIND_POST, + &[vec![ + "imeta".to_string(), + "url ftp://cdn.example/harvest.webp".to_string(), + "x AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_string(), + "m image/webp;quality=90".to_string(), + "dim 01x0".to_string(), + "size 0".to_string(), + "alt \t".to_string(), + "fallback file:///harvest.webp".to_string(), + ]], + "Harvest ftp://cdn.example/harvest.webp", + ) + .unwrap(); + + assert_eq!( + projection.classification(), + RadrootsPostClassification::Update + ); + assert_eq!( + diagnostic_codes(projection.diagnostics()), + [ + "imeta_url_invalid", + "imeta_hash_invalid", + "imeta_mime_invalid", + "imeta_dimensions_invalid", + "imeta_size_invalid", + "imeta_alt_invalid", + "imeta_fallback_url_invalid", + ] + ); + } + + #[test] + fn malformed_fields_and_oversized_alt_never_qualify_photo() { + let oversized_alt = "a".repeat(RADROOTS_POST_ALT_MAX_BYTES + 1); + let mut tag = qualifying_imeta(Vec::new()); + tag.push("malformed".to_string()); + tag[6] = format!("alt {oversized_alt}"); + let projection = project_inbound_post_parts( + KIND_POST, + &[tag], + "Harvest https://cdn.example/harvest.webp", + ) + .unwrap(); + + assert_eq!( + projection.classification(), + RadrootsPostClassification::Update + ); + assert_eq!( + diagnostic_codes(projection.diagnostics()), + ["imeta_field_invalid", "imeta_alt_too_large"] + ); + } + + #[test] + fn malformed_and_duplicate_normalized_ask_markers_are_distinct() { + let malformed = project_inbound_post_parts( + KIND_POST, + &[vec![ + "t".to_string(), + "RADROOTS-ASK".to_string(), + "extra".to_string(), + ]], + "Question", + ) + .unwrap(); + assert_eq!( + malformed.classification(), + RadrootsPostClassification::Update + ); + assert_eq!( + diagnostic_codes(malformed.diagnostics()), + ["ask_marker_shape"] + ); + + let error = project_inbound_post_parts( + KIND_POST, + &[ + vec!["t".to_string(), "radroots-ask".to_string()], + vec!["t".to_string(), " RADROOTS-ASK ".to_string()], + ], + "Question", + ) + .unwrap_err(); + assert_eq!(error.code(), "ask_marker_count"); + } + + #[test] + fn empty_inbound_root_is_update_without_becoming_valid_authored_content() { + let projection = project_inbound_post_parts(KIND_POST, &[], "\t").unwrap(); + assert_eq!( + projection.classification(), + RadrootsPostClassification::Update + ); + assert!(RadrootsAuthoredUpdate::new("\t").is_err()); + } + + #[test] + fn projection_rejects_wrong_kind_and_oversized_content() { + assert_eq!( + project_inbound_post_parts(20, &[], "photo") + .unwrap_err() + .code(), + "invalid_kind" + ); + let oversized = "x".repeat(RADROOTS_POST_CONTENT_MAX_BYTES + 1); + assert_eq!( + project_inbound_post_parts(KIND_POST, &[], &oversized) + .unwrap_err() + .code(), + "post_content_too_large" + ); + } + + fn qualifying_imeta(extra: Vec<&str>) -> Vec<String> { + let mut tag = vec![ + "imeta".to_string(), + "url https://cdn.example/harvest.webp".to_string(), + "x aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + "m image/webp".to_string(), + "dim 1200x900".to_string(), + "size 12345".to_string(), + "alt Harvest".to_string(), + ]; + tag.extend(extra.into_iter().map(str::to_string)); + tag + } + + fn diagnostic_codes(diagnostics: &[RadrootsPostDiagnostic]) -> Vec<&'static str> { + diagnostics + .iter() + .map(|diagnostic| diagnostic.code()) + .collect() + } +} diff --git a/crates/event_codec/src/post/mod.rs b/crates/event_codec/src/post/mod.rs @@ -1,2 +1,4 @@ +pub mod admission; +pub mod authored; pub mod decode; -pub mod encode; +pub mod inbound; diff --git a/crates/event_codec/src/tag_builders.rs b/crates/event_codec/src/tag_builders.rs @@ -16,9 +16,9 @@ use radroots_event::{ geochat::RadrootsGeoChat, gift_wrap::RadrootsGiftWrap, job_feedback::RadrootsJobFeedback, job_request::RadrootsJobRequest, job_result::RadrootsJobResult, list::RadrootsList, list_set::RadrootsListSet, listing::RadrootsListing, message::RadrootsMessage, - message_file::RadrootsMessageFile, plot::RadrootsPlot, post::RadrootsPost, - reaction::RadrootsReaction, resource_area::RadrootsResourceArea, - resource_cap::RadrootsResourceHarvestCap, seal::RadrootsSeal, + message_file::RadrootsMessageFile, plot::RadrootsPlot, reaction::RadrootsReaction, + resource_area::RadrootsResourceArea, resource_cap::RadrootsResourceHarvestCap, + seal::RadrootsSeal, }; use crate::app_data::encode::app_data_build_tags; @@ -48,7 +48,6 @@ use crate::listing::tags::listing_tags; use crate::message::encode::message_build_tags; use crate::message_file::encode::message_file_build_tags; use crate::plot::encode::plot_build_tags; -use crate::post::encode::post_build_tags; use crate::reaction::encode::reaction_build_tags; use crate::resource_area::encode::resource_area_build_tags; use crate::resource_cap::encode::resource_harvest_cap_build_tags; @@ -230,14 +229,6 @@ impl RadrootsEventTagBuilder for RadrootsGiftWrap { } } -impl RadrootsEventTagBuilder for RadrootsPost { - type Error = EventEncodeError; - - fn build_tags(&self) -> Result<Vec<Vec<String>>, Self::Error> { - post_build_tags(self) - } -} - #[cfg(feature = "knowledge")] impl RadrootsEventTagBuilder for RadrootsWikiArticle { type Error = EventEncodeError; diff --git a/crates/event_codec/tests/fixtures/post_verified_profiles.v1.json b/crates/event_codec/tests/fixtures/post_verified_profiles.v1.json @@ -0,0 +1,306 @@ +{ + "contract_version": "1.0.0", + "suite": "post_profiles", + "vectors": [ + { + "expected": { + "ask_marker": null, + "classification": "update", + "contract_id": "radroots.social.update.v1", + "diagnostics": [], + "imeta": [] + }, + "id": "signed_update", + "input": { + "event_json": "{\"id\":\"fb3f42caf9db337a7f1c0d49cd8ba5191f08dc1c419ed0640f7ea48a924e3bf3\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781632860,\"kind\":1,\"tags\":[],\"content\":\"The first strawberries are ready.\",\"sig\":\"dba0a86fee54304c2b419742f186e74d7edca5fc7234c8aa294651de9bc2f16bf829d46f36ec759a767c4ccd1841a73243eae89afd5f6c89b2243491bfbb5f50\"}" + }, + "kind": "post.verify_and_admit.valid" + }, + { + "expected": { + "ask_marker": null, + "classification": "update", + "contract_id": "radroots.social.update.v1", + "diagnostics": [], + "imeta": [] + }, + "id": "signed_empty_inbound_update", + "input": { + "event_json": "{\"id\":\"769b1b4e4428b1ffc57121e673c4b1131134c71ccb70120f382a76503e3c8634\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781632861,\"kind\":1,\"tags\":[],\"content\":\"\\t\",\"sig\":\"ae96f0c6cbbfe83b80bb92684f9f2a9930ee556f379bc03c77e61149b42441fca670251c17aacbfb9e38f159d08707999e4ffc6bfd0c38b7fa213f5053acd308\"}" + }, + "kind": "post.verify_and_admit.valid" + }, + { + "expected": { + "ask_marker": null, + "classification": "photo_update", + "contract_id": "radroots.social.photo_update.v1", + "diagnostics": [], + "imeta": [ + { + "diagnostics": [], + "fallbacks": [], + "qualifies_photo": true, + "raw_fields": [ + "url https://cdn.example/harvest.webp", + "x aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "m image/webp", + "dim 1200x900", + "size 12345", + "alt Harvest" + ], + "unknown_fields": [] + } + ] + }, + "id": "signed_structural_photo", + "input": { + "event_json": "{\"id\":\"f06df29688089218b10424a85a070ecd9fe0e7143bf24622fdd5f031fbe00029\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781635400,\"kind\":1,\"tags\":[[\"imeta\",\"url https://cdn.example/harvest.webp\",\"x aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"m image/webp\",\"dim 1200x900\",\"size 12345\",\"alt Harvest\"]],\"content\":\"Harvest https://cdn.example/harvest.webp\",\"sig\":\"2f0863959b972f639d028c65d9ca0c2b62d5ad57530ad4c810e67941d1d50ab249488f570b9905095db2df18440aac399907dda5ca0e8289c49e625ce8b0b28b\"}" + }, + "kind": "post.verify_and_admit.valid" + }, + { + "expected": { + "ask_marker": null, + "classification": "photo_update", + "contract_id": "radroots.social.photo_update.v1", + "diagnostics": [], + "imeta": [ + { + "diagnostics": [], + "fallbacks": [ + "https://cache-one.example/harvest.webp", + "https://cache-two.example/harvest.webp" + ], + "qualifies_photo": true, + "raw_fields": [ + "url https://cdn.example/harvest.webp", + "x aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "m image/webp", + "dim 1200x900", + "size 12345", + "alt Harvest", + "fallback https://cache-one.example/harvest.webp", + "x-farm cultivar-strawberry", + "fallback https://cache-two.example/harvest.webp", + "future-field retained value" + ], + "unknown_fields": [ + "x-farm cultivar-strawberry", + "future-field retained value" + ] + } + ] + }, + "id": "signed_photo_preserves_fallbacks_and_unknown_fields", + "input": { + "event_json": "{\"id\":\"c0b5925be0ec524708ab73002b7c1c8aa4269cf1aa63fc7ca96f86d2b458e12b\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781635402,\"kind\":1,\"tags\":[[\"imeta\",\"url https://cdn.example/harvest.webp\",\"x aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"m image/webp\",\"dim 1200x900\",\"size 12345\",\"alt Harvest\",\"fallback https://cache-one.example/harvest.webp\",\"x-farm cultivar-strawberry\",\"fallback https://cache-two.example/harvest.webp\",\"future-field retained value\"]],\"content\":\"Harvest https://cdn.example/harvest.webp\",\"sig\":\"fb20b6c59a7e0fd41d2ffd5c238d580d1d4d0a1d44db0a44efa1a82eb9497b963ef1bef3dcdbb1463f6229db60ccfd3fd915ae098c14261d4b33e2478a93e451\"}" + }, + "kind": "post.verify_and_admit.valid" + }, + { + "expected": { + "ask_marker": [ + "t", + " RADROOTS-ASK " + ], + "classification": "ask", + "contract_id": "radroots.social.ask.v1", + "diagnostics": [ + "imeta_metadata_missing", + "imeta_hash_invalid" + ], + "imeta": [ + { + "diagnostics": [ + "imeta_metadata_missing", + "imeta_hash_invalid" + ], + "fallbacks": [], + "qualifies_photo": false, + "raw_fields": [ + "url https://cdn.example/leaf.webp", + "x malformed" + ], + "unknown_fields": [] + } + ] + }, + "id": "signed_normalized_ask_precedes_malformed_media", + "input": { + "event_json": "{\"id\":\"5d15a6d516260b6d6cf4a7f2a22fcd349c2bee302fda2c92fa1679996290a1ac\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781635220,\"kind\":1,\"tags\":[[\"t\",\" RADROOTS-ASK \"],[\"imeta\",\"url https://cdn.example/leaf.webp\",\"x malformed\"]],\"content\":\"Question https://cdn.example/leaf.webp\",\"sig\":\"538636b2d163d1a392f4c3fced234ba6af5c9b3f1fc66e3bdbbe374287cf4e62adec6369056a3f096be1b268451c2fb25040ae8e0d67188284c2da18832c20d1\"}" + }, + "kind": "post.verify_and_admit.valid" + }, + { + "expected": { + "ask_marker": null, + "classification": "update", + "contract_id": "radroots.social.update.v1", + "diagnostics": [ + "imeta_metadata_missing", + "imeta_hash_invalid" + ], + "imeta": [ + { + "diagnostics": [ + "imeta_metadata_missing", + "imeta_hash_invalid" + ], + "fallbacks": [], + "qualifies_photo": false, + "raw_fields": [ + "url https://cdn.example/leaf.webp", + "x malformed" + ], + "unknown_fields": [] + } + ] + }, + "id": "signed_malformed_imeta_is_update", + "input": { + "event_json": "{\"id\":\"522177f3d46d3cefb674037b50a7512338ed8a5170154dcc5bf2576a36179bcd\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781635401,\"kind\":1,\"tags\":[[\"imeta\",\"url https://cdn.example/leaf.webp\",\"x malformed\"]],\"content\":\"Leaf https://cdn.example/leaf.webp\",\"sig\":\"4b60c2bc2019797978bdb77ad1534a253e829eb1b0baff9be4f4e482ed771ba146b2a4885366163760fe44a6840c6ab31b777deffb9c5306a974a25cd7e5aefa\"}" + }, + "kind": "post.verify_and_admit.valid" + }, + { + "expected": { + "ask_marker": null, + "classification": "update", + "contract_id": "radroots.social.update.v1", + "diagnostics": [ + "imeta_singleton_duplicate" + ], + "imeta": [ + { + "diagnostics": [ + "imeta_singleton_duplicate" + ], + "fallbacks": [], + "qualifies_photo": false, + "raw_fields": [ + "url https://cdn.example/harvest.webp", + "x aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "x bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "m image/webp", + "dim 1200x900", + "size 12345", + "alt Harvest" + ], + "unknown_fields": [] + } + ] + }, + "id": "signed_duplicate_singleton_is_update", + "input": { + "event_json": "{\"id\":\"62e2fa87b57ed7ed453ffb28a72ea9ae73c7473561c4ec35504b9576e52da8cb\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781635403,\"kind\":1,\"tags\":[[\"imeta\",\"url https://cdn.example/harvest.webp\",\"x aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"x bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"m image/webp\",\"dim 1200x900\",\"size 12345\",\"alt Harvest\"]],\"content\":\"Harvest https://cdn.example/harvest.webp\",\"sig\":\"07e94ffa547a84aa3fc07649d45020e8e2057fb7b65783e87a62c04cb04a3a5a873cce66f019e18610e28f6393b4f5fcfcb378bc823063d6d3fffd06e40a4ad1\"}" + }, + "kind": "post.verify_and_admit.valid" + }, + { + "expected": { + "ask_marker": null, + "classification": "update", + "contract_id": "radroots.social.update.v1", + "diagnostics": [ + "imeta_metadata_missing", + "imeta_hash_invalid" + ], + "imeta": [ + { + "diagnostics": [], + "fallbacks": [], + "qualifies_photo": true, + "raw_fields": [ + "url https://cdn.example/harvest.webp", + "x aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "m image/webp", + "dim 1200x900", + "size 12345", + "alt Harvest" + ], + "unknown_fields": [] + }, + { + "diagnostics": [ + "imeta_metadata_missing", + "imeta_hash_invalid" + ], + "fallbacks": [], + "qualifies_photo": false, + "raw_fields": [ + "url https://cdn.example/leaf.webp", + "x malformed" + ], + "unknown_fields": [] + } + ] + }, + "id": "signed_mixed_imeta_is_update", + "input": { + "event_json": "{\"id\":\"9316dda070247a72979c3146845ff0e8e5309505c8e922276552546d65d420d8\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781635404,\"kind\":1,\"tags\":[[\"imeta\",\"url https://cdn.example/harvest.webp\",\"x aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"m image/webp\",\"dim 1200x900\",\"size 12345\",\"alt Harvest\"],[\"imeta\",\"url https://cdn.example/leaf.webp\",\"x malformed\"]],\"content\":\"Harvest https://cdn.example/harvest.webp and https://cdn.example/leaf.webp\",\"sig\":\"e6fe6f76ad608d82da58b0f5ea4bb43676a11b3785789aea52c5041b6c72a732330a0883f9e3f384862758809cf5150b8ebd9392ff1907869daa9081e01791bb\"}" + }, + "kind": "post.verify_and_admit.valid" + }, + { + "expected": { + "ask_marker": null, + "classification": "reply", + "contract_id": "radroots.social.post.v1", + "diagnostics": [], + "imeta": [] + }, + "id": "signed_reply_precedes_ask_and_media", + "input": { + "event_json": "{\"id\":\"b3c2d97629ba09946a241cf44702e5fafd98c059ab7fccfd654db0fb642c3b40\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781635405,\"kind\":1,\"tags\":[[\"e\",\"ask-event-id\"],[\"p\",\"bob\"],[\"t\",\"radroots-ask\"],[\"imeta\",\"url https://cdn.example/leaf.webp\",\"x malformed\"]],\"content\":\"Reply https://cdn.example/leaf.webp\",\"sig\":\"cfdb2b7e04f49c1c5794d81bdffc38f6393ae85b3432c1737545b5fd83f76748d5a82514736f550624e569c7a5f1ef2e6ec759396668222f3204554bb5cbc042\"}" + }, + "kind": "post.verify_and_admit.valid" + }, + { + "expected": { + "ask_marker": null, + "classification": "update", + "contract_id": "radroots.social.update.v1", + "diagnostics": [ + "ask_marker_shape" + ], + "imeta": [] + }, + "id": "signed_malformed_ask_marker_is_update", + "input": { + "event_json": "{\"id\":\"8f003700904a568e00c603605545f455766033fdabeaf76d7762c54c52a568ca\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781635406,\"kind\":1,\"tags\":[[\"t\",\"RADROOTS-ASK\",\"extra\"]],\"content\":\"Question\",\"sig\":\"74716474d09a6aaf9b0301af77602567b87ab0761f753d4225e2f72ee671d58f69b5dbba3cfe7be44a15e98768674224bf9a03423c4e47f1ee88d3b9d8a63329\"}" + }, + "kind": "post.verify_and_admit.valid" + }, + { + "expected": { + "error": "ask_marker_count" + }, + "id": "signed_duplicate_normalized_ask_marker", + "input": { + "event_json": "{\"id\":\"076e0147f35e244daf5578b67a6cf0747d09ddaa7563fb3d195cf06be3057b86\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781635460,\"kind\":1,\"tags\":[[\"t\",\"radroots-ask\"],[\"t\",\" RADROOTS-ASK \"]],\"content\":\"Question\",\"sig\":\"873e2e9f6aa4443c83e51866bc87f0ed4a0388a37187a155c03104d6ad551a2f2017cd841855796a73f89cdf7ed0910946f8a7fa8fa1e678ced8b3865b95d55d\"}" + }, + "kind": "post.verify_and_admit.invalid" + }, + { + "expected": { + "error": "invalid_kind" + }, + "id": "signed_kind_20_is_not_photo_update", + "input": { + "event_json": "{\"id\":\"09e20ba068fcbfb682ba0a496c5bfaade0f2240cf2874f23af51125bb701f5b1\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781635580,\"kind\":20,\"tags\":[],\"content\":\"photo\",\"sig\":\"0aab6b82c08fa75db4b729b76a5fd2d1e726d103c436939a67888fe81bc21f93c875beedcf521e99a6cb0323382fb277be20e8982641f49dfa053ea54d165bb1\"}" + }, + "kind": "post.verify_and_admit.invalid" + }, + { + "expected": { + "error": "signature_invalid" + }, + "id": "signed_invalid_signature", + "input": { + "event_json": "{\"content\":\"Tamper signature\",\"created_at\":1781635600,\"id\":\"1b921b22caf6f648e9992773e1f680ffcb5b3e12d61cc33bc74a3d329dfdfa10\",\"kind\":1,\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"sig\":\"051c75db06b0a8b2383b947408b3f704990a74a1ccf0d7c35b438c88bb9ffda97604be8df3c677468814abb516b0a0d5e0dfbdb010d00fb2efb1d91c694a5b7f\",\"tags\":[]}" + }, + "kind": "post.verify_and_admit.invalid" + } + ] +} diff --git a/crates/event_codec/tests/post.rs b/crates/event_codec/tests/post.rs @@ -1,745 +1,270 @@ -mod common; - -use common::{AUTHOR, EVENT_ID, EVENT_SIG}; +use radroots_blossom::{ + RadrootsBlossomBlobDescriptor, RadrootsBlossomBlobUrl, RadrootsBlossomByteVerifiedDescriptor, + RadrootsBlossomMediaType, RadrootsBlossomSha256, +}; use radroots_event::{ - farm::RadrootsFarmRef, - kinds::{KIND_ARTICLE, KIND_COMMENT, KIND_FARM, KIND_POST}, - post::RadrootsPost, - social::{ - RadrootsSocialFarmAnchor, RadrootsSocialLocation, RadrootsSocialMediaDimensions, - RadrootsSocialMediaMetadata, RadrootsSocialMediaThumbnail, RadrootsSocialTarget, + RadrootsAuthoredImage, + post::{ + RADROOTS_ASK_MARKER_TAG_VALUE, RADROOTS_POST_ALT_MAX_BYTES, + RADROOTS_POST_CONTENT_MAX_BYTES, RADROOTS_POST_IMETA_MAX_COUNT, RadrootsAuthoredAsk, + RadrootsAuthoredPhotoUpdate, RadrootsAuthoredPostError, RadrootsAuthoredPostImage, + RadrootsAuthoredUpdate, RadrootsPostImageDimensions, post_image_media_type_is_valid, }, - tags::{TAG_A, TAG_G, TAG_IMETA, TAG_LOCATION, TAG_Q, TAG_T}, }; -use radroots_event_codec::error::{EventEncodeError, EventParseError}; -use radroots_event_codec::post::decode::{ - data_from_event, parsed_from_event, post_from_content, post_from_event, +use radroots_event_codec::post::authored::{ + authored_ask_to_wire_parts, authored_photo_update_to_wire_parts, authored_update_to_wire_parts, }; -use radroots_event_codec::post::encode::{post_build_tags, to_wire_parts, to_wire_parts_with_kind}; - -const QUOTE_ID: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; -const FARM_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAA"; -const ARTICLE_D_TAG: &str = "BBBBBBBBBBBBBBBBBBBBBA"; - -fn content_post() -> RadrootsPost { - RadrootsPost { - content: "field update".to_string(), - farm: None, - address_refs: None, - location: None, - topics: None, - quote_refs: None, - media: None, - } -} #[test] -fn post_to_wire_parts_requires_content() { - let post = RadrootsPost { - content: " ".to_string(), - farm: None, - address_refs: None, - location: None, - topics: None, - quote_refs: None, - media: None, - }; +fn authored_update_emits_only_bounded_nonblank_content() { + let update = RadrootsAuthoredUpdate::new("The first strawberries are ready.").unwrap(); + let wire = authored_update_to_wire_parts(&update); - let err = to_wire_parts(&post).unwrap_err(); - assert!(matches!( - err, - EventEncodeError::EmptyRequiredField("content") - )); + assert_eq!(wire.kind, 1); + assert_eq!(wire.content, update.content()); + assert!(wire.tags.is_empty()); + assert_eq!( + RadrootsAuthoredUpdate::new(" \t").unwrap_err().code(), + "post_content_missing" + ); } #[test] -fn post_to_wire_parts_sets_kind_and_content() { - let post = RadrootsPost { - content: "hello".to_string(), - farm: None, - address_refs: None, - location: None, - topics: None, - quote_refs: None, - media: None, - }; +fn authored_update_enforces_the_utf8_byte_limit() { + let maximum = "x".repeat(RADROOTS_POST_CONTENT_MAX_BYTES); + assert!(RadrootsAuthoredUpdate::new(maximum).is_ok()); - let parts = to_wire_parts(&post).unwrap(); - assert_eq!(parts.kind, KIND_POST); - assert_eq!(parts.content, "hello"); - assert!(parts.tags.is_empty()); + let over = "x".repeat(RADROOTS_POST_CONTENT_MAX_BYTES + 1); + assert_eq!( + RadrootsAuthoredUpdate::new(over).unwrap_err(), + RadrootsAuthoredPostError::ContentTooLarge { + max: RADROOTS_POST_CONTENT_MAX_BYTES, + actual: RADROOTS_POST_CONTENT_MAX_BYTES + 1, + } + ); } #[test] -fn post_to_wire_parts_with_kind_rejects_non_post_kind() { - let post = RadrootsPost { - content: "hello".to_string(), - farm: None, - address_refs: None, - location: None, - topics: None, - quote_refs: None, - media: None, - }; - - assert!(matches!( - to_wire_parts_with_kind(&post, KIND_ARTICLE), - Err(EventEncodeError::InvalidKind(KIND_ARTICLE)) - )); +fn authored_photo_emits_exact_nip92_order_and_repeatable_fallbacks() { + let image = authored_image(b"strawberries", "image/webp", "webp") + .try_with_fallback(fallback_url(b"strawberries", "cache-one.example", "webp")) + .unwrap() + .try_with_fallback(fallback_url(b"strawberries", "cache-two.example", "webp")) + .unwrap(); + let content = format!("Today's harvest {}", image.url()); + let photo = RadrootsAuthoredPhotoUpdate::new(content.clone(), vec![image]).unwrap(); + let wire = authored_photo_update_to_wire_parts(&photo); + + assert_eq!(wire.kind, 1); + assert_eq!(wire.content, content); + assert_eq!(wire.tags.len(), 1); + assert_eq!( + wire.tags[0] + .iter() + .map(|field| field.split_once(' ').map_or(field.as_str(), |part| part.0)) + .collect::<Vec<_>>(), + [ + "imeta", "url", "x", "m", "dim", "size", "alt", "fallback", "fallback" + ] + ); } #[test] -fn post_to_wire_parts_roundtrips_optional_social_tags() { - let post = RadrootsPost { - content: "field update".to_string(), - farm: Some(RadrootsSocialFarmAnchor { - farm: RadrootsFarmRef { - pubkey: "farm_pubkey".to_string(), - d_tag: FARM_D_TAG.to_string(), - }, - relays: Some(vec!["wss://farm-relay.example.test".to_string()]), - }), - address_refs: Some(vec![RadrootsSocialTarget::Address { - address: format!("30023:article_author:{ARTICLE_D_TAG}"), - author: Some("article_author".to_string()), - event_kind: Some(30023), - relays: Some(vec!["wss://article-relay.example.test".to_string()]), - }]), - location: Some(RadrootsSocialLocation { - name: Some("North field".to_string()), - geohash: Some("c23nb62w20st".to_string()), - }), - topics: Some(vec!["soil".to_string(), "cover-crops".to_string()]), - quote_refs: Some(vec![ - RadrootsSocialTarget::Event { - id: QUOTE_ID.to_string(), - author: None, - event_kind: None, - relays: Some(vec!["wss://quote-relay.example.test".to_string()]), - }, - RadrootsSocialTarget::Address { - address: format!("30023:quote_author:{ARTICLE_D_TAG}"), - author: Some("quote_author".to_string()), - event_kind: Some(30023), - relays: None, - }, - ]), - media: Some(vec![RadrootsSocialMediaMetadata { - imeta: Some(vec![vec![ - "url https://media.example.test/field.jpg".to_string(), - "m image/jpeg".to_string(), - format!("x {QUOTE_ID}"), - "dim 1200x800".to_string(), - "alt Field rows".to_string(), - "service https://media.example.test".to_string(), - ]]), - ..RadrootsSocialMediaMetadata::default() - }]), - }; - - let parts = to_wire_parts(&post).unwrap(); - assert_eq!(parts.kind, KIND_POST); - assert!(parts.tags.iter().any(|tag| { - tag.first().map(|value| value.as_str()) == Some(TAG_A) - && tag.get(1).map(|value| value.as_str()) - == Some("30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA") - })); - assert!(parts.tags.iter().any(|tag| { - tag.first().map(|value| value.as_str()) == Some(TAG_A) - && tag.get(1).map(|value| value.as_str()) - == Some("30023:article_author:BBBBBBBBBBBBBBBBBBBBBA") - })); - assert!(parts.tags.iter().any(|tag| { - tag.first().map(|value| value.as_str()) == Some(TAG_LOCATION) - && tag.get(1).map(|value| value.as_str()) == Some("North field") - })); - assert!(parts.tags.iter().any(|tag| { - tag.first().map(|value| value.as_str()) == Some(TAG_G) - && tag.get(1).map(|value| value.as_str()) == Some("c23nb62w20st") - })); - assert!(parts.tags.iter().any(|tag| { - tag.first().map(|value| value.as_str()) == Some(TAG_T) - && tag.get(1).map(|value| value.as_str()) == Some("soil") - })); - assert!(parts.tags.iter().any(|tag| { - tag.first().map(|value| value.as_str()) == Some(TAG_Q) - && tag.get(1).map(|value| value.as_str()) == Some(QUOTE_ID) - })); - assert!(parts.tags.iter().any(|tag| { - tag.first().map(|value| value.as_str()) == Some(TAG_IMETA) - && tag - .iter() - .any(|value| value == "url https://media.example.test/field.jpg") - })); +fn authored_ask_precedes_optional_media_with_one_exact_marker() { + let image = authored_image(b"leaf", "image/jpeg", "jpg"); + let ask = RadrootsAuthoredAsk::new( + format!("Is this leaf healthy? {}", image.url()), + vec![image], + ) + .unwrap(); + let wire = authored_ask_to_wire_parts(&ask); - let decoded = post_from_event(parts.kind, &parts.tags, &parts.content).unwrap(); - assert_eq!(decoded.content, "field update"); assert_eq!( - decoded.farm.as_ref().map(|farm| farm.farm.pubkey.as_str()), - Some("farm_pubkey") + wire.tags[0], + ["t".to_string(), RADROOTS_ASK_MARKER_TAG_VALUE.to_string()] ); - assert_eq!(decoded.address_refs.as_ref().map(Vec::len), Some(1)); + assert_eq!(wire.tags[1][0], "imeta"); +} + +#[test] +fn authored_photo_rejects_missing_and_duplicate_content_urls() { + let image = authored_image(b"leaf", "image/jpeg", "jpg"); assert_eq!( - decoded - .location - .as_ref() - .and_then(|location| location.name.as_deref()), - Some("North field") + RadrootsAuthoredPhotoUpdate::new("photo", Vec::new()).unwrap_err(), + RadrootsAuthoredPostError::ImageMissing ); - assert_eq!(decoded.topics.as_ref().map(Vec::len), Some(2)); - assert_eq!(decoded.quote_refs.as_ref().map(Vec::len), Some(2)); - let media = decoded.media.as_ref().expect("media"); assert_eq!( - media[0].url.as_deref(), - Some("https://media.example.test/field.jpg") + RadrootsAuthoredPhotoUpdate::new("photo", vec![image.clone()]) + .unwrap_err() + .code(), + "imeta_url_missing_from_content" ); - assert_eq!(media[0].mime_type.as_deref(), Some("image/jpeg")); + let content = image.url().to_string(); assert_eq!( - media[0].dimensions.as_ref().map(|value| value.width), - Some(1200) + RadrootsAuthoredPhotoUpdate::new(content, vec![image.clone(), image]).unwrap_err(), + RadrootsAuthoredPostError::DuplicateImageUrl ); - assert_eq!(media[0].alt.as_deref(), Some("Field rows")); - assert_eq!(media[0].services.as_ref().map(Vec::len), Some(1)); } #[test] -fn post_build_tags_covers_optional_social_encode_branches() { - let mut post = content_post(); - post.farm = Some(RadrootsSocialFarmAnchor { - farm: RadrootsFarmRef { - pubkey: "farm_pubkey".to_string(), - d_tag: FARM_D_TAG.to_string(), - }, - relays: Some(vec!["wss://farm-relay.example.test".to_string()]), - }); - post.address_refs = Some(vec![RadrootsSocialTarget::Address { - address: format!("30023:article_author:{ARTICLE_D_TAG}"), - author: None, - event_kind: None, - relays: Some(vec!["wss://article-relay.example.test".to_string()]), - }]); - post.quote_refs = Some(vec![ - RadrootsSocialTarget::Event { - id: QUOTE_ID.to_string(), - author: None, - event_kind: None, - relays: Some(vec!["wss://quote-relay.example.test".to_string()]), - }, - RadrootsSocialTarget::Address { - address: format!("30023:quote_author:{ARTICLE_D_TAG}"), - author: None, - event_kind: None, - relays: Some(vec!["wss://quote-address-relay.example.test".to_string()]), - }, - ]); - post.media = Some(vec![RadrootsSocialMediaMetadata { - thumbnails: Some(vec![RadrootsSocialMediaThumbnail { - url: "https://media.example.test/thumb.jpg".to_string(), - dimensions: Some(RadrootsSocialMediaDimensions { - width: 120, - height: 80, - }), - }]), - ..RadrootsSocialMediaMetadata::default() - }]); - - let tags = post_build_tags(&post).unwrap(); - assert!(tags.iter().any(|tag| { - tag.first().map(|value| value.as_str()) == Some(TAG_A) - && tag - .iter() - .any(|value| value == "wss://farm-relay.example.test") - })); - assert!(tags.iter().any(|tag| { - tag.first().map(|value| value.as_str()) == Some(TAG_A) - && tag - .iter() - .any(|value| value == "wss://article-relay.example.test") - })); - assert!(tags.iter().any(|tag| { - tag.first().map(|value| value.as_str()) == Some(TAG_Q) - && tag - .iter() - .any(|value| value == "wss://quote-relay.example.test") - })); - assert!(tags.iter().any(|tag| { - tag.first().map(|value| value.as_str()) == Some(TAG_Q) - && tag - .iter() - .any(|value| value == "wss://quote-address-relay.example.test") - })); - assert!(tags.iter().any(|tag| { - tag.first().map(|value| value.as_str()) == Some(TAG_IMETA) - && tag.iter().any(|value| value == "dim 120x80") - })); - - let mut no_relay_post = content_post(); - no_relay_post.farm = Some(RadrootsSocialFarmAnchor { - farm: RadrootsFarmRef { - pubkey: "farm_pubkey".to_string(), - d_tag: FARM_D_TAG.to_string(), - }, - relays: None, - }); - no_relay_post.address_refs = Some(vec![RadrootsSocialTarget::Address { - address: format!("30023:article_author:{ARTICLE_D_TAG}"), - author: None, - event_kind: None, - relays: None, - }]); - no_relay_post.quote_refs = Some(vec![RadrootsSocialTarget::Event { - id: QUOTE_ID.to_string(), - author: None, - event_kind: None, - relays: None, - }]); - no_relay_post.media = Some(vec![RadrootsSocialMediaMetadata { - thumbnails: Some(vec![RadrootsSocialMediaThumbnail { - url: "https://media.example.test/thumb-no-dim.jpg".to_string(), - dimensions: None, - }]), - ..RadrootsSocialMediaMetadata::default() - }]); +fn authored_photo_and_ask_enforce_the_imeta_count_limit() { + let image = authored_image(b"leaf", "image/jpeg", "jpg"); + let images = vec![image.clone(); RADROOTS_POST_IMETA_MAX_COUNT + 1]; + let expected = RadrootsAuthoredPostError::ImageCountExceeded { + max: RADROOTS_POST_IMETA_MAX_COUNT, + actual: RADROOTS_POST_IMETA_MAX_COUNT + 1, + }; - let tags = post_build_tags(&no_relay_post).unwrap(); - let farm_tag = tags - .iter() - .find(|tag| { - tag.first().map(String::as_str) == Some(TAG_A) - && tag.get(1).map(String::as_str) - == Some("30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA") - }) - .expect("farm tag"); - assert_eq!(farm_tag.len(), 2); - let address_tag = tags - .iter() - .find(|tag| { - tag.first().map(String::as_str) == Some(TAG_A) - && tag.get(1).map(String::as_str) - == Some("30023:article_author:BBBBBBBBBBBBBBBBBBBBBA") - }) - .expect("address tag"); - assert_eq!(address_tag.len(), 2); - let quote_tag = tags - .iter() - .find(|tag| tag.first().map(String::as_str) == Some(TAG_Q)) - .expect("quote tag"); - assert_eq!(quote_tag.len(), 2); - let imeta = tags - .iter() - .find(|tag| tag.first().map(String::as_str) == Some(TAG_IMETA)) - .expect("imeta tag"); - assert!( - imeta - .iter() - .any(|value| value == "thumb https://media.example.test/thumb-no-dim.jpg") + assert_eq!( + RadrootsAuthoredPhotoUpdate::new(image.url(), images.clone()).unwrap_err(), + expected + ); + assert_eq!( + RadrootsAuthoredAsk::new("Question", images).unwrap_err(), + expected ); - assert!(!imeta.iter().any(|value| value.starts_with("dim "))); -} - -#[test] -fn post_social_tags_reject_malformed_supported_structures() { - let mut post = content_post(); - post.address_refs = Some(vec![RadrootsSocialTarget::Event { - id: QUOTE_ID.to_string(), - author: None, - event_kind: None, - relays: None, - }]); - assert!(matches!( - post_build_tags(&post), - Err(EventEncodeError::InvalidField("address_refs")) - )); - - post.address_refs = Some(vec![RadrootsSocialTarget::Address { - address: "not-an-address".to_string(), - author: None, - event_kind: None, - relays: None, - }]); - assert!(matches!( - post_build_tags(&post), - Err(EventEncodeError::InvalidField("address_refs")) - )); - - post.address_refs = Some(vec![RadrootsSocialTarget::Address { - address: format!("30340:farm_pubkey:{FARM_D_TAG}"), - author: Some("farm_pubkey".to_string()), - event_kind: Some(30340), - relays: None, - }]); - assert!(matches!( - post_build_tags(&post), - Err(EventEncodeError::InvalidField("address_refs")) - )); - - post.address_refs = Some(vec![RadrootsSocialTarget::Address { - address: format!("30023:article_author:{ARTICLE_D_TAG}"), - author: Some("other_author".to_string()), - event_kind: Some(30023), - relays: None, - }]); - assert!(matches!( - post_build_tags(&post), - Err(EventEncodeError::InvalidField("address_refs")) - )); - - post.address_refs = Some(vec![RadrootsSocialTarget::Address { - address: format!("30023:article_author:{ARTICLE_D_TAG}"), - author: Some("article_author".to_string()), - event_kind: Some(30024), - relays: None, - }]); - assert!(matches!( - post_build_tags(&post), - Err(EventEncodeError::InvalidField("address_refs")) - )); - - post.address_refs = None; - post.farm = Some(RadrootsSocialFarmAnchor { - farm: RadrootsFarmRef { - pubkey: String::new(), - d_tag: FARM_D_TAG.to_string(), - }, - relays: None, - }); - assert!(matches!( - post_build_tags(&post), - Err(EventEncodeError::EmptyRequiredField("farm.pubkey")) - )); - - post.farm = Some(RadrootsSocialFarmAnchor { - farm: RadrootsFarmRef { - pubkey: "farm_pubkey".to_string(), - d_tag: String::new(), - }, - relays: None, - }); - assert!(matches!( - post_build_tags(&post), - Err(EventEncodeError::EmptyRequiredField("farm.d_tag")) - )); - - post.farm = Some(RadrootsSocialFarmAnchor { - farm: RadrootsFarmRef { - pubkey: "farm_pubkey".to_string(), - d_tag: "bad d".to_string(), - }, - relays: None, - }); - assert!(matches!( - post_build_tags(&post), - Err(EventEncodeError::InvalidField("farm")) - )); - - post.farm = None; - post.quote_refs = Some(vec![RadrootsSocialTarget::Event { - id: "not-hex".to_string(), - author: None, - event_kind: None, - relays: None, - }]); - assert!(matches!( - post_build_tags(&post), - Err(EventEncodeError::InvalidField("quote_refs")) - )); - - post.quote_refs = Some(vec![RadrootsSocialTarget::Address { - address: "not-an-address".to_string(), - author: None, - event_kind: None, - relays: None, - }]); - assert!(matches!( - post_build_tags(&post), - Err(EventEncodeError::InvalidField("quote_refs")) - )); - - post.quote_refs = Some(vec![RadrootsSocialTarget::Address { - address: format!("30023:quote_author:{ARTICLE_D_TAG}"), - author: None, - event_kind: Some(30024), - relays: None, - }]); - assert!(matches!( - post_build_tags(&post), - Err(EventEncodeError::InvalidField("quote_refs")) - )); - - post.quote_refs = Some(vec![RadrootsSocialTarget::External { - id: "https://example.test/object".to_string(), - external_kind: "web".to_string(), - hint: None, - }]); - assert!(matches!( - post_build_tags(&post), - Err(EventEncodeError::InvalidField("quote_refs")) - )); - - post.quote_refs = None; - post.media = Some(vec![RadrootsSocialMediaMetadata { - imeta: Some(vec![Vec::new()]), - ..RadrootsSocialMediaMetadata::default() - }]); - assert!(matches!( - post_build_tags(&post), - Err(EventEncodeError::InvalidField("imeta")) - )); - - post.media = Some(vec![RadrootsSocialMediaMetadata { - imeta: Some(vec![vec![" ".to_string()]]), - ..RadrootsSocialMediaMetadata::default() - }]); - assert!(matches!( - post_build_tags(&post), - Err(EventEncodeError::InvalidField("imeta")) - )); - - post.media = Some(vec![RadrootsSocialMediaMetadata { - thumbnails: Some(vec![RadrootsSocialMediaThumbnail { - url: " ".to_string(), - dimensions: None, - }]), - ..RadrootsSocialMediaMetadata::default() - }]); - assert!(matches!( - post_build_tags(&post), - Err(EventEncodeError::InvalidField("imeta")) - )); - - let err = post_from_event( - KIND_POST, - &[vec![TAG_IMETA.to_string(), "bad-imeta-entry".to_string()]], - "hello", - ) - .unwrap_err(); - assert!(matches!(err, EventParseError::InvalidTag(TAG_IMETA))); } #[test] -fn post_media_structured_fields_encode_and_decode_imeta() { - let mut post = content_post(); - post.topics = Some(vec![ - "soil".to_string(), - " ".to_string(), - "market".to_string(), - ]); - post.media = Some(vec![ - RadrootsSocialMediaMetadata::default(), - RadrootsSocialMediaMetadata { - url: Some("https://media.example.test/field.jpg".to_string()), - mime_type: Some("image/jpeg".to_string()), - sha256: Some(QUOTE_ID.to_string()), - original_sha256: Some(QUOTE_ID.to_string()), - size: Some(42), - dimensions: Some(RadrootsSocialMediaDimensions { - width: 1200, - height: 800, - }), - blurhash: Some("LEHV6nWB2yk8pyo0adR*.7kCMdnj".to_string()), - thumbnails: Some(vec![RadrootsSocialMediaThumbnail { - url: "https://media.example.test/thumb.jpg".to_string(), - dimensions: Some(RadrootsSocialMediaDimensions { - width: 120, - height: 80, - }), - }]), - image: Some("https://media.example.test/poster.jpg".to_string()), - summary: Some("Field row image".to_string()), - alt: Some("rows in field".to_string()), - fallback: Some("https://media.example.test/fallback.jpg".to_string()), - magnet: Some("magnet:?xt=urn:btih:fixture".to_string()), - content_hashes: Some(vec!["hash-a".to_string(), "hash-b".to_string()]), - services: Some(vec!["https://media.example.test".to_string()]), - imeta: None, - }, - ]); - - let parts = to_wire_parts(&post).unwrap(); - let topic_tags = parts - .tags - .iter() - .filter(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_T)) - .count(); - assert_eq!(topic_tags, 2); - - let imeta = parts - .tags - .iter() - .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_IMETA)) - .expect("imeta tag"); - for expected in [ - "url https://media.example.test/field.jpg", - "m image/jpeg", - "size 42", - "dim 1200x800", - "blurhash LEHV6nWB2yk8pyo0adR*.7kCMdnj", - "thumb https://media.example.test/thumb.jpg", - "dim 120x80", - "image https://media.example.test/poster.jpg", - "summary Field row image", - "alt rows in field", - "fallback https://media.example.test/fallback.jpg", - "magnet magnet:?xt=urn:btih:fixture", - "i hash-a", - "i hash-b", - "service https://media.example.test", - ] { - assert!(imeta.iter().any(|value| value == expected), "{expected}"); - } - - let decoded = post_from_event(parts.kind, &parts.tags, &parts.content).unwrap(); - let media = decoded.media.expect("media"); - assert_eq!(media.len(), 1); - assert_eq!(media[0].original_sha256.as_deref(), Some(QUOTE_ID)); - assert_eq!(media[0].size, Some(42)); - assert_eq!( - media[0].blurhash.as_deref(), - Some("LEHV6nWB2yk8pyo0adR*.7kCMdnj") - ); +fn authored_image_rejects_parameterized_mime_zero_dimensions_and_wrong_fallback_hash() { + let parameterized = RadrootsAuthoredImage::try_from(verified_descriptor( + b"leaf", + "image/webp; charset=binary", + "webp", + )) + .unwrap(); assert_eq!( - media[0].image.as_deref(), - Some("https://media.example.test/poster.jpg") + RadrootsAuthoredPostImage::new( + parameterized, + RadrootsPostImageDimensions::new(1, 1).unwrap(), + "Leaf", + ) + .unwrap_err() + .code(), + "imeta_mime_invalid" ); - assert_eq!(media[0].summary.as_deref(), Some("Field row image")); assert_eq!( - media[0].fallback.as_deref(), - Some("https://media.example.test/fallback.jpg") + RadrootsPostImageDimensions::new(0, 1).unwrap_err().code(), + "imeta_dimensions_invalid" ); + + let image = authored_image(b"leaf", "image/webp", "webp"); assert_eq!( - media[0].magnet.as_deref(), - Some("magnet:?xt=urn:btih:fixture") + image + .try_with_fallback(fallback_url(b"other", "cache.example", "webp")) + .unwrap_err() + .code(), + "imeta_fallback_hash_mismatch" ); - assert_eq!(media[0].content_hashes.as_ref().map(Vec::len), Some(2)); } #[test] -fn post_decode_rejects_more_invalid_imeta_shapes() { - for tags in [ - vec![TAG_IMETA.to_string()], - vec![TAG_IMETA.to_string(), " ".to_string()], - ] { - let err = post_from_event(KIND_POST, &[tags], "hello").unwrap_err(); - assert!(matches!(err, EventParseError::InvalidTag(TAG_IMETA))); - } - - for entry in ["url ", "size not-a-number", "dim bad", "dim 0x10"] { - let err = post_from_event( - KIND_POST, - &[vec![TAG_IMETA.to_string(), entry.to_string()]], - "hello", - ) - .unwrap_err(); - assert!(matches!( - err, - EventParseError::InvalidTag(TAG_IMETA) | EventParseError::InvalidNumber(TAG_IMETA, _) - )); - } +fn post_image_mime_profile_uses_canonical_parameter_free_media_types() { + assert!(post_image_media_type_is_valid("image/webp")); + assert!(post_image_media_type_is_valid("image/svg+xml")); + assert!(post_image_media_type_is_valid("image/vnd.microsoft.icon")); + assert!(!post_image_media_type_is_valid("IMAGE/WEBP")); + assert!(!post_image_media_type_is_valid("image/webp;quality=90")); + assert!(!post_image_media_type_is_valid("text/plain")); } #[test] -fn post_decode_handles_non_farm_address_refs_without_relays() { - let article = format!("30023:article_author:{ARTICLE_D_TAG}"); - let farm = format!("{KIND_FARM}:farm_pubkey:{FARM_D_TAG}"); - let decoded = post_from_event( - KIND_POST, - &[ - vec![TAG_A.to_string(), farm.clone()], - vec![TAG_A.to_string(), article.clone()], - ], - "address only", - ) - .unwrap(); +fn authored_image_rejects_zero_size_and_invalid_alt_text() { + let empty = + RadrootsAuthoredImage::try_from(verified_descriptor(b"", "image/webp", "webp")).unwrap(); + assert_eq!( + RadrootsAuthoredPostImage::new( + empty, + RadrootsPostImageDimensions::new(1, 1).unwrap(), + "Empty image", + ) + .unwrap_err(), + RadrootsAuthoredPostError::ImageSizeInvalid + ); - let anchor = decoded.farm.expect("farm anchor"); - assert_eq!(anchor.farm.d_tag, FARM_D_TAG); - assert_eq!(anchor.relays, None); - let refs = decoded.address_refs.expect("address refs"); - assert_eq!(refs.len(), 1); - match &refs[0] { - RadrootsSocialTarget::Address { - address, - author, - event_kind, - relays, - } => { - assert_eq!(address, &article); - assert_eq!(author.as_deref(), Some("article_author")); - assert_eq!(*event_kind, Some(30023)); - assert_eq!(relays, &None); - } - _ => panic!("expected address target"), - } -} + let blank_alt = + RadrootsAuthoredImage::try_from(verified_descriptor(b"leaf", "image/webp", "webp")) + .unwrap(); + assert_eq!( + RadrootsAuthoredPostImage::new( + blank_alt, + RadrootsPostImageDimensions::new(1, 1).unwrap(), + " \t", + ) + .unwrap_err(), + RadrootsAuthoredPostError::ImageAltInvalid + ); -#[test] -fn post_from_content_requires_kind_and_content() { - let err = post_from_content(KIND_COMMENT, "hello").unwrap_err(); - assert!(matches!( - err, - EventParseError::InvalidKind { - expected: "1", - got: KIND_COMMENT - } - )); + let maximum_alt = "a".repeat(RADROOTS_POST_ALT_MAX_BYTES); + let maximum = + RadrootsAuthoredImage::try_from(verified_descriptor(b"maximum", "image/webp", "webp")) + .unwrap(); + assert!( + RadrootsAuthoredPostImage::new( + maximum, + RadrootsPostImageDimensions::new(1, 1).unwrap(), + maximum_alt, + ) + .is_ok() + ); - let err = post_from_content(KIND_POST, " ").unwrap_err(); - assert!(matches!(err, EventParseError::InvalidTag("content"))); + let oversized_alt = "a".repeat(RADROOTS_POST_ALT_MAX_BYTES + 1); + let oversized = + RadrootsAuthoredImage::try_from(verified_descriptor(b"oversized", "image/webp", "webp")) + .unwrap(); + assert_eq!( + RadrootsAuthoredPostImage::new( + oversized, + RadrootsPostImageDimensions::new(1, 1).unwrap(), + oversized_alt, + ) + .unwrap_err(), + RadrootsAuthoredPostError::ImageAltTooLarge { + max: RADROOTS_POST_ALT_MAX_BYTES, + actual: RADROOTS_POST_ALT_MAX_BYTES + 1, + } + ); } -#[test] -fn post_metadata_and_index_from_event_roundtrip() { - let metadata = data_from_event( - "id".to_string(), - "author".to_string(), - 77, - KIND_POST, - "hello".to_string(), - Vec::new(), +fn authored_image(bytes: &[u8], media_type: &str, extension: &str) -> RadrootsAuthoredPostImage { + RadrootsAuthoredPostImage::new( + RadrootsAuthoredImage::try_from(verified_descriptor(bytes, media_type, extension)).unwrap(), + RadrootsPostImageDimensions::new(1200, 900).unwrap(), + "Harvest", ) - .unwrap(); - assert_eq!(metadata.id, "id"); - assert_eq!(metadata.author, "author"); - assert_eq!(metadata.published_at, 77); - assert_eq!(metadata.kind, KIND_POST); - assert_eq!(metadata.data.content, "hello"); + .unwrap() +} - let index = parsed_from_event( - EVENT_ID.to_string(), - AUTHOR.to_string(), - 77, - KIND_POST, - "hello".to_string(), - Vec::new(), - EVENT_SIG.to_string(), +fn verified_descriptor( + bytes: &[u8], + media_type: &str, + extension: &str, +) -> RadrootsBlossomByteVerifiedDescriptor { + let hash = RadrootsBlossomSha256::digest(bytes); + let media_type = RadrootsBlossomMediaType::parse(media_type).unwrap(); + RadrootsBlossomBlobDescriptor::new( + RadrootsBlossomBlobUrl::parse(&format!("https://media.example/{hash}.{extension}")) + .unwrap(), + hash, + bytes.len() as u64, + media_type.clone(), + 1_784_347_200, ) - .unwrap(); - assert_eq!(index.event.id_str(), EVENT_ID); - assert_eq!(index.event.author_str(), AUTHOR); - assert_eq!(index.event.created_at_u64(), 77); - assert_eq!(index.event.kind_u32(), KIND_POST); - assert_eq!(index.event.content(), "hello"); - assert_eq!(index.event.sig_str(), EVENT_SIG); - assert_eq!(index.data.data.content, "hello"); + .unwrap() + .approve_reference() + .unwrap() + .verify_bytes(bytes, &media_type) + .unwrap() } -#[test] -fn post_index_from_event_propagates_parse_errors() { - let err = parsed_from_event( - "id".to_string(), - "author".to_string(), - 77, - KIND_COMMENT, - "hello".to_string(), - Vec::new(), - "sig".to_string(), - ) - .unwrap_err(); - assert!(matches!( - err, - EventParseError::InvalidKind { - expected: "1", - got: KIND_COMMENT - } - )); +fn fallback_url( + bytes: &[u8], + host: &str, + extension: &str, +) -> radroots_blossom::RadrootsBlossomApprovedBlobUrl { + let hash = RadrootsBlossomSha256::digest(bytes); + RadrootsBlossomBlobUrl::parse(&format!("https://{host}/{hash}.{extension}")) + .unwrap() + .approve() + .unwrap() } diff --git a/crates/event_codec/tests/tag_builders.rs b/crates/event_codec/tests/tag_builders.rs @@ -34,7 +34,6 @@ use radroots_event::listing::{ use radroots_event::message::{RadrootsMessage, RadrootsMessageRecipient}; use radroots_event::message_file::RadrootsMessageFile; use radroots_event::plot::{RadrootsPlot, RadrootsPlotRef}; -use radroots_event::post::RadrootsPost; use radroots_event::reaction::RadrootsReaction; use radroots_event::resource_area::{ RadrootsResourceArea, RadrootsResourceAreaLocation, RadrootsResourceAreaRef, @@ -432,17 +431,6 @@ fn event_tag_builder_impls_build_tags_for_all_supported_types() { expiration: Some(1700000000), }; assert!(!gift_wrap.build_tags().unwrap().is_empty()); - - let post = RadrootsPost { - content: "hello".to_string(), - farm: None, - address_refs: None, - location: None, - topics: None, - quote_refs: None, - media: None, - }; - assert!(post.build_tags().unwrap().is_empty()); } #[test] diff --git a/crates/event_codec/tests/verified_post_conformance.rs b/crates/event_codec/tests/verified_post_conformance.rs @@ -0,0 +1,167 @@ +#![cfg(all(feature = "serde_json", feature = "nostr"))] + +use std::{borrow::Cow, fs, path::Path}; + +use radroots_event::{ + RadrootsEventEnvelope, RadrootsNip01EventWire, contract::identify_event_contract, +}; +use radroots_event_codec::post::{ + admission::verify_and_admit_post_event, + inbound::{RadrootsInboundPostProjection, RadrootsPostClassification, RadrootsPostDiagnostic}, +}; +use serde::Deserialize; +use serde_json::{Value, json}; + +const PACKAGED_VECTORS: &str = include_str!("fixtures/post_verified_profiles.v1.json"); +const WORKSPACE_VECTOR_PATH: &str = + "../../contracts/conformance/vectors/post/verified_profiles.v1.json"; +const WORKSPACE_CONTRACT_MARKER_PATH: &str = "../../contracts/manifest.toml"; + +#[derive(Debug, Deserialize)] +struct Suite { + suite: String, + contract_version: String, + vectors: Vec<Vector>, +} + +#[derive(Debug, Deserialize)] +struct Vector { + id: String, + kind: String, + input: Value, + expected: Value, +} + +#[test] +fn raw_signed_vectors_execute_against_verified_post_admission() { + let vectors = conformance_vectors(); + let suite: Suite = serde_json::from_str(&vectors).expect("verified post vectors must parse"); + assert_eq!(suite.suite, "post_profiles"); + assert_eq!(suite.contract_version, "1.0.0"); + assert!(!suite.vectors.is_empty()); + + for vector in &suite.vectors { + execute(vector); + } +} + +fn conformance_vectors() -> Cow<'static, str> { + let workspace_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(WORKSPACE_VECTOR_PATH); + match fs::read_to_string(&workspace_path) { + Ok(canonical) => { + assert_eq!( + canonical, + PACKAGED_VECTORS, + "packaged verified post vectors must match {}", + workspace_path.display() + ); + Cow::Owned(canonical) + } + Err(error) + if error.kind() == std::io::ErrorKind::NotFound + && !Path::new(env!("CARGO_MANIFEST_DIR")) + .join(WORKSPACE_CONTRACT_MARKER_PATH) + .is_file() => + { + Cow::Borrowed(PACKAGED_VECTORS) + } + Err(error) => panic!("failed to read {}: {error}", workspace_path.display()), + } +} + +fn execute(vector: &Vector) { + let envelope = canonical_envelope(input_str(vector, "event_json")); + match vector.kind.as_str() { + "post.verify_and_admit.valid" => { + let generic = identify_event_contract( + envelope.kind_u32(), + &envelope.tags_as_vec(), + envelope.content(), + ) + .expect("unsigned post identification remains available"); + assert_eq!(generic.id, "radroots.social.post.v1", "{}", vector.id); + + let admitted = verify_and_admit_post_event(envelope) + .unwrap_or_else(|error| panic!("{} failed: {error}", vector.id)); + assert_eq!( + admitted.contract().id, + expected_str(vector, "contract_id"), + "{}", + vector.id + ); + assert_eq!( + projection_value(admitted.projection()), + vector.expected, + "{}", + vector.id + ); + assert_eq!( + admitted.projection().classification().is_root_card(), + admitted.projection().classification() != RadrootsPostClassification::Reply, + "{}", + vector.id + ); + let (verified, projection) = admitted.into_parts(); + assert_eq!(verified.event().kind_u32(), 1); + assert_eq!(projection_value(&projection), vector.expected); + } + "post.verify_and_admit.invalid" => { + let error = verify_and_admit_post_event(envelope) + .expect_err("invalid signed post vector must fail"); + assert_eq!(error.code(), expected_str(vector, "error"), "{}", vector.id); + } + kind => panic!("{} uses unsupported vector kind {kind}", vector.id), + } +} + +fn projection_value(projection: &RadrootsInboundPostProjection) -> Value { + json!({ + "classification": classification_label(projection.classification()), + "contract_id": projection.classification().contract_id(), + "ask_marker": projection.ask_marker(), + "diagnostics": diagnostic_codes(projection.diagnostics()), + "imeta": projection.imeta().iter().map(|media| json!({ + "raw_fields": media.raw_fields(), + "fallbacks": media.fallbacks(), + "unknown_fields": media.unknown_fields(), + "diagnostics": diagnostic_codes(media.diagnostics()), + "qualifies_photo": media.qualifies_photo(), + })).collect::<Vec<_>>(), + }) +} + +fn classification_label(classification: RadrootsPostClassification) -> &'static str { + match classification { + RadrootsPostClassification::Reply => "reply", + RadrootsPostClassification::Update => "update", + RadrootsPostClassification::PhotoUpdate => "photo_update", + RadrootsPostClassification::Ask => "ask", + _ => "future", + } +} + +fn diagnostic_codes(diagnostics: &[RadrootsPostDiagnostic]) -> Vec<&'static str> { + diagnostics + .iter() + .map(|diagnostic| diagnostic.code()) + .collect() +} + +fn canonical_envelope(raw_json: &str) -> RadrootsEventEnvelope { + RadrootsNip01EventWire::parse_json(raw_json) + .expect("canonical raw event") + .into_envelope() + .expect("event envelope") +} + +fn input_str<'a>(vector: &'a Vector, field: &str) -> &'a str { + vector.input[field] + .as_str() + .unwrap_or_else(|| panic!("{} input.{field} must be a string", vector.id)) +} + +fn expected_str<'a>(vector: &'a Vector, field: &str) -> &'a str { + vector.expected[field] + .as_str() + .unwrap_or_else(|| panic!("{} expected.{field} must be a string", vector.id)) +} diff --git a/crates/net/Cargo.toml b/crates/net/Cargo.toml @@ -16,7 +16,7 @@ default = ["std"] std = ["serde/std"] rt = ["std", "dep:tokio"] nostr-client = [ - "std", + "rt", "dep:radroots_event", "dep:radroots_event_codec", "radroots_event/serde", diff --git a/crates/net/src/nostr_client/events/post.rs b/crates/net/src/nostr_client/events/post.rs @@ -1,26 +1,27 @@ use crate::error::{NetError, Result}; -use radroots_event::post::RadrootsPost; +use radroots_event::post::{RadrootsAuthoredUpdate, RadrootsPost}; use radroots_event_codec::parsed::RadrootsParsedData; use radroots_nostr::prelude::{ - radroots_nostr_build_post_event, radroots_nostr_build_post_reply_event, + radroots_nostr_build_post_reply_event, radroots_nostr_build_update_event, radroots_nostr_fetch_post_events, radroots_nostr_send_event, }; use crate::nostr_client::manager::NostrClientManager; impl NostrClientManager { - pub async fn publish_post_event(&self, content: String) -> Result<String> { - let builder = radroots_nostr_build_post_event(content); + pub async fn publish_update_event(&self, update: &RadrootsAuthoredUpdate) -> Result<String> { + let builder = + radroots_nostr_build_update_event(update).map_err(|e| NetError::Msg(e.to_string()))?; let out = radroots_nostr_send_event(&self.inner.client, builder) .await .map_err(|e| NetError::Msg(e.to_string()))?; Ok(out.val.to_string()) } - pub fn publish_post_event_blocking(&self, content: String) -> Result<String> { + pub fn publish_update_event_blocking(&self, update: RadrootsAuthoredUpdate) -> Result<String> { let rt = self.inner.rt.clone(); let this = self.clone(); - rt.block_on(async move { this.publish_post_event(content).await }) + rt.block_on(async move { this.publish_update_event(&update).await }) } pub async fn publish_post_reply_event( @@ -65,6 +66,8 @@ impl NostrClientManager { }) } + /// Fetches generic kind-1 compatibility projections without claiming + /// Radroots product-profile admission. pub async fn fetch_post_events( &self, limit: u16, diff --git a/crates/nostr/Cargo.toml b/crates/nostr/Cargo.toml @@ -51,4 +51,9 @@ serde_json = { workspace = true } thiserror = { workspace = true } [dev-dependencies] +radroots_blossom = { workspace = true, default-features = false, features = ["std"] } tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } + +[[test]] +name = "post_profile" +required-features = ["events"] diff --git a/crates/nostr/README b/crates/nostr/README @@ -19,6 +19,15 @@ verifies their `Authorization: Nostr` HTTP values. It does not publish these ephemeral authorization events to relays. Pure BUD-11 claim parsing and policy validation remain in `radroots_blossom`. +With the `events` feature, kind-1 root publication is available only through +typed Update, PhotoUpdate, and Ask builders backed by the strict +`radroots_event_codec` wire operations. The former free-form text-note post +builder is removed. Reply construction remains a separate compatibility +surface pending the dedicated strict NIP-10 contract; it is not used to author +root product cards. Media builders do not sign or publish and require the +owning runtime to prove successful BUD-02 upload completion first; the generic +net manager intentionally exposes no direct PhotoUpdate or media Ask publisher. + ## Portable relay-client lifecycle With the `client` feature, callers can subscribe and publish to selected relay diff --git a/crates/nostr/src/event_adapters.rs b/crates/nostr/src/event_adapters.rs @@ -13,6 +13,11 @@ use radroots_event_codec::profile::RadrootsProfileData; use crate::types::{RadrootsNostrEvent, RadrootsNostrMetadata}; #[cfg(feature = "events")] +/// Adapts an event through the compatibility-only legacy post projection. +/// +/// This helper discards tags and does not establish product profile admission. +/// Use `verify_and_admit_post_event` over `radroots_event_from_nostr` whenever +/// the caller needs Reply, Update, PhotoUpdate, or Ask classification. pub fn to_post_event_metadata(e: &RadrootsNostrEvent) -> RadrootsParsedData<RadrootsPost> { RadrootsParsedData::new( e.id.to_string(), diff --git a/crates/nostr/src/events/post.rs b/crates/nostr/src/events/post.rs @@ -4,13 +4,41 @@ use crate::types::{ RadrootsNostrPublicKey, RadrootsNostrTag, RadrootsNostrTimestamp, }; +#[cfg(feature = "events")] +use radroots_event::post::{ + RadrootsAuthoredAsk, RadrootsAuthoredPhotoUpdate, RadrootsAuthoredUpdate, +}; +#[cfg(feature = "events")] +use radroots_event::wire::RadrootsNip01EventWireParts; +#[cfg(feature = "events")] +use radroots_event_codec::post::authored::{ + authored_ask_to_wire_parts, authored_photo_update_to_wire_parts, authored_update_to_wire_parts, +}; + #[cfg(all(feature = "client", feature = "events"))] use crate::client::RadrootsNostrClient; #[cfg(all(feature = "client", feature = "events"))] use core::time::Duration; -pub fn radroots_nostr_build_post_event(content: impl Into<String>) -> RadrootsNostrEventBuilder { - RadrootsNostrEventBuilder::text_note(content) +#[cfg(feature = "events")] +pub fn radroots_nostr_build_update_event( + update: &RadrootsAuthoredUpdate, +) -> Result<RadrootsNostrEventBuilder, RadrootsNostrError> { + builder_from_wire_parts(authored_update_to_wire_parts(update)) +} + +#[cfg(feature = "events")] +pub fn radroots_nostr_build_photo_update_event( + photo: &RadrootsAuthoredPhotoUpdate, +) -> Result<RadrootsNostrEventBuilder, RadrootsNostrError> { + builder_from_wire_parts(authored_photo_update_to_wire_parts(photo)) +} + +#[cfg(feature = "events")] +pub fn radroots_nostr_build_ask_event( + ask: &RadrootsAuthoredAsk, +) -> Result<RadrootsNostrEventBuilder, RadrootsNostrError> { + builder_from_wire_parts(authored_ask_to_wire_parts(ask)) } pub fn radroots_nostr_post_events_filter( @@ -50,7 +78,19 @@ pub fn radroots_nostr_build_post_reply_event( Ok(RadrootsNostrEventBuilder::text_note(content).tags(tags)) } +#[cfg(feature = "events")] +fn builder_from_wire_parts( + parts: RadrootsNip01EventWireParts, +) -> Result<RadrootsNostrEventBuilder, RadrootsNostrError> { + crate::events::radroots_nostr_build_event(parts.kind, parts.content, parts.tags) +} + #[cfg(all(feature = "client", feature = "events"))] +/// Fetches generic kind-1 events through the compatibility post projection. +/// +/// The unmarked filter intentionally retains ordinary Nostr notes and replies. +/// This compatibility read discards tags and does not establish Radroots +/// product admission; product consumers must use the verified admission API. pub async fn radroots_nostr_fetch_post_events( client: &RadrootsNostrClient, limit: u16, diff --git a/crates/nostr/src/lib.rs b/crates/nostr/src/lib.rs @@ -69,10 +69,13 @@ pub mod prelude { pub use crate::events::{ jobs::{radroots_nostr_build_event_job_feedback, radroots_nostr_build_event_job_result}, - post::{ - radroots_nostr_build_post_event, radroots_nostr_build_post_reply_event, - radroots_nostr_post_events_filter, - }, + post::{radroots_nostr_build_post_reply_event, radroots_nostr_post_events_filter}, + }; + + #[cfg(feature = "events")] + pub use crate::events::post::{ + radroots_nostr_build_ask_event, radroots_nostr_build_photo_update_event, + radroots_nostr_build_update_event, }; #[cfg(feature = "events")] diff --git a/crates/nostr/tests/coverage.rs b/crates/nostr/tests/coverage.rs @@ -9,8 +9,7 @@ use radroots_nostr::events::jobs::{ radroots_nostr_build_event_job_feedback, radroots_nostr_build_event_job_result, }; use radroots_nostr::events::post::{ - radroots_nostr_build_post_event, radroots_nostr_build_post_reply_event, - radroots_nostr_post_events_filter, + radroots_nostr_build_post_reply_event, radroots_nostr_post_events_filter, }; use radroots_nostr::events::radroots_nostr_build_event; use radroots_nostr::filter::{ @@ -140,9 +139,6 @@ fn post_helpers_cover_success_and_error_paths() { let author_hex = parent.pubkey.to_hex(); let root_id_hex = parent.id.to_hex(); - let post_builder = radroots_nostr_build_post_event("hello"); - let _ = post_builder.build(keys.public_key()); - let _ = radroots_nostr_post_events_filter(None, None); let _ = radroots_nostr_post_events_filter(Some(10), Some(1_700_000_000)); diff --git a/crates/nostr/tests/post_profile.rs b/crates/nostr/tests/post_profile.rs @@ -0,0 +1,75 @@ +#[path = "../src/test_fixtures.rs"] +mod test_fixtures; + +use radroots_blossom::{ + RadrootsBlossomBlobDescriptor, RadrootsBlossomBlobUrl, RadrootsBlossomMediaType, + RadrootsBlossomSha256, +}; +use radroots_event::{ + RadrootsAuthoredImage, + post::{ + RadrootsAuthoredAsk, RadrootsAuthoredPhotoUpdate, RadrootsAuthoredPostImage, + RadrootsAuthoredUpdate, RadrootsPostImageDimensions, + }, +}; +use radroots_nostr::{ + events::post::{ + radroots_nostr_build_ask_event, radroots_nostr_build_photo_update_event, + radroots_nostr_build_update_event, + }, + types::RadrootsNostrPublicKey, +}; + +#[test] +fn typed_post_builders_preserve_strict_wire_profiles() { + let author = + RadrootsNostrPublicKey::from_hex(test_fixtures::FIXTURE_ALICE_PUBLIC_KEY_HEX).unwrap(); + let update = RadrootsAuthoredUpdate::new("Farm update").unwrap(); + let event = radroots_nostr_build_update_event(&update) + .unwrap() + .build(author); + assert_eq!(event.kind.as_u16(), 1); + assert!(event.tags.is_empty()); + + let image = authored_image(); + let photo = + RadrootsAuthoredPhotoUpdate::new(format!("Harvest {}", image.url()), vec![image.clone()]) + .unwrap(); + let event = radroots_nostr_build_photo_update_event(&photo) + .unwrap() + .build(author); + assert_eq!(event.tags.len(), 1); + assert_eq!(event.tags.iter().next().unwrap().as_slice()[0], "imeta"); + + let ask = + RadrootsAuthoredAsk::new(format!("Is this ready? {}", image.url()), vec![image]).unwrap(); + let event = radroots_nostr_build_ask_event(&ask).unwrap().build(author); + assert_eq!(event.tags.len(), 2); + let tags = event.tags.iter().collect::<Vec<_>>(); + assert_eq!(tags[0].as_slice(), ["t", "radroots-ask"]); + assert_eq!(tags[1].as_slice()[0], "imeta"); +} + +fn authored_image() -> RadrootsAuthoredPostImage { + let bytes = b"strawberries"; + let hash = RadrootsBlossomSha256::digest(bytes); + let media_type = RadrootsBlossomMediaType::parse("image/webp").unwrap(); + let descriptor = RadrootsBlossomBlobDescriptor::new( + RadrootsBlossomBlobUrl::parse(&format!("https://media.example/{hash}.webp")).unwrap(), + hash, + bytes.len() as u64, + media_type.clone(), + 1_784_347_200, + ) + .unwrap() + .approve_reference() + .unwrap() + .verify_bytes(bytes, &media_type) + .unwrap(); + RadrootsAuthoredPostImage::new( + RadrootsAuthoredImage::try_from(descriptor).unwrap(), + RadrootsPostImageDimensions::new(1200, 900).unwrap(), + "Harvest", + ) + .unwrap() +} diff --git a/tools/xtask/src/contract.rs b/tools/xtask/src/contract.rs @@ -28,7 +28,7 @@ const REPLICA_CONTRACT_NAME: &str = "radroots_replica_contract"; const REPLICA_TRANSFER_CONSTANT: &str = "RADROOTS_REPLICA_TRANSFER_VERSION"; const REPLICA_TRANSFER_VERSION: u32 = 2; const VENDORED_WORKSPACE_MEMBER_RELATIVE: &str = "crates/libsqlite3_sys_3_53_3"; -const CONFORMANCE_VECTOR_MIRRORS: [(&str, &str); 7] = [ +const CONFORMANCE_VECTOR_MIRRORS: [(&str, &str); 8] = [ ( "contracts/conformance/vectors/blossom/bud11_claims.v1.json", "crates/blossom/tests/fixtures/bud11_claims.v1.json", @@ -57,6 +57,10 @@ const CONFORMANCE_VECTOR_MIRRORS: [(&str, &str); 7] = [ "contracts/conformance/vectors/profile/verified_event.v1.json", "crates/event_codec/tests/fixtures/profile_verified_event.v1.json", ), + ( + "contracts/conformance/vectors/post/verified_profiles.v1.json", + "crates/event_codec/tests/fixtures/post_verified_profiles.v1.json", + ), ]; const KNOWLEDGE_MVP_SUPPORT_CONTRACT_IDS: [&str; 8] = [ "radroots.wiki.article.v1",