lib

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

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:
MCargo.lock | 2++
Mbuild/nix/common.nix | 2+-
Acontracts/conformance/vectors/profile/metadata.v1.json | 518+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcontracts/event_boundary_matrix.md | 2+-
Acontracts/events/profile-metadata.md | 147+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcontracts/operations.toml | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/event/Cargo.toml | 3++-
Mcrates/event/README | 6++++++
Mcrates/event/src/profile.rs | 493+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/event_codec/Cargo.toml | 4++++
Mcrates/event_codec/README | 8++++++++
Acrates/event_codec/src/profile/authored.rs | 181+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/event_codec/src/profile/decode.rs | 13+++++++++++++
Mcrates/event_codec/src/profile/encode.rs | 20++++++++++++++++++++
Acrates/event_codec/src/profile/inbound.rs | 335+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/event_codec/src/profile/mod.rs | 6++++++
Acrates/event_codec/tests/fixtures/profile_metadata.v1.json | 518+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/event_codec/tests/profile_conformance.rs | 423+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/net/src/nostr_client/events/profile.rs | 4++++
Mcrates/nostr/src/client.rs | 4++++
Mcrates/nostr/src/event_adapters.rs | 4++++
Mcrates/nostr/src/events/metadata.rs | 14++++++++++++++
Mcrates/nostr/src/identity_profile.rs | 14++++++++++++++
Mcrates/replica_sync/src/emit.rs | 11+++++++++++
Mcrates/replica_sync/src/ingest.rs | 8++++++++
Mcrates/replica_sync/src/sync_state.rs | 4++++
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> {