feat(relay-auth): per-account NIP-42 override store + state on Account

Per-relay ALLOW/DENY AUTH overrides (and the policy ledger that reads them)
lived in one process-wide DataStore keyed only by relay URL, so a DENY set for
one account silently applied to every logged-in account. Move them to a
per-account file under accounts/<pubkey>/ and warm-cache the overrides in memory
on the Account (RelayAuthPermissionCache) so an AUTH challenge is answered
without a disk read.

DataStoreRelayAuthPermissionStore now shares one DataStore per file path —
DataStore v1 forbids two live instances on one file, and loadAccount can build
the store more than once per account.

Introduces the per-account state holders wired up by the following commits:
Account.relayAuthPermissions, relayAuthLedger and relayNotifications.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-07-12 19:32:24 -04:00
co-authored by Claude Opus 4.8
parent c2714a2f9b
commit f8416f4940
5 changed files with 236 additions and 5 deletions
@@ -51,6 +51,8 @@ import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendStage
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSender
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapShare
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthCustomToggles
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.service.pow.PersistedPoWJob
import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory
@@ -127,6 +129,10 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxLoaderState
import com.vitorpamplona.amethyst.model.trustedAssertions.TrustProviderListState
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.notifyCommand.model.NotifyRequestsCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
import com.vitorpamplona.amethyst.service.uploads.FileHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor
@@ -170,6 +176,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
@@ -339,6 +346,7 @@ class Account(
val marmotMessageStore: com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore? = null,
val marmotKeyPackageStore: com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore? = null,
val powQueue: () -> PoWPublishQueue? = { null },
relayAuthPermissionStore: RelayAuthPermissionStore = InMemoryRelayAuthPermissionStore(),
) : IAccount {
private var userProfileCache: User? = null
@@ -353,6 +361,41 @@ class Account(
val userMetadata = UserMetadataState(signer, cache, scope, settings)
// Per-account NIP-42 ALLOW/DENY overrides, warm-cached in memory so a relay AUTH challenge is
// answered without a disk read. Backed by a per-account file (see AccountCacheState).
val relayAuthPermissions = RelayAuthPermissionCache(relayAuthPermissionStore, scope)
// 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
// AUTH challenge is decided per account instead of folding every logged-in account together.
val relayAuthLedger =
RelayAuthPermissionLedger(
store = relayAuthPermissions,
globalPolicy = { settings.defaultRelayAuthPolicy.value },
customToggles = {
RelayAuthCustomToggles(
myRelaysAndVenues = settings.relayAuthTrustMyRelaysAndVenues.value,
readFollows = settings.relayAuthTrustReadFollows.value,
messageFollows = settings.relayAuthTrustMessageFollows.value,
messageStrangers = settings.relayAuthTrustMessageStrangers.value,
)
},
isInMyRelayList = { relayUrl -> relayUrl.normalizeRelayUrlOrNull()?.let { it in trustedRelays.flow.value } ?: false },
isBlocked = { relayUrl -> relayUrl.normalizeRelayUrlOrNull()?.let { it in blockedRelayList.flow.value } ?: false },
isFollowed = { pubkey -> pubkey in allFollows.flow.value.authors },
isTrustedVenue = { venueId ->
venueId in publicChatList.flowSet.value ||
venueId in communityList.flowSet.value ||
Address.parse(venueId)?.pubKeyHex?.let { it in allFollows.flow.value.authors } == true
},
)
// Per-account relay NOTIFY (payment-prompt) cache. NotifyCoordinator attributes each incoming
// NOTIFY to the account whose AUTH the relay rejected and drops it here, so a prompt for one
// account never surfaces under another (the old cache was a process-wide singleton).
val relayNotifications = NotifyRequestsCache()
override val nip47SignerState = NwcSignerState(signer, nwcFilterAssembler, cache, scope, settings)
val nip65RelayList = Nip65RelayListState(signer, cache, scope, settings)
@@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.model.marmot.AndroidMarmotMessageStore
import com.vitorpamplona.amethyst.model.marmot.AndroidMlsGroupStateStore
import com.vitorpamplona.amethyst.model.marmot.InMemoryMlsGroupStateStore
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.DataStoreRelayAuthPermissionStore
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
@@ -224,6 +225,10 @@ class AccountCacheState(
null
}
// Per-account NIP-42 ALLOW/DENY overrides live in this account's own dir, so a DENY for one
// account never leaks into another (the store used to be a single app-wide file).
val relayAuthPermissionStore = DataStoreRelayAuthPermissionStore(accountDir)
return Account(
settings = accountSettings,
signer = signerWithClientTag,
@@ -247,6 +252,7 @@ class AccountCacheState(
marmotMessageStore = marmotMessageStore,
marmotKeyPackageStore = marmotKeyPackageStore,
powQueue = powQueue,
relayAuthPermissionStore = relayAuthPermissionStore,
).also { newAccount ->
accounts.update { existingAccounts ->
existingAccounts.plus(Pair(signer.pubKey, newAccount))
@@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.flow.first
import java.io.File
import java.security.MessageDigest
import java.util.concurrent.ConcurrentHashMap
/**
* Single-file DataStore-backed [RelayAuthPermissionStore]. All per-relay ALLOW/DENY overrides
@@ -45,11 +46,10 @@ class DataStoreRelayAuthPermissionStore(
) : RelayAuthPermissionStore {
constructor(context: Context) : this(context.applicationContext.filesDir)
private val store: DataStore<Preferences> by lazy {
PreferenceDataStoreFactory.create(
produceFile = { File(filesDir, "datastore/relay_auth.preferences_pb") },
)
}
// DataStore v1 throws if two instances are ever active on the same file. loadAccount can build
// this store more than once for the same account (re-login, cache races), so the underlying
// DataStore is shared per absolute file path across the process instead of created per instance.
private val store: DataStore<Preferences> get() = dataStoreFor(File(filesDir, "datastore/relay_auth.preferences_pb"))
override suspend fun loadDecision(relayUrl: String): RelayAuthDecision? {
val raw = store.data.first()[decisionKey(relayUrl)] ?: return null
@@ -198,6 +198,15 @@ class DataStoreRelayAuthPermissionStore(
private fun lastUsedKey(relayUrl: String) = stringPreferencesKey("$LAST_USED_PREFIX${hash(relayUrl)}")
companion object {
// One DataStore per file path, process-wide. computeIfAbsent runs the factory at most once
// per path, so concurrent constructions for the same account share a single active DataStore.
private val stores = ConcurrentHashMap<String, DataStore<Preferences>>()
private fun dataStoreFor(file: File): DataStore<Preferences> =
stores.computeIfAbsent(file.absolutePath) {
PreferenceDataStoreFactory.create(produceFile = { file })
}
private const val DECISION_PREFIX = "allow:"
private const val URL_PREFIX = "url:"
private const val RATIONALE_PREFIX = "rat:"
@@ -0,0 +1,68 @@
/*
* 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.AuthPurposeKind
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore
/**
* Volatile, non-persistent [RelayAuthPermissionStore]. Used as the default for [com.vitorpamplona.amethyst.model.Account]
* instances built without a disk-backed store (Compose previews, unit tests, the mock account view
* models) so nothing on the auth path has to null-check the store.
*/
class InMemoryRelayAuthPermissionStore : RelayAuthPermissionStore {
private val decisions = mutableMapOf<String, RelayAuthDecision>()
private val rationale = mutableMapOf<String, MutableMap<AuthPurposeKind, MutableSet<String>>>()
override suspend fun loadDecision(relayUrl: String): RelayAuthDecision? = decisions[relayUrl]
override suspend fun storeDecision(
relayUrl: String,
decision: RelayAuthDecision,
) {
decisions[relayUrl] = decision
}
override suspend fun clearDecision(relayUrl: String) {
decisions.remove(relayUrl)
}
override suspend fun allDecisions(): Map<String, RelayAuthDecision> = decisions.toMap()
override suspend fun recordUse(
relayUrl: String,
additions: Map<AuthPurposeKind, Set<String>>,
) {
val forRelay = rationale.getOrPut(relayUrl) { mutableMapOf() }
for ((kind, pubkeys) in additions) {
forRelay.getOrPut(kind) { mutableSetOf() }.addAll(pubkeys)
}
}
override suspend fun loadRationale(relayUrl: String): Map<AuthPurposeKind, Set<String>> = rationale[relayUrl]?.mapValues { it.value.toSet() } ?: emptyMap()
override suspend fun allRationales(): Map<String, Map<AuthPurposeKind, Set<String>>> = rationale.mapValues { entry -> entry.value.mapValues { it.value.toSet() } }
override suspend fun clearRationale(relayUrl: String) {
rationale.remove(relayUrl)
}
}
@@ -0,0 +1,105 @@
/*
* 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.AuthPurposeKind
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
/**
* In-memory, warm-cached view over one account's [RelayAuthPermissionStore] (a per-account file —
* see [DataStoreRelayAuthPermissionStore] built from `accounts/<pubkey>/`). Held on the
* [com.vitorpamplona.amethyst.model.Account] like the other state caches.
*
* The ALLOW/DENY overrides are the only thing read on the hot NIP-42 decision path
* ([RelayAuthPermissionLedger.decide]). They are snapshotted into memory once, right after the
* account loads, and served from there — so an incoming AUTH challenge is answered without a disk
* read (a slow disk hit here would stall login-time relay auth). Writes update memory *and* disk.
*
* Rationale + last-used are read only by the settings screen, off the hot path, so they pass
* straight through to disk. Implements [RelayAuthPermissionStore] so it drops into every existing
* call site (the ledger and the settings screen) unchanged.
*/
class RelayAuthPermissionCache(
private val disk: RelayAuthPermissionStore,
scope: CoroutineScope,
) : RelayAuthPermissionStore {
private val loaded = CompletableDeferred<Unit>()
private val _overrides = MutableStateFlow<Map<String, RelayAuthDecision>>(emptyMap())
/** Per-relay overrides for this account, observable so the settings screen refreshes on change. */
val overrides: StateFlow<Map<String, RelayAuthDecision>> = _overrides.asStateFlow()
init {
scope.launch {
_overrides.value = disk.allDecisions()
loaded.complete(Unit)
}
}
/** Non-suspending override lookup — returns null until the initial warm load finishes. */
fun decisionOrNull(relayUrl: String): RelayAuthDecision? = _overrides.value[relayUrl]
override suspend fun loadDecision(relayUrl: String): RelayAuthDecision? {
loaded.await()
return _overrides.value[relayUrl]
}
override suspend fun storeDecision(
relayUrl: String,
decision: RelayAuthDecision,
) {
disk.storeDecision(relayUrl, decision)
_overrides.update { it + (relayUrl to decision) }
}
override suspend fun clearDecision(relayUrl: String) {
disk.clearDecision(relayUrl)
_overrides.update { it - relayUrl }
}
override suspend fun allDecisions(): Map<String, RelayAuthDecision> {
loaded.await()
return _overrides.value
}
// --- rationale + last-used: settings-screen only, never on the auth decision path ---
override suspend fun recordUse(
relayUrl: String,
additions: Map<AuthPurposeKind, Set<String>>,
) = disk.recordUse(relayUrl, additions)
override suspend fun loadRationale(relayUrl: String): Map<AuthPurposeKind, Set<String>> = disk.loadRationale(relayUrl)
override suspend fun allRationales(): Map<String, Map<AuthPurposeKind, Set<String>>> = disk.allRationales()
override suspend fun clearRationale(relayUrl: String) = disk.clearRationale(relayUrl)
override suspend fun allLastUsed(): Map<String, Long> = disk.allLastUsed()
}