diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt index 8a2f1eb445..acc92802f0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt @@ -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(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(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) 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 index 728fb8aaf3..3fee214987 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/TolerantPrimitiveSerializers.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/TolerantPrimitiveSerializers.kt @@ -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 { 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})" } 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 index 84ba9d9c72..c216ac207c 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/TolerantPrimitiveSerializersTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/TolerantPrimitiveSerializersTest.kt @@ -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(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(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(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(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. */ diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEventWildProfilesTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEventWildProfilesTest.kt new file mode 100644 index 0000000000..811e20b096 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEventWildProfilesTest.kt @@ -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(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(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(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(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") + } + } +}