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:
Claude
2026-08-17 14:21:40 +00:00
parent be2ed3b7f4
commit cb7ba7dbf7
10 changed files with 429 additions and 11 deletions
@@ -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,
@@ -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
@@ -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()
@@ -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,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(
+4
View File
@@ -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>
@@ -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)
}
}
@@ -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,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)))