diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt index d06860e1a..d60b53446 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt @@ -201,6 +201,13 @@ fun SearchScreen( NamecoinResolveState.Error("Name exists but has no Nostr pubkey") } + is NamecoinResolveOutcome.MalformedRecord -> { + // Surface the parser detail verbatim so the publisher + // of the broken record can locate the bad byte + // (kotlinx.serialization includes a column number). + NamecoinResolveState.Error("Namecoin record JSON is malformed: ${outcome.error}") + } + is NamecoinResolveOutcome.ServersUnreachable -> { NamecoinResolveState.Error("ElectrumX servers unreachable — check your connection or try again") } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/NamecoinNameResolver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/NamecoinNameResolver.kt index 537ee9cb8..f55f4f28f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/NamecoinNameResolver.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/NamecoinNameResolver.kt @@ -43,6 +43,20 @@ sealed class NamecoinResolveOutcome { val name: String, ) : NamecoinResolveOutcome() + /** + * The name exists but its value is not parseable as a Namecoin Domain + * Name Object (i.e. valid JSON of the right shape). Distinct from + * [NoNostrField], which means valid JSON but no `nostr` data. + * + * `error` is the underlying parser message (kotlinx.serialization); + * useful for surfacing back to the publisher of the broken record + * (e.g. "Unfinished JSON term at EOF at line 1, column 474"). + */ + data class MalformedRecord( + val name: String, + val error: String, + ) : NamecoinResolveOutcome() + /** All ElectrumX servers were unreachable. */ data class ServersUnreachable( val message: String, @@ -234,9 +248,23 @@ class NamecoinNameResolver( ) } + // Distinguish "valid JSON, no `nostr` field" from "value is + // unparseable JSON". Both used to collapse into NoNostrField, which + // sent operators chasing a missing field that wasn't actually the + // problem (the Namecoin record itself was malformed). Surfacing + // the parser's column number lets a publisher see WHERE the value + // is bad without spelunking through the whole serialised form. val valueJson = - tryParseJson(nameResult.value) - ?: return NamecoinResolveOutcome.NoNostrField(parsed.namecoinName) + parseValueOrError(nameResult.value) + .fold( + onSuccess = { it }, + onFailure = { err -> + return NamecoinResolveOutcome.MalformedRecord( + name = parsed.namecoinName, + error = err.message ?: "unparseable JSON value", + ) + }, + ) val merged = expandImportsIfPresent(valueJson) val nostrResult = @@ -473,5 +501,25 @@ class NamecoinNameResolver( null } + /** + * Parse [raw] into a [JsonObject], surfacing the underlying parser + * error message instead of swallowing it. The result is used by + * [performLookupDetailed] to distinguish [NamecoinResolveOutcome.MalformedRecord] + * (the value itself is broken) from [NamecoinResolveOutcome.NoNostrField] + * (valid JSON, just missing the `nostr` block). + * + * Returns [Result.failure] with the parser's diagnostic message; the + * caller is expected to forward that text into a UI / log surface so + * the publisher of the broken record can fix it. + */ + private fun parseValueOrError(raw: String): Result = + runCatching { + val element = json.parseToJsonElement(raw) + element as? JsonObject + ?: throw IllegalArgumentException( + "top-level value is ${element::class.simpleName}, expected JSON object", + ) + } + private fun isValidPubkey(s: String): Boolean = HEX_PUBKEY_REGEX.matches(s) } diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinImportTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinImportTest.kt index 0edfb8723..0bdfb4c4f 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinImportTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinImportTest.kt @@ -502,6 +502,62 @@ class NamecoinImportTest { ) } + @Test + fun `NIP-05 lookup surfaces MalformedRecord with parser detail when value is broken JSON`() = + runTest { + // Real-world failure mode: a hand-built `name_update` with one + // closing brace short of balanced ends up published as broken + // JSON. The publisher then sees "name not found / no nostr + // field" and chases a phantom bug. Surface the parser's + // diagnostic so they know the value itself is the problem. + val truncated = + """{"ip":"1.2.3.4","nostr":{"names":{"_":"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"}}""".trimIndent() + val client = + FakeElectrumXClient().apply { + register("d/broken", truncated) + } + val resolver = NamecoinNameResolver(client, lookupTimeoutMs = 1_000L) + val outcome = resolver.resolveDetailed("_@broken.bit") + assertTrue( + "expected MalformedRecord, got $outcome", + outcome is NamecoinResolveOutcome.MalformedRecord, + ) + outcome as NamecoinResolveOutcome.MalformedRecord + assertEquals("d/broken", outcome.name) + // Don't pin the exact parser text — different kotlinx.serialization + // versions phrase it differently. Just require it's a non-empty + // diagnostic. + assertTrue( + "error must be a non-empty diagnostic: ${outcome.error}", + outcome.error.isNotBlank(), + ) + } + + @Test + fun `NIP-05 lookup surfaces MalformedRecord when top-level value is a JSON array`() = + runTest { + // Top-level non-object values (arrays, primitives, null) are + // not valid Domain Name Objects per ifa-0001. They should be + // rejected with a useful diagnostic, not collapsed into + // NoNostrField (which implies the JSON parsed fine). + val client = + FakeElectrumXClient().apply { + register("d/arr", """["not","an","object"]""") + } + val resolver = NamecoinNameResolver(client, lookupTimeoutMs = 1_000L) + val outcome = resolver.resolveDetailed("_@arr.bit") + assertTrue( + "expected MalformedRecord, got $outcome", + outcome is NamecoinResolveOutcome.MalformedRecord, + ) + outcome as NamecoinResolveOutcome.MalformedRecord + assertEquals("d/arr", outcome.name) + assertTrue( + "error must mention the unexpected top-level shape: ${outcome.error}", + outcome.error.contains("expected JSON object", ignoreCase = true), + ) + } + // ── Helpers ────────────────────────────────────────────────────────── private fun parse(s: String): JsonObject = json.parseToJsonElement(s).jsonObject