From 3526a14ebef8e6f2c2b2798b3705acf51559a86b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 15:15:34 +0000 Subject: [PATCH] feat(relayauth): venue-aware auth for public chats, communities & live streams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The purpose model was person-centric, so an auth-required NIP-28 channel, NIP-72 community, or NIP-53 live stream broke: reads filter by #e/#a with no authors, so nothing was attributable -> silent DENY (chat wouldn't load); top-level posts (no p tags) also silently failed; replies were mis-attributed as "notify" and trusted on the wrong signal. Add venue purposes (POST_VENUE / READ_VENUE) carrying the venue id (channel event-id, or community/live `kind:pubkey:dTag` address). The deriver recognizes kind-42 channel posts (root `e`), community/live posts and reads (`#a` 34550/30311), and channel reads (`#e`). Venues are trusted under TRUSTED_FOLLOWS when you've joined them (publicChatList / communityList) or their owner — the pubkey in the address, e.g. a live stream's host — is someone you follow; trusted venues auto-auth for both reading and posting. Safety net: any active use we still can't attribute yields an OTHER purpose so the relay is prompted about instead of failing silently. Prompt copy gains venue-aware titles/consequences ("Post to this room?", "If you don't, your message won't be posted."). Default policy already TRUSTED_FOLLOWS, so joined venues just work. Unit-tested across resolver and deriver. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a --- .../compose/AccountDataSourceSubscription.kt | 10 +++ .../compose/RelayAuthPromptHost.kt | 20 +++++- .../model/RelayAuthPermissionLedger.kt | 13 +++- .../model/RelayAuthPurposeDeriver.kt | 65 +++++++++++++++---- .../relayauth/RelayAuthSettingsScreen.kt | 3 + amethyst/src/main/res/values/strings.xml | 7 ++ .../model/RelayAuthPurposeDeriverTest.kt | 44 ++++++++++++- .../amethyst/commons/relayauth/AuthPurpose.kt | 16 ++++- .../commons/relayauth/RelayAuthResolver.kt | 10 ++- .../relayauth/RelayAuthResolverTest.kt | 9 +++ 10 files changed, 174 insertions(+), 23 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/AccountDataSourceSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/AccountDataSourceSubscription.kt index 0399b46634..3c5a8c622b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/AccountDataSourceSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/AccountDataSourceSubscription.kt @@ -30,6 +30,9 @@ import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.ScreenAu import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull +/** The owner pubkey of an addressable venue (`kind:pubkey:dTag`), or null for a bare channel id. */ +private fun venueOwnerPubkey(venueId: String): String? = venueId.split(':').getOrNull(1)?.takeIf { it.length == 64 } + @Composable fun RelayAuthSubscription(accountViewModel: AccountViewModel) = RelayAuthSubscription(accountViewModel, Amethyst.instance.authCoordinator) @@ -61,6 +64,13 @@ fun RelayAuthSubscription( // Any follow list (kind 3, follow sets, etc.) counts as trusting the counterparty // enough to reveal our identity to a relay that serves them. isFollowed = { pubkey -> pubkey in account.allFollows.flow.value.authors }, + // A venue (public chat / community / live stream) is trusted if we've joined it, or + // its owner — the pubkey in a `kind:pubkey:dTag` address — is someone we follow. + isTrustedVenue = { venueId -> + venueId in account.publicChatList.flowSet.value || + venueId in account.communityList.flowSet.value || + venueOwnerPubkey(venueId)?.let { it in account.allFollows.flow.value.authors } == true + }, readTrustEnabled = { account.settings.relayAuthTrustFollowsForReads.value }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/RelayAuthPromptHost.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/RelayAuthPromptHost.kt index 63fa8347a9..ff2edc8014 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/RelayAuthPromptHost.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/RelayAuthPromptHost.kt @@ -265,13 +265,23 @@ private fun reasonRes(kind: AuthPurposeKind): Int = AuthPurposeKind.SEND_DM -> R.string.relay_auth_reason_send_dm AuthPurposeKind.NOTIFY_INBOX -> R.string.relay_auth_reason_notify_inbox AuthPurposeKind.READ_OUTBOX -> R.string.relay_auth_reason_read_outbox + AuthPurposeKind.POST_VENUE -> R.string.relay_auth_reason_post_venue + AuthPurposeKind.READ_VENUE -> R.string.relay_auth_reason_read_venue AuthPurposeKind.MY_OWN_RELAY -> R.string.relay_auth_reason_my_own_relay + AuthPurposeKind.OTHER -> R.string.relay_auth_reason_other } -/** The purpose whose counterparties best describe what the user was doing (most user-facing first). */ +/** The purpose that best describes what the user was doing (most user-facing first). */ private fun List.primaryNamed(): AuthPurpose? = - listOf(AuthPurposeKind.SEND_DM, AuthPurposeKind.NOTIFY_INBOX, AuthPurposeKind.READ_OUTBOX) - .firstNotNullOfOrNull { kind -> firstOrNull { it.kind == kind && it.counterparties.isNotEmpty() } } + listOf( + AuthPurposeKind.SEND_DM, + AuthPurposeKind.NOTIFY_INBOX, + AuthPurposeKind.POST_VENUE, + AuthPurposeKind.READ_OUTBOX, + AuthPurposeKind.READ_VENUE, + ).firstNotNullOfOrNull { kind -> + firstOrNull { it.kind == kind && (it.counterparties.isNotEmpty() || it.venues.isNotEmpty()) } + } @Composable private fun titleFor( @@ -282,6 +292,8 @@ private fun titleFor( AuthPurposeKind.SEND_DM -> stringRes(R.string.relay_auth_title_send_dm, who ?: "") AuthPurposeKind.NOTIFY_INBOX -> stringRes(R.string.relay_auth_title_notify, who ?: "") AuthPurposeKind.READ_OUTBOX -> stringRes(R.string.relay_auth_title_read, who ?: "") + AuthPurposeKind.POST_VENUE -> stringRes(R.string.relay_auth_title_post_venue) + AuthPurposeKind.READ_VENUE -> stringRes(R.string.relay_auth_title_read_venue) else -> stringRes(R.string.relay_auth_prompt_title) } @@ -294,6 +306,8 @@ private fun consequenceFor( AuthPurposeKind.SEND_DM -> stringRes(R.string.relay_auth_consequence_send_dm, who ?: "") AuthPurposeKind.NOTIFY_INBOX -> stringRes(R.string.relay_auth_consequence_notify, who ?: "") AuthPurposeKind.READ_OUTBOX -> stringRes(R.string.relay_auth_consequence_read, who ?: "") + AuthPurposeKind.POST_VENUE -> stringRes(R.string.relay_auth_consequence_post_venue) + AuthPurposeKind.READ_VENUE -> stringRes(R.string.relay_auth_consequence_read_venue) else -> null } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPermissionLedger.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPermissionLedger.kt index adb0c2a0f7..ecc6ccf40a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPermissionLedger.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPermissionLedger.kt @@ -43,6 +43,7 @@ class RelayAuthPermissionLedger( val isInMyRelayList: (String) -> Boolean = { false }, val isBlocked: (String) -> Boolean = { false }, val isFollowed: (String) -> Boolean = { false }, + val isTrustedVenue: (String) -> Boolean = { false }, val readTrustEnabled: () -> Boolean = { false }, ) { /** The authorization verdict for [ctx], taking the challenge's purpose into account. */ @@ -62,9 +63,19 @@ class RelayAuthPermissionLedger( ctx.purposes.any { p -> p.kind == AuthPurposeKind.READ_OUTBOX && p.counterparties.any(isFollowed) }, + servesTrustedVenue = + ctx.purposes.any { p -> + (p.kind == AuthPurposeKind.POST_VENUE || p.kind == AuthPurposeKind.READ_VENUE) && + p.venues.any(isTrustedVenue) + }, readTrustEnabled = readTrustEnabled(), hasAttributablePurpose = - ctx.purposes.any { it.kind == AuthPurposeKind.MY_OWN_RELAY || it.counterparties.isNotEmpty() }, + ctx.purposes.any { + it.kind == AuthPurposeKind.MY_OWN_RELAY || + it.kind == AuthPurposeKind.OTHER || + it.counterparties.isNotEmpty() || + it.venues.isNotEmpty() + }, ) return RelayAuthResolver.resolve(inputs) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPurposeDeriver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPurposeDeriver.kt index 6cacc08fd3..956eb84922 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPurposeDeriver.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPurposeDeriver.kt @@ -25,17 +25,25 @@ import com.vitorpamplona.amethyst.commons.relayauth.AuthPurposeKind import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +/** `a`-address prefixes of venues whose home relay may require auth: NIP-72 communities (34550) and + * NIP-53 live activities (30311). Their addresses are `kind:ownerPubkey:dTag`. */ +private val VENUE_ADDRESS_PREFIXES = listOf("34550:", "30311:") + /** * Infers *why* a relay wants NIP-42 auth from what Amethyst is currently doing with it — the * events pending delivery and the active subscription filters (both from the [INostrClient]). * Pure so it can be unit-tested without the relay client. * - * - a pending gift wrap (kind 1059) => we're sending a DM to its `p` recipient ([AuthPurposeKind.SEND_DM]); - * - any other pending event carrying `p` tags => we're delivering it to those users' inboxes - * ([AuthPurposeKind.NOTIFY_INBOX]); - * - a subscription filter with `authors` => we're reading those authors' posts ([AuthPurposeKind.READ_OUTBOX]). + * - a pending gift wrap (kind 1059) => sending a DM to its `p` recipient ([AuthPurposeKind.SEND_DM]); + * - a pending channel/community/live post => [AuthPurposeKind.POST_VENUE] for that venue; + * - any other pending event with `p` tags => delivering it to those users' inboxes ([AuthPurposeKind.NOTIFY_INBOX]); + * - a filter with `authors` => reading those authors' posts ([AuthPurposeKind.READ_OUTBOX]); + * - a filter with `#e`/`#a` venue tags => reading a venue ([AuthPurposeKind.READ_VENUE]); + * - anything else we're actively doing but can't attribute => [AuthPurposeKind.OTHER], so the relay + * is prompted about instead of silently failing. */ object RelayAuthPurposeDeriver { fun derive( @@ -44,26 +52,61 @@ object RelayAuthPurposeDeriver { ): List { val dmRecipients = mutableSetOf() val notifyRecipients = mutableSetOf() + val postVenues = mutableSetOf() + var unattributedWrite = false + pendingEvents.forEach { event -> val pTags = event.tags.mapNotNullTo(mutableSetOf()) { if (it.size > 1 && it[0] == "p") it[1] else null } - if (event.kind == GiftWrapEvent.KIND) { - dmRecipients.addAll(pTags) - } else { - // We're notifying the people the event references, not its author — drop the - // author's own key so a self-p-tag doesn't read as "notify yourself". - notifyRecipients.addAll(pTags - event.pubKey) + val venueAddresses = event.tags.venueAddresses() + when { + event.kind == GiftWrapEvent.KIND -> dmRecipients.addAll(pTags) + event.kind == ChannelMessageEvent.KIND -> event.channelRootId()?.let { postVenues.add(it) } + venueAddresses.isNotEmpty() -> postVenues.addAll(venueAddresses) + pTags.isNotEmpty() -> notifyRecipients.addAll(pTags - event.pubKey) + else -> unattributedWrite = true } } val readAuthors = mutableSetOf() + val readVenues = mutableSetOf() + var unattributedRead = false activeFilters.values.forEach { filters -> - filters.forEach { filter -> filter.authors?.let(readAuthors::addAll) } + filters.forEach { filter -> + var matched = false + filter.authors?.let { + readAuthors.addAll(it) + matched = true + } + filter.tags?.get("e")?.let { + readVenues.addAll(it) + matched = true + } + filter.tags?.get("a")?.filter { addr -> VENUE_ADDRESS_PREFIXES.any(addr::startsWith) }?.let { + if (it.isNotEmpty()) { + readVenues.addAll(it) + matched = true + } + } + if (!matched) unattributedRead = true + } } return buildList { if (dmRecipients.isNotEmpty()) add(AuthPurpose(AuthPurposeKind.SEND_DM, dmRecipients)) if (notifyRecipients.isNotEmpty()) add(AuthPurpose(AuthPurposeKind.NOTIFY_INBOX, notifyRecipients)) + if (postVenues.isNotEmpty()) add(AuthPurpose(AuthPurposeKind.POST_VENUE, venues = postVenues)) if (readAuthors.isNotEmpty()) add(AuthPurpose(AuthPurposeKind.READ_OUTBOX, readAuthors)) + if (readVenues.isNotEmpty()) add(AuthPurpose(AuthPurposeKind.READ_VENUE, venues = readVenues)) + // Safety net: we're using this relay but couldn't say how — prompt rather than fail silently. + if (isEmpty() && (unattributedWrite || unattributedRead)) add(AuthPurpose(AuthPurposeKind.OTHER)) } } + + /** The venue a channel message posts into: the `e` tag marked "root", else the first `e` tag. */ + private fun Event.channelRootId(): HexKey? { + val eTags = tags.filter { it.size > 1 && it[0] == "e" } + return eTags.firstOrNull { it.size > 3 && it[3] == "root" }?.get(1) ?: eTags.firstOrNull()?.get(1) + } + + private fun Array>.venueAddresses(): List = mapNotNull { if (it.size > 1 && it[0] == "a" && VENUE_ADDRESS_PREFIXES.any(it[1]::startsWith)) it[1] else null } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt index eb24002f83..796cc69943 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt @@ -366,7 +366,10 @@ private fun reasonRes(kind: AuthPurposeKind): Int = AuthPurposeKind.SEND_DM -> R.string.relay_auth_reason_send_dm AuthPurposeKind.NOTIFY_INBOX -> R.string.relay_auth_reason_notify_inbox AuthPurposeKind.READ_OUTBOX -> R.string.relay_auth_reason_read_outbox + AuthPurposeKind.POST_VENUE -> R.string.relay_auth_reason_post_venue + AuthPurposeKind.READ_VENUE -> R.string.relay_auth_reason_read_venue AuthPurposeKind.MY_OWN_RELAY -> R.string.relay_auth_reason_my_own_relay + AuthPurposeKind.OTHER -> R.string.relay_auth_reason_other } @Composable diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 91b9b1b683..43702d9c8a 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -835,14 +835,21 @@ Send your message to %1$s? Notify %1$s? Load posts from %1$s? + Post to this room? + Open this room? If you don\'t, your message to %1$s won\'t be delivered. If you don\'t, %1$s won\'t be notified about this. If you don\'t, you won\'t see posts from %1$s here. + If you don\'t, your message won\'t be posted. + If you don\'t, you won\'t see it here. %1$s and others Send your private message to: Notify: Download posts from: + Post to this room + Open this room + Use this relay Connect to your own relay …and others Allow once diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPurposeDeriverTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPurposeDeriverTest.kt index 3186d1d60b..bb798ade54 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPurposeDeriverTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPurposeDeriverTest.kt @@ -97,9 +97,47 @@ class RelayAuthPurposeDeriverTest { } @Test - fun noAttributableWorkYieldsNoPurposes() { + fun noActivityYieldsNoPurposes() { assertEquals(emptyList(), RelayAuthPurposeDeriver.derive(emptyList(), emptyMap())) - // an event with no p tags gives nothing to attribute a notification to - assertEquals(emptyList(), RelayAuthPurposeDeriver.derive(listOf(event(1)), emptyMap())) + } + + @Test + fun unattributableActivityYieldsOtherAsSafetyNet() { + // An event we can't attribute (no p tags, not a venue) still prompts rather than fail silently. + val purposes = RelayAuthPurposeDeriver.derive(listOf(event(1)), emptyMap()) + assertEquals(listOf(AuthPurposeKind.OTHER), purposes.map { it.kind }) + } + + @Test + fun channelMessageBecomesPostVenue() { + val channelId = "e".repeat(64) + val ev = + Event( + id = "00".repeat(32), + pubKey = "11".repeat(32), + createdAt = 1_700_000_000L, + kind = 42, + tags = arrayOf(arrayOf("e", channelId, "", "root")), + content = "hi", + sig = "22".repeat(64), + ) + val purposes = RelayAuthPurposeDeriver.derive(listOf(ev), emptyMap()) + assertEquals(1, purposes.size) + assertEquals(AuthPurposeKind.POST_VENUE, purposes[0].kind) + assertEquals(setOf(channelId), purposes[0].venues) + } + + @Test + fun communityAndLiveSubscriptionsBecomeReadVenue() { + val community = "34550:${"1".repeat(64)}:my-community" + val live = "30311:${"2".repeat(64)}:my-stream" + val purposes = + RelayAuthPurposeDeriver.derive( + emptyList(), + mapOf("sub" to listOf(Filter(tags = mapOf("a" to listOf(community, live))))), + ) + assertEquals(1, purposes.size) + assertEquals(AuthPurposeKind.READ_VENUE, purposes[0].kind) + assertEquals(setOf(community, live), purposes[0].venues) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/AuthPurpose.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/AuthPurpose.kt index 37986c581e..89988235fc 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/AuthPurpose.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/AuthPurpose.kt @@ -38,17 +38,29 @@ enum class AuthPurposeKind { /** Reading an author's posts from their NIP-65 outbox (write relays). */ READ_OUTBOX, + /** Posting into a venue — a NIP-28 public chat or a NIP-72 community — hosted on this relay. */ + POST_VENUE, + + /** Reading a venue's content (public chat / community) from this relay. */ + READ_VENUE, + /** The relay is in the user's own relay list. */ MY_OWN_RELAY, + + /** We're actively using this relay but couldn't attribute a specific purpose (safety net so we + * prompt instead of silently failing). */ + OTHER, } /** - * A single reason a relay connection needs auth, with the counterparties it concerns. - * [counterparties] is empty for [AuthPurposeKind.MY_OWN_RELAY]. + * A single reason a relay connection needs auth. [counterparties] holds the people it concerns + * (pubkeys, for DM/notify/outbox purposes); [venues] holds venue identifiers (channel event-ids or + * community `a`-addresses, for [AuthPurposeKind.POST_VENUE]/[AuthPurposeKind.READ_VENUE]). */ data class AuthPurpose( val kind: AuthPurposeKind, val counterparties: Set = emptySet(), + val venues: Set = emptySet(), ) /** The relay plus every live reason we currently have to auth with it. */ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthResolver.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthResolver.kt index 900129395c..14aa401f3c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthResolver.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthResolver.kt @@ -33,6 +33,8 @@ package com.vitorpamplona.amethyst.commons.relayauth * (send DM / deliver notification) for this relay. * @param servesFollowedReadCounterparty a followed user is a counterparty of a *read* purpose * (download their outbox) for this relay. + * @param servesTrustedVenue this relay hosts a venue (public chat, community, or live stream) the + * user has joined, or whose owner they follow. Trusts both reading and posting to it. * @param readTrustEnabled the "also trust follows' outboxes when reading" sub-toggle. * @param hasAttributablePurpose we know *why* this relay wants auth (so a prompt can explain it). * When false, an unresolved challenge is denied silently rather than prompting. @@ -44,6 +46,7 @@ data class RelayAuthInputs( val isInMyRelayList: Boolean, val servesFollowedWriteCounterparty: Boolean, val servesFollowedReadCounterparty: Boolean, + val servesTrustedVenue: Boolean, val readTrustEnabled: Boolean, val hasAttributablePurpose: Boolean, ) @@ -57,9 +60,9 @@ data class RelayAuthInputs( * - [RelayAuthPolicy.NEVER] → DENY * - [RelayAuthPolicy.ALWAYS] → ALLOW * - [RelayAuthPolicy.IF_IN_MY_LIST] → ALLOW if in my list, else fall through - * - [RelayAuthPolicy.TRUSTED_FOLLOWS] → ALLOW if in my list, or a followed counterparty is - * served for a write purpose (DM/notification), or (when [RelayAuthInputs.readTrustEnabled]) - * for a read purpose; else fall through + * - [RelayAuthPolicy.TRUSTED_FOLLOWS] → ALLOW if in my list, a venue the user joined/follows is + * served, a followed counterparty is served for a write purpose (DM/notification), or (when + * [RelayAuthInputs.readTrustEnabled]) for a read purpose; else fall through * 4. Fall-through → [RelayAuthVerdict.ASK] when the purpose is known, otherwise DENY. */ object RelayAuthResolver { @@ -80,6 +83,7 @@ object RelayAuthResolver { if (inputs.isInMyRelayList) RelayAuthVerdict.ALLOW else fallThrough(inputs) RelayAuthPolicy.TRUSTED_FOLLOWS -> if (inputs.isInMyRelayList || + inputs.servesTrustedVenue || inputs.servesFollowedWriteCounterparty || (inputs.readTrustEnabled && inputs.servesFollowedReadCounterparty) ) { diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthResolverTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthResolverTest.kt index b19434e1f5..67e9fb8b6a 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthResolverTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthResolverTest.kt @@ -31,6 +31,7 @@ class RelayAuthResolverTest { isInMyRelayList: Boolean = false, servesFollowedWriteCounterparty: Boolean = false, servesFollowedReadCounterparty: Boolean = false, + servesTrustedVenue: Boolean = false, readTrustEnabled: Boolean = false, hasAttributablePurpose: Boolean = true, ) = RelayAuthInputs( @@ -40,6 +41,7 @@ class RelayAuthResolverTest { isInMyRelayList = isInMyRelayList, servesFollowedWriteCounterparty = servesFollowedWriteCounterparty, servesFollowedReadCounterparty = servesFollowedReadCounterparty, + servesTrustedVenue = servesTrustedVenue, readTrustEnabled = readTrustEnabled, hasAttributablePurpose = hasAttributablePurpose, ) @@ -92,6 +94,13 @@ class RelayAuthResolverTest { assertEquals(RelayAuthVerdict.ALLOW, resolve(inputs(servesFollowedReadCounterparty = true, readTrustEnabled = true))) } + @Test + fun trustedFollowsAllowsVenueYouJoinedOrFollow() { + // A public chat / community / live stream you've joined (or whose owner you follow) — + // auto-auth for both reading and posting, regardless of the read sub-toggle. + assertEquals(RelayAuthVerdict.ALLOW, resolve(inputs(servesTrustedVenue = true, readTrustEnabled = false))) + } + @Test fun trustedFollowsFallsThroughForStranger() { // Not my relay, no followed counterparty -> prompt when we know why, else silent deny.