fix(quartz): NIP-62 vanish compares relay urls as NormalizedRelayUrl

RelayTag.shouldVanishFrom compared the tag's url to the relay's normalized
url as a string, so a vanish naming `wss://relay.example` never matched the
relay whose normalized url is `wss://relay.example/`. The request was
accepted with OK true and deleted nothing; the author could republish the
"vanished" events. That is the form most clients write.

The tag value is now normalized and compared as a NormalizedRelayUrl, with
the everywhere marker and an already-normalized tag kept as fast paths.

Found by an over-the-wire contract test of a relay (vespa-relay) built on
quartz's relay server: `["relay","ws://localhost:7777"]` vanished nothing,
`["relay","ws://localhost:7777/"]` worked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rtkpq5k2zy2hGP44kXLtq9
This commit is contained in:
Claude
2026-09-28 02:15:01 +00:00
parent 77cb10ecc2
commit 749fab45f5
2 changed files with 87 additions and 1 deletions
@@ -37,10 +37,23 @@ class RelayTag {
fun shouldVanishFromEverywhere(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == EVERYWHERE
/**
* Whether this tag asks [relay] to vanish the author. The tag carries whatever url the
* client typed, so it is compared as a [NormalizedRelayUrl], not as a string: a
* `wss://relay.example` tag must reach the relay whose normalized url is
* `wss://relay.example/`. A raw string match quietly accepted the request and deleted
* nothing.
*/
fun shouldVanishFrom(
tag: Array<String>,
relay: NormalizedRelayUrl,
) = tag.has(1) && tag[0] == TAG_NAME && (tag[1] == relay.url || tag[1] == EVERYWHERE)
): Boolean {
if (!tag.has(1) || tag[0] != TAG_NAME) return false
val value = tag[1]
// Fast paths first: the everywhere marker, and a tag already in normalized form.
if (value == EVERYWHERE || value == relay.url) return true
return RelayUrlNormalizer.normalizeOrNull(value) == relay
}
fun parse(tag: Array<String>): NormalizedRelayUrl? {
ensure(tag.has(1)) { return null }
@@ -0,0 +1,73 @@
/*
* 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.nip62RequestToVanish.tags
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class RelayTagTest {
private val relay = RelayUrlNormalizer.normalize("wss://relay.example.com")
private fun tag(url: String) = arrayOf(RelayTag.TAG_NAME, url)
@Test
fun matchesTheNormalizedForm() {
assertTrue(RelayTag.shouldVanishFrom(tag(relay.url), relay))
}
@Test
fun matchesTheFormAClientTypes() {
// The form most clients write: no trailing slash. It used to be accepted and ignored.
assertTrue(RelayTag.shouldVanishFrom(tag("wss://relay.example.com"), relay))
assertTrue(RelayTag.shouldVanishFrom(tag("wss://Relay.Example.com/"), relay))
assertTrue(RelayTag.shouldVanishFrom(tag("relay.example.com"), relay))
}
@Test
fun matchesALocalRelay() {
val local = RelayUrlNormalizer.normalize("ws://localhost:7777")
assertTrue(RelayTag.shouldVanishFrom(tag("ws://localhost:7777"), local))
assertTrue(RelayTag.shouldVanishFrom(tag("ws://localhost:7777/"), local))
}
@Test
fun matchesEverywhere() {
assertTrue(RelayTag.shouldVanishFrom(tag(RelayTag.EVERYWHERE), relay))
}
@Test
fun ignoresOtherRelays() {
assertFalse(RelayTag.shouldVanishFrom(tag("wss://other.example.com"), relay))
assertFalse(RelayTag.shouldVanishFrom(tag("wss://relay.example.com/other"), relay))
assertFalse(RelayTag.shouldVanishFrom(tag("not a url"), relay))
assertFalse(RelayTag.shouldVanishFrom(arrayOf("r", relay.url), relay))
assertFalse(RelayTag.shouldVanishFrom(arrayOf(RelayTag.TAG_NAME), relay))
}
@Test
fun theEventLevelCheckUsesTheSameRule() {
val tags = arrayOf(arrayOf("p", "a".repeat(64)), tag("wss://relay.example.com"))
assertTrue(tags.shouldVanishFrom(relay))
assertFalse(tags.shouldVanishFrom(RelayUrlNormalizer.normalize("wss://other.example.com")))
}
}