From cb7ba7dbf7fe3db6a7e8ac22e55d7bd64bda84d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 14:21:40 +0000 Subject: [PATCH 1/6] 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))) From 91a3cd9fffafeec14fd7bebd21f339b44eda08a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 14:48:54 +0000 Subject: [PATCH 2/6] fix: close two gaps in the relay auth session grant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit follow-up to the previous commit. 1. setDecision revoked the in-memory grant before awaiting the store write, but RelayAuthPermissionCache only publishes an override to memory after its disk write returns. A challenge landing between the two saw neither the grant nor the override, fell through to the policy, and re-prompted — a fresh dialog for a user who had just pressed "Always", which is the exact prompt the feature exists to remove. Fixed with asymmetric ordering, because the two decisions want opposite bias: ALLOW revokes last so the grant covers the window, while DENY revokes first so the window asks or denies but never signs — someone who just pressed "never allow" must not get one more AUTH out of the grant they are replacing. clearDecision needs no change: the old override stays readable across its window, so no gap exists. Covered by a gated store that suspends mid-write; the ALLOW case fails without the reorder. 2. Switching the global policy to "Never log in" left previously granted relays authenticating, since the grant is checked before the policy. Stored exceptions outranking the policy is deliberate and documented, but a casual one-tap grant surviving the switch-it-all-off answer is not the same claim. Clearing grants on NEVER also puts RelayAuthSessionGrants.clear() to use, which was otherwise unreferenced. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Rado2dnqpbCuCUyCd3trQz --- .../model/RelayAuthPermissionLedger.kt | 12 +++- .../relayauth/RelayAuthSettingsScreen.kt | 9 ++- .../model/RelayAuthSessionGrantsTest.kt | 64 ++++++++++++++++++- 3 files changed, 82 insertions(+), 3 deletions(-) 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 2aefc81fca..aa45baf7fa 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 @@ -161,13 +161,23 @@ class RelayAuthPermissionLedger( * 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, ) { - sessionGrants.revoke(relayUrl) + 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. */ 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 aa02af4420..8d084160d8 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 @@ -230,7 +230,14 @@ fun RelayAuthSettingsScreen( selected = globalPolicy == policy, title = stringResource(titleRes), description = stringResource(descRes), - onClick = { account.settings.changeDefaultRelayAuthPolicy(policy) }, + onClick = { + account.settings.changeDefaultRelayAuthPolicy(policy) + // "Never log in" is the switch-it-all-off answer, so a casual + // "just for now" tap must not quietly outlive it. Deliberate + // Always/Never exceptions are left alone — those outrank the + // global policy by design, and the list above says so. + if (policy == RelayAuthPolicy.NEVER) account.relayAuthSessionGrants.clear() + }, ) } } 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 index 0d941b4b49..b9285192c8 100644 --- 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 @@ -24,11 +24,16 @@ 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 @@ -46,7 +51,7 @@ class RelayAuthSessionGrantsTest { private fun ledger( grants: RelayAuthSessionGrants = RelayAuthSessionGrants(), - store: InMemoryRelayAuthPermissionStore = InMemoryRelayAuthPermissionStore(), + store: RelayAuthPermissionStore = InMemoryRelayAuthPermissionStore(), blocked: Set = emptySet(), ) = RelayAuthPermissionLedger( store = store, @@ -157,6 +162,63 @@ class RelayAuthSessionGrantsTest { 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() From 4a1204ae577ee53e8aa9bbd9a0e344cc90849543 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 16:24:24 +0000 Subject: [PATCH 3/6] refactor: delete the unused AuthDecisionResolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AuthDecisionResolver has never had a production caller. Searching every commit that touched amethyst/src/main for the symbol outside the object's own file returns nothing, and `git log -S` against AuthCoordinator.kt is likewise empty: it arrived unused and stayed that way, kept alive only by its own test. The live equivalent is the per-account block in AuthCoordinator, which covers every branch it modelled — ALLOW/DENY/ASK, and the full UserAuthChoice mapping including the setDecision writes behind "always allow" and "never allow". Two things made it worse than merely dead. It folded every logged-in account into a single verdict, whereas the coordinator decides per account because one socket is shared and an answer given for @a must not reveal @b. And its "no verdicts -> authenticate" branch encoded the old any-account-allows fold that was deliberately removed to fix the over-AUTH bug; there is no random-key fallback any more. Left in place it reads as a template for policy the codebase has since rejected. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Rado2dnqpbCuCUyCd3trQz --- .../authCommand/model/AuthDecisionResolver.kt | 68 ---------- .../model/AuthDecisionResolverTest.kt | 117 ------------------ 2 files changed, 185 deletions(-) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthDecisionResolver.kt delete mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthDecisionResolverTest.kt 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/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) - } -} From 99dc8c57ff61c177df61af5c779a8657206f923a Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 19 Aug 2026 12:29:31 -0400 Subject: [PATCH 4/6] fix: don't let undo restore a session grant under "Never log in" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The undo on "forget this login" re-granted unconditionally, so this sequence put a grant back that the user had just switched off globally: forget a session grant -> select "Never log in" -> tap undo The snackbar is the reason the window is wide enough to matter. It is shown with an action label, and Material3 defaults that case to SnackbarDuration.Indefinite, so it sits on screen until acted on — plenty of time to change the policy above it first. Selecting NEVER clears the grants that exist, but nothing stopped a new one being written afterwards, and the grant is ranked above the policy in RelayAuthResolver, so the relay authenticated again. That is exactly the claim the previous commit made about NEVER being the switch-it-all-off answer. Guarded in the ledger rather than in the composable that found it: the ledger already owns globalPolicy and the precedence this protects, so every caller is covered, not just this screen's undo. The guard stops at the policy on purpose. A stored override written during the same window is self-protecting — it outranks the grant, so an ALLOW or DENY decides the relay whether or not the grant comes back — and so is the block list. Only the policy sits below the grant, so only the policy could be silently overridden. grantForSession now reports whether it took, and the screen says so instead of leaving a tapped undo looking like it did nothing. Verified on an emulator against a NIP-42 relay that logs every frame: the sequence above now leaves the policy on NEVER, no "Just for now" row, and no AUTH on the wire; undo with the policy untouched still restores the grant. Co-Authored-By: Claude Opus 5 (1M context) --- .../model/RelayAuthPermissionLedger.kt | 18 +++++- .../relayauth/RelayAuthSettingsScreen.kt | 15 ++++- amethyst/src/main/res/values/strings.xml | 1 + .../model/RelayAuthSessionGrantsTest.kt | 57 ++++++++++++++++++- 4 files changed, 87 insertions(+), 4 deletions(-) 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 aa45baf7fa..81caf47040 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 @@ -149,8 +149,24 @@ class RelayAuthPermissionLedger( /** * 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) = sessionGrants.grant(relayUrl) + 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) 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 8d084160d8..55f210a31b 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 @@ -155,18 +155,29 @@ fun RelayAuthSettingsScreen( 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(url.normalizeRelayUrlOrNull()?.displayUrl() ?: url), + message = sessionForgottenLabel.format(display), actionLabel = undoLabel, withDismissAction = true, ) - if (result == SnackbarResult.ActionPerformed) ledger.grantForSession(url) + // 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, + ) + } } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index b2a94d068f..cc7a5cf2c3 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1199,6 +1199,7 @@ 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/RelayAuthSessionGrantsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthSessionGrantsTest.kt index b9285192c8..c3290c9876 100644 --- 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 @@ -53,9 +53,10 @@ class RelayAuthSessionGrantsTest { grants: RelayAuthSessionGrants = RelayAuthSessionGrants(), store: RelayAuthPermissionStore = InMemoryRelayAuthPermissionStore(), blocked: Set = emptySet(), + policy: () -> RelayAuthPolicy = { RelayAuthPolicy.CUSTOM }, ) = RelayAuthPermissionLedger( store = store, - globalPolicy = { RelayAuthPolicy.CUSTOM }, + globalPolicy = policy, sessionGrants = grants, isBlocked = { it in blocked }, ) @@ -234,4 +235,58 @@ class RelayAuthSessionGrantsTest { 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))) + } } From 30b48de304fe90b31fbfe428c135fc83d562c0d3 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 19 Aug 2026 13:04:55 -0400 Subject: [PATCH 5/6] fix: keep the session-grant invariants with the state they protect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three follow-ups from reviewing the session-grant feature. Each is a case where the rule was right but lived somewhere it could not hold. 1. "Never log in" clears the grants, but that pairing was written in the settings screen's onClick, which made it a property of one screen rather than of the account. Any other caller of AccountSettings.changeDefaultRelayAuthPolicy would silently reintroduce grants that outlive the switch-it-all-off answer — and a grant outranks the policy, so they would authenticate. Moved to Account.changeDefaultRelayAuthPolicy, which owns both the setting and the grants; the screen now calls that. Stored Always/Never exceptions are still left alone, since those outrank the policy by design and are listed. 2. RelayAuthPermissionLedger.sessionGrants defaulted to a fresh instance. Account passes the shared one, so nothing was broken, but the default meant a ledger built without it got a private set instead of failing — and this is shared state by construction: the foreground screen and the background notification consumer decide off one ledger, so a split set would lose answers between them and bring the dialog back. Now required. 3. Blocking a relay did not drop its session grant. Blocking outranks everything while it is in force, so nothing leaked, but lifting the block resumed authenticating off an answer given before it — and the weaker "never allow" already drops the grant, so the stronger signal not doing so was backwards. Account now observes the block list and revokes through the new RelayAuthPermissionLedger.revokeSessionGrantsFor. Observed rather than hooked onto the local block action because kind 10006 is shared: a block published by another client arrives as a flow update with no call of ours behind it. Verified on an emulator for 1 (selecting "never log in" still clears the grants through the new path) and by unit test for 2 and 3. The block list has no editor screen in this build — it is rendered from a published kind-10006 note — so 3's wiring is covered by its test plus the fact that both sides key off NormalizedRelayUrl.url, not by an end-to-end run. Co-Authored-By: Claude Opus 5 (1M context) --- .../vitorpamplona/amethyst/model/Account.kt | 36 +++++++++++++++++++ .../model/RelayAuthPermissionLedger.kt | 22 ++++++++++-- .../relayauth/RelayAuthSettingsScreen.kt | 12 +++---- .../model/RelayAuthGrantRationaleTest.kt | 2 +- .../model/RelayAuthSessionGrantsTest.kt | 18 ++++++++++ .../model/RelayAuthVenueCoverageTest.kt | 1 + 6 files changed, 79 insertions(+), 12 deletions(-) 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 b763402e07..600fb8bf02 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -64,6 +64,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 @@ -441,6 +442,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 @@ -3550,6 +3572,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/RelayAuthPermissionLedger.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPermissionLedger.kt index 81caf47040..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 @@ -53,10 +53,15 @@ class RelayAuthPermissionLedger( /** * 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. + * 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 = RelayAuthSessionGrants(), + val sessionGrants: RelayAuthSessionGrants, val customToggles: () -> RelayAuthCustomToggles = { RelayAuthCustomToggles() }, val isInMyRelayList: (String) -> Boolean = { false }, val isBlocked: (String) -> Boolean = { false }, @@ -171,6 +176,17 @@ class RelayAuthPermissionLedger( /** 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]. * 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 55f210a31b..187d3798f9 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 @@ -241,14 +241,10 @@ fun RelayAuthSettingsScreen( selected = globalPolicy == policy, title = stringResource(titleRes), description = stringResource(descRes), - onClick = { - account.settings.changeDefaultRelayAuthPolicy(policy) - // "Never log in" is the switch-it-all-off answer, so a casual - // "just for now" tap must not quietly outlive it. Deliberate - // Always/Never exceptions are left alone — those outrank the - // global policy by design, and the list above says so. - if (policy == RelayAuthPolicy.NEVER) account.relayAuthSessionGrants.clear() - }, + // 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) }, ) } } 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/RelayAuthSessionGrantsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthSessionGrantsTest.kt index c3290c9876..0ae3d10c76 100644 --- 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 @@ -289,4 +289,22 @@ class RelayAuthSessionGrantsTest { 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 }, From 4f8a887a7b83ec2a8e54782c4db0d7e0f9ae67af Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 17:14:12 +0000 Subject: [PATCH 6/6] fix(test): pass the now-required sessionGrants in RelayAuthReadFollowsTest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A semantic merge conflict: no textual overlap, but the two sides do not compile together. Making RelayAuthPermissionLedger.sessionGrants required (no default) updated every ledger construction its author could see. RelayAuthReadFollowsTest was not one of them — it arrived independently from main, so neither branch was broken on its own and git had nothing to flag. Merging them produced "No value passed for parameter 'sessionGrants'". Passes a fresh RelayAuthSessionGrants() like the sibling suites. A private set is right here: this test never exercises grants, it only needs a ledger. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Rado2dnqpbCuCUyCd3trQz --- .../relayClient/authCommand/model/RelayAuthReadFollowsTest.kt | 1 + 1 file changed, 1 insertion(+) 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 }, )