From 3a96554c8d1baf8b582e593a91cc5da0e5fbab8f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 21:02:53 +0000 Subject: [PATCH] feat(relayauth): replace the policy list with Always / Never / Custom + toggles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure the global auth control into a top-level mode — Always authenticate, Never authenticate, or Custom — where Custom reveals independent per-situation toggles instead of the confusing single sub-toggle: - My relays and venues (own relays + joined/subscribed/favorited venues) — on - Read posts from people I follow — on - Message people I follow (DMs, replies, notifications) — on - Message anyone / strangers — off by default (you're asked each time instead) RelayAuthPolicy is now {ALWAYS, NEVER, CUSTOM}. The resolver takes a RelayAuthCustomToggles plus split serves-facts (followed-read, followed-write, stranger-write, own-relay, venue) and, under CUSTOM, allows if any enabled category matches — else falls through to a prompt. There is deliberately no "read strangers' posts" category, so that always prompts. Account settings replace the single delivery flag with four persisted booleans (default policy CUSTOM; no migration, unreleased). The contextual DM/notification prompt button now switches to CUSTOM and enables both message toggles. Resolver tests rewritten per-toggle, including that reading a stranger is never auto-allowed. Old IF_IN_MY_LIST / TRUSTED_FOLLOWS policies and their strings are removed. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a --- .../amethyst/LocalPreferences.kt | 22 ++++- .../amethyst/model/AccountSettings.kt | 25 ++++- .../compose/AccountDataSourceSubscription.kt | 10 +- .../compose/RelayAuthPromptHost.kt | 11 ++- .../model/RelayAuthPermissionLedger.kt | 34 +++---- .../relayauth/RelayAuthSettingsScreen.kt | 91 +++++++++++++------ amethyst/src/main/res/values/strings.xml | 16 ++-- .../model/RelayAuthGrantRationaleTest.kt | 2 +- .../commons/relayauth/RelayAuthPolicy.kt | 16 ++-- .../commons/relayauth/RelayAuthResolver.kt | 72 +++++++++------ .../relayauth/RelayAuthResolverTest.kt | 84 +++++++++-------- 11 files changed, 243 insertions(+), 140 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 79bb4c5874..aa759eed27 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -162,7 +162,10 @@ private object PrefKeys { const val ALWAYS_ON_NOTIFICATION_SERVICE = "always_on_notification_service" const val DEFAULT_RELAY_AUTH_POLICY = "default_relay_auth_policy" const val RELAY_GROUP_VIEW_MODE = "relay_group_view_mode" - const val RELAY_AUTH_TRUST_MESSAGE_DELIVERY = "relay_auth_trust_message_delivery" + const val RELAY_AUTH_TRUST_MY_RELAYS = "relay_auth_trust_my_relays_and_venues" + const val RELAY_AUTH_TRUST_READ_FOLLOWS = "relay_auth_trust_read_follows" + const val RELAY_AUTH_TRUST_MESSAGE_FOLLOWS = "relay_auth_trust_message_follows" + const val RELAY_AUTH_TRUST_MESSAGE_STRANGERS = "relay_auth_trust_message_strangers" const val SPLIT_NOTIFICATIONS_ENABLED = "split_notifications_enabled" const val SHOW_MESSAGES_IN_NOTIFICATIONS = "show_messages_in_notifications" @@ -518,7 +521,10 @@ object LocalPreferences { putBoolean(PrefKeys.ALWAYS_ON_NOTIFICATION_SERVICE, settings.alwaysOnNotificationService.value) putString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, settings.defaultRelayAuthPolicy.value.name) putString(PrefKeys.RELAY_GROUP_VIEW_MODE, settings.relayGroupViewMode.value.name) - putBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_DELIVERY, settings.relayAuthTrustMessageDelivery.value) + putBoolean(PrefKeys.RELAY_AUTH_TRUST_MY_RELAYS, settings.relayAuthTrustMyRelaysAndVenues.value) + putBoolean(PrefKeys.RELAY_AUTH_TRUST_READ_FOLLOWS, settings.relayAuthTrustReadFollows.value) + putBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_FOLLOWS, settings.relayAuthTrustMessageFollows.value) + putBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_STRANGERS, settings.relayAuthTrustMessageStrangers.value) putBoolean(PrefKeys.SPLIT_NOTIFICATIONS_ENABLED, settings.splitNotificationsEnabled.value) putBoolean(PrefKeys.SHOW_MESSAGES_IN_NOTIFICATIONS, settings.showMessagesInNotifications.value) // Any account that reaches a save has its notification filter in its @@ -638,9 +644,12 @@ object LocalPreferences { val defaultRelayAuthPolicy = getString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, null) ?.let { runCatching { RelayAuthPolicy.valueOf(it) }.getOrNull() } - ?: RelayAuthPolicy.TRUSTED_FOLLOWS + ?: RelayAuthPolicy.CUSTOM val relayGroupViewMode = RelayGroupViewMode.fromName(getString(PrefKeys.RELAY_GROUP_VIEW_MODE, null)) - val relayAuthTrustMessageDelivery = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_DELIVERY, false) + val relayAuthTrustMyRelays = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MY_RELAYS, true) + val relayAuthTrustReadFollows = getBoolean(PrefKeys.RELAY_AUTH_TRUST_READ_FOLLOWS, true) + val relayAuthTrustMessageFollows = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_FOLLOWS, true) + val relayAuthTrustMessageStrangers = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_STRANGERS, false) val splitNotificationsEnabled = getBoolean(PrefKeys.SPLIT_NOTIFICATIONS_ENABLED, false) val showMessagesInNotifications = getBoolean(PrefKeys.SHOW_MESSAGES_IN_NOTIFICATIONS, true) val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf() @@ -850,7 +859,10 @@ object LocalPreferences { alwaysOnNotificationService = MutableStateFlow(alwaysOnNotificationService), defaultRelayAuthPolicy = MutableStateFlow(defaultRelayAuthPolicy), relayGroupViewMode = MutableStateFlow(relayGroupViewMode), - relayAuthTrustMessageDelivery = MutableStateFlow(relayAuthTrustMessageDelivery), + relayAuthTrustMyRelaysAndVenues = MutableStateFlow(relayAuthTrustMyRelays), + relayAuthTrustReadFollows = MutableStateFlow(relayAuthTrustReadFollows), + relayAuthTrustMessageFollows = MutableStateFlow(relayAuthTrustMessageFollows), + relayAuthTrustMessageStrangers = MutableStateFlow(relayAuthTrustMessageStrangers), splitNotificationsEnabled = MutableStateFlow(splitNotificationsEnabled), showMessagesInNotifications = MutableStateFlow(showMessagesInNotifications), backupUserMetadata = latestUserMetadataResolved, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 1c54e34ca6..87f1363a3c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -273,9 +273,13 @@ class AccountSettings( var callVideoResolution: CallVideoResolution = CallVideoResolution.HD_720, var callMaxBitrateBps: Int = 1_500_000, val callsEnabled: MutableStateFlow = MutableStateFlow(true), - val defaultRelayAuthPolicy: MutableStateFlow = MutableStateFlow(RelayAuthPolicy.TRUSTED_FOLLOWS), + val defaultRelayAuthPolicy: MutableStateFlow = MutableStateFlow(RelayAuthPolicy.CUSTOM), val relayGroupViewMode: MutableStateFlow = MutableStateFlow(RelayGroupViewMode.DEFAULT), - val relayAuthTrustMessageDelivery: MutableStateFlow = MutableStateFlow(false), + // The per-situation toggles applied under RelayAuthPolicy.CUSTOM. + val relayAuthTrustMyRelaysAndVenues: MutableStateFlow = MutableStateFlow(true), + val relayAuthTrustReadFollows: MutableStateFlow = MutableStateFlow(true), + val relayAuthTrustMessageFollows: MutableStateFlow = MutableStateFlow(true), + val relayAuthTrustMessageStrangers: MutableStateFlow = MutableStateFlow(false), ) : EphemeralChatRepository, RelayGroupRepository, PublicChatListRepository { @@ -1526,12 +1530,23 @@ class AccountSettings( } } - fun changeRelayAuthTrustMessageDelivery(enabled: Boolean) { - if (relayAuthTrustMessageDelivery.value != enabled) { - relayAuthTrustMessageDelivery.tryEmit(enabled) + private fun changeToggle( + flow: MutableStateFlow, + enabled: Boolean, + ) { + if (flow.value != enabled) { + flow.tryEmit(enabled) saveAccountSettings() } } + + fun changeRelayAuthTrustMyRelaysAndVenues(enabled: Boolean) = changeToggle(relayAuthTrustMyRelaysAndVenues, enabled) + + fun changeRelayAuthTrustReadFollows(enabled: Boolean) = changeToggle(relayAuthTrustReadFollows, enabled) + + fun changeRelayAuthTrustMessageFollows(enabled: Boolean) = changeToggle(relayAuthTrustMessageFollows, enabled) + + fun changeRelayAuthTrustMessageStrangers(enabled: Boolean) = changeToggle(relayAuthTrustMessageStrangers, enabled) } @Serializable 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 75857c7b4e..c8a18ce7ef 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 @@ -24,6 +24,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthCustomToggles import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoordinator import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthPermissionLedger import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.ScreenAuthAccount @@ -54,6 +55,14 @@ fun RelayAuthSubscription( RelayAuthPermissionLedger( store = Amethyst.instance.relayAuthPermissionStore, globalPolicy = { account.settings.defaultRelayAuthPolicy.value }, + customToggles = { + RelayAuthCustomToggles( + myRelaysAndVenues = account.settings.relayAuthTrustMyRelaysAndVenues.value, + readFollows = account.settings.relayAuthTrustReadFollows.value, + messageFollows = account.settings.relayAuthTrustMessageFollows.value, + messageStrangers = account.settings.relayAuthTrustMessageStrangers.value, + ) + }, isInMyRelayList = { relayUrl -> val normalized = relayUrl.normalizeRelayUrlOrNull() ?: return@RelayAuthPermissionLedger false normalized in account.trustedRelays.flow.value @@ -72,7 +81,6 @@ fun RelayAuthSubscription( venueId in account.communityList.flowSet.value || venueOwnerPubkey(venueId)?.let { it in account.allFollows.flow.value.authors } == true }, - messageDeliveryTrustEnabled = { account.settings.relayAuthTrustMessageDelivery.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 19e677287d..0208cfa0bb 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 @@ -176,14 +176,15 @@ private fun RelayAuthPromptDialog( modifier = Modifier.fillMaxWidth(), ) { Text(stringRes(R.string.relay_auth_allow_once)) } // For a DM/notification prompt, offer the broad rule: always log in to deliver my - // messages to whoever I'm talking to, so these prompts stop appearing. That trust - // only applies under TRUSTED_FOLLOWS, so set both to make the promise hold on any - // policy. + // messages to whoever I'm talking to, so these prompts stop appearing. Those toggles + // only apply under CUSTOM, so switch to it and turn both message toggles on. if (primary?.kind == AuthPurposeKind.SEND_DM || primary?.kind == AuthPurposeKind.NOTIFY_INBOX) { FilledTonalButton( onClick = { - accountViewModel.account.settings.changeDefaultRelayAuthPolicy(RelayAuthPolicy.TRUSTED_FOLLOWS) - accountViewModel.account.settings.changeRelayAuthTrustMessageDelivery(true) + val settings = accountViewModel.account.settings + settings.changeDefaultRelayAuthPolicy(RelayAuthPolicy.CUSTOM) + settings.changeRelayAuthTrustMessageFollows(true) + settings.changeRelayAuthTrustMessageStrangers(true) onChoice(UserAuthChoice.ALLOW_ONCE) }, modifier = Modifier.fillMaxWidth(), 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 d3f91eefcb..1b21c235cc 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 @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.relayClient.authCommand.model import com.vitorpamplona.amethyst.commons.relayauth.AuthPurposeKind import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthContext +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthCustomToggles import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthInputs import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore @@ -33,44 +34,45 @@ import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthVerdict * Decides whether Amethyst should authenticate with a given relay (NIP-42), for one account. * * Precedence (see [RelayAuthResolver]): blocked-relay list → per-relay override → global - * [globalPolicy] → prompt-if-attributable-else-deny. The follow-graph half of - * [RelayAuthPolicy.TRUSTED_FOLLOWS] uses [isFollowed] against the counterparties carried in the - * [RelayAuthContext]. + * [globalPolicy] → prompt-if-attributable-else-deny. Under [RelayAuthPolicy.CUSTOM] the + * [customToggles] gate each category, using [isFollowed] to split the counterparties carried in the + * [RelayAuthContext] into followed vs. stranger. */ class RelayAuthPermissionLedger( val store: RelayAuthPermissionStore, val globalPolicy: () -> RelayAuthPolicy, + val customToggles: () -> RelayAuthCustomToggles = { RelayAuthCustomToggles() }, val isInMyRelayList: (String) -> Boolean = { false }, val isBlocked: (String) -> Boolean = { false }, val isFollowed: (String) -> Boolean = { false }, val isTrustedVenue: (String) -> Boolean = { false }, - val messageDeliveryTrustEnabled: () -> Boolean = { false }, ) { /** The authorization verdict for [ctx], taking the challenge's purpose into account. */ suspend fun decide(ctx: RelayAuthContext): RelayAuthVerdict { + fun isWrite(kind: AuthPurposeKind) = kind == AuthPurposeKind.SEND_DM || kind == AuthPurposeKind.NOTIFY_INBOX val inputs = RelayAuthInputs( storedOverride = store.loadDecision(ctx.relayUrl), isBlocked = isBlocked(ctx.relayUrl), policy = globalPolicy(), + toggles = customToggles(), isInMyRelayList = isInMyRelayList(ctx.relayUrl), - // A followed user is a counterparty here, whether we're reading them (outbox) or - // reaching them (DM / notification inbox). - servesFollowedCounterparty = - ctx.purposes.any { p -> p.counterparties.any(isFollowed) }, - // This relay is an inbox for someone we're messaging (DM or notification), follow - // or not — the target of the "deliver my messages" toggle. - servesWriteCounterparty = - ctx.purposes.any { p -> - (p.kind == AuthPurposeKind.SEND_DM || p.kind == AuthPurposeKind.NOTIFY_INBOX) && - p.counterparties.isNotEmpty() - }, servesTrustedVenue = ctx.purposes.any { p -> (p.kind == AuthPurposeKind.POST_VENUE || p.kind == AuthPurposeKind.READ_VENUE) && p.venues.any(isTrustedVenue) }, - messageDeliveryTrustEnabled = messageDeliveryTrustEnabled(), + // Reading a followed author's outbox. + servesFollowedReadCounterparty = + ctx.purposes.any { p -> + p.kind == AuthPurposeKind.READ_OUTBOX && p.counterparties.any(isFollowed) + }, + // Messaging a followed user's inbox (DM / notification). + servesFollowedWriteCounterparty = + ctx.purposes.any { p -> isWrite(p.kind) && p.counterparties.any(isFollowed) }, + // Messaging a non-followed user's inbox. + servesStrangerWriteCounterparty = + ctx.purposes.any { p -> isWrite(p.kind) && p.counterparties.any { !isFollowed(it) } }, hasAttributablePurpose = ctx.purposes.any { it.kind == AuthPurposeKind.MY_OWN_RELAY || 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 ace4395e60..c09242cfcb 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 @@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll @@ -148,17 +149,11 @@ fun RelayAuthSettingsScreen( R.string.relay_auth_policy_never_desc, MaterialSymbols.Lock, ) - RelayAuthPolicy.IF_IN_MY_LIST -> + RelayAuthPolicy.CUSTOM -> Triple( - R.string.relay_auth_policy_if_in_my_list, - 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, + R.string.relay_auth_policy_custom, + R.string.relay_auth_policy_custom_desc, + MaterialSymbols.Tune, ) } PolicyCard( @@ -171,27 +166,43 @@ fun RelayAuthSettingsScreen( } } - if (globalPolicy == RelayAuthPolicy.TRUSTED_FOLLOWS) { - val trustDelivery by account.settings.relayAuthTrustMessageDelivery.collectAsState() - Row( + if (globalPolicy == RelayAuthPolicy.CUSTOM) { + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.medium, modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - verticalAlignment = Alignment.CenterVertically, ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = stringResource(R.string.relay_auth_trust_delivery), - style = MaterialTheme.typography.bodyLarge, + Column(modifier = Modifier.padding(vertical = 4.dp)) { + val myRelays by account.settings.relayAuthTrustMyRelaysAndVenues.collectAsState() + val readFollows by account.settings.relayAuthTrustReadFollows.collectAsState() + val messageFollows by account.settings.relayAuthTrustMessageFollows.collectAsState() + val messageStrangers by account.settings.relayAuthTrustMessageStrangers.collectAsState() + + AuthToggleRow( + title = stringResource(R.string.relay_auth_toggle_my_relays), + description = stringResource(R.string.relay_auth_toggle_my_relays_desc), + checked = myRelays, + onCheckedChange = { account.settings.changeRelayAuthTrustMyRelaysAndVenues(it) }, ) - Text( - text = stringResource(R.string.relay_auth_trust_delivery_desc), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + AuthToggleRow( + title = stringResource(R.string.relay_auth_toggle_read_follows), + description = stringResource(R.string.relay_auth_toggle_read_follows_desc), + checked = readFollows, + onCheckedChange = { account.settings.changeRelayAuthTrustReadFollows(it) }, + ) + AuthToggleRow( + title = stringResource(R.string.relay_auth_toggle_message_follows), + description = stringResource(R.string.relay_auth_toggle_message_follows_desc), + checked = messageFollows, + onCheckedChange = { account.settings.changeRelayAuthTrustMessageFollows(it) }, + ) + AuthToggleRow( + title = stringResource(R.string.relay_auth_toggle_message_strangers), + description = stringResource(R.string.relay_auth_toggle_message_strangers_desc), + checked = messageStrangers, + onCheckedChange = { account.settings.changeRelayAuthTrustMessageStrangers(it) }, ) } - Switch( - checked = trustDelivery, - onCheckedChange = { account.settings.changeRelayAuthTrustMessageDelivery(it) }, - ) } } @@ -268,6 +279,34 @@ fun RelayAuthSettingsScreen( } } +/** A labelled Switch row for one [RelayAuthPolicy.CUSTOM] trust toggle. */ +@Composable +private fun AuthToggleRow( + title: String, + description: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(text = title, style = MaterialTheme.typography.bodyLarge) + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.width(8.dp)) + Switch(checked = checked, onCheckedChange = onCheckedChange) + } +} + /** * One relay's card in the merged list: NIP-11 icon + shortened URL (tap the card to open the relay's * info screen), when it was last used, an Allow/Deny chip, a Forget button, and a facepile of the diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 1a31dc2460..c5d30507fe 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -823,12 +823,16 @@ Sign auth challenges for every relay that requests it Never authenticate 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. - Also log in to deliver my messages - On its own, the option above only logs in for people you follow. Turn this on to also log in to send DMs, replies or notifications to anyone you\'re talking to, even if you don\'t follow them. + Custom + Choose exactly which relays to log in to. You\'ll be asked about anything you haven\'t allowed below. + My relays and venues + Log in to your own relays and to public chats, communities and live streams you\'ve joined or favorited. + Read posts from people I follow + Log in to a follow\'s relays to download their posts. + Message people I follow + Log in to a follow\'s relays to send DMs, replies and notifications. + Message anyone + Log in to strangers\' relays to send DMs, replies and notifications. Off by default; you\'ll be asked each time instead. Confirm it\'s you to this relay? This relay wants to confirm it\'s really you first. Its operator will see which account you are. diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthGrantRationaleTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthGrantRationaleTest.kt index a9cf1ea162..8b24f098cb 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthGrantRationaleTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthGrantRationaleTest.kt @@ -69,7 +69,7 @@ class RelayAuthGrantRationaleTest { private val bob = "b".repeat(64) private val carol = "c".repeat(64) - private fun ledger(store: RelayAuthPermissionStore) = RelayAuthPermissionLedger(store, { RelayAuthPolicy.TRUSTED_FOLLOWS }) + private fun ledger(store: RelayAuthPermissionStore) = RelayAuthPermissionLedger(store, { RelayAuthPolicy.CUSTOM }) @Test fun recordsCounterpartiesGroupedByPurpose() = 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 abedb87a2c..041fdb8cf9 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 @@ -21,26 +21,22 @@ package com.vitorpamplona.amethyst.commons.relayauth /** - * The default policy for authenticating with relays (NIP-42). + * The top-level mode for authenticating with relays (NIP-42). * Per-relay overrides stored in [RelayAuthPermissionStore] always take precedence. */ enum class RelayAuthPolicy { - /** Authenticate with every relay that requests it. Equivalent to current behavior. */ + /** Authenticate with every relay that requests it. */ ALWAYS, /** Never authenticate; do not reveal your identity to relay operators via NIP-42. */ NEVER, - /** 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]). + * Apply the per-situation [RelayAuthCustomToggles]: authenticate only for the categories the + * user turned on (own relays/venues, reading or messaging follows, messaging strangers). + * Situations no toggle covers fall through to an explicit prompt ([RelayAuthVerdict.ASK]). */ - TRUSTED_FOLLOWS, + CUSTOM, } /** 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 index ade236815e..12ef94d17a 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 @@ -20,6 +20,25 @@ */ package com.vitorpamplona.amethyst.commons.relayauth +/** + * The per-situation switches applied under [RelayAuthPolicy.CUSTOM]. Each independently authorizes + * one category of relay; a situation with no matching toggle falls through to a prompt. + * + * @param myRelaysAndVenues your own relays, plus venues (public chats, communities, live streams) + * you've joined, subscribed to, or favorited. + * @param readFollows a relay serving the outbox of someone you follow (to download their posts). + * @param messageFollows a relay serving the inbox of someone you follow (to send DMs, replies, + * notifications). + * @param messageStrangers a relay serving the inbox of someone you *don't* follow. Off by default — + * sending to a stranger otherwise prompts. + */ +data class RelayAuthCustomToggles( + val myRelaysAndVenues: Boolean = true, + val readFollows: Boolean = true, + val messageFollows: Boolean = true, + val messageStrangers: Boolean = false, +) + /** * 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 @@ -27,16 +46,14 @@ package com.vitorpamplona.amethyst.commons.relayauth * * @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 policy the top-level [RelayAuthPolicy]. + * @param toggles the [RelayAuthCustomToggles] applied when [policy] is [RelayAuthPolicy.CUSTOM]. * @param isInMyRelayList the relay is in the user's own relay list. - * @param servesFollowedCounterparty a user the person follows is a counterparty for this relay — - * whether they're reading that user (their outbox) or reaching them (DM / notification inbox). - * @param servesWriteCounterparty this relay serves the inbox of *someone the user is sending to* - * (a DM or a notification), whether or not that person is followed. * @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 messageDeliveryTrustEnabled the "also log in to deliver my messages to anyone I'm talking - * to" toggle, which extends trust to [servesWriteCounterparty] relays beyond the follow graph. + * user has joined, subscribed to, or favorited. + * @param servesFollowedReadCounterparty a followed user's outbox is served here (reading them). + * @param servesFollowedWriteCounterparty a followed user's inbox is served here (messaging them). + * @param servesStrangerWriteCounterparty a non-followed user's inbox is served here (messaging them). * @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,11 +61,12 @@ data class RelayAuthInputs( val storedOverride: RelayAuthDecision?, val isBlocked: Boolean, val policy: RelayAuthPolicy, + val toggles: RelayAuthCustomToggles, val isInMyRelayList: Boolean, - val servesFollowedCounterparty: Boolean, - val servesWriteCounterparty: Boolean, val servesTrustedVenue: Boolean, - val messageDeliveryTrustEnabled: Boolean, + val servesFollowedReadCounterparty: Boolean, + val servesFollowedWriteCounterparty: Boolean, + val servesStrangerWriteCounterparty: Boolean, val hasAttributablePurpose: Boolean, ) @@ -57,14 +75,12 @@ data class RelayAuthInputs( * * 1. Blocked-relay list → [RelayAuthVerdict.DENY] (never reveal identity to a blocked relay). * 2. Explicit per-relay override → honor it. - * 3. Global [RelayAuthPolicy]: + * 3. Top-level [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, a venue the user joined/follows is - * served, a followed user is a counterparty (reading them or reaching them), or (when - * [RelayAuthInputs.messageDeliveryTrustEnabled]) the relay serves the inbox of anyone the user - * is messaging; else fall through + * - [RelayAuthPolicy.CUSTOM] → ALLOW if any *enabled* [RelayAuthCustomToggles] category matches + * this relay (own relays/venues, reading follows, messaging follows, messaging strangers); + * else fall through * 4. Fall-through → [RelayAuthVerdict.ASK] when the purpose is known, otherwise DENY. */ object RelayAuthResolver { @@ -81,20 +97,18 @@ object RelayAuthResolver { 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.servesTrustedVenue || - inputs.servesFollowedCounterparty || - (inputs.messageDeliveryTrustEnabled && inputs.servesWriteCounterparty) - ) { - RelayAuthVerdict.ALLOW - } else { - fallThrough(inputs) - } + RelayAuthPolicy.CUSTOM -> + if (customAllows(inputs)) RelayAuthVerdict.ALLOW else fallThrough(inputs) } } + private fun customAllows(inputs: RelayAuthInputs): Boolean { + val t = inputs.toggles + return (t.myRelaysAndVenues && (inputs.isInMyRelayList || inputs.servesTrustedVenue)) || + (t.readFollows && inputs.servesFollowedReadCounterparty) || + (t.messageFollows && inputs.servesFollowedWriteCounterparty) || + (t.messageStrangers && inputs.servesStrangerWriteCounterparty) + } + 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 index 700367ccc8..aea8ece986 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 @@ -27,22 +27,24 @@ class RelayAuthResolverTest { private fun inputs( storedOverride: RelayAuthDecision? = null, isBlocked: Boolean = false, - policy: RelayAuthPolicy = RelayAuthPolicy.TRUSTED_FOLLOWS, + policy: RelayAuthPolicy = RelayAuthPolicy.CUSTOM, + toggles: RelayAuthCustomToggles = RelayAuthCustomToggles(), isInMyRelayList: Boolean = false, - servesFollowedCounterparty: Boolean = false, - servesWriteCounterparty: Boolean = false, servesTrustedVenue: Boolean = false, - messageDeliveryTrustEnabled: Boolean = false, + servesFollowedReadCounterparty: Boolean = false, + servesFollowedWriteCounterparty: Boolean = false, + servesStrangerWriteCounterparty: Boolean = false, hasAttributablePurpose: Boolean = true, ) = RelayAuthInputs( storedOverride = storedOverride, isBlocked = isBlocked, policy = policy, + toggles = toggles, isInMyRelayList = isInMyRelayList, - servesFollowedCounterparty = servesFollowedCounterparty, - servesWriteCounterparty = servesWriteCounterparty, servesTrustedVenue = servesTrustedVenue, - messageDeliveryTrustEnabled = messageDeliveryTrustEnabled, + servesFollowedReadCounterparty = servesFollowedReadCounterparty, + servesFollowedWriteCounterparty = servesFollowedWriteCounterparty, + servesStrangerWriteCounterparty = servesStrangerWriteCounterparty, hasAttributablePurpose = hasAttributablePurpose, ) @@ -70,51 +72,61 @@ class RelayAuthResolverTest { @Test fun neverAndAlwaysAreUnconditional() { - assertEquals(RelayAuthVerdict.DENY, resolve(inputs(policy = RelayAuthPolicy.NEVER, servesFollowedCounterparty = true))) + assertEquals(RelayAuthVerdict.DENY, resolve(inputs(policy = RelayAuthPolicy.NEVER, isInMyRelayList = 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))) + fun customMyRelaysAndVenuesToggleGatesOwnRelaysAndVenues() { + // On (default): my own relay and any joined venue auto-auth. + assertEquals(RelayAuthVerdict.ALLOW, resolve(inputs(isInMyRelayList = true))) + assertEquals(RelayAuthVerdict.ALLOW, resolve(inputs(servesTrustedVenue = true))) + // Off: even my own relay prompts. + val off = RelayAuthCustomToggles(myRelaysAndVenues = false) + assertEquals(RelayAuthVerdict.ASK, resolve(inputs(isInMyRelayList = true, toggles = off))) + assertEquals(RelayAuthVerdict.ASK, resolve(inputs(servesTrustedVenue = true, toggles = off))) } @Test - fun trustedFollowsAllowsAnyFollowedCounterparty() { - // Reading a followed author's outbox OR reaching them (DM/notification) -> auto-auth, - // independent of the delivery toggle. - assertEquals(RelayAuthVerdict.ALLOW, resolve(inputs(servesFollowedCounterparty = true))) - } - - @Test - fun trustedFollowsAsksToMessageAStrangerUnlessDeliveryToggleOn() { - // Sending to someone I don't follow: prompts by default... - assertEquals(RelayAuthVerdict.ASK, resolve(inputs(servesWriteCounterparty = true, messageDeliveryTrustEnabled = false))) - // ...auto-auths only when the "deliver my messages" toggle is enabled. - assertEquals(RelayAuthVerdict.ALLOW, resolve(inputs(servesWriteCounterparty = true, messageDeliveryTrustEnabled = true))) - } - - @Test - fun deliveryToggleDoesNotCoverReadingAStranger() { - // The delivery toggle is write-only: reading a non-followed author (no write counterparty) - // still prompts even with the toggle on. + fun customReadFollowsToggleGatesReadingFollows() { + assertEquals(RelayAuthVerdict.ALLOW, resolve(inputs(servesFollowedReadCounterparty = true))) assertEquals( RelayAuthVerdict.ASK, - resolve(inputs(servesWriteCounterparty = false, servesFollowedCounterparty = false, messageDeliveryTrustEnabled = true)), + resolve(inputs(servesFollowedReadCounterparty = true, toggles = RelayAuthCustomToggles(readFollows = false))), ) } @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 delivery toggle. - assertEquals(RelayAuthVerdict.ALLOW, resolve(inputs(servesTrustedVenue = true, messageDeliveryTrustEnabled = false))) + fun customMessageFollowsToggleGatesMessagingFollows() { + assertEquals(RelayAuthVerdict.ALLOW, resolve(inputs(servesFollowedWriteCounterparty = true))) + assertEquals( + RelayAuthVerdict.ASK, + resolve(inputs(servesFollowedWriteCounterparty = true, toggles = RelayAuthCustomToggles(messageFollows = false))), + ) } @Test - fun trustedFollowsFallsThroughForStranger() { - // Not my relay, no followed counterparty -> prompt when we know why, else silent deny. + fun customMessageStrangersIsOffByDefault() { + // Default off: messaging a stranger prompts... + assertEquals(RelayAuthVerdict.ASK, resolve(inputs(servesStrangerWriteCounterparty = true))) + // ...on: auto-auth. + assertEquals( + RelayAuthVerdict.ALLOW, + resolve(inputs(servesStrangerWriteCounterparty = true, toggles = RelayAuthCustomToggles(messageStrangers = true))), + ) + } + + @Test + fun customHasNoToggleForReadingStrangers() { + // Reading a non-followed author (no matching category) always prompts, even with every + // toggle on — there is deliberately no "read strangers" trust category. + val allOn = RelayAuthCustomToggles(myRelaysAndVenues = true, readFollows = true, messageFollows = true, messageStrangers = true) + assertEquals(RelayAuthVerdict.ASK, resolve(inputs(toggles = allOn, hasAttributablePurpose = true))) + } + + @Test + fun customFallsThroughForUncoveredSituation() { + // Nothing matches -> prompt when we know why, else silent deny. assertEquals(RelayAuthVerdict.ASK, resolve(inputs(hasAttributablePurpose = true))) assertEquals(RelayAuthVerdict.DENY, resolve(inputs(hasAttributablePurpose = false))) }