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:
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");