Merge pull request #3955 from vitorpamplona/claude/relay-auth-cache-91pa8f

Add session grants for relay auth to survive reconnects
This commit is contained in:
Vitor Pamplona
2026-08-19 16:19:32 -04:00
committed by GitHub
15 changed files with 661 additions and 198 deletions
@@ -67,6 +67,7 @@ import com.vitorpamplona.amethyst.commons.model.privateChats.hasEncryptedContent
import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderAccount
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthCustomToggles
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.service.pow.PersistedPoWJob
import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory
@@ -151,6 +152,7 @@ import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.InMemoryRelayAuthPermissionStore
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthPermissionCache
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthPermissionLedger
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthSessionGrants
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthVenues
import com.vitorpamplona.amethyst.service.relayClient.chatDelivery.ChatDeliveryTracker
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyRequestsCache
@@ -427,6 +429,11 @@ class Account(
// BuzzAttestationPreferences.
val buzzAttestation = BuzzHeldAttestations(pubKey)
// The relays this account approved by answering the NIP-42 prompt *without* the "remember"
// switch. Deliberately in-memory only: it dies with this Account (i.e. with the process, or at
// logout), which is what makes it a session grant rather than a stored ALLOW.
val relayAuthSessionGrants = RelayAuthSessionGrants()
// Per-account NIP-42 policy evaluator (blocked → per-relay override → global policy → prompt),
// reading THIS account's own toggles, relay lists and follow graph. Cached here so every AUTH
// path (foreground screen + background notification consumer) shares one instance, and so an
@@ -435,6 +442,7 @@ class Account(
RelayAuthPermissionLedger(
store = relayAuthPermissions,
globalPolicy = { settings.defaultRelayAuthPolicy.value },
sessionGrants = relayAuthSessionGrants,
customToggles = {
RelayAuthCustomToggles(
myRelaysAndVenues = settings.relayAuthTrustMyRelaysAndVenues.value,
@@ -455,6 +463,27 @@ class Account(
isVenueHostRelay = { relayUrl -> relayUrl.normalizeRelayUrlOrNull()?.let { it in venueHostRelays() } ?: false },
)
/**
* Sets the global NIP-42 policy, dropping every session grant when it becomes
* [RelayAuthPolicy.NEVER].
*
* The two halves belong together, which is why they live here instead of in the settings screen
* that used to pair them: a session grant outranks the policy (see
* [com.vitorpamplona.amethyst.commons.relayauth.RelayAuthResolver]), so "never log in" only
* means what it says if the casual one-tap answers go with it. As a composable's `onClick` that
* was a property of one screen rather than of the account, and any other caller of
* [AccountSettings.changeDefaultRelayAuthPolicy] silently reintroduced grants that outlive the
* switch-it-all-off answer.
*
* Stored Always/Never exceptions are deliberately left alone: those outrank the policy by
* design, and the settings screen lists them, so they are a standing answer rather than a
* casual one.
*/
fun changeDefaultRelayAuthPolicy(policy: RelayAuthPolicy) {
settings.changeDefaultRelayAuthPolicy(policy)
if (policy == RelayAuthPolicy.NEVER) relayAuthSessionGrants.clear()
}
/**
* Relays that exist here because *this account* joined a room on them: the host of every NIP-29
* relay group on its kind-10009 list, plus the relays of every Concord community on its
@@ -3595,6 +3624,20 @@ class Account(
init {
Log.d("AccountRegisterObservers", "Init")
// Blocking a relay has to forget any "just for now" login to it, or unblocking later would
// silently resume authenticating off an answer given before the block. Blocking is the
// strongest signal available here — the weaker "never allow" already drops the grant via
// RelayAuthPermissionLedger.setDecision, so it would be odd for the stronger one not to.
//
// Observed rather than hooked onto the local block action because the kind-10006 list is
// shared: a block published by another client arrives as a flow update with no call of ours
// behind it.
scope.launch {
blockedRelayList.flow.collect { blocked ->
relayAuthLedger.revokeSessionGrantsFor(blocked.map { it.url })
}
}
// Start the Cashu wallet state observers AFTER all field initializers
// complete — auto-redeem can fire as soon as start() returns, and it
// calls back into sendLiterallyEverywhere which depends on
@@ -143,7 +143,14 @@ class AuthCoordinator(
)
}
when (choice) {
UserAuthChoice.ALLOW_ONCE -> true
UserAuthChoice.ALLOW_ONCE -> {
// Not literally once: relays re-challenge on every reconnect,
// so answering only the in-flight challenge meant the same
// dialog came back minutes later. The grant is kept in memory
// for the rest of this run and dies with the process.
account.relayAuthLedger.grantForSession(relayUrl.url)
true
}
UserAuthChoice.ALWAYS_ALLOW -> {
account.relayAuthLedger.setDecision(relayUrl.url, RelayAuthDecision.ALLOW)
true
@@ -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<RelayAuthVerdict>,
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)
}
}
@@ -33,8 +33,9 @@ import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthVerdict
/**
* Decides whether Amethyst should authenticate with a given relay (NIP-42), for one account.
*
* Precedence (see [RelayAuthResolver]): blocked-relay list → per-relay override → global
* [globalPolicy] → prompt-if-attributable-else-deny. Under [RelayAuthPolicy.CUSTOM] the
* Precedence (see [RelayAuthResolver]): blocked-relay list → per-relay override → this session's
* in-memory grants ([sessionGrants]) → global [globalPolicy] → prompt-if-attributable-else-deny.
* Under [RelayAuthPolicy.CUSTOM] the
* [customToggles] gate each category, using [isFollowed] to split the counterparties carried in the
* [RelayAuthContext] into followed vs. stranger.
*
@@ -49,6 +50,18 @@ import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthVerdict
class RelayAuthPermissionLedger(
val store: RelayAuthPermissionStore,
val globalPolicy: () -> RelayAuthPolicy,
/**
* Relays this account already approved during this run of the app. Answering the prompt without
* the "remember" switch records the grant here, so the same relay's next reconnect is answered
* silently instead of raising the same dialog again.
*
* Required, with no default, because it is shared state: one account's grants have to be the
* same object on every AUTH path (the foreground screen and the background notification
* consumer both decide off this ledger — see [com.vitorpamplona.amethyst.model.Account]). A
* default would let a ledger built without one quietly get a private set instead, so answers
* given on one path would not be seen on the other and the dialog would come back anyway.
*/
val sessionGrants: RelayAuthSessionGrants,
val customToggles: () -> RelayAuthCustomToggles = { RelayAuthCustomToggles() },
val isInMyRelayList: (String) -> Boolean = { false },
val isBlocked: (String) -> Boolean = { false },
@@ -81,6 +94,7 @@ class RelayAuthPermissionLedger(
RelayAuthInputs(
storedOverride = store.loadDecision(ctx.relayUrl),
isBlocked = isBlocked(ctx.relayUrl),
hasSessionGrant = sessionGrants.isGranted(ctx.relayUrl),
policy = globalPolicy(),
toggles = customToggles(),
isInMyRelayList = isInMyRelayList(ctx.relayUrl),
@@ -137,14 +151,72 @@ class RelayAuthPermissionLedger(
if (additions.isNotEmpty()) store.recordUse(ctx.relayUrl, additions)
}
/** Stores a per-relay override for [relayUrl]. */
/**
* Remembers a "log in" answer for [relayUrl] until the app is restarted, so the relay's next
* reconnect doesn't ask again. Nothing is written to disk — see [RelayAuthSessionGrants].
*
* Refused, returning false, while [globalPolicy] is [RelayAuthPolicy.NEVER]: that is the
* switch-it-all-off answer, and a session grant outranks the policy (see [RelayAuthResolver]),
* so recording one here would quietly re-enable the very thing the user just turned off. The
* settings screen clears existing grants when the policy is set to NEVER; this stops a *new*
* one being written afterwards — which the undo on "forget this login" otherwise did, because
* its snackbar carries an action label and so sits on screen indefinitely, long enough for the
* policy to change underneath it.
*
* Only the policy needs this guard. A stored override arriving in the same window is
* self-protecting: it is ranked *above* the grant, so an ALLOW or DENY written meanwhile
* decides the relay either way. So is the block list, which outranks everything.
*/
fun grantForSession(relayUrl: String): Boolean {
if (globalPolicy() == RelayAuthPolicy.NEVER) return false
sessionGrants.grant(relayUrl)
return true
}
/** Forgets this session's grant for [relayUrl], so the next challenge is decided from scratch. */
fun revokeSessionGrant(relayUrl: String) = sessionGrants.revoke(relayUrl)
/**
* Forgets this session's grants for every relay in [blockedRelayUrls] — the kind-10006 block
* list, which outranks everything else on the decision path.
*
* Blocking already denies while it is in force, so this is about what happens *after* it is
* lifted: without it, unblocking would resume authenticating off an answer given before the
* block. The weaker "never allow" drops the grant too (see [setDecision]), so the stronger
* signal has to as well.
*/
fun revokeSessionGrantsFor(blockedRelayUrls: Collection<String>) = blockedRelayUrls.forEach(sessionGrants::revoke)
/**
* Stores a per-relay override for [relayUrl].
*
* Also drops any session grant: the stored decision is now the whole answer for this relay, so
* leaving the transient one behind would let a later [clearDecision] ("follows your rules again")
* silently keep authenticating off a grant the user can no longer see.
*
* The two writes are not atomic — [RelayAuthPermissionCache] only publishes an override to memory
* *after* its disk write returns — so a challenge arriving between them must never see neither.
* Which side to fail on depends on the decision:
* - **DENY** revokes first. The window then asks or denies, never signs: a user who just pressed
* "never allow" must not get one more AUTH out of the grant they are replacing.
* - **ALLOW** revokes last, so the grant still covers the window. Revoking first left the relay
* momentarily undecided, which re-prompted the user who had just pressed "always" — the very
* dialog this whole feature exists to stop.
*/
suspend fun setDecision(
relayUrl: String,
decision: RelayAuthDecision,
) = store.storeDecision(relayUrl, decision)
) {
if (decision == RelayAuthDecision.DENY) sessionGrants.revoke(relayUrl)
store.storeDecision(relayUrl, decision)
sessionGrants.revoke(relayUrl)
}
/** Removes the per-relay override for [relayUrl], reverting to the global policy. */
suspend fun clearDecision(relayUrl: String) = store.clearDecision(relayUrl)
suspend fun clearDecision(relayUrl: String) {
sessionGrants.revoke(relayUrl)
store.clearDecision(relayUrl)
}
/** All per-relay overrides — for the settings screen. */
suspend fun allDecisions(): Map<String, RelayAuthDecision> = store.allDecisions()
@@ -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. */
@@ -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<Set<String>>(emptySet())
/** Observable so the settings screen can list — and revoke — what is currently granted. */
val grants: StateFlow<Set<String>> = 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() }
}
@@ -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<Map<String, RelayAuthDecision>>(emptyMap()) }
var rationales by remember { mutableStateOf<Map<String, Map<AuthPurposeKind, Set<HexKey>>>>(emptyMap()) }
@@ -139,16 +140,47 @@ fun RelayAuthSettingsScreen(
val exceptionUrls = remember(exceptions) { exceptions.keys.sorted() }
val blockedUrls = remember(blockedRelays) { blockedRelays.map { it.url }.sorted() }
// Answers given to the prompt without the "remember" switch. Any relay that also carries a rule
// above is shown there instead — the rule is what actually decides it.
val sessionUrls =
remember(sessionGrants, exceptions, blockedUrls) {
(sessionGrants - exceptions.keys - blockedUrls.toSet()).sorted()
}
// The log is everything we have a record of that is not already stated above as a rule.
val logUrls =
remember(exceptions, rationales, lastUsed, blockedUrls) {
((rationales.keys + lastUsed.keys) - exceptions.keys - blockedUrls.toSet())
remember(exceptions, rationales, lastUsed, blockedUrls, sessionUrls) {
((rationales.keys + lastUsed.keys) - exceptions.keys - blockedUrls.toSet() - sessionUrls.toSet())
.sortedByDescending { lastUsed[it] ?: 0L }
}
val removedLabel = stringResource(R.string.relay_auth_exception_removed_undo)
val sessionForgottenLabel = stringResource(R.string.relay_auth_session_forgotten_undo)
val sessionUndoBlockedLabel = stringResource(R.string.relay_auth_session_undo_blocked)
val undoLabel = stringResource(R.string.relay_auth_undo)
fun forgetSessionGrant(url: String) {
ledger.revokeSessionGrant(url)
scope.launch {
val display = url.normalizeRelayUrlOrNull()?.displayUrl() ?: url
val result =
snackbarHostState.showSnackbar(
message = sessionForgottenLabel.format(display),
actionLabel = undoLabel,
withDismissAction = true,
)
// An action label makes Material3 show this indefinitely, so the undo can be tapped long
// after the fact — including after the policy above was switched to "Never log in", which
// clears every grant. The ledger refuses to write a new one in that state; report that
// instead of leaving a tapped undo looking like it silently did nothing.
if (result == SnackbarResult.ActionPerformed && !ledger.grantForSession(url)) {
snackbarHostState.showSnackbar(
message = sessionUndoBlockedLabel.format(display),
withDismissAction = true,
)
}
}
}
fun removeException(url: String) {
scope.launch {
val previous = exceptions[url]
@@ -209,7 +241,10 @@ fun RelayAuthSettingsScreen(
selected = globalPolicy == policy,
title = stringResource(titleRes),
description = stringResource(descRes),
onClick = { account.settings.changeDefaultRelayAuthPolicy(policy) },
// Account, not settings: choosing "never log in" also drops this
// session's grants, and that pairing is the account's rule rather
// than this screen's. See Account.changeDefaultRelayAuthPolicy.
onClick = { account.changeDefaultRelayAuthPolicy(policy) },
)
}
}
@@ -288,6 +323,32 @@ fun RelayAuthSettingsScreen(
}
}
// Only rendered when something is granted: an empty card here would advertise a list the
// user has no way to add to from this screen.
if (sessionUrls.isNotEmpty()) {
item {
Spacer(Modifier.height(20.dp))
GroupHeader(stringResource(R.string.relay_auth_session_section))
Spacer(Modifier.height(8.dp))
}
itemsIndexed(sessionUrls, key = { _, url -> "session:$url" }) { index, url ->
GroupedRow(index, sessionUrls.size) {
SessionGrantRow(
url = url,
accountViewModel = accountViewModel,
nav = nav,
onPromote = { next ->
scope.launch {
ledger.setDecision(url, next)
reloadKey++
}
},
onForget = { forgetSessionGrant(url) },
)
}
}
}
item {
Spacer(Modifier.height(20.dp))
GroupHeader(stringResource(R.string.relay_auth_blocked_section))
@@ -475,6 +536,42 @@ private fun ExceptionRow(
)
}
/**
* A relay the user logged in to from the prompt without asking to remember it. It behaves like an
* ALLOW exception for the rest of this run and then disappears, so it gets its own group rather than
* sitting in "Exceptions" — nothing here survives a restart.
*/
@Composable
private fun SessionGrantRow(
url: String,
accountViewModel: AccountViewModel,
nav: INav,
onPromote: (RelayAuthDecision) -> Unit,
onForget: () -> Unit,
) {
RelayRowFrame(
url = url,
accountViewModel = accountViewModel,
nav = nav,
subtitle = {
Text(
text = stringResource(R.string.relay_auth_session_row_desc),
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
)
},
trailing = {
// Neither segment is selected: a session grant is not an override, and promoting it to
// one is exactly what these two buttons are for.
DecisionSegments(current = null, onDecision = onPromote)
IconButton(onClick = onForget) {
Icon(MaterialSymbols.Close, contentDescription = stringResource(R.string.relay_auth_forget_session))
}
},
)
}
/** A relay on the kind-10006 block list: a hard DENY that outranks everything else on this screen. */
@Composable
private fun BlockedRow(
+5
View File
@@ -1198,6 +1198,11 @@
<string name="relay_auth_remove_exception">Remove exception</string>
<string name="relay_auth_exception_removed_undo">Exception removed. %1$s follows your rules again.</string>
<string name="relay_auth_undo">Undo</string>
<string name="relay_auth_session_section">Just for now</string>
<string name="relay_auth_session_row_desc">Logged in until you restart Amethyst</string>
<string name="relay_auth_forget_session">Forget this login</string>
<string name="relay_auth_session_forgotten_undo">%1$s will ask again the next time it needs you.</string>
<string name="relay_auth_session_undo_blocked">Not restored. “Never log in” is on, so Amethyst won\'t log in to %1$s.</string>
<string name="relay_auth_blocked_section">Blocked by your block list</string>
<string name="relay_auth_blocked_row_desc">Amethyst never logs in to blocked relays.</string>
<string name="relay_auth_no_blocked">Nothing blocked. Relays you block will never be logged in to, whatever you set here.</string>
@@ -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)
}
}
@@ -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() =
@@ -62,6 +62,7 @@ class RelayAuthReadFollowsTest {
RelayAuthPermissionLedger(
store = NoStore(),
globalPolicy = { RelayAuthPolicy.CUSTOM },
sessionGrants = RelayAuthSessionGrants(),
customToggles = { toggles },
isFollowed = { it == followed },
)
@@ -0,0 +1,310 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.relayClient.authCommand.model
import com.vitorpamplona.amethyst.commons.relayauth.AuthPurpose
import com.vitorpamplona.amethyst.commons.relayauth.AuthPurposeKind
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthContext
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthVerdict
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* "Log in" without the remember switch has to survive the relay's next reconnect, or the same dialog
* comes back every time the socket drops — which is what pushed users into "always allow".
*
* These tests drive [RelayAuthPermissionLedger] rather than the pure resolver, because the thing worth
* pinning is that the grant is consulted on the real decision path and that the persisted rules still
* outrank it.
*/
class RelayAuthSessionGrantsTest {
private val relay = "wss://auth.example.com/"
private val other = "wss://elsewhere.example.com/"
private fun ledger(
grants: RelayAuthSessionGrants = RelayAuthSessionGrants(),
store: RelayAuthPermissionStore = InMemoryRelayAuthPermissionStore(),
blocked: Set<String> = emptySet(),
policy: () -> RelayAuthPolicy = { RelayAuthPolicy.CUSTOM },
) = RelayAuthPermissionLedger(
store = store,
globalPolicy = policy,
sessionGrants = grants,
isBlocked = { it in blocked },
)
/** A challenge we can explain but have no automatic rule for: the ASK case. */
private fun askable(relayUrl: String) =
RelayAuthContext(
relayUrl,
listOf(AuthPurpose(AuthPurposeKind.MY_INBOX)),
)
@Test
fun withoutAGrantTheSameRelayKeepsAsking() =
runTest {
val ledger = ledger()
assertEquals(RelayAuthVerdict.ASK, ledger.decide(askable(relay)))
assertEquals(RelayAuthVerdict.ASK, ledger.decide(askable(relay)))
}
@Test
fun aSessionGrantAnswersEveryLaterReconnect() =
runTest {
val ledger = ledger()
assertEquals(RelayAuthVerdict.ASK, ledger.decide(askable(relay)))
ledger.grantForSession(relay)
assertEquals(RelayAuthVerdict.ALLOW, ledger.decide(askable(relay)))
assertEquals(RelayAuthVerdict.ALLOW, ledger.decide(askable(relay)))
}
@Test
fun aGrantCoversOnlyTheRelayItWasGivenFor() =
runTest {
val ledger = ledger()
ledger.grantForSession(relay)
assertEquals(RelayAuthVerdict.ALLOW, ledger.decide(askable(relay)))
assertEquals(RelayAuthVerdict.ASK, ledger.decide(askable(other)))
}
@Test
fun grantsAreNeverWrittenToTheStore() =
runTest {
val store = InMemoryRelayAuthPermissionStore()
val ledger = ledger(store = store)
ledger.grantForSession(relay)
// Nothing persisted: a fresh process (a ledger over the same disk, with empty session
// memory) is back to asking.
assertEquals(emptyMap<String, RelayAuthDecision>(), store.allDecisions())
assertEquals(RelayAuthVerdict.ASK, ledger(store = store).decide(askable(relay)))
}
@Test
fun blockListOutranksAGrant() =
runTest {
val ledger = ledger(blocked = setOf(relay))
ledger.grantForSession(relay)
assertEquals(RelayAuthVerdict.DENY, ledger.decide(askable(relay)))
}
@Test
fun neverAllowTakesEffectImmediatelyOverAGrant() =
runTest {
val grants = RelayAuthSessionGrants()
val ledger = ledger(grants)
ledger.grantForSession(relay)
assertEquals(RelayAuthVerdict.ALLOW, ledger.decide(askable(relay)))
// The user answers "Never allow" on a later prompt for the same relay.
ledger.setDecision(relay, RelayAuthDecision.DENY)
assertEquals(RelayAuthVerdict.DENY, ledger.decide(askable(relay)))
assertFalse(grants.isGranted(relay))
}
@Test
fun clearingAnExceptionDropsTheGrantSoTheRelayReallyFollowsTheRulesAgain() =
runTest {
val grants = RelayAuthSessionGrants()
val ledger = ledger(grants)
ledger.grantForSession(relay)
ledger.setDecision(relay, RelayAuthDecision.ALLOW)
ledger.clearDecision(relay)
assertFalse(grants.isGranted(relay))
assertEquals(RelayAuthVerdict.ASK, ledger.decide(askable(relay)))
}
@Test
fun revokingRestoresTheQuestion() =
runTest {
val grants = RelayAuthSessionGrants()
val ledger = ledger(grants)
ledger.grantForSession(relay)
assertTrue(grants.isGranted(relay))
ledger.revokeSessionGrant(relay)
assertFalse(grants.isGranted(relay))
assertEquals(RelayAuthVerdict.ASK, ledger.decide(askable(relay)))
}
/**
* A store whose write suspends until [gate] opens, modelling the real one: the disk write is
* awaited *before* [RelayAuthPermissionCache] publishes the new override to memory, so there is a
* window where the override is not yet readable.
*/
private class GatedStore(
private val gate: CompletableDeferred<Unit>,
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<Unit>()
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<Unit>()
val ledger = ledger(store = GatedStore(gate))
ledger.grantForSession(relay)
val write = launch { ledger.setDecision(relay, RelayAuthDecision.DENY) }
runCurrent()
// The opposite bias to the ALLOW case: a user who just said "never" must not have one more
// AUTH signed on the strength of the grant they are replacing.
assertNotEquals(RelayAuthVerdict.ALLOW, ledger.decide(askable(relay)))
gate.complete(Unit)
write.join()
assertEquals(RelayAuthVerdict.DENY, ledger.decide(askable(relay)))
}
@Test
fun grantsAreObservableForTheSettingsScreen() {
val grants = RelayAuthSessionGrants()
assertEquals(emptySet<String>(), 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<String>(), grants.grants.value)
}
@Test
fun aGrantIsRefusedWhileThePolicyIsNever() =
runTest {
val grants = RelayAuthSessionGrants()
val ledger = ledger(grants = grants, policy = { RelayAuthPolicy.NEVER })
assertFalse(ledger.grantForSession(relay))
assertFalse(grants.isGranted(relay))
assertEquals(RelayAuthVerdict.DENY, ledger.decide(askable(relay)))
}
@Test
fun undoingAForgetAfterSwitchingToNeverDoesNotResurrectTheGrant() =
runTest {
// The settings screen's undo snackbar carries an action label, so Material3 leaves it up
// indefinitely — the user can switch the whole policy off and only then tap undo.
val grants = RelayAuthSessionGrants()
var policy = RelayAuthPolicy.CUSTOM
val ledger = ledger(grants = grants, policy = { policy })
assertTrue(ledger.grantForSession(relay))
assertEquals(RelayAuthVerdict.ALLOW, ledger.decide(askable(relay)))
// "Forget this login", then "Never log in" — which also clears what is already granted.
ledger.revokeSessionGrant(relay)
policy = RelayAuthPolicy.NEVER
grants.clear()
// ...and only now, undo.
assertFalse(ledger.grantForSession(relay))
assertEquals(RelayAuthVerdict.DENY, ledger.decide(askable(relay)))
}
@Test
fun theGuardOnlyAppliesToNever() =
runTest {
assertTrue(ledger(policy = { RelayAuthPolicy.CUSTOM }).grantForSession(relay))
assertTrue(ledger(policy = { RelayAuthPolicy.ALWAYS }).grantForSession(relay))
}
@Test
fun aStoredDecisionTakenDuringTheUndoWindowNeedsNoGuardBecauseItOutranksTheGrant() =
runTest {
// Why the guard is narrowed to the policy: an override written while the snackbar was up
// is ranked above the grant, so restoring the grant cannot undo the user's newer answer.
val ledger = ledger()
ledger.revokeSessionGrant(relay)
ledger.setDecision(relay, RelayAuthDecision.DENY)
assertTrue(ledger.grantForSession(relay))
assertEquals(RelayAuthVerdict.DENY, ledger.decide(askable(relay)))
}
@Test
fun blockingARelayForgetsItsSessionGrantSoUnblockingDoesNotResumeIt() =
runTest {
// While the block is in force the relay is denied whatever the grant says, so what this
// pins is the state left behind for when the block is lifted.
val grants = RelayAuthSessionGrants()
val ledger = ledger(grants = grants)
ledger.grantForSession(relay)
ledger.grantForSession(other)
ledger.revokeSessionGrantsFor(listOf(relay))
assertEquals(setOf(other), grants.grants.value)
assertEquals(RelayAuthVerdict.ASK, ledger.decide(askable(relay)))
assertEquals(RelayAuthVerdict.ALLOW, ledger.decide(askable(other)))
}
}
@@ -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 },
@@ -45,6 +45,8 @@ data class RelayAuthCustomToggles(
* so the decision itself is pure and unit-testable without any account/relay wiring.
*
* @param storedOverride an explicit per-relay decision the user set previously, or null.
* @param hasSessionGrant the user already answered "log in" for this relay during this run of the
* app, without asking to remember it permanently. Held in memory only, so it dies with the process.
* @param isBlocked the relay is on the user's blocked-relay list (kind 10006).
* @param policy the top-level [RelayAuthPolicy].
* @param toggles the [RelayAuthCustomToggles] applied when [policy] is [RelayAuthPolicy.CUSTOM].
@@ -69,6 +71,7 @@ data class RelayAuthCustomToggles(
data class RelayAuthInputs(
val storedOverride: RelayAuthDecision?,
val isBlocked: Boolean,
val hasSessionGrant: Boolean = false,
val policy: RelayAuthPolicy,
val toggles: RelayAuthCustomToggles,
val isInMyRelayList: Boolean,
@@ -85,13 +88,16 @@ data class RelayAuthInputs(
*
* 1. Blocked-relay list → [RelayAuthVerdict.DENY] (never reveal identity to a blocked relay).
* 2. Explicit per-relay override → honor it.
* 3. Top-level [RelayAuthPolicy]:
* 3. [RelayAuthInputs.hasSessionGrant] → [RelayAuthVerdict.ALLOW]. Ranked *below* the stored override
* so a later "never allow" — the only way a DENY can be written for a relay already granted this
* session — takes effect immediately instead of losing to the in-memory grant.
* 4. Top-level [RelayAuthPolicy]:
* - [RelayAuthPolicy.NEVER] → DENY
* - [RelayAuthPolicy.ALWAYS] → ALLOW
* - [RelayAuthPolicy.CUSTOM] → ALLOW if any *enabled* [RelayAuthCustomToggles] category matches
* this relay (own relays/venues, reading follows, messaging follows, messaging strangers);
* else fall through
* 4. Fall-through → [RelayAuthVerdict.ASK] when the purpose is known, otherwise DENY.
* 5. Fall-through → [RelayAuthVerdict.ASK] when the purpose is known, otherwise DENY.
*
* Most [RelayAuthPolicy.CUSTOM] grants additionally require [RelayAuthInputs.isFirstParty]: under
* "decide per relay" an account never reveals its identity *without being asked* on a relay it has no
@@ -118,6 +124,11 @@ object RelayAuthResolver {
}
}
// The user answered this exact question, for this exact relay, earlier in this session. Not
// gated on isFirstParty: an explicit answer outranks every inference we would otherwise make
// about whether the account belongs here.
if (inputs.hasSessionGrant) return RelayAuthVerdict.ALLOW
return when (inputs.policy) {
RelayAuthPolicy.NEVER -> RelayAuthVerdict.DENY
// Unconditional, by design: "Always log in" means every relay that asks. Narrowing it to the
@@ -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)))