diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 18f036052f..0abbc5f37d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -67,6 +67,7 @@ import com.vitorpamplona.amethyst.commons.model.privateChats.hasEncryptedContent import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderAccount import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthCustomToggles import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.commons.service.pow.PersistedPoWJob import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory @@ -151,6 +152,7 @@ import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.InMemoryRelayAuthPermissionStore import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthPermissionCache import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthPermissionLedger +import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthSessionGrants import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthVenues import com.vitorpamplona.amethyst.service.relayClient.chatDelivery.ChatDeliveryTracker import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyRequestsCache @@ -427,6 +429,11 @@ class Account( // BuzzAttestationPreferences. val buzzAttestation = BuzzHeldAttestations(pubKey) + // The relays this account approved by answering the NIP-42 prompt *without* the "remember" + // switch. Deliberately in-memory only: it dies with this Account (i.e. with the process, or at + // logout), which is what makes it a session grant rather than a stored ALLOW. + val relayAuthSessionGrants = RelayAuthSessionGrants() + // Per-account NIP-42 policy evaluator (blocked → per-relay override → global policy → prompt), // reading THIS account's own toggles, relay lists and follow graph. Cached here so every AUTH // path (foreground screen + background notification consumer) shares one instance, and so an @@ -435,6 +442,7 @@ class Account( RelayAuthPermissionLedger( store = relayAuthPermissions, globalPolicy = { settings.defaultRelayAuthPolicy.value }, + sessionGrants = relayAuthSessionGrants, customToggles = { RelayAuthCustomToggles( myRelaysAndVenues = settings.relayAuthTrustMyRelaysAndVenues.value, @@ -455,6 +463,27 @@ class Account( isVenueHostRelay = { relayUrl -> relayUrl.normalizeRelayUrlOrNull()?.let { it in venueHostRelays() } ?: false }, ) + /** + * Sets the global NIP-42 policy, dropping every session grant when it becomes + * [RelayAuthPolicy.NEVER]. + * + * The two halves belong together, which is why they live here instead of in the settings screen + * that used to pair them: a session grant outranks the policy (see + * [com.vitorpamplona.amethyst.commons.relayauth.RelayAuthResolver]), so "never log in" only + * means what it says if the casual one-tap answers go with it. As a composable's `onClick` that + * was a property of one screen rather than of the account, and any other caller of + * [AccountSettings.changeDefaultRelayAuthPolicy] silently reintroduced grants that outlive the + * switch-it-all-off answer. + * + * Stored Always/Never exceptions are deliberately left alone: those outrank the policy by + * design, and the settings screen lists them, so they are a standing answer rather than a + * casual one. + */ + fun changeDefaultRelayAuthPolicy(policy: RelayAuthPolicy) { + settings.changeDefaultRelayAuthPolicy(policy) + if (policy == RelayAuthPolicy.NEVER) relayAuthSessionGrants.clear() + } + /** * Relays that exist here because *this account* joined a room on them: the host of every NIP-29 * relay group on its kind-10009 list, plus the relays of every Concord community on its @@ -3595,6 +3624,20 @@ class Account( init { Log.d("AccountRegisterObservers", "Init") + // Blocking a relay has to forget any "just for now" login to it, or unblocking later would + // silently resume authenticating off an answer given before the block. Blocking is the + // strongest signal available here — the weaker "never allow" already drops the grant via + // RelayAuthPermissionLedger.setDecision, so it would be odd for the stronger one not to. + // + // Observed rather than hooked onto the local block action because the kind-10006 list is + // shared: a block published by another client arrives as a flow update with no call of ours + // behind it. + scope.launch { + blockedRelayList.flow.collect { blocked -> + relayAuthLedger.revokeSessionGrantsFor(blocked.map { it.url }) + } + } + // Start the Cashu wallet state observers AFTER all field initializers // complete — auto-redeem can fire as soon as start() returns, and it // calls back into sendLiterallyEverywhere which depends on 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 ac9afc8d2e..cd005c4e41 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 @@ -143,7 +143,14 @@ class AuthCoordinator( ) } when (choice) { - UserAuthChoice.ALLOW_ONCE -> true + UserAuthChoice.ALLOW_ONCE -> { + // Not literally once: relays re-challenge on every reconnect, + // so answering only the in-flight challenge meant the same + // dialog came back minutes later. The grant is kept in memory + // for the rest of this run and dies with the process. + account.relayAuthLedger.grantForSession(relayUrl.url) + true + } UserAuthChoice.ALWAYS_ALLOW -> { account.relayAuthLedger.setDecision(relayUrl.url, RelayAuthDecision.ALLOW) true diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthDecisionResolver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthDecisionResolver.kt deleted file mode 100644 index 5223e51add..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthDecisionResolver.kt +++ /dev/null @@ -1,68 +0,0 @@ -/* - * 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.service.relayClient.authCommand.model - -import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision -import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthVerdict - -/** - * Combines every logged-in account's [RelayAuthVerdict] into a single decision for whether to - * authenticate with a relay — and, when the user is asked, what to remember. Pulled out of - * [AuthCoordinator] so the policy is unit-testable without the relay client, signers, or Compose. - */ -object AuthDecisionResolver { - /** - * @param shouldAuth whether to sign the NIP-42 auth challenge. - * @param remember a per-relay override to persist ([RelayAuthDecision.ALLOW]/[RelayAuthDecision.DENY]), - * or null to leave the relay's stored decision untouched. - */ - data class Outcome( - val shouldAuth: Boolean, - val remember: RelayAuthDecision? = null, - ) - - /** - * Resolves the combined verdict: - * - **No verdicts** (no ledgers watching) => auto-authenticate; the caller has no policy to apply. - * - **Any [RelayAuthVerdict.ALLOW]** => authenticate without asking. - * - **Any [RelayAuthVerdict.ASK]** (and none allow) => call [prompt] and act on the user's choice, - * remembering ALLOW/DENY for Always-allow / Block. - * - **Otherwise** (all DENY) => do not authenticate. - * - * [prompt] is only invoked in the ASK case, so the no-op paths never build a dialog. - */ - suspend fun resolve( - verdicts: List, - prompt: suspend () -> UserAuthChoice, - ): Outcome = - when { - verdicts.isEmpty() -> Outcome(shouldAuth = true) - verdicts.any { it == RelayAuthVerdict.ALLOW } -> Outcome(shouldAuth = true) - verdicts.any { it == RelayAuthVerdict.ASK } -> - when (prompt()) { - UserAuthChoice.ALLOW_ONCE -> Outcome(shouldAuth = true) - UserAuthChoice.ALWAYS_ALLOW -> Outcome(shouldAuth = true, remember = RelayAuthDecision.ALLOW) - UserAuthChoice.BLOCK -> Outcome(shouldAuth = false, remember = RelayAuthDecision.DENY) - UserAuthChoice.DISMISS -> Outcome(shouldAuth = false) - } - else -> Outcome(shouldAuth = false) - } -} 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 a4170280f8..ec6df47d4e 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 @@ -33,8 +33,9 @@ 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. Under [RelayAuthPolicy.CUSTOM] the + * Precedence (see [RelayAuthResolver]): blocked-relay list → per-relay override → this session's + * in-memory grants ([sessionGrants]) → global [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. * @@ -49,6 +50,18 @@ import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthVerdict class RelayAuthPermissionLedger( val store: RelayAuthPermissionStore, val globalPolicy: () -> RelayAuthPolicy, + /** + * Relays this account already approved during this run of the app. Answering the prompt without + * the "remember" switch records the grant here, so the same relay's next reconnect is answered + * silently instead of raising the same dialog again. + * + * Required, with no default, because it is shared state: one account's grants have to be the + * same object on every AUTH path (the foreground screen and the background notification + * consumer both decide off this ledger — see [com.vitorpamplona.amethyst.model.Account]). A + * default would let a ledger built without one quietly get a private set instead, so answers + * given on one path would not be seen on the other and the dialog would come back anyway. + */ + val sessionGrants: RelayAuthSessionGrants, val customToggles: () -> RelayAuthCustomToggles = { RelayAuthCustomToggles() }, val isInMyRelayList: (String) -> Boolean = { false }, val isBlocked: (String) -> Boolean = { false }, @@ -81,6 +94,7 @@ class RelayAuthPermissionLedger( RelayAuthInputs( storedOverride = store.loadDecision(ctx.relayUrl), isBlocked = isBlocked(ctx.relayUrl), + hasSessionGrant = sessionGrants.isGranted(ctx.relayUrl), policy = globalPolicy(), toggles = customToggles(), isInMyRelayList = isInMyRelayList(ctx.relayUrl), @@ -137,14 +151,72 @@ class RelayAuthPermissionLedger( if (additions.isNotEmpty()) store.recordUse(ctx.relayUrl, additions) } - /** Stores a per-relay override for [relayUrl]. */ + /** + * Remembers a "log in" answer for [relayUrl] until the app is restarted, so the relay's next + * reconnect doesn't ask again. Nothing is written to disk — see [RelayAuthSessionGrants]. + * + * Refused, returning false, while [globalPolicy] is [RelayAuthPolicy.NEVER]: that is the + * switch-it-all-off answer, and a session grant outranks the policy (see [RelayAuthResolver]), + * so recording one here would quietly re-enable the very thing the user just turned off. The + * settings screen clears existing grants when the policy is set to NEVER; this stops a *new* + * one being written afterwards — which the undo on "forget this login" otherwise did, because + * its snackbar carries an action label and so sits on screen indefinitely, long enough for the + * policy to change underneath it. + * + * Only the policy needs this guard. A stored override arriving in the same window is + * self-protecting: it is ranked *above* the grant, so an ALLOW or DENY written meanwhile + * decides the relay either way. So is the block list, which outranks everything. + */ + fun grantForSession(relayUrl: String): Boolean { + if (globalPolicy() == RelayAuthPolicy.NEVER) return false + sessionGrants.grant(relayUrl) + return true + } + + /** Forgets this session's grant for [relayUrl], so the next challenge is decided from scratch. */ + fun revokeSessionGrant(relayUrl: String) = sessionGrants.revoke(relayUrl) + + /** + * Forgets this session's grants for every relay in [blockedRelayUrls] — the kind-10006 block + * list, which outranks everything else on the decision path. + * + * Blocking already denies while it is in force, so this is about what happens *after* it is + * lifted: without it, unblocking would resume authenticating off an answer given before the + * block. The weaker "never allow" drops the grant too (see [setDecision]), so the stronger + * signal has to as well. + */ + fun revokeSessionGrantsFor(blockedRelayUrls: Collection) = blockedRelayUrls.forEach(sessionGrants::revoke) + + /** + * Stores a per-relay override for [relayUrl]. + * + * Also drops any session grant: the stored decision is now the whole answer for this relay, so + * leaving the transient one behind would let a later [clearDecision] ("follows your rules again") + * silently keep authenticating off a grant the user can no longer see. + * + * The two writes are not atomic — [RelayAuthPermissionCache] only publishes an override to memory + * *after* its disk write returns — so a challenge arriving between them must never see neither. + * Which side to fail on depends on the decision: + * - **DENY** revokes first. The window then asks or denies, never signs: a user who just pressed + * "never allow" must not get one more AUTH out of the grant they are replacing. + * - **ALLOW** revokes last, so the grant still covers the window. Revoking first left the relay + * momentarily undecided, which re-prompted the user who had just pressed "always" — the very + * dialog this whole feature exists to stop. + */ suspend fun setDecision( relayUrl: String, decision: RelayAuthDecision, - ) = store.storeDecision(relayUrl, decision) + ) { + if (decision == RelayAuthDecision.DENY) sessionGrants.revoke(relayUrl) + store.storeDecision(relayUrl, decision) + sessionGrants.revoke(relayUrl) + } /** Removes the per-relay override for [relayUrl], reverting to the global policy. */ - suspend fun clearDecision(relayUrl: String) = store.clearDecision(relayUrl) + suspend fun clearDecision(relayUrl: String) { + sessionGrants.revoke(relayUrl) + store.clearDecision(relayUrl) + } /** All per-relay overrides — for the settings screen. */ suspend fun allDecisions(): Map = store.allDecisions() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPromptBus.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPromptBus.kt index a8ad8ea46a..a7c000ad88 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPromptBus.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPromptBus.kt @@ -31,7 +31,10 @@ import kotlinx.coroutines.withTimeoutOrNull /** What the user chose when asked whether to authenticate with a relay. */ enum class UserAuthChoice { - /** Authenticate this one time; keep asking next time. */ + /** + * Authenticate, and remember it for this run of the app only (see [RelayAuthSessionGrants]) — + * reconnects to the same relay are answered silently, and the next cold start asks again. + */ ALLOW_ONCE, /** Authenticate now and remember ALLOW for this relay. */ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthSessionGrants.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthSessionGrants.kt new file mode 100644 index 0000000000..b02d83114a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthSessionGrants.kt @@ -0,0 +1,62 @@ +/* + * 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.service.relayClient.authCommand.model + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +/** + * The relays this account said "log in" to during **this run of the app**, without asking to + * remember the answer permanently. + * + * A NIP-42 challenge is not a one-off event: relays re-challenge on every reconnect, and a client + * that drops its socket on network changes, doze, or an app switch reconnects constantly. Answering + * the dialog only for the single in-flight challenge therefore meant the same relay asked the same + * question again minutes later — the prompt fatigue that makes users reach for "always allow" on a + * relay they only wanted to try once. Holding the grant in memory keeps the answer alive for exactly + * as long as the user is plausibly still doing the thing they answered for. + * + * Deliberately **not** persisted: it is dropped when the process dies (this object lives on + * [com.vitorpamplona.amethyst.model.Account], which is built per process) and when the account is + * logged out, so the next cold start asks again. That is the whole difference from + * [com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision.ALLOW], which the "remember" + * switch writes to disk. + * + * Scoped per account because the grant authorizes revealing *one* identity: the prompt names whose + * npub is at stake, so account B is never covered by an answer given for account A. + */ +class RelayAuthSessionGrants { + private val granted = MutableStateFlow>(emptySet()) + + /** Observable so the settings screen can list — and revoke — what is currently granted. */ + val grants: StateFlow> = granted.asStateFlow() + + /** Non-suspending: this is read on the hot NIP-42 decision path. */ + fun isGranted(relayUrl: String): Boolean = relayUrl in granted.value + + fun grant(relayUrl: String) = granted.update { it + relayUrl } + + fun revoke(relayUrl: String) = granted.update { it - relayUrl } + + fun clear() = granted.update { emptySet() } +} 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 463c4d34a0..196e631cb7 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 @@ -123,6 +123,7 @@ fun RelayAuthSettingsScreen( val globalPolicy by account.settings.defaultRelayAuthPolicy.collectAsState() val blockedRelays by account.blockedRelayList.flow.collectAsState() + val sessionGrants by account.relayAuthSessionGrants.grants.collectAsState() var exceptions by remember { mutableStateOf>(emptyMap()) } var rationales by remember { mutableStateOf>>>(emptyMap()) } @@ -139,16 +140,47 @@ fun RelayAuthSettingsScreen( val exceptionUrls = remember(exceptions) { exceptions.keys.sorted() } val blockedUrls = remember(blockedRelays) { blockedRelays.map { it.url }.sorted() } + // Answers given to the prompt without the "remember" switch. Any relay that also carries a rule + // above is shown there instead — the rule is what actually decides it. + val sessionUrls = + remember(sessionGrants, exceptions, blockedUrls) { + (sessionGrants - exceptions.keys - blockedUrls.toSet()).sorted() + } // The log is everything we have a record of that is not already stated above as a rule. val logUrls = - remember(exceptions, rationales, lastUsed, blockedUrls) { - ((rationales.keys + lastUsed.keys) - exceptions.keys - blockedUrls.toSet()) + remember(exceptions, rationales, lastUsed, blockedUrls, sessionUrls) { + ((rationales.keys + lastUsed.keys) - exceptions.keys - blockedUrls.toSet() - sessionUrls.toSet()) .sortedByDescending { lastUsed[it] ?: 0L } } val removedLabel = stringResource(R.string.relay_auth_exception_removed_undo) + val sessionForgottenLabel = stringResource(R.string.relay_auth_session_forgotten_undo) + val sessionUndoBlockedLabel = stringResource(R.string.relay_auth_session_undo_blocked) val undoLabel = stringResource(R.string.relay_auth_undo) + fun forgetSessionGrant(url: String) { + ledger.revokeSessionGrant(url) + scope.launch { + val display = url.normalizeRelayUrlOrNull()?.displayUrl() ?: url + val result = + snackbarHostState.showSnackbar( + message = sessionForgottenLabel.format(display), + actionLabel = undoLabel, + withDismissAction = true, + ) + // An action label makes Material3 show this indefinitely, so the undo can be tapped long + // after the fact — including after the policy above was switched to "Never log in", which + // clears every grant. The ledger refuses to write a new one in that state; report that + // instead of leaving a tapped undo looking like it silently did nothing. + if (result == SnackbarResult.ActionPerformed && !ledger.grantForSession(url)) { + snackbarHostState.showSnackbar( + message = sessionUndoBlockedLabel.format(display), + withDismissAction = true, + ) + } + } + } + fun removeException(url: String) { scope.launch { val previous = exceptions[url] @@ -209,7 +241,10 @@ fun RelayAuthSettingsScreen( selected = globalPolicy == policy, title = stringResource(titleRes), description = stringResource(descRes), - onClick = { account.settings.changeDefaultRelayAuthPolicy(policy) }, + // Account, not settings: choosing "never log in" also drops this + // session's grants, and that pairing is the account's rule rather + // than this screen's. See Account.changeDefaultRelayAuthPolicy. + onClick = { account.changeDefaultRelayAuthPolicy(policy) }, ) } } @@ -288,6 +323,32 @@ fun RelayAuthSettingsScreen( } } + // Only rendered when something is granted: an empty card here would advertise a list the + // user has no way to add to from this screen. + if (sessionUrls.isNotEmpty()) { + item { + Spacer(Modifier.height(20.dp)) + GroupHeader(stringResource(R.string.relay_auth_session_section)) + Spacer(Modifier.height(8.dp)) + } + itemsIndexed(sessionUrls, key = { _, url -> "session:$url" }) { index, url -> + GroupedRow(index, sessionUrls.size) { + SessionGrantRow( + url = url, + accountViewModel = accountViewModel, + nav = nav, + onPromote = { next -> + scope.launch { + ledger.setDecision(url, next) + reloadKey++ + } + }, + onForget = { forgetSessionGrant(url) }, + ) + } + } + } + item { Spacer(Modifier.height(20.dp)) GroupHeader(stringResource(R.string.relay_auth_blocked_section)) @@ -475,6 +536,42 @@ private fun ExceptionRow( ) } +/** + * A relay the user logged in to from the prompt without asking to remember it. It behaves like an + * ALLOW exception for the rest of this run and then disappears, so it gets its own group rather than + * sitting in "Exceptions" — nothing here survives a restart. + */ +@Composable +private fun SessionGrantRow( + url: String, + accountViewModel: AccountViewModel, + nav: INav, + onPromote: (RelayAuthDecision) -> Unit, + onForget: () -> Unit, +) { + RelayRowFrame( + url = url, + accountViewModel = accountViewModel, + nav = nav, + subtitle = { + Text( + text = stringResource(R.string.relay_auth_session_row_desc), + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp), + ) + }, + trailing = { + // Neither segment is selected: a session grant is not an override, and promoting it to + // one is exactly what these two buttons are for. + DecisionSegments(current = null, onDecision = onPromote) + IconButton(onClick = onForget) { + Icon(MaterialSymbols.Close, contentDescription = stringResource(R.string.relay_auth_forget_session)) + } + }, + ) +} + /** A relay on the kind-10006 block list: a hard DENY that outranks everything else on this screen. */ @Composable private fun BlockedRow( diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 9b7a06a4bc..89bd5d8792 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1198,6 +1198,11 @@ Remove exception Exception removed. %1$s follows your rules again. Undo + Just for now + Logged in until you restart Amethyst + Forget this login + %1$s will ask again the next time it needs you. + Not restored. “Never log in” is on, so Amethyst won\'t log in to %1$s. Blocked by your block list Amethyst never logs in to blocked relays. Nothing blocked. Relays you block will never be logged in to, whatever you set here. diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthDecisionResolverTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthDecisionResolverTest.kt deleted file mode 100644 index 4f92e7450b..0000000000 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthDecisionResolverTest.kt +++ /dev/null @@ -1,117 +0,0 @@ -/* - * 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.service.relayClient.authCommand.model - -import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision -import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthVerdict -import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test - -class AuthDecisionResolverTest { - /** A prompt that must never be called; fails the test if the resolver asks the user. */ - private val neverPrompt: suspend () -> UserAuthChoice = { error("prompt() should not be called") } - - @Test - fun noVerdictsAutoAuthenticatesWithoutPrompting() = - runTest { - val outcome = AuthDecisionResolver.resolve(emptyList(), neverPrompt) - assertTrue(outcome.shouldAuth) - assertNull(outcome.remember) - } - - @Test - fun anyAllowAuthenticatesWithoutPrompting() = - runTest { - val outcome = - AuthDecisionResolver.resolve( - listOf(RelayAuthVerdict.DENY, RelayAuthVerdict.ALLOW, RelayAuthVerdict.ASK), - neverPrompt, - ) - assertTrue(outcome.shouldAuth) - assertNull(outcome.remember) - } - - @Test - fun allDenyDoesNotAuthenticateAndDoesNotPrompt() = - runTest { - val outcome = - AuthDecisionResolver.resolve( - listOf(RelayAuthVerdict.DENY, RelayAuthVerdict.DENY), - neverPrompt, - ) - assertFalse(outcome.shouldAuth) - assertNull(outcome.remember) - } - - @Test - fun askAllowOnceAuthenticatesButRemembersNothing() = - runTest { - val outcome = - AuthDecisionResolver.resolve(listOf(RelayAuthVerdict.ASK)) { UserAuthChoice.ALLOW_ONCE } - assertTrue(outcome.shouldAuth) - assertNull(outcome.remember) - } - - @Test - fun askAlwaysAllowAuthenticatesAndRemembersAllow() = - runTest { - val outcome = - AuthDecisionResolver.resolve(listOf(RelayAuthVerdict.ASK)) { UserAuthChoice.ALWAYS_ALLOW } - assertTrue(outcome.shouldAuth) - assertEquals(RelayAuthDecision.ALLOW, outcome.remember) - } - - @Test - fun askBlockDoesNotAuthenticateAndRemembersDeny() = - runTest { - val outcome = - AuthDecisionResolver.resolve(listOf(RelayAuthVerdict.ASK)) { UserAuthChoice.BLOCK } - assertFalse(outcome.shouldAuth) - assertEquals(RelayAuthDecision.DENY, outcome.remember) - } - - @Test - fun askDismissDoesNotAuthenticateAndRemembersNothing() = - runTest { - val outcome = - AuthDecisionResolver.resolve(listOf(RelayAuthVerdict.ASK)) { UserAuthChoice.DISMISS } - assertFalse(outcome.shouldAuth) - assertNull(outcome.remember) - } - - @Test - fun askIsOnlyReachedWhenNoAccountAllows() = - runTest { - // ALLOW present alongside ASK must short-circuit to auth without prompting. - var prompted = false - val outcome = - AuthDecisionResolver.resolve(listOf(RelayAuthVerdict.ASK, RelayAuthVerdict.ALLOW)) { - prompted = true - UserAuthChoice.BLOCK - } - assertTrue(outcome.shouldAuth) - assertFalse(prompted) - } -} 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 8b24f098cb..f3068d9195 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.CUSTOM }) + private fun ledger(store: RelayAuthPermissionStore) = RelayAuthPermissionLedger(store, { RelayAuthPolicy.CUSTOM }, RelayAuthSessionGrants()) @Test fun recordsCounterpartiesGroupedByPurpose() = diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthReadFollowsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthReadFollowsTest.kt index 4d41a8b9f3..407eba4297 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthReadFollowsTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthReadFollowsTest.kt @@ -62,6 +62,7 @@ class RelayAuthReadFollowsTest { RelayAuthPermissionLedger( store = NoStore(), globalPolicy = { RelayAuthPolicy.CUSTOM }, + sessionGrants = RelayAuthSessionGrants(), customToggles = { toggles }, isFollowed = { it == followed }, ) diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthSessionGrantsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthSessionGrantsTest.kt new file mode 100644 index 0000000000..0ae3d10c76 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthSessionGrantsTest.kt @@ -0,0 +1,310 @@ +/* + * 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.service.relayClient.authCommand.model + +import com.vitorpamplona.amethyst.commons.relayauth.AuthPurpose +import com.vitorpamplona.amethyst.commons.relayauth.AuthPurposeKind +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthContext +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthVerdict +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * "Log in" without the remember switch has to survive the relay's next reconnect, or the same dialog + * comes back every time the socket drops — which is what pushed users into "always allow". + * + * These tests drive [RelayAuthPermissionLedger] rather than the pure resolver, because the thing worth + * pinning is that the grant is consulted on the real decision path and that the persisted rules still + * outrank it. + */ +class RelayAuthSessionGrantsTest { + private val relay = "wss://auth.example.com/" + private val other = "wss://elsewhere.example.com/" + + private fun ledger( + grants: RelayAuthSessionGrants = RelayAuthSessionGrants(), + store: RelayAuthPermissionStore = InMemoryRelayAuthPermissionStore(), + blocked: Set = emptySet(), + policy: () -> RelayAuthPolicy = { RelayAuthPolicy.CUSTOM }, + ) = RelayAuthPermissionLedger( + store = store, + globalPolicy = policy, + sessionGrants = grants, + isBlocked = { it in blocked }, + ) + + /** A challenge we can explain but have no automatic rule for: the ASK case. */ + private fun askable(relayUrl: String) = + RelayAuthContext( + relayUrl, + listOf(AuthPurpose(AuthPurposeKind.MY_INBOX)), + ) + + @Test + fun withoutAGrantTheSameRelayKeepsAsking() = + runTest { + val ledger = ledger() + assertEquals(RelayAuthVerdict.ASK, ledger.decide(askable(relay))) + assertEquals(RelayAuthVerdict.ASK, ledger.decide(askable(relay))) + } + + @Test + fun aSessionGrantAnswersEveryLaterReconnect() = + runTest { + val ledger = ledger() + assertEquals(RelayAuthVerdict.ASK, ledger.decide(askable(relay))) + + ledger.grantForSession(relay) + + assertEquals(RelayAuthVerdict.ALLOW, ledger.decide(askable(relay))) + assertEquals(RelayAuthVerdict.ALLOW, ledger.decide(askable(relay))) + } + + @Test + fun aGrantCoversOnlyTheRelayItWasGivenFor() = + runTest { + val ledger = ledger() + ledger.grantForSession(relay) + + assertEquals(RelayAuthVerdict.ALLOW, ledger.decide(askable(relay))) + assertEquals(RelayAuthVerdict.ASK, ledger.decide(askable(other))) + } + + @Test + fun grantsAreNeverWrittenToTheStore() = + runTest { + val store = InMemoryRelayAuthPermissionStore() + val ledger = ledger(store = store) + ledger.grantForSession(relay) + + // Nothing persisted: a fresh process (a ledger over the same disk, with empty session + // memory) is back to asking. + assertEquals(emptyMap(), store.allDecisions()) + assertEquals(RelayAuthVerdict.ASK, ledger(store = store).decide(askable(relay))) + } + + @Test + fun blockListOutranksAGrant() = + runTest { + val ledger = ledger(blocked = setOf(relay)) + ledger.grantForSession(relay) + + assertEquals(RelayAuthVerdict.DENY, ledger.decide(askable(relay))) + } + + @Test + fun neverAllowTakesEffectImmediatelyOverAGrant() = + runTest { + val grants = RelayAuthSessionGrants() + val ledger = ledger(grants) + ledger.grantForSession(relay) + assertEquals(RelayAuthVerdict.ALLOW, ledger.decide(askable(relay))) + + // The user answers "Never allow" on a later prompt for the same relay. + ledger.setDecision(relay, RelayAuthDecision.DENY) + + assertEquals(RelayAuthVerdict.DENY, ledger.decide(askable(relay))) + assertFalse(grants.isGranted(relay)) + } + + @Test + fun clearingAnExceptionDropsTheGrantSoTheRelayReallyFollowsTheRulesAgain() = + runTest { + val grants = RelayAuthSessionGrants() + val ledger = ledger(grants) + ledger.grantForSession(relay) + ledger.setDecision(relay, RelayAuthDecision.ALLOW) + + ledger.clearDecision(relay) + + assertFalse(grants.isGranted(relay)) + assertEquals(RelayAuthVerdict.ASK, ledger.decide(askable(relay))) + } + + @Test + fun revokingRestoresTheQuestion() = + runTest { + val grants = RelayAuthSessionGrants() + val ledger = ledger(grants) + ledger.grantForSession(relay) + assertTrue(grants.isGranted(relay)) + + ledger.revokeSessionGrant(relay) + + assertFalse(grants.isGranted(relay)) + assertEquals(RelayAuthVerdict.ASK, ledger.decide(askable(relay))) + } + + /** + * A store whose write suspends until [gate] opens, modelling the real one: the disk write is + * awaited *before* [RelayAuthPermissionCache] publishes the new override to memory, so there is a + * window where the override is not yet readable. + */ + private class GatedStore( + private val gate: CompletableDeferred, + private val inner: RelayAuthPermissionStore = InMemoryRelayAuthPermissionStore(), + ) : RelayAuthPermissionStore by inner { + override suspend fun storeDecision( + relayUrl: String, + decision: RelayAuthDecision, + ) { + gate.await() + inner.storeDecision(relayUrl, decision) + } + } + + @Test + fun promotingAGrantToAlwaysNeverOpensAGapThatRePrompts() = + runTest { + val gate = CompletableDeferred() + val ledger = ledger(store = GatedStore(gate)) + ledger.grantForSession(relay) + + val write = launch { ledger.setDecision(relay, RelayAuthDecision.ALLOW) } + runCurrent() + + // Mid-write the override is not readable yet. If the grant has already been dropped the + // relay is momentarily undecided and a reconnect lands a fresh dialog on a user who just + // pressed "Always" — the exact prompt this feature exists to stop. + assertEquals(RelayAuthVerdict.ALLOW, ledger.decide(askable(relay))) + + gate.complete(Unit) + write.join() + assertEquals(RelayAuthVerdict.ALLOW, ledger.decide(askable(relay))) + } + + @Test + fun neverAllowStopsAuthenticatingBeforeItsWriteLands() = + runTest { + val gate = CompletableDeferred() + val ledger = ledger(store = GatedStore(gate)) + ledger.grantForSession(relay) + + val write = launch { ledger.setDecision(relay, RelayAuthDecision.DENY) } + runCurrent() + + // The opposite bias to the ALLOW case: a user who just said "never" must not have one more + // AUTH signed on the strength of the grant they are replacing. + assertNotEquals(RelayAuthVerdict.ALLOW, ledger.decide(askable(relay))) + + gate.complete(Unit) + write.join() + assertEquals(RelayAuthVerdict.DENY, ledger.decide(askable(relay))) + } + + @Test + fun grantsAreObservableForTheSettingsScreen() { + val grants = RelayAuthSessionGrants() + assertEquals(emptySet(), grants.grants.value) + + grants.grant(relay) + grants.grant(other) + assertEquals(setOf(relay, other), grants.grants.value) + + grants.revoke(relay) + assertEquals(setOf(other), grants.grants.value) + + grants.clear() + assertEquals(emptySet(), grants.grants.value) + } + + @Test + fun aGrantIsRefusedWhileThePolicyIsNever() = + runTest { + val grants = RelayAuthSessionGrants() + val ledger = ledger(grants = grants, policy = { RelayAuthPolicy.NEVER }) + + assertFalse(ledger.grantForSession(relay)) + assertFalse(grants.isGranted(relay)) + assertEquals(RelayAuthVerdict.DENY, ledger.decide(askable(relay))) + } + + @Test + fun undoingAForgetAfterSwitchingToNeverDoesNotResurrectTheGrant() = + runTest { + // The settings screen's undo snackbar carries an action label, so Material3 leaves it up + // indefinitely — the user can switch the whole policy off and only then tap undo. + val grants = RelayAuthSessionGrants() + var policy = RelayAuthPolicy.CUSTOM + val ledger = ledger(grants = grants, policy = { policy }) + + assertTrue(ledger.grantForSession(relay)) + assertEquals(RelayAuthVerdict.ALLOW, ledger.decide(askable(relay))) + + // "Forget this login", then "Never log in" — which also clears what is already granted. + ledger.revokeSessionGrant(relay) + policy = RelayAuthPolicy.NEVER + grants.clear() + + // ...and only now, undo. + assertFalse(ledger.grantForSession(relay)) + assertEquals(RelayAuthVerdict.DENY, ledger.decide(askable(relay))) + } + + @Test + fun theGuardOnlyAppliesToNever() = + runTest { + assertTrue(ledger(policy = { RelayAuthPolicy.CUSTOM }).grantForSession(relay)) + assertTrue(ledger(policy = { RelayAuthPolicy.ALWAYS }).grantForSession(relay)) + } + + @Test + fun aStoredDecisionTakenDuringTheUndoWindowNeedsNoGuardBecauseItOutranksTheGrant() = + runTest { + // Why the guard is narrowed to the policy: an override written while the snackbar was up + // is ranked above the grant, so restoring the grant cannot undo the user's newer answer. + val ledger = ledger() + + ledger.revokeSessionGrant(relay) + ledger.setDecision(relay, RelayAuthDecision.DENY) + + assertTrue(ledger.grantForSession(relay)) + assertEquals(RelayAuthVerdict.DENY, ledger.decide(askable(relay))) + } + + @Test + fun blockingARelayForgetsItsSessionGrantSoUnblockingDoesNotResumeIt() = + runTest { + // While the block is in force the relay is denied whatever the grant says, so what this + // pins is the state left behind for when the block is lifted. + val grants = RelayAuthSessionGrants() + val ledger = ledger(grants = grants) + + ledger.grantForSession(relay) + ledger.grantForSession(other) + + ledger.revokeSessionGrantsFor(listOf(relay)) + + assertEquals(setOf(other), grants.grants.value) + assertEquals(RelayAuthVerdict.ASK, ledger.decide(askable(relay))) + assertEquals(RelayAuthVerdict.ALLOW, ledger.decide(askable(other))) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthVenueCoverageTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthVenueCoverageTest.kt index 6b423aa049..f34e6a59f9 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthVenueCoverageTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthVenueCoverageTest.kt @@ -62,6 +62,7 @@ class RelayAuthVenueCoverageTest { RelayAuthPermissionLedger( store = NoStore(), globalPolicy = { RelayAuthPolicy.CUSTOM }, + sessionGrants = RelayAuthSessionGrants(), customToggles = { toggles }, isTrustedVenue = { _, venueId -> venueId == joinedGroupId || venueId == joinedCommunityId }, isVenueHostRelay = { it == groupRelay || it == concordRelay }, 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 f3dd99dae5..aee001ad97 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 @@ -45,6 +45,8 @@ data class RelayAuthCustomToggles( * 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 hasSessionGrant the user already answered "log in" for this relay during this run of the + * app, without asking to remember it permanently. Held in memory only, so it dies with the process. * @param isBlocked the relay is on the user's blocked-relay list (kind 10006). * @param policy the top-level [RelayAuthPolicy]. * @param toggles the [RelayAuthCustomToggles] applied when [policy] is [RelayAuthPolicy.CUSTOM]. @@ -69,6 +71,7 @@ data class RelayAuthCustomToggles( data class RelayAuthInputs( val storedOverride: RelayAuthDecision?, val isBlocked: Boolean, + val hasSessionGrant: Boolean = false, val policy: RelayAuthPolicy, val toggles: RelayAuthCustomToggles, val isInMyRelayList: Boolean, @@ -85,13 +88,16 @@ data class RelayAuthInputs( * * 1. Blocked-relay list → [RelayAuthVerdict.DENY] (never reveal identity to a blocked relay). * 2. Explicit per-relay override → honor it. - * 3. Top-level [RelayAuthPolicy]: + * 3. [RelayAuthInputs.hasSessionGrant] → [RelayAuthVerdict.ALLOW]. Ranked *below* the stored override + * so a later "never allow" — the only way a DENY can be written for a relay already granted this + * session — takes effect immediately instead of losing to the in-memory grant. + * 4. Top-level [RelayAuthPolicy]: * - [RelayAuthPolicy.NEVER] → DENY * - [RelayAuthPolicy.ALWAYS] → ALLOW * - [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. + * 5. Fall-through → [RelayAuthVerdict.ASK] when the purpose is known, otherwise DENY. * * Most [RelayAuthPolicy.CUSTOM] grants additionally require [RelayAuthInputs.isFirstParty]: under * "decide per relay" an account never reveals its identity *without being asked* on a relay it has no @@ -118,6 +124,11 @@ object RelayAuthResolver { } } + // The user answered this exact question, for this exact relay, earlier in this session. Not + // gated on isFirstParty: an explicit answer outranks every inference we would otherwise make + // about whether the account belongs here. + if (inputs.hasSessionGrant) return RelayAuthVerdict.ALLOW + return when (inputs.policy) { RelayAuthPolicy.NEVER -> RelayAuthVerdict.DENY // Unconditional, by design: "Always log in" means every relay that asks. Narrowing it to the 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 485344e630..1937b49ea2 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 @@ -36,9 +36,11 @@ class RelayAuthResolverTest { servesStrangerWriteCounterparty: Boolean = false, hasAttributablePurpose: Boolean = true, isFirstParty: Boolean = true, + hasSessionGrant: Boolean = false, ) = RelayAuthInputs( storedOverride = storedOverride, isBlocked = isBlocked, + hasSessionGrant = hasSessionGrant, policy = policy, toggles = toggles, isInMyRelayList = isInMyRelayList, @@ -66,6 +68,40 @@ class RelayAuthResolverTest { ) } + @Test + fun sessionGrantAllowsWhatWouldOtherwiseAsk() { + // Without the grant this is the plain "we can explain it, so ask" case. + assertEquals(RelayAuthVerdict.ASK, resolve(inputs())) + assertEquals(RelayAuthVerdict.ALLOW, resolve(inputs(hasSessionGrant = true))) + } + + @Test + fun sessionGrantSurvivesTheCasesThatWouldNormallySuppressTheAnswer() { + // The two inputs that turn an automatic grant off: no first-party reason to be here, and a + // NEVER policy. An answer the user typed for this exact relay outranks both. + assertEquals( + RelayAuthVerdict.ALLOW, + resolve(inputs(hasSessionGrant = true, isFirstParty = false)), + ) + assertEquals( + RelayAuthVerdict.ALLOW, + resolve(inputs(hasSessionGrant = true, policy = RelayAuthPolicy.NEVER)), + ) + } + + @Test + fun blockedRelayAndStoredDenyBothBeatASessionGrant() { + assertEquals( + RelayAuthVerdict.DENY, + resolve(inputs(hasSessionGrant = true, isBlocked = true)), + ) + // "Never allow" answered later in the same session must take effect immediately. + assertEquals( + RelayAuthVerdict.DENY, + resolve(inputs(hasSessionGrant = true, storedOverride = RelayAuthDecision.DENY)), + ) + } + @Test fun explicitOverrideBeatsPolicy() { assertEquals(RelayAuthVerdict.DENY, resolve(inputs(storedOverride = RelayAuthDecision.DENY, policy = RelayAuthPolicy.ALWAYS)))