mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
feat: remember relay auth "log in" for the rest of the session
A NIP-42 challenge is not a one-off: relays re-challenge on every reconnect, and the client reconnects constantly (network changes, doze, app switches). Answering the prompt without the "remember" switch authorized only the single in-flight challenge, so the same relay asked the same question again minutes later — the prompt fatigue that pushes users into "always allow" on a relay they only wanted to try once. Adds RelayAuthSessionGrants: a per-account, in-memory set of relays approved during this run of the app. It lives on Account, so it dies with the process and at logout — that is what keeps it distinct from the stored ALLOW the "remember" switch writes to disk. Wiring: - RelayAuthInputs/RelayAuthResolver gain hasSessionGrant, ranked below the stored override so a later "never allow" takes effect immediately, and below the block list which still wins outright. Not gated on isFirstParty: an explicit answer for this relay outranks any inference about it. - AuthCoordinator records the grant on ALLOW_ONCE. - setDecision/clearDecision drop the grant, so "follows your rules again" after removing an exception is true rather than silently still allowed. - Relay auth settings lists the grants under "Just for now", each row promotable to a real exception or forgettable with an undo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rado2dnqpbCuCUyCd3trQz
This commit is contained in:
@@ -147,6 +147,7 @@ import com.vitorpamplona.amethyst.service.location.LocationState
|
||||
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.InMemoryRelayAuthPermissionStore
|
||||
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthPermissionCache
|
||||
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthPermissionLedger
|
||||
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthSessionGrants
|
||||
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthVenues
|
||||
import com.vitorpamplona.amethyst.service.relayClient.chatDelivery.ChatDeliveryTracker
|
||||
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyRequestsCache
|
||||
@@ -406,6 +407,11 @@ class Account(
|
||||
// answered without a disk read. Backed by a per-account file (see AccountCacheState).
|
||||
val relayAuthPermissions = RelayAuthPermissionCache(relayAuthPermissionStore, scope)
|
||||
|
||||
// The relays this account approved by answering the NIP-42 prompt *without* the "remember"
|
||||
// switch. Deliberately in-memory only: it dies with this Account (i.e. with the process, or at
|
||||
// logout), which is what makes it a session grant rather than a stored ALLOW.
|
||||
val relayAuthSessionGrants = RelayAuthSessionGrants()
|
||||
|
||||
// Per-account NIP-42 policy evaluator (blocked → per-relay override → global policy → prompt),
|
||||
// reading THIS account's own toggles, relay lists and follow graph. Cached here so every AUTH
|
||||
// path (foreground screen + background notification consumer) shares one instance, and so an
|
||||
@@ -414,6 +420,7 @@ class Account(
|
||||
RelayAuthPermissionLedger(
|
||||
store = relayAuthPermissions,
|
||||
globalPolicy = { settings.defaultRelayAuthPolicy.value },
|
||||
sessionGrants = relayAuthSessionGrants,
|
||||
customToggles = {
|
||||
RelayAuthCustomToggles(
|
||||
myRelaysAndVenues = settings.relayAuthTrustMyRelaysAndVenues.value,
|
||||
|
||||
+8
-1
@@ -140,7 +140,14 @@ class AuthCoordinator(
|
||||
)
|
||||
}
|
||||
when (choice) {
|
||||
UserAuthChoice.ALLOW_ONCE -> true
|
||||
UserAuthChoice.ALLOW_ONCE -> {
|
||||
// Not literally once: relays re-challenge on every reconnect,
|
||||
// so answering only the in-flight challenge meant the same
|
||||
// dialog came back minutes later. The grant is kept in memory
|
||||
// for the rest of this run and dies with the process.
|
||||
account.relayAuthLedger.grantForSession(relayUrl.url)
|
||||
true
|
||||
}
|
||||
UserAuthChoice.ALWAYS_ALLOW -> {
|
||||
account.relayAuthLedger.setDecision(relayUrl.url, RelayAuthDecision.ALLOW)
|
||||
true
|
||||
|
||||
+35
-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,13 @@ import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthVerdict
|
||||
class RelayAuthPermissionLedger(
|
||||
val store: RelayAuthPermissionStore,
|
||||
val globalPolicy: () -> RelayAuthPolicy,
|
||||
/**
|
||||
* Relays this account already approved during this run of the app. Answering the prompt without
|
||||
* the "remember" switch records the grant here, so the same relay's next reconnect is answered
|
||||
* silently instead of raising the same dialog again. Empty by default — a ledger built without
|
||||
* one simply has no session memory.
|
||||
*/
|
||||
val sessionGrants: RelayAuthSessionGrants = RelayAuthSessionGrants(),
|
||||
val customToggles: () -> RelayAuthCustomToggles = { RelayAuthCustomToggles() },
|
||||
val isInMyRelayList: (String) -> Boolean = { false },
|
||||
val isBlocked: (String) -> Boolean = { false },
|
||||
@@ -81,6 +89,7 @@ class RelayAuthPermissionLedger(
|
||||
RelayAuthInputs(
|
||||
storedOverride = store.loadDecision(ctx.relayUrl),
|
||||
isBlocked = isBlocked(ctx.relayUrl),
|
||||
hasSessionGrant = sessionGrants.isGranted(ctx.relayUrl),
|
||||
policy = globalPolicy(),
|
||||
toggles = customToggles(),
|
||||
isInMyRelayList = isInMyRelayList(ctx.relayUrl),
|
||||
@@ -137,14 +146,35 @@ class RelayAuthPermissionLedger(
|
||||
if (additions.isNotEmpty()) store.recordUse(ctx.relayUrl, additions)
|
||||
}
|
||||
|
||||
/** Stores a per-relay override for [relayUrl]. */
|
||||
/**
|
||||
* Remembers a "log in" answer for [relayUrl] until the app is restarted, so the relay's next
|
||||
* reconnect doesn't ask again. Nothing is written to disk — see [RelayAuthSessionGrants].
|
||||
*/
|
||||
fun grantForSession(relayUrl: String) = sessionGrants.grant(relayUrl)
|
||||
|
||||
/** Forgets this session's grant for [relayUrl], so the next challenge is decided from scratch. */
|
||||
fun revokeSessionGrant(relayUrl: String) = sessionGrants.revoke(relayUrl)
|
||||
|
||||
/**
|
||||
* Stores a per-relay override for [relayUrl].
|
||||
*
|
||||
* Also drops any session grant: the stored decision is now the whole answer for this relay, so
|
||||
* leaving the transient one behind would let a later [clearDecision] ("follows your rules again")
|
||||
* silently keep authenticating off a grant the user can no longer see.
|
||||
*/
|
||||
suspend fun setDecision(
|
||||
relayUrl: String,
|
||||
decision: RelayAuthDecision,
|
||||
) = store.storeDecision(relayUrl, decision)
|
||||
) {
|
||||
sessionGrants.revoke(relayUrl)
|
||||
store.storeDecision(relayUrl, decision)
|
||||
}
|
||||
|
||||
/** Removes the per-relay override for [relayUrl], reverting to the global policy. */
|
||||
suspend fun clearDecision(relayUrl: String) = store.clearDecision(relayUrl)
|
||||
suspend fun clearDecision(relayUrl: String) {
|
||||
sessionGrants.revoke(relayUrl)
|
||||
store.clearDecision(relayUrl)
|
||||
}
|
||||
|
||||
/** All per-relay overrides — for the settings screen. */
|
||||
suspend fun allDecisions(): Map<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() }
|
||||
}
|
||||
+85
-2
@@ -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,36 @@ fun RelayAuthSettingsScreen(
|
||||
|
||||
val exceptionUrls = remember(exceptions) { exceptions.keys.sorted() }
|
||||
val blockedUrls = remember(blockedRelays) { blockedRelays.map { it.url }.sorted() }
|
||||
// Answers given to the prompt without the "remember" switch. Any relay that also carries a rule
|
||||
// above is shown there instead — the rule is what actually decides it.
|
||||
val sessionUrls =
|
||||
remember(sessionGrants, exceptions, blockedUrls) {
|
||||
(sessionGrants - exceptions.keys - blockedUrls.toSet()).sorted()
|
||||
}
|
||||
// The log is everything we have a record of that is not already stated above as a rule.
|
||||
val logUrls =
|
||||
remember(exceptions, rationales, lastUsed, blockedUrls) {
|
||||
((rationales.keys + lastUsed.keys) - exceptions.keys - blockedUrls.toSet())
|
||||
remember(exceptions, rationales, lastUsed, blockedUrls, sessionUrls) {
|
||||
((rationales.keys + lastUsed.keys) - exceptions.keys - blockedUrls.toSet() - sessionUrls.toSet())
|
||||
.sortedByDescending { lastUsed[it] ?: 0L }
|
||||
}
|
||||
|
||||
val removedLabel = stringResource(R.string.relay_auth_exception_removed_undo)
|
||||
val sessionForgottenLabel = stringResource(R.string.relay_auth_session_forgotten_undo)
|
||||
val undoLabel = stringResource(R.string.relay_auth_undo)
|
||||
|
||||
fun forgetSessionGrant(url: String) {
|
||||
ledger.revokeSessionGrant(url)
|
||||
scope.launch {
|
||||
val result =
|
||||
snackbarHostState.showSnackbar(
|
||||
message = sessionForgottenLabel.format(url.normalizeRelayUrlOrNull()?.displayUrl() ?: url),
|
||||
actionLabel = undoLabel,
|
||||
withDismissAction = true,
|
||||
)
|
||||
if (result == SnackbarResult.ActionPerformed) ledger.grantForSession(url)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeException(url: String) {
|
||||
scope.launch {
|
||||
val previous = exceptions[url]
|
||||
@@ -288,6 +309,32 @@ fun RelayAuthSettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Only rendered when something is granted: an empty card here would advertise a list the
|
||||
// user has no way to add to from this screen.
|
||||
if (sessionUrls.isNotEmpty()) {
|
||||
item {
|
||||
Spacer(Modifier.height(20.dp))
|
||||
GroupHeader(stringResource(R.string.relay_auth_session_section))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
itemsIndexed(sessionUrls, key = { _, url -> "session:$url" }) { index, url ->
|
||||
GroupedRow(index, sessionUrls.size) {
|
||||
SessionGrantRow(
|
||||
url = url,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
onPromote = { next ->
|
||||
scope.launch {
|
||||
ledger.setDecision(url, next)
|
||||
reloadKey++
|
||||
}
|
||||
},
|
||||
onForget = { forgetSessionGrant(url) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(Modifier.height(20.dp))
|
||||
GroupHeader(stringResource(R.string.relay_auth_blocked_section))
|
||||
@@ -475,6 +522,42 @@ private fun ExceptionRow(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A relay the user logged in to from the prompt without asking to remember it. It behaves like an
|
||||
* ALLOW exception for the rest of this run and then disappears, so it gets its own group rather than
|
||||
* sitting in "Exceptions" — nothing here survives a restart.
|
||||
*/
|
||||
@Composable
|
||||
private fun SessionGrantRow(
|
||||
url: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
onPromote: (RelayAuthDecision) -> Unit,
|
||||
onForget: () -> Unit,
|
||||
) {
|
||||
RelayRowFrame(
|
||||
url = url,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
subtitle = {
|
||||
Text(
|
||||
text = stringResource(R.string.relay_auth_session_row_desc),
|
||||
fontSize = 13.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
},
|
||||
trailing = {
|
||||
// Neither segment is selected: a session grant is not an override, and promoting it to
|
||||
// one is exactly what these two buttons are for.
|
||||
DecisionSegments(current = null, onDecision = onPromote)
|
||||
IconButton(onClick = onForget) {
|
||||
Icon(MaterialSymbols.Close, contentDescription = stringResource(R.string.relay_auth_forget_session))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** A relay on the kind-10006 block list: a hard DENY that outranks everything else on this screen. */
|
||||
@Composable
|
||||
private fun BlockedRow(
|
||||
|
||||
@@ -1195,6 +1195,10 @@
|
||||
<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_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>
|
||||
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.relayClient.authCommand.model
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.AuthPurpose
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.AuthPurposeKind
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthContext
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthVerdict
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* "Log in" without the remember switch has to survive the relay's next reconnect, or the same dialog
|
||||
* comes back every time the socket drops — which is what pushed users into "always allow".
|
||||
*
|
||||
* These tests drive [RelayAuthPermissionLedger] rather than the pure resolver, because the thing worth
|
||||
* pinning is that the grant is consulted on the real decision path and that the persisted rules still
|
||||
* outrank it.
|
||||
*/
|
||||
class RelayAuthSessionGrantsTest {
|
||||
private val relay = "wss://auth.example.com/"
|
||||
private val other = "wss://elsewhere.example.com/"
|
||||
|
||||
private fun ledger(
|
||||
grants: RelayAuthSessionGrants = RelayAuthSessionGrants(),
|
||||
store: InMemoryRelayAuthPermissionStore = InMemoryRelayAuthPermissionStore(),
|
||||
blocked: Set<String> = emptySet(),
|
||||
) = RelayAuthPermissionLedger(
|
||||
store = store,
|
||||
globalPolicy = { RelayAuthPolicy.CUSTOM },
|
||||
sessionGrants = grants,
|
||||
isBlocked = { it in blocked },
|
||||
)
|
||||
|
||||
/** A challenge we can explain but have no automatic rule for: the ASK case. */
|
||||
private fun askable(relayUrl: String) =
|
||||
RelayAuthContext(
|
||||
relayUrl,
|
||||
listOf(AuthPurpose(AuthPurposeKind.MY_INBOX)),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun withoutAGrantTheSameRelayKeepsAsking() =
|
||||
runTest {
|
||||
val ledger = ledger()
|
||||
assertEquals(RelayAuthVerdict.ASK, ledger.decide(askable(relay)))
|
||||
assertEquals(RelayAuthVerdict.ASK, ledger.decide(askable(relay)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSessionGrantAnswersEveryLaterReconnect() =
|
||||
runTest {
|
||||
val ledger = ledger()
|
||||
assertEquals(RelayAuthVerdict.ASK, ledger.decide(askable(relay)))
|
||||
|
||||
ledger.grantForSession(relay)
|
||||
|
||||
assertEquals(RelayAuthVerdict.ALLOW, ledger.decide(askable(relay)))
|
||||
assertEquals(RelayAuthVerdict.ALLOW, ledger.decide(askable(relay)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aGrantCoversOnlyTheRelayItWasGivenFor() =
|
||||
runTest {
|
||||
val ledger = ledger()
|
||||
ledger.grantForSession(relay)
|
||||
|
||||
assertEquals(RelayAuthVerdict.ALLOW, ledger.decide(askable(relay)))
|
||||
assertEquals(RelayAuthVerdict.ASK, ledger.decide(askable(other)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun grantsAreNeverWrittenToTheStore() =
|
||||
runTest {
|
||||
val store = InMemoryRelayAuthPermissionStore()
|
||||
val ledger = ledger(store = store)
|
||||
ledger.grantForSession(relay)
|
||||
|
||||
// Nothing persisted: a fresh process (a ledger over the same disk, with empty session
|
||||
// memory) is back to asking.
|
||||
assertEquals(emptyMap<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)))
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
+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].
|
||||
@@ -68,6 +70,7 @@ data class RelayAuthCustomToggles(
|
||||
data class RelayAuthInputs(
|
||||
val storedOverride: RelayAuthDecision?,
|
||||
val isBlocked: Boolean,
|
||||
val hasSessionGrant: Boolean = false,
|
||||
val policy: RelayAuthPolicy,
|
||||
val toggles: RelayAuthCustomToggles,
|
||||
val isInMyRelayList: Boolean,
|
||||
@@ -84,13 +87,16 @@ data class RelayAuthInputs(
|
||||
*
|
||||
* 1. Blocked-relay list → [RelayAuthVerdict.DENY] (never reveal identity to a blocked relay).
|
||||
* 2. Explicit per-relay override → honor it.
|
||||
* 3. Top-level [RelayAuthPolicy]:
|
||||
* 3. [RelayAuthInputs.hasSessionGrant] → [RelayAuthVerdict.ALLOW]. Ranked *below* the stored override
|
||||
* so a later "never allow" — the only way a DENY can be written for a relay already granted this
|
||||
* session — takes effect immediately instead of losing to the in-memory grant.
|
||||
* 4. Top-level [RelayAuthPolicy]:
|
||||
* - [RelayAuthPolicy.NEVER] → DENY
|
||||
* - [RelayAuthPolicy.ALWAYS] → ALLOW
|
||||
* - [RelayAuthPolicy.CUSTOM] → ALLOW if any *enabled* [RelayAuthCustomToggles] category matches
|
||||
* this relay (own relays/venues, reading follows, messaging follows, messaging strangers);
|
||||
* else fall through
|
||||
* 4. Fall-through → [RelayAuthVerdict.ASK] when the purpose is known, otherwise DENY.
|
||||
* 5. Fall-through → [RelayAuthVerdict.ASK] when the purpose is known, otherwise DENY.
|
||||
*
|
||||
* The [RelayAuthPolicy.CUSTOM] grant additionally requires [RelayAuthInputs.isFirstParty]: under
|
||||
* "decide per relay" an account never reveals its identity *without being asked* on a relay it has no
|
||||
@@ -112,6 +118,11 @@ object RelayAuthResolver {
|
||||
}
|
||||
}
|
||||
|
||||
// The user answered this exact question, for this exact relay, earlier in this session. Not
|
||||
// gated on isFirstParty: an explicit answer outranks every inference we would otherwise make
|
||||
// about whether the account belongs here.
|
||||
if (inputs.hasSessionGrant) return RelayAuthVerdict.ALLOW
|
||||
|
||||
return when (inputs.policy) {
|
||||
RelayAuthPolicy.NEVER -> RelayAuthVerdict.DENY
|
||||
// Unconditional, by design: "Always log in" means every relay that asks. Narrowing it to the
|
||||
|
||||
+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