Merge pull request #3788 from vitorpamplona/claude/nip29-wisp-relay-test-dloy9s

Support NIP-29 group relays in first-party AUTH logic
This commit is contained in:
Vitor Pamplona
2026-07-28 20:38:50 -04:00
committed by GitHub
3 changed files with 35 additions and 2 deletions
@@ -221,6 +221,13 @@ class AuthCoordinator(
relayUrl = relayUrl,
pendingEvents = client.activeOutboxEvents(relayUrl),
myRelays = account.trustedRelays.flow.value,
// A NIP-29 relay group the user explicitly joined (kind-10009) is a first-party reason
// to authenticate with its host relay: private/closed group content is `#h`-scoped and
// never names the user, so it fails the pubkey checks above — without this, a joined
// private group's messages are refused with `auth-required` and the group stays empty.
myGroupRelays =
account.relayGroupList.liveRelayGroupIds.value
.mapTo(mutableSetOf()) { it.relayUrl },
)
fun destroy() {
@@ -34,7 +34,14 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
* - it is publishing its own event there ([pendingEvents] authored by it — e.g. delivering a DM to
* the recipient's inbox relay), or
* - the relay is one it configured itself ([myRelays] — its NIP-65 / DM / search / … lists, which
* is where its own inbox/outbox reads are routed anyway).
* is where its own inbox/outbox reads are routed anyway), or
* - the relay hosts a NIP-29 relay group the account explicitly joined ([myGroupRelays], from its
* kind-10009 list). A private/closed group's content (kind-9 chat, kind-11 threads, …) is
* `#h`-scoped and served only to authenticated members; that read never names the user, so it
* can't qualify via [pendingEvents], and a group host relay is not one of the account's own
* NIP-65/DM/… lists, so it can't qualify via [myRelays] either. Without this, a joined private
* group is refused with `auth-required` and renders empty — the group's own metadata (39000) is
* public and still loads, so the group appears but shows no messages.
*
* Crucially, an active subscription merely *naming* the account (a `#p` tag or `authors` entry) is
* NOT a first-party reason: the app packs several accounts' pubkeys into one merged filter and fans
@@ -49,8 +56,10 @@ object RelayAuthFirstParty {
relayUrl: NormalizedRelayUrl,
pendingEvents: List<Event>,
myRelays: Set<NormalizedRelayUrl>,
myGroupRelays: Set<NormalizedRelayUrl> = emptySet(),
): Boolean {
if (pendingEvents.any { it.pubKey == me }) return true
return relayUrl in myRelays
if (relayUrl in myRelays) return true
return relayUrl in myGroupRelays
}
}
@@ -71,4 +71,21 @@ class RelayAuthFirstPartyTest {
// Delivering my own DM/post to the recipient's relay, even one not in my list.
assertTrue(RelayAuthFirstParty.hasReason(me, relay, listOf(event(me)), emptySet()))
}
@Test
fun aRelayHostingAJoinedGroupIsFirstParty() {
// A NIP-29 group host relay is in none of my NIP-65/DM/… lists, and a private group's content
// is `#h`-scoped so it never names me — the joined-group set is the only signal that lets us
// AUTH so the relay serves the group's `auth-required` content instead of leaving it empty.
val groupRelay = NormalizedRelayUrl("wss://chat.wisp.talk/")
assertTrue(RelayAuthFirstParty.hasReason(me, groupRelay, emptyList(), emptySet(), setOf(groupRelay)))
}
@Test
fun aGroupRelayIHaveNotJoinedIsNotFirstParty() {
// Merely knowing a group relay exists (e.g. browsing its public directory) must not AUTH it;
// only a group on my own kind-10009 list counts.
val groupRelay = NormalizedRelayUrl("wss://chat.wisp.talk/")
assertFalse(RelayAuthFirstParty.hasReason(me, groupRelay, emptyList(), emptySet(), emptySet()))
}
}