fix(namecoin): accept single-identity {nostr:{pubkey,relays}} on d/ names

ifa-0001 doesn't mandate that domain records use the nostr.names
sub-dictionary. Operators who own a name outright commonly publish:

    {"nostr": {"pubkey": "<hex>", "relays": ["wss://..."]}}

(the same shape id/ records use). Before this fix d/-namespace branch
only accepted {"nostr":"<hex>"} or {"nostr":{"names":{...}}},
so a record like d/mstrofnone with the single-identity object form
silently failed with NoNostrField even though id/mstrofnone resolved
fine.

Resolution rules:
  1. nostr.names wins for any sub-identity.
  2. Root lookups fall back to bare pubkey when names["_"] is absent.
  3. Non-root lookups against names-only or single-identity records
     do NOT silently use the bare pubkey.
This commit is contained in:
m
2026-05-08 16:36:04 +10:00
parent 5efead42fb
commit 23a7ac4770
@@ -281,8 +281,16 @@ class NamecoinNameResolver(
* Extract Nostr data from a `d/` domain value.
*
* Supports:
* { "nostr": "hex-pubkey" } → simple form
* { "nostr": "hex-pubkey" } → simple-string form
* { "nostr": { "names": { "alice": "hex" }, ... } } → extended NIP-05-like form
* { "nostr": { "pubkey": "hex", "relays": [...] } } → single-identity form
*
* The single-identity form is the same shape used by `id/` records and is
* the natural way to express "this one name = this one pubkey". It only
* resolves the root local-part (`_`) — there are no sub-identities in
* this shape. If `nostr.names` is also present, it wins for any
* sub-identity; root-lookups fall back to the bare `pubkey` when
* `names["_"]` is missing.
*/
private fun extractFromDomainValue(
value: JsonObject,
@@ -290,7 +298,7 @@ class NamecoinNameResolver(
): NamecoinNostrResult? {
val nostrField = value["nostr"] ?: return null
// Simple form: "nostr": "hex-pubkey"
// Simple-string form: "nostr": "hex-pubkey"
if (nostrField is JsonPrimitive && nostrField.isString) {
val pubkey = nostrField.content
if (parsed.localPart == "_" && isValidPubkey(pubkey)) {
@@ -305,48 +313,73 @@ class NamecoinNameResolver(
if (parsed.localPart != "_") return null
}
// Extended form: "nostr": { "names": {...}, "relays": {...} }
if (nostrField is JsonObject) {
val names = nostrField["names"]?.jsonObject ?: return null
// Extended NIP-05-like form first: "nostr": { "names": {...}, "relays": {...} }
val names = nostrField["names"]?.jsonObject
// Resolve: exact match → "_" root → first entry (root lookups only)
val resolvedLocalPart: String
val pubkey: String
if (names != null) {
val exactMatch = names[parsed.localPart]
val rootMatch = names["_"]
val firstEntry = if (parsed.localPart == "_") names.entries.firstOrNull() else null
val exactMatch = names[parsed.localPart]
val rootMatch = names["_"]
val firstEntry = if (parsed.localPart == "_") names.entries.firstOrNull() else null
when {
exactMatch is JsonPrimitive && isValidPubkey(exactMatch.content) -> {
resolvedLocalPart = parsed.localPart
pubkey = exactMatch.content
if (exactMatch is JsonPrimitive && isValidPubkey(exactMatch.content)) {
return NamecoinNostrResult(
pubkey = exactMatch.content.lowercase(),
relays = extractRelays(nostrField, exactMatch.content),
namecoinName = parsed.namecoinName,
localPart = parsed.localPart,
)
}
rootMatch is JsonPrimitive && isValidPubkey(rootMatch.content) -> {
resolvedLocalPart = "_"
pubkey = rootMatch.content
if (rootMatch is JsonPrimitive && isValidPubkey(rootMatch.content)) {
return NamecoinNostrResult(
pubkey = rootMatch.content.lowercase(),
relays = extractRelays(nostrField, rootMatch.content),
namecoinName = parsed.namecoinName,
localPart = "_",
)
}
firstEntry != null &&
if (firstEntry != null &&
firstEntry.value is JsonPrimitive &&
isValidPubkey((firstEntry.value as JsonPrimitive).content) -> {
resolvedLocalPart = firstEntry.key
pubkey = (firstEntry.value as JsonPrimitive).content
}
else -> {
return null
isValidPubkey((firstEntry.value as JsonPrimitive).content)
) {
val pk = (firstEntry.value as JsonPrimitive).content
return NamecoinNostrResult(
pubkey = pk.lowercase(),
relays = extractRelays(nostrField, pk),
namecoinName = parsed.namecoinName,
localPart = firstEntry.key,
)
}
// names was present but didn't yield a match. Fall through to
// the single-identity check below — only meaningful for root
// lookups (non-root requests against a names-only record
// correctly stop here).
if (parsed.localPart != "_") return null
}
val relays = extractRelays(nostrField, pubkey)
return NamecoinNostrResult(
pubkey = pubkey.lowercase(),
relays = relays,
namecoinName = parsed.namecoinName,
localPart = resolvedLocalPart,
)
// Single-identity form: "nostr": { "pubkey": "hex", "relays": [...] }
// Only resolves the root local-part; non-root requests against a
// single-identity record fall through to null so we don't hand
// alice@example.bit the root operator's identity.
if (parsed.localPart == "_") {
val pubkey = (nostrField["pubkey"] as? JsonPrimitive)?.content
if (pubkey != null && isValidPubkey(pubkey)) {
val relays =
try {
nostrField["relays"]?.jsonArray?.mapNotNull {
(it as? JsonPrimitive)?.content
} ?: emptyList()
} catch (_: Exception) {
emptyList()
}
return NamecoinNostrResult(
pubkey = pubkey.lowercase(),
relays = relays,
namecoinName = parsed.namecoinName,
localPart = "_",
)
}
}
}
return null