mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
@@ -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
|
||||
|
||||
+8
-1
@@ -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
|
||||
|
||||
-68
@@ -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)
|
||||
}
|
||||
}
|
||||
+77
-5
@@ -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()
|
||||
|
||||
+4
-1
@@ -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. */
|
||||
|
||||
+62
@@ -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() }
|
||||
}
|
||||
+100
-3
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
-117
@@ -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)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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() =
|
||||
|
||||
+1
@@ -62,6 +62,7 @@ class RelayAuthReadFollowsTest {
|
||||
RelayAuthPermissionLedger(
|
||||
store = NoStore(),
|
||||
globalPolicy = { RelayAuthPolicy.CUSTOM },
|
||||
sessionGrants = RelayAuthSessionGrants(),
|
||||
customToggles = { toggles },
|
||||
isFollowed = { it == followed },
|
||||
)
|
||||
|
||||
+310
@@ -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)))
|
||||
}
|
||||
}
|
||||
+1
@@ -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 },
|
||||
|
||||
+13
-2
@@ -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
@@ -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)))
|
||||
|
||||
Reference in New Issue
Block a user