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:
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",