mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 08:47:33 +00:00
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:
co-authored by
Claude Opus 4.8
parent
c2714a2f9b
commit
f8416f4940
@@ -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)
|
||||
|
||||
+6
@@ -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))
|
||||
|
||||
+14
-5
@@ -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:"
|
||||
|
||||
+68
@@ -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)
|
||||
}
|
||||
}
|
||||
+105
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user