Merge pull request #3824 from vitorpamplona/claude/event-kind-0-empty-content-josbwa

Handle blank and malformed metadata gracefully
This commit is contained in:
Vitor Pamplona
2026-07-29 22:17:41 -04:00
committed by GitHub
4 changed files with 248 additions and 16 deletions
@@ -88,27 +88,41 @@ class MetadataEvent(
} ?: ""
fun contactMetadataJson() =
try {
Json.parseToJsonElement(content) as JsonObject
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("MetadataEvent") { "Content Parse Error: ${toNostrUri()} ${e.message}" }
null
if (content.isBlank()) {
// An empty kind-0 is a valid, empty profile — not a parse failure.
EMPTY_METADATA_JSON
} else {
try {
Json.parseToJsonElement(content) as JsonObject
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("MetadataEvent") { "Content Parse Error: ${toNostrUri()} ${e.message}" }
null
}
}
fun contactMetaData() =
try {
JsonMapper.fromJson<UserMetadata>(content)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("MetadataEvent") { "Content Parse Error: ${toNostrUri()} ${e.message}" }
null
if (content.isBlank()) {
// Users do publish kind 0s with an empty content to wipe their
// profile. That must replace the previous metadata with a blank
// one, not be discarded as an error.
UserMetadata()
} else {
try {
JsonMapper.fromJson<UserMetadata>(content)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("MetadataEvent") { "Content Parse Error: ${toNostrUri()} ${e.message}" }
null
}
}
companion object {
const val KIND = 0
const val FIXED_D_TAG = ""
private val EMPTY_METADATA_JSON = JsonObject(emptyMap())
fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, FIXED_D_TAG)
fun createAddressATag(pubKey: HexKey): ATag = ATag(KIND, pubKey, FIXED_D_TAG, null)
@@ -29,6 +29,7 @@ import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.nullable
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
@@ -44,8 +45,10 @@ import kotlinx.serialization.json.booleanOrNull
* profile (name, picture, about…).
*
* This serializer accepts any JSON primitive (matching the pre-existing lenient
* behavior, where a bare number or boolean decodes into a string field) and
* treats objects, arrays, and JSON null as absent rather than failing.
* behavior, where a bare number or boolean decodes into a string field), unwraps
* a single-element array wrapping a primitive (seen in the wild as
* `"nip05":["name@domain"]`, where the intended value is unambiguous), and treats
* objects, longer arrays, and JSON null as absent rather than failing.
*
* Same rationale as [BirthdayTolerantSerializer].
*/
@@ -60,6 +63,17 @@ object TolerantStringSerializer : KSerializer<String?> {
return when {
element is JsonNull -> null
element is JsonPrimitive -> element.content
// A one-element array around a primitive has only one possible reading,
// so recover the value instead of dropping the field.
element is JsonArray -> {
val single = element.singleOrNull()
if (single is JsonPrimitive && single !is JsonNull) {
single.content
} else {
Log.w("TolerantStringSerializer") { "Ignoring array string field with ${element.size} elements" }
null
}
}
else -> {
// Log the JSON kind only, not the raw (untrusted, network-sourced) value.
Log.w("TolerantStringSerializer") { "Ignoring non-primitive string field (${element::class.simpleName})" }
@@ -66,7 +66,7 @@ class TolerantPrimitiveSerializersTest {
fun otherNonStringValuesAreIgnoredFieldByField() {
val meta =
metaWith(
"""{"name":{"first":"A"},"display_name":["A"],"picture":null,"about":"bio","website":{}}""",
"""{"name":{"first":"A"},"display_name":["A","B"],"picture":null,"about":"bio","website":{}}""",
).contactMetaData()
assertIs<UserMetadata>(meta)
@@ -77,6 +77,37 @@ class TolerantPrimitiveSerializersTest {
assertEquals("bio", meta.about, "well-formed fields must survive the malformed ones")
}
/**
* Regression for profiles that wrap a string field in a one-element array
* (seen in the wild as `"nip05":["shoreline@decentnewsroom.com"]`). There is
* only one possible reading, so the value must be recovered rather than
* silently dropped along with the user's NIP-05 verification.
*/
@Test
fun singleElementArrayIsUnwrapped() {
val meta = metaWith("""{"name":"A","nip05":["a@b.com"],"lud16":[123]}""").contactMetaData()
assertIs<UserMetadata>(meta)
assertEquals("a@b.com", meta.nip05)
assertEquals("123", meta.lud16)
}
@Test
fun arraysThatAreNotASinglePrimitiveAreStillIgnored() {
listOf(
"""{"name":"A","nip05":[]}""",
"""{"name":"A","nip05":["a@b.com","c@d.com"]}""",
"""{"name":"A","nip05":[{}]}""",
"""{"name":"A","nip05":[["a@b.com"]]}""",
"""{"name":"A","nip05":[null]}""",
).forEach { json ->
val meta = metaWith(json).contactMetaData()
assertIs<UserMetadata>(meta, "profile must survive nip05 in $json")
assertEquals("A", meta.name)
assertNull(meta.nip05, "ambiguous array nip05 must be dropped in $json")
}
}
/** Pre-existing lenient behavior: bare primitives still decode into string fields. */
@Test
fun numberAndBooleanPrimitivesStillDecodeIntoStringFields() {
@@ -117,7 +148,26 @@ class TolerantPrimitiveSerializersTest {
@Test
fun nonJsonContentReturnsNull() {
assertNull(metaWith("Relay initialized").contactMetaData())
assertNull(metaWith("").contactMetaData())
}
/**
* An empty content is a valid, empty profile — a user wiping their metadata —
* not a parse error. It must decode to a blank [UserMetadata] so the new
* (replaceable) event actually replaces the stale profile downstream.
*/
@Test
fun blankContentIsAnEmptyProfileNotAnError() {
listOf("", " ", "\n", "\t ").forEach { blank ->
val meta = metaWith(blank).contactMetaData()
assertIs<UserMetadata>(meta, "blank content must decode into an empty profile, not null")
assertNull(meta.name)
assertNull(meta.displayName)
assertNull(meta.picture)
assertNull(meta.about)
assertNull(meta.nip05)
assertEquals(emptySet(), metaWith(blank).contactMetadataJson()?.keys, "blank content must decode into an empty json object")
}
}
/** Dropped fields must not leak back into the serialized profile as nulls. */
@@ -0,0 +1,154 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.metadata
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.KotlinSerializationMapper
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNull
/**
* Real kind-0 events collected from relays that used to lose all or part of the
* profile. Each is checked through **both** wire parsers — Jackson (the
* JVM/Android [com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper]
* actual) and kotlinx serialization (the native actual) — because a profile that
* renders on one platform and not the other is the harder bug to spot.
*/
class MetadataEventWildProfilesTest {
/** Runs [assertions] against the event as parsed by each of the two mappers. */
private fun bothMappers(
json: String,
assertions: (label: String, event: MetadataEvent) -> Unit,
) {
assertions("jackson", JacksonMapper.fromJson(json) as MetadataEvent)
assertions("kotlinx", KotlinSerializationMapper.fromJson(json) as MetadataEvent)
}
/**
* Empty content is a valid, empty profile — someone wiping their metadata —
* so it must decode into a blank [UserMetadata]. Returning null made this a
* parse "error" that `LocalCache.consume` dropped, leaving the stale profile
* in place forever even though a newer replaceable event had arrived.
*/
@Test
fun emptyContentIsAnEmptyProfile() {
val json =
"""
{
"id": "3d6f3be9e56aaf0f6e7f013d886f123ff72863df6cc103f2a9d2398221a46a73",
"pubkey": "bed07edf2bb9690324f7898213a7431586f12088e727c2b722ab7c5b2d80368f",
"created_at": 1785356310,
"kind": 0,
"tags": [],
"content": "",
"sig": "2bddad095b8a7dc087cf1c030bf48f93280274d4cb01eb352d1540c0f2d2f4dad84fbd52d8a031c148df735a22e706e29c79d2c956b5e5d9edca49346bb2ba5a"
}
""".trimIndent()
bothMappers(json) { label, event ->
val meta = event.contactMetaData()
assertIs<UserMetadata>(meta, "$label: empty content must not be an error")
assertNull(meta.name, label)
assertNull(meta.displayName, label)
assertNull(meta.picture, label)
assertNull(meta.about, label)
assertNull(meta.nip05, label)
assertEquals(emptySet(), event.contactMetadataJson()?.keys, "$label: empty content is an empty json object")
}
}
/**
* `"nip05":["shoreline@decentnewsroom.com"]` — a string field wrapped in a
* one-element array. The intended value is unambiguous, so it is recovered
* instead of dropping the user's NIP-05 verification.
*/
@Test
fun singleElementArrayNip05IsRecovered() {
val json =
"""
{"id":"642168789c75b50a7f67d0dab7bf4723433a70a7694c63c77eeca03a74af636c","pubkey":"689c44a2c362229489eb3fc5273920469ae3fa6ce01467e07f1c3383097a26b9","created_at":1782219566,"kind":0,"tags":[["display_name","Shoreline Poetry"],["name","shoreline"],["about","Poems and fragments. Inner weather. Ceasing, breaking, flowing, and becoming something new."],["picture","https://image.nostr.build/e98b3341636054b33ccf414390d75d02b721511d5e331ffcac63bacfa56fa1c4.jpg"],["banner","https://images.pexels.com/photos/18558371/pexels-photo-18558371.jpeg"],["lud16","purepug2@primal.net"],["website","https://shoreline.decentnewsroom.com"],["nip05","shoreline@decentnewsroom.com"]],"content":"{\"display_name\":\"Shoreline Poetry\",\"name\":\"shoreline\",\"about\":\"Poems and fragments. Inner weather. Ceasing, breaking, flowing, and becoming something new.\",\"banner\":\"https://images.pexels.com/photos/18558371/pexels-photo-18558371.jpeg\",\"website\":\"https://shoreline.decentnewsroom.com\",\"nip05\":[\"shoreline@decentnewsroom.com\"],\"lud16\":\"purepug2@primal.net\",\"picture\":\"https://image.nostr.build/e98b3341636054b33ccf414390d75d02b721511d5e331ffcac63bacfa56fa1c4.jpg\"}","sig":"fe2a682f4cc407e6de07ff7e68309f757b3a63ba41851c21199ea71067aafd0a27dc874fc575104654d908cb65aba25dd060086c31723f496efb9271ee256560"}
""".trimIndent()
bothMappers(json) { label, event ->
val meta = event.contactMetaData()
assertIs<UserMetadata>(meta, label)
assertEquals("shoreline", meta.name, label)
assertEquals("Shoreline Poetry", meta.displayName, label)
assertEquals("shoreline@decentnewsroom.com", meta.nip05, "$label: the one-element array must be unwrapped")
assertEquals("purepug2@primal.net", meta.lud16, label)
assertEquals("https://shoreline.decentnewsroom.com", meta.website, label)
}
}
/**
* A profile dumped straight out of some client's internal state: `"nip05":{}`
* and `"custom_data":{}` plus keys that are not NIP-24 fields at all. The
* malformed nip05 is dropped, everything else survives.
*/
@Test
fun objectNip05AndForeignKeysDoNotDropTheProfile() {
val json =
"""
{"id":"dde6cd9661490879de9d5c1db12e1025ef6e429e7f6efb1021f1fda7fa1bc99d","pubkey":"85faee02e65460a46a5e8dbb47d46f0b1c02030aba3da437257e5759a00e0885","created_at":1710229789,"kind":0,"tags":[],"content":"{\"name\":\"krit\",\"about\":\"\",\"display_name\":\"krit\",\"picture\":\"\",\"pubkey\":\"npub1shawuqhx23s2g6j73ka504r0pvwqyqc2hg76gde90et4ngqwpzzsua9kwr\",\"pubkeyHex\":\"85faee02e65460a46a5e8dbb47d46f0b1c02030aba3da437257e5759a00e0885\",\"lastUpdatedAt\":0,\"nip05\":{},\"custom_data\":{},\"isLogin\":false}","sig":"df4ef440b80843e53b9b99e0ebbf1d5f22fbeb4cc8ce8993b3df42a16d96e06b74036f10f3cf62348a4f5124b91afc2ff7d82482289879994b30bb73138a92d9"}
""".trimIndent()
bothMappers(json) { label, event ->
val meta = event.contactMetaData()
assertIs<UserMetadata>(meta, label)
assertEquals("krit", meta.name, label)
assertEquals("krit", meta.displayName, label)
assertNull(meta.nip05, "$label: an object nip05 has no readable value")
// The empty strings the client wrote are normalized away on consumption.
meta.cleanBlankNames()
assertNull(meta.picture, label)
}
}
/**
* A Ditto profile: `"birthday":"10-24"` (NIP-24 defines an object), a `fields`
* array of arrays, and assorted client-private keys. Everything the app reads
* must survive; the birthday stays dropped because a bare `"10-24"` is
* ambiguous (MM-DD vs DD-MM) — see [BirthdayTolerantSerializer].
*/
@Test
fun stringBirthdayAndExtraFieldsDoNotDropTheProfile() {
val json =
"""
{"id":"ed269c23907649461da4b0fe109eed689ed1a562d33873b97ed01496dd02b87c","pubkey":"932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d","created_at":1779416127,"kind":0,"tags":[["client","Ditto","31990:781a1527055f74c1f70230f10384609b34548f8ab6a0a6caa74025827f9fdae5:ditto"],["published_at","1779044138"]],"content":"{\"about\":\"Team Soapbox. Freedom advocate.\",\"banner\":\"https://blossom.ditto.pub/ee0d.jpeg\",\"bot\":false,\"display_name\":\"MK Fain\",\"lud16\":\"mkfain@cash.app\",\"name\":\"MK Fain\",\"nip05\":\"mk@ditto.pub\",\"picture\":\"https://blossom.ditto.pub/cf2b.jpeg\",\"displayName\":\"MK Fain\",\"pubkey\":\"932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d\",\"npub\":\"npub1jvnpg4c6ljadf5t6ry0w9q0rnm4mksde87kglkrc993z46c39axsgq89sc\",\"created_at\":1718637715,\"fields\":[[\"Soapbox\",\"https://soapbox.pub\"],[\"Ditto FAQ\",\"https://ditto.pub/help\"]],\"birthday\":\"10-24\",\"showBirthday\":true,\"client\":\"divine.video\",\"shape\":\"x\"}","sig":"a78ed7b638eb240dbc81ea6565d5de4fb67d970fa391ea69b9757e1c6dfb448c54ea7d41f16802027135c1b6bd729e3889eb27065b432d42660cf1fb186fc8a0"}
""".trimIndent()
bothMappers(json) { label, event ->
val meta = event.contactMetaData()
assertIs<UserMetadata>(meta, label)
assertEquals("MK Fain", meta.name, label)
assertEquals("MK Fain", meta.displayName, label)
assertEquals("mk@ditto.pub", meta.nip05, label)
assertEquals("mkfain@cash.app", meta.lud16, label)
assertEquals(false, meta.bot, label)
assertEquals("Team Soapbox. Freedom advocate.", meta.about, label)
assertNull(meta.birthday, "$label: an ambiguous string birthday is dropped, not fatal")
}
}
}