commit 06491f0f17b9c5c090eeb340242ae527ad1c44d9
parent 4ec115296b1667331a3a8774baf313c33917867a
Author: triesap <tyson@radroots.org>
Date: Sat, 18 Jul 2026 05:47:41 +0000
profile: add strict metadata boundaries
- add byte-verified image-only authored Profile construction
- preserve tolerant inbound metadata and explicit unverified state
- quarantine legacy Profile authoring and decoding surfaces
- execute packaged vectors across feature and coverage lanes
Diffstat:
26 files changed, 2809 insertions(+), 3 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -4309,6 +4309,7 @@ version = "0.1.0-alpha.2"
dependencies = [
"dto_bindgen",
"hex",
+ "radroots_blossom",
"radroots_core",
"secp256k1",
"serde",
@@ -4322,6 +4323,7 @@ version = "0.1.0-alpha.2"
dependencies = [
"hex",
"nostr",
+ "radroots_blossom",
"radroots_core",
"radroots_event",
"radroots_test_fixtures",
diff --git a/build/nix/common.nix b/build/nix/common.nix
@@ -100,7 +100,7 @@ let
];
coreContractCargoArgs =
lib.concatStringsSep " " (map (crate: "-p ${crate}") coreContractCrates)
- + " --features radroots_nostr/blossom";
+ + " --features radroots_event_codec/serde_json,radroots_nostr/blossom";
craneLib = (crane.mkLib pkgs).overrideToolchain toolchains.stable;
commonCraneArgs = {
inherit version;
diff --git a/contracts/conformance/vectors/profile/metadata.v1.json b/contracts/conformance/vectors/profile/metadata.v1.json
@@ -0,0 +1,518 @@
+{
+ "suite": "profile_metadata",
+ "contract_version": "0.1.0",
+ "vectors": [
+ {
+ "id": "profile_nip05_valid_root_identifier_001",
+ "kind": "profile.nip05.parse.valid",
+ "input": {
+ "identifier": "_@mossstreet.example"
+ },
+ "expected": {
+ "identifier": "_@mossstreet.example",
+ "local_part": "_",
+ "domain": "mossstreet.example",
+ "identity_verification": "not_performed"
+ }
+ },
+ {
+ "id": "profile_nip05_valid_uppercase_domain_001",
+ "kind": "profile.nip05.parse.valid",
+ "input": {
+ "identifier": "alice@MossStreet.EXAMPLE"
+ },
+ "expected": {
+ "identifier": "alice@mossstreet.example",
+ "local_part": "alice",
+ "domain": "mossstreet.example",
+ "identity_verification": "not_performed"
+ }
+ },
+ {
+ "id": "profile_nip05_invalid_upper_local_001",
+ "kind": "profile.nip05.parse.invalid",
+ "input": {
+ "identifier": "Alice@mossstreet.example"
+ },
+ "expected": {
+ "error": "invalid_local_part"
+ }
+ },
+ {
+ "id": "profile_nip05_invalid_multiple_separator_001",
+ "kind": "profile.nip05.parse.invalid",
+ "input": {
+ "identifier": "alice@mossstreet.example@other.example"
+ },
+ "expected": {
+ "error": "multiple_separators"
+ }
+ },
+ {
+ "id": "profile_authored_required_name_001",
+ "kind": "profile.build_authored_draft.valid",
+ "input": {
+ "profile": {
+ "name": "moss-street-farm"
+ }
+ },
+ "expected": {
+ "wire_parts": {
+ "kind": 0,
+ "content": "{\"name\":\"moss-street-farm\"}",
+ "tags": []
+ },
+ "upload_completion": "not_attested_by_codec"
+ }
+ },
+ {
+ "id": "profile_authored_content_limit_exact_001",
+ "kind": "profile.build_authored_draft.limit.valid",
+ "input": {
+ "generated_name_bytes": 131061
+ },
+ "expected": {
+ "kind": 0,
+ "content_bytes": 131072,
+ "tags": [],
+ "upload_completion": "not_attested_by_codec"
+ }
+ },
+ {
+ "id": "profile_authored_complete_blossom_metadata_001",
+ "kind": "profile.build_authored_draft.valid",
+ "input": {
+ "profile": {
+ "name": "moss-street-farm",
+ "display_name": "Moss Street Farm",
+ "about": "Small urban farm in Victoria, B.C.",
+ "picture": {
+ "bytes_utf8": "victoria-profile",
+ "descriptor": {
+ "url": "https://media.example/9c31d190b20b9ccf039ed13f758e976f13b716cd73dee6cb57a7b0550540d06d.webp",
+ "sha256": "9c31d190b20b9ccf039ed13f758e976f13b716cd73dee6cb57a7b0550540d06d",
+ "size": 16,
+ "type": "image/webp",
+ "uploaded": 1784347200
+ }
+ },
+ "banner": {
+ "bytes_utf8": "victoria-banner",
+ "descriptor": {
+ "url": "https://media.example/55384b73c8df8a842891346dcf614e46f9fedd6c15fde95a157f7172a113209a.webp",
+ "sha256": "55384b73c8df8a842891346dcf614e46f9fedd6c15fde95a157f7172a113209a",
+ "size": 15,
+ "type": "image/webp",
+ "uploaded": 1784347201
+ }
+ },
+ "nip05": "_@mossstreet.example",
+ "bot": false
+ }
+ },
+ "expected": {
+ "wire_parts": {
+ "kind": 0,
+ "content": "{\"name\":\"moss-street-farm\",\"display_name\":\"Moss Street Farm\",\"about\":\"Small urban farm in Victoria, B.C.\",\"picture\":\"https://media.example/9c31d190b20b9ccf039ed13f758e976f13b716cd73dee6cb57a7b0550540d06d.webp\",\"banner\":\"https://media.example/55384b73c8df8a842891346dcf614e46f9fedd6c15fde95a157f7172a113209a.webp\",\"nip05\":\"_@mossstreet.example\",\"bot\":false}",
+ "tags": []
+ },
+ "upload_completion": "not_attested_by_codec"
+ }
+ },
+ {
+ "id": "profile_authored_boolean_true_001",
+ "kind": "profile.build_authored_draft.valid",
+ "input": {
+ "profile": {
+ "name": "harvest-bot",
+ "bot": true
+ }
+ },
+ "expected": {
+ "wire_parts": {
+ "kind": 0,
+ "content": "{\"name\":\"harvest-bot\",\"bot\":true}",
+ "tags": []
+ },
+ "upload_completion": "not_attested_by_codec"
+ }
+ },
+ {
+ "id": "profile_authored_json_escaping_001",
+ "kind": "profile.build_authored_draft.valid",
+ "input": {
+ "profile": {
+ "name": "moss\"\\farm",
+ "about": "line\nfeed\t\u00e9"
+ }
+ },
+ "expected": {
+ "wire_parts": {
+ "kind": 0,
+ "content": "{\"name\":\"moss\\\"\\\\farm\",\"about\":\"line\\nfeed\\t\u00e9\"}",
+ "tags": []
+ },
+ "upload_completion": "not_attested_by_codec"
+ }
+ },
+ {
+ "id": "profile_authored_empty_name_001",
+ "kind": "profile.authored.new.invalid",
+ "input": {
+ "name": ""
+ },
+ "expected": {
+ "error": "invalid_name"
+ }
+ },
+ {
+ "id": "profile_authored_whitespace_name_001",
+ "kind": "profile.authored.new.invalid",
+ "input": {
+ "name": " \t "
+ },
+ "expected": {
+ "error": "invalid_name"
+ }
+ },
+ {
+ "id": "profile_authored_control_name_001",
+ "kind": "profile.authored.new.invalid",
+ "input": {
+ "name": "moss\nstreet"
+ },
+ "expected": {
+ "error": "invalid_name"
+ }
+ },
+ {
+ "id": "profile_authored_non_image_descriptor_001",
+ "kind": "profile.image.from_verified_descriptor.invalid",
+ "input": {
+ "media": {
+ "bytes_utf8": "not-an-image",
+ "descriptor": {
+ "url": "https://media.example/f2e2c6db1745cc40df646dc40c385487c36e4ceb3f1d5c8d6ad1f7620af1ebae.txt",
+ "sha256": "f2e2c6db1745cc40df646dc40c385487c36e4ceb3f1d5c8d6ad1f7620af1ebae",
+ "size": 12,
+ "type": "text/plain",
+ "uploaded": 1784347202
+ }
+ }
+ },
+ "expected": {
+ "error": "media_type_not_image"
+ }
+ },
+ {
+ "id": "profile_authored_content_too_large_001",
+ "kind": "profile.build_authored_draft.invalid",
+ "input": {
+ "generated_name_bytes": 131062
+ },
+ "expected": {
+ "error": "content_too_large",
+ "max": 131072,
+ "actual": 131073
+ }
+ },
+ {
+ "id": "profile_inbound_missing_name_001",
+ "kind": "profile.parse_inbound_metadata.valid",
+ "input": {
+ "content": "{}"
+ },
+ "expected": {
+ "projected": {},
+ "residual_fields": {},
+ "identity_verification": "not_performed"
+ }
+ },
+ {
+ "id": "profile_inbound_complete_boolean_true_001",
+ "kind": "profile.parse_inbound_metadata.valid",
+ "input": {
+ "content": "{\"name\":\"alice\",\"display_name\":\"Alice\",\"about\":\"Victoria gardener\",\"website\":\"https://alice.example\",\"nip05\":\"alice@example.com\",\"lud06\":\"lnurl1alice\",\"lud16\":\"alice@example.com\",\"bot\":true}"
+ },
+ "expected": {
+ "projected": {
+ "name": "alice",
+ "display_name": "Alice",
+ "about": "Victoria gardener",
+ "nip05": "alice@example.com",
+ "bot": true
+ },
+ "residual_fields": {
+ "website": "https://alice.example",
+ "lud06": "lnurl1alice",
+ "lud16": "alice@example.com"
+ },
+ "identity_verification": "not_performed"
+ }
+ },
+ {
+ "id": "profile_inbound_uppercase_nip05_domain_001",
+ "kind": "profile.parse_inbound_metadata.valid",
+ "input": {
+ "content": "{\"nip05\":\"alice@MossStreet.EXAMPLE\"}"
+ },
+ "expected": {
+ "projected": {
+ "nip05": "alice@mossstreet.example"
+ },
+ "residual_fields": {},
+ "identity_verification": "not_performed"
+ }
+ },
+ {
+ "id": "profile_inbound_boolean_false_001",
+ "kind": "profile.parse_inbound_metadata.valid",
+ "input": {
+ "content": "{\"bot\":false}"
+ },
+ "expected": {
+ "projected": {
+ "bot": false
+ },
+ "residual_fields": {},
+ "identity_verification": "not_performed"
+ }
+ },
+ {
+ "id": "profile_inbound_arbitrary_media_unverified_001",
+ "kind": "profile.parse_inbound_metadata.valid",
+ "input": {
+ "content": "{\"picture\":\"https://legacy.example/avatar.jpg\",\"banner\":\"ipfs://legacy-banner\"}"
+ },
+ "expected": {
+ "projected": {
+ "picture": "https://legacy.example/avatar.jpg",
+ "banner": "ipfs://legacy-banner"
+ },
+ "residual_fields": {},
+ "media_verification": {
+ "picture": "unverified",
+ "banner": "unverified"
+ },
+ "identity_verification": "not_performed"
+ }
+ },
+ {
+ "id": "profile_inbound_blossom_shape_still_unverified_001",
+ "kind": "profile.parse_inbound_metadata.valid",
+ "input": {
+ "content": "{\"picture\":\"https://media.example/9c31d190b20b9ccf039ed13f758e976f13b716cd73dee6cb57a7b0550540d06d.webp\"}"
+ },
+ "expected": {
+ "projected": {
+ "picture": "https://media.example/9c31d190b20b9ccf039ed13f758e976f13b716cd73dee6cb57a7b0550540d06d.webp"
+ },
+ "residual_fields": {},
+ "media_verification": {
+ "picture": "unverified"
+ },
+ "identity_verification": "not_performed"
+ }
+ },
+ {
+ "id": "profile_inbound_wrong_types_and_unknown_fields_001",
+ "kind": "profile.parse_inbound_metadata.valid",
+ "input": {
+ "content": "{\"name\":42,\"display_name\":null,\"nip05\":\"Alice@example.com\",\"picture\":{\"url\":\"https://legacy.example/avatar.jpg\"},\"bot\":\"true\",\"birthday\":{\"year\":1988,\"month\":6},\"custom\":{\"nested\":[1,true]}}"
+ },
+ "expected": {
+ "projected": {},
+ "residual_fields": {
+ "name": 42,
+ "display_name": null,
+ "nip05": "Alice@example.com",
+ "picture": {
+ "url": "https://legacy.example/avatar.jpg"
+ },
+ "bot": "true",
+ "birthday": {
+ "year": 1988,
+ "month": 6
+ },
+ "custom": {
+ "nested": [
+ 1,
+ true
+ ]
+ }
+ },
+ "identity_verification": "not_performed"
+ }
+ },
+ {
+ "id": "profile_inbound_wrong_type_nip05_001",
+ "kind": "profile.parse_inbound_metadata.valid",
+ "input": {
+ "content": "{\"nip05\":42}"
+ },
+ "expected": {
+ "projected": {},
+ "residual_fields": {
+ "nip05": 42
+ },
+ "identity_verification": "not_performed"
+ }
+ },
+ {
+ "id": "profile_inbound_deprecated_fields_retained_001",
+ "kind": "profile.parse_inbound_metadata.valid",
+ "input": {
+ "content": "{\"displayName\":\"Legacy Display\",\"username\":\"legacy-name\"}"
+ },
+ "expected": {
+ "projected": {},
+ "residual_fields": {
+ "displayName": "Legacy Display",
+ "username": "legacy-name"
+ },
+ "identity_verification": "not_performed"
+ }
+ },
+ {
+ "id": "profile_inbound_raw_whitespace_escape_nested_duplicate_001",
+ "kind": "profile.parse_inbound_metadata.valid",
+ "input": {
+ "content": " { \"about\":\"line\\nfeed\", \"custom\":{\"key\":1,\"key\":2} } "
+ },
+ "expected": {
+ "projected": {
+ "about": "line\nfeed"
+ },
+ "residual_fields": {
+ "custom": {
+ "key": 2
+ }
+ },
+ "identity_verification": "not_performed"
+ }
+ },
+ {
+ "id": "profile_inbound_content_limit_exact_001",
+ "kind": "profile.parse_inbound_metadata.limit.valid",
+ "input": {
+ "generated_content_bytes": 131072
+ },
+ "expected": {
+ "content_bytes": 131072
+ }
+ },
+ {
+ "id": "profile_inbound_content_limit_exceeded_001",
+ "kind": "profile.parse_inbound_metadata.invalid",
+ "input": {
+ "generated_content_bytes": 131073
+ },
+ "expected": {
+ "error": "content_too_large",
+ "max": 131072,
+ "actual": 131073
+ }
+ },
+ {
+ "id": "profile_inbound_malformed_json_001",
+ "kind": "profile.parse_inbound_metadata.invalid",
+ "input": {
+ "content": "{"
+ },
+ "expected": {
+ "error": "invalid_json"
+ }
+ },
+ {
+ "id": "profile_inbound_malformed_array_001",
+ "kind": "profile.parse_inbound_metadata.invalid",
+ "input": {
+ "content": "[1,"
+ },
+ "expected": {
+ "error": "invalid_json"
+ }
+ },
+ {
+ "id": "profile_inbound_non_object_array_001",
+ "kind": "profile.parse_inbound_metadata.invalid",
+ "input": {
+ "content": "[1]"
+ },
+ "expected": {
+ "error": "root_not_object"
+ }
+ },
+ {
+ "id": "profile_inbound_non_object_null_001",
+ "kind": "profile.parse_inbound_metadata.invalid",
+ "input": {
+ "content": "null"
+ },
+ "expected": {
+ "error": "root_not_object"
+ }
+ },
+ {
+ "id": "profile_inbound_non_object_boolean_001",
+ "kind": "profile.parse_inbound_metadata.invalid",
+ "input": {
+ "content": "true"
+ },
+ "expected": {
+ "error": "root_not_object"
+ }
+ },
+ {
+ "id": "profile_inbound_non_object_negative_integer_001",
+ "kind": "profile.parse_inbound_metadata.invalid",
+ "input": {
+ "content": "-1"
+ },
+ "expected": {
+ "error": "root_not_object"
+ }
+ },
+ {
+ "id": "profile_inbound_non_object_unsigned_integer_001",
+ "kind": "profile.parse_inbound_metadata.invalid",
+ "input": {
+ "content": "1"
+ },
+ "expected": {
+ "error": "root_not_object"
+ }
+ },
+ {
+ "id": "profile_inbound_non_object_float_001",
+ "kind": "profile.parse_inbound_metadata.invalid",
+ "input": {
+ "content": "1.5"
+ },
+ "expected": {
+ "error": "root_not_object"
+ }
+ },
+ {
+ "id": "profile_inbound_non_object_string_001",
+ "kind": "profile.parse_inbound_metadata.invalid",
+ "input": {
+ "content": "\"profile\""
+ },
+ "expected": {
+ "error": "root_not_object"
+ }
+ },
+ {
+ "id": "profile_inbound_duplicate_field_001",
+ "kind": "profile.parse_inbound_metadata.invalid",
+ "input": {
+ "content": "{\"name\":\"first\",\"name\":\"second\",\"name\":\"third\"}"
+ },
+ "expected": {
+ "error": "duplicate_field",
+ "field": "name"
+ }
+ }
+ ]
+}
diff --git a/contracts/event_boundary_matrix.md b/contracts/event_boundary_matrix.md
@@ -24,7 +24,7 @@ contract package.
| Domain | Kind | Radroots Type | RPC Methods | Notes |
| --- | --- | --- | --- | --- |
-| profile | 0 | RadrootsProfile | events.profile.publish, events.profile.list, events.profile.get | content must be canonical JSON; `t=radroots:type` tag required |
+| profile | 0 | RadrootsProfile | events.profile.publish, events.profile.list, events.profile.get | compatibility RPCs retain legacy authoring; only `profile.build_authored_draft` is strict authored authority and it emits deterministic JSON with no marker tag |
| follow | 3 | RadrootsFollow | events.follow.publish, events.follow.list, events.follow.get | replaceable event |
| post | 1 | RadrootsPost | events.post.publish, events.post.list, events.post.get | plaintext content |
| comment | 1111 | RadrootsComment | events.comment.publish, events.comment.list, events.comment.get | requires root and parent tags |
diff --git a/contracts/events/profile-metadata.md b/contracts/events/profile-metadata.md
@@ -0,0 +1,147 @@
+# Profile metadata contract
+
+Status: canonical
+
+This contract defines the public kind-`0` Profile metadata boundary used by
+Radroots strict authoring and tolerant inbound projection. It is based on the
+pinned NIP-01, NIP-05, and NIP-24 documents at NIPs commit
+`bdfa7e62ef87fcfcb992b1a27aee49d36b0b4f91` and the Blossom protocol at commit
+`b5bd2801d1763aa635fc8fea7a76597e0eb18990`.
+
+## Operation authority
+
+| Operation | Boundary | Signing | Transport |
+| --- | --- | --- | --- |
+| `profile.build_authored_draft` | strict authored metadata to kind-`0` wire parts | none | none |
+| `profile.parse_inbound_metadata` | JSON object to tolerant inbound metadata | none | none |
+
+`profile.parse_inbound_metadata` is a content parser, not an event-acceptance
+boundary. A caller must supply content from a kind-`0` event only after the
+event identifier and signature have been verified.
+
+The following authored paths remain compatibility-only. Their behavior is
+preserved, but none can satisfy the strict authored operation:
+
+| Compatibility surface | Exclusion reason |
+| --- | --- |
+| `RadrootsProfile` and `profile::encode::{to_metadata, to_wire_parts, to_wire_parts_with_profile_type, profile_type_tags, profile_build_tags}` (`profile.build_draft`) | accept arbitrary media strings, model `bot` as a string, and support a Radroots marker tag |
+| `radroots_nostr_build_metadata_event` and `radroots_nostr_post_metadata_event` | accept generic upstream metadata without the strict Profile typestates |
+| `RadrootsNostrClient::set_metadata` | publishes generic upstream metadata directly |
+| `radroots_nostr_publish_identity_profile*` and `radroots_nostr_bootstrap_service_presence` | publish the legacy identity Profile and can add the marker tag |
+| `NostrClientManager::publish_profile_event_blocking` | publishes generic metadata directly through the legacy network client |
+| `radroots_replica_sync` Profile draft emission | serializes stored legacy Profile fields without the strict authored model |
+
+New authored callers must use `profile.build_authored_draft`. Tightening or
+removing the compatibility paths is a separate breaking-release decision.
+Generic raw event builders and send APIs remain protocol escape hatches; their
+ability to emit kind `0` does not make them Profile operation authority.
+
+Legacy `profile::decode`, Nostr Profile adapters/fetchers, the network Profile
+fetch methods, and replica Profile ingest remain read-side compatibility paths.
+They may require or coerce legacy fields, discard unprojected metadata, or rely
+on legacy marker tags. They are not `profile.parse_inbound_metadata`, do not
+establish verified event admission, and must not be used as its substitute.
+
+## Strict authored boundary
+
+`RadrootsAuthoredProfile` has private fields and requires a non-whitespace,
+control-free `name`, consistent with the NIP-24 recommendation that `name`
+remain present when `display_name` is used. The scoped optional fields are
+`display_name`, `about`, `nip05`, `bot`, `picture`, and `banner`; `bot` is a
+Boolean. NIP-05 values enter only through `RadrootsNip05Identifier`. Picture and
+banner values enter through `RadrootsAuthoredProfileImage`, which can wrap only
+an `image/*` `RadrootsBlossomByteVerifiedDescriptor`. There is no raw-string
+media setter or unchecked deserialization path. Generic `website`, `lud06`, and
+`lud16` strings remain outside this strict authored operation.
+
+Kind `0` is whole-object replaceable. Strict authored output is therefore a
+complete replacement snapshot, never a patch: every omitted existing standard,
+residual, or custom field is removed by the replacement. This operation does
+not merge the tolerant inbound raw object. It is safe for initial Profile
+creation or an explicitly confirmed full replacement; it must not power a
+silent partial edit. Retaining an existing picture or banner requires the
+runtime to re-fetch or retain the bytes, re-establish the byte-verified image
+descriptor, and satisfy BUD-02 before signing. Until such a full-snapshot edit
+pipeline exists, clients must keep existing Profile editing read-only.
+
+The authored codec emits:
+
+- kind `0`
+- no tags
+- one JSON object with fields in this deterministic order when present:
+ `name`, `display_name`, `about`, `picture`, `banner`, `nip05`, `bot`
+- only the descriptor URL for each media field
+- at most 131072 UTF-8 bytes of metadata content
+
+The descriptor state proves that approved descriptor hash, size, and media type
+match supplied bytes. It does not prove that BUD-02 upload completed or that a
+blob is network-retrievable, and it does not inspect or sanitize the image
+format. A publication runtime must require successful BUD-02 completion before
+signing media-bearing output. A consuming media runtime remains responsible for
+decode and format-safety policy.
+
+## Tolerant inbound boundary
+
+`profile.parse_inbound_metadata` accepts a JSON object of at most 131072 UTF-8
+bytes without requiring `name`. Correctly typed `name`, `display_name`, `about`,
+`picture`, `banner`, `nip05`, and `bot` fields are projected without JSON type
+coercion; accepted NIP-05 domains are canonicalized as described below. In
+particular, `bot` must be a JSON Boolean. Every string picture or banner is
+returned as `RadrootsUnverifiedProfileMediaReference`, including strings that
+look like valid Blossom hash-path URLs.
+
+The result retains:
+
+- the exact input content
+- the complete parsed object
+- a residual map containing every unknown field, wrong-typed known field, and
+ syntactically invalid NIP-05 string
+
+Oversized content is rejected before parsing. Malformed JSON and non-object
+roots are parse errors. Duplicate top-level metadata keys are rejected because
+they have ambiguous cross-parser semantics. Optional metadata that this
+contract does not project, including NIP-24 `website` and birthday data plus
+`lud06` and `lud16`, remains in the residual and complete raw views. Exact
+nested JSON text, including any duplicate nested names, remains available
+through the raw content view.
+
+## NIP-05 boundary
+
+`RadrootsNip05Identifier` requires exactly one `@`, a non-empty local part using
+only lowercase `a-z`, digits, `-`, `_`, or `.`, and an ASCII DNS domain.
+Domain matching is case-insensitive, so accepted domains are canonicalized to
+lowercase. Parsing is syntax-only. It performs no HTTPS lookup and never
+represents verified ownership or identity trust.
+
+A syntax-checked identifier is not a safe network-fetch target by itself. A
+future resolver must separately govern HTTPS, redirects, address resolution,
+loopback/private/link-local targets, response size, and timeouts.
+
+## Stable error codes
+
+The public error enums are non-exhaustive so future codes can be added without
+making downstream matches exhaustive. Current stable codes are:
+
+| Boundary | Codes |
+| --- | --- |
+| NIP-05 syntax | `missing_separator`, `multiple_separators`, `invalid_local_part`, `invalid_domain` |
+| strict Profile construction | `invalid_name` |
+| strict Profile image construction | `media_type_not_image` |
+| strict Profile encoding | `content_too_large` |
+| tolerant inbound parsing | `content_too_large`, `invalid_json`, `root_not_object`, `duplicate_field` |
+
+Inbound size validation occurs before JSON parsing. For content within the
+limit, malformed JSON returns `invalid_json`; a well-formed non-object returns
+`root_not_object`; and a well-formed object with a duplicate top-level field
+returns `duplicate_field`. Wrong-typed projected fields are residual data, not
+parse errors.
+
+## Conformance
+
+The canonical suite is
+`contracts/conformance/vectors/profile/metadata.v1.json`. It is mirrored under
+`crates/event_codec/tests/fixtures/` for published-package tests. The dispatcher
+executes every vector against the public strict authored or tolerant inbound
+API and requires the canonical and packaged copies to be byte-for-byte equal
+when the workspace contract is present. Consumers must enable the codec's
+optional `serde_json` feature to use these operations.
diff --git a/contracts/operations.toml b/contracts/operations.toml
@@ -48,6 +48,17 @@ public = [
"RadrootsEventPtr",
"RadrootsListingAddress",
"RadrootsProfile",
+ "RadrootsNip05Identifier",
+ "RadrootsNip05IdentifierError",
+ "RadrootsAuthoredProfile",
+ "RadrootsAuthoredProfileError",
+ "RadrootsAuthoredProfileImage",
+ "RadrootsAuthoredProfileImageError",
+ "RadrootsAuthoredProfileEncodeError",
+ "RadrootsInboundProfileMetadata",
+ "RadrootsProfileMetadataParseError",
+ "RadrootsNip05IdentityVerification",
+ "RadrootsUnverifiedProfileMediaReference",
"RadrootsFarm",
"RadrootsListing",
"RadrootsPost",
@@ -320,6 +331,63 @@ rust_types = ["radroots_event::profile::RadrootsProfile"]
[operations.profile_build_draft.conformance]
vector = "contracts/conformance/vectors/profile/build_draft.v1.json"
+[operations.profile_build_authored_draft]
+domain = "profile"
+id = "profile.build_authored_draft"
+stability = "beta"
+inputs = ["RadrootsAuthoredProfile"]
+outputs = ["RadrootsNip01EventWireParts"]
+error_class = "encode_error"
+deterministic = true
+signing = "none"
+transport = "none"
+
+[operations.profile_build_authored_draft.implementation]
+rust_modules = [
+ "crates/event/src/profile.rs",
+ "crates/event_codec/src/profile/authored.rs",
+]
+rust_types = [
+ "radroots_blossom::RadrootsBlossomByteVerifiedDescriptor",
+ "radroots_event::profile::RadrootsAuthoredProfile",
+ "radroots_event::profile::RadrootsAuthoredProfileError",
+ "radroots_event::profile::RadrootsAuthoredProfileImage",
+ "radroots_event::profile::RadrootsAuthoredProfileImageError",
+ "radroots_event::profile::RadrootsNip05Identifier",
+ "radroots_event::profile::RadrootsNip05IdentifierError",
+ "radroots_event_codec::profile::authored::RadrootsAuthoredProfileEncodeError",
+]
+
+[operations.profile_build_authored_draft.conformance]
+vector = "contracts/conformance/vectors/profile/metadata.v1.json"
+
+[operations.profile_parse_inbound_metadata]
+domain = "profile"
+id = "profile.parse_inbound_metadata"
+stability = "beta"
+inputs = ["String"]
+outputs = ["RadrootsInboundProfileMetadata"]
+error_class = "parse_error"
+deterministic = true
+signing = "none"
+transport = "none"
+
+[operations.profile_parse_inbound_metadata.implementation]
+rust_modules = [
+ "crates/event/src/profile.rs",
+ "crates/event_codec/src/profile/inbound.rs",
+]
+rust_types = [
+ "radroots_event::profile::RadrootsNip05Identifier",
+ "radroots_event_codec::profile::inbound::RadrootsInboundProfileMetadata",
+ "radroots_event_codec::profile::inbound::RadrootsNip05IdentityVerification",
+ "radroots_event_codec::profile::inbound::RadrootsProfileMetadataParseError",
+ "radroots_event_codec::profile::inbound::RadrootsUnverifiedProfileMediaReference",
+]
+
+[operations.profile_parse_inbound_metadata.conformance]
+vector = "contracts/conformance/vectors/profile/metadata.v1.json"
+
[operations.farm_build_draft]
domain = "farm"
id = "farm.build_draft"
diff --git a/crates/event/Cargo.toml b/crates/event/Cargo.toml
@@ -24,11 +24,12 @@ dto-bindgen = [
knowledge = []
knowledge-nip54 = ["knowledge"]
signature = ["dep:secp256k1"]
-std = ["radroots_core/std"]
+std = ["radroots_blossom/std", "radroots_core/std"]
serde = ["dep:serde", "radroots_core/serde"]
[dependencies]
dto_bindgen = { workspace = true, optional = true }
+radroots_blossom = { workspace = true, default-features = false }
radroots_core = { workspace = true, default-features = false }
hex = { version = "0.4", default-features = false, features = ["alloc"] }
serde = { workspace = true, default-features = false, features = [
diff --git a/crates/event/README b/crates/event/README
@@ -14,6 +14,12 @@ models, kinds, and tag conventions for the `radroots` core libraries.
* portable event model semantics for both `std` and `no_std` builds;
* optional integration with `serde` for serialization.
+The Profile module also exposes an additive strict authored model. It requires
+a non-whitespace, control-free name; its media fields accept only image-typed,
+byte-verified Blossom descriptors; and its NIP-05 identifier type validates
+syntax without making a network identity claim. The legacy `RadrootsProfile`
+model remains available for compatibility.
+
## Field Event Boundary
`radroots_event` includes the public event-layer models needed by Field-style
diff --git a/crates/event/src/profile.rs b/crates/event/src/profile.rs
@@ -1,5 +1,8 @@
#[cfg(not(feature = "std"))]
use alloc::string::String;
+use core::{fmt, str::FromStr};
+
+use radroots_blossom::RadrootsBlossomByteVerifiedDescriptor;
pub const RADROOTS_PROFILE_TYPE_TAG_KEY: &str = "t";
pub const RADROOTS_PROFILE_TYPE_TAG_INDIVIDUAL: &str = "radroots:type:individual";
@@ -7,6 +10,8 @@ pub const RADROOTS_PROFILE_TYPE_TAG_FARM: &str = "radroots:type:farm";
pub const RADROOTS_PROFILE_TYPE_TAG_COOP: &str = "radroots:type:coop";
pub const RADROOTS_PROFILE_TYPE_TAG_ANY: &str = "radroots:type:any";
pub const RADROOTS_PROFILE_TYPE_TAG_RADROOTSD: &str = "radroots:type:radrootsd";
+pub const RADROOTS_PROFILE_METADATA_MAX_CONTENT_BYTES: usize =
+ crate::wire::DEFAULT_CONTENT_MAX_BYTES;
#[cfg_attr(feature = "dto-bindgen", derive(dto_bindgen::Dto))]
#[cfg_attr(feature = "dto-bindgen", dto(export))]
@@ -56,6 +61,10 @@ pub fn radroots_profile_type_from_tag_value(value: &str) -> Option<RadrootsProfi
any(feature = "serde", test),
derive(serde::Serialize, serde::Deserialize)
)]
+/// Compatibility-only Profile model used by legacy codecs and runtimes.
+///
+/// Its media fields are arbitrary strings and its `bot` field is not a JSON
+/// Boolean. New strict Profile authoring must use `RadrootsAuthoredProfile`.
#[derive(Clone, Debug)]
pub struct RadrootsProfile {
pub name: String,
@@ -70,9 +79,327 @@ pub struct RadrootsProfile {
pub bot: Option<String>,
}
+#[non_exhaustive]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum RadrootsNip05IdentifierError {
+ MissingSeparator,
+ MultipleSeparators,
+ InvalidLocalPart,
+ InvalidDomain,
+}
+
+impl RadrootsNip05IdentifierError {
+ pub const fn code(&self) -> &'static str {
+ match self {
+ Self::MissingSeparator => "missing_separator",
+ Self::MultipleSeparators => "multiple_separators",
+ Self::InvalidLocalPart => "invalid_local_part",
+ Self::InvalidDomain => "invalid_domain",
+ }
+ }
+}
+
+impl fmt::Display for RadrootsNip05IdentifierError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::MissingSeparator => f.write_str("NIP-05 identifier must contain one @ separator"),
+ Self::MultipleSeparators => {
+ f.write_str("NIP-05 identifier must not contain multiple @ separators")
+ }
+ Self::InvalidLocalPart => f.write_str("NIP-05 identifier local part is invalid"),
+ Self::InvalidDomain => f.write_str("NIP-05 identifier domain is invalid"),
+ }
+ }
+}
+
+#[cfg(feature = "std")]
+impl std::error::Error for RadrootsNip05IdentifierError {}
+
+/// A syntax-checked NIP-05 internet identifier.
+///
+/// The DNS domain is canonicalized to lowercase. This type performs no network
+/// resolution and makes no identity-verification claim.
+#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub struct RadrootsNip05Identifier(String);
+
+impl RadrootsNip05Identifier {
+ pub fn parse(value: &str) -> Result<Self, RadrootsNip05IdentifierError> {
+ let Some((local_part, domain)) = value.split_once('@') else {
+ return Err(RadrootsNip05IdentifierError::MissingSeparator);
+ };
+ if domain.contains('@') {
+ return Err(RadrootsNip05IdentifierError::MultipleSeparators);
+ }
+ if local_part.is_empty()
+ || !local_part.bytes().all(|byte| {
+ byte.is_ascii_lowercase()
+ || byte.is_ascii_digit()
+ || matches!(byte, b'-' | b'_' | b'.')
+ })
+ {
+ return Err(RadrootsNip05IdentifierError::InvalidLocalPart);
+ }
+ let domain = domain.to_ascii_lowercase();
+ if !valid_nip05_domain(&domain) {
+ return Err(RadrootsNip05IdentifierError::InvalidDomain);
+ }
+ let mut canonical = String::with_capacity(value.len());
+ canonical.push_str(local_part);
+ canonical.push('@');
+ canonical.push_str(&domain);
+ Ok(Self(canonical))
+ }
+
+ pub fn as_str(&self) -> &str {
+ &self.0
+ }
+
+ pub fn local_part(&self) -> &str {
+ self.0
+ .split_once('@')
+ .expect("validated NIP-05 identifiers always contain one separator")
+ .0
+ }
+
+ pub fn domain(&self) -> &str {
+ self.0
+ .split_once('@')
+ .expect("validated NIP-05 identifiers always contain one separator")
+ .1
+ }
+}
+
+impl fmt::Display for RadrootsNip05Identifier {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str(self.as_str())
+ }
+}
+
+impl FromStr for RadrootsNip05Identifier {
+ type Err = RadrootsNip05IdentifierError;
+
+ fn from_str(value: &str) -> Result<Self, Self::Err> {
+ Self::parse(value)
+ }
+}
+
+fn valid_nip05_domain(domain: &str) -> bool {
+ !domain.is_empty()
+ && domain.len() <= 253
+ && domain.split('.').all(|label| {
+ !label.is_empty()
+ && label.len() <= 63
+ && label
+ .bytes()
+ .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
+ && label
+ .as_bytes()
+ .first()
+ .is_some_and(u8::is_ascii_alphanumeric)
+ && label
+ .as_bytes()
+ .last()
+ .is_some_and(u8::is_ascii_alphanumeric)
+ })
+}
+
+#[non_exhaustive]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum RadrootsAuthoredProfileImageError {
+ MediaTypeNotImage,
+}
+
+impl RadrootsAuthoredProfileImageError {
+ pub const fn code(&self) -> &'static str {
+ match self {
+ Self::MediaTypeNotImage => "media_type_not_image",
+ }
+ }
+}
+
+impl fmt::Display for RadrootsAuthoredProfileImageError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::MediaTypeNotImage => {
+ f.write_str("Profile media descriptor must have an image media type")
+ }
+ }
+ }
+}
+
+#[cfg(feature = "std")]
+impl std::error::Error for RadrootsAuthoredProfileImageError {}
+
+/// A byte-verified Blossom descriptor whose declared media type is `image/*`.
+///
+/// This typestate does not inspect or sanitize the image format. Media runtimes
+/// remain responsible for decode and format-safety policy.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsAuthoredProfileImage(RadrootsBlossomByteVerifiedDescriptor);
+
+impl RadrootsAuthoredProfileImage {
+ pub fn try_from_verified_descriptor(
+ descriptor: RadrootsBlossomByteVerifiedDescriptor,
+ ) -> Result<Self, RadrootsAuthoredProfileImageError> {
+ if !descriptor.media_type().as_str().starts_with("image/") {
+ return Err(RadrootsAuthoredProfileImageError::MediaTypeNotImage);
+ }
+ Ok(Self(descriptor))
+ }
+
+ pub fn descriptor(&self) -> &RadrootsBlossomByteVerifiedDescriptor {
+ &self.0
+ }
+}
+
+impl TryFrom<RadrootsBlossomByteVerifiedDescriptor> for RadrootsAuthoredProfileImage {
+ type Error = RadrootsAuthoredProfileImageError;
+
+ fn try_from(value: RadrootsBlossomByteVerifiedDescriptor) -> Result<Self, Self::Error> {
+ Self::try_from_verified_descriptor(value)
+ }
+}
+
+#[non_exhaustive]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum RadrootsAuthoredProfileError {
+ InvalidName,
+}
+
+impl RadrootsAuthoredProfileError {
+ pub const fn code(&self) -> &'static str {
+ match self {
+ Self::InvalidName => "invalid_name",
+ }
+ }
+}
+
+impl fmt::Display for RadrootsAuthoredProfileError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::InvalidName => {
+ f.write_str("authored Profile name must be non-whitespace and control-free")
+ }
+ }
+ }
+}
+
+#[cfg(feature = "std")]
+impl std::error::Error for RadrootsAuthoredProfileError {}
+
+/// Metadata accepted by the strict authored kind-0 Profile operation.
+///
+/// A non-whitespace, control-free name is required. Media values can only enter
+/// through image-only, byte-verified Blossom descriptors. That state proves
+/// descriptor/byte agreement, not successful network upload.
+///
+/// This model represents a complete kind-0 replacement snapshot, not a patch.
+/// Omitting an existing field removes it from the authored replacement.
+///
+/// ```compile_fail
+/// let _: radroots_event::profile::RadrootsAuthoredProfile =
+/// serde_json::from_str(r#"{"name":"alice"}"#).unwrap();
+/// ```
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsAuthoredProfile {
+ name: String,
+ display_name: Option<String>,
+ about: Option<String>,
+ picture: Option<RadrootsAuthoredProfileImage>,
+ banner: Option<RadrootsAuthoredProfileImage>,
+ nip05: Option<RadrootsNip05Identifier>,
+ bot: Option<bool>,
+}
+
+impl RadrootsAuthoredProfile {
+ pub fn new(name: impl Into<String>) -> Result<Self, RadrootsAuthoredProfileError> {
+ let name = name.into();
+ if name.trim().is_empty() || name.chars().any(char::is_control) {
+ return Err(RadrootsAuthoredProfileError::InvalidName);
+ }
+ Ok(Self {
+ name,
+ display_name: None,
+ about: None,
+ picture: None,
+ banner: None,
+ nip05: None,
+ bot: None,
+ })
+ }
+
+ #[must_use]
+ pub fn with_display_name(mut self, value: impl Into<String>) -> Self {
+ self.display_name = Some(value.into());
+ self
+ }
+
+ #[must_use]
+ pub fn with_about(mut self, value: impl Into<String>) -> Self {
+ self.about = Some(value.into());
+ self
+ }
+
+ #[must_use]
+ pub fn with_picture(mut self, value: RadrootsAuthoredProfileImage) -> Self {
+ self.picture = Some(value);
+ self
+ }
+
+ #[must_use]
+ pub fn with_banner(mut self, value: RadrootsAuthoredProfileImage) -> Self {
+ self.banner = Some(value);
+ self
+ }
+
+ #[must_use]
+ pub fn with_nip05(mut self, value: RadrootsNip05Identifier) -> Self {
+ self.nip05 = Some(value);
+ self
+ }
+
+ #[must_use]
+ pub const fn with_bot(mut self, value: bool) -> Self {
+ self.bot = Some(value);
+ self
+ }
+
+ pub fn name(&self) -> &str {
+ &self.name
+ }
+
+ pub fn display_name(&self) -> Option<&str> {
+ self.display_name.as_deref()
+ }
+
+ pub fn about(&self) -> Option<&str> {
+ self.about.as_deref()
+ }
+
+ pub fn picture(&self) -> Option<&RadrootsAuthoredProfileImage> {
+ self.picture.as_ref()
+ }
+
+ pub fn banner(&self) -> Option<&RadrootsAuthoredProfileImage> {
+ self.banner.as_ref()
+ }
+
+ pub fn nip05(&self) -> Option<&RadrootsNip05Identifier> {
+ self.nip05.as_ref()
+ }
+
+ pub const fn bot(&self) -> Option<bool> {
+ self.bot
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
+ use radroots_blossom::{
+ RadrootsBlossomBlobDescriptor, RadrootsBlossomBlobUrl, RadrootsBlossomMediaType,
+ RadrootsBlossomSha256,
+ };
#[test]
fn maps_profile_type_to_tag_value() {
@@ -122,4 +449,170 @@ mod tests {
);
assert_eq!(radroots_profile_type_from_tag_value("unknown"), None);
}
+
+ #[test]
+ fn nip05_identifier_accepts_the_pinned_nip05_syntax_without_claiming_trust() {
+ for (value, canonical) in [
+ ("alice@example.com", "alice@example.com"),
+ ("_@example.com", "_@example.com"),
+ (
+ "farm.stand-1@markets.example",
+ "farm.stand-1@markets.example",
+ ),
+ ("alice@farm2.example", "alice@farm2.example"),
+ ("alice@xn--bcher-kva.example", "alice@xn--bcher-kva.example"),
+ ("alice@Example.COM", "alice@example.com"),
+ ] {
+ let identifier = RadrootsNip05Identifier::parse(value).unwrap();
+ assert_eq!(identifier.as_str(), canonical);
+ assert_eq!(identifier.to_string(), canonical);
+ assert_eq!(
+ canonical.parse::<RadrootsNip05Identifier>().unwrap(),
+ identifier
+ );
+ }
+
+ let identifier = RadrootsNip05Identifier::parse("alice@example.com").unwrap();
+ assert_eq!(identifier.local_part(), "alice");
+ assert_eq!(identifier.domain(), "example.com");
+ }
+
+ #[test]
+ fn nip05_identifier_rejects_noncanonical_or_ambiguous_syntax() {
+ let cases = [
+ ("alice", RadrootsNip05IdentifierError::MissingSeparator),
+ (
+ "alice@example.com@other.example",
+ RadrootsNip05IdentifierError::MultipleSeparators,
+ ),
+ (
+ "@example.com",
+ RadrootsNip05IdentifierError::InvalidLocalPart,
+ ),
+ (
+ "Alice@example.com",
+ RadrootsNip05IdentifierError::InvalidLocalPart,
+ ),
+ (
+ "alice+farm@example.com",
+ RadrootsNip05IdentifierError::InvalidLocalPart,
+ ),
+ ("alice@", RadrootsNip05IdentifierError::InvalidDomain),
+ (
+ "alice@-example.com",
+ RadrootsNip05IdentifierError::InvalidDomain,
+ ),
+ (
+ "alice@example-.com",
+ RadrootsNip05IdentifierError::InvalidDomain,
+ ),
+ (
+ "alice@example..com",
+ RadrootsNip05IdentifierError::InvalidDomain,
+ ),
+ (
+ "alice@example.com.",
+ RadrootsNip05IdentifierError::InvalidDomain,
+ ),
+ (
+ "alice@example_com",
+ RadrootsNip05IdentifierError::InvalidDomain,
+ ),
+ ];
+
+ for (value, expected) in cases {
+ let error = RadrootsNip05Identifier::parse(value).unwrap_err();
+ assert_eq!(error, expected, "{value}");
+ assert!(!error.code().is_empty());
+ assert!(!error.to_string().is_empty());
+ }
+
+ let long_label = "a".repeat(64);
+ assert_eq!(
+ RadrootsNip05Identifier::parse(&format!("alice@{long_label}.example")),
+ Err(RadrootsNip05IdentifierError::InvalidDomain)
+ );
+ let long_domain = (0..43).map(|_| "aaaaa").collect::<Vec<_>>().join(".");
+ assert!(long_domain.len() > 253);
+ assert_eq!(
+ RadrootsNip05Identifier::parse(&format!("alice@{long_domain}")),
+ Err(RadrootsNip05IdentifierError::InvalidDomain)
+ );
+ }
+
+ #[test]
+ fn authored_profile_requires_name_and_image_only_verified_media() {
+ let media =
+ RadrootsAuthoredProfileImage::try_from(verified_descriptor("image/webp", "webp"))
+ .unwrap();
+ let profile = RadrootsAuthoredProfile::new("alice")
+ .unwrap()
+ .with_display_name("Alice")
+ .with_about("Victoria grower")
+ .with_picture(media.clone())
+ .with_banner(media)
+ .with_nip05(RadrootsNip05Identifier::parse("alice@example.com").unwrap())
+ .with_bot(false);
+
+ assert_eq!(profile.name(), "alice");
+ assert_eq!(profile.display_name(), Some("Alice"));
+ assert_eq!(profile.about(), Some("Victoria grower"));
+ assert_eq!(
+ profile.nip05().map(RadrootsNip05Identifier::as_str),
+ Some("alice@example.com")
+ );
+ assert_eq!(profile.bot(), Some(false));
+ assert_eq!(
+ profile
+ .picture()
+ .map(|value| value.descriptor().url().as_str()),
+ Some(
+ "https://media.example/2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824.webp"
+ )
+ );
+ assert_eq!(
+ profile
+ .banner()
+ .map(|value| value.descriptor().url().as_str()),
+ profile
+ .picture()
+ .map(|value| value.descriptor().url().as_str())
+ );
+
+ let error =
+ RadrootsAuthoredProfileImage::try_from(verified_descriptor("text/plain", "txt"))
+ .unwrap_err();
+ assert_eq!(error, RadrootsAuthoredProfileImageError::MediaTypeNotImage);
+ assert_eq!(error.code(), "media_type_not_image");
+ assert!(!error.to_string().is_empty());
+
+ for invalid_name in ["", " ", "alice\n"] {
+ let error = RadrootsAuthoredProfile::new(invalid_name).unwrap_err();
+ assert_eq!(error, RadrootsAuthoredProfileError::InvalidName);
+ assert_eq!(error.code(), "invalid_name");
+ assert!(!error.to_string().is_empty());
+ }
+ }
+
+ fn verified_descriptor(
+ media_type: &str,
+ extension: &str,
+ ) -> RadrootsBlossomByteVerifiedDescriptor {
+ let bytes = b"hello";
+ let hash = RadrootsBlossomSha256::digest(bytes);
+ let media_type = RadrootsBlossomMediaType::parse(media_type).unwrap();
+ RadrootsBlossomBlobDescriptor::new(
+ RadrootsBlossomBlobUrl::parse(&format!("https://media.example/{hash}.{extension}"))
+ .unwrap(),
+ hash,
+ bytes.len() as u64,
+ media_type.clone(),
+ 1_784_347_200,
+ )
+ .unwrap()
+ .approve_reference()
+ .unwrap()
+ .verify_bytes(bytes, &media_type)
+ .unwrap()
+ }
}
diff --git a/crates/event_codec/Cargo.toml b/crates/event_codec/Cargo.toml
@@ -38,5 +38,9 @@ hex = { version = "0.4", default-features = false, features = [
sha2 = { workspace = true, default-features = false, optional = true }
[dev-dependencies]
+radroots_blossom = { workspace = true, default-features = false, features = [
+ "serde",
+ "std",
+] }
radroots_test_fixtures = { workspace = true }
serde_json = { workspace = true, features = ["std"] }
diff --git a/crates/event_codec/README b/crates/event_codec/README
@@ -13,6 +13,14 @@ codecs and tag builders for the `radroots` core libraries.
* optional `serde_json` and `radroots_nostr` integration for JSON and wire
interop.
+With the optional `serde_json` feature, the Profile codec exposes separate
+strict authored and tolerant inbound operations. Strict authoring emits
+bounded, deterministic kind-`0` metadata with empty tags and image-typed,
+byte-verified Blossom media references. Tolerant inbound parsing retains raw
+and residual fields and never upgrades observed media or NIP-05 syntax into
+network verification. Its content parser accepts only bounded kind-`0` content
+from an event whose identifier and signature the caller has already verified.
+
## Field Event Codecs
The Field codec surface validates the public Nostr event substrate exposed by
diff --git a/crates/event_codec/src/profile/authored.rs b/crates/event_codec/src/profile/authored.rs
@@ -0,0 +1,181 @@
+#[cfg(not(feature = "std"))]
+use alloc::vec::Vec;
+use core::fmt;
+
+use radroots_event::{
+ kinds::KIND_PROFILE,
+ profile::{RADROOTS_PROFILE_METADATA_MAX_CONTENT_BYTES, RadrootsAuthoredProfile},
+ wire::RadrootsNip01EventWireParts,
+};
+use serde::Serialize;
+
+#[non_exhaustive]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum RadrootsAuthoredProfileEncodeError {
+ ContentTooLarge { max: usize, actual: usize },
+}
+
+impl RadrootsAuthoredProfileEncodeError {
+ pub const fn code(&self) -> &'static str {
+ match self {
+ Self::ContentTooLarge { .. } => "content_too_large",
+ }
+ }
+}
+
+impl fmt::Display for RadrootsAuthoredProfileEncodeError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::ContentTooLarge { max, actual } => {
+ write!(
+ f,
+ "authored Profile metadata is {actual} bytes; max is {max}"
+ )
+ }
+ }
+ }
+}
+
+#[cfg(feature = "std")]
+impl std::error::Error for RadrootsAuthoredProfileEncodeError {}
+
+#[derive(Serialize)]
+struct AuthoredProfileMetadata<'a> {
+ name: &'a str,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ display_name: Option<&'a str>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ about: Option<&'a str>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ picture: Option<&'a str>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ banner: Option<&'a str>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ nip05: Option<&'a str>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ bot: Option<bool>,
+}
+
+/// Builds deterministic unsigned kind-0 wire parts from strict authored metadata.
+///
+/// The output is a complete replacement snapshot, not a merge or patch. The
+/// caller must not use it for an existing Profile edit unless omitted fields
+/// are intentionally removed. Retaining existing media requires the caller to
+/// re-establish its byte-verified descriptor state.
+///
+/// The caller must separately prove successful BUD-02 upload completion before
+/// passing media-bearing output to a signing boundary.
+pub fn authored_profile_to_wire_parts(
+ profile: &RadrootsAuthoredProfile,
+) -> Result<RadrootsNip01EventWireParts, RadrootsAuthoredProfileEncodeError> {
+ let metadata = AuthoredProfileMetadata {
+ name: profile.name(),
+ display_name: profile.display_name(),
+ about: profile.about(),
+ picture: profile
+ .picture()
+ .map(|image| image.descriptor().url().as_str()),
+ banner: profile
+ .banner()
+ .map(|image| image.descriptor().url().as_str()),
+ nip05: profile.nip05().map(|identifier| identifier.as_str()),
+ bot: profile.bot(),
+ };
+ let expected_len = authored_profile_metadata_len(&metadata);
+ if expected_len > RADROOTS_PROFILE_METADATA_MAX_CONTENT_BYTES {
+ return Err(RadrootsAuthoredProfileEncodeError::ContentTooLarge {
+ max: RADROOTS_PROFILE_METADATA_MAX_CONTENT_BYTES,
+ actual: expected_len,
+ });
+ }
+ let content = serde_json::to_string(&metadata)
+ .expect("authored Profile metadata contains only infallible JSON scalar types");
+ Ok(RadrootsNip01EventWireParts {
+ kind: KIND_PROFILE,
+ content,
+ tags: Vec::new(),
+ })
+}
+
+fn authored_profile_metadata_len(metadata: &AuthoredProfileMetadata<'_>) -> usize {
+ let mut len = 2usize;
+ let mut fields = 0usize;
+ add_string_field_len(&mut len, &mut fields, "name", metadata.name);
+ if let Some(value) = metadata.display_name {
+ add_string_field_len(&mut len, &mut fields, "display_name", value);
+ }
+ if let Some(value) = metadata.about {
+ add_string_field_len(&mut len, &mut fields, "about", value);
+ }
+ if let Some(value) = metadata.picture {
+ add_string_field_len(&mut len, &mut fields, "picture", value);
+ }
+ if let Some(value) = metadata.banner {
+ add_string_field_len(&mut len, &mut fields, "banner", value);
+ }
+ if let Some(value) = metadata.nip05 {
+ add_string_field_len(&mut len, &mut fields, "nip05", value);
+ }
+ if let Some(value) = metadata.bot {
+ add_field_prefix_len(&mut len, &mut fields, "bot");
+ len = len.saturating_add(if value { 4 } else { 5 });
+ }
+ len
+}
+
+fn add_string_field_len(len: &mut usize, fields: &mut usize, key: &str, value: &str) {
+ add_field_prefix_len(len, fields, key);
+ *len = len.saturating_add(json_string_encoded_len(value));
+}
+
+fn add_field_prefix_len(len: &mut usize, fields: &mut usize, key: &str) {
+ if *fields > 0 {
+ *len = len.saturating_add(1);
+ }
+ *fields = fields.saturating_add(1);
+ *len = len.saturating_add(key.len().saturating_add(3));
+}
+
+fn json_string_encoded_len(value: &str) -> usize {
+ value.bytes().fold(2usize, |len, byte| {
+ let encoded = match byte {
+ b'"' | b'\\' | b'\x08' | b'\t' | b'\n' | b'\x0c' | b'\r' => 2,
+ 0x00..=0x1f => 6,
+ _ => 1,
+ };
+ len.saturating_add(encoded)
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use radroots_event::profile::RadrootsNip05Identifier;
+
+ #[test]
+ fn preflight_length_matches_compact_json_escaping() {
+ let escaped = "\0\u{8}\t\n\u{c}\r\"\\e\u{301}";
+ assert_eq!(
+ json_string_encoded_len(escaped),
+ serde_json::to_string(escaped).unwrap().len()
+ );
+
+ let profile = RadrootsAuthoredProfile::new("farm \\\"one\\\"")
+ .unwrap()
+ .with_display_name(escaped)
+ .with_about("Victoria \u{e9}")
+ .with_nip05(RadrootsNip05Identifier::parse("farm@example.com").unwrap())
+ .with_bot(true);
+ let wire = authored_profile_to_wire_parts(&profile).unwrap();
+ let metadata = AuthoredProfileMetadata {
+ name: profile.name(),
+ display_name: profile.display_name(),
+ about: profile.about(),
+ picture: None,
+ banner: None,
+ nip05: profile.nip05().map(|identifier| identifier.as_str()),
+ bot: profile.bot(),
+ };
+ assert_eq!(authored_profile_metadata_len(&metadata), wire.content.len());
+ }
+}
diff --git a/crates/event_codec/src/profile/decode.rs b/crates/event_codec/src/profile/decode.rs
@@ -41,6 +41,11 @@ fn profile_type_from_tags(tags: &[Vec<String>]) -> Option<RadrootsProfileType> {
.find_map(|value| radroots_profile_type_from_tag_value(value))
}
+/// Decodes content into the compatibility-only legacy Profile model.
+///
+/// This API requires `name`, coerces Boolean `bot` to a string, and discards
+/// unprojected fields. Use `profile.parse_inbound_metadata` for the tolerant
+/// inbound metadata contract.
pub fn profile_from_content(content: &str) -> Result<RadrootsProfile, EventParseError> {
let value: Value =
serde_json::from_str(content).map_err(|_| EventParseError::InvalidJson("content"))?;
@@ -66,6 +71,10 @@ pub fn profile_from_content(content: &str) -> Result<RadrootsProfile, EventParse
})
}
+/// Projects caller-supplied event fields through the legacy Profile decoder.
+///
+/// This compatibility API does not verify the event identifier or signature
+/// and is not the strict inbound event-admission boundary.
pub fn data_from_event(
id: String,
author: String,
@@ -94,6 +103,10 @@ pub fn data_from_event(
))
}
+/// Builds a legacy parsed Profile wrapper from caller-supplied event fields.
+///
+/// This compatibility API is outside `profile.parse_inbound_metadata` and does
+/// not establish strict event admission.
pub fn parsed_from_event(
id: String,
author: String,
diff --git a/crates/event_codec/src/profile/encode.rs b/crates/event_codec/src/profile/encode.rs
@@ -19,6 +19,10 @@ fn push_tag(tags: &mut Vec<Vec<String>>, key: &str, value: &str) {
tags.push(vec![key.to_string(), value.to_string()]);
}
+/// Builds the legacy Radroots Profile marker tag.
+///
+/// This compatibility helper is not part of the strict authored Profile
+/// operation.
pub fn profile_type_tags(profile_type: RadrootsProfileType) -> Vec<Vec<String>> {
let mut tags = Vec::with_capacity(1);
push_tag(
@@ -29,6 +33,10 @@ pub fn profile_type_tags(profile_type: RadrootsProfileType) -> Vec<Vec<String>>
tags
}
+/// Builds optional legacy Radroots Profile marker tags.
+///
+/// This compatibility helper is not part of the strict authored Profile
+/// operation.
pub fn profile_build_tags(profile_type: Option<RadrootsProfileType>) -> Vec<Vec<String>> {
match profile_type {
Some(value) => profile_type_tags(value),
@@ -36,6 +44,10 @@ pub fn profile_build_tags(profile_type: Option<RadrootsProfileType>) -> Vec<Vec<
}
}
+/// Converts the legacy Profile model to generic Nostr metadata.
+///
+/// This compatibility API accepts arbitrary media strings and does not satisfy
+/// the strict authored Profile contract.
pub fn to_metadata(p: &RadrootsProfile) -> Result<Metadata, ProfileEncodeError> {
let mut md = Metadata::new().name(p.name.clone());
@@ -71,6 +83,10 @@ pub fn to_metadata(p: &RadrootsProfile) -> Result<Metadata, ProfileEncodeError>
}
#[cfg(feature = "serde_json")]
+/// Encodes the legacy Profile model without a marker tag.
+///
+/// This compatibility API does not satisfy the strict authored Profile media
+/// contract. New authored callers must use `profile.build_authored_draft`.
pub fn to_wire_parts(
p: &RadrootsProfile,
) -> Result<RadrootsNip01EventWireParts, ProfileEncodeError> {
@@ -78,6 +94,10 @@ pub fn to_wire_parts(
}
#[cfg(feature = "serde_json")]
+/// Encodes the legacy Profile model with an optional marker tag.
+///
+/// This compatibility API does not satisfy the strict authored Profile media
+/// contract. New authored callers must use `profile.build_authored_draft`.
pub fn to_wire_parts_with_profile_type(
p: &RadrootsProfile,
profile_type: Option<RadrootsProfileType>,
diff --git a/crates/event_codec/src/profile/inbound.rs b/crates/event_codec/src/profile/inbound.rs
@@ -0,0 +1,335 @@
+#[cfg(not(feature = "std"))]
+use alloc::{
+ collections::BTreeMap,
+ string::{String, ToString},
+};
+#[cfg(feature = "std")]
+use std::{collections::BTreeMap, string::String};
+
+use core::fmt;
+
+use radroots_event::profile::{
+ RADROOTS_PROFILE_METADATA_MAX_CONTENT_BYTES, RadrootsNip05Identifier,
+};
+use serde::de::{IgnoredAny, MapAccess, SeqAccess, Visitor};
+use serde::{Deserialize, Deserializer};
+use serde_json::Value;
+
+#[non_exhaustive]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum RadrootsProfileMetadataParseError {
+ ContentTooLarge { max: usize, actual: usize },
+ InvalidJson,
+ RootNotObject,
+ DuplicateField(String),
+}
+
+impl RadrootsProfileMetadataParseError {
+ pub const fn code(&self) -> &'static str {
+ match self {
+ Self::ContentTooLarge { .. } => "content_too_large",
+ Self::InvalidJson => "invalid_json",
+ Self::RootNotObject => "root_not_object",
+ Self::DuplicateField(_) => "duplicate_field",
+ }
+ }
+}
+
+impl fmt::Display for RadrootsProfileMetadataParseError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::ContentTooLarge { max, actual } => {
+ write!(f, "Profile metadata is {actual} bytes; max is {max}")
+ }
+ Self::InvalidJson => f.write_str("Profile metadata is invalid JSON"),
+ Self::RootNotObject => f.write_str("Profile metadata root must be a JSON object"),
+ Self::DuplicateField(field) => {
+ write!(f, "Profile metadata contains duplicate field {field:?}")
+ }
+ }
+ }
+}
+
+#[cfg(feature = "std")]
+impl std::error::Error for RadrootsProfileMetadataParseError {}
+
+/// A string media reference observed in inbound Profile metadata.
+///
+/// Even a structurally valid Blossom URL remains unverified in this state.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsUnverifiedProfileMediaReference(String);
+
+impl RadrootsUnverifiedProfileMediaReference {
+ fn from_inbound(value: String) -> Self {
+ Self(value)
+ }
+
+ pub fn as_str(&self) -> &str {
+ &self.0
+ }
+}
+
+/// The content parser never performs NIP-05 network identity resolution.
+///
+/// A future resolved identity must use a separate verified result type.
+#[non_exhaustive]
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum RadrootsNip05IdentityVerification {
+ NotPerformed,
+}
+
+impl RadrootsNip05IdentityVerification {
+ pub const fn code(self) -> &'static str {
+ match self {
+ Self::NotPerformed => "not_performed",
+ }
+ }
+}
+
+impl fmt::Display for RadrootsUnverifiedProfileMediaReference {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str(self.as_str())
+ }
+}
+
+/// Tolerant inbound Profile metadata with lossless raw and residual views.
+///
+/// Correctly typed known fields are projected below. Unknown fields, known
+/// fields with the wrong JSON type, and syntactically invalid NIP-05 strings
+/// remain in `residual_fields` and in the complete raw object. This type does
+/// not attest event kind, identifier, signature, or author.
+#[derive(Clone, Debug, PartialEq)]
+pub struct RadrootsInboundProfileMetadata {
+ raw_content: String,
+ raw_fields: BTreeMap<String, Value>,
+ residual_fields: BTreeMap<String, Value>,
+ name: Option<String>,
+ display_name: Option<String>,
+ about: Option<String>,
+ picture: Option<RadrootsUnverifiedProfileMediaReference>,
+ banner: Option<RadrootsUnverifiedProfileMediaReference>,
+ nip05: Option<RadrootsNip05Identifier>,
+ bot: Option<bool>,
+}
+
+impl RadrootsInboundProfileMetadata {
+ pub fn raw_content(&self) -> &str {
+ &self.raw_content
+ }
+
+ pub fn raw_fields(&self) -> &BTreeMap<String, Value> {
+ &self.raw_fields
+ }
+
+ pub fn residual_fields(&self) -> &BTreeMap<String, Value> {
+ &self.residual_fields
+ }
+
+ pub fn name(&self) -> Option<&str> {
+ self.name.as_deref()
+ }
+
+ pub fn display_name(&self) -> Option<&str> {
+ self.display_name.as_deref()
+ }
+
+ pub fn about(&self) -> Option<&str> {
+ self.about.as_deref()
+ }
+
+ pub fn picture(&self) -> Option<&RadrootsUnverifiedProfileMediaReference> {
+ self.picture.as_ref()
+ }
+
+ pub fn banner(&self) -> Option<&RadrootsUnverifiedProfileMediaReference> {
+ self.banner.as_ref()
+ }
+
+ /// Returns only a syntax-checked identifier; no NIP-05 resolution occurred.
+ pub fn nip05(&self) -> Option<&RadrootsNip05Identifier> {
+ self.nip05.as_ref()
+ }
+
+ pub const fn nip05_identity_verification(&self) -> RadrootsNip05IdentityVerification {
+ RadrootsNip05IdentityVerification::NotPerformed
+ }
+
+ pub const fn bot(&self) -> Option<bool> {
+ self.bot
+ }
+}
+
+/// Parses bounded, untrusted kind-0 metadata content after event verification.
+///
+/// Successful parsing is not event admission. The caller must first require an
+/// exact kind-0 event with a verified identifier and signature. Content larger
+/// than [`RADROOTS_PROFILE_METADATA_MAX_CONTENT_BYTES`] is rejected before JSON
+/// parsing.
+pub fn parse_inbound_profile_metadata(
+ content: &str,
+) -> Result<RadrootsInboundProfileMetadata, RadrootsProfileMetadataParseError> {
+ if content.len() > RADROOTS_PROFILE_METADATA_MAX_CONTENT_BYTES {
+ return Err(RadrootsProfileMetadataParseError::ContentTooLarge {
+ max: RADROOTS_PROFILE_METADATA_MAX_CONTENT_BYTES,
+ actual: content.len(),
+ });
+ }
+
+ let root: ProfileMetadataRoot = serde_json::from_str(content)
+ .map_err(|_| RadrootsProfileMetadataParseError::InvalidJson)?;
+ let ProfileMetadataRoot::Object(unique) = root else {
+ return Err(RadrootsProfileMetadataParseError::RootNotObject);
+ };
+ if let Some(field) = unique.duplicate {
+ return Err(RadrootsProfileMetadataParseError::DuplicateField(field));
+ }
+
+ let raw_fields = unique.fields;
+ let mut residual_fields = raw_fields.clone();
+ let name = project_string(&raw_fields, &mut residual_fields, "name");
+ let display_name = project_string(&raw_fields, &mut residual_fields, "display_name");
+ let about = project_string(&raw_fields, &mut residual_fields, "about");
+ let picture = project_string(&raw_fields, &mut residual_fields, "picture")
+ .map(RadrootsUnverifiedProfileMediaReference::from_inbound);
+ let banner = project_string(&raw_fields, &mut residual_fields, "banner")
+ .map(RadrootsUnverifiedProfileMediaReference::from_inbound);
+ let nip05 = project_nip05(&raw_fields, &mut residual_fields);
+ let bot = project_bool(&raw_fields, &mut residual_fields, "bot");
+
+ Ok(RadrootsInboundProfileMetadata {
+ raw_content: content.to_string(),
+ raw_fields,
+ residual_fields,
+ name,
+ display_name,
+ about,
+ picture,
+ banner,
+ nip05,
+ bot,
+ })
+}
+
+fn project_string(
+ raw_fields: &BTreeMap<String, Value>,
+ residual_fields: &mut BTreeMap<String, Value>,
+ key: &'static str,
+) -> Option<String> {
+ let value = raw_fields.get(key)?.as_str()?.to_string();
+ residual_fields.remove(key);
+ Some(value)
+}
+
+fn project_bool(
+ raw_fields: &BTreeMap<String, Value>,
+ residual_fields: &mut BTreeMap<String, Value>,
+ key: &'static str,
+) -> Option<bool> {
+ let value = raw_fields.get(key)?.as_bool()?;
+ residual_fields.remove(key);
+ Some(value)
+}
+
+fn project_nip05(
+ raw_fields: &BTreeMap<String, Value>,
+ residual_fields: &mut BTreeMap<String, Value>,
+) -> Option<RadrootsNip05Identifier> {
+ let value = raw_fields.get("nip05")?.as_str()?;
+ let identifier = RadrootsNip05Identifier::parse(value).ok()?;
+ residual_fields.remove("nip05");
+ Some(identifier)
+}
+
+struct UniqueMetadataObject {
+ fields: BTreeMap<String, Value>,
+ duplicate: Option<String>,
+}
+
+enum ProfileMetadataRoot {
+ Object(UniqueMetadataObject),
+ NonObject,
+}
+
+impl<'de> Deserialize<'de> for ProfileMetadataRoot {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: Deserializer<'de>,
+ {
+ deserializer.deserialize_any(ProfileMetadataRootVisitor)
+ }
+}
+
+struct ProfileMetadataRootVisitor;
+
+impl<'de> Visitor<'de> for ProfileMetadataRootVisitor {
+ type Value = ProfileMetadataRoot;
+
+ fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str("Profile metadata JSON")
+ }
+
+ fn visit_bool<E>(self, _value: bool) -> Result<Self::Value, E> {
+ Ok(ProfileMetadataRoot::NonObject)
+ }
+
+ fn visit_i64<E>(self, _value: i64) -> Result<Self::Value, E> {
+ Ok(ProfileMetadataRoot::NonObject)
+ }
+
+ fn visit_u64<E>(self, _value: u64) -> Result<Self::Value, E> {
+ Ok(ProfileMetadataRoot::NonObject)
+ }
+
+ fn visit_f64<E>(self, _value: f64) -> Result<Self::Value, E> {
+ Ok(ProfileMetadataRoot::NonObject)
+ }
+
+ fn visit_str<E>(self, _value: &str) -> Result<Self::Value, E> {
+ Ok(ProfileMetadataRoot::NonObject)
+ }
+
+ fn visit_unit<E>(self) -> Result<Self::Value, E> {
+ Ok(ProfileMetadataRoot::NonObject)
+ }
+
+ fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
+ where
+ A: SeqAccess<'de>,
+ {
+ loop {
+ if sequence.next_element::<IgnoredAny>()?.is_none() {
+ break;
+ }
+ }
+ Ok(ProfileMetadataRoot::NonObject)
+ }
+
+ fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
+ where
+ A: MapAccess<'de>,
+ {
+ let mut fields = BTreeMap::new();
+ let mut duplicate = None;
+ while let Some((key, value)) = map.next_entry::<String, Value>()? {
+ if fields.insert(key.clone(), value).is_some() && duplicate.is_none() {
+ duplicate = Some(key);
+ }
+ }
+ Ok(ProfileMetadataRoot::Object(UniqueMetadataObject {
+ fields,
+ duplicate,
+ }))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use serde::de::{Error, Unexpected, value};
+
+ #[test]
+ fn root_visitor_expecting_message_is_stable() {
+ let error = value::Error::invalid_type(Unexpected::Bool(true), &ProfileMetadataRootVisitor);
+ assert!(error.to_string().contains("Profile metadata JSON"));
+ }
+}
diff --git a/crates/event_codec/src/profile/mod.rs b/crates/event_codec/src/profile/mod.rs
@@ -5,12 +5,18 @@ use radroots_event::profile::{RadrootsProfile, RadrootsProfileType};
pub mod error;
+#[cfg(feature = "serde_json")]
+pub mod authored;
+
#[cfg(feature = "nostr")]
pub mod encode;
#[cfg(feature = "serde_json")]
pub mod decode;
+#[cfg(feature = "serde_json")]
+pub mod inbound;
+
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Debug)]
pub struct RadrootsProfileData {
diff --git a/crates/event_codec/tests/fixtures/profile_metadata.v1.json b/crates/event_codec/tests/fixtures/profile_metadata.v1.json
@@ -0,0 +1,518 @@
+{
+ "suite": "profile_metadata",
+ "contract_version": "0.1.0",
+ "vectors": [
+ {
+ "id": "profile_nip05_valid_root_identifier_001",
+ "kind": "profile.nip05.parse.valid",
+ "input": {
+ "identifier": "_@mossstreet.example"
+ },
+ "expected": {
+ "identifier": "_@mossstreet.example",
+ "local_part": "_",
+ "domain": "mossstreet.example",
+ "identity_verification": "not_performed"
+ }
+ },
+ {
+ "id": "profile_nip05_valid_uppercase_domain_001",
+ "kind": "profile.nip05.parse.valid",
+ "input": {
+ "identifier": "alice@MossStreet.EXAMPLE"
+ },
+ "expected": {
+ "identifier": "alice@mossstreet.example",
+ "local_part": "alice",
+ "domain": "mossstreet.example",
+ "identity_verification": "not_performed"
+ }
+ },
+ {
+ "id": "profile_nip05_invalid_upper_local_001",
+ "kind": "profile.nip05.parse.invalid",
+ "input": {
+ "identifier": "Alice@mossstreet.example"
+ },
+ "expected": {
+ "error": "invalid_local_part"
+ }
+ },
+ {
+ "id": "profile_nip05_invalid_multiple_separator_001",
+ "kind": "profile.nip05.parse.invalid",
+ "input": {
+ "identifier": "alice@mossstreet.example@other.example"
+ },
+ "expected": {
+ "error": "multiple_separators"
+ }
+ },
+ {
+ "id": "profile_authored_required_name_001",
+ "kind": "profile.build_authored_draft.valid",
+ "input": {
+ "profile": {
+ "name": "moss-street-farm"
+ }
+ },
+ "expected": {
+ "wire_parts": {
+ "kind": 0,
+ "content": "{\"name\":\"moss-street-farm\"}",
+ "tags": []
+ },
+ "upload_completion": "not_attested_by_codec"
+ }
+ },
+ {
+ "id": "profile_authored_content_limit_exact_001",
+ "kind": "profile.build_authored_draft.limit.valid",
+ "input": {
+ "generated_name_bytes": 131061
+ },
+ "expected": {
+ "kind": 0,
+ "content_bytes": 131072,
+ "tags": [],
+ "upload_completion": "not_attested_by_codec"
+ }
+ },
+ {
+ "id": "profile_authored_complete_blossom_metadata_001",
+ "kind": "profile.build_authored_draft.valid",
+ "input": {
+ "profile": {
+ "name": "moss-street-farm",
+ "display_name": "Moss Street Farm",
+ "about": "Small urban farm in Victoria, B.C.",
+ "picture": {
+ "bytes_utf8": "victoria-profile",
+ "descriptor": {
+ "url": "https://media.example/9c31d190b20b9ccf039ed13f758e976f13b716cd73dee6cb57a7b0550540d06d.webp",
+ "sha256": "9c31d190b20b9ccf039ed13f758e976f13b716cd73dee6cb57a7b0550540d06d",
+ "size": 16,
+ "type": "image/webp",
+ "uploaded": 1784347200
+ }
+ },
+ "banner": {
+ "bytes_utf8": "victoria-banner",
+ "descriptor": {
+ "url": "https://media.example/55384b73c8df8a842891346dcf614e46f9fedd6c15fde95a157f7172a113209a.webp",
+ "sha256": "55384b73c8df8a842891346dcf614e46f9fedd6c15fde95a157f7172a113209a",
+ "size": 15,
+ "type": "image/webp",
+ "uploaded": 1784347201
+ }
+ },
+ "nip05": "_@mossstreet.example",
+ "bot": false
+ }
+ },
+ "expected": {
+ "wire_parts": {
+ "kind": 0,
+ "content": "{\"name\":\"moss-street-farm\",\"display_name\":\"Moss Street Farm\",\"about\":\"Small urban farm in Victoria, B.C.\",\"picture\":\"https://media.example/9c31d190b20b9ccf039ed13f758e976f13b716cd73dee6cb57a7b0550540d06d.webp\",\"banner\":\"https://media.example/55384b73c8df8a842891346dcf614e46f9fedd6c15fde95a157f7172a113209a.webp\",\"nip05\":\"_@mossstreet.example\",\"bot\":false}",
+ "tags": []
+ },
+ "upload_completion": "not_attested_by_codec"
+ }
+ },
+ {
+ "id": "profile_authored_boolean_true_001",
+ "kind": "profile.build_authored_draft.valid",
+ "input": {
+ "profile": {
+ "name": "harvest-bot",
+ "bot": true
+ }
+ },
+ "expected": {
+ "wire_parts": {
+ "kind": 0,
+ "content": "{\"name\":\"harvest-bot\",\"bot\":true}",
+ "tags": []
+ },
+ "upload_completion": "not_attested_by_codec"
+ }
+ },
+ {
+ "id": "profile_authored_json_escaping_001",
+ "kind": "profile.build_authored_draft.valid",
+ "input": {
+ "profile": {
+ "name": "moss\"\\farm",
+ "about": "line\nfeed\t\u00e9"
+ }
+ },
+ "expected": {
+ "wire_parts": {
+ "kind": 0,
+ "content": "{\"name\":\"moss\\\"\\\\farm\",\"about\":\"line\\nfeed\\t\u00e9\"}",
+ "tags": []
+ },
+ "upload_completion": "not_attested_by_codec"
+ }
+ },
+ {
+ "id": "profile_authored_empty_name_001",
+ "kind": "profile.authored.new.invalid",
+ "input": {
+ "name": ""
+ },
+ "expected": {
+ "error": "invalid_name"
+ }
+ },
+ {
+ "id": "profile_authored_whitespace_name_001",
+ "kind": "profile.authored.new.invalid",
+ "input": {
+ "name": " \t "
+ },
+ "expected": {
+ "error": "invalid_name"
+ }
+ },
+ {
+ "id": "profile_authored_control_name_001",
+ "kind": "profile.authored.new.invalid",
+ "input": {
+ "name": "moss\nstreet"
+ },
+ "expected": {
+ "error": "invalid_name"
+ }
+ },
+ {
+ "id": "profile_authored_non_image_descriptor_001",
+ "kind": "profile.image.from_verified_descriptor.invalid",
+ "input": {
+ "media": {
+ "bytes_utf8": "not-an-image",
+ "descriptor": {
+ "url": "https://media.example/f2e2c6db1745cc40df646dc40c385487c36e4ceb3f1d5c8d6ad1f7620af1ebae.txt",
+ "sha256": "f2e2c6db1745cc40df646dc40c385487c36e4ceb3f1d5c8d6ad1f7620af1ebae",
+ "size": 12,
+ "type": "text/plain",
+ "uploaded": 1784347202
+ }
+ }
+ },
+ "expected": {
+ "error": "media_type_not_image"
+ }
+ },
+ {
+ "id": "profile_authored_content_too_large_001",
+ "kind": "profile.build_authored_draft.invalid",
+ "input": {
+ "generated_name_bytes": 131062
+ },
+ "expected": {
+ "error": "content_too_large",
+ "max": 131072,
+ "actual": 131073
+ }
+ },
+ {
+ "id": "profile_inbound_missing_name_001",
+ "kind": "profile.parse_inbound_metadata.valid",
+ "input": {
+ "content": "{}"
+ },
+ "expected": {
+ "projected": {},
+ "residual_fields": {},
+ "identity_verification": "not_performed"
+ }
+ },
+ {
+ "id": "profile_inbound_complete_boolean_true_001",
+ "kind": "profile.parse_inbound_metadata.valid",
+ "input": {
+ "content": "{\"name\":\"alice\",\"display_name\":\"Alice\",\"about\":\"Victoria gardener\",\"website\":\"https://alice.example\",\"nip05\":\"alice@example.com\",\"lud06\":\"lnurl1alice\",\"lud16\":\"alice@example.com\",\"bot\":true}"
+ },
+ "expected": {
+ "projected": {
+ "name": "alice",
+ "display_name": "Alice",
+ "about": "Victoria gardener",
+ "nip05": "alice@example.com",
+ "bot": true
+ },
+ "residual_fields": {
+ "website": "https://alice.example",
+ "lud06": "lnurl1alice",
+ "lud16": "alice@example.com"
+ },
+ "identity_verification": "not_performed"
+ }
+ },
+ {
+ "id": "profile_inbound_uppercase_nip05_domain_001",
+ "kind": "profile.parse_inbound_metadata.valid",
+ "input": {
+ "content": "{\"nip05\":\"alice@MossStreet.EXAMPLE\"}"
+ },
+ "expected": {
+ "projected": {
+ "nip05": "alice@mossstreet.example"
+ },
+ "residual_fields": {},
+ "identity_verification": "not_performed"
+ }
+ },
+ {
+ "id": "profile_inbound_boolean_false_001",
+ "kind": "profile.parse_inbound_metadata.valid",
+ "input": {
+ "content": "{\"bot\":false}"
+ },
+ "expected": {
+ "projected": {
+ "bot": false
+ },
+ "residual_fields": {},
+ "identity_verification": "not_performed"
+ }
+ },
+ {
+ "id": "profile_inbound_arbitrary_media_unverified_001",
+ "kind": "profile.parse_inbound_metadata.valid",
+ "input": {
+ "content": "{\"picture\":\"https://legacy.example/avatar.jpg\",\"banner\":\"ipfs://legacy-banner\"}"
+ },
+ "expected": {
+ "projected": {
+ "picture": "https://legacy.example/avatar.jpg",
+ "banner": "ipfs://legacy-banner"
+ },
+ "residual_fields": {},
+ "media_verification": {
+ "picture": "unverified",
+ "banner": "unverified"
+ },
+ "identity_verification": "not_performed"
+ }
+ },
+ {
+ "id": "profile_inbound_blossom_shape_still_unverified_001",
+ "kind": "profile.parse_inbound_metadata.valid",
+ "input": {
+ "content": "{\"picture\":\"https://media.example/9c31d190b20b9ccf039ed13f758e976f13b716cd73dee6cb57a7b0550540d06d.webp\"}"
+ },
+ "expected": {
+ "projected": {
+ "picture": "https://media.example/9c31d190b20b9ccf039ed13f758e976f13b716cd73dee6cb57a7b0550540d06d.webp"
+ },
+ "residual_fields": {},
+ "media_verification": {
+ "picture": "unverified"
+ },
+ "identity_verification": "not_performed"
+ }
+ },
+ {
+ "id": "profile_inbound_wrong_types_and_unknown_fields_001",
+ "kind": "profile.parse_inbound_metadata.valid",
+ "input": {
+ "content": "{\"name\":42,\"display_name\":null,\"nip05\":\"Alice@example.com\",\"picture\":{\"url\":\"https://legacy.example/avatar.jpg\"},\"bot\":\"true\",\"birthday\":{\"year\":1988,\"month\":6},\"custom\":{\"nested\":[1,true]}}"
+ },
+ "expected": {
+ "projected": {},
+ "residual_fields": {
+ "name": 42,
+ "display_name": null,
+ "nip05": "Alice@example.com",
+ "picture": {
+ "url": "https://legacy.example/avatar.jpg"
+ },
+ "bot": "true",
+ "birthday": {
+ "year": 1988,
+ "month": 6
+ },
+ "custom": {
+ "nested": [
+ 1,
+ true
+ ]
+ }
+ },
+ "identity_verification": "not_performed"
+ }
+ },
+ {
+ "id": "profile_inbound_wrong_type_nip05_001",
+ "kind": "profile.parse_inbound_metadata.valid",
+ "input": {
+ "content": "{\"nip05\":42}"
+ },
+ "expected": {
+ "projected": {},
+ "residual_fields": {
+ "nip05": 42
+ },
+ "identity_verification": "not_performed"
+ }
+ },
+ {
+ "id": "profile_inbound_deprecated_fields_retained_001",
+ "kind": "profile.parse_inbound_metadata.valid",
+ "input": {
+ "content": "{\"displayName\":\"Legacy Display\",\"username\":\"legacy-name\"}"
+ },
+ "expected": {
+ "projected": {},
+ "residual_fields": {
+ "displayName": "Legacy Display",
+ "username": "legacy-name"
+ },
+ "identity_verification": "not_performed"
+ }
+ },
+ {
+ "id": "profile_inbound_raw_whitespace_escape_nested_duplicate_001",
+ "kind": "profile.parse_inbound_metadata.valid",
+ "input": {
+ "content": " { \"about\":\"line\\nfeed\", \"custom\":{\"key\":1,\"key\":2} } "
+ },
+ "expected": {
+ "projected": {
+ "about": "line\nfeed"
+ },
+ "residual_fields": {
+ "custom": {
+ "key": 2
+ }
+ },
+ "identity_verification": "not_performed"
+ }
+ },
+ {
+ "id": "profile_inbound_content_limit_exact_001",
+ "kind": "profile.parse_inbound_metadata.limit.valid",
+ "input": {
+ "generated_content_bytes": 131072
+ },
+ "expected": {
+ "content_bytes": 131072
+ }
+ },
+ {
+ "id": "profile_inbound_content_limit_exceeded_001",
+ "kind": "profile.parse_inbound_metadata.invalid",
+ "input": {
+ "generated_content_bytes": 131073
+ },
+ "expected": {
+ "error": "content_too_large",
+ "max": 131072,
+ "actual": 131073
+ }
+ },
+ {
+ "id": "profile_inbound_malformed_json_001",
+ "kind": "profile.parse_inbound_metadata.invalid",
+ "input": {
+ "content": "{"
+ },
+ "expected": {
+ "error": "invalid_json"
+ }
+ },
+ {
+ "id": "profile_inbound_malformed_array_001",
+ "kind": "profile.parse_inbound_metadata.invalid",
+ "input": {
+ "content": "[1,"
+ },
+ "expected": {
+ "error": "invalid_json"
+ }
+ },
+ {
+ "id": "profile_inbound_non_object_array_001",
+ "kind": "profile.parse_inbound_metadata.invalid",
+ "input": {
+ "content": "[1]"
+ },
+ "expected": {
+ "error": "root_not_object"
+ }
+ },
+ {
+ "id": "profile_inbound_non_object_null_001",
+ "kind": "profile.parse_inbound_metadata.invalid",
+ "input": {
+ "content": "null"
+ },
+ "expected": {
+ "error": "root_not_object"
+ }
+ },
+ {
+ "id": "profile_inbound_non_object_boolean_001",
+ "kind": "profile.parse_inbound_metadata.invalid",
+ "input": {
+ "content": "true"
+ },
+ "expected": {
+ "error": "root_not_object"
+ }
+ },
+ {
+ "id": "profile_inbound_non_object_negative_integer_001",
+ "kind": "profile.parse_inbound_metadata.invalid",
+ "input": {
+ "content": "-1"
+ },
+ "expected": {
+ "error": "root_not_object"
+ }
+ },
+ {
+ "id": "profile_inbound_non_object_unsigned_integer_001",
+ "kind": "profile.parse_inbound_metadata.invalid",
+ "input": {
+ "content": "1"
+ },
+ "expected": {
+ "error": "root_not_object"
+ }
+ },
+ {
+ "id": "profile_inbound_non_object_float_001",
+ "kind": "profile.parse_inbound_metadata.invalid",
+ "input": {
+ "content": "1.5"
+ },
+ "expected": {
+ "error": "root_not_object"
+ }
+ },
+ {
+ "id": "profile_inbound_non_object_string_001",
+ "kind": "profile.parse_inbound_metadata.invalid",
+ "input": {
+ "content": "\"profile\""
+ },
+ "expected": {
+ "error": "root_not_object"
+ }
+ },
+ {
+ "id": "profile_inbound_duplicate_field_001",
+ "kind": "profile.parse_inbound_metadata.invalid",
+ "input": {
+ "content": "{\"name\":\"first\",\"name\":\"second\",\"name\":\"third\"}"
+ },
+ "expected": {
+ "error": "duplicate_field",
+ "field": "name"
+ }
+ }
+ ]
+}
diff --git a/crates/event_codec/tests/profile_conformance.rs b/crates/event_codec/tests/profile_conformance.rs
@@ -0,0 +1,423 @@
+#![cfg(feature = "serde_json")]
+
+use std::{borrow::Cow, fs, path::Path};
+
+use radroots_blossom::{RadrootsBlossomBlobDescriptor, RadrootsBlossomByteVerifiedDescriptor};
+use radroots_event::profile::{
+ RADROOTS_PROFILE_METADATA_MAX_CONTENT_BYTES, RadrootsAuthoredProfile,
+ RadrootsAuthoredProfileError, RadrootsAuthoredProfileImage, RadrootsAuthoredProfileImageError,
+ RadrootsNip05Identifier,
+};
+use radroots_event_codec::profile::{
+ authored::{RadrootsAuthoredProfileEncodeError, authored_profile_to_wire_parts},
+ inbound::{RadrootsProfileMetadataParseError, parse_inbound_profile_metadata},
+};
+use serde::Deserialize;
+use serde_json::{Map, Value};
+
+const PACKAGED_VECTORS: &str = include_str!("fixtures/profile_metadata.v1.json");
+const WORKSPACE_VECTOR_PATH: &str = "../../contracts/conformance/vectors/profile/metadata.v1.json";
+const WORKSPACE_CONTRACT_MARKER_PATH: &str = "../../contracts/manifest.toml";
+
+#[derive(Debug, Deserialize)]
+struct Suite {
+ suite: String,
+ contract_version: String,
+ vectors: Vec<Vector>,
+}
+
+#[derive(Debug, Deserialize)]
+struct Vector {
+ id: String,
+ kind: String,
+ input: Value,
+ expected: Value,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct AuthoredProfileInput {
+ name: String,
+ display_name: Option<String>,
+ about: Option<String>,
+ picture: Option<AuthoredMediaInput>,
+ banner: Option<AuthoredMediaInput>,
+ nip05: Option<String>,
+ bot: Option<bool>,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct AuthoredMediaInput {
+ bytes_utf8: String,
+ descriptor: RadrootsBlossomBlobDescriptor,
+}
+
+#[test]
+fn checked_in_profile_vectors_execute_against_strict_and_tolerant_public_apis() {
+ let vectors = conformance_vectors();
+ let suite: Suite = serde_json::from_str(&vectors).expect("Profile vectors must parse");
+ assert_eq!(suite.suite, "profile_metadata");
+ assert_eq!(suite.contract_version, "0.1.0");
+ assert!(!suite.vectors.is_empty());
+
+ for vector in &suite.vectors {
+ execute(vector);
+ }
+}
+
+fn conformance_vectors() -> Cow<'static, str> {
+ let workspace_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(WORKSPACE_VECTOR_PATH);
+ match fs::read_to_string(&workspace_path) {
+ Ok(canonical) => {
+ assert_eq!(
+ canonical,
+ PACKAGED_VECTORS,
+ "packaged Profile vectors must match {}",
+ workspace_path.display()
+ );
+ Cow::Owned(canonical)
+ }
+ Err(error)
+ if error.kind() == std::io::ErrorKind::NotFound
+ && !Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join(WORKSPACE_CONTRACT_MARKER_PATH)
+ .is_file() =>
+ {
+ Cow::Borrowed(PACKAGED_VECTORS)
+ }
+ Err(error) => panic!("failed to read {}: {error}", workspace_path.display()),
+ }
+}
+
+fn execute(vector: &Vector) {
+ match vector.kind.as_str() {
+ "profile.nip05.parse.valid" => nip05_valid(vector),
+ "profile.nip05.parse.invalid" => nip05_invalid(vector),
+ "profile.authored.new.invalid" => authored_name_invalid(vector),
+ "profile.image.from_verified_descriptor.invalid" => authored_image_invalid(vector),
+ "profile.build_authored_draft.valid" => authored_valid(vector),
+ "profile.build_authored_draft.limit.valid" => authored_limit_valid(vector),
+ "profile.build_authored_draft.invalid" => authored_invalid(vector),
+ "profile.parse_inbound_metadata.valid" => inbound_valid(vector),
+ "profile.parse_inbound_metadata.limit.valid" => inbound_limit_valid(vector),
+ "profile.parse_inbound_metadata.invalid" => inbound_invalid(vector),
+ kind => panic!("{} uses unsupported vector kind {kind}", vector.id),
+ }
+}
+
+fn nip05_valid(vector: &Vector) {
+ let identifier = RadrootsNip05Identifier::parse(input_str(vector, "identifier"))
+ .unwrap_or_else(|error| panic!("{} failed: {error}", vector.id));
+ assert_eq!(
+ identifier.as_str(),
+ expected_str(vector, "identifier"),
+ "{}",
+ vector.id
+ );
+ assert_eq!(
+ identifier.local_part(),
+ expected_str(vector, "local_part"),
+ "{}",
+ vector.id
+ );
+ assert_eq!(
+ identifier.domain(),
+ expected_str(vector, "domain"),
+ "{}",
+ vector.id
+ );
+ assert_eq!(
+ expected_str(vector, "identity_verification"),
+ "not_performed"
+ );
+}
+
+fn nip05_invalid(vector: &Vector) {
+ let error = RadrootsNip05Identifier::parse(input_str(vector, "identifier"))
+ .expect_err("invalid NIP-05 vector must fail");
+ assert_eq!(error.code(), expected_str(vector, "error"), "{}", vector.id);
+}
+
+fn authored_valid(vector: &Vector) {
+ let profile = authored_profile(&vector.input["profile"], &vector.id);
+ let wire = authored_profile_to_wire_parts(&profile)
+ .unwrap_or_else(|error| panic!("{} failed: {error}", vector.id));
+ let expected = &vector.expected["wire_parts"];
+ assert_eq!(
+ u64::from(wire.kind),
+ expected["kind"].as_u64().unwrap(),
+ "{}",
+ vector.id
+ );
+ assert_eq!(
+ wire.content,
+ expected["content"].as_str().unwrap(),
+ "{}",
+ vector.id
+ );
+ assert_eq!(
+ serde_json::to_value(wire.tags).unwrap(),
+ expected["tags"],
+ "{}",
+ vector.id
+ );
+ assert_eq!(
+ expected_str(vector, "upload_completion"),
+ "not_attested_by_codec"
+ );
+}
+
+fn authored_name_invalid(vector: &Vector) {
+ let error = RadrootsAuthoredProfile::new(input_str(vector, "name"))
+ .expect_err("invalid authored Profile name must fail");
+ assert_eq!(error, RadrootsAuthoredProfileError::InvalidName);
+ assert_eq!(error.code(), expected_str(vector, "error"));
+}
+
+fn authored_limit_valid(vector: &Vector) {
+ let name_bytes = vector.input["generated_name_bytes"]
+ .as_u64()
+ .expect("generated_name_bytes") as usize;
+ let profile = RadrootsAuthoredProfile::new("a".repeat(name_bytes)).unwrap();
+ let wire = authored_profile_to_wire_parts(&profile)
+ .unwrap_or_else(|error| panic!("{} failed: {error}", vector.id));
+ assert_eq!(
+ u64::from(wire.kind),
+ vector.expected["kind"].as_u64().unwrap()
+ );
+ assert_eq!(wire.content.len(), expected_usize(vector, "content_bytes"));
+ assert_eq!(
+ wire.content.len(),
+ RADROOTS_PROFILE_METADATA_MAX_CONTENT_BYTES
+ );
+ assert_eq!(
+ serde_json::to_value(wire.tags).unwrap(),
+ vector.expected["tags"]
+ );
+ assert_eq!(
+ expected_str(vector, "upload_completion"),
+ "not_attested_by_codec"
+ );
+}
+
+fn authored_image_invalid(vector: &Vector) {
+ let input: AuthoredMediaInput = serde_json::from_value(vector.input["media"].clone())
+ .unwrap_or_else(|error| panic!("{} media fixture failed: {error}", vector.id));
+ let descriptor = verified_descriptor(&input, &vector.id);
+ let error = RadrootsAuthoredProfileImage::try_from(descriptor)
+ .expect_err("non-image Profile media must fail");
+ assert_eq!(error, RadrootsAuthoredProfileImageError::MediaTypeNotImage);
+ assert_eq!(error.code(), expected_str(vector, "error"));
+}
+
+fn authored_invalid(vector: &Vector) {
+ let name_bytes = vector.input["generated_name_bytes"]
+ .as_u64()
+ .expect("generated_name_bytes") as usize;
+ let profile = RadrootsAuthoredProfile::new("a".repeat(name_bytes)).unwrap();
+ let error = authored_profile_to_wire_parts(&profile)
+ .expect_err("oversized authored Profile metadata must fail");
+ assert_eq!(error.code(), expected_str(vector, "error"));
+ assert!(!error.to_string().is_empty());
+ match error {
+ RadrootsAuthoredProfileEncodeError::ContentTooLarge { max, actual } => {
+ assert_eq!(max, expected_usize(vector, "max"));
+ assert_eq!(actual, expected_usize(vector, "actual"));
+ assert_eq!(max, RADROOTS_PROFILE_METADATA_MAX_CONTENT_BYTES);
+ }
+ _ => panic!("{} returned an unexpected authored error", vector.id),
+ }
+}
+
+fn authored_profile(input: &Value, vector_id: &str) -> RadrootsAuthoredProfile {
+ let input: AuthoredProfileInput = serde_json::from_value(input.clone())
+ .unwrap_or_else(|error| panic!("{vector_id} authored input failed: {error}"));
+ let mut profile = RadrootsAuthoredProfile::new(input.name)
+ .unwrap_or_else(|error| panic!("{vector_id} name failed: {error}"));
+ if let Some(value) = input.display_name {
+ profile = profile.with_display_name(value);
+ }
+ if let Some(value) = input.about {
+ profile = profile.with_about(value);
+ }
+ if let Some(value) = input.picture {
+ profile = profile.with_picture(authored_image(&value, vector_id));
+ }
+ if let Some(value) = input.banner {
+ profile = profile.with_banner(authored_image(&value, vector_id));
+ }
+ if let Some(value) = input.nip05 {
+ profile = profile.with_nip05(
+ RadrootsNip05Identifier::parse(&value)
+ .unwrap_or_else(|error| panic!("{vector_id} NIP-05 failed: {error}")),
+ );
+ }
+ if let Some(value) = input.bot {
+ profile = profile.with_bot(value);
+ }
+ profile
+}
+
+fn authored_image(input: &AuthoredMediaInput, vector_id: &str) -> RadrootsAuthoredProfileImage {
+ RadrootsAuthoredProfileImage::try_from(verified_descriptor(input, vector_id))
+ .unwrap_or_else(|error| panic!("{vector_id} Profile image failed: {error}"))
+}
+
+fn verified_descriptor(
+ input: &AuthoredMediaInput,
+ vector_id: &str,
+) -> RadrootsBlossomByteVerifiedDescriptor {
+ let media_type = input.descriptor.media_type().clone();
+ input
+ .descriptor
+ .clone()
+ .approve_reference()
+ .unwrap_or_else(|error| panic!("{vector_id} descriptor approval failed: {error}"))
+ .verify_bytes(input.bytes_utf8.as_bytes(), &media_type)
+ .unwrap_or_else(|error| panic!("{vector_id} byte verification failed: {error}"))
+}
+
+fn inbound_valid(vector: &Vector) {
+ let content = inbound_content(vector);
+ let metadata = parse_inbound_profile_metadata(&content)
+ .unwrap_or_else(|error| panic!("{} failed: {error}", vector.id));
+ assert_eq!(metadata.raw_content(), content.as_ref(), "{}", vector.id);
+
+ let raw: Value = serde_json::from_str(&content).expect("valid vector JSON");
+ assert_eq!(
+ serde_json::to_value(metadata.raw_fields()).unwrap(),
+ raw,
+ "{}",
+ vector.id
+ );
+ assert_eq!(
+ serde_json::to_value(metadata.residual_fields()).unwrap(),
+ vector.expected["residual_fields"],
+ "{}",
+ vector.id
+ );
+ assert_eq!(
+ projected_metadata(&metadata),
+ vector.expected["projected"],
+ "{}",
+ vector.id
+ );
+ assert_eq!(
+ metadata.nip05_identity_verification().code(),
+ expected_str(vector, "identity_verification")
+ );
+
+ if let Some(media) = vector.expected.get("media_verification") {
+ if media.get("picture").is_some() {
+ assert_eq!(
+ metadata.picture().expect("picture").to_string(),
+ metadata.picture().unwrap().as_str()
+ );
+ assert_eq!(media["picture"], "unverified");
+ }
+ if media.get("banner").is_some() {
+ assert_eq!(
+ metadata.banner().expect("banner").to_string(),
+ metadata.banner().unwrap().as_str()
+ );
+ assert_eq!(media["banner"], "unverified");
+ }
+ }
+}
+
+fn inbound_limit_valid(vector: &Vector) {
+ let content = inbound_content(vector);
+ let metadata = parse_inbound_profile_metadata(&content)
+ .unwrap_or_else(|error| panic!("{} failed: {error}", vector.id));
+ assert_eq!(content.len(), expected_usize(vector, "content_bytes"));
+ assert_eq!(content.len(), RADROOTS_PROFILE_METADATA_MAX_CONTENT_BYTES);
+ assert_eq!(metadata.raw_content(), content.as_ref());
+ assert_eq!(metadata.name().map(str::len), Some(content.len() - 11));
+}
+
+fn projected_metadata(
+ metadata: &radroots_event_codec::profile::inbound::RadrootsInboundProfileMetadata,
+) -> Value {
+ let mut projected = Map::new();
+ insert_string(&mut projected, "name", metadata.name());
+ insert_string(&mut projected, "display_name", metadata.display_name());
+ insert_string(&mut projected, "about", metadata.about());
+ insert_string(
+ &mut projected,
+ "picture",
+ metadata.picture().map(|value| value.as_str()),
+ );
+ insert_string(
+ &mut projected,
+ "banner",
+ metadata.banner().map(|value| value.as_str()),
+ );
+ insert_string(
+ &mut projected,
+ "nip05",
+ metadata.nip05().map(|value| value.as_str()),
+ );
+ if let Some(value) = metadata.bot() {
+ projected.insert("bot".to_string(), Value::Bool(value));
+ }
+ Value::Object(projected)
+}
+
+fn insert_string(projected: &mut Map<String, Value>, key: &str, value: Option<&str>) {
+ if let Some(value) = value {
+ projected.insert(key.to_string(), Value::String(value.to_string()));
+ }
+}
+
+fn inbound_invalid(vector: &Vector) {
+ let content = inbound_content(vector);
+ let error = parse_inbound_profile_metadata(&content)
+ .expect_err("invalid inbound Profile vector must fail");
+ assert_eq!(error.code(), expected_str(vector, "error"), "{}", vector.id);
+ assert!(!error.to_string().is_empty());
+ match error {
+ RadrootsProfileMetadataParseError::ContentTooLarge { max, actual } => {
+ assert_eq!(max, expected_usize(vector, "max"));
+ assert_eq!(actual, expected_usize(vector, "actual"));
+ }
+ RadrootsProfileMetadataParseError::DuplicateField(field) => {
+ assert_eq!(field, expected_str(vector, "field"), "{}", vector.id);
+ }
+ RadrootsProfileMetadataParseError::InvalidJson
+ | RadrootsProfileMetadataParseError::RootNotObject => {}
+ _ => panic!("{} returned an unexpected inbound error", vector.id),
+ }
+}
+
+fn inbound_content<'a>(vector: &'a Vector) -> Cow<'a, str> {
+ if let Some(content) = vector.input.get("content").and_then(Value::as_str) {
+ return Cow::Borrowed(content);
+ }
+ let bytes = vector.input["generated_content_bytes"]
+ .as_u64()
+ .expect("input.generated_content_bytes") as usize;
+ assert!(bytes >= 11);
+ let value = "a".repeat(bytes - 11);
+ let content = format!(r#"{{"name":"{value}"}}"#);
+ assert_eq!(content.len(), bytes);
+ Cow::Owned(content)
+}
+
+fn input_str<'a>(vector: &'a Vector, field: &str) -> &'a str {
+ vector.input[field]
+ .as_str()
+ .unwrap_or_else(|| panic!("{} input.{field} must be a string", vector.id))
+}
+
+fn expected_str<'a>(vector: &'a Vector, field: &str) -> &'a str {
+ vector.expected[field]
+ .as_str()
+ .unwrap_or_else(|| panic!("{} expected.{field} must be a string", vector.id))
+}
+
+fn expected_usize(vector: &Vector, field: &str) -> usize {
+ vector.expected[field]
+ .as_u64()
+ .unwrap_or_else(|| panic!("{} expected.{field} must be an integer", vector.id)) as usize
+}
diff --git a/crates/net/src/nostr_client/events/profile.rs b/crates/net/src/nostr_client/events/profile.rs
@@ -40,6 +40,10 @@ impl NostrClientManager {
rt.block_on(async move { this.fetch_profile_event(author).await })
}
+ /// Publishes through the legacy generic metadata compatibility surface.
+ ///
+ /// This API does not enforce the strict authored Profile media contract.
+ /// New strict Profile authoring must use `profile.build_authored_draft`.
pub fn publish_profile_event_blocking(
&self,
name: Option<String>,
diff --git a/crates/nostr/src/client.rs b/crates/nostr/src/client.rs
@@ -225,6 +225,10 @@ impl RadrootsNostrClient {
.await?)
}
+ /// Publishes through the legacy generic metadata compatibility surface.
+ ///
+ /// This API does not enforce the strict authored Profile media contract.
+ /// New strict Profile authoring must use `profile.build_authored_draft`.
pub async fn set_metadata(
&self,
md: &RadrootsNostrMetadata,
diff --git a/crates/nostr/src/event_adapters.rs b/crates/nostr/src/event_adapters.rs
@@ -32,6 +32,10 @@ pub fn to_post_event_metadata(e: &RadrootsNostrEvent) -> RadrootsParsedData<Radr
}
#[cfg(feature = "events")]
+/// Adapts an event through the compatibility-only legacy Profile decoder.
+///
+/// This helper does not establish kind, identifier, or signature verification
+/// and is outside `profile.parse_inbound_metadata`.
pub fn to_profile_event_metadata(
e: &RadrootsNostrEvent,
) -> Option<RadrootsParsedData<RadrootsProfileData>> {
diff --git a/crates/nostr/src/events/metadata.rs b/crates/nostr/src/events/metadata.rs
@@ -12,6 +12,11 @@ use crate::types::{
#[cfg(feature = "client")]
use core::time::Duration;
+/// Builds kind-0 metadata through the legacy generic metadata surface.
+///
+/// This compatibility API does not enforce the strict authored Profile media
+/// contract. New strict Profile authoring must use
+/// `profile.build_authored_draft` before signing.
pub fn radroots_nostr_build_metadata_event(
md: &RadrootsNostrMetadata,
) -> RadrootsNostrEventBuilder {
@@ -19,6 +24,11 @@ pub fn radroots_nostr_build_metadata_event(
}
#[cfg(feature = "client")]
+/// Publishes kind-0 metadata through the legacy generic metadata surface.
+///
+/// This compatibility API does not enforce the strict authored Profile media
+/// or upload-completion contract. New strict Profile authoring must use
+/// `profile.build_authored_draft` and prove BUD-02 completion before signing.
pub async fn radroots_nostr_post_metadata_event(
client: &RadrootsNostrClient,
md: &RadrootsNostrMetadata,
@@ -28,6 +38,10 @@ pub async fn radroots_nostr_post_metadata_event(
}
#[cfg(feature = "client")]
+/// Fetches metadata through the legacy compatibility path.
+///
+/// This helper is outside `profile.parse_inbound_metadata`; callers must not
+/// treat its result as strict Profile admission.
pub async fn radroots_nostr_fetch_metadata_for_author(
client: &RadrootsNostrClient,
author: RadrootsNostrPublicKey,
diff --git a/crates/nostr/src/identity_profile.rs b/crates/nostr/src/identity_profile.rs
@@ -17,6 +17,10 @@ use radroots_event::profile::RadrootsProfileType;
use radroots_event_codec::profile::encode::profile_build_tags;
use radroots_identity::RadrootsIdentity;
+/// Publishes an identity Profile through the legacy compatibility model.
+///
+/// This API does not enforce the strict authored Profile media contract. New
+/// Profile product authoring must use `profile.build_authored_draft`.
pub async fn radroots_nostr_publish_identity_profile(
client: &RadrootsNostrClient,
identity: &RadrootsIdentity,
@@ -24,6 +28,11 @@ pub async fn radroots_nostr_publish_identity_profile(
radroots_nostr_publish_identity_profile_with_type(client, identity, None).await
}
+/// Publishes a typed identity Profile through the legacy compatibility model.
+///
+/// This API can emit the legacy Radroots marker tag and does not enforce the
+/// strict authored Profile media contract. New Profile product authoring must
+/// use `profile.build_authored_draft`.
pub async fn radroots_nostr_publish_identity_profile_with_type(
client: &RadrootsNostrClient,
identity: &RadrootsIdentity,
@@ -51,6 +60,11 @@ pub async fn radroots_nostr_publish_identity_profile_with_type(
}
#[cfg(feature = "events")]
+/// Bootstraps service presence through legacy Profile publication surfaces.
+///
+/// This compatibility API does not enforce the strict authored Profile media
+/// contract. New Profile product authoring must use
+/// `profile.build_authored_draft`.
pub async fn radroots_nostr_bootstrap_service_presence(
client: &RadrootsNostrClient,
identity: &RadrootsIdentity,
diff --git a/crates/replica_sync/src/emit.rs b/crates/replica_sync/src/emit.rs
@@ -101,6 +101,10 @@ pub(crate) mod failpoints {
}
}
+/// Builds the full replica transfer bundle.
+///
+/// When Profile inclusion is enabled, the bundle contains compatibility-only
+/// legacy Profile drafts that do not satisfy `profile.build_authored_draft`.
pub fn radroots_replica_sync_all(
exec: &dyn SqlExecutor,
request: &RadrootsReplicaSyncRequest,
@@ -108,6 +112,10 @@ pub fn radroots_replica_sync_all(
radroots_replica_sync_all_with_options(exec, &request.farm, request.options.as_ref())
}
+/// Builds a replica transfer bundle using explicit inclusion options.
+///
+/// Included Profile drafts use the compatibility-only legacy Profile model and
+/// do not satisfy `profile.build_authored_draft`.
pub fn radroots_replica_sync_all_with_options(
exec: &dyn SqlExecutor,
farm_selector: &RadrootsReplicaFarmSelector,
@@ -150,6 +158,9 @@ pub fn radroots_replica_sync_all_with_options(
})
}
+/// Builds compatibility-only legacy Profile drafts for replica transfer.
+///
+/// These drafts do not satisfy `profile.build_authored_draft`.
pub fn radroots_replica_profile_events(
exec: &dyn SqlExecutor,
farm: &Farm,
diff --git a/crates/replica_sync/src/ingest.rs b/crates/replica_sync/src/ingest.rs
@@ -155,6 +155,10 @@ impl RadrootsReplicaIdFactory for RadrootsReplicaDefaultIdFactory {
}
#[cfg(feature = "std")]
+/// Ingests an envelope through the legacy replica projection.
+///
+/// The Profile branch currently requires a legacy Profile marker tag and is
+/// not the strict Profile inbound-admission boundary.
pub fn radroots_replica_ingest_event(
exec: &dyn SqlExecutor,
event: &RadrootsEventEnvelope,
@@ -162,6 +166,10 @@ pub fn radroots_replica_ingest_event(
radroots_replica_ingest_event_with_factory(exec, event, &RadrootsReplicaDefaultIdFactory)
}
+/// Ingests an envelope through the legacy replica projection with an ID source.
+///
+/// The Profile branch currently requires a legacy Profile marker tag and is
+/// not the strict Profile inbound-admission boundary.
pub fn radroots_replica_ingest_event_with_factory(
exec: &dyn SqlExecutor,
event: &RadrootsEventEnvelope,
diff --git a/crates/replica_sync/src/sync_state.rs b/crates/replica_sync/src/sync_state.rs
@@ -44,6 +44,10 @@ pub fn radroots_replica_sync_status<E: SqlExecutor>(
})
}
+/// Computes the replica drafts that have not reached their expected heads.
+///
+/// Profile entries originate from the compatibility-only legacy replica
+/// emitter and do not satisfy `profile.build_authored_draft`.
pub fn radroots_replica_pending_publish_batch<E: SqlExecutor>(
exec: &E,
) -> Result<RadrootsReplicaPendingPublishBatch, RadrootsReplicaEventsError> {