lib

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

commit dc867c0d5d86e90328d968e49773063736b367d9
parent 7088fc112295e5510995fea25df09db590a6c81c
Author: triesap <tyson@radroots.org>
Date:   Sat, 18 Jul 2026 19:48:43 +0000

events: close strict post authoring boundaries

- reserve Profile and root post authoring behind opaque typed policy
- enforce thread exclusion, exact MIME, tag, and wire resource bounds
- execute exact post vectors and harden SDK conversion
- align contract, release, signer, formatting, and Nix authority

Diffstat:
MCHANGELOG.md | 43+++++++++++++++++++++++++++++++++++++------
MCargo.lock | 4++++
Mbuild/nix/common.nix | 2+-
Mcontracts/conformance/vectors/knowledge/manifest_and_decode.v1.json | 3++-
Mcontracts/conformance/vectors/post/verified_profiles.v1.json | 433++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mcontracts/event_boundary_matrix.md | 16++++++++++------
Mcontracts/events/profile-metadata.md | 15+++++++++------
Mcontracts/events/social-events.md | 58+++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcontracts/knowledge/knowledge_event_contract_manifest.v2.json | 2+-
Mcontracts/knowledge/knowledge_event_contract_manifest.v2.sha256 | 2+-
Mcontracts/operations.toml | 30+++++++++++++++++++++++++++---
Mcontracts/releases/1.0.0-alpha.1.toml | 6++++++
Mcontracts/version.toml | 3+++
Mcrates/authority/src/authorization.rs | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/authority/src/error.rs | 26++++++++++++++++++++++++++
Mcrates/authority/src/local_signer.rs | 10+++++-----
Mcrates/authority/src/signer.rs | 8++++----
Mcrates/event/src/contract.rs | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mcrates/event/src/draft.rs | 298++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mcrates/event/src/envelope.rs | 40+++++++++++++++++++++++++++++++++++++++-
Mcrates/event/src/post.rs | 240+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/event/src/wire.rs | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/event_codec/src/post/admission.rs | 87++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mcrates/event_codec/src/post/authored.rs | 31++++---------------------------
Mcrates/event_codec/src/post/inbound.rs | 41++++++++++++++++++++++++++++++-----------
Mcrates/event_codec/tests/fixtures/post_verified_profiles.v1.json | 433++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mcrates/event_codec/tests/post.rs | 294++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/event_codec/tests/verified_post_conformance.rs | 375++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mcrates/event_codec/tests/wire.rs | 10+++++-----
Mcrates/event_store/Cargo.toml | 1+
Mcrates/event_store/src/store.rs | 32++++++++++++++++++++++++++------
Dcrates/net/src/nostr_client/events/custom.rs | 57---------------------------------------------------------
Acrates/net/src/nostr_client/events/fetch.rs | 30++++++++++++++++++++++++++++++
Mcrates/net/src/nostr_client/events/post.rs | 4++--
Mcrates/net/src/nostr_client/mod.rs | 2+-
Mcrates/nostr/Cargo.toml | 4+++-
Mcrates/nostr/README | 19+++++++++++++++----
Mcrates/nostr/src/blossom.rs | 17++++++++++-------
Mcrates/nostr/src/client.rs | 123++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/nostr/src/draft_signing.rs | 33++++++++++++++-------------------
Mcrates/nostr/src/error.rs | 3+++
Mcrates/nostr/src/event_adapters.rs | 3++-
Mcrates/nostr/src/event_convert.rs | 62+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/nostr/src/event_verify.rs | 6+++---
Mcrates/nostr/src/events/application_handler.rs | 9+++++----
Mcrates/nostr/src/events/jobs.rs | 29+++++++++++++++++++----------
Mcrates/nostr/src/events/mod.rs | 27+++++++++++++++++----------
Mcrates/nostr/src/events/post.rs | 56++++++++++++++++++++++++++++++++++++++++++++++++--------
Mcrates/nostr/src/lib.rs | 12+++++++-----
Mcrates/nostr/src/types.rs | 195++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/nostr/tests/blossom_conformance.rs | 6+++---
Mcrates/nostr/tests/coverage.rs | 88+++++++++++++++++++++++++++++++++----------------------------------------------
Acrates/nostr/tests/generic_builder_boundary.rs | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/nostr/tests/post_profile.rs | 26++++++++++++++++++++------
Mcrates/nostr_runtime/Cargo.toml | 1+
Mcrates/nostr_runtime/src/sink.rs | 5+++--
Mcrates/nostr_signer/README | 7+++++++
Mcrates/nostr_signer/src/backend.rs | 124+++++++++++++++++++++++--------------------------------------------------------
Mcrates/nostr_signer/src/nip46.rs | 32+++++++++++++++++++++++---------
Mcrates/nostrdb/Cargo.toml | 1+
Mcrates/nostrdb/src/nostrdb.rs | 27++++++++++++++-------------
Mcrates/nostrdb/src/runtime_adapter.rs | 9+++++----
Mcrates/outbox/src/store.rs | 107+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Mcrates/trade/Cargo.toml | 1+
Mcrates/trade/src/projection.rs | 29+++++++++++++++++++++++++----
Mcrates/transport_nostr/src/fetch.rs | 4+---
Mcrates/transport_nostr/src/publish.rs | 6+++---
Mcrates/transport_nostr/tests/transport.rs | 290+++++++++++++++++--------------------------------------------------------------
Mtools/xtask/src/contract.rs | 919++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
69 files changed, 4366 insertions(+), 887 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md @@ -21,8 +21,25 @@ publish policy both pass for the same source revision. - 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. + separates every `e`-tagged event as a thread-excluded candidate without a + Reply claim, and deterministically admits Ask, PhotoUpdate, or Update roots + while preserving malformed media diagnostics. +- Typed root posts now enter signing and client publication through an opaque + builder with no raw tag/content mutation. Generic builder direct signing and + client publication reject kind `0` plus unmarked kind `1` before signer + access; externally supplied unsigned events, NIP-46 signing, and signed-event + relay remain explicit low-level Nostr interoperability with no typed product + authoring claim. +- Frozen drafts now carry event-contract registry version `2`, revalidate all + persisted fields and the recomputed event id during deserialization and + signing, and enforce explicit `GenericDraft`, `TypedOnly`, and `ReadOnly` + authoring policies. +- Event wire and envelope admission now reject more than 4,096 aggregate tag + elements, and SDK-event conversion returns the typed envelope error instead + of panicking on relay-controlled oversized input. +- Strict authored posts enforce the 256 KiB compact signed-event ceiling after + exact JSON escaping, in addition to per-element and aggregate decoded tag + limits. - Workspace packages declare one governed version explicitly so mounted path consumers preserve it, and every internal root dependency requires that exact pre-release version. @@ -41,8 +58,10 @@ publish policy both pass for the same source revision. 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. + admission, thread-candidate exclusion, classifier precedence, tolerant + metadata retention, and stable rejection codes. Operation-owned vectors also + execute every typed post authoring, projection, and admission function and + compare complete deterministic outputs. ### Removed @@ -57,9 +76,14 @@ 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. +- `RadrootsNostrSignerBackend` no longer accepts a raw `nostr::EventBuilder`; + callers that implement standard external signer protocols must supply the + protocol's unsigned event explicitly. - 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. + the generic net custom publisher were removed. Product-root publication now + requires one of the strict authored Update, PhotoUpdate, or Ask states; the + generic protocol builder remains only behind a root-kind-`1` publication + guard and for non-product interoperability. ### Compatibility @@ -74,3 +98,10 @@ publish policy both pass for the same source revision. compatibility version instead of package SemVer. Existing backups stamped by `0.1.0-alpha.2` remain restorable because this release does not change their stored schema. +- Persisted frozen drafts with event-contract registry version `1` are rejected + and must be reconstructed against registry version `2`; strict Profile plus + typed and read-only post contracts can no longer be reconstructed as generic + drafts. +- `radroots_event_from_nostr` now returns `Result<RadrootsEventEnvelope, + RadrootsEventEnvelopeError>`; callers must handle hostile or oversized SDK + events explicitly. diff --git a/Cargo.lock b/Cargo.lock @@ -4355,6 +4355,7 @@ name = "radroots_event_store" version = "1.0.0-alpha.1" dependencies = [ "hex", + "nostr", "radroots_event", "radroots_nostr", "radroots_transport", @@ -4516,6 +4517,7 @@ name = "radroots_nostr_runtime" version = "1.0.0-alpha.1" dependencies = [ "futures", + "nostr", "radroots_nostr", "thiserror 1.0.69", "tokio", @@ -4547,6 +4549,7 @@ version = "1.0.0-alpha.1" dependencies = [ "futures", "hex", + "nostr", "nostrdb", "radroots_nostr", "radroots_nostr_runtime", @@ -4848,6 +4851,7 @@ dependencies = [ "dto_bindgen", "dto_bindgen_core", "hex", + "nostr", "radroots_authority", "radroots_core", "radroots_event", diff --git a/build/nix/common.nix b/build/nix/common.nix @@ -107,7 +107,7 @@ let ]; coreContractCargoArgs = lib.concatStringsSep " " (map (crate: "-p ${crate}") coreContractCrates) - + " --features radroots_event_codec/serde_json,radroots_event_codec/nostr,radroots_nostr/blossom"; + + " --features radroots_event_codec/serde_json,radroots_event_codec/nostr,radroots_nostr/blossom,radroots_nostr/client,radroots_nostr/codec,radroots_nostr/events"; craneLib = (crane.mkLib pkgs).overrideToolchain toolchains.stable; commonCraneArgs = { inherit version; diff --git a/contracts/conformance/vectors/knowledge/manifest_and_decode.v1.json b/contracts/conformance/vectors/knowledge/manifest_and_decode.v1.json @@ -6,10 +6,11 @@ "id": "knowledge_manifest_fields_valid_001", "kind": "knowledge.contract_manifest_json.valid", "input": { - "registry": "radroots_event_contract_registry_v1" + "registry": "radroots_event_contract_registry_v2" }, "expected": { "schema_version": 2, + "registry_version": 2, "required_fields": [ "schema_version", "registry_version", diff --git a/contracts/conformance/vectors/post/verified_profiles.v1.json b/contracts/conformance/vectors/post/verified_profiles.v1.json @@ -1,8 +1,142 @@ { - "contract_version": "1.0.0", "suite": "post_profiles", + "contract_version": "1.0.0", "vectors": [ { + "id": "authored_update_wire", + "kind": "social.update.build_authored_draft.valid", + "input": { + "content": "Farm update" + }, + "expected": { + "kind": 1, + "content": "Farm update", + "tags": [] + } + }, + { + "id": "authored_update_blank", + "kind": "social.update.build_authored_draft.invalid", + "input": { + "content": " \t" + }, + "expected": { + "error": "post_content_missing" + } + }, + { + "id": "authored_photo_update_wire", + "kind": "social.photo_update.build_authored_draft.valid", + "input": { + "content": "Harvest https://media.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp", + "images": [ + { + "bytes_utf8": "strawberries", + "url": "https://media.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp", + "media_type": "image/webp", + "uploaded": 1784347200, + "width": 1200, + "height": 900, + "alt": "Harvest", + "fallbacks": [ + "https://backup.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp" + ] + } + ] + }, + "expected": { + "kind": 1, + "content": "Harvest https://media.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp", + "tags": [ + [ + "imeta", + "url https://media.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp", + "x 1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276", + "m image/webp", + "dim 1200x900", + "size 12", + "alt Harvest", + "fallback https://backup.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp" + ] + ] + } + }, + { + "id": "authored_photo_update_mime_underscore", + "kind": "social.photo_update.build_authored_draft.invalid", + "input": { + "content": "Harvest https://media.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp", + "images": [ + { + "bytes_utf8": "strawberries", + "url": "https://media.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp", + "media_type": "image/p_ng", + "uploaded": 1784347200, + "width": 1200, + "height": 900, + "alt": "Harvest", + "fallbacks": [ + "https://backup.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp" + ] + } + ] + }, + "expected": { + "error": "imeta_mime_invalid" + } + }, + { + "id": "authored_ask_wire", + "kind": "social.ask.build_authored_draft.valid", + "input": { + "content": "When are carrots ready? https://media.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp", + "images": [ + { + "bytes_utf8": "strawberries", + "url": "https://media.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp", + "media_type": "image/webp", + "uploaded": 1784347200, + "width": 1200, + "height": 900, + "alt": "Harvest", + "fallbacks": [ + "https://backup.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp" + ] + } + ] + }, + "expected": { + "kind": 1, + "content": "When are carrots ready? https://media.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp", + "tags": [ + [ + "t", + "radroots-ask" + ], + [ + "imeta", + "url https://media.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp", + "x 1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276", + "m image/webp", + "dim 1200x900", + "size 12", + "alt Harvest", + "fallback https://backup.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp" + ] + ] + } + }, + { + "id": "authored_ask_blank", + "kind": "social.ask.build_authored_draft.invalid", + "input": { + "content": "\n" + }, + "expected": { + "error": "post_content_missing" + } + }, + { "expected": { "ask_marker": null, "classification": "update", @@ -10,11 +144,11 @@ "diagnostics": [], "imeta": [] }, - "id": "signed_update", + "id": "project_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" + "kind": "social.post.project_verified_event.valid" }, { "expected": { @@ -24,11 +158,11 @@ "diagnostics": [], "imeta": [] }, - "id": "signed_empty_inbound_update", + "id": "project_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" + "kind": "social.post.project_verified_event.valid" }, { "expected": { @@ -49,15 +183,24 @@ "size 12345", "alt Harvest" ], - "unknown_fields": [] + "unknown_fields": [], + "url": "https://cdn.example/harvest.webp", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "media_type": "image/webp", + "dimensions": { + "width": 1200, + "height": 900 + }, + "size": 12345, + "alt": "Harvest" } ] }, - "id": "signed_structural_photo", + "id": "project_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" + "kind": "social.post.project_verified_event.valid" }, { "expected": { @@ -88,15 +231,24 @@ "unknown_fields": [ "x-farm cultivar-strawberry", "future-field retained value" - ] + ], + "url": "https://cdn.example/harvest.webp", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "media_type": "image/webp", + "dimensions": { + "width": 1200, + "height": 900 + }, + "size": 12345, + "alt": "Harvest" } ] }, - "id": "signed_photo_preserves_fallbacks_and_unknown_fields", + "id": "project_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" + "kind": "social.post.project_verified_event.valid" }, { "expected": { @@ -122,15 +274,21 @@ "url https://cdn.example/leaf.webp", "x malformed" ], - "unknown_fields": [] + "unknown_fields": [], + "url": "https://cdn.example/leaf.webp", + "sha256": "malformed", + "media_type": null, + "dimensions": null, + "size": null, + "alt": null } ] }, - "id": "signed_normalized_ask_precedes_malformed_media", + "id": "project_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" + "kind": "social.post.project_verified_event.valid" }, { "expected": { @@ -153,15 +311,21 @@ "url https://cdn.example/leaf.webp", "x malformed" ], - "unknown_fields": [] + "unknown_fields": [], + "url": "https://cdn.example/leaf.webp", + "sha256": "malformed", + "media_type": null, + "dimensions": null, + "size": null, + "alt": null } ] }, - "id": "signed_malformed_imeta_is_update", + "id": "project_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" + "kind": "social.post.project_verified_event.valid" }, { "expected": { @@ -187,15 +351,24 @@ "size 12345", "alt Harvest" ], - "unknown_fields": [] + "unknown_fields": [], + "url": "https://cdn.example/harvest.webp", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "media_type": "image/webp", + "dimensions": { + "width": 1200, + "height": 900 + }, + "size": 12345, + "alt": "Harvest" } ] }, - "id": "signed_duplicate_singleton_is_update", + "id": "project_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" + "kind": "social.post.project_verified_event.valid" }, { "expected": { @@ -219,7 +392,16 @@ "size 12345", "alt Harvest" ], - "unknown_fields": [] + "unknown_fields": [], + "url": "https://cdn.example/harvest.webp", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "media_type": "image/webp", + "dimensions": { + "width": 1200, + "height": 900 + }, + "size": 12345, + "alt": "Harvest" }, { "diagnostics": [ @@ -232,29 +414,35 @@ "url https://cdn.example/leaf.webp", "x malformed" ], - "unknown_fields": [] + "unknown_fields": [], + "url": "https://cdn.example/leaf.webp", + "sha256": "malformed", + "media_type": null, + "dimensions": null, + "size": null, + "alt": null } ] }, - "id": "signed_mixed_imeta_is_update", + "id": "project_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" + "kind": "social.post.project_verified_event.valid" }, { "expected": { "ask_marker": null, - "classification": "reply", + "classification": "thread_excluded", "contract_id": "radroots.social.post.v1", "diagnostics": [], "imeta": [] }, - "id": "signed_reply_precedes_ask_and_media", + "id": "project_signed_thread_candidate_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" + "kind": "social.post.project_verified_event.valid" }, { "expected": { @@ -266,41 +454,216 @@ ], "imeta": [] }, - "id": "signed_malformed_ask_marker_is_update", + "id": "project_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" + "kind": "social.post.project_verified_event.valid" + }, + { + "expected": { + "error": "ask_marker_count" + }, + "id": "project_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": "social.post.project_verified_event.invalid" + }, + { + "expected": { + "error": "invalid_kind" + }, + "id": "project_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": "social.post.project_verified_event.invalid" + }, + { + "expected": { + "outcome": "root", + "contract_id": "radroots.social.update.v1", + "projection": { + "ask_marker": null, + "classification": "update", + "contract_id": "radroots.social.update.v1", + "diagnostics": [], + "imeta": [] + } + }, + "id": "admit_signed_update", + "input": { + "event_json": "{\"id\":\"fb3f42caf9db337a7f1c0d49cd8ba5191f08dc1c419ed0640f7ea48a924e3bf3\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781632860,\"kind\":1,\"tags\":[],\"content\":\"The first strawberries are ready.\",\"sig\":\"dba0a86fee54304c2b419742f186e74d7edca5fc7234c8aa294651de9bc2f16bf829d46f36ec759a767c4ccd1841a73243eae89afd5f6c89b2243491bfbb5f50\"}" + }, + "kind": "social.post.verify_and_admit_event.valid" + }, + { + "expected": { + "outcome": "root", + "contract_id": "radroots.social.photo_update.v1", + "projection": { + "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": [], + "url": "https://cdn.example/harvest.webp", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "media_type": "image/webp", + "dimensions": { + "width": 1200, + "height": 900 + }, + "size": 12345, + "alt": "Harvest" + } + ] + } + }, + "id": "admit_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": "social.post.verify_and_admit_event.valid" + }, + { + "expected": { + "outcome": "root", + "contract_id": "radroots.social.ask.v1", + "projection": { + "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": [], + "url": "https://cdn.example/leaf.webp", + "sha256": "malformed", + "media_type": null, + "dimensions": null, + "size": null, + "alt": null + } + ] + } + }, + "id": "admit_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": "social.post.verify_and_admit_event.valid" + }, + { + "expected": { + "outcome": "thread_excluded", + "projection": { + "ask_marker": null, + "classification": "thread_excluded", + "contract_id": "radroots.social.post.v1", + "diagnostics": [], + "imeta": [] + } + }, + "id": "admit_signed_thread_candidate_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": "social.post.verify_and_admit_event.valid" + }, + { + "expected": { + "outcome": "thread_excluded", + "projection": { + "ask_marker": null, + "classification": "thread_excluded", + "contract_id": "radroots.social.post.v1", + "diagnostics": [], + "imeta": [] + } + }, + "id": "admit_signed_empty_e_thread_candidate", + "input": { + "event_json": "{\"id\":\"c3cc3425ba033b045d48f78da004aa74018ce889f0918ad455e03f732cef09e5\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781635610,\"kind\":1,\"tags\":[[\"e\"]],\"content\":\"Empty e tag\",\"sig\":\"d45829da243b29287e2c270338835bad8634b1a1c4c5aa5ced8a4d4da72cc2adc4005573ad240a1964e0ad4d0584c2fc3f3cf9a5ec10b7792bb1f789762401a8\"}" + }, + "kind": "social.post.verify_and_admit_event.valid" + }, + { + "expected": { + "outcome": "thread_excluded", + "projection": { + "ask_marker": null, + "classification": "thread_excluded", + "contract_id": "radroots.social.post.v1", + "diagnostics": [], + "imeta": [] + } + }, + "id": "admit_signed_empty_e_value_thread_candidate", + "input": { + "event_json": "{\"id\":\"41a8d475f22365c0518fa7db97ee684a071fbe886b083050eb34bb66c2066933\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781635611,\"kind\":1,\"tags\":[[\"e\",\"\"]],\"content\":\"Empty e tag value\",\"sig\":\"ebf7c7ae45660bb48ff8c5113119ab359ffa698a6697018023a966edbd07cf3e6d586d4c9e4461a5d6fe2bf840291a081dd89912404eaa1e41e95787165bfbfd\"}" + }, + "kind": "social.post.verify_and_admit_event.valid" }, { "expected": { "error": "ask_marker_count" }, - "id": "signed_duplicate_normalized_ask_marker", + "id": "admit_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" + "kind": "social.post.verify_and_admit_event.invalid" }, { "expected": { "error": "invalid_kind" }, - "id": "signed_kind_20_is_not_photo_update", + "id": "admit_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" + "kind": "social.post.verify_and_admit_event.invalid" }, { "expected": { "error": "signature_invalid" }, - "id": "signed_invalid_signature", + "id": "admit_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" + "kind": "social.post.verify_and_admit_event.invalid" } ] } diff --git a/contracts/event_boundary_matrix.md b/contracts/event_boundary_matrix.md @@ -35,12 +35,16 @@ wire parts and require runtime signing and transport. 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 +`RadrootsSignatureVerifiedEvent`; any `e` tag excludes the event as a thread +candidate without asserting NIP-10 Reply validity, then root-card precedence is +Ask, PhotoUpdate, Update. The admission result keeps root product admission and +thread-excluded candidates in distinct public variants. 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. +matching. New root publication uses the strict authored types and a sealed +Nostr builder with no raw tag/content mutation; generic builder publication +rejects unmarked kind `1` before signing. The legacy mutable `RadrootsPost` +decoder is compatibility-only and has no authored encoder or tag-builder +implementation. ## Coverage matrix @@ -48,7 +52,7 @@ has no authored encoder or tag-builder implementation. | --- | --- | --- | --- | --- | | 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 | 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 | +| 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; any `e` tag produces a thread-excluded candidate without a Reply claim | | 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/profile-metadata.md b/contracts/events/profile-metadata.md @@ -38,15 +38,18 @@ The direct legacy `RadrootsProfile` codec, Profile-specific Nostr/network publish helpers, and replica Profile draft emission were removed in the `1.0.0-alpha.1` breaking contract. A replica Profile row is a lossy inbound projection and cannot prove author intent for a complete kind-`0` replacement. -New authored callers must use `profile.build_authored_draft`. Generic raw event -builders and send APIs remain protocol escape hatches; their ability to emit -kind `0` does not make them Profile operation authority. +New authored callers must use `profile.build_authored_draft`. The opaque +Radroots generic builder rejects kind `0` at direct-signing and client +publication boundaries, so it cannot substitute for Profile operation +authority. `RadrootsNostrClient` no longer dereferences implicitly to the upstream SDK client, so upstream Profile conveniences are not exposed through ordinary -method resolution. `from_inner` and `into_inner` remain explicit low-level -interoperability boundaries; once a caller takes that boundary, upstream APIs -are outside the Radroots authored-operation contract. +method resolution. `from_inner` and `into_inner`, externally supplied unsigned +events and NIP-46 signing, and already-signed event relay remain explicit +low-level interoperability boundaries. Their results carry no Radroots typed +product-authoring claim; once a caller takes one of those boundaries, upstream +protocol behavior is outside the Radroots authored-operation contract. Legacy `profile::decode`, Nostr Profile adapters/fetchers, the network Profile fetch methods, and replica Profile ingest remain read-side compatibility paths. diff --git a/contracts/events/social-events.md b/contracts/events/social-events.md @@ -87,8 +87,17 @@ 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. +canonical lowercase and matches `^image/[a-z0-9][a-z0-9.+-]*$` exactly; +dimensions are nonzero `u32` values; size is a nonzero `u64`; alt text is +non-whitespace and at most 4092 UTF-8 bytes. Authored events emit at most 4096 +tag elements; every element is at most 4096 bytes and all tag elements together +are at most 131072 bytes, including the Ask marker. The strict image state +retains the exact validated +`imeta` representation that the codec emits, so limit validation and encoding +cannot diverge. Authored posts also fit within the 262144-byte compact signed +event limit after exact JSON escaping, tag-array punctuation, and a worst-case +20-digit NIP-01 timestamp are counted; decoded content and tag limits do not +operate as independent escape hatches around that wire budget. Every authored primary image is a `RadrootsAuthoredImage` backed by an approved, byte-verified Blossom descriptor. Every authored fallback is an approved @@ -101,9 +110,15 @@ Ask is kind `1` and deterministically emits exactly 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 +tag, including an empty or malformed one, selects `ThreadExcluded` before Ask +or media inspection. This is an exclusion-only candidate classification and +does not claim that the event is a valid NIP-10 reply; strict NIP-10 parsing and +promotion remain separately owned. `verify_and_admit_post_event` returns +`RadrootsPostAdmissionOutcome`: only `Root(RadrootsAdmittedRootPostEvent)` +exposes an exact product contract, while +`ThreadExcluded(RadrootsThreadExcludedPostCandidate)` preserves the verified +event and exclusion projection. 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` @@ -118,6 +133,39 @@ 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. +The event-contract registry assigns an explicit authoring policy to every +contract. Strict Profile, Update, PhotoUpdate, and Ask contracts are +`TypedOnly`; `radroots.social.post.v1` is `ReadOnly`; ordinary generic-draft +contracts remain `GenericDraft`. `RadrootsEventDraft::new` therefore rejects +the strict Profile contract and all four governed kind-1 post contracts with +`contract_not_draft_authorable`. Serialized drafts record registry version `2` +and are accepted only after deserialization revalidates the registry version, +contract, kind, shape, policy, recomputed event id, and known fields. The +frozen-draft signing boundary repeats that validation, so stale version-`1` +drafts must be rebuilt. Typed root posts enter Nostr signing and client +publication only through an opaque post builder that exposes timestamp +selection and signing, but no raw tag/content mutation or public conversion to +the upstream builder. The opaque generic builder rejects kind `0` and unmarked +kind `1` at both direct signing and client publication before a signer is +consulted; `e`-tagged kind-1 builders remain a thread-compatibility surface and +cannot enter root-card admission. + +The signer backend's externally supplied unsigned-event operation and the +standard NIP-46 `sign_event` method are explicit low-level interoperability +boundaries. Their signed results prove Nostr cryptographic authorization only; +they do not confer a Radroots typed product-authoring claim and are not product +authoring entry points. Relaying an already signed event is likewise a generic +transport operation with no Radroots authoring claim. + +Each post operation owns exact valid and invalid case kinds in +`contracts/operations.toml`. The canonical and packaged conformance suites +execute every public Update, PhotoUpdate, and Ask authoring function, verified +projection, and admission function; they compare complete deterministic wire +parts or projections and enforce stable negative error codes. Xtask validation +pins the complete post-operation namespace, ownership metadata, public types, +and exact case-id-to-kind corpus; it rejects missing, duplicate, unclaimed, +mis-prefixed, or substituted cases. + `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 `K`/`k` kind metadata. Canonical encode and decode must reject ordinary kind `1` short text note diff --git a/contracts/knowledge/knowledge_event_contract_manifest.v2.json b/contracts/knowledge/knowledge_event_contract_manifest.v2.json @@ -1,6 +1,6 @@ { "schema_version": 2, - "registry_version": 1, + "registry_version": 2, "radroots_event_version": "1.0.0-alpha.1", "radroots_event_codec_version": "1.0.0-alpha.1", "contract_count": 11, diff --git a/contracts/knowledge/knowledge_event_contract_manifest.v2.sha256 b/contracts/knowledge/knowledge_event_contract_manifest.v2.sha256 @@ -1 +1 @@ -7e73cf5df737f6fd3cdb8c57fc198d8d3f1758585f9437b26972d908ec432f05 +19c3baa86b56f418d5aa8eefbd315b5685d54deb068ca29e96fd8dec8b0ef7e1 diff --git a/contracts/operations.toml b/contracts/operations.toml @@ -82,7 +82,9 @@ public = [ "RadrootsInboundPostImeta", "RadrootsInboundPostProjection", "RadrootsPostProjectionError", - "RadrootsAdmittedPostEvent", + "RadrootsAdmittedRootPostEvent", + "RadrootsThreadExcludedPostCandidate", + "RadrootsPostAdmissionOutcome", "RadrootsPostAdmissionError", "RadrootsComment", "RadrootsReaction", @@ -599,6 +601,10 @@ rust_types = [ [operations.social_update_build_authored_draft.conformance] vector = "contracts/conformance/vectors/post/verified_profiles.v1.json" +case_kinds = [ + "social.update.build_authored_draft.valid", + "social.update.build_authored_draft.invalid", +] [operations.social_photo_update_build_authored_draft] domain = "social" @@ -628,6 +634,10 @@ rust_types = [ [operations.social_photo_update_build_authored_draft.conformance] vector = "contracts/conformance/vectors/post/verified_profiles.v1.json" +case_kinds = [ + "social.photo_update.build_authored_draft.valid", + "social.photo_update.build_authored_draft.invalid", +] [operations.social_ask_build_authored_draft] domain = "social" @@ -653,6 +663,10 @@ rust_types = [ [operations.social_ask_build_authored_draft.conformance] vector = "contracts/conformance/vectors/post/verified_profiles.v1.json" +case_kinds = [ + "social.ask.build_authored_draft.valid", + "social.ask.build_authored_draft.invalid", +] [operations.social_post_project_verified_event] domain = "social" @@ -678,13 +692,17 @@ rust_types = [ [operations.social_post_project_verified_event.conformance] vector = "contracts/conformance/vectors/post/verified_profiles.v1.json" +case_kinds = [ + "social.post.project_verified_event.valid", + "social.post.project_verified_event.invalid", +] [operations.social_post_verify_and_admit_event] domain = "social" id = "social.post.verify_and_admit_event" stability = "beta" inputs = ["RadrootsEventEnvelope"] -outputs = ["RadrootsAdmittedPostEvent"] +outputs = ["RadrootsPostAdmissionOutcome"] error_class = "admission_error" deterministic = true signing = "nip01" @@ -698,14 +716,20 @@ rust_modules = [ ] rust_types = [ "radroots_event::RadrootsEventEnvelope", - "radroots_event_codec::post::admission::RadrootsAdmittedPostEvent", + "radroots_event_codec::post::admission::RadrootsAdmittedRootPostEvent", "radroots_event_codec::post::admission::RadrootsPostAdmissionError", + "radroots_event_codec::post::admission::RadrootsPostAdmissionOutcome", + "radroots_event_codec::post::admission::RadrootsThreadExcludedPostCandidate", "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" +case_kinds = [ + "social.post.verify_and_admit_event.valid", + "social.post.verify_and_admit_event.invalid", +] [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 @@ -94,11 +94,17 @@ id = "strict-kind-one-product-profiles" classification = "breaking" semver_impacts = [ "add_exported_type", + "add_exported_constant", + "add_exported_field", "add_enum_variant", "add_conformance_vector", + "remove_exported_type", "remove_exported_function", "remove_exported_module", "remove_exported_trait_impl", + "change_exported_function_signature", + "change_exported_enum_variant", + "change_exported_constant_value", "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/contracts/version.toml b/contracts/version.toml @@ -11,7 +11,9 @@ major_on = [ "remove_exported_constant", "remove_exported_trait_impl", "remove_public_cargo_feature", + "add_exported_field", "change_exported_field_type", + "change_exported_function_signature", "change_exported_enum_variant", "change_exported_constant_value", "change_exported_algorithm_behavior", @@ -19,6 +21,7 @@ major_on = [ ] minor_on = [ "add_exported_type", + "add_exported_constant", "add_public_foundation_crate", "add_optional_field", "add_enum_variant", diff --git a/crates/authority/src/authorization.rs b/crates/authority/src/authorization.rs @@ -79,8 +79,22 @@ pub fn sign_authorized_draft<S>( where S: RadrootsEventSigner + ?Sized, { + sign_authorized_draft_with_validator(actor, signer, draft, |draft| draft.validate_for_signing()) +} + +fn sign_authorized_draft_with_validator<S, V>( + actor: &RadrootsActorContext, + signer: &S, + draft: &RadrootsEventDraft, + validate_draft: V, +) -> Result<RadrootsSignedEvent, RadrootsAuthorityError> +where + S: RadrootsEventSigner + ?Sized, + V: FnOnce(&RadrootsEventDraft) -> Result<(), RadrootsDraftError>, +{ authorize_actor_for_draft(actor, draft)?; authorize_signer_for_draft(signer, draft)?; + validate_draft(draft).map_err(RadrootsAuthorityError::DraftValidation)?; let signed_event = signer.sign_frozen_draft(draft)?; validate_signed_event_matches_draft(&signed_event, draft)?; Ok(signed_event) @@ -155,6 +169,7 @@ fn authority_error_from_draft_validation(error: RadrootsDraftError) -> RadrootsA mod tests { use super::*; use crate::RadrootsSignerError; + use core::cell::Cell; use radroots_event::contract::{RadrootsActorRole, event_contract}; use radroots_event::ids::RadrootsPublicKey; use radroots_event::kinds::{KIND_LISTING, KIND_POST, KIND_TRADE_PROPOSAL}; @@ -277,6 +292,35 @@ mod tests { } } + struct CountingSigner { + pubkey: RadrootsPublicKey, + sign_invocations: Cell<usize>, + } + + impl CountingSigner { + fn new(pubkey: &str) -> Self { + Self { + pubkey: RadrootsPublicKey::parse(pubkey).expect("pubkey"), + sign_invocations: Cell::new(0), + } + } + } + + impl RadrootsEventSigner for CountingSigner { + fn pubkey(&self) -> &RadrootsPublicKey { + &self.pubkey + } + + fn sign_frozen_draft( + &self, + _draft: &RadrootsEventDraft, + ) -> Result<RadrootsSignedEvent, RadrootsSignerError> { + self.sign_invocations + .set(self.sign_invocations.get().saturating_add(1)); + Err(RadrootsSignerError::Rejected) + } + } + fn signed_event_from_draft(draft: &RadrootsEventDraft) -> RadrootsSignedEvent { signed_event_from_parts( draft.expected_pubkey_str().to_owned(), @@ -374,6 +418,33 @@ mod tests { } #[test] + fn draft_validation_failure_does_not_invoke_custom_signer() { + let pubkey = hex_64('a'); + let draft = listing_event_draft(pubkey.as_str()); + let actor = seller_actor(pubkey.as_str()); + let signer = CountingSigner::new(pubkey.as_str()); + + let error = sign_authorized_draft_with_validator(&actor, &signer, &draft, |_| { + Err(RadrootsDraftError::ContractRegistryVersionMismatch { + expected: 2, + actual: 1, + }) + }) + .expect_err("stale draft validation must fail"); + + assert!(matches!( + error, + RadrootsAuthorityError::DraftValidation( + RadrootsDraftError::ContractRegistryVersionMismatch { + expected: 2, + actual: 1 + } + ) + )); + assert_eq!(signer.sign_invocations.get(), 0); + } + + #[test] fn signer_explicit_id_mismatch_fails_before_authorized_signing() { let pubkey = hex_64('a'); let draft = listing_event_draft(pubkey.as_str()); diff --git a/crates/authority/src/error.rs b/crates/authority/src/error.rs @@ -48,6 +48,8 @@ pub enum RadrootsAuthorityError { signer_pubkey: String, }, + DraftValidation(radroots_event::draft::RadrootsDraftError), + SignedEventPubkeyMismatch { expected_pubkey: String, actual_pubkey: String, @@ -143,6 +145,7 @@ impl fmt::Display for RadrootsAuthorityError { f, "signer pubkey mismatch: expected {expected_pubkey}, got {signer_pubkey}" ), + Self::DraftValidation(error) => write!(f, "draft validation failed: {error}"), Self::SignedEventPubkeyMismatch { expected_pubkey, actual_pubkey, @@ -207,6 +210,7 @@ impl fmt::Display for RadrootsAuthorityError { impl std::error::Error for RadrootsAuthorityError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { + Self::DraftValidation(error) => Some(error), Self::Signer(error) => Some(error), _ => None, } @@ -401,4 +405,26 @@ mod tests { "signing failed: deterministic failure" ); } + + #[test] + fn draft_validation_error_preserves_typed_source() { + let authority_error = RadrootsAuthorityError::DraftValidation( + radroots_event::draft::RadrootsDraftError::ContractRegistryVersionMismatch { + expected: 2, + actual: 1, + }, + ); + + assert_eq!( + authority_error.to_string(), + "draft validation failed: event contract registry version mismatch: expected 2, got 1" + ); + assert_eq!( + authority_error + .source() + .expect("draft validation source") + .to_string(), + "event contract registry version mismatch: expected 2, got 1" + ); + } } diff --git a/crates/authority/src/local_signer.rs b/crates/authority/src/local_signer.rs @@ -39,7 +39,7 @@ impl RadrootsEventSigner for RadrootsLocalEventSigner { mod tests { use super::*; use radroots_event::RadrootsEventEnvelope; - use radroots_event::kinds::KIND_POST; + use radroots_event::kinds::KIND_GEOCHAT; use radroots_nostr::prelude::{ RadrootsNostrEventVerification, RadrootsNostrSecretKey, radroots_nostr_verify_event, }; @@ -55,10 +55,10 @@ mod tests { RadrootsNostrKeys::new(secret_key) } - fn post_draft() -> RadrootsEventDraft { + fn generic_draft() -> RadrootsEventDraft { RadrootsEventDraft::new( - "radroots.social.post.v1", - KIND_POST, + "radroots.social.geochat.v1", + KIND_GEOCHAT, 1_700_000_000, vec![vec!["t".to_owned(), "soil".to_owned()]], "hello", @@ -81,7 +81,7 @@ mod tests { #[test] fn local_signer_signs_and_verifies_frozen_drafts() { let signer = RadrootsLocalEventSigner::new(fixture_keys()).expect("signer"); - let draft = post_draft(); + let draft = generic_draft(); let signed = signer.sign_frozen_draft(&draft).expect("signed"); diff --git a/crates/authority/src/signer.rs b/crates/authority/src/signer.rs @@ -35,7 +35,7 @@ pub trait RadrootsEventSigner { #[cfg(test)] mod tests { use super::*; - use radroots_event::kinds::KIND_POST; + use radroots_event::kinds::KIND_GEOCHAT; fn hex_64(character: char) -> String { std::iter::repeat_n(character, 64).collect() @@ -47,8 +47,8 @@ mod tests { fn draft_for(pubkey: &str) -> RadrootsEventDraft { RadrootsEventDraft::new( - "radroots.social.post.v1", - KIND_POST, + "radroots.social.geochat.v1", + KIND_GEOCHAT, 1_700_000_000, vec![vec!["t".to_owned(), "soil".to_owned()]], "hello", @@ -176,7 +176,7 @@ mod tests { assert_eq!(signed.id_str(), draft.expected_event_id_str()); assert_eq!(signed.pubkey_str(), pubkey); - assert_eq!(signed.kind(), KIND_POST); + assert_eq!(signed.kind(), KIND_GEOCHAT); } #[test] diff --git a/crates/event/src/contract.rs b/crates/event/src/contract.rs @@ -18,7 +18,7 @@ use crate::{ }; use radroots_blossom::RadrootsBlossomBlobUrl; -pub const RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION: u32 = 1; +pub const RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION: u32 = 2; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum RadrootsEventClass { @@ -216,6 +216,17 @@ pub enum RadrootsEventDiscriminator { Composite(&'static [RadrootsEventDiscriminator]), } +/// Governs whether a contract may enter the generic frozen-draft boundary. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RadrootsEventAuthoringPolicy { + /// Generic contract validation is sufficient to construct a frozen draft. + GenericDraft, + /// Authoring requires a sealed typed API instead of generic draft parts. + TypedOnly, + /// The contract is an inbound/read boundary and cannot be authored. + ReadOnly, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum RadrootsContractMatchError { UnsupportedKind(u32), @@ -339,6 +350,7 @@ pub struct RadrootsEventContract { pub privacy: RadrootsEventPrivacy, pub author_role: RadrootsActorRole, pub content_schema: RadrootsContentSchema, + pub authoring_policy: RadrootsEventAuthoringPolicy, pub discriminator: RadrootsEventDiscriminator, pub tags: &'static [RadrootsTagContract], pub reducers: &'static [RadrootsReducer], @@ -1031,6 +1043,7 @@ macro_rules! event_contract_with_stability { privacy: $standard_privacy, author_role: $author_role, content_schema: $content_schema, + authoring_policy: RadrootsEventAuthoringPolicy::GenericDraft, discriminator: $discriminator, tags: $tags, reducers: $reducers, @@ -1038,6 +1051,40 @@ macro_rules! event_contract_with_stability { }; } +macro_rules! event_contract_with_authoring_policy { + ( + $id:literal, + $kind:expr, + $name:literal, + $payload_type:literal, + $class:expr, + $standard_privacy:expr, + $author_role:expr, + $content_schema:expr, + $authoring_policy:expr, + $discriminator:expr, + $tags:expr, + $reducers:expr $(,)? + ) => { + RadrootsEventContract { + authoring_policy: $authoring_policy, + ..event_contract!( + $id, + $kind, + $name, + $payload_type, + $class, + $standard_privacy, + $author_role, + $content_schema, + $discriminator, + $tags, + $reducers + ) + } + }; +} + macro_rules! event_contract { ( $id:literal, @@ -1852,7 +1899,7 @@ static ALL_KIND_CONTRACTS: &[RadrootsKindContract] = &[ ]; static ALL_EVENT_CONTRACTS: &[RadrootsEventContract] = &[ - event_contract!( + event_contract_with_authoring_policy!( "radroots.profile.metadata.v1", KIND_PROFILE, "Profile Metadata", @@ -1861,11 +1908,12 @@ static ALL_EVENT_CONTRACTS: &[RadrootsEventContract] = &[ RadrootsEventPrivacy::Public, RadrootsActorRole::Any, RadrootsContentSchema::JsonObject, + RadrootsEventAuthoringPolicy::TypedOnly, RadrootsEventDiscriminator::KindOnly, PROFILE_TAGS, PROFILE_REDUCERS ), - event_contract!( + event_contract_with_authoring_policy!( "radroots.social.post.v1", KIND_POST, "Short Text Note", @@ -1874,11 +1922,12 @@ static ALL_EVENT_CONTRACTS: &[RadrootsEventContract] = &[ RadrootsEventPrivacy::Public, RadrootsActorRole::Any, RadrootsContentSchema::PlainText, + RadrootsEventAuthoringPolicy::ReadOnly, RadrootsEventDiscriminator::KindOnly, NO_TAGS, SOCIAL_REDUCERS ), - event_contract!( + event_contract_with_authoring_policy!( "radroots.social.update.v1", KIND_POST, "Root Text Update", @@ -1887,11 +1936,12 @@ static ALL_EVENT_CONTRACTS: &[RadrootsEventContract] = &[ RadrootsEventPrivacy::Public, RadrootsActorRole::Any, RadrootsContentSchema::PlainText, + RadrootsEventAuthoringPolicy::TypedOnly, RadrootsEventDiscriminator::AdmissionOnly, NO_TAGS, SOCIAL_REDUCERS ), - event_contract!( + event_contract_with_authoring_policy!( "radroots.social.photo_update.v1", KIND_POST, "NIP-92 Photo Update", @@ -1900,11 +1950,12 @@ static ALL_EVENT_CONTRACTS: &[RadrootsEventContract] = &[ RadrootsEventPrivacy::Public, RadrootsActorRole::Any, RadrootsContentSchema::PlainText, + RadrootsEventAuthoringPolicy::TypedOnly, RadrootsEventDiscriminator::AdmissionOnly, PHOTO_UPDATE_TAGS, SOCIAL_REDUCERS ), - event_contract!( + event_contract_with_authoring_policy!( "radroots.social.ask.v1", KIND_POST, "Root Ask", @@ -1913,6 +1964,7 @@ static ALL_EVENT_CONTRACTS: &[RadrootsEventContract] = &[ RadrootsEventPrivacy::Public, RadrootsActorRole::Any, RadrootsContentSchema::PlainText, + RadrootsEventAuthoringPolicy::TypedOnly, RadrootsEventDiscriminator::AdmissionOnly, ASK_TAGS, SOCIAL_REDUCERS @@ -4484,6 +4536,7 @@ mod tests { privacy: RadrootsEventPrivacy::Public, author_role: RadrootsActorRole::Any, content_schema: RadrootsContentSchema::PlainText, + authoring_policy: RadrootsEventAuthoringPolicy::GenericDraft, discriminator: RadrootsEventDiscriminator::KindOnly, tags, reducers: SOCIAL_REDUCERS, @@ -5130,6 +5183,10 @@ mod tests { let generic = identify_event_contract(KIND_POST, &tags, "Question") .expect("unsigned kind-1 identification remains generic"); assert_eq!(generic.id, "radroots.social.post.v1"); + assert_eq!( + generic.authoring_policy, + RadrootsEventAuthoringPolicy::ReadOnly + ); for id in [ "radroots.social.update.v1", @@ -5142,10 +5199,27 @@ mod tests { Some(RadrootsContractFamily::Social) ); assert_eq!( + contract.authoring_policy, + RadrootsEventAuthoringPolicy::TypedOnly + ); + assert_eq!( validate_event_contract_parts(KIND_POST, &tags, "Question", id), Err(RadrootsContractValidationError::AdmissionRequired { contract_id: id }) ); } + + assert_eq!( + event_contract("radroots.profile.metadata.v1") + .expect("strict authored profile contract") + .authoring_policy, + RadrootsEventAuthoringPolicy::TypedOnly + ); + assert_eq!( + event_contract("radroots.social.geochat.v1") + .expect("generic-draft control contract") + .authoring_policy, + RadrootsEventAuthoringPolicy::GenericDraft + ); } #[test] diff --git a/crates/event/src/draft.rs b/crates/event/src/draft.rs @@ -7,7 +7,8 @@ use alloc::{borrow::ToOwned, string::String, vec::Vec}; use std::{borrow::ToOwned, string::String, vec::Vec}; use crate::contract::{ - RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION, RadrootsContractValidationError, event_contract, + RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION, RadrootsContractValidationError, + RadrootsEventAuthoringPolicy, RadrootsEventContract, event_contract, validate_event_contract_parts, }; use crate::ids::{ @@ -31,6 +32,17 @@ pub enum RadrootsDraftError { expected_kind: u32, actual_kind: u32, }, + ContractNotDraftAuthorable { + contract_id: String, + }, + ContractRegistryVersionMismatch { + expected: u32, + actual: u32, + }, + DraftExpectedEventIdMismatch { + expected_event_id: String, + actual_event_id: String, + }, ContractShape { contract_id: String, error: RadrootsContractValidationError, @@ -83,6 +95,21 @@ impl fmt::Display for RadrootsDraftError { f, "event contract `{contract_id}` expects kind {expected_kind}, got {actual_kind}" ), + Self::ContractNotDraftAuthorable { contract_id } => write!( + f, + "event contract `{contract_id}` is not authorable through generic frozen drafts" + ), + Self::ContractRegistryVersionMismatch { expected, actual } => write!( + f, + "event contract registry version mismatch: expected {expected}, got {actual}" + ), + Self::DraftExpectedEventIdMismatch { + expected_event_id, + actual_event_id, + } => write!( + f, + "frozen draft event ID mismatch: expected {expected_event_id}, got {actual_event_id}" + ), Self::ContractShape { contract_id, error } => write!( f, "event contract `{contract_id}` shape validation failed with code {}", @@ -175,10 +202,7 @@ impl From<RadrootsSignedEventError> for RadrootsDraftError { } } -#[cfg_attr( - any(feature = "serde", test), - derive(serde::Serialize, serde::Deserialize) -)] +#[cfg_attr(any(feature = "serde", test), derive(serde::Serialize))] #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsEventDraft { contract_id: String, @@ -212,6 +236,7 @@ impl RadrootsEventDraft { actual_kind: kind, }); } + ensure_generic_draft_authorable(contract)?; let expected_pubkey = RadrootsPublicKey::parse(expected_pubkey.as_ref())?; let content = content.into(); validate_event_contract_parts(kind, &tags, content.as_str(), contract.id).map_err( @@ -250,6 +275,53 @@ impl RadrootsEventDraft { )) } + /// Revalidates registry policy, contract shape, and the deterministic ID. + /// + /// Signing boundaries must call this even for a previously validated draft + /// so persisted data cannot bypass current registry authority. + pub fn validate_for_signing(&self) -> Result<(), RadrootsDraftError> { + if self.contract_registry_version != RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION { + return Err(RadrootsDraftError::ContractRegistryVersionMismatch { + expected: RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION, + actual: self.contract_registry_version, + }); + } + let contract = event_contract(self.contract_id()) + .ok_or_else(|| RadrootsDraftError::UnknownContract(self.contract_id().to_owned()))?; + if contract.kind != self.kind_u32() { + return Err(RadrootsDraftError::ContractKindMismatch { + contract_id: contract.id.to_owned(), + expected_kind: contract.kind, + actual_kind: self.kind_u32(), + }); + } + ensure_generic_draft_authorable(contract)?; + validate_event_contract_parts( + self.kind_u32(), + &self.tags_as_vec(), + self.content(), + contract.id, + ) + .map_err(|error| RadrootsDraftError::ContractShape { + contract_id: contract.id.to_owned(), + error, + })?; + let actual_event_id = compute_nip01_event_id_for_valid_pubkey( + self.expected_pubkey_str(), + self.created_at_u64(), + self.kind_u32(), + &self.tags_as_vec(), + self.content(), + ); + if actual_event_id != self.expected_event_id { + return Err(RadrootsDraftError::DraftExpectedEventIdMismatch { + expected_event_id: actual_event_id.as_str().to_owned(), + actual_event_id: self.expected_event_id_str().to_owned(), + }); + } + Ok(()) + } + #[inline] pub fn contract_id(&self) -> &str { self.contract_id.as_str() @@ -315,6 +387,66 @@ impl RadrootsEventDraft { } } +fn ensure_generic_draft_authorable( + contract: &RadrootsEventContract, +) -> Result<(), RadrootsDraftError> { + if contract.authoring_policy != RadrootsEventAuthoringPolicy::GenericDraft { + return Err(RadrootsDraftError::ContractNotDraftAuthorable { + contract_id: contract.id.to_owned(), + }); + } + Ok(()) +} + +#[cfg(any(feature = "serde", test))] +impl<'de> serde::Deserialize<'de> for RadrootsEventDraft { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + #[derive(serde::Deserialize)] + #[serde(deny_unknown_fields)] + struct DraftSerde { + contract_id: String, + contract_registry_version: u32, + kind: RadrootsEventKind, + created_at: RadrootsEventTimestamp, + tags: RadrootsEventTags, + content: String, + expected_pubkey: RadrootsPublicKey, + expected_event_id: RadrootsEventId, + } + + let value = DraftSerde::deserialize(deserializer)?; + if value.contract_registry_version != RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION { + return Err(serde::de::Error::custom( + RadrootsDraftError::ContractRegistryVersionMismatch { + expected: RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION, + actual: value.contract_registry_version, + }, + )); + } + let draft = Self::new( + value.contract_id, + value.kind.as_u32(), + value.created_at.as_u64(), + value.tags.to_vec(), + value.content, + value.expected_pubkey.as_str(), + ) + .map_err(serde::de::Error::custom)?; + if draft.expected_event_id != value.expected_event_id { + return Err(serde::de::Error::custom( + RadrootsDraftError::DraftExpectedEventIdMismatch { + expected_event_id: draft.expected_event_id_str().to_owned(), + actual_event_id: value.expected_event_id.as_str().to_owned(), + }, + )); + } + Ok(draft) + } +} + #[cfg_attr( any(feature = "serde", test), derive(serde::Serialize, serde::Deserialize) @@ -595,6 +727,7 @@ pub fn validate_signed_nostr_event_matches_draft( signed_event: &RadrootsSignedEvent, draft: &RadrootsEventDraft, ) -> Result<(), RadrootsDraftError> { + draft.validate_for_signing()?; if signed_event.pubkey_str() != draft.expected_pubkey_str() { return Err(RadrootsDraftError::SignedEventPubkeyMismatch { expected_pubkey: draft.expected_pubkey_str().to_owned(), @@ -728,7 +861,10 @@ fn nip01_event_id_preimage_for_valid_pubkey( #[cfg(test)] mod tests { use super::*; - use crate::kinds::{KIND_KNOWLEDGE_CLAIM, KIND_KNOWLEDGE_SOURCE, KIND_POST, KIND_PROFILE}; + use crate::kinds::{ + KIND_FARM_CRDT_CHANGE, KIND_GEOCHAT, KIND_KNOWLEDGE_CLAIM, KIND_KNOWLEDGE_SOURCE, + KIND_POST, KIND_PROFILE, + }; fn hex_64(character: char) -> String { core::iter::repeat_n(character, 64).collect() @@ -814,12 +950,12 @@ mod tests { RadrootsSignedEvent::from_wire_verified_id(wire, raw_json).expect("signed event") } - fn post_draft() -> RadrootsEventDraft { + fn generic_draft() -> RadrootsEventDraft { RadrootsEventDraft::new( - "radroots.social.post.v1", - KIND_POST, + "radroots.social.geochat.v1", + KIND_GEOCHAT, 1_700_000_000, - vec![vec!["t".to_owned(), "soil".to_owned()]], + Vec::new(), "hello", "a".repeat(64), ) @@ -833,13 +969,10 @@ mod tests { #[test] fn draft_computes_expected_event_id() { let draft = RadrootsEventDraft::new( - "radroots.social.post.v1", - KIND_POST, + "radroots.social.geochat.v1", + KIND_GEOCHAT, 1_700_000_000, - vec![ - vec!["t".to_owned(), "soil".to_owned()], - vec!["p".to_owned(), hex_64('b')], - ], + Vec::new(), "hello", hex_64('a'), ) @@ -847,11 +980,11 @@ mod tests { assert_eq!( draft.nip01_preimage().expect("preimage"), - "[0,\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",1700000000,1,[[\"t\",\"soil\"],[\"p\",\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]],\"hello\"]" + "[0,\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",1700000000,20000,[],\"hello\"]" ); assert_eq!( draft.expected_event_id_str(), - "59d2486ef5557e0e317127de55005f2863361ad4041277ae523a869f2294cf9c" + "07643222c33091b20114d5baf1a32288e808177eac7d87acb2c4f610363a2a7d" ); } @@ -923,8 +1056,8 @@ mod tests { )); let invalid_pubkey = RadrootsEventDraft::new( - "radroots.social.post.v1", - KIND_POST, + "radroots.social.geochat.v1", + KIND_GEOCHAT, 1, Vec::new(), "", @@ -935,6 +1068,88 @@ mod tests { } #[test] + fn draft_constructor_rejects_read_only_and_typed_only_contracts() { + for (contract_id, kind) in [ + ("radroots.profile.metadata.v1", KIND_PROFILE), + ("radroots.social.post.v1", KIND_POST), + ("radroots.social.update.v1", KIND_POST), + ("radroots.social.photo_update.v1", KIND_POST), + ("radroots.social.ask.v1", KIND_POST), + ] { + let error = + RadrootsEventDraft::new(contract_id, kind, 1, Vec::new(), "hello", hex_64('a')) + .expect_err("governed typed or read-only contract must reject generic drafts"); + assert_eq!( + error, + RadrootsDraftError::ContractNotDraftAuthorable { + contract_id: contract_id.to_owned(), + } + ); + } + } + + #[test] + fn draft_deserialization_revalidates_registry_policy_shape_and_event_id() { + let draft = generic_draft(); + let value = serde_json::to_value(&draft).expect("draft json"); + let decoded: RadrootsEventDraft = + serde_json::from_value(value.clone()).expect("validated roundtrip"); + assert_eq!(decoded, draft); + + let mut tampered = value.clone(); + tampered["contract_registry_version"] = serde_json::json!(1); + let error = serde_json::from_value::<RadrootsEventDraft>(tampered) + .expect_err("stale registry generation must fail"); + assert!(error.to_string().contains("registry version mismatch")); + + let mut tampered = value.clone(); + tampered["expected_event_id"] = serde_json::Value::String(hex_64('f')); + let error = serde_json::from_value::<RadrootsEventDraft>(tampered) + .expect_err("tampered deterministic ID must fail"); + assert!(error.to_string().contains("frozen draft event ID mismatch")); + + let mut tampered = value.clone(); + tampered["contract_id"] = serde_json::Value::String("missing".to_owned()); + let error = serde_json::from_value::<RadrootsEventDraft>(tampered) + .expect_err("unknown persisted contract must fail"); + assert!(error.to_string().contains("unknown event contract")); + + let mut tampered = value.clone(); + tampered["kind"] = serde_json::json!(KIND_PROFILE); + let error = serde_json::from_value::<RadrootsEventDraft>(tampered) + .expect_err("persisted contract kind mismatch must fail"); + assert!(error.to_string().contains("expects kind")); + + let mut tampered = value.clone(); + tampered["contract_id"] = serde_json::Value::String("radroots.social.post.v1".to_owned()); + tampered["kind"] = serde_json::json!(KIND_POST); + let error = serde_json::from_value::<RadrootsEventDraft>(tampered) + .expect_err("read-only persisted contract must fail"); + assert!(error.to_string().contains("not authorable")); + + let json_draft = RadrootsEventDraft::new( + "radroots.farm.crdt_change.v1", + KIND_FARM_CRDT_CHANGE, + 1, + Vec::new(), + "{}", + hex_64('a'), + ) + .expect("generic JSON draft"); + let mut tampered = serde_json::to_value(json_draft).expect("JSON draft value"); + tampered["content"] = serde_json::Value::String("not-json".to_owned()); + let error = serde_json::from_value::<RadrootsEventDraft>(tampered) + .expect_err("persisted contract shape mismatch must fail"); + assert!(error.to_string().contains("shape validation failed")); + + let mut tampered = value; + tampered["unexpected"] = serde_json::json!(true); + let error = serde_json::from_value::<RadrootsEventDraft>(tampered) + .expect_err("unknown persisted draft field must fail"); + assert!(error.to_string().contains("unknown field")); + } + + #[test] fn draft_constructor_rejects_contract_shape_errors() { let missing_contract = RadrootsEventDraft::new( "radroots.knowledge.claim.v1", @@ -1152,7 +1367,7 @@ mod tests { #[test] fn signed_event_validation_accepts_exact_draft_match() { - let draft = post_draft(); + let draft = generic_draft(); let signed = signed_event_for_draft(&draft); validate_signed_nostr_event_matches_draft(&signed, &draft).expect("valid signed event"); @@ -1160,7 +1375,7 @@ mod tests { #[test] fn signed_event_validation_rejects_draft_mismatches() { - let draft = post_draft(); + let draft = generic_draft(); let signed = RadrootsSignedEvent::from_wire_unchecked( unchecked_wire( @@ -1283,30 +1498,6 @@ mod tests { error, RadrootsDraftError::SignedEventContentMismatch { .. } )); - - let mut draft_value = serde_json::to_value(post_draft()).expect("draft json"); - draft_value["expected_event_id"] = serde_json::Value::String(hex_64('f')); - let draft: RadrootsEventDraft = - serde_json::from_value(draft_value).expect("tampered draft"); - let signed = RadrootsSignedEvent::from_wire_unchecked( - unchecked_wire( - draft.expected_event_id_str().to_string(), - draft.expected_pubkey_str().to_string(), - draft.created_at_u64(), - draft.kind_u32(), - draft.tags_as_vec(), - draft.content().to_string(), - hex_128('b'), - ), - "{}", - ) - .expect("unchecked signed event"); - let error = - validate_signed_nostr_event_matches_draft(&signed, &draft).expect_err("mismatch"); - assert!(matches!( - error, - RadrootsDraftError::SignedEventComputedIdMismatch { .. } - )); } #[test] @@ -1318,6 +1509,17 @@ mod tests { expected_kind: KIND_POST, actual_kind: KIND_PROFILE, }, + RadrootsDraftError::ContractNotDraftAuthorable { + contract_id: "radroots.social.post.v1".to_owned(), + }, + RadrootsDraftError::ContractRegistryVersionMismatch { + expected: RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION, + actual: 1, + }, + RadrootsDraftError::DraftExpectedEventIdMismatch { + expected_event_id: hex_64('a'), + actual_event_id: hex_64('b'), + }, RadrootsDraftError::ContractShape { contract_id: "radroots.knowledge.claim.v1".to_owned(), error: RadrootsContractValidationError::MissingTag { @@ -1394,8 +1596,8 @@ mod tests { #[test] #[cfg_attr(coverage_nightly, coverage(off))] fn draft_and_signed_event_accessors_expose_typed_state() { - let draft = post_draft(); - assert_eq!(draft.contract_id(), "radroots.social.post.v1"); + let draft = generic_draft(); + assert_eq!(draft.contract_id(), "radroots.social.geochat.v1"); assert_eq!( draft.contract_registry_version(), RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION diff --git a/crates/event/src/envelope.rs b/crates/event/src/envelope.rs @@ -11,7 +11,7 @@ use crate::ids::{ }; use crate::wire::{ DEFAULT_CONTENT_MAX_BYTES, DEFAULT_TAG_ELEMENT_MAX_BYTES, DEFAULT_TAG_MAX_COUNT, - DEFAULT_TAG_TOTAL_MAX_BYTES, RadrootsNip01EventWire, + DEFAULT_TAG_TOTAL_ELEMENT_MAX_COUNT, DEFAULT_TAG_TOTAL_MAX_BYTES, RadrootsNip01EventWire, }; use core::fmt; @@ -156,6 +156,10 @@ pub enum RadrootsEventEnvelopeError { max: usize, actual: usize, }, + TooManyTagElements { + max: usize, + actual: usize, + }, TagElementTooLarge { tag_index: usize, element_index: usize, @@ -203,6 +207,9 @@ impl fmt::Display for RadrootsEventEnvelopeError { Self::TooManyTags { max, actual } => { write!(f, "event envelope tag count {actual} exceeds {max}") } + Self::TooManyTagElements { max, actual } => { + write!(f, "event envelope tag element count {actual} exceeds {max}") + } Self::TagElementTooLarge { tag_index, element_index, @@ -226,6 +233,7 @@ impl std::error::Error for RadrootsEventEnvelopeError {} pub struct RadrootsEventEnvelopeLimits { pub max_content_bytes: usize, pub max_tag_count: usize, + pub max_total_tag_elements: usize, pub max_tag_element_bytes: usize, pub max_total_tag_bytes: usize, } @@ -235,6 +243,7 @@ impl Default for RadrootsEventEnvelopeLimits { Self { max_content_bytes: DEFAULT_CONTENT_MAX_BYTES, max_tag_count: DEFAULT_TAG_MAX_COUNT, + max_total_tag_elements: DEFAULT_TAG_TOTAL_ELEMENT_MAX_COUNT, max_tag_element_bytes: DEFAULT_TAG_ELEMENT_MAX_BYTES, max_total_tag_bytes: DEFAULT_TAG_TOTAL_MAX_BYTES, } @@ -255,6 +264,13 @@ impl RadrootsEventTag { limits: RadrootsEventEnvelopeLimits, ) -> Result<Self, RadrootsEventEnvelopeError> { validate_tag(index, &values)?; + let total_tag_elements = values.len(); + if total_tag_elements > limits.max_total_tag_elements { + return Err(RadrootsEventEnvelopeError::TooManyTagElements { + max: limits.max_total_tag_elements, + actual: total_tag_elements, + }); + } let total_bytes = validate_tag_elements(index, &values, limits)?; if total_bytes > limits.max_total_tag_bytes { return Err(RadrootsEventEnvelopeError::TagsTooLarge { @@ -316,6 +332,15 @@ impl RadrootsEventTags { actual: tag_count, }); } + let total_tag_elements = values + .iter() + .fold(0usize, |total, tag| total.saturating_add(tag.len())); + if total_tag_elements > limits.max_total_tag_elements { + return Err(RadrootsEventEnvelopeError::TooManyTagElements { + max: limits.max_total_tag_elements, + actual: total_tag_elements, + }); + } let mut tags = Vec::with_capacity(values.len()); let mut total_tag_bytes = 0usize; for (index, tag) in values.into_iter().enumerate() { @@ -718,6 +743,17 @@ mod tests { RadrootsEventEnvelope::new_with_limits( parts.clone(), RadrootsEventEnvelopeLimits { + max_total_tag_elements: 1, + ..RadrootsEventEnvelopeLimits::default() + } + ), + Err(RadrootsEventEnvelopeError::TooManyTagElements { max: 1, actual: 2 }) + ); + + assert_eq!( + RadrootsEventEnvelope::new_with_limits( + parts.clone(), + RadrootsEventEnvelopeLimits { max_tag_element_bytes: 3, ..RadrootsEventEnvelopeLimits::default() } @@ -754,6 +790,7 @@ mod tests { RadrootsEventEnvelopeLimits { max_content_bytes: 5, max_tag_count: 1, + max_total_tag_elements: 2, max_tag_element_bytes: 4, max_total_tag_bytes: 5, }, @@ -853,6 +890,7 @@ mod tests { RadrootsEventEnvelopeError::ControlCharacterTagKey { index: 1 }, RadrootsEventEnvelopeError::ContentTooLarge { max: 1, actual: 2 }, RadrootsEventEnvelopeError::TooManyTags { max: 1, actual: 2 }, + RadrootsEventEnvelopeError::TooManyTagElements { max: 1, actual: 2 }, RadrootsEventEnvelopeError::TagElementTooLarge { tag_index: 1, element_index: 2, diff --git a/crates/event/src/post.rs b/crates/event/src/post.rs @@ -1,8 +1,8 @@ #[cfg(not(feature = "std"))] -use alloc::{string::String, vec::Vec}; +use alloc::{format, string::String, vec::Vec}; use core::fmt; -use radroots_blossom::{RadrootsBlossomApprovedBlobUrl, RadrootsBlossomMediaType}; +use radroots_blossom::RadrootsBlossomApprovedBlobUrl; use url_nostd::Url; use crate::media::RadrootsAuthoredImage; @@ -10,13 +10,31 @@ use crate::social::{ RadrootsSocialFarmAnchor, RadrootsSocialLocation, RadrootsSocialMediaMetadata, RadrootsSocialTarget, }; +use crate::tags::TAG_IMETA; 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_POST_EVENT_WIRE_MAX_BYTES: usize = crate::wire::DEFAULT_RAW_JSON_MAX_BYTES; +pub const RADROOTS_POST_TAG_ELEMENT_MAX_BYTES: usize = crate::wire::DEFAULT_TAG_ELEMENT_MAX_BYTES; +pub const RADROOTS_POST_TAG_TOTAL_MAX_BYTES: usize = crate::wire::DEFAULT_TAG_TOTAL_MAX_BYTES; +pub const RADROOTS_POST_ALT_MAX_BYTES: usize = RADROOTS_POST_TAG_ELEMENT_MAX_BYTES - "alt ".len(); pub const RADROOTS_ASK_MARKER_TAG_KEY: &str = "t"; pub const RADROOTS_ASK_MARKER_TAG_VALUE: &str = "radroots-ask"; +const RADROOTS_ASK_MARKER_TAG_BYTES: usize = + RADROOTS_ASK_MARKER_TAG_KEY.len() + RADROOTS_ASK_MARKER_TAG_VALUE.len(); +const RADROOTS_POST_SIGNED_EVENT_FIXED_MAX_BYTES: usize = "{\"id\":\"".len() + + 64 + + "\",\"pubkey\":\"".len() + + 64 + + "\",\"created_at\":".len() + + 20 + + ",\"kind\":1,\"tags\":".len() + + ",\"content\":".len() + + ",\"sig\":\"".len() + + 128 + + "\"}".len(); + #[cfg_attr( any(feature = "serde", test), derive(serde::Serialize, serde::Deserialize) @@ -78,6 +96,9 @@ pub enum RadrootsAuthoredPostError { ImageAltInvalid, ImageAltTooLarge { max: usize, actual: usize }, ImageFallbackHashMismatch, + TagElementTooLarge { max: usize, actual: usize }, + TagBytesExceeded { max: usize, actual: usize }, + EventWireTooLarge { max: usize, actual: usize }, } impl RadrootsAuthoredPostError { @@ -95,6 +116,9 @@ impl RadrootsAuthoredPostError { Self::ImageAltInvalid => "imeta_alt_invalid", Self::ImageAltTooLarge { .. } => "imeta_alt_too_large", Self::ImageFallbackHashMismatch => "imeta_fallback_hash_mismatch", + Self::TagElementTooLarge { .. } => "post_tag_element_too_large", + Self::TagBytesExceeded { .. } => "post_tag_bytes_exceeded", + Self::EventWireTooLarge { .. } => "post_event_wire_too_large", } } } @@ -144,6 +168,22 @@ impl fmt::Display for RadrootsAuthoredPostError { Self::ImageFallbackHashMismatch => formatter.write_str( "authored post image fallback URL must contain the primary image digest", ), + Self::TagElementTooLarge { max, actual } => { + write!( + formatter, + "authored post tag element is {actual} bytes; max is {max}" + ) + } + Self::TagBytesExceeded { max, actual } => { + write!( + formatter, + "authored post tag bytes are {actual}; max is {max}" + ) + } + Self::EventWireTooLarge { max, actual } => write!( + formatter, + "authored post canonical signed event is at most {actual} bytes; max is {max}" + ), } } } @@ -187,6 +227,7 @@ pub struct RadrootsAuthoredPostImage { dimensions: RadrootsPostImageDimensions, alt: String, fallbacks: Vec<RadrootsBlossomApprovedBlobUrl>, + imeta_tag: Vec<String>, } impl RadrootsAuthoredPostImage { @@ -212,11 +253,14 @@ impl RadrootsAuthoredPostImage { actual: alt.len(), }); } + let fallbacks = Vec::new(); + let imeta_tag = derive_imeta_tag(&image, dimensions, &alt, &fallbacks)?; Ok(Self { image, dimensions, alt, - fallbacks: Vec::new(), + fallbacks, + imeta_tag, }) } @@ -227,7 +271,13 @@ impl RadrootsAuthoredPostImage { if fallback.as_blob_url().hash_path().hash() != self.image.descriptor().sha256() { return Err(RadrootsAuthoredPostError::ImageFallbackHashMismatch); } + let fallback_element = format!("fallback {fallback}"); + validate_tag_element(&fallback_element)?; + validate_tag_bytes( + imeta_tag_bytes(&self.imeta_tag).saturating_add(fallback_element.len()), + )?; self.fallbacks.push(fallback); + self.imeta_tag.push(fallback_element); Ok(self) } @@ -247,6 +297,11 @@ impl RadrootsAuthoredPostImage { &self.fallbacks } + /// Returns the exact validated NIP-92 `imeta` tag emitted for this image. + pub fn imeta_tag(&self) -> &[String] { + &self.imeta_tag + } + pub fn url(&self) -> &str { self.image.descriptor().url().as_str() } @@ -267,6 +322,7 @@ impl RadrootsAuthoredUpdate { pub fn new(content: impl Into<String>) -> Result<Self, RadrootsAuthoredPostError> { let content = content.into(); validate_authored_root_content(&content)?; + validate_post_event_wire_size(&content, false, &[])?; Ok(Self { content }) } @@ -289,7 +345,8 @@ impl RadrootsAuthoredPhotoUpdate { ) -> Result<Self, RadrootsAuthoredPostError> { let content = content.into(); validate_content_size(&content)?; - validate_authored_images(&content, &images)?; + validate_authored_images(&content, &images, 0)?; + validate_post_event_wire_size(&content, false, &images)?; Ok(Self { content, images }) } @@ -323,8 +380,9 @@ impl RadrootsAuthoredAsk { }); } if !images.is_empty() { - validate_authored_images(&content, &images)?; + validate_authored_images(&content, &images, RADROOTS_ASK_MARKER_TAG_BYTES)?; } + validate_post_event_wire_size(&content, true, &images)?; Ok(Self { content, images }) } @@ -358,6 +416,7 @@ fn validate_content_size(content: &str) -> Result<(), RadrootsAuthoredPostError> fn validate_authored_images( content: &str, images: &[RadrootsAuthoredPostImage], + initial_tag_bytes: usize, ) -> Result<(), RadrootsAuthoredPostError> { if images.is_empty() { return Err(RadrootsAuthoredPostError::ImageMissing); @@ -379,14 +438,143 @@ fn validate_authored_images( return Err(RadrootsAuthoredPostError::DuplicateImageUrl); } } + let total_tag_bytes = images.iter().fold(initial_tag_bytes, |total, image| { + total.saturating_add(imeta_tag_bytes(image.imeta_tag())) + }); + validate_tag_bytes(total_tag_bytes)?; Ok(()) } +fn derive_imeta_tag( + image: &RadrootsAuthoredImage, + dimensions: RadrootsPostImageDimensions, + alt: &str, + fallbacks: &[RadrootsBlossomApprovedBlobUrl], +) -> Result<Vec<String>, RadrootsAuthoredPostError> { + let descriptor = image.descriptor(); + let mut tag = Vec::with_capacity(7 + fallbacks.len()); + tag.push(TAG_IMETA.into()); + 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 {alt}")); + tag.extend( + fallbacks + .iter() + .map(|fallback| format!("fallback {fallback}")), + ); + for element in &tag { + validate_tag_element(element)?; + } + validate_tag_bytes(imeta_tag_bytes(&tag))?; + Ok(tag) +} + +fn validate_tag_element(element: &str) -> Result<(), RadrootsAuthoredPostError> { + if element.len() > RADROOTS_POST_TAG_ELEMENT_MAX_BYTES { + return Err(RadrootsAuthoredPostError::TagElementTooLarge { + max: RADROOTS_POST_TAG_ELEMENT_MAX_BYTES, + actual: element.len(), + }); + } + Ok(()) +} + +fn validate_tag_bytes(actual: usize) -> Result<(), RadrootsAuthoredPostError> { + if actual > RADROOTS_POST_TAG_TOTAL_MAX_BYTES { + return Err(RadrootsAuthoredPostError::TagBytesExceeded { + max: RADROOTS_POST_TAG_TOTAL_MAX_BYTES, + actual, + }); + } + Ok(()) +} + +fn imeta_tag_bytes(tag: &[String]) -> usize { + tag.iter() + .fold(0, |total, element| total.saturating_add(element.len())) +} + +fn validate_post_event_wire_size( + content: &str, + ask_marker: bool, + images: &[RadrootsAuthoredPostImage], +) -> Result<(), RadrootsAuthoredPostError> { + let mut tags_json_bytes = 2usize; + let mut tag_count = 0usize; + if ask_marker { + add_tag_json_bytes( + &mut tags_json_bytes, + &mut tag_count, + [RADROOTS_ASK_MARKER_TAG_KEY, RADROOTS_ASK_MARKER_TAG_VALUE], + ); + } + for image in images { + add_tag_json_bytes( + &mut tags_json_bytes, + &mut tag_count, + image.imeta_tag().iter().map(String::as_str), + ); + } + let actual = RADROOTS_POST_SIGNED_EVENT_FIXED_MAX_BYTES + .saturating_add(tags_json_bytes) + .saturating_add(canonical_json_string_bytes(content)); + if actual > RADROOTS_POST_EVENT_WIRE_MAX_BYTES { + return Err(RadrootsAuthoredPostError::EventWireTooLarge { + max: RADROOTS_POST_EVENT_WIRE_MAX_BYTES, + actual, + }); + } + Ok(()) +} + +fn add_tag_json_bytes<'a>( + total: &mut usize, + tag_count: &mut usize, + elements: impl IntoIterator<Item = &'a str>, +) { + if *tag_count > 0 { + *total = total.saturating_add(1); + } + *total = total.saturating_add(2); + let mut element_count = 0usize; + for element in elements { + if element_count > 0 { + *total = total.saturating_add(1); + } + *total = total.saturating_add(canonical_json_string_bytes(element)); + element_count = element_count.saturating_add(1); + } + *tag_count = tag_count.saturating_add(1); +} + +fn canonical_json_string_bytes(value: &str) -> usize { + value.chars().fold(2usize, |total, character| { + total.saturating_add(match character { + '"' | '\\' | '\u{0008}' | '\t' | '\n' | '\u{000c}' | '\r' => 2, + '\u{0000}'..='\u{001f}' => 6, + _ => character.len_utf8(), + }) + }) +} + 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) + let Some(subtype) = value.strip_prefix("image/") else { + return false; + }; + let mut bytes = subtype.bytes(); + bytes + .next() + .is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) + && bytes.all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'+' | b'-') + }) } /// Returns whether an inbound media reference is a structural HTTP(S) URL. @@ -458,4 +646,36 @@ mod tests { let json = serde_json::to_string(&post).expect("json"); assert_eq!(json, r#"{"content":"farm update"}"#); } + + #[test] + fn post_image_media_type_uses_exact_product_grammar() { + for valid in [ + "image/png", + "image/1", + "image/vnd.radroots+png", + "image/x-radroots.photo", + ] { + assert!(post_image_media_type_is_valid(valid), "{valid}"); + } + + for invalid in [ + "image/", + "image/PNG", + "IMAGE/png", + "image/_png", + "image/p_ng", + "image/p!ng", + "image/p#ng", + "image/p$ng", + "image/p&ng", + "image/p^ng", + "image/p%ng", + "image/p*ng", + "image/p'ng", + "image/png;quality=90", + "text/png", + ] { + assert!(!post_image_media_type_is_valid(invalid), "{invalid}"); + } + } } diff --git a/crates/event/src/wire.rs b/crates/event/src/wire.rs @@ -21,6 +21,7 @@ use sha2::{Digest, Sha256}; pub const DEFAULT_RAW_JSON_MAX_BYTES: usize = 256 * 1024; pub const DEFAULT_CONTENT_MAX_BYTES: usize = 128 * 1024; pub const DEFAULT_TAG_MAX_COUNT: usize = 1024; +pub const DEFAULT_TAG_TOTAL_ELEMENT_MAX_COUNT: usize = 4096; pub const DEFAULT_TAG_ELEMENT_MAX_BYTES: usize = 4 * 1024; pub const DEFAULT_TAG_TOTAL_MAX_BYTES: usize = 128 * 1024; pub const DEFAULT_EXTRA_MAX_FIELDS: usize = 64; @@ -31,6 +32,7 @@ pub struct RadrootsEventWireLimits { pub max_raw_json_bytes: usize, pub max_content_bytes: usize, pub max_tag_count: usize, + pub max_total_tag_elements: usize, pub max_tag_element_bytes: usize, pub max_total_tag_bytes: usize, pub max_extra_fields: usize, @@ -43,6 +45,7 @@ impl Default for RadrootsEventWireLimits { max_raw_json_bytes: DEFAULT_RAW_JSON_MAX_BYTES, max_content_bytes: DEFAULT_CONTENT_MAX_BYTES, max_tag_count: DEFAULT_TAG_MAX_COUNT, + max_total_tag_elements: DEFAULT_TAG_TOTAL_ELEMENT_MAX_COUNT, max_tag_element_bytes: DEFAULT_TAG_ELEMENT_MAX_BYTES, max_total_tag_bytes: DEFAULT_TAG_TOTAL_MAX_BYTES, max_extra_fields: DEFAULT_EXTRA_MAX_FIELDS, @@ -98,6 +101,10 @@ pub enum RadrootsEventWireError { max: usize, actual: usize, }, + TooManyTagElements { + max: usize, + actual: usize, + }, EmptyTag { index: usize, }, @@ -158,6 +165,9 @@ impl fmt::Display for RadrootsEventWireError { Self::TooManyTags { max, actual } => { write!(f, "event wire tag count {actual} exceeds {max}") } + Self::TooManyTagElements { max, actual } => { + write!(f, "event wire tag element count {actual} exceeds {max}") + } Self::EmptyTag { index } => write!(f, "event wire tag {index} is empty"), Self::EmptyTagKey { index } => write!(f, "event wire tag {index} key is empty"), Self::ControlCharacterTagKey { index } => { @@ -487,6 +497,18 @@ fn take_tags( actual: tag_count, }); } + let total_tag_elements = raw_tags.iter().try_fold(0usize, |total, raw_tag| { + let Value::Array(values) = raw_tag else { + return Err(RadrootsEventWireError::InvalidField("tags")); + }; + Ok(total.saturating_add(values.len())) + })?; + if total_tag_elements > limits.max_total_tag_elements { + return Err(RadrootsEventWireError::TooManyTagElements { + max: limits.max_total_tag_elements, + actual: total_tag_elements, + }); + } let mut total_tag_bytes = 0usize; let mut tags = Vec::with_capacity(tag_count); for (tag_index, raw_tag) in raw_tags.into_iter().enumerate() { @@ -652,6 +674,31 @@ mod tests { vec![vec!["t".to_owned(), "soil".to_owned()]] } + fn tag_with_total_elements(element_count: usize) -> Vec<Vec<String>> { + assert!(element_count > 0); + let mut tag = Vec::with_capacity(element_count); + tag.push("t".to_owned()); + tag.resize(element_count, String::new()); + vec![tag] + } + + fn valid_envelope_parts(content: &str, tags: Vec<Vec<String>>) -> RadrootsEventEnvelopeParts { + let pubkey = hex_64('a'); + let id = + compute_canonical_nip01_event_id(pubkey.as_str(), 1_700_000_000, 1, &tags, content) + .expect("event id") + .into_string(); + RadrootsEventEnvelopeParts { + id, + author: pubkey, + created_at: 1_700_000_000, + kind: 1, + tags, + content: content.to_owned(), + sig: hex_128('b'), + } + } + #[test] fn canonical_preimage_escapes_required_json_characters() { let preimage = canonical_nip01_event_id_preimage( @@ -900,6 +947,18 @@ mod tests { Err(RadrootsEventWireError::TooManyTags { .. }) )); + let raw = valid_event_json("hello", default_tags()); + assert_eq!( + RadrootsNip01EventWire::parse_json_with_limits( + raw.as_str(), + RadrootsEventWireLimits { + max_total_tag_elements: 1, + ..RadrootsEventWireLimits::default() + } + ), + Err(RadrootsEventWireError::TooManyTagElements { max: 1, actual: 2 }) + ); + let raw = valid_event_json("hello", vec![vec!["t".to_owned(), "soil".to_owned()]]); assert!(matches!( RadrootsNip01EventWire::parse_json_with_limits( @@ -954,6 +1013,49 @@ mod tests { } #[test] + fn wire_accepts_exact_total_tag_element_boundary() { + let raw = valid_event_json("hello", default_tags()); + let wire = RadrootsNip01EventWire::parse_json_with_limits( + raw.as_str(), + RadrootsEventWireLimits { + max_total_tag_elements: 2, + ..RadrootsEventWireLimits::default() + }, + ) + .expect("wire at exact tag element boundary"); + + assert_eq!(wire.tags, default_tags()); + } + + #[test] + fn wire_and_envelope_share_default_total_tag_element_boundary() { + let exact_tags = tag_with_total_elements(DEFAULT_TAG_TOTAL_ELEMENT_MAX_COUNT); + RadrootsNip01EventWire::parse_json(valid_event_json("", exact_tags.clone()).as_str()) + .expect("wire at default tag element boundary"); + RadrootsEventEnvelope::new(valid_envelope_parts("", exact_tags)) + .expect("envelope at default tag element boundary"); + + let overflow_elements = DEFAULT_TAG_TOTAL_ELEMENT_MAX_COUNT + 1; + let overflow_tags = tag_with_total_elements(overflow_elements); + assert_eq!( + RadrootsNip01EventWire::parse_json( + valid_event_json("", overflow_tags.clone()).as_str() + ), + Err(RadrootsEventWireError::TooManyTagElements { + max: DEFAULT_TAG_TOTAL_ELEMENT_MAX_COUNT, + actual: overflow_elements, + }) + ); + assert_eq!( + RadrootsEventEnvelope::new(valid_envelope_parts("", overflow_tags)), + Err(RadrootsEventEnvelopeError::TooManyTagElements { + max: DEFAULT_TAG_TOTAL_ELEMENT_MAX_COUNT, + actual: overflow_elements, + }) + ); + } + + #[test] #[cfg_attr(coverage_nightly, coverage(off))] fn wire_parser_and_error_contracts_cover_all_typed_failures() { let parse_error = RadrootsEventId::parse("bad").expect_err("invalid id"); @@ -970,6 +1072,7 @@ mod tests { RadrootsEventWireError::RawJsonTooLarge { max: 1, actual: 2 }, RadrootsEventWireError::ContentTooLarge { max: 1, actual: 2 }, RadrootsEventWireError::TooManyTags { max: 1, actual: 2 }, + RadrootsEventWireError::TooManyTagElements { max: 1, actual: 2 }, RadrootsEventWireError::EmptyTag { index: 1 }, RadrootsEventWireError::EmptyTagKey { index: 1 }, RadrootsEventWireError::ControlCharacterTagKey { index: 1 }, diff --git a/crates/event_codec/src/post/admission.rs b/crates/event_codec/src/post/admission.rs @@ -11,18 +11,14 @@ use crate::{ }, }; -/// 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. +/// A signature-and-id verified root kind-1 event bound to a product projection. #[derive(Clone, Debug, PartialEq, Eq)] -pub struct RadrootsAdmittedPostEvent { +pub struct RadrootsAdmittedRootPostEvent { verified_event: RadrootsSignatureVerifiedEvent, projection: RadrootsInboundPostProjection, } -impl RadrootsAdmittedPostEvent { +impl RadrootsAdmittedRootPostEvent { pub fn verified_event(&self) -> &RadrootsSignatureVerifiedEvent { &self.verified_event } @@ -36,8 +32,42 @@ impl RadrootsAdmittedPostEvent { } pub fn contract(&self) -> &'static RadrootsEventContract { + debug_assert!(self.projection.classification().is_root_card()); radroots_event::contract::event_contract(self.projection.classification().contract_id()) - .expect("post projection contract IDs are registry-owned") + .expect("root post projection contract IDs are registry-owned") + } + + pub fn into_parts( + self, + ) -> ( + RadrootsSignatureVerifiedEvent, + RadrootsInboundPostProjection, + ) { + (self.verified_event, self.projection) + } +} + +/// A verified kind-1 event excluded from root-card admission by an `e` tag. +/// +/// This candidate carries no Reply claim. Slice 06 owns strict NIP-10 parsing +/// and any later promotion into a semantic thread model. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsThreadExcludedPostCandidate { + verified_event: RadrootsSignatureVerifiedEvent, + projection: RadrootsInboundPostProjection, +} + +impl RadrootsThreadExcludedPostCandidate { + 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 into_parts( @@ -50,6 +80,14 @@ impl RadrootsAdmittedPostEvent { } } +/// Result of verifying a kind-1 event at the root-post admission boundary. +#[non_exhaustive] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RadrootsPostAdmissionOutcome { + Root(RadrootsAdmittedRootPostEvent), + ThreadExcluded(RadrootsThreadExcludedPostCandidate), +} + #[non_exhaustive] #[derive(Clone, Debug, PartialEq, Eq)] pub enum RadrootsPostAdmissionError { @@ -97,20 +135,37 @@ impl From<RadrootsPostProjectionError> for RadrootsPostAdmissionError { } } -/// Admits an already verified kind-1 event and binds its tolerant projection. +/// Classifies an already verified kind-1 event at the product post boundary. +/// +/// Root post profiles are admitted as product posts. Thread-shaped candidates +/// are returned separately and do not establish a root post or reply contract. pub fn admit_verified_post_event( verified_event: RadrootsSignatureVerifiedEvent, -) -> Result<RadrootsAdmittedPostEvent, RadrootsPostAdmissionError> { +) -> Result<RadrootsPostAdmissionOutcome, RadrootsPostAdmissionError> { let projection = project_verified_post_event(&verified_event)?; - Ok(RadrootsAdmittedPostEvent { - verified_event, - projection, - }) + if projection.classification().is_root_card() { + Ok(RadrootsPostAdmissionOutcome::Root( + RadrootsAdmittedRootPostEvent { + verified_event, + projection, + }, + )) + } else { + Ok(RadrootsPostAdmissionOutcome::ThreadExcluded( + RadrootsThreadExcludedPostCandidate { + verified_event, + projection, + }, + )) + } } -/// Verifies NIP-01 identifier/signature state before kind-1 projection. +/// Verifies NIP-01 state, admitting only root profiles as product posts. +/// +/// A verified thread candidate is returned as thread-excluded compatibility +/// data; this boundary does not claim that it is a valid Radroots reply. pub fn verify_and_admit_post_event( event: RadrootsEventEnvelope, -) -> Result<RadrootsAdmittedPostEvent, RadrootsPostAdmissionError> { +) -> Result<RadrootsPostAdmissionOutcome, 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 @@ -1,6 +1,5 @@ #[cfg(not(feature = "std"))] use alloc::{ - format, string::{String, ToString}, vec, vec::Vec, @@ -12,7 +11,6 @@ use radroots_event::{ RADROOTS_ASK_MARKER_TAG_KEY, RADROOTS_ASK_MARKER_TAG_VALUE, RadrootsAuthoredAsk, RadrootsAuthoredPhotoUpdate, RadrootsAuthoredPostImage, RadrootsAuthoredUpdate, }, - tags::TAG_IMETA, wire::RadrootsNip01EventWireParts, }; @@ -61,29 +59,8 @@ pub fn authored_ask_to_wire_parts(ask: &RadrootsAuthoredAsk) -> RadrootsNip01Eve } 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 + images + .iter() + .map(|image| image.imeta_tag().to_vec()) + .collect() } diff --git a/crates/event_codec/src/post/inbound.rs b/crates/event_codec/src/post/inbound.rs @@ -74,12 +74,13 @@ impl fmt::Display for RadrootsPostDiagnostic { /// 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. +/// ThreadExcluded 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, + ThreadExcluded, Update, PhotoUpdate, Ask, @@ -88,7 +89,7 @@ pub enum RadrootsPostClassification { impl RadrootsPostClassification { pub const fn contract_id(self) -> &'static str { match self { - Self::Reply => "radroots.social.post.v1", + Self::ThreadExcluded => "radroots.social.post.v1", Self::Update => "radroots.social.update.v1", Self::PhotoUpdate => "radroots.social.photo_update.v1", Self::Ask => "radroots.social.ask.v1", @@ -96,7 +97,7 @@ impl RadrootsPostClassification { } pub const fn is_root_card(self) -> bool { - !matches!(self, Self::Reply) + !matches!(self, Self::ThreadExcluded) } } @@ -235,9 +236,9 @@ 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. +/// Any `e` tag excludes the event before Ask or media inspection. This function +/// does not claim that the event is a valid NIP-10 reply; that belongs to the +/// dedicated reply contract. pub fn project_verified_post_event( verified_event: &RadrootsSignatureVerifiedEvent, ) -> Result<RadrootsInboundPostProjection, RadrootsPostProjectionError> { @@ -267,7 +268,7 @@ pub(crate) fn project_inbound_post_parts( .any(|tag| tag.first().is_some_and(|key| key == "e")) { return Ok(RadrootsInboundPostProjection { - classification: RadrootsPostClassification::Reply, + classification: RadrootsPostClassification::ThreadExcluded, ask_marker: None, imeta: Vec::new(), diagnostics: Vec::new(), @@ -496,7 +497,7 @@ mod tests { use radroots_event::post::RadrootsAuthoredUpdate; #[test] - fn reply_exclusion_precedes_ask_and_media_projection() { + fn thread_exclusion_precedes_ask_and_media_projection() { let projection = project_inbound_post_parts( KIND_POST, &[ @@ -510,13 +511,31 @@ mod tests { assert_eq!( projection.classification(), - RadrootsPostClassification::Reply + RadrootsPostClassification::ThreadExcluded ); assert!(projection.imeta().is_empty()); assert!(projection.diagnostics().is_empty()); } #[test] + fn every_empty_or_malformed_event_reference_is_thread_excluded() { + for event_reference in [ + vec!["e".to_string()], + vec!["e".to_string(), String::new()], + vec!["e".to_string(), "not-an-event-id".to_string()], + ] { + let projection = + project_inbound_post_parts(KIND_POST, &[event_reference], "candidate").unwrap(); + + assert_eq!( + projection.classification(), + RadrootsPostClassification::ThreadExcluded + ); + assert!(!projection.classification().is_root_card()); + } + } + + #[test] fn normalized_ask_precedes_malformed_media_and_retains_diagnostics() { let projection = project_inbound_post_parts( KIND_POST, diff --git a/crates/event_codec/tests/fixtures/post_verified_profiles.v1.json b/crates/event_codec/tests/fixtures/post_verified_profiles.v1.json @@ -1,8 +1,142 @@ { - "contract_version": "1.0.0", "suite": "post_profiles", + "contract_version": "1.0.0", "vectors": [ { + "id": "authored_update_wire", + "kind": "social.update.build_authored_draft.valid", + "input": { + "content": "Farm update" + }, + "expected": { + "kind": 1, + "content": "Farm update", + "tags": [] + } + }, + { + "id": "authored_update_blank", + "kind": "social.update.build_authored_draft.invalid", + "input": { + "content": " \t" + }, + "expected": { + "error": "post_content_missing" + } + }, + { + "id": "authored_photo_update_wire", + "kind": "social.photo_update.build_authored_draft.valid", + "input": { + "content": "Harvest https://media.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp", + "images": [ + { + "bytes_utf8": "strawberries", + "url": "https://media.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp", + "media_type": "image/webp", + "uploaded": 1784347200, + "width": 1200, + "height": 900, + "alt": "Harvest", + "fallbacks": [ + "https://backup.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp" + ] + } + ] + }, + "expected": { + "kind": 1, + "content": "Harvest https://media.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp", + "tags": [ + [ + "imeta", + "url https://media.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp", + "x 1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276", + "m image/webp", + "dim 1200x900", + "size 12", + "alt Harvest", + "fallback https://backup.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp" + ] + ] + } + }, + { + "id": "authored_photo_update_mime_underscore", + "kind": "social.photo_update.build_authored_draft.invalid", + "input": { + "content": "Harvest https://media.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp", + "images": [ + { + "bytes_utf8": "strawberries", + "url": "https://media.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp", + "media_type": "image/p_ng", + "uploaded": 1784347200, + "width": 1200, + "height": 900, + "alt": "Harvest", + "fallbacks": [ + "https://backup.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp" + ] + } + ] + }, + "expected": { + "error": "imeta_mime_invalid" + } + }, + { + "id": "authored_ask_wire", + "kind": "social.ask.build_authored_draft.valid", + "input": { + "content": "When are carrots ready? https://media.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp", + "images": [ + { + "bytes_utf8": "strawberries", + "url": "https://media.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp", + "media_type": "image/webp", + "uploaded": 1784347200, + "width": 1200, + "height": 900, + "alt": "Harvest", + "fallbacks": [ + "https://backup.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp" + ] + } + ] + }, + "expected": { + "kind": 1, + "content": "When are carrots ready? https://media.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp", + "tags": [ + [ + "t", + "radroots-ask" + ], + [ + "imeta", + "url https://media.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp", + "x 1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276", + "m image/webp", + "dim 1200x900", + "size 12", + "alt Harvest", + "fallback https://backup.example/1ff0938846474bffb82fe6f107422337fd051c1191c9cc3584854fbb1501b276.webp" + ] + ] + } + }, + { + "id": "authored_ask_blank", + "kind": "social.ask.build_authored_draft.invalid", + "input": { + "content": "\n" + }, + "expected": { + "error": "post_content_missing" + } + }, + { "expected": { "ask_marker": null, "classification": "update", @@ -10,11 +144,11 @@ "diagnostics": [], "imeta": [] }, - "id": "signed_update", + "id": "project_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" + "kind": "social.post.project_verified_event.valid" }, { "expected": { @@ -24,11 +158,11 @@ "diagnostics": [], "imeta": [] }, - "id": "signed_empty_inbound_update", + "id": "project_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" + "kind": "social.post.project_verified_event.valid" }, { "expected": { @@ -49,15 +183,24 @@ "size 12345", "alt Harvest" ], - "unknown_fields": [] + "unknown_fields": [], + "url": "https://cdn.example/harvest.webp", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "media_type": "image/webp", + "dimensions": { + "width": 1200, + "height": 900 + }, + "size": 12345, + "alt": "Harvest" } ] }, - "id": "signed_structural_photo", + "id": "project_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" + "kind": "social.post.project_verified_event.valid" }, { "expected": { @@ -88,15 +231,24 @@ "unknown_fields": [ "x-farm cultivar-strawberry", "future-field retained value" - ] + ], + "url": "https://cdn.example/harvest.webp", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "media_type": "image/webp", + "dimensions": { + "width": 1200, + "height": 900 + }, + "size": 12345, + "alt": "Harvest" } ] }, - "id": "signed_photo_preserves_fallbacks_and_unknown_fields", + "id": "project_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" + "kind": "social.post.project_verified_event.valid" }, { "expected": { @@ -122,15 +274,21 @@ "url https://cdn.example/leaf.webp", "x malformed" ], - "unknown_fields": [] + "unknown_fields": [], + "url": "https://cdn.example/leaf.webp", + "sha256": "malformed", + "media_type": null, + "dimensions": null, + "size": null, + "alt": null } ] }, - "id": "signed_normalized_ask_precedes_malformed_media", + "id": "project_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" + "kind": "social.post.project_verified_event.valid" }, { "expected": { @@ -153,15 +311,21 @@ "url https://cdn.example/leaf.webp", "x malformed" ], - "unknown_fields": [] + "unknown_fields": [], + "url": "https://cdn.example/leaf.webp", + "sha256": "malformed", + "media_type": null, + "dimensions": null, + "size": null, + "alt": null } ] }, - "id": "signed_malformed_imeta_is_update", + "id": "project_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" + "kind": "social.post.project_verified_event.valid" }, { "expected": { @@ -187,15 +351,24 @@ "size 12345", "alt Harvest" ], - "unknown_fields": [] + "unknown_fields": [], + "url": "https://cdn.example/harvest.webp", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "media_type": "image/webp", + "dimensions": { + "width": 1200, + "height": 900 + }, + "size": 12345, + "alt": "Harvest" } ] }, - "id": "signed_duplicate_singleton_is_update", + "id": "project_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" + "kind": "social.post.project_verified_event.valid" }, { "expected": { @@ -219,7 +392,16 @@ "size 12345", "alt Harvest" ], - "unknown_fields": [] + "unknown_fields": [], + "url": "https://cdn.example/harvest.webp", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "media_type": "image/webp", + "dimensions": { + "width": 1200, + "height": 900 + }, + "size": 12345, + "alt": "Harvest" }, { "diagnostics": [ @@ -232,29 +414,35 @@ "url https://cdn.example/leaf.webp", "x malformed" ], - "unknown_fields": [] + "unknown_fields": [], + "url": "https://cdn.example/leaf.webp", + "sha256": "malformed", + "media_type": null, + "dimensions": null, + "size": null, + "alt": null } ] }, - "id": "signed_mixed_imeta_is_update", + "id": "project_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" + "kind": "social.post.project_verified_event.valid" }, { "expected": { "ask_marker": null, - "classification": "reply", + "classification": "thread_excluded", "contract_id": "radroots.social.post.v1", "diagnostics": [], "imeta": [] }, - "id": "signed_reply_precedes_ask_and_media", + "id": "project_signed_thread_candidate_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" + "kind": "social.post.project_verified_event.valid" }, { "expected": { @@ -266,41 +454,216 @@ ], "imeta": [] }, - "id": "signed_malformed_ask_marker_is_update", + "id": "project_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" + "kind": "social.post.project_verified_event.valid" + }, + { + "expected": { + "error": "ask_marker_count" + }, + "id": "project_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": "social.post.project_verified_event.invalid" + }, + { + "expected": { + "error": "invalid_kind" + }, + "id": "project_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": "social.post.project_verified_event.invalid" + }, + { + "expected": { + "outcome": "root", + "contract_id": "radroots.social.update.v1", + "projection": { + "ask_marker": null, + "classification": "update", + "contract_id": "radroots.social.update.v1", + "diagnostics": [], + "imeta": [] + } + }, + "id": "admit_signed_update", + "input": { + "event_json": "{\"id\":\"fb3f42caf9db337a7f1c0d49cd8ba5191f08dc1c419ed0640f7ea48a924e3bf3\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781632860,\"kind\":1,\"tags\":[],\"content\":\"The first strawberries are ready.\",\"sig\":\"dba0a86fee54304c2b419742f186e74d7edca5fc7234c8aa294651de9bc2f16bf829d46f36ec759a767c4ccd1841a73243eae89afd5f6c89b2243491bfbb5f50\"}" + }, + "kind": "social.post.verify_and_admit_event.valid" + }, + { + "expected": { + "outcome": "root", + "contract_id": "radroots.social.photo_update.v1", + "projection": { + "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": [], + "url": "https://cdn.example/harvest.webp", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "media_type": "image/webp", + "dimensions": { + "width": 1200, + "height": 900 + }, + "size": 12345, + "alt": "Harvest" + } + ] + } + }, + "id": "admit_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": "social.post.verify_and_admit_event.valid" + }, + { + "expected": { + "outcome": "root", + "contract_id": "radroots.social.ask.v1", + "projection": { + "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": [], + "url": "https://cdn.example/leaf.webp", + "sha256": "malformed", + "media_type": null, + "dimensions": null, + "size": null, + "alt": null + } + ] + } + }, + "id": "admit_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": "social.post.verify_and_admit_event.valid" + }, + { + "expected": { + "outcome": "thread_excluded", + "projection": { + "ask_marker": null, + "classification": "thread_excluded", + "contract_id": "radroots.social.post.v1", + "diagnostics": [], + "imeta": [] + } + }, + "id": "admit_signed_thread_candidate_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": "social.post.verify_and_admit_event.valid" + }, + { + "expected": { + "outcome": "thread_excluded", + "projection": { + "ask_marker": null, + "classification": "thread_excluded", + "contract_id": "radroots.social.post.v1", + "diagnostics": [], + "imeta": [] + } + }, + "id": "admit_signed_empty_e_thread_candidate", + "input": { + "event_json": "{\"id\":\"c3cc3425ba033b045d48f78da004aa74018ce889f0918ad455e03f732cef09e5\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781635610,\"kind\":1,\"tags\":[[\"e\"]],\"content\":\"Empty e tag\",\"sig\":\"d45829da243b29287e2c270338835bad8634b1a1c4c5aa5ced8a4d4da72cc2adc4005573ad240a1964e0ad4d0584c2fc3f3cf9a5ec10b7792bb1f789762401a8\"}" + }, + "kind": "social.post.verify_and_admit_event.valid" + }, + { + "expected": { + "outcome": "thread_excluded", + "projection": { + "ask_marker": null, + "classification": "thread_excluded", + "contract_id": "radroots.social.post.v1", + "diagnostics": [], + "imeta": [] + } + }, + "id": "admit_signed_empty_e_value_thread_candidate", + "input": { + "event_json": "{\"id\":\"41a8d475f22365c0518fa7db97ee684a071fbe886b083050eb34bb66c2066933\",\"pubkey\":\"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"created_at\":1781635611,\"kind\":1,\"tags\":[[\"e\",\"\"]],\"content\":\"Empty e tag value\",\"sig\":\"ebf7c7ae45660bb48ff8c5113119ab359ffa698a6697018023a966edbd07cf3e6d586d4c9e4461a5d6fe2bf840291a081dd89912404eaa1e41e95787165bfbfd\"}" + }, + "kind": "social.post.verify_and_admit_event.valid" }, { "expected": { "error": "ask_marker_count" }, - "id": "signed_duplicate_normalized_ask_marker", + "id": "admit_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" + "kind": "social.post.verify_and_admit_event.invalid" }, { "expected": { "error": "invalid_kind" }, - "id": "signed_kind_20_is_not_photo_update", + "id": "admit_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" + "kind": "social.post.verify_and_admit_event.invalid" }, { "expected": { "error": "signature_invalid" }, - "id": "signed_invalid_signature", + "id": "admit_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" + "kind": "social.post.verify_and_admit_event.invalid" } ] } diff --git a/crates/event_codec/tests/post.rs b/crates/event_codec/tests/post.rs @@ -6,9 +6,11 @@ use radroots_event::{ 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, + RADROOTS_POST_CONTENT_MAX_BYTES, RADROOTS_POST_EVENT_WIRE_MAX_BYTES, + RADROOTS_POST_IMETA_MAX_COUNT, RADROOTS_POST_TAG_ELEMENT_MAX_BYTES, + RADROOTS_POST_TAG_TOTAL_MAX_BYTES, RadrootsAuthoredAsk, RadrootsAuthoredPhotoUpdate, + RadrootsAuthoredPostError, RadrootsAuthoredPostImage, RadrootsAuthoredUpdate, + RadrootsPostImageDimensions, post_image_media_type_is_valid, }, }; use radroots_event_codec::post::authored::{ @@ -58,6 +60,7 @@ fn authored_photo_emits_exact_nip92_order_and_repeatable_fallbacks() { assert_eq!(wire.kind, 1); assert_eq!(wire.content, content); assert_eq!(wire.tags.len(), 1); + assert_eq!(wire.tags[0].as_slice(), photo.images()[0].imeta_tag()); assert_eq!( wire.tags[0] .iter() @@ -226,6 +229,193 @@ fn authored_image_rejects_zero_size_and_invalid_alt_text() { ); } +#[test] +fn authored_image_enforces_the_exact_tag_element_limit() { + let maximum = RadrootsAuthoredImage::try_from(verified_descriptor_with_url_element_bytes( + b"maximum-url", + RADROOTS_POST_TAG_ELEMENT_MAX_BYTES, + )) + .unwrap(); + let maximum = RadrootsAuthoredPostImage::new( + maximum, + RadrootsPostImageDimensions::new(1, 1).unwrap(), + "Maximum URL", + ) + .unwrap(); + assert_eq!( + maximum.imeta_tag()[1].len(), + RADROOTS_POST_TAG_ELEMENT_MAX_BYTES + ); + + let oversized = RadrootsAuthoredImage::try_from(verified_descriptor_with_url_element_bytes( + b"oversized-url", + RADROOTS_POST_TAG_ELEMENT_MAX_BYTES + 1, + )) + .unwrap(); + assert_eq!( + RadrootsAuthoredPostImage::new( + oversized, + RadrootsPostImageDimensions::new(1, 1).unwrap(), + "Oversized URL", + ) + .unwrap_err(), + RadrootsAuthoredPostError::TagElementTooLarge { + max: RADROOTS_POST_TAG_ELEMENT_MAX_BYTES, + actual: RADROOTS_POST_TAG_ELEMENT_MAX_BYTES + 1, + } + ); + + let maximum_fallback = authored_image(b"maximum-fallback", "image/webp", "webp") + .try_with_fallback(fallback_url_with_element_bytes( + b"maximum-fallback", + RADROOTS_POST_TAG_ELEMENT_MAX_BYTES, + )) + .unwrap(); + assert_eq!( + maximum_fallback.imeta_tag().last().unwrap().len(), + RADROOTS_POST_TAG_ELEMENT_MAX_BYTES + ); + assert_eq!( + authored_image(b"oversized-fallback", "image/webp", "webp") + .try_with_fallback(fallback_url_with_element_bytes( + b"oversized-fallback", + RADROOTS_POST_TAG_ELEMENT_MAX_BYTES + 1, + )) + .unwrap_err(), + RadrootsAuthoredPostError::TagElementTooLarge { + max: RADROOTS_POST_TAG_ELEMENT_MAX_BYTES, + actual: RADROOTS_POST_TAG_ELEMENT_MAX_BYTES + 1, + } + ); +} + +#[test] +fn authored_posts_enforce_the_exact_aggregate_tag_byte_limit() { + let second = authored_image(b"second-image", "image/webp", "webp"); + let second_tag_bytes = tag_bytes(second.imeta_tag()); + let first_target = RADROOTS_POST_TAG_TOTAL_MAX_BYTES - second_tag_bytes; + let first = fill_image_tag_bytes( + authored_image(b"first-image", "image/webp", "webp"), + b"first-image", + first_target, + ); + let content = format!("{} {}", first.url(), second.url()); + let photo = + RadrootsAuthoredPhotoUpdate::new(content.clone(), vec![first.clone(), second.clone()]) + .unwrap(); + let wire = authored_photo_update_to_wire_parts(&photo); + assert_eq!( + wire.tags.iter().map(|tag| tag_bytes(tag)).sum::<usize>(), + RADROOTS_POST_TAG_TOTAL_MAX_BYTES + ); + + let first_over = fill_image_tag_bytes( + authored_image(b"first-over", "image/webp", "webp"), + b"first-over", + first_target + 1, + ); + let over_content = format!("{} {}", first_over.url(), second.url()); + assert_eq!( + RadrootsAuthoredPhotoUpdate::new(over_content, vec![first_over, second]).unwrap_err(), + RadrootsAuthoredPostError::TagBytesExceeded { + max: RADROOTS_POST_TAG_TOTAL_MAX_BYTES, + actual: RADROOTS_POST_TAG_TOTAL_MAX_BYTES + 1, + } + ); + + let full = fill_image_tag_bytes( + authored_image(b"full-image", "image/webp", "webp"), + b"full-image", + RADROOTS_POST_TAG_TOTAL_MAX_BYTES, + ); + assert!( + RadrootsAuthoredPhotoUpdate::new(full.url(), vec![full.clone()]).is_ok(), + "PhotoUpdate must accept the exact aggregate maximum" + ); + assert_eq!( + RadrootsAuthoredAsk::new(full.url(), vec![full.clone()]).unwrap_err(), + RadrootsAuthoredPostError::TagBytesExceeded { + max: RADROOTS_POST_TAG_TOTAL_MAX_BYTES, + actual: RADROOTS_POST_TAG_TOTAL_MAX_BYTES + + "t".len() + + RADROOTS_ASK_MARKER_TAG_VALUE.len(), + } + ); + + let minimum_fallback_bytes = fallback_element_prefix(b"full-image").len() + 1; + assert_eq!( + full.try_with_fallback(fallback_url_with_element_bytes( + b"full-image", + minimum_fallback_bytes, + )) + .unwrap_err(), + RadrootsAuthoredPostError::TagBytesExceeded { + max: RADROOTS_POST_TAG_TOTAL_MAX_BYTES, + actual: RADROOTS_POST_TAG_TOTAL_MAX_BYTES + minimum_fallback_bytes, + } + ); +} + +#[test] +fn authored_posts_enforce_the_exact_canonical_signed_event_budget() { + let image = fill_image_tag_bytes( + authored_image(b"wire-budget", "image/webp", "webp"), + b"wire-budget", + RADROOTS_POST_TAG_TOTAL_MAX_BYTES, + ); + let tags = vec![image.imeta_tag().to_vec()]; + let minimum_content = image.url(); + let minimum_wire_bytes = canonical_signed_post_json_bytes(minimum_content, &tags); + let padding_bytes = RADROOTS_POST_EVENT_WIRE_MAX_BYTES - minimum_wire_bytes; + let maximum_content = format!("{minimum_content}{}", "x".repeat(padding_bytes)); + + let maximum = + RadrootsAuthoredPhotoUpdate::new(maximum_content.clone(), vec![image.clone()]).unwrap(); + let maximum_wire = authored_photo_update_to_wire_parts(&maximum); + assert_eq!( + canonical_signed_post_json_bytes(&maximum_wire.content, &maximum_wire.tags), + RADROOTS_POST_EVENT_WIRE_MAX_BYTES + ); + + let oversized_content = format!("{maximum_content}x"); + assert_eq!( + RadrootsAuthoredPhotoUpdate::new(oversized_content, vec![image.clone()]).unwrap_err(), + RadrootsAuthoredPostError::EventWireTooLarge { + max: RADROOTS_POST_EVENT_WIRE_MAX_BYTES, + actual: RADROOTS_POST_EVENT_WIRE_MAX_BYTES + 1, + } + ); + + let mut escaped_content = maximum_content; + escaped_content.pop(); + escaped_content.push('"'); + assert_eq!( + RadrootsAuthoredPhotoUpdate::new(escaped_content, vec![image]).unwrap_err(), + RadrootsAuthoredPostError::EventWireTooLarge { + max: RADROOTS_POST_EVENT_WIRE_MAX_BYTES, + actual: RADROOTS_POST_EVENT_WIRE_MAX_BYTES + 1, + } + ); +} + +#[test] +fn authored_update_counts_json_control_character_expansion() { + let content = format!( + "a{}", + "\u{0001}".repeat(RADROOTS_POST_CONTENT_MAX_BYTES - 1) + ); + let actual = canonical_signed_post_json_bytes(&content, &[]); + + assert_eq!( + RadrootsAuthoredUpdate::new(content).unwrap_err(), + RadrootsAuthoredPostError::EventWireTooLarge { + max: RADROOTS_POST_EVENT_WIRE_MAX_BYTES, + actual, + } + ); + assert!(actual > RADROOTS_POST_EVENT_WIRE_MAX_BYTES); +} + fn authored_image(bytes: &[u8], media_type: &str, extension: &str) -> RadrootsAuthoredPostImage { RadrootsAuthoredPostImage::new( RadrootsAuthoredImage::try_from(verified_descriptor(bytes, media_type, extension)).unwrap(), @@ -257,6 +447,36 @@ fn verified_descriptor( .unwrap() } +fn verified_descriptor_with_url_element_bytes( + bytes: &[u8], + element_bytes: usize, +) -> RadrootsBlossomByteVerifiedDescriptor { + let hash = RadrootsBlossomSha256::digest(bytes); + let prefix = format!("https://media.example/{hash}."); + let extension_bytes = element_bytes + .checked_sub("url ".len() + prefix.len()) + .expect("requested URL tag element is large enough"); + assert!(extension_bytes > 0); + let media_type = RadrootsBlossomMediaType::parse("image/webp").unwrap(); + let descriptor = RadrootsBlossomBlobDescriptor::new( + RadrootsBlossomBlobUrl::parse(&format!("{prefix}{}", "x".repeat(extension_bytes))).unwrap(), + hash, + bytes.len() as u64, + media_type.clone(), + 1_784_347_200, + ) + .unwrap() + .approve_reference() + .unwrap() + .verify_bytes(bytes, &media_type) + .unwrap(); + assert_eq!( + "url ".len() + descriptor.url().as_str().len(), + element_bytes + ); + descriptor +} + fn fallback_url( bytes: &[u8], host: &str, @@ -268,3 +488,71 @@ fn fallback_url( .approve() .unwrap() } + +fn fallback_url_with_element_bytes( + bytes: &[u8], + element_bytes: usize, +) -> radroots_blossom::RadrootsBlossomApprovedBlobUrl { + let prefix = fallback_element_prefix(bytes); + let extension_bytes = element_bytes + .checked_sub(prefix.len()) + .expect("requested fallback tag element is large enough"); + assert!(extension_bytes > 0); + let fallback = RadrootsBlossomBlobUrl::parse(&format!( + "{}{}", + prefix.strip_prefix("fallback ").unwrap(), + "x".repeat(extension_bytes) + )) + .unwrap() + .approve() + .unwrap(); + assert_eq!(format!("fallback {fallback}").len(), element_bytes); + fallback +} + +fn fallback_element_prefix(bytes: &[u8]) -> String { + let hash = RadrootsBlossomSha256::digest(bytes); + format!("fallback https://cache.example/{hash}.") +} + +fn tag_bytes(tag: &[String]) -> usize { + tag.iter().map(String::len).sum() +} + +fn canonical_signed_post_json_bytes(content: &str, tags: &[Vec<String>]) -> usize { + serde_json::json!({ + "id": "0".repeat(64), + "pubkey": "0".repeat(64), + "created_at": u64::MAX, + "kind": 1, + "tags": tags, + "content": content, + "sig": "0".repeat(128), + }) + .to_string() + .len() +} + +fn fill_image_tag_bytes( + mut image: RadrootsAuthoredPostImage, + bytes: &[u8], + target: usize, +) -> RadrootsAuthoredPostImage { + let minimum_fallback_bytes = fallback_element_prefix(bytes).len() + 1; + while tag_bytes(image.imeta_tag()) < target { + let remaining = target - tag_bytes(image.imeta_tag()); + assert!(remaining >= minimum_fallback_bytes); + let mut next = remaining.min(RADROOTS_POST_TAG_ELEMENT_MAX_BYTES); + if remaining > RADROOTS_POST_TAG_ELEMENT_MAX_BYTES + && remaining - RADROOTS_POST_TAG_ELEMENT_MAX_BYTES < minimum_fallback_bytes + { + next = remaining - minimum_fallback_bytes; + } + assert!((minimum_fallback_bytes..=RADROOTS_POST_TAG_ELEMENT_MAX_BYTES).contains(&next)); + image = image + .try_with_fallback(fallback_url_with_element_bytes(bytes, next)) + .unwrap(); + } + assert_eq!(tag_bytes(image.imeta_tag()), target); + image +} diff --git a/crates/event_codec/tests/verified_post_conformance.rs b/crates/event_codec/tests/verified_post_conformance.rs @@ -1,14 +1,31 @@ #![cfg(all(feature = "serde_json", feature = "nostr"))] -use std::{borrow::Cow, fs, path::Path}; +use std::{borrow::Cow, collections::BTreeMap, fs, path::Path}; +use radroots_blossom::{ + RadrootsBlossomBlobDescriptor, RadrootsBlossomBlobUrl, RadrootsBlossomMediaType, + RadrootsBlossomSha256, +}; use radroots_event::{ - RadrootsEventEnvelope, RadrootsNip01EventWire, contract::identify_event_contract, + RadrootsAuthoredImage, RadrootsEventEnvelope, RadrootsNip01EventWire, + contract::identify_event_contract, + post::{ + RadrootsAuthoredAsk, RadrootsAuthoredPhotoUpdate, RadrootsAuthoredPostError, + RadrootsAuthoredPostImage, RadrootsAuthoredUpdate, RadrootsPostImageDimensions, + }, + wire::RadrootsNip01EventWireParts, }; use radroots_event_codec::post::{ - admission::verify_and_admit_post_event, + admission::{RadrootsPostAdmissionOutcome, verify_and_admit_post_event}, + authored::{ + authored_ask_to_wire_parts, authored_photo_update_to_wire_parts, + authored_update_to_wire_parts, + }, inbound::{RadrootsInboundPostProjection, RadrootsPostClassification, RadrootsPostDiagnostic}, }; +use radroots_event_codec::{ + post::inbound::project_verified_post_event, verification::verify_nip01_event, +}; use serde::Deserialize; use serde_json::{Value, json}; @@ -16,6 +33,113 @@ const PACKAGED_VECTORS: &str = include_str!("fixtures/post_verified_profiles.v1. const WORKSPACE_VECTOR_PATH: &str = "../../contracts/conformance/vectors/post/verified_profiles.v1.json"; const WORKSPACE_CONTRACT_MARKER_PATH: &str = "../../contracts/manifest.toml"; +const EXPECTED_VECTOR_CASES: [(&str, &str); 27] = [ + ( + "admit_signed_duplicate_normalized_ask_marker", + "social.post.verify_and_admit_event.invalid", + ), + ( + "admit_signed_empty_e_thread_candidate", + "social.post.verify_and_admit_event.valid", + ), + ( + "admit_signed_empty_e_value_thread_candidate", + "social.post.verify_and_admit_event.valid", + ), + ( + "admit_signed_invalid_signature", + "social.post.verify_and_admit_event.invalid", + ), + ( + "admit_signed_kind_20_is_not_photo_update", + "social.post.verify_and_admit_event.invalid", + ), + ( + "admit_signed_normalized_ask_precedes_malformed_media", + "social.post.verify_and_admit_event.valid", + ), + ( + "admit_signed_structural_photo", + "social.post.verify_and_admit_event.valid", + ), + ( + "admit_signed_thread_candidate_precedes_ask_and_media", + "social.post.verify_and_admit_event.valid", + ), + ( + "admit_signed_update", + "social.post.verify_and_admit_event.valid", + ), + ( + "authored_ask_blank", + "social.ask.build_authored_draft.invalid", + ), + ("authored_ask_wire", "social.ask.build_authored_draft.valid"), + ( + "authored_photo_update_mime_underscore", + "social.photo_update.build_authored_draft.invalid", + ), + ( + "authored_photo_update_wire", + "social.photo_update.build_authored_draft.valid", + ), + ( + "authored_update_blank", + "social.update.build_authored_draft.invalid", + ), + ( + "authored_update_wire", + "social.update.build_authored_draft.valid", + ), + ( + "project_signed_duplicate_normalized_ask_marker", + "social.post.project_verified_event.invalid", + ), + ( + "project_signed_duplicate_singleton_is_update", + "social.post.project_verified_event.valid", + ), + ( + "project_signed_empty_inbound_update", + "social.post.project_verified_event.valid", + ), + ( + "project_signed_kind_20_is_not_photo_update", + "social.post.project_verified_event.invalid", + ), + ( + "project_signed_malformed_ask_marker_is_update", + "social.post.project_verified_event.valid", + ), + ( + "project_signed_malformed_imeta_is_update", + "social.post.project_verified_event.valid", + ), + ( + "project_signed_mixed_imeta_is_update", + "social.post.project_verified_event.valid", + ), + ( + "project_signed_normalized_ask_precedes_malformed_media", + "social.post.project_verified_event.valid", + ), + ( + "project_signed_photo_preserves_fallbacks_and_unknown_fields", + "social.post.project_verified_event.valid", + ), + ( + "project_signed_structural_photo", + "social.post.project_verified_event.valid", + ), + ( + "project_signed_thread_candidate_precedes_ask_and_media", + "social.post.project_verified_event.valid", + ), + ( + "project_signed_update", + "social.post.project_verified_event.valid", + ), +]; #[derive(Debug, Deserialize)] struct Suite { @@ -33,12 +157,12 @@ struct Vector { } #[test] -fn raw_signed_vectors_execute_against_verified_post_admission() { +fn post_operation_vectors_execute_the_declared_public_functions() { 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()); + assert_eq!(vector_cases(&suite.vectors), expected_vector_cases()); for vector in &suite.vectors { execute(vector); @@ -70,9 +194,73 @@ fn conformance_vectors() -> Cow<'static, str> { } fn execute(vector: &Vector) { - let envelope = canonical_envelope(input_str(vector, "event_json")); match vector.kind.as_str() { - "post.verify_and_admit.valid" => { + "social.update.build_authored_draft.valid" => { + let update = RadrootsAuthoredUpdate::new(input_str(vector, "content")) + .unwrap_or_else(|error| panic!("{} failed: {error}", vector.id)); + let first = authored_update_to_wire_parts(&update); + let second = authored_update_to_wire_parts(&update); + assert_eq!(first, second, "{} repeat encoding drifted", vector.id); + assert_eq!(wire_parts_value(&first), vector.expected, "{}", vector.id); + } + "social.update.build_authored_draft.invalid" => { + let error = RadrootsAuthoredUpdate::new(input_str(vector, "content")) + .expect_err("invalid authored Update must fail"); + assert_eq!(error.code(), expected_str(vector, "error"), "{}", vector.id); + } + "social.photo_update.build_authored_draft.valid" => { + let images = authored_images(vector) + .unwrap_or_else(|error| panic!("{} image construction failed: {error}", vector.id)); + let photo = RadrootsAuthoredPhotoUpdate::new(input_str(vector, "content"), images) + .unwrap_or_else(|error| panic!("{} failed: {error}", vector.id)); + let first = authored_photo_update_to_wire_parts(&photo); + let second = authored_photo_update_to_wire_parts(&photo); + assert_eq!(first, second, "{} repeat encoding drifted", vector.id); + assert_eq!(wire_parts_value(&first), vector.expected, "{}", vector.id); + } + "social.photo_update.build_authored_draft.invalid" => { + let error = authored_images(vector).expect_err("invalid authored image must fail"); + assert_eq!(error.code(), expected_str(vector, "error"), "{}", vector.id); + } + "social.ask.build_authored_draft.valid" => { + let images = authored_images(vector) + .unwrap_or_else(|error| panic!("{} image construction failed: {error}", vector.id)); + let ask = RadrootsAuthoredAsk::new(input_str(vector, "content"), images) + .unwrap_or_else(|error| panic!("{} failed: {error}", vector.id)); + let first = authored_ask_to_wire_parts(&ask); + let second = authored_ask_to_wire_parts(&ask); + assert_eq!(first, second, "{} repeat encoding drifted", vector.id); + assert_eq!(wire_parts_value(&first), vector.expected, "{}", vector.id); + } + "social.ask.build_authored_draft.invalid" => { + let error = RadrootsAuthoredAsk::new(input_str(vector, "content"), Vec::new()) + .expect_err("invalid authored Ask must fail"); + assert_eq!(error.code(), expected_str(vector, "error"), "{}", vector.id); + } + "social.post.project_verified_event.valid" => { + let envelope = canonical_envelope(input_str(vector, "event_json")); + let verified = verify_nip01_event(envelope) + .unwrap_or_else(|error| panic!("{} verification failed: {error}", vector.id)); + let projection = project_verified_post_event(&verified) + .unwrap_or_else(|error| panic!("{} projection failed: {error}", vector.id)); + assert_eq!( + projection_value(&projection), + vector.expected, + "{}", + vector.id + ); + } + "social.post.project_verified_event.invalid" => { + let envelope = canonical_envelope(input_str(vector, "event_json")); + let verified = verify_nip01_event(envelope) + .unwrap_or_else(|error| panic!("{} verification failed: {error}", vector.id)); + let error = project_verified_post_event(&verified) + .expect_err("invalid verified projection must fail"); + assert_eq!(error.code(), expected_str(vector, "error"), "{}", vector.id); + } + "social.post.verify_and_admit_event.valid" => { + let envelope = canonical_envelope(input_str(vector, "event_json")); + let expected_envelope = envelope.clone(); let generic = identify_event_contract( envelope.kind_u32(), &envelope.tags_as_vec(), @@ -81,31 +269,43 @@ fn execute(vector: &Vector) { .expect("unsigned post identification remains available"); assert_eq!(generic.id, "radroots.social.post.v1", "{}", vector.id); - let admitted = verify_and_admit_post_event(envelope) + let outcome = 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); + let actual = match outcome { + RadrootsPostAdmissionOutcome::Root(admitted) => { + assert_eq!(admitted.event(), &expected_envelope, "{}", vector.id); + let actual = json!({ + "outcome": "root", + "contract_id": admitted.contract().id, + "projection": projection_value(admitted.projection()), + }); + let (verified, projection) = admitted.into_parts(); + assert_eq!(verified.event(), &expected_envelope, "{}", vector.id); + assert!(projection.classification().is_root_card(), "{}", vector.id); + actual + } + RadrootsPostAdmissionOutcome::ThreadExcluded(candidate) => { + assert_eq!(candidate.event(), &expected_envelope, "{}", vector.id); + let actual = json!({ + "outcome": "thread_excluded", + "projection": projection_value(candidate.projection()), + }); + let (verified, projection) = candidate.into_parts(); + assert_eq!(verified.event(), &expected_envelope, "{}", vector.id); + assert_eq!( + projection.classification(), + RadrootsPostClassification::ThreadExcluded, + "{}", + vector.id + ); + actual + } + _ => panic!("{} returned an unsupported admission outcome", vector.id), + }; + assert_eq!(actual, vector.expected, "{}", vector.id); } - "post.verify_and_admit.invalid" => { + "social.post.verify_and_admit_event.invalid" => { + let envelope = canonical_envelope(input_str(vector, "event_json")); 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); @@ -122,6 +322,15 @@ fn projection_value(projection: &RadrootsInboundPostProjection) -> Value { "diagnostics": diagnostic_codes(projection.diagnostics()), "imeta": projection.imeta().iter().map(|media| json!({ "raw_fields": media.raw_fields(), + "url": media.url(), + "sha256": media.sha256(), + "media_type": media.media_type(), + "dimensions": media.dimensions().map(|dimensions| json!({ + "width": dimensions.width(), + "height": dimensions.height(), + })), + "size": media.size(), + "alt": media.alt(), "fallbacks": media.fallbacks(), "unknown_fields": media.unknown_fields(), "diagnostics": diagnostic_codes(media.diagnostics()), @@ -130,9 +339,83 @@ fn projection_value(projection: &RadrootsInboundPostProjection) -> Value { }) } +fn wire_parts_value(parts: &RadrootsNip01EventWireParts) -> Value { + json!({ + "kind": parts.kind, + "content": parts.content, + "tags": parts.tags, + }) +} + +fn authored_images( + vector: &Vector, +) -> Result<Vec<RadrootsAuthoredPostImage>, RadrootsAuthoredPostError> { + vector + .input + .get("images") + .and_then(Value::as_array) + .map_or_else( + || Ok(Vec::new()), + |images| { + images + .iter() + .map(|image| authored_image(vector, image)) + .collect() + }, + ) +} + +fn authored_image( + vector: &Vector, + input: &Value, +) -> Result<RadrootsAuthoredPostImage, RadrootsAuthoredPostError> { + let bytes = value_str(vector, input, "bytes_utf8").as_bytes(); + let media_type = RadrootsBlossomMediaType::parse(value_str(vector, input, "media_type")) + .unwrap_or_else(|error| panic!("{} media type setup failed: {error}", vector.id)); + let hash = RadrootsBlossomSha256::digest(bytes); + let descriptor = RadrootsBlossomBlobDescriptor::new( + RadrootsBlossomBlobUrl::parse(value_str(vector, input, "url")) + .unwrap_or_else(|error| panic!("{} URL setup failed: {error}", vector.id)), + hash, + u64::try_from(bytes.len()).expect("authored image byte length must fit u64"), + media_type.clone(), + value_u64(vector, input, "uploaded"), + ) + .unwrap_or_else(|error| panic!("{} descriptor setup failed: {error}", vector.id)) + .approve_reference() + .unwrap_or_else(|error| panic!("{} URL approval failed: {error}", vector.id)) + .verify_bytes(bytes, &media_type) + .unwrap_or_else(|error| panic!("{} byte verification failed: {error}", vector.id)); + let image = RadrootsAuthoredImage::try_from(descriptor) + .unwrap_or_else(|error| panic!("{} image typestate setup failed: {error}", vector.id)); + let dimensions = RadrootsPostImageDimensions::new( + u32::try_from(value_u64(vector, input, "width")) + .unwrap_or_else(|_| panic!("{} image.width must fit u32", vector.id)), + u32::try_from(value_u64(vector, input, "height")) + .unwrap_or_else(|_| panic!("{} image.height must fit u32", vector.id)), + )?; + let mut image = + RadrootsAuthoredPostImage::new(image, dimensions, value_str(vector, input, "alt"))?; + if let Some(fallbacks) = input.get("fallbacks").and_then(Value::as_array) { + for fallback in fallbacks { + image = image.try_with_fallback( + RadrootsBlossomBlobUrl::parse( + fallback + .as_str() + .unwrap_or_else(|| panic!("{} image fallback must be a string", vector.id)), + ) + .unwrap_or_else(|error| panic!("{} fallback setup failed: {error}", vector.id)) + .approve() + .unwrap_or_else(|error| panic!("{} fallback approval failed: {error}", vector.id)), + )?; + } + } + Ok(image) +} + fn classification_label(classification: RadrootsPostClassification) -> &'static str { match classification { - RadrootsPostClassification::Reply => "reply", + RadrootsPostClassification::ThreadExcluded => "thread_excluded", RadrootsPostClassification::Update => "update", RadrootsPostClassification::PhotoUpdate => "photo_update", RadrootsPostClassification::Ask => "ask", @@ -160,8 +443,38 @@ fn input_str<'a>(vector: &'a Vector, field: &str) -> &'a str { .unwrap_or_else(|| panic!("{} input.{field} must be a string", vector.id)) } +fn value_str<'a>(vector: &Vector, value: &'a Value, field: &str) -> &'a str { + value[field] + .as_str() + .unwrap_or_else(|| panic!("{} image.{field} must be a string", vector.id)) +} + +fn value_u64(vector: &Vector, value: &Value, field: &str) -> u64 { + value[field] + .as_u64() + .unwrap_or_else(|| panic!("{} image.{field} must be a u64", 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)) } + +fn vector_cases(vectors: &[Vector]) -> BTreeMap<&str, &str> { + let mut cases = BTreeMap::new(); + for vector in vectors { + assert!( + cases + .insert(vector.id.as_str(), vector.kind.as_str()) + .is_none(), + "duplicate post vector id {}", + vector.id + ); + } + cases +} + +fn expected_vector_cases() -> BTreeMap<&'static str, &'static str> { + EXPECTED_VECTOR_CASES.into_iter().collect() +} diff --git a/crates/event_codec/tests/wire.rs b/crates/event_codec/tests/wire.rs @@ -1,6 +1,6 @@ use radroots_event::contract::RadrootsContractValidationError; use radroots_event::draft::{RadrootsDraftError, RadrootsEventDraft}; -use radroots_event::kinds::{KIND_KNOWLEDGE_CLAIM, KIND_KNOWLEDGE_SOURCE, KIND_POST}; +use radroots_event::kinds::{KIND_GEOCHAT, KIND_KNOWLEDGE_CLAIM, KIND_KNOWLEDGE_SOURCE}; use radroots_event::wire::RadrootsNip01EventWireParts; use radroots_event_codec::wire::{canonicalize_tags, empty_content}; @@ -28,7 +28,7 @@ fn wire_canonicalize_tags_trims_sorts_and_dedups() { #[test] fn wire_parts_are_canonical_event_owned_payload_parts() { let parts = RadrootsNip01EventWireParts { - kind: KIND_POST, + kind: KIND_GEOCHAT, content: "hello".to_string(), tags: vec![vec!["t".to_string(), "a".to_string()]], }; @@ -41,13 +41,13 @@ fn wire_parts_are_canonical_event_owned_payload_parts() { #[test] fn draft_validation_accepts_wire_parts_without_signed_envelope() { let parts = RadrootsNip01EventWireParts { - kind: KIND_POST, + kind: KIND_GEOCHAT, content: "hello".to_string(), tags: vec![vec!["t".to_string(), "a".to_string()]], }; let draft = RadrootsEventDraft::new( - "radroots.social.post.v1", + "radroots.social.geochat.v1", parts.kind, 99, parts.tags, @@ -56,7 +56,7 @@ fn draft_validation_accepts_wire_parts_without_signed_envelope() { ) .expect("draft"); - assert_eq!(draft.kind_u32(), KIND_POST); + assert_eq!(draft.kind_u32(), KIND_GEOCHAT); assert_eq!(draft.created_at_u64(), 99); assert_eq!(draft.expected_pubkey_str(), "a".repeat(64)); assert_eq!(draft.content(), "hello"); diff --git a/crates/event_store/Cargo.toml b/crates/event_store/Cargo.toml @@ -34,6 +34,7 @@ sqlx = { workspace = true, optional = true, features = ["derive"] } thiserror = { workspace = true } [dev-dependencies] +nostr = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/crates/event_store/src/store.rs b/crates/event_store/src/store.rs @@ -1639,6 +1639,7 @@ fn validate_trade_query_limit(limit: u32) -> Result<(), RadrootsEventStoreError> #[cfg(test)] mod tests { use super::*; + use nostr::EventBuilder; use radroots_event::draft::RadrootsSignedEvent; use radroots_event::event_head::event_head_candidate_for_event; use radroots_event::ids::{RadrootsAddressableCoordinate, RadrootsInventoryBinId}; @@ -1655,8 +1656,8 @@ mod tests { }; use radroots_event::wire::{RadrootsNip01EventWire, compute_canonical_nip01_event_id}; use radroots_nostr::prelude::{ - RadrootsNostrKeys, RadrootsNostrSecretKey, RadrootsNostrTimestamp, - radroots_nostr_build_event, + RadrootsNostrKeys, RadrootsNostrKind, RadrootsNostrSecretKey, RadrootsNostrTag, + RadrootsNostrTagKind, RadrootsNostrTimestamp, }; const FIXTURE_ALICE_SECRET_KEY_HEX: &str = @@ -1678,6 +1679,27 @@ mod tests { RadrootsNostrKeys::new(secret_key) } + fn test_event_builder( + kind: u32, + content: impl Into<String>, + tags: Vec<Vec<String>>, + ) -> EventBuilder { + let tags: Vec<_> = tags + .into_iter() + .filter(|tag| !tag.is_empty()) + .map(|mut tag| { + let key = tag.remove(0); + RadrootsNostrTag::custom(RadrootsNostrTagKind::Custom(key.into()), tag) + }) + .collect(); + EventBuilder::new( + RadrootsNostrKind::Custom(u16::try_from(kind).expect("test kind must fit NIP-01")), + content.into(), + ) + .tags(tags) + .allow_self_tagging() + } + fn event_id(character: char) -> String { core::iter::repeat_n(character, 64).collect() } @@ -1843,12 +1865,11 @@ mod tests { for parent in &canonical.envelope.parent_mutation_ids { tags.push(vec!["e".to_owned(), parent.to_string()]); } - let raw_event = radroots_nostr_build_event( + let raw_event = test_event_builder( canonical.envelope.mutation_kind().nostr_kind(), content, tags, ) - .expect("trade event builder") .custom_created_at(RadrootsNostrTimestamp::from_secs( canonical.envelope.authored_at_unix_s, )) @@ -1863,8 +1884,7 @@ mod tests { tags: Vec<Vec<String>>, content: &str, ) -> RadrootsSignedEvent { - let raw_event = radroots_nostr_build_event(kind, content, tags) - .expect("builder") + let raw_event = test_event_builder(kind, content, tags) .custom_created_at(RadrootsNostrTimestamp::from_secs(u64::from(created_at))) .sign_with_keys(&fixture_keys()) .expect("signed event"); diff --git a/crates/net/src/nostr_client/events/custom.rs b/crates/net/src/nostr_client/events/custom.rs @@ -1,57 +0,0 @@ -#![forbid(unsafe_code)] - -use crate::error::{NetError, Result}; -use radroots_nostr::prelude::{ - RadrootsNostrEvent, RadrootsNostrFilter, radroots_nostr_build_event, radroots_nostr_send_event, -}; - -use crate::nostr_client::manager::NostrClientManager; - -impl NostrClientManager { - pub async fn send_custom_event( - &self, - kind: u32, - content: String, - tags: Vec<Vec<String>>, - ) -> Result<String> { - let builder = radroots_nostr_build_event(kind, content, tags) - .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 send_custom_event_blocking( - &self, - kind: u32, - content: String, - tags: Vec<Vec<String>>, - ) -> Result<String> { - let rt = self.inner.rt.clone(); - let this = self.clone(); - rt.block_on(async move { this.send_custom_event(kind, content, tags).await }) - } - - pub async fn fetch_events( - &self, - filter: RadrootsNostrFilter, - timeout: core::time::Duration, - ) -> Result<Vec<RadrootsNostrEvent>> { - self.inner - .client - .fetch_events(filter, timeout) - .await - .map_err(|e| NetError::Msg(e.to_string())) - } - - pub fn fetch_events_blocking( - &self, - filter: RadrootsNostrFilter, - timeout: core::time::Duration, - ) -> Result<Vec<RadrootsNostrEvent>> { - let rt = self.inner.rt.clone(); - let this = self.clone(); - rt.block_on(async move { this.fetch_events(filter, timeout).await }) - } -} diff --git a/crates/net/src/nostr_client/events/fetch.rs b/crates/net/src/nostr_client/events/fetch.rs @@ -0,0 +1,30 @@ +#![forbid(unsafe_code)] + +use crate::error::{NetError, Result}; +use radroots_nostr::prelude::{RadrootsNostrEvent, RadrootsNostrFilter}; + +use crate::nostr_client::manager::NostrClientManager; + +impl NostrClientManager { + pub async fn fetch_events( + &self, + filter: RadrootsNostrFilter, + timeout: core::time::Duration, + ) -> Result<Vec<RadrootsNostrEvent>> { + self.inner + .client + .fetch_events(filter, timeout) + .await + .map_err(|error| NetError::Msg(error.to_string())) + } + + pub fn fetch_events_blocking( + &self, + filter: RadrootsNostrFilter, + timeout: core::time::Duration, + ) -> Result<Vec<RadrootsNostrEvent>> { + let rt = self.inner.rt.clone(); + let this = self.clone(); + rt.block_on(async move { this.fetch_events(filter, timeout).await }) + } +} diff --git a/crates/net/src/nostr_client/events/post.rs b/crates/net/src/nostr_client/events/post.rs @@ -3,7 +3,7 @@ use radroots_event::post::{RadrootsAuthoredUpdate, RadrootsPost}; use radroots_event_codec::parsed::RadrootsParsedData; use radroots_nostr::prelude::{ radroots_nostr_build_post_reply_event, radroots_nostr_build_update_event, - radroots_nostr_fetch_post_events, radroots_nostr_send_event, + radroots_nostr_fetch_post_events, radroots_nostr_send_event, radroots_nostr_send_post_event, }; use crate::nostr_client::manager::NostrClientManager; @@ -12,7 +12,7 @@ impl NostrClientManager { 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) + let out = radroots_nostr_send_post_event(&self.inner.client, builder) .await .map_err(|e| NetError::Msg(e.to_string()))?; Ok(out.val.to_string()) diff --git a/crates/net/src/nostr_client/mod.rs b/crates/net/src/nostr_client/mod.rs @@ -2,7 +2,7 @@ mod connection; mod inner; mod manager; mod events { - mod custom; + mod fetch; mod post; mod profile; } diff --git a/crates/nostr/Cargo.toml b/crates/nostr/Cargo.toml @@ -51,7 +51,9 @@ serde_json = { workspace = true } thiserror = { workspace = true } [dev-dependencies] -radroots_blossom = { workspace = true, default-features = false, features = ["std"] } +radroots_blossom = { workspace = true, default-features = false, features = [ + "std", +] } tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } [[test]] diff --git a/crates/nostr/README b/crates/nostr/README @@ -12,7 +12,7 @@ primitives for the `radroots` core libraries. representations; * strict BUD-11 signed HTTP authorization adapters behind the `blossom` feature; - * optional NIP-11 and NIP-17 support alongside `std` and `no_std` builds. + * optional NIP-11 and NIP-17 support across feature-gated builds. The `blossom` feature signs kind-24242 authorization events and encodes or verifies their `Authorization: Nostr` HTTP values. It does not publish these @@ -24,9 +24,20 @@ 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. +root product cards. Generic protocol builders reject kind-0 Profile events and +unmarked root kind-1 events at both direct signing and client-publication +boundaries. Kind-1 builders carrying an `e` tag remain available for thread +compatibility. Typed media builders can sign or publish only after the owning +runtime separately proves successful BUD-02 upload completion; their +byte-verified descriptors do not attest upload completion. The generic net +manager intentionally exposes no direct PhotoUpdate or media Ask publisher. + +The opaque generic-builder policy governs Radroots builder signing and client +publication. It does not redefine the standard NIP-46 `sign_event` method or a +signer backend's externally supplied unsigned-event operation. Those are +explicit low-level Nostr interoperability boundaries; their outputs carry no +Radroots typed product-authoring claim and are not product authoring entry +points. ## Portable relay-client lifecycle diff --git a/crates/nostr/src/blossom.rs b/crates/nostr/src/blossom.rs @@ -14,9 +14,9 @@ use radroots_blossom::{ }; use crate::types::{ - RadrootsNostrEvent, RadrootsNostrEventBuilder, RadrootsNostrEventId, RadrootsNostrKeys, - RadrootsNostrKind, RadrootsNostrPublicKey, RadrootsNostrTag, RadrootsNostrTagKind, - RadrootsNostrTimestamp, + RadrootsNostrEvent, RadrootsNostrEventBuilderUnchecked, RadrootsNostrEventId, + RadrootsNostrKeys, RadrootsNostrKind, RadrootsNostrPublicKey, RadrootsNostrTag, + RadrootsNostrTagKind, RadrootsNostrTimestamp, }; const AUTHORIZATION_SCHEME: &str = "Nostr "; @@ -236,10 +236,13 @@ pub fn radroots_nostr_sign_blossom_authorization( }) .collect::<Vec<_>>(); finish_signed_event( - RadrootsNostrEventBuilder::new(RadrootsNostrKind::Custom(wire.kind()), wire.content()) - .tags(tags) - .custom_created_at(RadrootsNostrTimestamp::from_secs(wire.created_at())) - .sign_with_keys(keys), + RadrootsNostrEventBuilderUnchecked::new( + RadrootsNostrKind::Custom(wire.kind()), + wire.content(), + ) + .tags(tags) + .custom_created_at(RadrootsNostrTimestamp::from_secs(wire.created_at())) + .sign_with_keys(keys), ) } diff --git a/crates/nostr/src/client.rs b/crates/nostr/src/client.rs @@ -9,9 +9,11 @@ use nostr_sdk::{Client, ClientBuilder, ClientOptions}; use radroots_identity::RadrootsIdentity; use crate::error::RadrootsNostrError; +#[cfg(feature = "events")] +use crate::events::post::RadrootsNostrPostEventBuilder; use crate::types::{ - RadrootsNostrEvent, RadrootsNostrEventBuilder, RadrootsNostrEventId, RadrootsNostrEventStream, - RadrootsNostrFilter, RadrootsNostrKeys, RadrootsNostrMonitor, RadrootsNostrOutput, + RadrootsNostrEvent, RadrootsNostrEventId, RadrootsNostrEventStream, RadrootsNostrFilter, + RadrootsNostrGenericEventBuilder, RadrootsNostrKeys, RadrootsNostrMonitor, RadrootsNostrOutput, RadrootsNostrPublicKey, RadrootsNostrRelay, RadrootsNostrRelayUrl, RadrootsNostrSubscribeAutoCloseOptions, RadrootsNostrSubscriptionId, }; @@ -239,13 +241,35 @@ impl RadrootsNostrClient { self.inner.unsubscribe(subscription_id).await; } + /// Publishes a generic event builder. + /// + /// Kind 0 profiles and unmarked root kind 1 events are rejected because + /// their product shape must come from typed authoring. Kind 1 builders + /// with an `e` tag remain available for thread compatibility. pub async fn send_event_builder( &self, - event: RadrootsNostrEventBuilder, + event: RadrootsNostrGenericEventBuilder, ) -> Result<RadrootsNostrOutput<RadrootsNostrEventId>, RadrootsNostrError> { + let event = event.into_checked_event_builder()?; Ok(self.inner.send_event_builder(event).await?) } + /// Publishes a validated root post through the sealed typed boundary. + #[cfg(feature = "events")] + pub async fn send_post_event_builder( + &self, + event: RadrootsNostrPostEventBuilder, + ) -> Result<RadrootsNostrOutput<RadrootsNostrEventId>, RadrootsNostrError> { + Ok(self + .inner + .send_event_builder(event.into_event_builder()) + .await?) + } + + /// Relays a caller-supplied signed event. + /// + /// This is a transport boundary, not an authored-builder boundary. The + /// caller is responsible for the event's authoring policy and signature. pub async fn send_event( &self, event: &RadrootsNostrEvent, @@ -273,13 +297,23 @@ impl RadrootsNostrClient { } } +/// Publishes a generic builder subject to typed-authoring reservations. pub async fn radroots_nostr_send_event( client: &RadrootsNostrClient, - event: RadrootsNostrEventBuilder, + event: RadrootsNostrGenericEventBuilder, ) -> Result<RadrootsNostrOutput<RadrootsNostrEventId>, RadrootsNostrError> { client.send_event_builder(event).await } +/// Publishes a validated root post through the sealed typed boundary. +#[cfg(feature = "events")] +pub async fn radroots_nostr_send_post_event( + client: &RadrootsNostrClient, + event: RadrootsNostrPostEventBuilder, +) -> Result<RadrootsNostrOutput<RadrootsNostrEventId>, RadrootsNostrError> { + client.send_post_event_builder(event).await +} + pub async fn radroots_nostr_fetch_event_by_id( client: &RadrootsNostrClient, id: &str, @@ -296,9 +330,10 @@ pub async fn radroots_nostr_fetch_event_by_id( #[cfg(test)] mod tests { use super::{RadrootsNostrClient, RadrootsNostrClientOptions}; + use crate::error::RadrootsNostrError; use crate::types::{ - RadrootsNostrEventBuilder, RadrootsNostrFilter, RadrootsNostrKeys, RadrootsNostrKind, - RadrootsNostrSecretKey, RadrootsNostrSubscriptionId, + RadrootsNostrFilter, RadrootsNostrGenericEventBuilder, RadrootsNostrKeys, + RadrootsNostrKind, RadrootsNostrSecretKey, RadrootsNostrSubscriptionId, RadrootsNostrTag, }; #[tokio::test] @@ -331,7 +366,7 @@ mod tests { .await; assert!(subscription.is_err()); - let event = RadrootsNostrEventBuilder::new(RadrootsNostrKind::TextNote, "test") + let event = nostr::EventBuilder::new(RadrootsNostrKind::TextNote, "test") .sign_with_keys(&keys) .expect("test event"); let published = client.send_event_to_relays(&[], &event).await; @@ -341,4 +376,78 @@ mod tests { .unsubscribe(&RadrootsNostrSubscriptionId::new("missing")) .await; } + + #[tokio::test] + async fn generic_builder_rejects_typed_only_profiles_and_root_kind_one() { + let client = RadrootsNostrClient::new_signerless(); + let raw_kind_one = RadrootsNostrKind::Custom(RadrootsNostrKind::TextNote.as_u16()); + let builders = [ + RadrootsNostrGenericEventBuilder::new(RadrootsNostrKind::Metadata, "{}"), + RadrootsNostrGenericEventBuilder::new(raw_kind_one, "Unmarked root"), + RadrootsNostrGenericEventBuilder::new(raw_kind_one, "Is it ripe?").tags([ + RadrootsNostrTag::parse(["t", "radroots-ask"]).expect("ask marker"), + RadrootsNostrTag::parse([ + "imeta", + "url https://media.example/ask.webp", + "m image/webp", + ]) + .expect("image metadata"), + ]), + ]; + + for builder in builders { + let error = client + .send_event_builder(builder) + .await + .expect_err("raw root kind-1 builder must be rejected before signer access"); + + assert!(matches!( + error, + RadrootsNostrError::TypedAuthoringRequired { .. } + )); + } + } + + #[tokio::test] + async fn generic_builder_allows_e_tagged_thread_compatibility() { + let keys = RadrootsNostrKeys::new( + RadrootsNostrSecretKey::from_slice(&[3_u8; 32]).expect("test secret key"), + ); + let parent_author = keys.public_key().to_hex(); + let client = RadrootsNostrClient::new(keys); + let builder = crate::events::post::radroots_nostr_build_post_reply_event( + "0000000000000000000000000000000000000000000000000000000000000000", + &parent_author, + "Reply", + None, + ) + .expect("reply builder"); + + let error = client + .send_event_builder(builder) + .await + .expect_err("no relay is configured"); + + assert!(matches!(error, RadrootsNostrError::ClientError(_))); + } + + #[cfg(feature = "events")] + #[tokio::test] + async fn sealed_post_builder_reaches_typed_client_publication() { + let keys = RadrootsNostrKeys::new( + RadrootsNostrSecretKey::from_slice(&[4_u8; 32]).expect("test secret key"), + ); + let client = RadrootsNostrClient::new(keys); + let update = radroots_event::post::RadrootsAuthoredUpdate::new("Farm update") + .expect("authored update"); + let builder = crate::events::post::radroots_nostr_build_update_event(&update) + .expect("sealed post builder"); + + let error = client + .send_post_event_builder(builder) + .await + .expect_err("no relay is configured"); + + assert!(matches!(error, RadrootsNostrError::ClientError(_))); + } } diff --git a/crates/nostr/src/draft_signing.rs b/crates/nostr/src/draft_signing.rs @@ -1,7 +1,7 @@ #![forbid(unsafe_code)] use crate::error::RadrootsNostrError; -use crate::events::radroots_nostr_build_event; +use crate::events::radroots_nostr_build_event_unchecked; use crate::types::{RadrootsNostrKeys, RadrootsNostrTimestamp}; use nostr::JsonUtil; use radroots_event::draft::{RadrootsEventDraft, RadrootsSignedEvent}; @@ -11,6 +11,7 @@ pub fn radroots_nostr_sign_frozen_draft( keys: &RadrootsNostrKeys, draft: &RadrootsEventDraft, ) -> Result<RadrootsSignedEvent, RadrootsNostrError> { + draft.validate_for_signing()?; let actual_pubkey = keys.public_key().to_hex(); if actual_pubkey != draft.expected_pubkey_str() { return Err(RadrootsNostrError::FrozenDraftPubkeyMismatch { @@ -19,7 +20,7 @@ pub fn radroots_nostr_sign_frozen_draft( }); } - let event = radroots_nostr_build_event( + let event = radroots_nostr_build_event_unchecked( draft.kind_u32(), draft.content().to_owned(), draft.tags_as_vec(), @@ -47,19 +48,19 @@ mod tests { use crate::types::{RadrootsNostrKeys, RadrootsNostrSecretKey}; use nostr::JsonUtil; use radroots_event::draft::RadrootsEventDraft; - use radroots_event::kinds::KIND_POST; + use radroots_event::kinds::KIND_GEOCHAT; fn fixture_keys(secret_key_hex: &str) -> RadrootsNostrKeys { let secret_key = RadrootsNostrSecretKey::from_hex(secret_key_hex).expect("secret key"); RadrootsNostrKeys::new(secret_key) } - fn post_draft(expected_pubkey: &str) -> RadrootsEventDraft { + fn generic_draft(expected_pubkey: &str) -> RadrootsEventDraft { RadrootsEventDraft::new( - "radroots.social.post.v1", - KIND_POST, + "radroots.social.geochat.v1", + KIND_GEOCHAT, 1_700_000_000, - vec![vec!["t".to_owned(), "soil".to_owned()]], + Vec::new(), "hello", expected_pubkey, ) @@ -69,7 +70,7 @@ mod tests { #[test] fn sign_frozen_draft_uses_fixed_created_at_and_expected_id() { let keys = fixture_keys(FIXTURE_ALICE.secret_key_hex); - let draft = post_draft(FIXTURE_ALICE.public_key_hex); + let draft = generic_draft(FIXTURE_ALICE.public_key_hex); let signed = radroots_nostr_sign_frozen_draft(&keys, &draft).expect("signed event"); assert_eq!(signed.id_str(), draft.expected_event_id_str()); @@ -88,7 +89,7 @@ mod tests { #[test] fn sign_frozen_draft_rejects_wrong_signer() { let keys = fixture_keys(FIXTURE_BOB.secret_key_hex); - let draft = post_draft(FIXTURE_ALICE.public_key_hex); + let draft = generic_draft(FIXTURE_ALICE.public_key_hex); let error = radroots_nostr_sign_frozen_draft(&keys, &draft).expect_err("wrong signer"); assert!(matches!( @@ -98,17 +99,11 @@ mod tests { } #[test] - fn sign_frozen_draft_rejects_event_id_mismatch() { - let keys = fixture_keys(FIXTURE_ALICE.secret_key_hex); - let draft = post_draft(FIXTURE_ALICE.public_key_hex); + fn frozen_draft_deserialization_rejects_event_id_mismatch() { + let draft = generic_draft(FIXTURE_ALICE.public_key_hex); let mut raw = serde_json::to_value(&draft).expect("draft json"); raw["expected_event_id"] = serde_json::Value::String("f".repeat(64)); - let draft: RadrootsEventDraft = serde_json::from_value(raw).expect("draft"); - let error = radroots_nostr_sign_frozen_draft(&keys, &draft).expect_err("id mismatch"); - - assert!(matches!( - error, - RadrootsNostrError::FrozenDraftEventIdMismatch { .. } - )); + serde_json::from_value::<RadrootsEventDraft>(raw) + .expect_err("tampered draft must fail before signing"); } } diff --git a/crates/nostr/src/error.rs b/crates/nostr/src/error.rs @@ -17,6 +17,9 @@ pub enum RadrootsNostrError { #[error("Client configuration error: {0}")] ClientConfigError(String), + #[error("Nostr event kind {kind} requires typed authoring")] + TypedAuthoringRequired { kind: u16 }, + #[error("Event error: {0}")] EventError(#[from] nostr::event::Error), diff --git a/crates/nostr/src/event_adapters.rs b/crates/nostr/src/event_adapters.rs @@ -17,7 +17,8 @@ use crate::types::{RadrootsNostrEvent, RadrootsNostrMetadata}; /// /// 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. +/// the caller needs root Update, PhotoUpdate, or Ask admission. Its explicit +/// thread-excluded outcome makes no Reply claim. pub fn to_post_event_metadata(e: &RadrootsNostrEvent) -> RadrootsParsedData<RadrootsPost> { RadrootsParsedData::new( e.id.to_string(), diff --git a/crates/nostr/src/event_convert.rs b/crates/nostr/src/event_convert.rs @@ -1,9 +1,13 @@ #![forbid(unsafe_code)] use crate::types::RadrootsNostrEvent as RadrootsNostrRawEvent; -use radroots_event::{RadrootsEventEnvelope, RadrootsEventEnvelopeParts, RadrootsEventPtr}; +use radroots_event::{ + RadrootsEventEnvelope, RadrootsEventEnvelopeError, RadrootsEventEnvelopeParts, RadrootsEventPtr, +}; -pub fn radroots_event_from_nostr(event: &RadrootsNostrRawEvent) -> RadrootsEventEnvelope { +pub fn radroots_event_from_nostr( + event: &RadrootsNostrRawEvent, +) -> Result<RadrootsEventEnvelope, RadrootsEventEnvelopeError> { RadrootsEventEnvelope::new(RadrootsEventEnvelopeParts { id: event.id.to_string(), author: event.pubkey.to_string(), @@ -13,7 +17,6 @@ pub fn radroots_event_from_nostr(event: &RadrootsNostrRawEvent) -> RadrootsEvent content: event.content.clone(), sig: event.sig.to_string(), }) - .expect("nostr event is canonical") } pub fn radroots_event_ptr_from_nostr(event: &RadrootsNostrRawEvent) -> RadrootsEventPtr { @@ -22,3 +25,56 @@ pub fn radroots_event_ptr_from_nostr(event: &RadrootsNostrRawEvent) -> RadrootsE relays: None, } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::events::radroots_nostr_build_event_unchecked; + use crate::test_fixtures::FIXTURE_ALICE; + use crate::types::{RadrootsNostrKeys, RadrootsNostrSecretKey, RadrootsNostrTimestamp}; + use radroots_event::wire::{DEFAULT_CONTENT_MAX_BYTES, DEFAULT_TAG_TOTAL_ELEMENT_MAX_COUNT}; + + fn fixture_keys() -> RadrootsNostrKeys { + let secret_key = + RadrootsNostrSecretKey::from_hex(FIXTURE_ALICE.secret_key_hex).expect("secret key"); + RadrootsNostrKeys::new(secret_key) + } + + fn signed_event(content: String, tags: Vec<Vec<String>>) -> RadrootsNostrRawEvent { + radroots_nostr_build_event_unchecked(1, content, tags) + .expect("builder") + .custom_created_at(RadrootsNostrTimestamp::from_secs(1_700_000_000)) + .sign_with_keys(&fixture_keys()) + .expect("signed event") + } + + #[test] + fn conversion_returns_typed_content_limit_error() { + let content = "x".repeat(DEFAULT_CONTENT_MAX_BYTES + 1); + let event = signed_event(content, Vec::new()); + + assert_eq!( + radroots_event_from_nostr(&event), + Err(RadrootsEventEnvelopeError::ContentTooLarge { + max: DEFAULT_CONTENT_MAX_BYTES, + actual: DEFAULT_CONTENT_MAX_BYTES + 1, + }) + ); + } + + #[test] + fn conversion_returns_typed_total_tag_element_limit_error() { + let mut tag = Vec::with_capacity(DEFAULT_TAG_TOTAL_ELEMENT_MAX_COUNT + 1); + tag.push("x".to_owned()); + tag.resize(DEFAULT_TAG_TOTAL_ELEMENT_MAX_COUNT + 1, String::new()); + let event = signed_event(String::new(), vec![tag]); + + assert_eq!( + radroots_event_from_nostr(&event), + Err(RadrootsEventEnvelopeError::TooManyTagElements { + max: DEFAULT_TAG_TOTAL_ELEMENT_MAX_COUNT, + actual: DEFAULT_TAG_TOTAL_ELEMENT_MAX_COUNT + 1, + }) + ); + } +} diff --git a/crates/nostr/src/event_verify.rs b/crates/nostr/src/event_verify.rs @@ -71,7 +71,7 @@ fn raw_event_from_radroots(event: &RadrootsEventEnvelope) -> Option<RadrootsNost mod tests { use super::*; use crate::event_convert::radroots_event_from_nostr; - use crate::events::radroots_nostr_build_event; + use crate::events::radroots_nostr_build_event_unchecked; use crate::test_fixtures::FIXTURE_ALICE; use crate::types::{RadrootsNostrKeys, RadrootsNostrSecretKey}; use radroots_event::{RadrootsEventEnvelopeParts, kinds::KIND_POST}; @@ -83,7 +83,7 @@ mod tests { } fn signed_event() -> RadrootsEventEnvelope { - let raw_event = radroots_nostr_build_event( + let raw_event = radroots_nostr_build_event_unchecked( KIND_POST, "hello", vec![vec!["t".to_owned(), "soil".to_owned()]], @@ -92,7 +92,7 @@ mod tests { .custom_created_at(RadrootsNostrTimestamp::from_secs(1_700_000_000)) .sign_with_keys(&fixture_keys()) .expect("signed event"); - radroots_event_from_nostr(&raw_event) + radroots_event_from_nostr(&raw_event).expect("Radroots event envelope") } fn envelope_with( diff --git a/crates/nostr/src/events/application_handler.rs b/crates/nostr/src/events/application_handler.rs @@ -5,14 +5,14 @@ extern crate alloc; use alloc::{string::String, vec::Vec}; use crate::error::RadrootsNostrError; -use crate::events::radroots_nostr_build_event; +use crate::events::radroots_nostr_build_event_unchecked; #[cfg(feature = "client")] use crate::filter::radroots_nostr_filter_tag; #[cfg(feature = "client")] use crate::tags::radroots_nostr_tag_first_value; #[cfg(feature = "client")] use crate::types::{RadrootsNostrEvent, RadrootsNostrFilter, RadrootsNostrKind}; -use crate::types::{RadrootsNostrEventBuilder, RadrootsNostrMetadata}; +use crate::types::{RadrootsNostrGenericEventBuilder, RadrootsNostrMetadata}; #[cfg(feature = "client")] use core::time::Duration; use radroots_event::kinds::KIND_APPLICATION_HANDLER; @@ -42,7 +42,7 @@ impl RadrootsNostrApplicationHandlerSpec { pub fn radroots_nostr_build_application_handler_event( spec: &RadrootsNostrApplicationHandlerSpec, -) -> Result<RadrootsNostrEventBuilder, RadrootsNostrError> { +) -> Result<RadrootsNostrGenericEventBuilder, RadrootsNostrError> { if spec.kinds.is_empty() { return Err(RadrootsNostrError::FilterTagError( "application handler kinds are empty".to_string(), @@ -86,7 +86,8 @@ pub fn radroots_nostr_build_application_handler_event( tags.push(tag.clone()); } - radroots_nostr_build_event(KIND_APPLICATION_HANDLER, content, tags) + let builder = radroots_nostr_build_event_unchecked(KIND_APPLICATION_HANDLER, content, tags)?; + Ok(RadrootsNostrGenericEventBuilder::from_unchecked(builder)) } pub fn radroots_nostr_metadata_has_fields(md: &RadrootsNostrMetadata) -> bool { diff --git a/crates/nostr/src/events/jobs.rs b/crates/nostr/src/events/jobs.rs @@ -1,7 +1,12 @@ +use alloc::{string::String, vec::Vec}; + use nostr::nips::nip90::{DataVendingMachineStatus, JobFeedbackData}; use crate::error::RadrootsNostrError; -use crate::types::{RadrootsNostrEvent, RadrootsNostrEventBuilder, RadrootsNostrTag}; +use crate::types::{ + RadrootsNostrEvent, RadrootsNostrEventBuilderUnchecked, RadrootsNostrGenericEventBuilder, + RadrootsNostrTag, +}; pub fn radroots_nostr_build_event_job_result( job_request: &RadrootsNostrEvent, @@ -9,12 +14,16 @@ pub fn radroots_nostr_build_event_job_result( millisats: u64, bolt11: Option<String>, tags: Option<Vec<RadrootsNostrTag>>, -) -> Result<RadrootsNostrEventBuilder, RadrootsNostrError> { - let builder = - RadrootsNostrEventBuilder::job_result(job_request.clone(), payload, millisats, bolt11)? - .tags(tags.unwrap_or_default()) - .allow_self_tagging(); - Ok(builder) +) -> Result<RadrootsNostrGenericEventBuilder, RadrootsNostrError> { + let builder = RadrootsNostrEventBuilderUnchecked::job_result( + job_request.clone(), + payload, + millisats, + bolt11, + )? + .tags(tags.unwrap_or_default()) + .allow_self_tagging(); + Ok(RadrootsNostrGenericEventBuilder::from_unchecked(builder)) } pub fn radroots_nostr_build_event_job_feedback( @@ -22,14 +31,14 @@ pub fn radroots_nostr_build_event_job_feedback( status: &str, extra_info: Option<String>, tags: Option<Vec<RadrootsNostrTag>>, -) -> Result<RadrootsNostrEventBuilder, RadrootsNostrError> { +) -> Result<RadrootsNostrGenericEventBuilder, RadrootsNostrError> { let status = status .parse::<DataVendingMachineStatus>() .unwrap_or(DataVendingMachineStatus::Error); let feedback_data = JobFeedbackData::new(&job_request.clone(), status) .extra_info(extra_info.unwrap_or_default()); - let builder = RadrootsNostrEventBuilder::job_feedback(feedback_data) + let builder = RadrootsNostrEventBuilderUnchecked::job_feedback(feedback_data) .tags(tags.unwrap_or_default()) .allow_self_tagging(); - Ok(builder) + Ok(RadrootsNostrGenericEventBuilder::from_unchecked(builder)) } diff --git a/crates/nostr/src/events/mod.rs b/crates/nostr/src/events/mod.rs @@ -5,18 +5,22 @@ pub mod metadata; pub mod post; extern crate alloc; +#[cfg(any(feature = "events", test))] use alloc::{string::String, vec::Vec}; +#[cfg(any(feature = "events", test))] use crate::error::RadrootsNostrError; +#[cfg(any(feature = "events", test))] use crate::types::{ - RadrootsNostrEventBuilder, RadrootsNostrKind, RadrootsNostrTag, RadrootsNostrTagKind, + RadrootsNostrEventBuilderUnchecked, RadrootsNostrKind, RadrootsNostrTag, RadrootsNostrTagKind, }; -pub fn radroots_nostr_build_event( +#[cfg(any(feature = "events", test))] +pub(crate) fn radroots_nostr_build_event_unchecked( kind_u32: u32, content: impl Into<String>, tag_slices: Vec<Vec<String>>, -) -> Result<RadrootsNostrEventBuilder, RadrootsNostrError> { +) -> Result<RadrootsNostrEventBuilderUnchecked, RadrootsNostrError> { let kind = u16::try_from(kind_u32).map_err(|_| RadrootsNostrError::KindOutOfRange { kind: kind_u32, max: u16::MAX, @@ -33,15 +37,18 @@ pub fn radroots_nostr_build_event( values, )); } - let builder = RadrootsNostrEventBuilder::new(RadrootsNostrKind::Custom(kind), content.into()) - .tags(tags) - .allow_self_tagging(); + let builder = + RadrootsNostrEventBuilderUnchecked::new(RadrootsNostrKind::Custom(kind), content.into()) + .tags(tags) + .allow_self_tagging(); Ok(builder) } #[cfg(test)] mod tests { - use super::radroots_nostr_build_event; + use alloc::{vec, vec::Vec}; + + use super::radroots_nostr_build_event_unchecked; use crate::error::RadrootsNostrError; use crate::test_fixtures::FIXTURE_ALICE_PUBLIC_KEY_HEX; use crate::types::{RadrootsNostrPublicKey, RadrootsNostrTagKind}; @@ -55,7 +62,7 @@ mod tests { vec!["p".to_string(), pubkey_hex.to_string()], ]; - let builder = radroots_nostr_build_event(1, "test", tags).expect("builder"); + let builder = radroots_nostr_build_event_unchecked(1, "test", tags).expect("builder"); let event = builder.build(pubkey); let has_self_tag = event.tags.iter().any(|tag| { @@ -72,7 +79,7 @@ mod tests { #[test] fn build_event_accepts_maximum_nip01_kind() { - let builder = radroots_nostr_build_event(u32::from(u16::MAX), "test", Vec::new()) + let builder = radroots_nostr_build_event_unchecked(u32::from(u16::MAX), "test", Vec::new()) .expect("maximum NIP-01 kind"); let event = builder .build(RadrootsNostrPublicKey::from_hex(FIXTURE_ALICE_PUBLIC_KEY_HEX).expect("pubkey")); @@ -83,7 +90,7 @@ mod tests { fn build_event_rejects_kind_overflow() { let kind = u32::from(u16::MAX) + 1; assert!(matches!( - radroots_nostr_build_event(kind, "test", Vec::new()), + radroots_nostr_build_event_unchecked(kind, "test", Vec::new()), Err(RadrootsNostrError::KindOutOfRange { kind: actual, max: u16::MAX diff --git a/crates/nostr/src/events/post.rs b/crates/nostr/src/events/post.rs @@ -1,6 +1,12 @@ +use alloc::{string::String, vec::Vec}; + use crate::error::RadrootsNostrError; +#[cfg(feature = "events")] +use crate::types::RadrootsNostrEventBuilderUnchecked; +#[cfg(feature = "events")] +use crate::types::{RadrootsNostrEvent, RadrootsNostrKeys}; use crate::types::{ - RadrootsNostrEventBuilder, RadrootsNostrEventId, RadrootsNostrFilter, RadrootsNostrKind, + RadrootsNostrEventId, RadrootsNostrFilter, RadrootsNostrGenericEventBuilder, RadrootsNostrKind, RadrootsNostrPublicKey, RadrootsNostrTag, RadrootsNostrTimestamp, }; @@ -20,24 +26,56 @@ use crate::client::RadrootsNostrClient; #[cfg(all(feature = "client", feature = "events"))] use core::time::Duration; +/// A sealed builder for a validated Radroots root post profile. +/// +/// The wrapper intentionally exposes no raw builder conversion or tag/content +/// mutation. Construct it through one of the typed post authoring functions. +#[cfg(feature = "events")] +#[must_use = "post event builders must be signed or published"] +pub struct RadrootsNostrPostEventBuilder { + inner: RadrootsNostrEventBuilderUnchecked, +} + +#[cfg(feature = "events")] +impl RadrootsNostrPostEventBuilder { + /// Sets the event timestamp without changing the validated post shape. + pub fn custom_created_at(mut self, created_at: RadrootsNostrTimestamp) -> Self { + self.inner = self.inner.custom_created_at(created_at); + self + } + + /// Signs the validated post directly with local keys. + pub fn sign_with_keys( + self, + keys: &RadrootsNostrKeys, + ) -> Result<RadrootsNostrEvent, RadrootsNostrError> { + Ok(self.inner.sign_with_keys(keys)?) + } + + #[cfg(feature = "client")] + pub(crate) fn into_event_builder(self) -> RadrootsNostrEventBuilderUnchecked { + self.inner + } +} + #[cfg(feature = "events")] pub fn radroots_nostr_build_update_event( update: &RadrootsAuthoredUpdate, -) -> Result<RadrootsNostrEventBuilder, RadrootsNostrError> { +) -> Result<RadrootsNostrPostEventBuilder, 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> { +) -> Result<RadrootsNostrPostEventBuilder, 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> { +) -> Result<RadrootsNostrPostEventBuilder, RadrootsNostrError> { builder_from_wire_parts(authored_ask_to_wire_parts(ask)) } @@ -60,7 +98,7 @@ pub fn radroots_nostr_build_post_reply_event( parent_author_hex: &str, content: impl Into<String>, root_event_id_hex: Option<&str>, -) -> Result<RadrootsNostrEventBuilder, RadrootsNostrError> { +) -> Result<RadrootsNostrGenericEventBuilder, RadrootsNostrError> { let parent_id = RadrootsNostrEventId::from_hex(parent_event_id_hex)?; let parent_pubkey = RadrootsNostrPublicKey::from_hex(parent_author_hex)?; let mut tags: Vec<RadrootsNostrTag> = Vec::new(); @@ -75,14 +113,16 @@ pub fn radroots_nostr_build_post_reply_event( tags.push(RadrootsNostrTag::event(parent_id)); tags.push(RadrootsNostrTag::public_key(parent_pubkey)); - Ok(RadrootsNostrEventBuilder::text_note(content).tags(tags)) + Ok(RadrootsNostrGenericEventBuilder::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) +) -> Result<RadrootsNostrPostEventBuilder, RadrootsNostrError> { + let inner = + crate::events::radroots_nostr_build_event_unchecked(parts.kind, parts.content, parts.tags)?; + Ok(RadrootsNostrPostEventBuilder { inner }) } #[cfg(all(feature = "client", feature = "events"))] diff --git a/crates/nostr/src/lib.rs b/crates/nostr/src/lib.rs @@ -44,7 +44,6 @@ pub mod event_convert; pub mod event_verify; pub mod prelude { - pub use crate::events::radroots_nostr_build_event; #[cfg(feature = "blossom")] pub use crate::blossom::{ @@ -61,6 +60,9 @@ pub mod prelude { radroots_nostr_send_event, }; + #[cfg(all(feature = "client", feature = "events"))] + pub use crate::client::radroots_nostr_send_post_event; + pub use crate::error::{RadrootsNostrError, RadrootsNostrTagsResolveError}; pub use crate::filter::{ radroots_nostr_filter_kind, radroots_nostr_filter_new_events, radroots_nostr_filter_tag, @@ -74,8 +76,8 @@ pub mod prelude { #[cfg(feature = "events")] pub use crate::events::post::{ - radroots_nostr_build_ask_event, radroots_nostr_build_photo_update_event, - radroots_nostr_build_update_event, + RadrootsNostrPostEventBuilder, radroots_nostr_build_ask_event, + radroots_nostr_build_photo_update_event, radroots_nostr_build_update_event, }; #[cfg(feature = "events")] @@ -100,8 +102,8 @@ pub mod prelude { }; pub use crate::tags::*; pub use crate::types::{ - RadrootsNostrCoordinate, RadrootsNostrEvent, RadrootsNostrEventBuilder, - RadrootsNostrEventId, RadrootsNostrFilter, RadrootsNostrFromBech32, RadrootsNostrKeys, + RadrootsNostrCoordinate, RadrootsNostrEvent, RadrootsNostrEventId, RadrootsNostrFilter, + RadrootsNostrFromBech32, RadrootsNostrGenericEventBuilder, RadrootsNostrKeys, RadrootsNostrKind, RadrootsNostrMetadata, RadrootsNostrPublicKey, RadrootsNostrRelayUrl, RadrootsNostrSecp256k1SecretKey, RadrootsNostrSecretKey, RadrootsNostrSubscriptionId, RadrootsNostrTag, RadrootsNostrTagKind, RadrootsNostrTagStandard, RadrootsNostrTimestamp, diff --git a/crates/nostr/src/types.rs b/crates/nostr/src/types.rs @@ -1,8 +1,10 @@ #![forbid(unsafe_code)] +use crate::error::RadrootsNostrError; + pub type RadrootsNostrCoordinate = nostr::nips::nip01::Coordinate; pub type RadrootsNostrEvent = nostr::Event; -pub type RadrootsNostrEventBuilder = nostr::EventBuilder; +pub(crate) type RadrootsNostrEventBuilderUnchecked = nostr::EventBuilder; pub type RadrootsNostrEventId = nostr::EventId; pub type RadrootsNostrFilter = nostr::Filter; pub type RadrootsNostrKind = nostr::Kind; @@ -18,6 +20,146 @@ pub type RadrootsNostrTagStandard = nostr::TagStandard; pub type RadrootsNostrTimestamp = nostr::Timestamp; pub type RadrootsNostrUrl = nostr::Url; +/// An opaque builder for generic Nostr events. +/// +/// Kind 0 profile events and unmarked root kind 1 events are reserved for +/// typed Radroots authoring. Kind 1 events carrying an `e` tag remain +/// available for thread compatibility. The policy is enforced before direct +/// signing and before a client is allowed to consult its signer. +/// +/// The upstream unsigned builder is intentionally inaccessible: +/// +/// ```compile_fail +/// use radroots_nostr::prelude::{ +/// RadrootsNostrGenericEventBuilder, RadrootsNostrKind, +/// }; +/// +/// let builder = RadrootsNostrGenericEventBuilder::new( +/// RadrootsNostrKind::Custom(30_001), +/// "content", +/// ); +/// let _: nostr::EventBuilder = builder.into(); +/// ``` +/// +/// ```compile_fail +/// use radroots_nostr::prelude::{ +/// RadrootsNostrGenericEventBuilder, RadrootsNostrKind, +/// }; +/// +/// let builder = RadrootsNostrGenericEventBuilder::new( +/// RadrootsNostrKind::Custom(30_001), +/// "content", +/// ); +/// let _raw: nostr::EventBuilder = builder.into_inner(); +/// ``` +/// +/// ```compile_fail +/// use radroots_nostr::prelude::{ +/// RadrootsNostrGenericEventBuilder, RadrootsNostrKeys, +/// RadrootsNostrKind, +/// }; +/// +/// let builder = RadrootsNostrGenericEventBuilder::new( +/// RadrootsNostrKind::Custom(30_001), +/// "content", +/// ); +/// let keys = RadrootsNostrKeys::generate(); +/// let _unsigned = builder.build(keys.public_key()); +/// ``` +#[must_use = "generic event builders must be signed or published"] +pub struct RadrootsNostrGenericEventBuilder { + inner: RadrootsNostrEventBuilderUnchecked, +} + +impl RadrootsNostrGenericEventBuilder { + pub fn new(kind: RadrootsNostrKind, content: impl Into<alloc::string::String>) -> Self { + Self::from_unchecked(RadrootsNostrEventBuilderUnchecked::new(kind, content)) + } + + pub fn text_note(content: impl Into<alloc::string::String>) -> Self { + Self::from_unchecked(RadrootsNostrEventBuilderUnchecked::text_note(content)) + } + + pub fn tag(mut self, tag: RadrootsNostrTag) -> Self { + self.inner = self.inner.tag(tag); + self + } + + pub fn tags<I>(mut self, tags: I) -> Self + where + I: IntoIterator<Item = RadrootsNostrTag>, + { + self.inner = self.inner.tags(tags); + self + } + + pub fn custom_created_at(mut self, created_at: RadrootsNostrTimestamp) -> Self { + self.inner = self.inner.custom_created_at(created_at); + self + } + + pub fn pow(mut self, difficulty: u8) -> Self { + self.inner = self.inner.pow(difficulty); + self + } + + pub fn allow_self_tagging(mut self) -> Self { + self.inner = self.inner.allow_self_tagging(); + self + } + + pub fn dedup_tags(mut self) -> Self { + self.inner = self.inner.dedup_tags(); + self + } + + /// Signs a generic event after enforcing typed-authoring reservations. + pub fn sign_with_keys( + self, + keys: &RadrootsNostrKeys, + ) -> Result<RadrootsNostrEvent, RadrootsNostrError> { + self.validate_generic_authoring_policy()?; + Ok(self.inner.sign_with_keys(keys)?) + } + + pub(crate) fn from_unchecked(inner: RadrootsNostrEventBuilderUnchecked) -> Self { + Self { inner } + } + + #[cfg(feature = "client")] + pub(crate) fn into_checked_event_builder( + self, + ) -> Result<RadrootsNostrEventBuilderUnchecked, RadrootsNostrError> { + self.validate_generic_authoring_policy()?; + Ok(self.inner) + } + + fn validate_generic_authoring_policy(&self) -> Result<(), RadrootsNostrError> { + // Inspect an unsigned clone so rejection never consults a signer. PoW + // is irrelevant to kind/tag policy and must not delay the check. + let mut inspection = self.inner.clone(); + inspection.custom_created_at = Some(RadrootsNostrTimestamp::from_secs(1)); + inspection.pow = None; + inspection.allow_self_tagging = true; + inspection.dedup_tags = false; + let inspection_pubkey = RadrootsNostrPublicKey::from_hex( + "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + )?; + let event = inspection.build(inspection_pubkey); + let kind = event.kind.as_u16(); + let is_profile = kind == RadrootsNostrKind::Metadata.as_u16(); + let is_unmarked_root_post = kind == RadrootsNostrKind::TextNote.as_u16() + && !event + .tags + .iter() + .any(|tag| tag.kind() == RadrootsNostrTagKind::e()); + if is_profile || is_unmarked_root_post { + return Err(RadrootsNostrError::TypedAuthoringRequired { kind }); + } + Ok(()) + } +} + pub use nostr::nips::nip19::{ FromBech32 as RadrootsNostrFromBech32, ToBech32 as RadrootsNostrToBech32, }; @@ -46,3 +188,54 @@ pub type RadrootsNostrRelayStatus = nostr_sdk::RelayStatus; #[cfg(feature = "client")] pub type RadrootsNostrSubscribeAutoCloseOptions = nostr_sdk::SubscribeAutoCloseOptions; + +#[cfg(test)] +mod tests { + use super::*; + + fn keys() -> RadrootsNostrKeys { + RadrootsNostrKeys::generate() + } + + #[test] + fn generic_direct_signing_rejects_typed_only_kinds() { + let keys = keys(); + + for (builder, expected_kind) in [ + ( + RadrootsNostrGenericEventBuilder::new(RadrootsNostrKind::Metadata, "{}"), + RadrootsNostrKind::Metadata.as_u16(), + ), + ( + RadrootsNostrGenericEventBuilder::text_note("root post"), + RadrootsNostrKind::TextNote.as_u16(), + ), + ] { + assert!(matches!( + builder.sign_with_keys(&keys), + Err(RadrootsNostrError::TypedAuthoringRequired { kind }) + if kind == expected_kind + )); + } + } + + #[test] + fn generic_direct_signing_allows_thread_kind_one() { + let event = RadrootsNostrGenericEventBuilder::text_note("reply") + .tag(RadrootsNostrTag::event(RadrootsNostrEventId::all_zeros())) + .sign_with_keys(&keys()) + .expect("e-tagged kind 1 remains generic-authorable"); + + assert_eq!(event.kind, RadrootsNostrKind::TextNote); + } + + #[test] + fn generic_direct_signing_allows_non_reserved_kind() { + let event = + RadrootsNostrGenericEventBuilder::new(RadrootsNostrKind::Custom(30_001), "generic") + .sign_with_keys(&keys()) + .expect("generic kind signs"); + + assert_eq!(event.kind.as_u16(), 30_001); + } +} diff --git a/crates/nostr/tests/blossom_conformance.rs b/crates/nostr/tests/blossom_conformance.rs @@ -13,8 +13,8 @@ use radroots_nostr::blossom::{ radroots_nostr_encode_blossom_authorization_header, radroots_nostr_sign_blossom_authorization, }; use radroots_nostr::types::{ - RadrootsNostrEvent, RadrootsNostrEventBuilder, RadrootsNostrKeys, RadrootsNostrKind, - RadrootsNostrTag, RadrootsNostrTimestamp, + RadrootsNostrEvent, RadrootsNostrKeys, RadrootsNostrKind, RadrootsNostrTag, + RadrootsNostrTimestamp, }; use serde::Deserialize; use serde_json::Value; @@ -351,7 +351,7 @@ fn event_from_header(header: &str) -> RadrootsNostrEvent { } fn sign_raw(kind: u16, content: &str, tags: Vec<RadrootsNostrTag>) -> RadrootsNostrEvent { - RadrootsNostrEventBuilder::new(RadrootsNostrKind::Custom(kind), content) + nostr::EventBuilder::new(RadrootsNostrKind::Custom(kind), content) .tags(tags) .custom_created_at(RadrootsNostrTimestamp::from_secs(CREATED_AT)) .sign_with_keys(&keys()) diff --git a/crates/nostr/tests/coverage.rs b/crates/nostr/tests/coverage.rs @@ -11,7 +11,6 @@ use radroots_nostr::events::jobs::{ use radroots_nostr::events::post::{ radroots_nostr_build_post_reply_event, radroots_nostr_post_events_filter, }; -use radroots_nostr::events::radroots_nostr_build_event; use radroots_nostr::filter::{ radroots_nostr_filter_kind, radroots_nostr_filter_new_events, radroots_nostr_filter_tag, radroots_nostr_kind, @@ -25,8 +24,8 @@ use radroots_nostr::tags::{ radroots_nostr_tags_resolve, }; use radroots_nostr::types::{ - RadrootsNostrEventBuilder, RadrootsNostrKeys, RadrootsNostrKind, RadrootsNostrRelayUrl, - RadrootsNostrTag, RadrootsNostrTagKind, RadrootsNostrTagStandard, RadrootsNostrTimestamp, + RadrootsNostrKeys, RadrootsNostrKind, RadrootsNostrRelayUrl, RadrootsNostrTag, + RadrootsNostrTagKind, RadrootsNostrTagStandard, RadrootsNostrTimestamp, }; use radroots_nostr::util::{ created_at_u32_saturating, event_created_at_u32_saturating, radroots_nostr_npub_string, @@ -38,7 +37,7 @@ fn make_keys() -> RadrootsNostrKeys { } fn text_event_with_tags(keys: &RadrootsNostrKeys, tags: Vec<RadrootsNostrTag>) -> nostr::Event { - RadrootsNostrEventBuilder::new(RadrootsNostrKind::TextNote, "content") + nostr::EventBuilder::new(RadrootsNostrKind::TextNote, "content") .tags(tags) .sign_with_keys(keys) .expect("sign event") @@ -49,7 +48,7 @@ fn encrypted_event_with_p_tag( content: impl Into<String>, recipient_hex: &str, ) -> nostr::Event { - RadrootsNostrEventBuilder::new(RadrootsNostrKind::TextNote, content.into()) + nostr::EventBuilder::new(RadrootsNostrKind::TextNote, content.into()) .tags(vec![ RadrootsNostrTag::custom( RadrootsNostrTagKind::Encrypted, @@ -62,38 +61,12 @@ fn encrypted_event_with_p_tag( } #[test] -fn build_event_skips_empty_tag_slices() { - let keys = make_keys(); - let pubkey_hex = keys.public_key().to_hex(); - let builder = radroots_nostr_build_event( - 1, - "test", - vec![vec![], vec!["p".to_string(), pubkey_hex.clone()]], - ) - .expect("builder"); - let event = builder.build(keys.public_key()); - let has_self_p_tag = event.tags.iter().any(|tag| { - tag.kind() == RadrootsNostrTagKind::p() && tag.content() == Some(pubkey_hex.as_str()) - }); - assert!(has_self_p_tag); - - let builder_string = radroots_nostr_build_event( - 1, - String::from("test"), - vec![vec![], vec!["x".to_string(), "v".to_string()]], - ) - .expect("builder string"); - let event_string = builder_string.build(keys.public_key()); - assert_eq!(event_string.tags.len(), 1); -} - -#[test] fn job_event_builders_are_callable() { let keys = make_keys(); - let job_request = RadrootsNostrEventBuilder::new(RadrootsNostrKind::Custom(5001), "job") + let job_request = nostr::EventBuilder::new(RadrootsNostrKind::Custom(5001), "job") .sign_with_keys(&keys) .expect("job request"); - let non_job_request = RadrootsNostrEventBuilder::new(RadrootsNostrKind::TextNote, "job") + let non_job_request = nostr::EventBuilder::new(RadrootsNostrKind::TextNote, "job") .sign_with_keys(&keys) .expect("non-job request"); @@ -105,7 +78,9 @@ fn job_event_builders_are_callable() { Some(Vec::new()), ) .expect("job result builder"); - let _ = job_result.build(keys.public_key()); + let _ = job_result + .sign_with_keys(&keys) + .expect("job result signs through the generic boundary"); let feedback_ok = radroots_nostr_build_event_job_feedback( &job_request, @@ -114,12 +89,16 @@ fn job_event_builders_are_callable() { Some(Vec::new()), ) .expect("job feedback builder"); - let _ = feedback_ok.build(keys.public_key()); + let _ = feedback_ok + .sign_with_keys(&keys) + .expect("job feedback signs through the generic boundary"); let feedback_invalid = radroots_nostr_build_event_job_feedback(&job_request, "invalid-status", None, None) .expect("job feedback fallback builder"); - let _ = feedback_invalid.build(keys.public_key()); + let _ = feedback_invalid + .sign_with_keys(&keys) + .expect("fallback job feedback signs through the generic boundary"); let invalid_job_result = radroots_nostr_build_event_job_result( &non_job_request, @@ -149,7 +128,9 @@ fn post_helpers_cover_success_and_error_paths() { Some(root_id_hex.as_str()), ) .expect("reply event builder"); - let _ = reply_ok.build(keys.public_key()); + let _ = reply_ok + .sign_with_keys(&keys) + .expect("reply signs through the generic boundary"); let reply_invalid_root = radroots_nostr_build_post_reply_event( &parent_id_hex, @@ -158,15 +139,21 @@ fn post_helpers_cover_success_and_error_paths() { Some("not-hex-root"), ) .expect("reply builder with invalid optional root"); - let _ = reply_invalid_root.build(keys.public_key()); + let _ = reply_invalid_root + .sign_with_keys(&keys) + .expect("reply with invalid optional root signs"); let reply_empty_root = radroots_nostr_build_post_reply_event(&parent_id_hex, &author_hex, "reply", Some("")) .expect("reply builder with empty optional root"); - let _ = reply_empty_root.build(keys.public_key()); + let _ = reply_empty_root + .sign_with_keys(&keys) + .expect("reply with empty optional root signs"); let reply_none_root = radroots_nostr_build_post_reply_event(&parent_id_hex, &author_hex, "reply", None) .expect("reply builder without optional root"); - let _ = reply_none_root.build(keys.public_key()); + let _ = reply_none_root + .sign_with_keys(&keys) + .expect("reply without optional root signs"); let invalid_parent = radroots_nostr_build_post_reply_event("bad", &author_hex, "reply", None); assert!(invalid_parent.is_err()); @@ -351,17 +338,16 @@ fn tag_helpers_cover_matchers_and_resolve_paths() { Err(RadrootsNostrTagsResolveError::MissingPTag(_)) )); - let encrypted_empty_p_content = - RadrootsNostrEventBuilder::new(RadrootsNostrKind::TextNote, "cipher") - .tags(vec![ - RadrootsNostrTag::custom( - RadrootsNostrTagKind::Encrypted, - vec!["encrypted".to_string()], - ), - RadrootsNostrTag::custom(RadrootsNostrTagKind::p(), Vec::<String>::new()), - ]) - .sign_with_keys(&sender) - .expect("sign encrypted event with empty p tag"); + let encrypted_empty_p_content = nostr::EventBuilder::new(RadrootsNostrKind::TextNote, "cipher") + .tags(vec![ + RadrootsNostrTag::custom( + RadrootsNostrTagKind::Encrypted, + vec!["encrypted".to_string()], + ), + RadrootsNostrTag::custom(RadrootsNostrTagKind::p(), Vec::<String>::new()), + ]) + .sign_with_keys(&sender) + .expect("sign encrypted event with empty p tag"); let empty_p_content = radroots_nostr_tags_resolve(&encrypted_empty_p_content, &keys); assert!(matches!( empty_p_content, diff --git a/crates/nostr/tests/generic_builder_boundary.rs b/crates/nostr/tests/generic_builder_boundary.rs @@ -0,0 +1,77 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +#[test] +fn public_source_does_not_expose_the_upstream_event_builder() { + let repo_root = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("lib repo root"); + let mut findings = Vec::new(); + + for path in rust_source_files(repo_root.join("crates").as_path()) { + if path.file_name().and_then(|name| name.to_str()) == Some("generic_builder_boundary.rs") { + continue; + } + let source = fs::read_to_string(&path).expect("read Rust source"); + let normalized = source.split_whitespace().collect::<Vec<_>>().join(" "); + for statement in normalized.split(';') { + let statement = statement.trim(); + let exposes_alias = + statement.starts_with("pub type ") && statement.contains("EventBuilder"); + let exposes_reexport = + statement.starts_with("pub use nostr") && statement.contains("EventBuilder"); + if exposes_alias || exposes_reexport { + findings.push(format!( + "{} exposes upstream EventBuilder: {statement}", + relative_path(repo_root, &path) + )); + } + } + + for forbidden in [ + "impl Deref for RadrootsNostrGenericEventBuilder", + "impl AsRef<nostr::EventBuilder> for RadrootsNostrGenericEventBuilder", + "impl From<RadrootsNostrGenericEventBuilder> for nostr::EventBuilder", + "impl Into<nostr::EventBuilder> for RadrootsNostrGenericEventBuilder", + ] { + if normalized.contains(forbidden) { + findings.push(format!( + "{} contains forbidden raw-builder escape `{forbidden}`", + relative_path(repo_root, &path) + )); + } + } + } + + assert!( + findings.is_empty(), + "public generic-builder boundary violations:\n{}", + findings.join("\n") + ); +} + +fn rust_source_files(root: &Path) -> Vec<PathBuf> { + let mut paths = Vec::new(); + collect_rust_source_files(root, &mut paths); + paths.sort(); + paths +} + +fn collect_rust_source_files(root: &Path, paths: &mut Vec<PathBuf>) { + for entry in fs::read_dir(root).expect("read source directory") { + let path = entry.expect("source entry").path(); + if path.is_dir() { + collect_rust_source_files(&path, paths); + } else if path.extension().and_then(|extension| extension.to_str()) == Some("rs") { + paths.push(path); + } + } +} + +fn relative_path(root: &Path, path: &Path) -> String { + path.strip_prefix(root) + .expect("source path is under repo root") + .to_string_lossy() + .replace('\\', "/") +} diff --git a/crates/nostr/tests/post_profile.rs b/crates/nostr/tests/post_profile.rs @@ -17,19 +17,25 @@ use radroots_nostr::{ radroots_nostr_build_ask_event, radroots_nostr_build_photo_update_event, radroots_nostr_build_update_event, }, - types::RadrootsNostrPublicKey, + types::{RadrootsNostrKeys, RadrootsNostrSecretKey, RadrootsNostrTimestamp}, }; #[test] fn typed_post_builders_preserve_strict_wire_profiles() { - let author = - RadrootsNostrPublicKey::from_hex(test_fixtures::FIXTURE_ALICE_PUBLIC_KEY_HEX).unwrap(); + let keys = RadrootsNostrKeys::new( + RadrootsNostrSecretKey::from_hex(test_fixtures::FIXTURE_ALICE_SECRET_KEY_HEX).unwrap(), + ); + let created_at = RadrootsNostrTimestamp::from_secs(1_784_347_200); let update = RadrootsAuthoredUpdate::new("Farm update").unwrap(); let event = radroots_nostr_build_update_event(&update) .unwrap() - .build(author); + .custom_created_at(created_at) + .sign_with_keys(&keys) + .unwrap(); assert_eq!(event.kind.as_u16(), 1); assert!(event.tags.is_empty()); + assert_eq!(event.created_at, created_at); + assert!(event.verify().is_ok()); let image = authored_image(); let photo = @@ -37,17 +43,25 @@ fn typed_post_builders_preserve_strict_wire_profiles() { .unwrap(); let event = radroots_nostr_build_photo_update_event(&photo) .unwrap() - .build(author); + .custom_created_at(created_at) + .sign_with_keys(&keys) + .unwrap(); assert_eq!(event.tags.len(), 1); assert_eq!(event.tags.iter().next().unwrap().as_slice()[0], "imeta"); + assert!(event.verify().is_ok()); let ask = RadrootsAuthoredAsk::new(format!("Is this ready? {}", image.url()), vec![image]).unwrap(); - let event = radroots_nostr_build_ask_event(&ask).unwrap().build(author); + let event = radroots_nostr_build_ask_event(&ask) + .unwrap() + .custom_created_at(created_at) + .sign_with_keys(&keys) + .unwrap(); 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"); + assert!(event.verify().is_ok()); } fn authored_image() -> RadrootsAuthoredPostImage { diff --git a/crates/nostr_runtime/Cargo.toml b/crates/nostr_runtime/Cargo.toml @@ -27,4 +27,5 @@ thiserror = { workspace = true } tokio = { workspace = true, optional = true, features = ["rt", "sync", "time"] } [dev-dependencies] +nostr = { workspace = true } tokio = { workspace = true, features = ["macros", "rt", "sync", "time"] } diff --git a/crates/nostr_runtime/src/sink.rs b/crates/nostr_runtime/src/sink.rs @@ -47,13 +47,14 @@ impl RadrootsNostrEventSink for RadrootsNostrInMemoryEventSink { #[cfg(test)] mod tests { use super::*; - use radroots_nostr::prelude::{RadrootsNostrEventBuilder, RadrootsNostrKeys}; + use nostr::EventBuilder; + use radroots_nostr::prelude::RadrootsNostrKeys; #[test] fn in_memory_sink_tracks_events() { let sink = RadrootsNostrInMemoryEventSink::new(); let keys = RadrootsNostrKeys::generate(); - let event = RadrootsNostrEventBuilder::text_note("hello") + let event = EventBuilder::text_note("hello") .sign_with_keys(&keys) .expect("event should sign"); diff --git a/crates/nostr_signer/README b/crates/nostr_signer/README @@ -22,6 +22,13 @@ display metadata without treating it as an authorization input. JSON written before metadata support remains readable, and the native SQLite migration adds the same optional state without replacing existing connections. +The backend accepts externally supplied unsigned events and the NIP-46 codec +implements the standard `sign_event` operation. These are low-level Nostr +interoperability boundaries: a successful signature proves cryptographic +authorization only and does not confer a Radroots typed product-authoring +claim. The backend intentionally exposes no raw `nostr::EventBuilder` +convenience. + Connection revocation is explicit and idempotent. A transport service handling `logout` is responsible for publishing the typed `ack` before it revokes the session; storage transitions do not hide or reorder transport publication. diff --git a/crates/nostr_signer/src/backend.rs b/crates/nostr_signer/src/backend.rs @@ -15,7 +15,7 @@ use crate::model::{ RadrootsNostrSignerPublishWorkflowRecord, RadrootsNostrSignerRequestAuditRecord, RadrootsNostrSignerRequestDecision, RadrootsNostrSignerWorkflowId, }; -use nostr::{Event, EventBuilder, PublicKey, RelayUrl, UnsignedEvent}; +use nostr::{Event, PublicKey, RelayUrl, UnsignedEvent}; use radroots_identity::{RadrootsIdentity, RadrootsIdentityPublic}; use radroots_nostr_connect::prelude::{ RadrootsNostrConnectMethod, RadrootsNostrConnectPermissions, RadrootsNostrConnectRequest, @@ -31,6 +31,10 @@ pub struct RadrootsNostrSignerBackendCapabilities { pub remote_sessions: Vec<RadrootsNostrRemoteSessionSignerCapability>, } +/// Result of signing an externally supplied unsigned Nostr event. +/// +/// This low-level protocol result does not establish Radroots typed-authoring +/// validity for the event. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RadrootsNostrSignerSignOutput { pub signer: RadrootsNostrSignerCapability, @@ -214,21 +218,15 @@ pub trait RadrootsNostrSignerBackend: Send + Sync { message: Option<String>, ) -> Result<RadrootsNostrSignerRequestAuditRecord, RadrootsNostrSignerError>; + /// Signs an externally supplied unsigned Nostr event. + /// + /// This is a low-level interoperability boundary used by generic signer + /// protocols. It does not validate or confer a Radroots product-authoring + /// contract; product events must use their typed authoring boundary. fn sign_unsigned_event( &self, unsigned_event: UnsignedEvent, ) -> Result<RadrootsNostrSignerSignOutput, RadrootsNostrSignerError>; - - fn sign_event_builder( - &self, - builder: EventBuilder, - ) -> Result<RadrootsNostrSignerSignOutput, RadrootsNostrSignerError> { - let signer_identity = self - .signer_identity()? - .ok_or(RadrootsNostrSignerError::MissingSignerIdentity)?; - let public_key = parse_identity_public_key(&signer_identity)?; - self.sign_unsigned_event(builder.build(public_key)) - } } #[derive(Clone)] @@ -638,23 +636,13 @@ fn same_public_identity_key(left: &RadrootsIdentityPublic, right: &RadrootsIdent && left.public_key_npub == right.public_key_npub } -fn parse_identity_public_key( - identity: &RadrootsIdentityPublic, -) -> Result<PublicKey, RadrootsNostrSignerError> { - PublicKey::parse(identity.public_key_hex.as_str()) - .or_else(|_| PublicKey::from_hex(identity.public_key_hex.as_str())) - .map_err(|_| { - RadrootsNostrSignerError::InvalidState("identity public key is invalid".into()) - }) -} - #[cfg(test)] #[cfg_attr(coverage_nightly, coverage(off))] mod tests { use super::{ RadrootsNostrEmbeddedSignerBackend, RadrootsNostrSignerBackend, RadrootsNostrSignerBackendCapabilities, RadrootsNostrSignerPublishTransition, - parse_identity_public_key, same_public_identity_key, + same_public_identity_key, }; use crate::error::RadrootsNostrSignerError; use crate::evaluation::{ @@ -679,7 +667,6 @@ mod tests { RadrootsNostrConnectMethod, RadrootsNostrConnectPermission, RadrootsNostrConnectRequest, RadrootsNostrConnectRequestMessage, }; - use serde_json::json; use std::panic::{AssertUnwindSafe, catch_unwind}; use std::sync::Arc; use std::sync::RwLock; @@ -1112,51 +1099,30 @@ mod tests { } #[test] - fn sign_event_builder_propagates_identity_and_sign_errors() { - let missing_identity_backend = StubBackend { - signer_identity: None, - signer_identity_error: None, - sign_error_message: Some("sign should not be called"), - }; - let err = missing_identity_backend - .sign_event_builder(EventBuilder::new(Kind::TextNote, "missing")) - .expect_err("missing identity"); - assert!(matches!( - err, - RadrootsNostrSignerError::MissingSignerIdentity - )); + fn backend_source_does_not_accept_raw_event_builders() { + let production_source = include_str!("backend.rs") + .split("\n#[cfg(test)]") + .next() + .expect("production backend source"); - let identity_error_backend = StubBackend { + assert!(!production_source.contains(concat!("fn sign_event_", "builder"))); + } + + #[test] + fn external_unsigned_signing_propagates_backend_errors() { + let backend = StubBackend { signer_identity: None, - signer_identity_error: Some("stub signer identity failure"), - sign_error_message: Some("sign should not be called"), - }; - let err = identity_error_backend - .sign_event_builder(EventBuilder::new(Kind::TextNote, "identity-error")) - .expect_err("signer identity error"); - assert!(err.to_string().contains("stub signer identity failure")); - - let mut invalid_identity = synthetic_public_identity(0xaa); - invalid_identity.public_key_hex = "invalid".into(); - let invalid_identity_backend = StubBackend { - signer_identity: Some(invalid_identity), signer_identity_error: None, - sign_error_message: Some("sign should not be called"), + sign_error_message: Some("stub interop signing failure"), }; - let err = invalid_identity_backend - .sign_event_builder(EventBuilder::new(Kind::TextNote, "invalid")) - .expect_err("invalid signer identity"); - assert!(err.to_string().contains("identity public key is invalid")); + let unsigned_event = + EventBuilder::new(Kind::TextNote, "external interop").build(synthetic_public_key(0xaa)); - let signing_error_backend = StubBackend { - signer_identity: Some(synthetic_public_identity(0xab)), - signer_identity_error: None, - sign_error_message: Some("stub sign failure"), - }; - let err = signing_error_backend - .sign_event_builder(EventBuilder::new(Kind::TextNote, "sign-failure")) - .expect_err("sign failure"); - assert!(err.to_string().contains("stub sign failure")); + let error = backend + .sign_unsigned_event(unsigned_event) + .expect_err("external unsigned signing failure"); + + assert!(error.to_string().contains("stub interop signing failure")); } #[test] @@ -1659,24 +1625,18 @@ mod tests { } #[test] - fn embedded_backend_signs_builder_with_local_capability() { + fn embedded_backend_signs_external_unsigned_event_with_local_capability() { let identity = embedded_identity(0x95); let backend = RadrootsNostrEmbeddedSignerBackend::new_in_memory(identity.clone()) .expect("embedded backend"); - let backend_trait: &dyn RadrootsNostrSignerBackend = &backend; - - let output = backend_trait - .sign_event_builder(EventBuilder::new(Kind::TextNote, "hello")) - .expect("sign event builder"); - let direct_output = + let output = <RadrootsNostrEmbeddedSignerBackend as RadrootsNostrSignerBackend>::sign_unsigned_event( &backend, - EventBuilder::new(Kind::TextNote, "hello-direct").build(identity.public_key()), + EventBuilder::new(Kind::TextNote, "hello").build(identity.public_key()), ) - .expect("sign unsigned event"); + .expect("sign external unsigned event"); assert_eq!(output.event.pubkey, identity.public_key()); - assert_eq!(direct_output.event.pubkey, identity.public_key()); let local = output.signer.local_account().expect("local signer"); assert_eq!(local.public_identity.id, identity.to_public().id); assert!(local.is_secret_backed()); @@ -1735,7 +1695,7 @@ mod tests { } #[test] - fn backend_capabilities_all_signers_supports_remote_only_and_identity_helpers() { + fn backend_capabilities_all_signers_supports_remote_only_and_identity_comparison() { let remote = crate::capability::RadrootsNostrRemoteSessionSignerCapability::new( crate::model::RadrootsNostrSignerConnectionId::new_v7(), synthetic_public_identity(0xb0), @@ -1752,10 +1712,6 @@ mod tests { let valid_identity = synthetic_public_identity(0xb2); assert!(same_public_identity_key(&valid_identity, &valid_identity)); - assert_eq!( - parse_identity_public_key(&valid_identity).expect("valid public key"), - synthetic_public_key(0xb2) - ); let mut valid_identity_with_different_hex = valid_identity.clone(); valid_identity_with_different_hex.public_key_hex = synthetic_public_identity(0xb3).public_key_hex; @@ -1763,16 +1719,6 @@ mod tests { &valid_identity, &valid_identity_with_different_hex )); - - let invalid_identity: RadrootsIdentityPublic = serde_json::from_value(json!({ - "id": "not-a-public-key", - "public_key_hex": "not-a-public-key", - "public_key_npub": "npub1invalid" - })) - .expect("invalid identity payload"); - let error = - parse_identity_public_key(&invalid_identity).expect_err("invalid public identity"); - assert!(error.to_string().contains("identity public key is invalid")); } #[test] diff --git a/crates/nostr_signer/src/nip46.rs b/crates/nostr_signer/src/nip46.rs @@ -4,7 +4,7 @@ use nostr::{ }; use radroots_identity::RadrootsIdentityPublic; use radroots_nostr::prelude::{ - RadrootsNostrEvent, RadrootsNostrEventBuilder, RadrootsNostrFilter, RadrootsNostrKind, + RadrootsNostrEvent, RadrootsNostrFilter, RadrootsNostrGenericEventBuilder, RadrootsNostrKind, RadrootsNostrPublicKey, RadrootsNostrRelayUrl, RadrootsNostrTag, RadrootsNostrTimestamp, }; use radroots_nostr_connect::prelude::{ @@ -25,6 +25,11 @@ use crate::model::{ RadrootsNostrSignerRequestDecision, }; +/// Cryptographic operations required by the external NIP-46 protocol. +/// +/// NIP-46 `sign_event` accepts caller-supplied unsigned Nostr events. Signing +/// one is protocol interoperability only and does not establish a Radroots +/// typed product-authoring contract. pub trait RadrootsNostrSignerNip46Signer: Clone + Send + Sync { fn signer_public_key_hex(&self) -> String; fn decrypt_request( @@ -38,6 +43,8 @@ pub trait RadrootsNostrSignerNip46Signer: Clone + Send + Sync { payload: &str, ) -> Result<String, RadrootsNostrSignerError>; fn user_identity(&self) -> RadrootsIdentityPublic; + /// Signs a caller-supplied NIP-46 unsigned event without claiming typed + /// Radroots product authoring. fn sign_user_event( &self, unsigned_event: UnsignedEvent, @@ -172,18 +179,22 @@ impl<S: RadrootsNostrSignerNip46Signer> RadrootsNostrSignerNip46Codec<S> { client_public_key: RadrootsNostrPublicKey, request_id: impl Into<String>, response: RadrootsNostrConnectResponse, - ) -> Result<RadrootsNostrEventBuilder, RadrootsNostrSignerError> { + ) -> Result<RadrootsNostrGenericEventBuilder, RadrootsNostrSignerError> { let envelope = response.into_envelope(request_id.into())?; let payload = serde_json::to_string(&envelope).map_err(RadrootsNostrConnectError::from)?; let ciphertext = self.signer.encrypt_response(&client_public_key, &payload)?; - Ok(RadrootsNostrEventBuilder::new( + Ok(RadrootsNostrGenericEventBuilder::new( RadrootsNostrKind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND), ciphertext, ) .tags(vec![RadrootsNostrTag::public_key(client_public_key)])) } + /// Produces a NIP-46 response for an externally supplied unsigned event. + /// + /// A successful response proves only protocol signing. It does not confer + /// a Radroots typed-authoring or product-admission claim. pub fn sign_event_response( &self, unsigned_event: UnsignedEvent, @@ -303,7 +314,7 @@ where client_public_key: RadrootsNostrPublicKey, request_id: impl Into<String>, response: RadrootsNostrConnectResponse, - ) -> Result<RadrootsNostrEventBuilder, RadrootsNostrSignerError> { + ) -> Result<RadrootsNostrGenericEventBuilder, RadrootsNostrSignerError> { self.codec .build_response_event(client_public_key, request_id, response) } @@ -845,8 +856,8 @@ mod tests { use nostr::{Keys, Timestamp, UnsignedEvent}; use radroots_identity::{RadrootsIdentity, RadrootsIdentityPublic}; use radroots_nostr::prelude::{ - RadrootsNostrEvent, RadrootsNostrEventBuilder, RadrootsNostrKind, RadrootsNostrPublicKey, - RadrootsNostrTagKind, + RadrootsNostrEvent, RadrootsNostrGenericEventBuilder, RadrootsNostrKind, + RadrootsNostrPublicKey, RadrootsNostrTagKind, }; use radroots_nostr_connect::prelude::{ RADROOTS_NOSTR_CONNECT_RPC_KIND, RadrootsNostrConnectMethod, @@ -1199,7 +1210,7 @@ mod tests { let client_public_key = fixture_carol_public_key(); let request = request_message("req-parse", RadrootsNostrConnectRequest::Ping); let raw = serde_json::to_string(&request).expect("serialize request"); - let event = RadrootsNostrEventBuilder::new( + let event = RadrootsNostrGenericEventBuilder::new( RadrootsNostrKind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND), raw, ) @@ -1216,7 +1227,9 @@ mod tests { RadrootsNostrConnectResponse::Pong, ) .expect("response builder"); - let response_event = response_builder.build(test_signer().signer_identity.public_key()); + let response_event = response_builder + .sign_with_keys(&Keys::generate()) + .expect("sign response event"); assert_eq!( response_event.kind, RadrootsNostrKind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND) @@ -1239,7 +1252,8 @@ mod tests { RadrootsNostrConnectResponse::ConnectAcknowledged, ) .expect("handler response") - .build(test_signer().signer_identity.public_key()); + .sign_with_keys(&Keys::generate()) + .expect("sign handler response event"); assert_eq!( handler_event.kind, RadrootsNostrKind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND) diff --git a/crates/nostrdb/Cargo.toml b/crates/nostrdb/Cargo.toml @@ -36,5 +36,6 @@ thiserror = { workspace = true } tokio = { workspace = true, optional = true, features = ["rt", "sync", "time"] } [dev-dependencies] +nostr = { workspace = true } tokio = { workspace = true, features = ["macros", "rt", "sync", "time"] } tempfile = { workspace = true } diff --git a/crates/nostrdb/src/nostrdb.rs b/crates/nostrdb/src/nostrdb.rs @@ -357,8 +357,9 @@ mod tests { use crate::query::RadrootsNostrdbQuerySpec; use crate::test_fixtures::{FIXTURE_ALICE_EMAIL, FIXTURE_ALICE_USERNAME}; use futures::StreamExt; + use nostr::EventBuilder; + use radroots_nostr::prelude::RadrootsNostrKeys; use radroots_nostr::prelude::RadrootsNostrMetadata; - use radroots_nostr::prelude::{RadrootsNostrEventBuilder, RadrootsNostrKeys}; use std::sync::atomic::Ordering; use std::sync::{Mutex, OnceLock}; use std::time::Duration; @@ -471,7 +472,7 @@ mod tests { let nostrdb = RadrootsNostrdb::open(config).expect("database should open"); let keys = RadrootsNostrKeys::generate(); - let event = RadrootsNostrEventBuilder::text_note("hello from nostrdb") + let event = EventBuilder::text_note("hello from nostrdb") .sign_with_keys(&keys) .expect("event should sign"); @@ -488,7 +489,7 @@ mod tests { let nostrdb = RadrootsNostrdb::open(config).expect("database should open"); let keys = RadrootsNostrKeys::generate(); - let event = RadrootsNostrEventBuilder::text_note("hello from nostrdb json") + let event = EventBuilder::text_note("hello from nostrdb json") .sign_with_keys(&keys) .expect("event should sign"); let json = serde_json::to_string(&event).expect("event json"); @@ -524,7 +525,7 @@ mod tests { let nostrdb = RadrootsNostrdb::open(config).expect("database should open"); let keys = RadrootsNostrKeys::generate(); - let event = RadrootsNostrEventBuilder::text_note("forced json error") + let event = EventBuilder::text_note("forced json error") .sign_with_keys(&keys) .expect("event should sign"); test_hooks::FORCE_EVENT_JSON_ERROR.store(true, Ordering::SeqCst); @@ -549,7 +550,7 @@ mod tests { let handle = nostrdb.subscribe(&spec).expect("subscribe should succeed"); let keys = RadrootsNostrKeys::generate(); - let event = RadrootsNostrEventBuilder::text_note("subscription test") + let event = EventBuilder::text_note("subscription test") .sign_with_keys(&keys) .expect("event should sign"); nostrdb @@ -619,7 +620,7 @@ mod tests { let handle = nostrdb.subscribe(&spec).expect("subscribe should succeed"); let keys = RadrootsNostrKeys::generate(); - let event = RadrootsNostrEventBuilder::text_note("wait test") + let event = EventBuilder::text_note("wait test") .sign_with_keys(&keys) .expect("event should sign"); nostrdb @@ -663,7 +664,7 @@ mod tests { let nostrdb = RadrootsNostrdb::open(config).expect("database should open"); let keys = RadrootsNostrKeys::generate(); - let event = RadrootsNostrEventBuilder::text_note("query note") + let event = EventBuilder::text_note("query note") .sign_with_keys(&keys) .expect("event should sign"); nostrdb @@ -764,7 +765,7 @@ mod tests { let nostrdb = RadrootsNostrdb::open(config).expect("database should open"); let keys = RadrootsNostrKeys::generate(); - let event = RadrootsNostrEventBuilder::text_note("note json error") + let event = EventBuilder::text_note("note json error") .sign_with_keys(&keys) .expect("event should sign"); nostrdb @@ -805,7 +806,7 @@ mod tests { .display_name(FIXTURE_ALICE_USERNAME) .about("coffee operator") .lud16(FIXTURE_ALICE_EMAIL); - let metadata_event = RadrootsNostrEventBuilder::metadata(&metadata) + let metadata_event = EventBuilder::metadata(&metadata) .sign_with_keys(&keys) .expect("metadata event should sign"); nostrdb @@ -893,7 +894,7 @@ mod tests { let keys = RadrootsNostrKeys::generate(); let pubkey_hex = keys.public_key().to_hex(); - let event = RadrootsNostrEventBuilder::text_note("non profile event") + let event = EventBuilder::text_note("non profile event") .sign_with_keys(&keys) .expect("event should sign"); nostrdb @@ -917,7 +918,7 @@ mod tests { let keys = RadrootsNostrKeys::generate(); let pubkey_hex = keys.public_key().to_hex(); - let event = RadrootsNostrEventBuilder::new( + let event = EventBuilder::new( radroots_nostr::prelude::RadrootsNostrKind::Metadata, "not valid metadata json", ) @@ -974,7 +975,7 @@ mod tests { assert!(pending.is_err()); let keys = RadrootsNostrKeys::generate(); - let event = RadrootsNostrEventBuilder::text_note("stream note") + let event = EventBuilder::text_note("stream note") .sign_with_keys(&keys) .expect("event should sign"); nostrdb @@ -1008,7 +1009,7 @@ mod tests { let keys = RadrootsNostrKeys::generate(); for idx in 0..notes_per_worker { let content = format!("parallel-{worker}-{idx}"); - let event = RadrootsNostrEventBuilder::text_note(content.as_str()) + let event = EventBuilder::text_note(content.as_str()) .sign_with_keys(&keys) .expect("event should sign"); db.ingest_event(&event, RadrootsNostrdbIngestSource::client()) diff --git a/crates/nostrdb/src/runtime_adapter.rs b/crates/nostrdb/src/runtime_adapter.rs @@ -51,7 +51,8 @@ impl RadrootsNostrEventSink for RadrootsNostrdbEventSinkAdapter { mod tests { use super::*; use crate::config::RadrootsNostrdbConfig; - use radroots_nostr::prelude::{RadrootsNostrEventBuilder, RadrootsNostrKeys}; + use nostr::EventBuilder; + use radroots_nostr::prelude::RadrootsNostrKeys; use tempfile::TempDir; #[test] @@ -63,7 +64,7 @@ mod tests { let adapter = RadrootsNostrdbEventSinkAdapter::new(nostrdb); let keys = RadrootsNostrKeys::generate(); - let event = RadrootsNostrEventBuilder::text_note("hello from runtime adapter") + let event = EventBuilder::text_note("hello from runtime adapter") .sign_with_keys(&keys) .expect("event should sign"); @@ -83,7 +84,7 @@ mod tests { .into_event_sink(); let keys = RadrootsNostrKeys::generate(); - let event = RadrootsNostrEventBuilder::text_note("hello trait object") + let event = EventBuilder::text_note("hello trait object") .sign_with_keys(&keys) .expect("event should sign"); @@ -100,7 +101,7 @@ mod tests { let sink: Arc<dyn RadrootsNostrEventSink> = Arc::new(nostrdb.clone()); let keys = RadrootsNostrKeys::generate(); - let event = RadrootsNostrEventBuilder::text_note("hello nostrdb trait object") + let event = EventBuilder::text_note("hello nostrdb trait object") .sign_with_keys(&keys) .expect("event should sign"); diff --git a/crates/outbox/src/store.rs b/crates/outbox/src/store.rs @@ -3254,7 +3254,7 @@ mod tests { RadrootsAddressableCoordinate, RadrootsDTag, RadrootsEventId, RadrootsInventoryBinId, RadrootsPublicKey, RadrootsTradeId, }; - use radroots_event::kinds::{KIND_LISTING, KIND_POST}; + use radroots_event::kinds::{KIND_GEOCHAT, KIND_LISTING}; use radroots_event::trade::{ RADROOTS_TRADE_PROPOSAL_CONTRACT_ID, RADROOTS_TRADE_SCHEMA_VERSION, RadrootsFulfillmentProfileV1, RadrootsTradeCancellationProfileV1, @@ -3289,16 +3289,16 @@ mod tests { RadrootsPublicKey::parse(hex_64(character)).expect("pubkey") } - fn post_draft(expected_pubkey: &str, content: &str) -> RadrootsEventDraft { + fn generic_draft(expected_pubkey: &str, content: &str) -> RadrootsEventDraft { RadrootsEventDraft::new( - "radroots.social.post.v1", - KIND_POST, + "radroots.social.geochat.v1", + KIND_GEOCHAT, 1_700_000_000, vec![vec!["t".to_owned(), "soil".to_owned()]], content, expected_pubkey, ) - .expect("post draft") + .expect("generic draft") } fn candidate_terms() -> RadrootsTradeCandidateTermsV1 { @@ -3726,7 +3726,7 @@ mod tests { canonical.envelope.trade_id.as_str(), canonical.mutation_id.as_str(), valid_hash.as_str(), - &post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "not a trade mutation"), + &generic_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "not a trade mutation"), ), Err(RadrootsOutboxError::TradeMutationMetadataMismatch { field: "kind" }) )); @@ -4128,7 +4128,7 @@ mod tests { .await .expect("pool outbox"); - let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "preflight"); + let draft = generic_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "preflight"); let signed_event = radroots_nostr_sign_frozen_draft(&fixture_keys(), &draft) .expect("signed preflight event"); let input = signed_operation_input(draft.clone(), signed_event.clone(), 1_000) @@ -4154,7 +4154,7 @@ mod tests { .await .expect("matching existing preflight"); - let changed_draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "preflight changed"); + let changed_draft = generic_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "preflight changed"); let changed_signed = radroots_nostr_sign_frozen_draft(&fixture_keys(), &changed_draft) .expect("changed signed event"); let conflict = signed_operation_input(changed_draft, changed_signed, 1_100) @@ -4166,7 +4166,7 @@ mod tests { Err(RadrootsOutboxError::IdempotencyConflict { .. }) )); - let transaction_draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "transaction"); + let transaction_draft = generic_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "transaction"); let transaction_signed = radroots_nostr_sign_frozen_draft(&fixture_keys(), &transaction_draft) .expect("transaction signed event"); @@ -4184,7 +4184,8 @@ mod tests { .expect("transaction existing"); assert_eq!(inserted.operation_id, existing.operation_id); - let transaction_changed = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "transaction changed"); + let transaction_changed = + generic_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "transaction changed"); let transaction_changed_signed = radroots_nostr_sign_frozen_draft(&fixture_keys(), &transaction_changed) .expect("transaction changed signed event"); @@ -4203,7 +4204,7 @@ mod tests { #[tokio::test] async fn defensive_storage_decoding_and_remaining_idempotency_edges_are_explicit() { let outbox = RadrootsOutbox::open_memory().await.expect("open"); - let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "defensive storage"); + let draft = generic_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "defensive storage"); let signed_event = radroots_nostr_sign_frozen_draft(&fixture_keys(), &draft) .expect("signed defensive event"); let signed_receipt = outbox @@ -4215,7 +4216,7 @@ mod tests { .await .expect("signed insert without idempotency key"); - let keyed_draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "keyed public insert"); + let keyed_draft = generic_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "keyed public insert"); let keyed_signed = radroots_nostr_sign_frozen_draft(&fixture_keys(), &keyed_draft) .expect("keyed signed event"); outbox @@ -4225,7 +4226,7 @@ mod tests { ) .await .expect("keyed public signed insert"); - let changed_draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "keyed public conflict"); + let changed_draft = generic_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "keyed public conflict"); let changed_signed = radroots_nostr_sign_frozen_draft(&fixture_keys(), &changed_draft) .expect("changed signed event"); assert!(matches!( @@ -4239,7 +4240,7 @@ mod tests { )); let canonical = canonical_trade_proposal(); - let invalid_trade_draft = post_draft( + let invalid_trade_draft = generic_draft( FIXTURE_ALICE_PUBLIC_KEY_HEX, "not a semantic trade mutation", ); @@ -4305,7 +4306,7 @@ mod tests { .await .expect("trade insert without idempotency key"); - let transaction_draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "transaction no key"); + let transaction_draft = generic_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "transaction no key"); let transaction_signed = radroots_nostr_sign_frozen_draft(&fixture_keys(), &transaction_draft) .expect("transaction signed event"); @@ -4549,7 +4550,7 @@ mod tests { }) )); - let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "plan lifecycle"); + let draft = generic_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "plan lifecycle"); let signed_event = radroots_nostr_sign_frozen_draft(&fixture_keys(), &draft).expect("sign plan lifecycle"); let first = outbox @@ -4648,7 +4649,7 @@ mod tests { #[tokio::test] async fn operation_and_delivery_plan_idempotency_are_split() { let outbox = RadrootsOutbox::open_memory().await.expect("open"); - let draft = post_draft(hex_64('a').as_str(), "hello"); + let draft = generic_draft(hex_64('a').as_str(), "hello"); let first = outbox .enqueue_operation(operation_input(draft.clone(), 1_000).with_idempotency_key("idem-a")) .await @@ -4690,7 +4691,7 @@ mod tests { let conflict = outbox .enqueue_operation( - operation_input(post_draft(hex_64('a').as_str(), "changed"), 1_300) + operation_input(generic_draft(hex_64('a').as_str(), "changed"), 1_300) .with_idempotency_key("idem-a"), ) .await @@ -4719,7 +4720,7 @@ mod tests { .is_none() ); - let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "retry signing"); + let draft = generic_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "retry signing"); let receipt = outbox .enqueue_operation(operation_input(draft.clone(), 1_000)) .await @@ -4744,7 +4745,7 @@ mod tests { Err(RadrootsOutboxError::ClaimTokenMismatch { .. }) )); - let other_draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "different event"); + let other_draft = generic_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "different event"); let other_signed = radroots_nostr_sign_frozen_draft(&fixture_keys(), &other_draft) .expect("sign different event"); assert!(matches!( @@ -4884,7 +4885,7 @@ mod tests { #[tokio::test] async fn claim_compare_and_swap_guards_report_lost_worker_races() { let unsigned_outbox = RadrootsOutbox::open_memory().await.expect("open unsigned"); - let unsigned_draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "unsigned race"); + let unsigned_draft = generic_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "unsigned race"); unsigned_outbox .enqueue_operation(operation_input(unsigned_draft, 1_000)) .await @@ -4904,7 +4905,7 @@ mod tests { ); let signed_outbox = RadrootsOutbox::open_memory().await.expect("open signed"); - let signed_draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "signed race"); + let signed_draft = generic_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "signed race"); let signed_event = radroots_nostr_sign_frozen_draft(&fixture_keys(), &signed_draft) .expect("sign race event"); signed_outbox @@ -4929,7 +4930,7 @@ mod tests { #[tokio::test] async fn claimed_publish_mutations_enforce_plan_identity_and_atomic_updates() { let outbox = RadrootsOutbox::open_memory().await.expect("open"); - let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "publish guards"); + let draft = generic_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "publish guards"); let signed_event = radroots_nostr_sign_frozen_draft(&fixture_keys(), &draft).expect("sign publish guards"); let receipt = outbox @@ -5430,7 +5431,7 @@ mod tests { #[tokio::test] async fn enqueue_rejects_empty_delivery_targets_before_persistence() { let outbox = RadrootsOutbox::open_memory().await.expect("open"); - let draft = post_draft(hex_64('a').as_str(), "hello"); + let draft = generic_draft(hex_64('a').as_str(), "hello"); let err = outbox .enqueue_operation(RadrootsOutboxOperationInput::new( @@ -5451,7 +5452,7 @@ mod tests { #[tokio::test] async fn no_wait_delivery_plan_persists_directly_and_completes_without_target_satisfaction() { let outbox = RadrootsOutbox::open_memory().await.expect("open"); - let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "no wait"); + let draft = generic_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "no wait"); let signed_event = radroots_nostr_sign_frozen_draft(&fixture_keys(), &draft).expect("signed event"); let receipt = outbox @@ -5537,7 +5538,7 @@ mod tests { #[tokio::test] async fn delivery_targets_persist_scope_label_and_identity_rules() { let outbox = RadrootsOutbox::open_memory().await.expect("open"); - let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "target metadata"); + let draft = generic_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "target metadata"); let scoped = scoped_nostr_target(NOSTR_PRIMARY_WSS, "farm.alpha", "North greenhouse"); let relabeled = scoped_nostr_target(NOSTR_PRIMARY_WSS, "farm.alpha", "Renamed greenhouse"); let rescaled = scoped_nostr_target(NOSTR_PRIMARY_WSS, "farm.beta", "North greenhouse"); @@ -5617,7 +5618,7 @@ mod tests { #[tokio::test] async fn enqueue_rejects_duplicate_delivery_targets_before_persistence() { let outbox = RadrootsOutbox::open_memory().await.expect("open"); - let draft = post_draft(hex_64('a').as_str(), "duplicate targets"); + let draft = generic_draft(hex_64('a').as_str(), "duplicate targets"); let err = outbox .enqueue_operation(RadrootsOutboxOperationInput::new( @@ -5645,7 +5646,7 @@ mod tests { #[tokio::test] async fn enqueue_rejects_invalid_reticulum_targets_before_persistence() { let outbox = RadrootsOutbox::open_memory().await.expect("open"); - let draft = post_draft(hex_64('a').as_str(), "invalid reticulum"); + let draft = generic_draft(hex_64('a').as_str(), "invalid reticulum"); let err = outbox .enqueue_operation(RadrootsOutboxOperationInput::new( @@ -5672,7 +5673,7 @@ mod tests { #[tokio::test] async fn signed_enqueue_claims_ready_delivery_targets_and_records_attempts() { let outbox = RadrootsOutbox::open_memory().await.expect("open"); - let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "signed"); + let draft = generic_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "signed"); let signed_event = radroots_nostr_sign_frozen_draft(&fixture_keys(), &draft).expect("signed event"); let receipt = outbox @@ -5775,7 +5776,7 @@ mod tests { #[tokio::test] async fn required_target_plan_evaluation_uses_persisted_fingerprints() { let outbox = RadrootsOutbox::open_memory().await.expect("open"); - let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "required target"); + let draft = generic_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "required target"); let signed_event = radroots_nostr_sign_frozen_draft(&fixture_keys(), &draft).expect("signed event"); let optional = nostr_target(NOSTR_PRIMARY_WSS); @@ -5883,7 +5884,7 @@ mod tests { #[tokio::test] async fn required_target_plan_evaluation_ignores_optional_retryable_failure() { let outbox = RadrootsOutbox::open_memory().await.expect("open"); - let draft = post_draft( + let draft = generic_draft( FIXTURE_ALICE_PUBLIC_KEY_HEX, "required target optional failure", ); @@ -5977,7 +5978,7 @@ mod tests { #[tokio::test] async fn enqueue_rejects_required_targets_outside_delivery_plan_before_persistence() { let outbox = RadrootsOutbox::open_memory().await.expect("open"); - let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "stale required target"); + let draft = generic_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "stale required target"); let missing = nostr_target(NOSTR_SECONDARY_WSS); let err = outbox @@ -6019,7 +6020,7 @@ mod tests { RadrootsOutboxDeliveryTargetStatus::FailedTerminal, ] { let outbox = RadrootsOutbox::open_memory().await.expect("open"); - let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, terminal_status.as_str()); + let draft = generic_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, terminal_status.as_str()); let signed_event = radroots_nostr_sign_frozen_draft(&fixture_keys(), &draft).expect("signed event"); let receipt = outbox @@ -6125,7 +6126,7 @@ mod tests { #[tokio::test] async fn publish_claims_one_delivery_plan_and_rejects_sibling_target_outcomes() { let outbox = RadrootsOutbox::open_memory().await.expect("open"); - let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "duplicate endpoint plans"); + let draft = generic_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "duplicate endpoint plans"); let signed_event = radroots_nostr_sign_frozen_draft(&fixture_keys(), &draft).expect("signed event"); let first = outbox @@ -6268,7 +6269,7 @@ mod tests { #[tokio::test] async fn active_plan_terminal_failure_leaves_sibling_claimable() { let outbox = RadrootsOutbox::open_memory().await.expect("open"); - let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "plan scoped terminal"); + let draft = generic_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "plan scoped terminal"); let signed_event = radroots_nostr_sign_frozen_draft(&fixture_keys(), &draft).expect("signed event"); let first = outbox @@ -6411,7 +6412,7 @@ mod tests { #[tokio::test] async fn cancel_claimed_event_marks_every_delivery_plan_cancelled() { let outbox = RadrootsOutbox::open_memory().await.expect("open"); - let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "cancel all plans"); + let draft = generic_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "cancel all plans"); let signed_event = radroots_nostr_sign_frozen_draft(&fixture_keys(), &draft).expect("signed event"); let first = outbox @@ -6495,7 +6496,7 @@ mod tests { #[tokio::test] async fn quorum_accepted_delivery_plan_round_trips_and_completes_after_required_target() { let outbox = RadrootsOutbox::open_memory().await.expect("open"); - let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "at least"); + let draft = generic_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "at least"); let signed_event = radroots_nostr_sign_frozen_draft(&fixture_keys(), &draft).expect("signed event"); let receipt = outbox @@ -6563,7 +6564,7 @@ mod tests { #[tokio::test] async fn publish_attempt_stays_retryable_while_delivery_targets_remain_ready() { let outbox = RadrootsOutbox::open_memory().await.expect("open"); - let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "retryable"); + let draft = generic_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "retryable"); let signed_event = radroots_nostr_sign_frozen_draft(&fixture_keys(), &draft).expect("signed event"); let receipt = outbox @@ -6605,7 +6606,7 @@ mod tests { #[tokio::test] async fn reticulum_reject_targets_are_deferred_until_implemented_and_not_ready() { let outbox = RadrootsOutbox::open_memory().await.expect("open"); - let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "reticulum rejected"); + let draft = generic_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "reticulum rejected"); let signed_event = radroots_nostr_sign_frozen_draft(&fixture_keys(), &draft).expect("signed event"); let receipt = outbox @@ -6675,7 +6676,7 @@ mod tests { #[tokio::test] async fn reticulum_deferred_targets_do_not_retry_or_satisfy_delivery() { let outbox = RadrootsOutbox::open_memory().await.expect("open"); - let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "reticulum deferred"); + let draft = generic_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "reticulum deferred"); let signed_event = radroots_nostr_sign_frozen_draft(&fixture_keys(), &draft).expect("signed event"); let receipt = outbox @@ -6741,7 +6742,8 @@ mod tests { .expect("claim") .is_none() ); - let default_behavior_draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "reticulum deferred"); + let default_behavior_draft = + generic_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "reticulum deferred"); let default_behavior_signed_event = radroots_nostr_sign_frozen_draft(&fixture_keys(), &default_behavior_draft) .expect("signed event"); @@ -6771,7 +6773,7 @@ mod tests { #[tokio::test] async fn repeated_reticulum_signed_delivery_reuses_one_logical_operation_and_plan() { let outbox = RadrootsOutbox::open_memory().await.expect("open"); - let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "reticulum repeated"); + let draft = generic_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "reticulum repeated"); let signed_event = radroots_nostr_sign_frozen_draft(&fixture_keys(), &draft).expect("signed event"); let first = outbox @@ -6840,7 +6842,7 @@ mod tests { #[tokio::test] async fn reticulum_events_report_deferred_records() { let outbox = RadrootsOutbox::open_memory().await.expect("open"); - let reject_draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "reticulum record"); + let reject_draft = generic_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "reticulum record"); let reject_signed_event = radroots_nostr_sign_frozen_draft(&fixture_keys(), &reject_draft) .expect("reticulum signed event"); let reject = outbox @@ -6860,7 +6862,8 @@ mod tests { )) .await .expect("reticulum enqueue"); - let deferred_draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "reticulum deferred record"); + let deferred_draft = + generic_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "reticulum deferred record"); let deferred_signed_event = radroots_nostr_sign_frozen_draft(&fixture_keys(), &deferred_draft) .expect("deferred signed event"); @@ -6973,7 +6976,7 @@ mod tests { #[tokio::test] async fn complete_signing_sets_failed_terminal_lifecycle_for_reticulum_only_plans() { let outbox = RadrootsOutbox::open_memory().await.expect("open"); - let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "reticulum after signing"); + let draft = generic_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "reticulum after signing"); let receipt = outbox .enqueue_operation(RadrootsOutboxOperationInput::new( "publish_post", @@ -7030,7 +7033,7 @@ mod tests { #[tokio::test] async fn explicit_all_targets_completes_after_nostr_success_with_reticulum_preserved() { let outbox = RadrootsOutbox::open_memory().await.expect("open"); - let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "explicit all targets"); + let draft = generic_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "explicit all targets"); let signed_event = radroots_nostr_sign_frozen_draft(&fixture_keys(), &draft).expect("signed event"); let receipt = outbox @@ -7132,7 +7135,7 @@ mod tests { #[tokio::test] async fn explicit_quorum_rejects_deferred_targets_as_required_success_capacity() { let outbox = RadrootsOutbox::open_memory().await.expect("open"); - let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "explicit quorum"); + let draft = generic_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "explicit quorum"); let signed_event = radroots_nostr_sign_frozen_draft(&fixture_keys(), &draft).expect("signed event"); let err = outbox @@ -7169,7 +7172,7 @@ mod tests { #[tokio::test] async fn explicit_nostr_and_reticulum_preserves_ready_nostr_work() { let outbox = RadrootsOutbox::open_memory().await.expect("open"); - let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "explicit reticulum"); + let draft = generic_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "explicit reticulum"); let signed_event = radroots_nostr_sign_frozen_draft(&fixture_keys(), &draft).expect("signed event"); let receipt = outbox @@ -7226,7 +7229,7 @@ mod tests { #[tokio::test] async fn accepted_delivery_target_can_advance_to_strict_success_status() { let outbox = RadrootsOutbox::open_memory().await.expect("open"); - let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "accepted then delivered"); + let draft = generic_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "accepted then delivered"); let signed_event = radroots_nostr_sign_frozen_draft(&fixture_keys(), &draft).expect("signed event"); let receipt = outbox @@ -7334,7 +7337,7 @@ mod tests { ), ] { let outbox = RadrootsOutbox::open_memory().await.expect("open"); - let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, content); + let draft = generic_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, content); let signed_event = radroots_nostr_sign_frozen_draft(&fixture_keys(), &draft).expect("signed event"); let receipt = outbox @@ -7478,7 +7481,7 @@ mod tests { ), ] { let outbox = RadrootsOutbox::open_memory().await.expect("open"); - let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, content); + let draft = generic_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, content); let signed_event = radroots_nostr_sign_frozen_draft(&fixture_keys(), &draft).expect("signed event"); let receipt = outbox @@ -7603,7 +7606,7 @@ mod tests { let event_store = RadrootsEventStore::open_memory() .await .expect("event store"); - let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "local ingest"); + let draft = generic_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "local ingest"); let receipt = outbox .enqueue_operation(operation_input(draft, 1_000)) .await diff --git a/crates/trade/Cargo.toml b/crates/trade/Cargo.toml @@ -76,6 +76,7 @@ sqlx = { workspace = true, default-features = false, features = [ thiserror = { workspace = true } [dev-dependencies] +nostr = { workspace = true } radroots_nostr = { workspace = true, default-features = false, features = [ "std", "events", diff --git a/crates/trade/src/projection.rs b/crates/trade/src/projection.rs @@ -911,6 +911,7 @@ fn bool_i64(value: bool) -> i64 { #[cfg(test)] mod tests { use super::*; + use nostr::EventBuilder; use radroots_core::{ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit, @@ -937,8 +938,8 @@ mod tests { RadrootsEventIngest, RadrootsTransportObservation, RadrootsTransportObservationType, }; use radroots_nostr::prelude::{ - RadrootsNostrKeys, RadrootsNostrSecretKey, RadrootsNostrTimestamp, - radroots_nostr_build_event, + RadrootsNostrKeys, RadrootsNostrKind, RadrootsNostrSecretKey, RadrootsNostrTag, + RadrootsNostrTagKind, RadrootsNostrTimestamp, }; use radroots_transport::RadrootsTransportKind; @@ -959,6 +960,27 @@ mod tests { RadrootsNostrKeys::new(RadrootsNostrSecretKey::from_hex(secret).expect("secret")) } + fn test_event_builder( + kind: u32, + content: impl Into<String>, + tags: Vec<Vec<String>>, + ) -> EventBuilder { + let tags = tags + .into_iter() + .filter(|tag| !tag.is_empty()) + .map(|mut tag| { + let key = tag.remove(0); + RadrootsNostrTag::custom(RadrootsNostrTagKind::Custom(key.into()), tag) + }) + .collect(); + EventBuilder::new( + RadrootsNostrKind::Custom(u16::try_from(kind).expect("test kind must fit NIP-01")), + content.into(), + ) + .tags(tags) + .allow_self_tagging() + } + fn decimal(raw: &str) -> RadrootsCoreDecimal { raw.parse().expect("decimal") } @@ -1200,8 +1222,7 @@ mod tests { created_at: u32, keys: &RadrootsNostrKeys, ) -> RadrootsSignedEvent { - let raw_event = radroots_nostr_build_event(kind, content, tags) - .expect("builder") + let raw_event = test_event_builder(kind, content, tags) .custom_created_at(RadrootsNostrTimestamp::from_secs(u64::from(created_at))) .sign_with_keys(keys) .expect("signed"); diff --git a/crates/transport_nostr/src/fetch.rs b/crates/transport_nostr/src/fetch.rs @@ -824,7 +824,6 @@ mod tests { use nostr::JsonUtil; use radroots_nostr::prelude::{ RadrootsNostrFilter, RadrootsNostrKeys, RadrootsNostrKind, RadrootsNostrSecretKey, - radroots_nostr_build_event, }; use std::collections::HashMap; @@ -835,8 +834,7 @@ mod tests { let secret_key = RadrootsNostrSecretKey::from_hex(FIXTURE_ALICE_SECRET_KEY_HEX).expect("secret key"); let keys = RadrootsNostrKeys::new(secret_key); - let event = radroots_nostr_build_event(1, "hello", Vec::new()) - .expect("event builder") + let event = nostr::EventBuilder::new(nostr::Kind::TextNote, "hello") .sign_with_keys(&keys) .expect("signed event"); RadrootsNostrEvent::from_json(event.as_json().as_str()).expect("raw event") diff --git a/crates/transport_nostr/src/publish.rs b/crates/transport_nostr/src/publish.rs @@ -780,7 +780,7 @@ mod tests { use super::{RadrootsNostrEvent, ensure_raw_event_matches_signed_event}; use nostr::JsonUtil; use radroots_event::draft::{RadrootsEventDraft, RadrootsSignedEvent}; - use radroots_event::kinds::KIND_POST; + use radroots_event::kinds::KIND_GEOCHAT; use radroots_event::wire::RadrootsNip01EventWire; use radroots_nostr::prelude::{ RadrootsNostrKeys, RadrootsNostrSecretKey, radroots_nostr_sign_frozen_draft, @@ -796,8 +796,8 @@ mod tests { RadrootsNostrSecretKey::from_hex(FIXTURE_ALICE_SECRET_KEY_HEX).expect("secret key"); let keys = RadrootsNostrKeys::new(secret_key); let draft = RadrootsEventDraft::new( - "radroots.social.post.v1", - KIND_POST, + "radroots.social.geochat.v1", + KIND_GEOCHAT, 1_700_000_000, vec![vec!["t".to_owned(), "soil".to_owned()]], content, diff --git a/crates/transport_nostr/tests/transport.rs b/crates/transport_nostr/tests/transport.rs @@ -1,14 +1,14 @@ use futures::future::BoxFuture; -use nostr::JsonUtil; +use nostr::{EventBuilder, JsonUtil}; use radroots_event::draft::{RadrootsEventDraft, RadrootsSignedEvent}; -use radroots_event::kinds::KIND_POST; +use radroots_event::kinds::{KIND_GEOCHAT, KIND_POST}; use radroots_event_store::{ RadrootsEventStore, RadrootsEventVerificationStatus, RadrootsTransportObservationRow, RadrootsTransportObservationType, }; use radroots_nostr::prelude::{ RadrootsNostrFilter, RadrootsNostrKeys, RadrootsNostrKind, RadrootsNostrSecretKey, - RadrootsNostrTimestamp, radroots_nostr_build_event, radroots_nostr_filter_tag, + RadrootsNostrTag, RadrootsNostrTagKind, RadrootsNostrTimestamp, radroots_nostr_filter_tag, radroots_nostr_sign_frozen_draft, }; use radroots_outbox::{ @@ -204,10 +204,43 @@ fn fixture_keys() -> RadrootsNostrKeys { RadrootsNostrKeys::new(secret_key) } +fn test_event_builder( + kind: u32, + content: impl Into<String>, + tags: Vec<Vec<String>>, +) -> EventBuilder { + let tags: Vec<_> = tags + .into_iter() + .filter(|tag| !tag.is_empty()) + .map(|mut tag| { + let key = tag.remove(0); + RadrootsNostrTag::custom(RadrootsNostrTagKind::Custom(key.into()), tag) + }) + .collect(); + EventBuilder::new( + RadrootsNostrKind::Custom(u16::try_from(kind).expect("test kind must fit NIP-01")), + content.into(), + ) + .tags(tags) + .allow_self_tagging() +} + fn signed_post(content: &str) -> RadrootsSignedEvent { signed_event_with_kind_and_hashtag(content, KIND_POST, "soil") } +fn generic_draft(content: &str) -> RadrootsEventDraft { + RadrootsEventDraft::new( + "radroots.social.geochat.v1", + KIND_GEOCHAT, + 1_700_000_000, + vec![vec!["t".to_owned(), "soil".to_owned()]], + content, + FIXTURE_ALICE_PUBLIC_KEY_HEX, + ) + .expect("generic draft") +} + fn assert_outbox_publish_observations( observations: &[RadrootsTransportObservationRow], publish_ack_count: usize, @@ -237,25 +270,18 @@ fn signed_event_with_kind_and_hashtag( kind: u32, hashtag: &str, ) -> RadrootsSignedEvent { - let draft = RadrootsEventDraft::new( - "radroots.social.post.v1", - kind, - 1_700_000_000, - vec![vec!["t".to_owned(), hashtag.to_owned()]], - content, - FIXTURE_ALICE_PUBLIC_KEY_HEX, - ) - .expect("draft"); - radroots_nostr_sign_frozen_draft(&fixture_keys(), &draft).expect("signed event") + let raw_event = signed_raw_event_with_kind_and_hashtag(content, kind, hashtag); + let raw_json = raw_event.as_json(); + let wire = radroots_event::wire::RadrootsNip01EventWire::parse_json(&raw_json).expect("wire"); + RadrootsSignedEvent::from_wire_verified_id(wire, raw_json).expect("signed event") } fn signed_raw_event_with_kind_and_hashtag(content: &str, kind: u32, hashtag: &str) -> nostr::Event { - radroots_nostr_build_event( + test_event_builder( kind, content, vec![vec!["t".to_owned(), hashtag.to_owned()]], ) - .expect("event builder") .custom_created_at(RadrootsNostrTimestamp::from_secs(1_700_000_000)) .sign_with_keys(&fixture_keys()) .expect("signed event") @@ -337,8 +363,7 @@ where } fn unsupported_raw_event() -> String { - let event = radroots_nostr_build_event(999, "unsupported", Vec::new()) - .expect("event builder") + let event = test_event_builder(999, "unsupported", Vec::new()) .custom_created_at(RadrootsNostrTimestamp::from_secs(1_700_000_001)) .sign_with_keys(&fixture_keys()) .expect("signed unsupported event"); @@ -1839,18 +1864,9 @@ async fn fetch_ingest_rejects_invalid_observation_endpoint() { #[tokio::test] async fn outbox_publish_persists_partial_success_and_skips_accepted_retry() { - let signed = signed_post("hello"); let outbox = RadrootsOutbox::open_memory().await.expect("outbox"); let store = RadrootsEventStore::open_memory().await.expect("store"); - let draft = RadrootsEventDraft::new( - "radroots.social.post.v1", - KIND_POST, - signed.created_at(), - signed.tags_as_vec(), - signed.content().to_owned(), - signed.pubkey_str(), - ) - .expect("draft"); + let draft = generic_draft("hello"); let receipt = outbox .enqueue_operation(all_accepted_outbox_operation_input( draft, @@ -1979,18 +1995,9 @@ async fn outbox_publish_persists_partial_success_and_skips_accepted_retry() { #[tokio::test] async fn outbox_transport_facade_persists_partial_success_and_retryable_failures() { - let signed = signed_post("transport facade outbox"); let outbox = RadrootsOutbox::open_memory().await.expect("outbox"); let store = RadrootsEventStore::open_memory().await.expect("store"); - let draft = RadrootsEventDraft::new( - "radroots.social.post.v1", - KIND_POST, - signed.created_at(), - signed.tags_as_vec(), - signed.content().to_owned(), - signed.pubkey_str(), - ) - .expect("draft"); + let draft = generic_draft("transport facade outbox"); let receipt = outbox .enqueue_operation(all_accepted_outbox_operation_input( draft, @@ -2070,18 +2077,9 @@ async fn outbox_transport_facade_persists_partial_success_and_retryable_failures #[tokio::test] async fn outbox_transport_facade_persists_every_delivery_status() { - let signed = signed_post("transport outcome matrix"); let outbox = RadrootsOutbox::open_memory().await.expect("outbox"); let store = RadrootsEventStore::open_memory().await.expect("store"); - let draft = RadrootsEventDraft::new( - "radroots.social.post.v1", - KIND_POST, - signed.created_at(), - signed.tags_as_vec(), - signed.content().to_owned(), - signed.pubkey_str(), - ) - .expect("draft"); + let draft = generic_draft("transport outcome matrix"); let relays = (0..14) .map(|index| format!("wss://relay-{index}.example.com")) .collect::<Vec<_>>(); @@ -2179,18 +2177,9 @@ async fn outbox_transport_facade_persists_every_delivery_status() { #[tokio::test] async fn outbox_transport_facade_rejects_pending_receipts() { - let signed = signed_post("pending transport receipt"); let outbox = RadrootsOutbox::open_memory().await.expect("outbox"); let store = RadrootsEventStore::open_memory().await.expect("store"); - let draft = RadrootsEventDraft::new( - "radroots.social.post.v1", - KIND_POST, - signed.created_at(), - signed.tags_as_vec(), - signed.content().to_owned(), - signed.pubkey_str(), - ) - .expect("draft"); + let draft = generic_draft("pending transport receipt"); outbox .enqueue_operation(all_accepted_outbox_operation_input( draft, @@ -2232,18 +2221,9 @@ async fn outbox_transport_facade_rejects_pending_receipts() { #[tokio::test] async fn outbox_transport_facade_handles_empty_and_invalid_claim_plans() { - let signed = signed_post("transport plan edges"); let outbox = RadrootsOutbox::open_memory().await.expect("outbox"); let store = RadrootsEventStore::open_memory().await.expect("store"); - let draft = RadrootsEventDraft::new( - "radroots.social.post.v1", - KIND_POST, - signed.created_at(), - signed.tags_as_vec(), - signed.content().to_owned(), - signed.pubkey_str(), - ) - .expect("draft"); + let draft = generic_draft("transport plan edges"); let receipt = outbox .enqueue_operation(all_accepted_outbox_operation_input( draft, @@ -2288,16 +2268,7 @@ async fn outbox_transport_facade_handles_empty_and_invalid_claim_plans() { assert_eq!(published.accepted_count, 2); assert!(published.quorum_met); - let second_signed = signed_post("invalid claimed plan"); - let second_draft = RadrootsEventDraft::new( - "radroots.social.post.v1", - KIND_POST, - second_signed.created_at(), - second_signed.tags_as_vec(), - second_signed.content().to_owned(), - second_signed.pubkey_str(), - ) - .expect("draft"); + let second_draft = generic_draft("invalid claimed plan"); outbox .enqueue_operation(all_accepted_outbox_operation_input( second_draft, @@ -2353,18 +2324,9 @@ async fn outbox_transport_facade_handles_empty_and_invalid_claim_plans() { #[tokio::test] async fn outbox_transport_facade_requires_signed_claims() { - let signed = signed_post("missing transport signature"); let outbox = RadrootsOutbox::open_memory().await.expect("outbox"); let store = RadrootsEventStore::open_memory().await.expect("store"); - let draft = RadrootsEventDraft::new( - "radroots.social.post.v1", - KIND_POST, - signed.created_at(), - signed.tags_as_vec(), - signed.content().to_owned(), - signed.pubkey_str(), - ) - .expect("draft"); + let draft = generic_draft("missing transport signature"); let receipt = outbox .enqueue_operation(all_accepted_outbox_operation_input( draft, @@ -2397,18 +2359,9 @@ async fn outbox_transport_facade_requires_signed_claims() { #[tokio::test] async fn outbox_publish_fans_out_endpoint_receipts_to_scoped_logical_targets() { - let signed = signed_post("scoped duplicate relay"); let outbox = RadrootsOutbox::open_memory().await.expect("outbox"); let store = RadrootsEventStore::open_memory().await.expect("store"); - let draft = RadrootsEventDraft::new( - "radroots.social.post.v1", - KIND_POST, - signed.created_at(), - signed.tags_as_vec(), - signed.content().to_owned(), - signed.pubkey_str(), - ) - .expect("draft"); + let draft = generic_draft("scoped duplicate relay"); let receipt = outbox .enqueue_operation(RadrootsOutboxOperationInput::new( "publish_post", @@ -2511,18 +2464,9 @@ async fn outbox_publish_fans_out_endpoint_receipts_to_scoped_logical_targets() { #[tokio::test] async fn outbox_publish_required_target_failure_is_not_satisfied_by_optional_success() { - let signed = signed_post("required target optional success"); let outbox = RadrootsOutbox::open_memory().await.expect("outbox"); let store = RadrootsEventStore::open_memory().await.expect("store"); - let draft = RadrootsEventDraft::new( - "radroots.social.post.v1", - KIND_POST, - signed.created_at(), - signed.tags_as_vec(), - signed.content().to_owned(), - signed.pubkey_str(), - ) - .expect("draft"); + let draft = generic_draft("required target optional success"); let optional = nostr_target(RELAY_PRIMARY_WSS); let required = nostr_target(RELAY_SECONDARY_WSS); let receipt = outbox @@ -2614,18 +2558,9 @@ async fn outbox_publish_required_target_failure_is_not_satisfied_by_optional_suc #[tokio::test] async fn outbox_publish_required_target_success_is_not_blocked_by_optional_retryable_failure() { - let signed = signed_post("required target optional failure"); let outbox = RadrootsOutbox::open_memory().await.expect("outbox"); let store = RadrootsEventStore::open_memory().await.expect("store"); - let draft = RadrootsEventDraft::new( - "radroots.social.post.v1", - KIND_POST, - signed.created_at(), - signed.tags_as_vec(), - signed.content().to_owned(), - signed.pubkey_str(), - ) - .expect("draft"); + let draft = generic_draft("required target optional failure"); let optional = nostr_target(RELAY_PRIMARY_WSS); let required = nostr_target(RELAY_SECONDARY_WSS); let receipt = outbox @@ -2720,18 +2655,9 @@ async fn outbox_publish_required_target_success_is_not_blocked_by_optional_retry #[tokio::test] async fn outbox_publish_required_targets_fan_out_same_endpoint_scoped_receipts() { - let signed = signed_post("required target scoped duplicate relay"); let outbox = RadrootsOutbox::open_memory().await.expect("outbox"); let store = RadrootsEventStore::open_memory().await.expect("store"); - let draft = RadrootsEventDraft::new( - "radroots.social.post.v1", - KIND_POST, - signed.created_at(), - signed.tags_as_vec(), - signed.content().to_owned(), - signed.pubkey_str(), - ) - .expect("draft"); + let draft = generic_draft("required target scoped duplicate relay"); let required = scoped_nostr_target(RELAY_PRIMARY_WSS, "foodshed.west", "West foodshed"); let optional = scoped_nostr_target(RELAY_PRIMARY_WSS, "foodshed.east", "East foodshed"); let terminal = scoped_nostr_target(RELAY_PRIMARY_WSS, "foodshed.closed", "Closed foodshed"); @@ -2859,18 +2785,9 @@ async fn outbox_publish_required_targets_fan_out_same_endpoint_scoped_receipts() #[tokio::test] async fn outbox_transport_publish_failure_releases_retryable_claim() { - let signed = signed_post("adapter transport failure"); let outbox = RadrootsOutbox::open_memory().await.expect("outbox"); let store = RadrootsEventStore::open_memory().await.expect("store"); - let draft = RadrootsEventDraft::new( - "radroots.social.post.v1", - KIND_POST, - signed.created_at(), - signed.tags_as_vec(), - signed.content().to_owned(), - signed.pubkey_str(), - ) - .expect("draft"); + let draft = generic_draft("adapter transport failure"); let receipt = outbox .enqueue_operation(all_accepted_outbox_operation_input( draft, @@ -2950,18 +2867,9 @@ async fn outbox_transport_publish_failure_releases_retryable_claim() { #[tokio::test] async fn outbox_publish_marks_published_without_adapter_when_all_relays_already_accepted() { - let signed = signed_post("already accepted"); let outbox = RadrootsOutbox::open_memory().await.expect("outbox"); let store = RadrootsEventStore::open_memory().await.expect("store"); - let draft = RadrootsEventDraft::new( - "radroots.social.post.v1", - KIND_POST, - signed.created_at(), - signed.tags_as_vec(), - signed.content().to_owned(), - signed.pubkey_str(), - ) - .expect("draft"); + let draft = generic_draft("already accepted"); let receipt = outbox .enqueue_operation(all_accepted_outbox_operation_input( draft, @@ -3039,18 +2947,9 @@ async fn outbox_publish_marks_published_without_adapter_when_all_relays_already_ #[tokio::test] async fn outbox_publish_ignores_unknown_adapter_receipts() { - let signed = signed_post("unknown receipt"); let outbox = RadrootsOutbox::open_memory().await.expect("outbox"); let store = RadrootsEventStore::open_memory().await.expect("store"); - let draft = RadrootsEventDraft::new( - "radroots.social.post.v1", - KIND_POST, - signed.created_at(), - signed.tags_as_vec(), - signed.content().to_owned(), - signed.pubkey_str(), - ) - .expect("draft"); + let draft = generic_draft("unknown receipt"); let receipt = outbox .enqueue_operation(all_accepted_outbox_operation_input( draft, @@ -3105,18 +3004,9 @@ async fn outbox_publish_ignores_unknown_adapter_receipts() { #[tokio::test] async fn outbox_publish_skips_non_nostr_targets() { - let signed = signed_post("mixed target"); let outbox = RadrootsOutbox::open_memory().await.expect("outbox"); let store = RadrootsEventStore::open_memory().await.expect("store"); - let draft = RadrootsEventDraft::new( - "radroots.social.post.v1", - KIND_POST, - signed.created_at(), - signed.tags_as_vec(), - signed.content().to_owned(), - signed.pubkey_str(), - ) - .expect("draft"); + let draft = generic_draft("mixed target"); let receipt = outbox .enqueue_operation(RadrootsOutboxOperationInput::new( "publish_post", @@ -3179,18 +3069,9 @@ async fn outbox_publish_skips_non_nostr_targets() { #[tokio::test] async fn outbox_publish_marks_published_when_delivery_plan_satisfaction_is_met_with_failure_diagnostics() { - let signed = signed_post("quorum"); let outbox = RadrootsOutbox::open_memory().await.expect("outbox"); let store = RadrootsEventStore::open_memory().await.expect("store"); - let draft = RadrootsEventDraft::new( - "radroots.social.post.v1", - KIND_POST, - signed.created_at(), - signed.tags_as_vec(), - signed.content().to_owned(), - signed.pubkey_str(), - ) - .expect("draft"); + let draft = generic_draft("quorum"); let receipt = outbox .enqueue_operation(outbox_operation_input( draft, @@ -3284,18 +3165,9 @@ async fn outbox_publish_marks_published_when_delivery_plan_satisfaction_is_met_w #[tokio::test] async fn outbox_publish_republishes_accepted_relays_when_policy_requests_it() { - let signed = signed_post("republish accepted"); let outbox = RadrootsOutbox::open_memory().await.expect("outbox"); let store = RadrootsEventStore::open_memory().await.expect("store"); - let draft = RadrootsEventDraft::new( - "radroots.social.post.v1", - KIND_POST, - signed.created_at(), - signed.tags_as_vec(), - signed.content().to_owned(), - signed.pubkey_str(), - ) - .expect("draft"); + let draft = generic_draft("republish accepted"); let receipt = outbox .enqueue_operation(all_accepted_outbox_operation_input( draft, @@ -3367,18 +3239,9 @@ async fn outbox_publish_republishes_accepted_relays_when_policy_requests_it() { #[tokio::test] async fn outbox_publish_republish_policy_keeps_terminal_targets_excluded() { - let signed = signed_post("republish terminal excluded"); let outbox = RadrootsOutbox::open_memory().await.expect("outbox"); let store = RadrootsEventStore::open_memory().await.expect("store"); - let draft = RadrootsEventDraft::new( - "radroots.social.post.v1", - KIND_POST, - signed.created_at(), - signed.tags_as_vec(), - signed.content().to_owned(), - signed.pubkey_str(), - ) - .expect("draft"); + let draft = generic_draft("republish terminal excluded"); let receipt = outbox .enqueue_operation(all_accepted_outbox_operation_input( draft, @@ -3447,18 +3310,9 @@ async fn outbox_publish_republish_policy_keeps_terminal_targets_excluded() { #[tokio::test] async fn outbox_publish_requires_claimed_signed_event() { - let signed = signed_post("missing signature"); let outbox = RadrootsOutbox::open_memory().await.expect("outbox"); let store = RadrootsEventStore::open_memory().await.expect("store"); - let draft = RadrootsEventDraft::new( - "radroots.social.post.v1", - KIND_POST, - signed.created_at(), - signed.tags_as_vec(), - signed.content().to_owned(), - signed.pubkey_str(), - ) - .expect("draft"); + let draft = generic_draft("missing signature"); let receipt = outbox .enqueue_operation(all_accepted_outbox_operation_input( draft, @@ -3494,18 +3348,9 @@ async fn outbox_publish_requires_claimed_signed_event() { #[tokio::test] async fn outbox_publish_propagates_non_transport_adapter_errors_after_target_filtering() { - let signed = signed_post("adapter non transport failure"); let outbox = RadrootsOutbox::open_memory().await.expect("outbox"); let store = RadrootsEventStore::open_memory().await.expect("store"); - let draft = RadrootsEventDraft::new( - "radroots.social.post.v1", - KIND_POST, - signed.created_at(), - signed.tags_as_vec(), - signed.content().to_owned(), - signed.pubkey_str(), - ) - .expect("draft"); + let draft = generic_draft("adapter non transport failure"); let receipt = outbox .enqueue_operation(all_accepted_outbox_operation_input( draft, @@ -3551,18 +3396,9 @@ async fn outbox_publish_propagates_non_transport_adapter_errors_after_target_fil #[tokio::test] async fn outbox_publish_rejects_invalid_relay_target_uri_before_adapter_publish() { - let signed = signed_post("invalid relay target"); let outbox = RadrootsOutbox::open_memory().await.expect("outbox"); let store = RadrootsEventStore::open_memory().await.expect("store"); - let draft = RadrootsEventDraft::new( - "radroots.social.post.v1", - KIND_POST, - signed.created_at(), - signed.tags_as_vec(), - signed.content().to_owned(), - signed.pubkey_str(), - ) - .expect("draft"); + let draft = generic_draft("invalid relay target"); let receipt = outbox .enqueue_operation(all_accepted_outbox_operation_input( draft, diff --git a/tools/xtask/src/contract.rs b/tools/xtask/src/contract.rs @@ -21,6 +21,8 @@ const KNOWLEDGE_MANIFEST_AND_DECODE_RELATIVE: &str = "contracts/conformance/vectors/knowledge/manifest_and_decode.v1.json"; const KNOWLEDGE_PUBLIC_SURFACE_RELATIVE: &str = "contracts/conformance/vectors/knowledge/public_surface.v1.json"; +const POST_CONFORMANCE_VECTOR_RELATIVE: &str = + "contracts/conformance/vectors/post/verified_profiles.v1.json"; const RELEASES_ROOT_RELATIVE: &str = "contracts/releases"; const CHANGELOG_RELATIVE: &str = "CHANGELOG.md"; const REPLICA_CONTRACT_RELATIVE: &str = "contracts/replica.toml"; @@ -139,6 +141,30 @@ const REQUIRED_CALENDAR_PUBLIC_TYPES: [&str; 34] = [ "RadrootsParsedNip52CalendarEventRsvp", "RadrootsAdmittedCalendarEventRsvp", ]; +const REQUIRED_POST_PUBLIC_TYPES: [&str; 22] = [ + "RadrootsBlossomApprovedBlobUrl", + "RadrootsBlossomByteVerifiedDescriptor", + "RadrootsNip01EventWireParts", + "RadrootsEventEnvelope", + "RadrootsSignatureVerifiedEvent", + "RadrootsAuthoredImage", + "RadrootsPost", + "RadrootsAuthoredPostError", + "RadrootsPostImageDimensions", + "RadrootsAuthoredPostImage", + "RadrootsAuthoredUpdate", + "RadrootsAuthoredPhotoUpdate", + "RadrootsAuthoredAsk", + "RadrootsPostDiagnostic", + "RadrootsPostClassification", + "RadrootsInboundPostImeta", + "RadrootsInboundPostProjection", + "RadrootsPostProjectionError", + "RadrootsAdmittedRootPostEvent", + "RadrootsThreadExcludedPostCandidate", + "RadrootsPostAdmissionOutcome", + "RadrootsPostAdmissionError", +]; const CALENDAR_OPERATION_EXPECTATIONS: [CalendarOperationExpectation; 12] = [ CalendarOperationExpectation { key: "social_calendar_date_event_build_authored_draft", @@ -392,6 +418,240 @@ const CALENDAR_OPERATION_EXPECTATIONS: [CalendarOperationExpectation; 12] = [ vector: "contracts/conformance/vectors/calendar/radroots_profile.v1.json", }, ]; +const POST_OPERATION_EXPECTATIONS: [PostOperationExpectation; 5] = [ + PostOperationExpectation { + key: "social_update_build_authored_draft", + id: "social.update.build_authored_draft", + inputs: &["RadrootsAuthoredUpdate"], + outputs: &["RadrootsNip01EventWireParts"], + error_class: "encode_error", + signing: "none", + rust_modules: &[ + "crates/event/src/post.rs", + "crates/event_codec/src/post/authored.rs", + ], + rust_types: &[ + "radroots_event::post::RadrootsAuthoredPostError", + "radroots_event::post::RadrootsAuthoredUpdate", + ], + case_kinds: &[ + "social.update.build_authored_draft.valid", + "social.update.build_authored_draft.invalid", + ], + }, + PostOperationExpectation { + key: "social_photo_update_build_authored_draft", + id: "social.photo_update.build_authored_draft", + inputs: &["RadrootsAuthoredPhotoUpdate"], + outputs: &["RadrootsNip01EventWireParts"], + error_class: "encode_error", + signing: "none", + 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", + ], + case_kinds: &[ + "social.photo_update.build_authored_draft.valid", + "social.photo_update.build_authored_draft.invalid", + ], + }, + PostOperationExpectation { + key: "social_ask_build_authored_draft", + id: "social.ask.build_authored_draft", + inputs: &["RadrootsAuthoredAsk"], + outputs: &["RadrootsNip01EventWireParts"], + error_class: "encode_error", + signing: "none", + 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", + ], + case_kinds: &[ + "social.ask.build_authored_draft.valid", + "social.ask.build_authored_draft.invalid", + ], + }, + PostOperationExpectation { + key: "social_post_project_verified_event", + id: "social.post.project_verified_event", + inputs: &["RadrootsSignatureVerifiedEvent"], + outputs: &["RadrootsInboundPostProjection"], + error_class: "parse_error", + signing: "none", + 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", + ], + case_kinds: &[ + "social.post.project_verified_event.valid", + "social.post.project_verified_event.invalid", + ], + }, + PostOperationExpectation { + key: "social_post_verify_and_admit_event", + id: "social.post.verify_and_admit_event", + inputs: &["RadrootsEventEnvelope"], + outputs: &["RadrootsPostAdmissionOutcome"], + error_class: "admission_error", + signing: "nip01", + 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::RadrootsAdmittedRootPostEvent", + "radroots_event_codec::post::admission::RadrootsPostAdmissionError", + "radroots_event_codec::post::admission::RadrootsPostAdmissionOutcome", + "radroots_event_codec::post::admission::RadrootsThreadExcludedPostCandidate", + "radroots_event_codec::post::inbound::RadrootsInboundPostProjection", + "radroots_event_codec::verification::RadrootsSignatureVerifiedEvent", + ], + case_kinds: &[ + "social.post.verify_and_admit_event.valid", + "social.post.verify_and_admit_event.invalid", + ], + }, +]; +const POST_OPERATION_KEY_PREFIXES: [&str; 4] = [ + "social_update_", + "social_photo_update_", + "social_ask_", + "social_post_", +]; +const POST_OPERATION_ID_PREFIXES: [&str; 4] = [ + "social.update.", + "social.photo_update.", + "social.ask.", + "social.post.", +]; +const POST_VECTOR_EXPECTATIONS: [(&str, &str); 27] = [ + ( + "authored_update_wire", + "social.update.build_authored_draft.valid", + ), + ( + "authored_update_blank", + "social.update.build_authored_draft.invalid", + ), + ( + "authored_photo_update_wire", + "social.photo_update.build_authored_draft.valid", + ), + ( + "authored_photo_update_mime_underscore", + "social.photo_update.build_authored_draft.invalid", + ), + ("authored_ask_wire", "social.ask.build_authored_draft.valid"), + ( + "authored_ask_blank", + "social.ask.build_authored_draft.invalid", + ), + ( + "project_signed_update", + "social.post.project_verified_event.valid", + ), + ( + "project_signed_empty_inbound_update", + "social.post.project_verified_event.valid", + ), + ( + "project_signed_structural_photo", + "social.post.project_verified_event.valid", + ), + ( + "project_signed_photo_preserves_fallbacks_and_unknown_fields", + "social.post.project_verified_event.valid", + ), + ( + "project_signed_normalized_ask_precedes_malformed_media", + "social.post.project_verified_event.valid", + ), + ( + "project_signed_malformed_imeta_is_update", + "social.post.project_verified_event.valid", + ), + ( + "project_signed_duplicate_singleton_is_update", + "social.post.project_verified_event.valid", + ), + ( + "project_signed_mixed_imeta_is_update", + "social.post.project_verified_event.valid", + ), + ( + "project_signed_thread_candidate_precedes_ask_and_media", + "social.post.project_verified_event.valid", + ), + ( + "project_signed_malformed_ask_marker_is_update", + "social.post.project_verified_event.valid", + ), + ( + "project_signed_duplicate_normalized_ask_marker", + "social.post.project_verified_event.invalid", + ), + ( + "project_signed_kind_20_is_not_photo_update", + "social.post.project_verified_event.invalid", + ), + ( + "admit_signed_update", + "social.post.verify_and_admit_event.valid", + ), + ( + "admit_signed_structural_photo", + "social.post.verify_and_admit_event.valid", + ), + ( + "admit_signed_normalized_ask_precedes_malformed_media", + "social.post.verify_and_admit_event.valid", + ), + ( + "admit_signed_thread_candidate_precedes_ask_and_media", + "social.post.verify_and_admit_event.valid", + ), + ( + "admit_signed_empty_e_thread_candidate", + "social.post.verify_and_admit_event.valid", + ), + ( + "admit_signed_empty_e_value_thread_candidate", + "social.post.verify_and_admit_event.valid", + ), + ( + "admit_signed_duplicate_normalized_ask_marker", + "social.post.verify_and_admit_event.invalid", + ), + ( + "admit_signed_kind_20_is_not_photo_update", + "social.post.verify_and_admit_event.invalid", + ), + ( + "admit_signed_invalid_signature", + "social.post.verify_and_admit_event.invalid", + ), +]; const EVENT_BOUNDARY_MATRIX_RELATIVES: [&str; 2] = [ "contracts/event_boundary_matrix.md", "docs/platform/canonical/open_source/radroots_v1_spec/02_public_contract_and_runtime/08_event_boundary_matrix.md", @@ -563,6 +823,8 @@ pub struct PublicOperationImplementation { #[serde(deny_unknown_fields)] pub struct PublicOperationConformance { pub vector: String, + #[serde(default)] + pub case_kinds: Vec<String>, } #[derive(Clone, Copy)] @@ -577,6 +839,19 @@ struct CalendarOperationExpectation { vector: &'static str, } +#[derive(Clone, Copy)] +struct PostOperationExpectation { + key: &'static str, + id: &'static str, + inputs: &'static [&'static str], + outputs: &'static [&'static str], + error_class: &'static str, + signing: &'static str, + rust_modules: &'static [&'static str], + rust_types: &'static [&'static str], + case_kinds: &'static [&'static str], +} + #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] pub struct VersionPolicy { @@ -810,10 +1085,39 @@ const FOLLOW_WITNESSES: [EventBoundarySourceWitness; 2] = [ }, ]; -const POST_WITNESSES: [EventBoundarySourceWitness; 2] = [ +const POST_WITNESSES: [EventBoundarySourceWitness; 5] = [ EventBoundarySourceWitness { relative_path: "crates/event/src/post.rs", - required_fragments: &["pub struct RadrootsPost"], + required_fragments: &[ + "pub struct RadrootsPost", + "pub struct RadrootsAuthoredUpdate", + "pub struct RadrootsAuthoredPhotoUpdate", + "pub struct RadrootsAuthoredAsk", + ], + }, + EventBoundarySourceWitness { + relative_path: "crates/event_codec/src/post/authored.rs", + required_fragments: &[ + "pub fn authored_update_to_wire_parts", + "pub fn authored_photo_update_to_wire_parts", + "pub fn authored_ask_to_wire_parts", + ], + }, + EventBoundarySourceWitness { + relative_path: "crates/event_codec/src/post/inbound.rs", + required_fragments: &[ + "pub struct RadrootsInboundPostProjection", + "pub fn project_verified_post_event", + ], + }, + EventBoundarySourceWitness { + relative_path: "crates/event_codec/src/post/admission.rs", + required_fragments: &[ + "pub struct RadrootsAdmittedRootPostEvent", + "pub struct RadrootsThreadExcludedPostCandidate", + "pub enum RadrootsPostAdmissionOutcome", + "pub fn verify_and_admit_post_event", + ], }, EventBoundarySourceWitness { relative_path: "crates/event/src/kinds.rs", @@ -1438,7 +1742,7 @@ const CANONICAL_EVENT_BOUNDARY_EXPECTATIONS: [EventBoundaryExpectation; 41] = [ EventBoundaryExpectation { domain: "post", kind: "1", - radroots_type: "RadrootsPost", + radroots_type: "RadrootsAuthoredUpdate / RadrootsAuthoredPhotoUpdate / RadrootsAuthoredAsk / RadrootsInboundPostProjection", rpc_methods: &["events.post.publish", "events.post.list", "events.post.get"], witnesses: &POST_WITNESSES, }, @@ -3038,10 +3342,11 @@ pub fn validate_knowledge_contract_manifest(workspace_root: &Path) -> Result<(), validate_knowledge_conformance_vector_inventory(workspace_root)?; let bundle = load_contract_bundle(workspace_root)?; - validate_conformance_vector_file( + let knowledge_manifest_vector = validate_conformance_vector_file( &workspace_root.join(KNOWLEDGE_MANIFEST_AND_DECODE_RELATIVE), &bundle.manifest.contract.version, )?; + validate_knowledge_manifest_vector_semantics(&actual_json, &knowledge_manifest_vector)?; validate_conformance_vector_file( &workspace_root.join(KNOWLEDGE_PUBLIC_SURFACE_RELATIVE), &bundle.manifest.contract.version, @@ -3049,6 +3354,65 @@ pub fn validate_knowledge_contract_manifest(workspace_root: &Path) -> Result<(), Ok(()) } +fn validate_knowledge_manifest_vector_semantics( + manifest_json: &str, + vector: &ConformanceVectorFile, +) -> Result<(), String> { + let manifest = serde_json::from_str::<Value>(manifest_json) + .map_err(|error| format!("parse knowledge manifest JSON: {error}"))?; + let schema_version = manifest + .get("schema_version") + .and_then(Value::as_u64) + .ok_or_else(|| { + "knowledge manifest schema_version must be an unsigned integer".to_string() + })?; + let registry_version = manifest + .get("registry_version") + .and_then(Value::as_u64) + .ok_or_else(|| { + "knowledge manifest registry_version must be an unsigned integer".to_string() + })?; + let case = vector + .vectors + .iter() + .find(|entry| entry.id == "knowledge_manifest_fields_valid_001") + .ok_or_else(|| { + "knowledge manifest conformance must define knowledge_manifest_fields_valid_001" + .to_string() + })?; + if case.kind != "knowledge.contract_manifest_json.valid" { + return Err(format!( + "knowledge manifest conformance case kind drift: expected knowledge.contract_manifest_json.valid, got {}", + case.kind + )); + } + + let expected_registry_marker = format!("radroots_event_contract_registry_v{registry_version}"); + let actual_registry_marker = case.input.get("registry").and_then(Value::as_str); + if actual_registry_marker != Some(expected_registry_marker.as_str()) { + return Err(format!( + "knowledge manifest conformance registry marker drift: expected {expected_registry_marker}, got {}", + actual_registry_marker.unwrap_or("<missing-or-non-string>") + )); + } + + for (field, expected) in [ + ("schema_version", schema_version), + ("registry_version", registry_version), + ] { + let actual = case.expected.get(field).and_then(Value::as_u64); + if actual != Some(expected) { + return Err(format!( + "knowledge manifest conformance expected {field} drift: expected {expected}, got {}", + actual + .map(|value| value.to_string()) + .unwrap_or_else(|| "<missing-or-non-integer>".to_string()) + )); + } + } + Ok(()) +} + fn validate_knowledge_conformance_vector_inventory(workspace_root: &Path) -> Result<(), String> { let expected = BTreeSet::from([ KNOWLEDGE_MANIFEST_AND_DECODE_RELATIVE.to_owned(), @@ -4113,13 +4477,262 @@ fn validate_operations_contract( conformance_root.display() )); } - validate_conformance_vector_file(&vector_path, &operations_manifest.contract.version)?; + let vector = + validate_conformance_vector_file(&vector_path, &operations_manifest.contract.version)?; + validate_operation_case_kinds(operation, &vector)?; } if domains.contains("social") { validate_calendar_operation_authority(operations_manifest, &shared_types)?; + validate_post_operation_authority(operations_manifest, workspace_root)?; + } + + Ok(()) +} + +fn validate_operation_case_kinds( + operation: &PublicOperationContract, + vector: &ConformanceVectorFile, +) -> Result<(), String> { + if operation.conformance.case_kinds.is_empty() { + return Ok(()); + } + let case_kinds = collect_non_empty_set( + &operation.conformance.case_kinds, + &format!("operation {} conformance.case_kinds", operation.id), + )?; + if case_kinds.len() != operation.conformance.case_kinds.len() { + return Err(format!( + "operation {} conformance.case_kinds must not contain duplicates", + operation.id + )); + } + let prefix = format!("{}.", operation.id); + let vector_kinds = vector + .vectors + .iter() + .map(|entry| entry.kind.as_str()) + .collect::<BTreeSet<_>>(); + for case_kind in case_kinds { + if !case_kind.starts_with(&prefix) { + return Err(format!( + "operation {} conformance case kind {} must start with {}", + operation.id, case_kind, prefix + )); + } + if !vector_kinds.contains(case_kind.as_str()) { + return Err(format!( + "operation {} conformance case kind {} is absent from {}", + operation.id, case_kind, operation.conformance.vector + )); + } + } + Ok(()) +} + +fn validate_post_operation_authority( + manifest: &OperationsContractManifest, + workspace_root: &Path, +) -> Result<(), String> { + let vector = validate_conformance_vector_file( + &workspace_root.join(POST_CONFORMANCE_VECTOR_RELATIVE), + &manifest.contract.version, + )?; + validate_post_operation_inventory(manifest, &vector) +} + +fn validate_post_operation_inventory( + manifest: &OperationsContractManifest, + vector: &ConformanceVectorFile, +) -> Result<(), String> { + let shared_types = collect_non_empty_set( + &manifest.shared_types.public, + "post operation shared_types.public", + )?; + for required in REQUIRED_POST_PUBLIC_TYPES { + if !shared_types.contains(required) { + return Err(format!( + "post operation authority requires shared public type {required}" + )); + } + } + + let expected_keys = POST_OPERATION_EXPECTATIONS + .iter() + .map(|expectation| expectation.key.to_string()) + .collect::<BTreeSet<_>>(); + let actual_keys = manifest + .operations + .iter() + .filter(|(key, operation)| { + operation.conformance.vector == POST_CONFORMANCE_VECTOR_RELATIVE + || POST_OPERATION_KEY_PREFIXES + .iter() + .any(|prefix| key.starts_with(prefix)) + || POST_OPERATION_ID_PREFIXES + .iter() + .any(|prefix| operation.id.starts_with(prefix)) + }) + .map(|(key, _)| key.clone()) + .collect::<BTreeSet<_>>(); + if actual_keys != expected_keys { + let missing = expected_keys + .difference(&actual_keys) + .cloned() + .collect::<BTreeSet<_>>(); + let unexpected = actual_keys + .difference(&expected_keys) + .cloned() + .collect::<BTreeSet<_>>(); + return Err(format!( + "post operation authority drift: missing {}; unexpected {}", + join_set(&missing), + join_set(&unexpected) + )); } + let mut owners = BTreeMap::new(); + for expected in POST_OPERATION_EXPECTATIONS { + let operation = manifest + .operations + .get(expected.key) + .ok_or_else(|| format!("post operation {} is required", expected.key))?; + validate_post_operation_scalar(expected.key, "domain", &operation.domain, "social")?; + validate_post_operation_scalar(expected.key, "id", &operation.id, expected.id)?; + validate_post_operation_scalar(expected.key, "stability", &operation.stability, "beta")?; + validate_post_operation_scalar( + expected.key, + "error_class", + &operation.error_class, + expected.error_class, + )?; + validate_post_operation_scalar( + expected.key, + "signing", + &operation.signing, + expected.signing, + )?; + validate_post_operation_scalar(expected.key, "transport", &operation.transport, "none")?; + if !operation.deterministic { + return Err(format!( + "post operation {} deterministic drift: expected true, got false", + expected.key + )); + } + validate_post_operation_sequence( + expected.key, + "inputs", + &operation.inputs, + expected.inputs, + )?; + validate_post_operation_sequence( + expected.key, + "outputs", + &operation.outputs, + expected.outputs, + )?; + validate_post_operation_sequence( + expected.key, + "implementation.rust_modules", + &operation.implementation.rust_modules, + expected.rust_modules, + )?; + validate_post_operation_sequence( + expected.key, + "implementation.rust_types", + &operation.implementation.rust_types, + expected.rust_types, + )?; + validate_post_operation_scalar( + expected.key, + "conformance.vector", + &operation.conformance.vector, + POST_CONFORMANCE_VECTOR_RELATIVE, + )?; + validate_operation_case_kinds(operation, vector)?; + if !operation + .conformance + .case_kinds + .iter() + .map(String::as_str) + .eq(expected.case_kinds.iter().copied()) + { + return Err(format!( + "post operation {} conformance.case_kinds drift: expected {:?}, got {:?}", + expected.key, expected.case_kinds, operation.conformance.case_kinds + )); + } + for case_kind in &operation.conformance.case_kinds { + if let Some(previous) = owners.insert(case_kind.as_str(), expected.key) { + return Err(format!( + "post conformance case kind {case_kind} is multiply claimed by {previous} and {}", + expected.key + )); + } + } + } + + let mut actual_inventory = BTreeMap::new(); + for entry in &vector.vectors { + if actual_inventory + .insert(entry.id.as_str(), entry.kind.as_str()) + .is_some() + { + return Err(format!( + "post conformance vector inventory has duplicate id {}", + entry.id + )); + } + } + for kind in actual_inventory.values() { + if !owners.contains_key(kind) { + return Err(format!( + "post conformance vector kind {kind} is not claimed by exactly one operation" + )); + } + } + let expected_inventory = POST_VECTOR_EXPECTATIONS + .into_iter() + .collect::<BTreeMap<_, _>>(); + if actual_inventory != expected_inventory { + return Err(format!( + "post conformance vector inventory drift: expected {:?}, got {:?}", + expected_inventory, actual_inventory + )); + } + Ok(()) +} + +fn validate_post_operation_scalar( + operation_key: &str, + field: &str, + actual: &str, + expected: &str, +) -> Result<(), String> { + if actual != expected { + return Err(format!( + "post operation {operation_key} {field} drift: expected {expected}, got {actual}" + )); + } + Ok(()) +} + +fn validate_post_operation_sequence( + operation_key: &str, + field: &str, + actual: &[String], + expected: &[&str], +) -> Result<(), String> { + if !actual + .iter() + .map(String::as_str) + .eq(expected.iter().copied()) + { + return Err(format!( + "post operation {operation_key} {field} drift: expected {:?}, got {:?}", + expected, actual + )); + } Ok(()) } @@ -5450,6 +6063,27 @@ mod tests { .expect("canonical workspace root") } + fn current_post_authority() -> (OperationsContractManifest, ConformanceVectorFile) { + let root = workspace_root(); + let manifest = + parse_toml::<OperationsContractManifest>(&root.join("contracts/operations.toml")) + .expect("current operations manifest"); + let vector = + parse_json::<ConformanceVectorFile>(&root.join(POST_CONFORMANCE_VECTOR_RELATIVE)) + .expect("current post conformance vector"); + (manifest, vector) + } + + fn current_knowledge_manifest_authority() -> (String, ConformanceVectorFile) { + let root = workspace_root(); + let manifest = fs::read_to_string(root.join(KNOWLEDGE_MANIFEST_RELATIVE)) + .expect("current knowledge manifest"); + let vector = + parse_json::<ConformanceVectorFile>(&root.join(KNOWLEDGE_MANIFEST_AND_DECODE_RELATIVE)) + .expect("current knowledge manifest conformance vector"); + (manifest, vector) + } + fn temp_root(prefix: &str) -> PathBuf { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -6058,6 +6692,281 @@ crates = ["radroots_a", "radroots_b", "radroots_c", "radroots_d", "radroots_e"] } #[test] + fn knowledge_manifest_vector_authority_rejects_registry_drift() { + let (manifest, vector) = current_knowledge_manifest_authority(); + validate_knowledge_manifest_vector_semantics(&manifest, &vector) + .expect("current knowledge manifest vector authority"); + + let (manifest, mut vector) = current_knowledge_manifest_authority(); + let case = vector + .vectors + .iter_mut() + .find(|entry| entry.id == "knowledge_manifest_fields_valid_001") + .expect("knowledge manifest case"); + case.input + .as_object_mut() + .expect("knowledge manifest input") + .insert( + "registry".to_string(), + Value::String("radroots_event_contract_registry_v1".to_string()), + ); + let error = validate_knowledge_manifest_vector_semantics(&manifest, &vector) + .expect_err("stale registry marker must fail"); + assert!(error.contains("registry marker drift"), "{error}"); + + let (manifest, mut vector) = current_knowledge_manifest_authority(); + let case = vector + .vectors + .iter_mut() + .find(|entry| entry.id == "knowledge_manifest_fields_valid_001") + .expect("knowledge manifest case"); + case.expected + .as_object_mut() + .expect("knowledge manifest expected output") + .insert("registry_version".to_string(), Value::from(1_u64)); + let error = validate_knowledge_manifest_vector_semantics(&manifest, &vector) + .expect_err("stale expected registry version must fail"); + assert!(error.contains("expected registry_version drift"), "{error}"); + } + + #[test] + fn strict_post_release_record_governs_public_boundary_breaks() { + let root = workspace_root(); + let bundle = load_contract_bundle(&root).expect("load current contract bundle"); + let major_impacts = bundle + .version + .semver + .major_on + .iter() + .map(String::as_str) + .collect::<BTreeSet<_>>(); + for impact in [ + "add_exported_field", + "change_exported_function_signature", + "change_exported_constant_value", + ] { + assert!( + major_impacts.contains(impact), + "version policy must govern {impact} as a major impact" + ); + } + assert!( + bundle + .version + .semver + .minor_on + .iter() + .any(|impact| impact == "add_exported_constant"), + "version policy must govern add_exported_constant as a minor impact" + ); + + let release = + parse_toml::<ReleaseRecord>(&root.join("contracts/releases/1.0.0-alpha.1.toml")) + .expect("current release record"); + let change = release + .changes + .iter() + .find(|change| change.id == "strict-kind-one-product-profiles") + .expect("strict kind-one release change"); + let impacts = change + .semver_impacts + .iter() + .map(String::as_str) + .collect::<BTreeSet<_>>(); + for impact in [ + "remove_exported_type", + "add_exported_constant", + "add_exported_field", + "change_exported_function_signature", + "change_exported_enum_variant", + "change_exported_constant_value", + ] { + assert!( + impacts.contains(impact), + "strict kind-one release change must declare {impact}" + ); + } + } + + #[test] + fn post_operation_authority_rejects_manifest_and_inventory_drift() { + let (manifest, vector) = current_post_authority(); + validate_post_operation_inventory(&manifest, &vector).expect("current post authority"); + + let (mut manifest, vector) = current_post_authority(); + manifest + .operations + .remove("social_update_build_authored_draft"); + let error = validate_post_operation_inventory(&manifest, &vector) + .expect_err("missing post operation must fail"); + assert!(error.contains("post operation authority drift")); + + let (mut manifest, vector) = current_post_authority(); + manifest + .operations + .get_mut("social_update_build_authored_draft") + .expect("Update operation") + .id = "social.update.wrong".to_string(); + let error = validate_post_operation_inventory(&manifest, &vector) + .expect_err("wrong post operation ID must fail"); + assert!(error.contains("id drift")); + + let (mut manifest, vector) = current_post_authority(); + manifest + .operations + .get_mut("social_update_build_authored_draft") + .expect("Update operation") + .conformance + .vector = "contracts/conformance/vectors/profile/metadata.v1.json".to_string(); + let error = validate_post_operation_inventory(&manifest, &vector) + .expect_err("wrong post vector path must fail"); + assert!(error.contains("conformance.vector drift")); + + let (mut manifest, vector) = current_post_authority(); + manifest + .operations + .get_mut("social_update_build_authored_draft") + .expect("Update operation") + .conformance + .case_kinds[0] = "social.ask.build_authored_draft.valid".to_string(); + let error = validate_post_operation_inventory(&manifest, &vector) + .expect_err("wrong post case prefix must fail"); + assert!(error.contains("must start with social.update.build_authored_draft.")); + + let (mut manifest, vector) = current_post_authority(); + manifest + .operations + .get_mut("social_update_build_authored_draft") + .expect("Update operation") + .conformance + .case_kinds + .pop(); + let error = validate_post_operation_inventory(&manifest, &vector) + .expect_err("missing post case kind must fail"); + assert!(error.contains("conformance.case_kinds drift")); + + let (mut manifest, vector) = current_post_authority(); + let operation = manifest + .operations + .get_mut("social_update_build_authored_draft") + .expect("Update operation"); + operation.conformance.case_kinds[1] = operation.conformance.case_kinds[0].clone(); + let error = validate_post_operation_inventory(&manifest, &vector) + .expect_err("duplicate post case kind must fail"); + assert!(error.contains("duplicate value"), "{error}"); + + let (manifest, mut vector) = current_post_authority(); + vector.vectors.push(ConformanceVectorEntry { + id: "unclaimed_post_case".to_string(), + kind: "social.post.unclaimed.valid".to_string(), + input: Value::Object(Default::default()), + expected: Value::Object(Default::default()), + }); + let error = validate_post_operation_inventory(&manifest, &vector) + .expect_err("unclaimed post vector kind must fail"); + assert!(error.contains("is not claimed by exactly one operation")); + + let (manifest, mut vector) = current_post_authority(); + vector.vectors.remove( + vector + .vectors + .iter() + .position(|entry| entry.kind == "social.post.project_verified_event.valid") + .expect("project valid case"), + ); + let error = validate_post_operation_inventory(&manifest, &vector) + .expect_err("post vector count drift must fail"); + assert!(error.contains("post conformance vector inventory drift")); + } + + #[test] + fn post_operation_authority_rejects_another_vector_namespace_operation() { + let (mut manifest, vector) = current_post_authority(); + let mut unexpected = manifest + .operations + .remove("social_comment_build_tags") + .expect("unrelated social operation"); + unexpected.id = "social.update.shadow".to_string(); + manifest + .operations + .insert("social_update_shadow".to_string(), unexpected); + + let error = validate_post_operation_inventory(&manifest, &vector) + .expect_err("another-vector post namespace operation must fail"); + assert!(error.contains("post operation authority drift"), "{error}"); + assert!(error.contains("social_update_shadow"), "{error}"); + } + + #[test] + fn post_operation_authority_rejects_metadata_drift() { + let (mut manifest, vector) = current_post_authority(); + manifest + .operations + .get_mut("social_update_build_authored_draft") + .expect("Update operation") + .stability = "stable".to_string(); + + let error = validate_post_operation_inventory(&manifest, &vector) + .expect_err("post operation metadata drift must fail"); + assert!(error.contains("stability drift"), "{error}"); + } + + #[test] + fn post_operation_authority_rejects_required_public_type_removal() { + let (mut manifest, vector) = current_post_authority(); + manifest + .shared_types + .public + .retain(|value| value != "RadrootsPostAdmissionOutcome"); + + let error = validate_post_operation_inventory(&manifest, &vector) + .expect_err("required post public type removal must fail"); + assert!( + error.contains( + "post operation authority requires shared public type RadrootsPostAdmissionOutcome" + ), + "{error}" + ); + } + + #[test] + fn post_operation_authority_rejects_same_count_vector_id_replacement() { + let (manifest, mut vector) = current_post_authority(); + vector + .vectors + .iter_mut() + .find(|entry| entry.id == "authored_update_wire") + .expect("authored Update case") + .id = "authored_update_wire_replacement".to_string(); + + let error = validate_post_operation_inventory(&manifest, &vector) + .expect_err("same-count post vector ID replacement must fail"); + assert!(error.contains("post conformance vector inventory drift")); + } + + #[test] + fn post_operation_authority_rejects_vector_id_kind_drift() { + let (manifest, mut vector) = current_post_authority(); + let update_position = vector + .vectors + .iter() + .position(|entry| entry.id == "authored_update_wire") + .expect("authored Update case"); + let ask_position = vector + .vectors + .iter() + .position(|entry| entry.id == "authored_ask_wire") + .expect("authored Ask case"); + let update_kind = vector.vectors[update_position].kind.clone(); + vector.vectors[update_position].kind = vector.vectors[ask_position].kind.clone(); + vector.vectors[ask_position].kind = update_kind; + + let error = validate_post_operation_inventory(&manifest, &vector) + .expect_err("post vector ID-to-kind drift must fail"); + assert!(error.contains("post conformance vector inventory drift")); + } + + #[test] fn version_governance_rejects_contract_workspace_and_lock_drift() { let root = create_synthetic_workspace("version_governance_drift"); let mut bundle = load_contract_bundle(&root).expect("load contract");