From 3383b60315d20efce4d349cfca45ccd6f0926da2 Mon Sep 17 00:00:00 2001 From: m Date: Sat, 9 May 2026 10:02:26 +1000 Subject: [PATCH] feat(namecoin): distinguish malformed-JSON values from missing-field When a Namecoin record's value isn't valid JSON (a real failure mode when an operator hand-builds the value and miscounts braces), the NIP-05 path used to silently swallow the parser exception and surface a misleading "no nostr field" message. That sends the publisher chasing a phantom missing field when the actual problem is the value itself. Concrete case that triggered this: a `name_update` published a 474-byte d/testls value with one closing brace short of balanced. The string parses up to the missing brace, after which kotlinx.serialization throws "Unfinished JSON term at EOF at line 1, column 474". That error was previously dropped, leaving the operator to debug "no nostr field" without ever seeing the underlying JSON parse failure. Changes: - New NamecoinResolveOutcome.MalformedRecord(name, error). Distinct from NoNostrField. The `error` field is the parser's own diagnostic (e.g. "Unfinished JSON term at EOF at line 1, column 474") so the publisher can locate the broken byte without spelunking. - NamecoinNameResolver.performLookupDetailed: parse via a new parseValueOrError helper and surface MalformedRecord instead of collapsing into NoNostrField. Also rejects non-object top-level values (arrays, primitives, null) with a useful diagnostic ("top-level value is JsonArray, expected JSON object"). - DesktopSearchScreen handles the new outcome by surfacing the parser error verbatim in the Namecoin status banner, so the column number reaches the publisher's screen. Tests (commonTest / NamecoinImportTest): - "NIP-05 lookup surfaces MalformedRecord with parser detail when value is broken JSON": a deliberately one-brace-short value yields MalformedRecord with a non-empty diagnostic. - "NIP-05 lookup surfaces MalformedRecord when top-level value is a JSON array": ensures non-object top-level values are rejected with a useful "expected JSON object" message rather than silently parsing as something unusable. Tests don't pin the exact parser wording (kotlinx.serialization can change it across versions); they only pin that the message is attributed to JSON parsing rather than to a missing field. --- .../amethyst/desktop/ui/SearchScreen.kt | 7 +++ .../namecoin/NamecoinNameResolver.kt | 52 ++++++++++++++++- .../nip05/namecoin/NamecoinImportTest.kt | 56 +++++++++++++++++++ 3 files changed, 113 insertions(+), 2 deletions(-) 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