From cb7ba7dbf7fe3db6a7e8ac22e55d7bd64bda84d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 14:21:40 +0000 Subject: [PATCH] feat: remember relay auth "log in" for the rest of the session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A NIP-42 challenge is not a one-off: relays re-challenge on every reconnect, and the client reconnects constantly (network changes, doze, app switches). Answering the prompt without the "remember" switch authorized only the single in-flight challenge, so the same relay asked the same question again minutes later — the prompt fatigue that pushes users into "always allow" on a relay they only wanted to try once. Adds RelayAuthSessionGrants: a per-account, in-memory set of relays approved during this run of the app. It lives on Account, so it dies with the process and at logout — that is what keeps it distinct from the stored ALLOW the "remember" switch writes to disk. Wiring: - RelayAuthInputs/RelayAuthResolver gain hasSessionGrant, ranked below the stored override so a later "never allow" takes effect immediately, and below the block list which still wins outright. Not gated on isFirstParty: an explicit answer for this relay outranks any inference about it. - AuthCoordinator records the grant on ALLOW_ONCE. - setDecision/clearDecision drop the grant, so "follows your rules again" after removing an exception is true rather than silently still allowed. - Relay auth settings lists the grants under "Just for now", each row promotable to a real exception or forgettable with an undo. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Rado2dnqpbCuCUyCd3trQz --- .../vitorpamplona/amethyst/model/Account.kt | 7 + .../authCommand/model/AuthCoordinator.kt | 9 +- .../model/RelayAuthPermissionLedger.kt | 40 +++- .../authCommand/model/RelayAuthPromptBus.kt | 5 +- .../model/RelayAuthSessionGrants.kt | 62 +++++++ .../relayauth/RelayAuthSettingsScreen.kt | 87 ++++++++- amethyst/src/main/res/values/strings.xml | 4 + .../model/RelayAuthSessionGrantsTest.kt | 175 ++++++++++++++++++ .../commons/relayauth/RelayAuthResolver.kt | 15 +- .../relayauth/RelayAuthResolverTest.kt | 36 ++++ 10 files changed, 429 insertions(+), 11 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthSessionGrants.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthSessionGrantsTest.kt 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 ee50121463..b763402e07 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -147,6 +147,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 @@ -406,6 +407,11 @@ class Account( // answered without a disk read. Backed by a per-account file (see AccountCacheState). val relayAuthPermissions = RelayAuthPermissionCache(relayAuthPermissionStore, scope) + // 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 @@ -414,6 +420,7 @@ class Account( RelayAuthPermissionLedger( store = relayAuthPermissions, globalPolicy = { settings.defaultRelayAuthPolicy.value }, + sessionGrants = relayAuthSessionGrants, customToggles = { RelayAuthCustomToggles( myRelaysAndVenues = settings.relayAuthTrustMyRelaysAndVenues.value, 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 7ff6533dc3..038013711e 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 @@ -140,7 +140,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/RelayAuthPermissionLedger.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPermissionLedger.kt index a4170280f8..2aefc81fca 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,13 @@ 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. Empty by default — a ledger built without + * one simply has no session memory. + */ + val sessionGrants: RelayAuthSessionGrants = RelayAuthSessionGrants(), val customToggles: () -> RelayAuthCustomToggles = { RelayAuthCustomToggles() }, val isInMyRelayList: (String) -> Boolean = { false }, val isBlocked: (String) -> Boolean = { false }, @@ -81,6 +89,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 +146,35 @@ 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]. + */ + fun grantForSession(relayUrl: String) = sessionGrants.grant(relayUrl) + + /** Forgets this session's grant for [relayUrl], so the next challenge is decided from scratch. */ + fun revokeSessionGrant(relayUrl: String) = sessionGrants.revoke(relayUrl) + + /** + * 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. + */ suspend fun setDecision( relayUrl: String, decision: RelayAuthDecision, - ) = store.storeDecision(relayUrl, decision) + ) { + sessionGrants.revoke(relayUrl) + store.storeDecision(relayUrl, decision) + } /** 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 209329fa47..aa02af4420 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,36 @@ 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 undoLabel = stringResource(R.string.relay_auth_undo) + fun forgetSessionGrant(url: String) { + ledger.revokeSessionGrant(url) + scope.launch { + val result = + snackbarHostState.showSnackbar( + message = sessionForgottenLabel.format(url.normalizeRelayUrlOrNull()?.displayUrl() ?: url), + actionLabel = undoLabel, + withDismissAction = true, + ) + if (result == SnackbarResult.ActionPerformed) ledger.grantForSession(url) + } + } + fun removeException(url: String) { scope.launch { val previous = exceptions[url] @@ -288,6 +309,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 +522,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 97e1ef306e..b2a94d068f 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1195,6 +1195,10 @@ 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. 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/RelayAuthSessionGrantsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthSessionGrantsTest.kt new file mode 100644 index 0000000000..0d941b4b49 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthSessionGrantsTest.kt @@ -0,0 +1,175 @@ +/* + * 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.RelayAuthPolicy +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.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: InMemoryRelayAuthPermissionStore = InMemoryRelayAuthPermissionStore(), + blocked: Set = emptySet(), + ) = RelayAuthPermissionLedger( + store = store, + globalPolicy = { RelayAuthPolicy.CUSTOM }, + 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))) + } + + @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) + } +} 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 3d095ca38b..26b61fe48b 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]. @@ -68,6 +70,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, @@ -84,13 +87,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. * * The [RelayAuthPolicy.CUSTOM] grant additionally requires [RelayAuthInputs.isFirstParty]: under * "decide per relay" an account never reveals its identity *without being asked* on a relay it has no @@ -112,6 +118,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 9b97ddeea4..9ababd1fdd 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)))