From 0e1a14a62d7d53dbc140e83549555bbd09ea48d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 14:34:50 +0000 Subject: [PATCH] fix: don't drop whole kind-0 profiles over one mistyped field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clients in the wild publish structurally wrong values in profile metadata — e.g. "nip05":{} — and the strict field serializers made JsonMapper throw, so contactMetaData() returned null and the entire profile (name, picture, about…) was discarded. Generalize the BirthdayTolerantSerializer precedent: string fields now use TolerantStringSerializer (accepts any JSON primitive, ignores objects/arrays/null) and the bot flag uses TolerantBooleanSerializer, so a single malformed field is skipped instead of being fatal. Non-JSON content (e.g. "Relay initialized") remains unrecoverable and still parses to null; a test pins that it does so without throwing. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KSCGp6KgcnaepEMbxNroYe --- .../metadata/TolerantPrimitiveSerializers.kt | 118 ++++++++++++++++ .../quartz/nip01Core/metadata/UserMetadata.kt | 27 ++++ .../TolerantPrimitiveSerializersTest.kt | 132 ++++++++++++++++++ 3 files changed, 277 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/TolerantPrimitiveSerializers.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/TolerantPrimitiveSerializersTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/TolerantPrimitiveSerializers.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/TolerantPrimitiveSerializers.kt new file mode 100644 index 0000000000..728fb8aaf3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/TolerantPrimitiveSerializers.kt @@ -0,0 +1,118 @@ +/* + * 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.utils.Log +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.nullable +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull + +/** + * Tolerant serializer for kind-0 string fields. + * + * Some clients publish structurally wrong values for NIP-01/NIP-24 profile + * fields — seen in the wild: `"nip05":{}`. With the default serializer that + * type mismatch throws, and because [MetadataEvent.contactMetaData] turns any + * parse exception into `null`, one malformed field would discard the **entire** + * 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. + * + * Same rationale as [BirthdayTolerantSerializer]. + */ +object TolerantStringSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("com.vitorpamplona.quartz.nip01Core.metadata.TolerantString", PrimitiveKind.STRING).nullable + + override fun deserialize(decoder: Decoder): String? { + require(decoder is JsonDecoder) { "This serializer can only be used with Json format" } + + val element = decoder.decodeJsonElement() + return when { + element is JsonNull -> null + element is JsonPrimitive -> element.content + else -> { + // Log the JSON kind only, not the raw (untrusted, network-sourced) value. + Log.w("TolerantStringSerializer") { "Ignoring non-primitive string field (${element::class.simpleName})" } + null + } + } + } + + @OptIn(ExperimentalSerializationApi::class) + override fun serialize( + encoder: Encoder, + value: String?, + ) { + if (value == null) { + encoder.encodeNull() + } else { + encoder.encodeString(value) + } + } +} + +/** + * Tolerant serializer for the kind-0 `bot` flag: accepts `true`/`false` and their + * quoted string forms; anything else (objects, arrays, other strings, JSON null) + * is treated as absent rather than failing the whole profile parse. + */ +object TolerantBooleanSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("com.vitorpamplona.quartz.nip01Core.metadata.TolerantBoolean", PrimitiveKind.BOOLEAN).nullable + + override fun deserialize(decoder: Decoder): Boolean? { + require(decoder is JsonDecoder) { "This serializer can only be used with Json format" } + + val element = decoder.decodeJsonElement() + if (element is JsonPrimitive && element !is JsonNull) { + val parsed = element.booleanOrNull + if (parsed != null) return parsed + } + if (element !is JsonNull) { + Log.w("TolerantBooleanSerializer") { "Ignoring non-boolean bot field (${element::class.simpleName})" } + } + return null + } + + @OptIn(ExperimentalSerializationApi::class) + override fun serialize( + encoder: Encoder, + value: Boolean?, + ) { + if (value == null) { + encoder.encodeNull() + } else { + encoder.encodeBoolean(value) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/UserMetadata.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/UserMetadata.kt index b902f1604b..fc59e9540f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/UserMetadata.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/UserMetadata.kt @@ -35,31 +35,58 @@ class Birthday { var day: Int? = null } +// Every field uses a tolerant serializer: clients in the wild publish +// structurally wrong values (e.g. "nip05":{}, birthday as a string), and one +// bad field must not discard the whole profile — see TolerantStringSerializer. @Stable @Serializable class UserMetadata { + @Serializable(with = TolerantStringSerializer::class) var name: String? = null @SerialName("display_name") + @Serializable(with = TolerantStringSerializer::class) var displayName: String? = null + + @Serializable(with = TolerantStringSerializer::class) var picture: String? = null + + @Serializable(with = TolerantStringSerializer::class) var banner: String? = null + + @Serializable(with = TolerantStringSerializer::class) var website: String? = null + + @Serializable(with = TolerantStringSerializer::class) var about: String? = null + + @Serializable(with = TolerantBooleanSerializer::class) var bot: Boolean? = null + + @Serializable(with = TolerantStringSerializer::class) var pronouns: String? = null @Serializable(with = BirthdayTolerantSerializer::class) var birthday: Birthday? = null + + @Serializable(with = TolerantStringSerializer::class) var nip05: String? = null + + @Serializable(with = TolerantStringSerializer::class) var domain: String? = null + + @Serializable(with = TolerantStringSerializer::class) var lud06: String? = null + + @Serializable(with = TolerantStringSerializer::class) var lud16: String? = null /** CLINK Offers pointer (`noffer1…`) the user advertises to receive payments over Nostr. */ @SerialName("clink_offer") + @Serializable(with = TolerantStringSerializer::class) var clinkOffer: String? = null + @Serializable(with = TolerantStringSerializer::class) var twitter: String? = null fun anyName(): String? = displayName ?: name diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/TolerantPrimitiveSerializersTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/TolerantPrimitiveSerializersTest.kt new file mode 100644 index 0000000000..84ba9d9c72 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/TolerantPrimitiveSerializersTest.kt @@ -0,0 +1,132 @@ +/* + * 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.core.JsonMapper +import com.vitorpamplona.quartz.utils.EventFactory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class TolerantPrimitiveSerializersTest { + private fun metaWith(content: String): MetadataEvent = + EventFactory.create( + id = "ed269c23907649461da4b0fe109eed689ed1a562d33873b97ed01496dd02b87c", + pubKey = "932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d", + createdAt = 1L, + kind = MetadataEvent.KIND, + tags = emptyArray(), + content = content, + sig = "00".repeat(64), + ) as MetadataEvent + + /** + * Regression for profiles seen in the wild that publish `"nip05":{}` + * (an empty object where NIP-05 mandates a string). Before the tolerant + * serializer this threw and [MetadataEvent.contactMetaData] returned null, + * dropping the whole profile. + */ + @Test + fun objectNip05DoesNotDropTheProfile() { + val meta = + metaWith( + """{"name":"alice","about":"welcome to follow me","picture":"https://example.com/a.jpg","nip05":{},"lud06":null,"lud16":null}""", + ).contactMetaData() + + assertIs(meta, "profile must still parse despite the malformed nip05") + assertEquals("alice", meta.name) + assertEquals("welcome to follow me", meta.about) + assertEquals("https://example.com/a.jpg", meta.picture) + assertNull(meta.nip05, "non-string nip05 must be ignored, not fatal") + assertNull(meta.lud06) + assertNull(meta.lud16) + } + + @Test + fun otherNonStringValuesAreIgnoredFieldByField() { + val meta = + metaWith( + """{"name":{"first":"A"},"display_name":["A"],"picture":null,"about":"bio","website":{}}""", + ).contactMetaData() + + assertIs(meta) + assertNull(meta.name) + assertNull(meta.displayName) + assertNull(meta.picture) + assertNull(meta.website) + assertEquals("bio", meta.about, "well-formed fields must survive the malformed ones") + } + + /** Pre-existing lenient behavior: bare primitives still decode into string fields. */ + @Test + fun numberAndBooleanPrimitivesStillDecodeIntoStringFields() { + val meta = metaWith("""{"name":123,"about":true}""").contactMetaData() + + assertIs(meta) + assertEquals("123", meta.name) + assertEquals("true", meta.about) + } + + @Test + fun malformedBotFlagIsIgnored() { + listOf( + """{"name":"A","bot":{}}""", + """{"name":"A","bot":[true]}""", + """{"name":"A","bot":"maybe"}""", + """{"name":"A","bot":null}""", + ).forEach { json -> + val meta = metaWith(json).contactMetaData() + assertIs(meta, "profile must survive bot field in $json") + assertEquals("A", meta.name) + assertNull(meta.bot) + } + } + + @Test + fun stringBotFlagStillDecodes() { + val meta = metaWith("""{"name":"A","bot":"true"}""").contactMetaData() + assertIs(meta) + assertEquals(true, meta.bot) + } + + /** + * Content that is not JSON at all (some relays hand back things like + * "Relay initialized") cannot be salvaged: contactMetaData must return + * null without throwing. + */ + @Test + fun nonJsonContentReturnsNull() { + assertNull(metaWith("Relay initialized").contactMetaData()) + assertNull(metaWith("").contactMetaData()) + } + + /** Dropped fields must not leak back into the serialized profile as nulls. */ + @Test + fun droppedFieldsAreOmittedOnSerialization() { + val meta = metaWith("""{"name":"A","nip05":{}}""").contactMetaData() + assertIs(meta) + val serialized = JsonMapper.toJson(meta) + assertTrue("nip05" !in serialized, "a dropped nip05 should not be serialized back out: $serialized") + assertTrue("\"name\":\"A\"" in serialized, "valid fields must round-trip: $serialized") + } +}