From 7d1caf590a496ac629cd896cf6080dd8b7d5eba7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 23:45:49 +0000 Subject: [PATCH] fix: authenticate to NIP-29 host relays of joined groups so private group content loads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_018puT2YnevbLHYG2PdCLAeh --- .../authCommand/model/AuthCoordinator.kt | 7 +++++++ .../authCommand/model/RelayAuthFirstParty.kt | 13 +++++++++++-- .../model/RelayAuthFirstPartyTest.kt | 17 +++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt index 89e5d31506..fed6d37f04 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt @@ -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() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthFirstParty.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthFirstParty.kt index ac6bca8ffe..c44c506364 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthFirstParty.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthFirstParty.kt @@ -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, myRelays: Set, + myGroupRelays: Set = emptySet(), ): Boolean { if (pendingEvents.any { it.pubKey == me }) return true - return relayUrl in myRelays + if (relayUrl in myRelays) return true + return relayUrl in myGroupRelays } } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthFirstPartyTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthFirstPartyTest.kt index 817fb6defd..0f248049ee 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthFirstPartyTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthFirstPartyTest.kt @@ -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())) + } }