profile-metadata.md (9487B)
1 # Profile metadata contract 2 3 Status: canonical 4 5 This contract defines the public kind-`0` Profile metadata boundary used by 6 Radroots strict authoring and tolerant inbound projection. It is based on the 7 pinned NIP-01, NIP-05, and NIP-24 documents at NIPs commit 8 `bdfa7e62ef87fcfcb992b1a27aee49d36b0b4f91` and the Blossom protocol at commit 9 `b5bd2801d1763aa635fc8fea7a76597e0eb18990`. 10 11 ## Operation authority 12 13 | Operation | Boundary | Signing | Transport | 14 | --- | --- | --- | --- | 15 | `event.verify_nip01` | exact identifier and Schnorr verification to a non-forgeable wrapper | NIP-01 | none | 16 | `event.select_head` | NIP-01 replaceable/addressable timestamp and lowest-id selection | none | none | 17 | `profile.build_authored_draft` | strict authored metadata to kind-`0` wire parts | none | none | 18 | `profile.parse_inbound_metadata` | JSON object to tolerant inbound metadata | none | none | 19 | `profile.verify_and_admit_event` | verified exact kind-`0` envelope to tolerant metadata bound to that envelope | NIP-01 | none | 20 21 `profile.parse_inbound_metadata` is a content parser, not an event-acceptance 22 boundary. A caller must supply content from a kind-`0` event only after the 23 event identifier and signature have been verified. The authoritative combined 24 boundary is `profile.verify_and_admit_event`: it recomputes the identifier, 25 verifies the Schnorr signature, requires exact kind `0`, and only then invokes 26 the same tolerant parser. It accepts standard tagless Profile events and does 27 not require a Radroots marker tag. 28 29 `event.verify_nip01` and its `RadrootsSignatureVerifiedEvent` result are 30 available through the codec's `nostr` feature without enabling `knowledge`. 31 Envelope conversion requires the author to be a valid secp256k1 x-only public 32 key; a canonical-length hex value that is not a curve point returns 33 `malformed_envelope`, not `signature_invalid`. Knowledge contract validation and 34 decoding are a later optional stage and cannot be substituted for general event 35 verification. 36 37 The direct legacy `Profile` codec, Profile-specific Nostr/network 38 publish helpers, and replica Profile draft emission were removed in the 39 `1.0.0-alpha.1` breaking contract. A replica Profile row is a lossy inbound 40 projection and cannot prove author intent for a complete kind-`0` replacement. 41 New authored callers must use `profile.build_authored_draft`. The opaque 42 Radroots generic builder rejects kind `0` at direct-signing and client 43 publication boundaries, so it cannot substitute for Profile operation 44 authority. 45 46 `RadrootsNostrClient` no longer dereferences implicitly to the upstream SDK 47 client, so upstream Profile conveniences are not exposed through ordinary 48 method resolution. `from_inner` and `into_inner`, externally supplied unsigned 49 events and NIP-46 signing, and already-signed event relay remain explicit 50 low-level interoperability boundaries. Their results carry no Radroots typed 51 product-authoring claim; once a caller takes one of those boundaries, upstream 52 protocol behavior is outside the Radroots authored-operation contract. 53 54 Legacy `profile::decode`, Nostr Profile adapters/fetchers, the network Profile 55 fetch methods, and replica Profile ingest remain read-side compatibility paths. 56 They may require or coerce legacy fields, discard unprojected metadata, or rely 57 on legacy marker tags. They are not `profile.parse_inbound_metadata`, do not 58 establish verified event admission, and must not be used as its substitute. 59 60 ## Strict authored boundary 61 62 `AuthoredProfile` has private fields and requires a non-whitespace, 63 control-free `name`, consistent with the NIP-24 recommendation that `name` 64 remain present when `display_name` is used. The scoped optional fields are 65 `display_name`, `about`, `nip05`, `bot`, `picture`, and `banner`; `bot` is a 66 Boolean. NIP-05 values enter only through `Nip05Identifier`. Picture and 67 banner values enter through the shared `AuthoredImage`, which can wrap 68 only an `image/*` `ByteVerifiedDescriptor`. There is no raw-string 69 media setter or unchecked deserialization path. Generic `website`, `lud06`, and 70 `lud16` strings remain outside this strict authored operation. 71 72 Kind `0` is whole-object replaceable. Strict authored output is therefore a 73 complete replacement snapshot, never a patch: every omitted existing standard, 74 residual, or custom field is removed by the replacement. This operation does 75 not merge the tolerant inbound raw object. It is safe for initial Profile 76 creation or an explicitly confirmed full replacement; it must not power a 77 silent partial edit. Retaining an existing picture or banner requires the 78 runtime to re-fetch or retain the bytes, re-establish the byte-verified image 79 descriptor, and satisfy BUD-02 before signing. Until such a full-snapshot edit 80 pipeline exists, clients must keep existing Profile editing read-only. 81 82 The authored codec emits: 83 84 - kind `0` 85 - no tags 86 - one JSON object with fields in this deterministic order when present: 87 `name`, `display_name`, `about`, `picture`, `banner`, `nip05`, `bot` 88 - only the descriptor URL for each media field 89 - at most 131072 UTF-8 bytes of metadata content 90 91 The descriptor state proves that approved descriptor hash, size, and media type 92 match supplied bytes. It does not prove that BUD-02 upload completed or that a 93 blob is network-retrievable, and it does not inspect or sanitize the image 94 format. A publication runtime must require successful BUD-02 completion before 95 signing media-bearing output. A consuming media runtime remains responsible for 96 decode and format-safety policy. 97 98 For two verified kind-`0` events from the same author, the head is the event 99 with the greater `created_at`. Equal timestamps select the lexicographically 100 lowest canonical event id, independent of relay or ingestion arrival order. 101 102 ## Tolerant inbound boundary 103 104 `profile.parse_inbound_metadata` accepts a JSON object of at most 131072 UTF-8 105 bytes without requiring `name`. Correctly typed `name`, `display_name`, `about`, 106 `picture`, `banner`, `nip05`, and `bot` fields are projected without JSON type 107 coercion; accepted NIP-05 domains are canonicalized as described below. In 108 particular, `bot` must be a JSON Boolean. Every string picture or banner is 109 returned as `RadrootsUnverifiedProfileMediaReference`, including strings that 110 look like valid Blossom hash-path URLs. 111 112 The result retains: 113 114 - the exact input content 115 - the complete parsed object 116 - a residual map containing every unknown field, wrong-typed known field, and 117 syntactically invalid NIP-05 string 118 119 Oversized content is rejected before parsing. Malformed JSON and non-object 120 roots are parse errors. Duplicate top-level metadata keys are rejected because 121 they have ambiguous cross-parser semantics. Optional metadata that this 122 contract does not project, including NIP-24 `website` and birthday data plus 123 `lud06` and `lud16`, remains in the residual and complete raw views. Exact 124 nested JSON text, including any duplicate nested names, remains available 125 through the raw content view. 126 127 ## NIP-05 boundary 128 129 `Nip05Identifier` requires exactly one `@`, a non-empty local part using 130 only lowercase `a-z`, digits, `-`, `_`, or `.`, and an ASCII DNS domain. 131 Domain matching is case-insensitive, so accepted domains are canonicalized to 132 lowercase. Parsing is syntax-only. It performs no HTTPS lookup and never 133 represents verified ownership or identity trust. 134 135 A syntax-checked identifier is not a safe network-fetch target by itself. A 136 future resolver must separately govern HTTPS, redirects, address resolution, 137 loopback/private/link-local targets, response size, and timeouts. 138 139 ## Stable error codes 140 141 The public error enums are non-exhaustive so future codes can be added without 142 making downstream matches exhaustive. Current stable codes are: 143 144 | Boundary | Codes | 145 | --- | --- | 146 | NIP-05 syntax | `missing_separator`, `multiple_separators`, `invalid_local_part`, `invalid_domain` | 147 | strict Profile construction | `invalid_name` | 148 | shared authored image construction | `media_type_not_image` | 149 | strict Profile encoding | `content_too_large` | 150 | tolerant inbound parsing | `content_too_large`, `invalid_json`, `root_not_object`, `duplicate_field` | 151 | NIP-01 event verification | `malformed_envelope`, `kind_out_of_range`, `id_mismatch`, `signature_invalid`, `signature_verification_unavailable` | 152 | verified Profile admission | NIP-01 codes plus `invalid_kind`, `content_too_large`, `invalid_json`, `root_not_object`, `duplicate_field` | 153 154 Inbound size validation occurs before JSON parsing. For content within the 155 limit, malformed JSON returns `invalid_json`; a well-formed non-object returns 156 `root_not_object`; and a well-formed object with a duplicate top-level field 157 returns `duplicate_field`. Wrong-typed projected fields are residual data, not 158 parse errors. 159 160 ## Conformance 161 162 The canonical suite is 163 `contracts/conformance/vectors/profile/metadata.v1.json`. Verified event 164 admission and replacement use 165 `contracts/conformance/vectors/profile/verified_event.v1.json`. Both are 166 mirrored under `crates/event_codec/tests/fixtures/` for published-package 167 tests. The dispatchers execute every vector against the public APIs and require 168 canonical and packaged copies to be byte-for-byte equal when the workspace 169 contract is present. The verified-event suite includes a canonical-id raw event 170 whose author is not a valid secp256k1 curve point, proving the stable 171 `malformed_envelope` mapping. Consumers enable `serde_json` for metadata 172 operations and both `serde_json` and `nostr` for cryptographic Profile 173 admission.