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 e7c5c69ca2..63be539e7d 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
@@ -47,6 +47,11 @@ class RelayAuthPermissionLedger(
RelayAuthPolicy.NEVER -> RelayAuthDecision.DENY
RelayAuthPolicy.IF_IN_MY_LIST ->
if (isInMyRelayList(relayUrl)) RelayAuthDecision.ALLOW else RelayAuthDecision.DENY
+ // This URL-only entry point has no purpose/counterparty context, so it can only
+ // apply the "in my list" half of TRUSTED_FOLLOWS. The follow-graph half runs in the
+ // context-aware path (RelayAuthResolver) once the challenge purpose is known.
+ RelayAuthPolicy.TRUSTED_FOLLOWS ->
+ if (isInMyRelayList(relayUrl)) RelayAuthDecision.ALLOW else RelayAuthDecision.DENY
}
}
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 b228c4812e..236e9d65a7 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
@@ -128,6 +128,12 @@ fun RelayAuthSettingsScreen(
R.string.relay_auth_policy_if_in_my_list_desc,
MaterialSymbols.PrivacyTip,
)
+ RelayAuthPolicy.TRUSTED_FOLLOWS ->
+ Triple(
+ R.string.relay_auth_policy_trusted_follows,
+ R.string.relay_auth_policy_trusted_follows_desc,
+ MaterialSymbols.Group,
+ )
}
PolicyCard(
selected = globalPolicy == policy,
diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml
index f275053e00..64807cff9f 100644
--- a/amethyst/src/main/res/values/strings.xml
+++ b/amethyst/src/main/res/values/strings.xml
@@ -825,6 +825,8 @@
Ignore auth challenges from all relays
My relays only
Only authenticate with relays in your relay list
+ My relays and people I follow
+ Also authenticate with relays that serve people you follow, such as sending a message to a friend. You\'ll be asked about anyone else.
Per-relay overrides
No per-relay overrides — global policy applies everywhere
Allow
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
new file mode 100644
index 0000000000..37986c581e
--- /dev/null
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/AuthPurpose.kt
@@ -0,0 +1,74 @@
+/*
+ * 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.amethyst.commons.relayauth
+
+/**
+ * Why Amethyst is about to authenticate (NIP-42) with a relay. Carried to the decision
+ * point so we can (a) tell the user *why* an auth is requested and (b) apply follow-based
+ * trust against the counterparties a relay serves.
+ *
+ * Counterparty pubkeys are stored as hex strings to keep this module free of protocol types;
+ * display names/avatars are resolved elsewhere at render time.
+ */
+enum class AuthPurposeKind {
+ /** Delivering a NIP-17 private message to a recipient's DM inbox (kind 10050). */
+ SEND_DM,
+
+ /** Delivering a public reply/mention/reaction to a recipient's NIP-65 inbox (read relays). */
+ NOTIFY_INBOX,
+
+ /** Reading an author's posts from their NIP-65 outbox (write relays). */
+ READ_OUTBOX,
+
+ /** The relay is in the user's own relay list. */
+ MY_OWN_RELAY,
+}
+
+/**
+ * A single reason a relay connection needs auth, with the counterparties it concerns.
+ * [counterparties] is empty for [AuthPurposeKind.MY_OWN_RELAY].
+ */
+data class AuthPurpose(
+ val kind: AuthPurposeKind,
+ val counterparties: Set = emptySet(),
+)
+
+/** The relay plus every live reason we currently have to auth with it. */
+data class RelayAuthContext(
+ val relayUrl: String,
+ val purposes: List = emptyList(),
+)
+
+/**
+ * The runtime verdict for an auth challenge. Distinct from [RelayAuthDecision], which is the
+ * two-value ([RelayAuthDecision.ALLOW]/[RelayAuthDecision.DENY]) *persisted* per-relay override:
+ * [ASK] is only ever a live decision, never stored.
+ */
+enum class RelayAuthVerdict {
+ /** Sign and send the NIP-42 auth event. */
+ ALLOW,
+
+ /** Do not auth; do not reveal identity. */
+ DENY,
+
+ /** Prompt the user, explaining the purpose, before deciding. */
+ ASK,
+}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthPolicy.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthPolicy.kt
index 4287267c36..abedb87a2c 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthPolicy.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthPolicy.kt
@@ -33,6 +33,14 @@ enum class RelayAuthPolicy {
/** Authenticate only with relays explicitly listed in the user's relay list. */
IF_IN_MY_LIST,
+
+ /**
+ * Authenticate with relays in the user's own list, and additionally with relays that
+ * serve someone the user follows (any follow list) for the current purpose — e.g. the
+ * DM inbox of a friend you're messaging. Relays that can't be attributed to a followed
+ * counterparty fall through to an explicit prompt ([RelayAuthVerdict.ASK]).
+ */
+ TRUSTED_FOLLOWS,
}
/** A persisted per-relay override decision. */
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
new file mode 100644
index 0000000000..900129395c
--- /dev/null
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthResolver.kt
@@ -0,0 +1,94 @@
+/*
+ * 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.amethyst.commons.relayauth
+
+/**
+ * Everything the resolver needs to decide an auth challenge, gathered by the host (which owns
+ * the blocked-relay list, the user's relay lists, and the follow graph). Kept as plain values
+ * so the decision itself is pure and unit-testable without any account/relay wiring.
+ *
+ * @param storedOverride an explicit per-relay decision the user set previously, or null.
+ * @param isBlocked the relay is on the user's blocked-relay list (kind 10006).
+ * @param policy the global [RelayAuthPolicy].
+ * @param isInMyRelayList the relay is in the user's own relay list.
+ * @param servesFollowedWriteCounterparty a followed user is a counterparty of a *write* purpose
+ * (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 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.
+ */
+data class RelayAuthInputs(
+ val storedOverride: RelayAuthDecision?,
+ val isBlocked: Boolean,
+ val policy: RelayAuthPolicy,
+ val isInMyRelayList: Boolean,
+ val servesFollowedWriteCounterparty: Boolean,
+ val servesFollowedReadCounterparty: Boolean,
+ val readTrustEnabled: Boolean,
+ val hasAttributablePurpose: Boolean,
+)
+
+/**
+ * Pure NIP-42 auth decision. Precedence, highest first:
+ *
+ * 1. Blocked-relay list → [RelayAuthVerdict.DENY] (never reveal identity to a blocked relay).
+ * 2. Explicit per-relay override → honor it.
+ * 3. Global [RelayAuthPolicy]:
+ * - [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
+ * 4. Fall-through → [RelayAuthVerdict.ASK] when the purpose is known, otherwise DENY.
+ */
+object RelayAuthResolver {
+ fun resolve(inputs: RelayAuthInputs): RelayAuthVerdict {
+ if (inputs.isBlocked) return RelayAuthVerdict.DENY
+
+ inputs.storedOverride?.let {
+ return when (it) {
+ RelayAuthDecision.ALLOW -> RelayAuthVerdict.ALLOW
+ RelayAuthDecision.DENY -> RelayAuthVerdict.DENY
+ }
+ }
+
+ return when (inputs.policy) {
+ RelayAuthPolicy.NEVER -> RelayAuthVerdict.DENY
+ RelayAuthPolicy.ALWAYS -> RelayAuthVerdict.ALLOW
+ RelayAuthPolicy.IF_IN_MY_LIST ->
+ if (inputs.isInMyRelayList) RelayAuthVerdict.ALLOW else fallThrough(inputs)
+ RelayAuthPolicy.TRUSTED_FOLLOWS ->
+ if (inputs.isInMyRelayList ||
+ inputs.servesFollowedWriteCounterparty ||
+ (inputs.readTrustEnabled && inputs.servesFollowedReadCounterparty)
+ ) {
+ RelayAuthVerdict.ALLOW
+ } else {
+ fallThrough(inputs)
+ }
+ }
+ }
+
+ private fun fallThrough(inputs: RelayAuthInputs): RelayAuthVerdict = if (inputs.hasAttributablePurpose) RelayAuthVerdict.ASK else RelayAuthVerdict.DENY
+}
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
new file mode 100644
index 0000000000..b19434e1f5
--- /dev/null
+++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthResolverTest.kt
@@ -0,0 +1,101 @@
+/*
+ * 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.amethyst.commons.relayauth
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class RelayAuthResolverTest {
+ private fun inputs(
+ storedOverride: RelayAuthDecision? = null,
+ isBlocked: Boolean = false,
+ policy: RelayAuthPolicy = RelayAuthPolicy.TRUSTED_FOLLOWS,
+ isInMyRelayList: Boolean = false,
+ servesFollowedWriteCounterparty: Boolean = false,
+ servesFollowedReadCounterparty: Boolean = false,
+ readTrustEnabled: Boolean = false,
+ hasAttributablePurpose: Boolean = true,
+ ) = RelayAuthInputs(
+ storedOverride = storedOverride,
+ isBlocked = isBlocked,
+ policy = policy,
+ isInMyRelayList = isInMyRelayList,
+ servesFollowedWriteCounterparty = servesFollowedWriteCounterparty,
+ servesFollowedReadCounterparty = servesFollowedReadCounterparty,
+ readTrustEnabled = readTrustEnabled,
+ hasAttributablePurpose = hasAttributablePurpose,
+ )
+
+ private fun resolve(inputs: RelayAuthInputs) = RelayAuthResolver.resolve(inputs)
+
+ @Test
+ fun blockedRelayAlwaysDeniesEvenWithAllowOverrideAndAlwaysPolicy() {
+ assertEquals(
+ RelayAuthVerdict.DENY,
+ resolve(
+ inputs(
+ isBlocked = true,
+ storedOverride = RelayAuthDecision.ALLOW,
+ policy = RelayAuthPolicy.ALWAYS,
+ ),
+ ),
+ )
+ }
+
+ @Test
+ fun explicitOverrideBeatsPolicy() {
+ assertEquals(RelayAuthVerdict.DENY, resolve(inputs(storedOverride = RelayAuthDecision.DENY, policy = RelayAuthPolicy.ALWAYS)))
+ assertEquals(RelayAuthVerdict.ALLOW, resolve(inputs(storedOverride = RelayAuthDecision.ALLOW, policy = RelayAuthPolicy.NEVER)))
+ }
+
+ @Test
+ fun neverAndAlwaysAreUnconditional() {
+ assertEquals(RelayAuthVerdict.DENY, resolve(inputs(policy = RelayAuthPolicy.NEVER, servesFollowedWriteCounterparty = true)))
+ assertEquals(RelayAuthVerdict.ALLOW, resolve(inputs(policy = RelayAuthPolicy.ALWAYS, hasAttributablePurpose = false)))
+ }
+
+ @Test
+ fun ifInMyListAllowsOnlyMyRelaysElseAsksWhenAttributable() {
+ assertEquals(RelayAuthVerdict.ALLOW, resolve(inputs(policy = RelayAuthPolicy.IF_IN_MY_LIST, isInMyRelayList = true)))
+ assertEquals(RelayAuthVerdict.ASK, resolve(inputs(policy = RelayAuthPolicy.IF_IN_MY_LIST, isInMyRelayList = false)))
+ }
+
+ @Test
+ fun trustedFollowsAllowsWriteToFollowedCounterparty() {
+ // Sending a DM / delivering a notification to someone I follow -> auto-auth.
+ assertEquals(RelayAuthVerdict.ALLOW, resolve(inputs(servesFollowedWriteCounterparty = true)))
+ }
+
+ @Test
+ fun trustedFollowsDoesNotAutoAllowReadUnlessSubToggleOn() {
+ // Reading a followed author's outbox: prompts by default (decision D, conservative)...
+ assertEquals(RelayAuthVerdict.ASK, resolve(inputs(servesFollowedReadCounterparty = true, readTrustEnabled = false)))
+ // ...auto-auths only when the read sub-toggle is enabled.
+ assertEquals(RelayAuthVerdict.ALLOW, resolve(inputs(servesFollowedReadCounterparty = true, readTrustEnabled = true)))
+ }
+
+ @Test
+ fun trustedFollowsFallsThroughForStranger() {
+ // Not my relay, no followed counterparty -> prompt when we know why, else silent deny.
+ assertEquals(RelayAuthVerdict.ASK, resolve(inputs(hasAttributablePurpose = true)))
+ assertEquals(RelayAuthVerdict.DENY, resolve(inputs(hasAttributablePurpose = false)))
+ }
+}