fix: authenticate to NIP-29 host relays of joined groups so private group content loads

A private/closed NIP-29 group serves its content (kind-9 chat, kind-11
threads, …) only over a NIP-42-authenticated connection. Amethyst's
group-content reads are all `#h`-scoped with all authors, so they never
name the user's pubkey, and a group host relay (e.g. wss://chat.wisp.talk)
is not in any of the NIP-65/DM/search/… lists that feed
`account.trustedRelays`. The per-account first-party AUTH gate therefore
returned false and Amethyst never AUTHed, so the relay refused the content
with `auth-required` — the group's public 39000 metadata still loaded, so
the group appeared but showed no messages.

Treat a NIP-29 relay group the user explicitly joined (their kind-10009
list) as a first-party reason to authenticate with its host relay,
mirroring the existing `BuzzWorkspaces.isJoined` carve-out. Reads of a
group the user has not joined (e.g. browsing a relay's public directory)
still do not AUTH.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018puT2YnevbLHYG2PdCLAeh
This commit is contained in:
Claude
2026-07-28 23:45:49 +00:00
parent 26e09339b5
commit 7d1caf590a
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()))
}
}