Merge remote-tracking branch 'origin/claude/relay-auth-cache-91pa8f' into claude/relay-auth-cache-91pa8f

This commit is contained in:
Claude
2026-08-19 17:10:27 +00:00
6 changed files with 79 additions and 12 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
@@ -462,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
@@ -3602,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
@@ -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<String>) = blockedRelayUrls.forEach(sessionGrants::revoke)
/**
* Stores a per-relay override for [relayUrl].
*
@@ -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) },
)
}
}
@@ -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() =
@@ -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)))
}
}
@@ -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 },