Merge remote-tracking branch 'origin/main' into claude/armada-nip29-integration-lwqard

# Conflicts:
#	cli/tests/.gitignore
This commit is contained in:
Claude
2026-07-09 21:48:04 +00:00
226 changed files with 21723 additions and 1072 deletions
@@ -0,0 +1,44 @@
/*
* 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.commons.defaults
/**
* Curated indexer relays for resolving NIP-17 inbox lookups (kind:10050).
*
* Used by [com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.DmInboxRelayResolver]
* via a SEPARATE unauthenticated NostrClient — these queries MUST NOT carry an
* AUTH event back to the user's identity key (security review F-01: an
* authenticated indexer fan-out turns "indexer learns we queried for pubkey X"
* into "indexer learns Amethyst user U queried for pubkey X").
*
* Set selected for known kind:10050 indexing coverage; `purplepag.es` is
* deliberately excluded (metadata indexer, weak kind:10050 coverage).
*/
object DefaultDmIndexerRelays {
val RELAYS: List<String> =
listOf(
"wss://relay.nos.social",
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.nostr.band",
"wss://purplerelay.com",
)
}
@@ -114,6 +114,24 @@ class User(
fun dmInboxRelays() = dmInboxRelayList()?.relays()?.ifEmpty { null } ?: inboxRelays()
/**
* Strict variant of [dmInboxRelays] that returns ONLY the user's NIP-17
* inbox relays (kind:10050) and never falls back to the NIP-65 read
* marker (kind:10002).
*
* Per NIP-17 §Publishing, gift wraps MUST land on relays advertised in
* the recipient's kind:10050 — the NIP-65 read fallback in
* [dmInboxRelays] is a UI-convenience heuristic that leaks the DM
* metadata to relays the recipient did not designate for DMs. Any code
* that decides "can I actually deliver a NIP-17 wrap to this user"
* should call this strict variant; UI hints and probe-time bootstrap
* paths may continue to use the lenient one.
*
* Returns `null` when the recipient has no published kind:10050 (or an
* empty one) — callers treat this as "unreachable via NIP-17".
*/
fun dmInboxRelaysStrict() = dmInboxRelayList()?.relays()?.ifEmpty { null }
fun bestRelayHint() = authorRelayList()?.writeRelaysNorm()?.firstOrNull() ?: mostUsedNonLocalRelay()
fun allUsedRelaysOrNull() = relays?.allOrNull()
@@ -185,6 +185,7 @@ class NostrSignerPermissionLedger(
* Deliberately conservative: when a kind's blast radius is unclear, it is left out so the user
* is asked rather than surprised.
*/
@Suppress("DEPRECATION") // TorrentCommentEvent is deprecated (NIP-22) but still a reasonable sign kind
val REASONABLE_SIGN_KINDS: Set<Int> =
setOf(
TextNoteEvent.KIND, // 1 — short text notes & replies
@@ -0,0 +1,30 @@
/*
* 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.commons.privacylock
/**
* Routes gated by the privacy lock.
*
* A single master `PrivacyLockSettings.lockEnabled` flag protects all scopes
* together, but each scope keeps its own [PrivacyLockState] so that unlock,
* idle-timer, and leave-route transitions apply independently per route.
*/
enum class LockScope { Messages, Wallet }
@@ -37,7 +37,7 @@ import kotlinx.coroutines.flow.StateFlow
interface PrivacyLockSettings {
val lockEnabled: StateFlow<Boolean>
val inactivityTimer: StateFlow<InactivityTimer>
val redactionLevel: StateFlow<DmRedactionLevel>
val dmRedactionLevel: StateFlow<DmRedactionLevel>
val firstRunCardSeen: StateFlow<Boolean>
/**
@@ -68,7 +68,7 @@ interface PrivacyLockSettings {
fun setInactivityTimer(timer: InactivityTimer)
fun setRedactionLevel(level: DmRedactionLevel)
fun setDmRedactionLevel(level: DmRedactionLevel)
fun setFirstRunCardSeen(seen: Boolean)
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.commons.privacylock
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.compositionLocalOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
@@ -33,19 +35,25 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
/**
* App-global state holder for the Messages privacy lock.
* App-global state holder for a single privacy-lock [scope].
*
* One instance per gated route (Messages, Wallet, ) is provided via
* [LocalPrivacyLockState] at the App composition root. All instances share
* the same [PrivacyLockSettings] one master `lockEnabled` flag enables
* every scope together but each scope keeps its own [LockState] and its
* own idle-timer [Job] so unlock, leave-route, and inactivity transitions
* apply independently per route.
*
* - Single instance per app, provided via [LocalMessagesLockState] at the
* App composition root.
* - Initial value is seeded synchronously from [settings.lockEnabled.value]
* so the first composition sees [LockState.Locked] without flashing
* content (deep-link race fix, plan §Security Hardening H1).
* - The underlying StateFlow is hot (`MutableStateFlow`); notification path
* can read `state.value` synchronously without subscribing.
*/
class MessagesLockState(
class PrivacyLockState(
val scope: LockScope,
private val settings: PrivacyLockSettings,
private val scope: CoroutineScope,
private val coroutineScope: CoroutineScope,
) {
private val seed: LockState =
if (settings.lockEnabled.value) LockState.Locked else LockState.Disabled
@@ -64,12 +72,12 @@ class MessagesLockState(
} else if (mutableState.value is LockState.Disabled) {
mutableState.value = LockState.Locked
}
}.launchIn(scope)
}.launchIn(coroutineScope)
combine(settings.lockEnabled, settings.inactivityTimer) { enabled, timer -> enabled to timer }
.onEach { _ ->
if (mutableState.value is LockState.Unlocked) restartIdleTimer()
}.launchIn(scope)
}.launchIn(coroutineScope)
}
/** Resets the inactivity timer. No-op unless currently Unlocked. */
@@ -90,7 +98,7 @@ class MessagesLockState(
* Mark the session as authenticated. Transitions from either
* [LockState.Locked] (normal unlock path) or [LockState.Disabled]
* (first-run banner path enabling the lock while the user is
* actively in Messages should NOT flash the lock screen).
* actively in a gated route should NOT flash the lock screen).
* No-op if already [LockState.Unlocked]. Starts the idle timer.
*/
fun onUnlockSuccess() {
@@ -105,7 +113,8 @@ class MessagesLockState(
/**
* Triggered when biometric / OS credential is permanently unavailable.
* Auto-disables the lock so the user can keep accessing Messages.
* Auto-disables the lock (flips every scope to [LockState.Disabled]
* via the shared setting) so the user can keep accessing gated routes.
*/
fun onCredentialUnavailable() {
cancelIdleTimer()
@@ -118,6 +127,10 @@ class MessagesLockState(
* [PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES] failures: base 30 s,
* doubling each further failure, capped at 5 min.
*
* Backoff state is shared across scopes a mistyped password on the
* Wallet gate locks out the Messages gate too (and vice versa). This is
* intentional anti-brute-force behaviour.
*
* @param nowMs current epoch millis (injected for testability).
* @return the new [PrivacyLockSettings.lockedUntilEpochMs] value, or
* null when no lockout yet applies.
@@ -148,7 +161,7 @@ class MessagesLockState(
cancelIdleTimer()
val millis = settings.inactivityTimer.value.millis ?: return
idleTimerJob =
scope.launch {
coroutineScope.launch {
delay(millis)
if (mutableState.value is LockState.Unlocked) {
mutableState.value = LockState.Locked
@@ -162,8 +175,22 @@ class MessagesLockState(
}
}
/** Provided once at the App composition root. */
val LocalMessagesLockState =
compositionLocalOf<MessagesLockState> {
error("LocalMessagesLockState not provided — wrap App() with CompositionLocalProvider")
/**
* Provided once at the App composition root. Map keyed by [LockScope]; every
* scope must have an entry (see [lockStateFor] which throws when missing).
*/
val LocalPrivacyLockState =
compositionLocalOf<Map<LockScope, PrivacyLockState>> {
error("LocalPrivacyLockState not provided — wrap App() with CompositionLocalProvider")
}
/**
* Convenience accessor used inside gate composables. Reads the map from the
* ambient [LocalPrivacyLockState] and returns the state holder for [scope].
* Throws if the scope was not registered at the App root.
*/
@Composable
@ReadOnlyComposable
fun lockStateFor(scope: LockScope): PrivacyLockState =
LocalPrivacyLockState.current[scope]
?: error("PrivacyLockState for $scope not registered at App root")
@@ -33,13 +33,16 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.concurrent.Volatile
/**
* Coordinates metadata and reactions loading for feed items.
@@ -73,6 +76,15 @@ class FeedMetadataCoordinator(
private val queuedPubkeys = mutableSetOf<HexKey>()
private val queuedNoteIds = mutableSetOf<HexKey>()
private val queuedBoostedIds = mutableSetOf<HexKey>()
private val queuedKind3Pubkeys = mutableSetOf<HexKey>()
// Batched paths only — pubkeys currently in-flight in a batched REQ.
// Prevents rapid re-fire of the same batch. Distinct from queuedPubkeys
// and queuedKind3Pubkeys (which record "asked and at least one relay
// returned EOSE") so a batch that times out with zero events can be
// retried on the next call — see PR #3483 review finding 5.
private val inFlightBatchedMetadata = mutableSetOf<HexKey>()
private val inFlightBatchedKind3 = mutableSetOf<HexKey>()
/**
* Start processing the subscription queue.
@@ -251,14 +263,24 @@ class FeedMetadataCoordinator(
/**
* Fast-path: batched metadata subscription for visible-viewport authors.
* Bypasses rate limiter. Single filter with all authors. Closes after EOSE.
*
* Pubkeys are moved into [queuedPubkeys] (dedup) only after at least one
* relay EOSE'd. On timeout with zero EOSE (index relays all unreachable)
* they roll out of [inFlightBatchedMetadata] so a subsequent call can
* retry — see PR #3483 review finding 5.
*/
fun loadMetadataBatched(
pubkeys: List<HexKey>,
timeoutMs: Long = 5_000L,
) {
val newPubkeys = pubkeys.filter { it !in queuedPubkeys }.distinct()
val newPubkeys =
pubkeys
.asSequence()
.filter { it !in queuedPubkeys && it !in inFlightBatchedMetadata }
.distinct()
.toList()
if (newPubkeys.isEmpty()) return
queuedPubkeys.addAll(newPubkeys)
inFlightBatchedMetadata.addAll(newPubkeys)
scope.launch {
val filter =
@@ -269,8 +291,7 @@ class FeedMetadataCoordinator(
)
val filterMap = indexRelays.associateWith { listOf(filter) }
val subId = newSubId()
val eoseReceived = mutableSetOf<NormalizedRelayUrl>()
val allEose = CompletableDeferred<Unit>()
val gate = BatchEoseGate(scope, target = indexRelays.size)
val listener =
object : SubscriptionListener {
@@ -287,16 +308,96 @@ class FeedMetadataCoordinator(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
eoseReceived.add(relay)
if (eoseReceived.size >= indexRelays.size) {
allEose.complete(Unit)
}
gate.notifyEose(relay)
}
}
client.subscribe(subId, filterMap, listener)
withTimeoutOrNull(timeoutMs) { allEose.await() }
val eosedRelays = gate.awaitAll(timeoutMs)
client.unsubscribe(subId)
if (eosedRelays > 0) {
queuedPubkeys.addAll(newPubkeys)
}
inFlightBatchedMetadata.removeAll(newPubkeys.toSet())
}
}
/**
* Batched kind-3 (follow list) subscription. Used by the WoT service
* to fetch the follow lists of every account the active user follows,
* so friends-of-friends counts can be computed.
*
* Chunks authors into ≤100 per Filter within a single subscription
* so relays with per-filter author caps (nostr-rs-relay defaults to
* ~100) don't silently truncate the batch. Aggregates EOSE across
* chunks and calls [onEose] once (or after [timeoutMs]).
*
* Pubkeys are moved into [queuedKind3Pubkeys] (dedup) only after at
* least one relay EOSE'd. On timeout with zero EOSE (index relays all
* unreachable — common on flaky mobile networks) they roll out of
* [inFlightBatchedKind3] so the next `loadKind3Batched` call retries
* — see PR #3483 review finding 5.
*/
fun loadKind3Batched(
pubkeys: Collection<HexKey>,
timeoutMs: Long = 5_000L,
onEose: () -> Unit = {},
) {
val newPubkeys =
pubkeys
.asSequence()
.filter { it !in queuedKind3Pubkeys && it !in inFlightBatchedKind3 }
.distinct()
.toList()
if (newPubkeys.isEmpty()) {
onEose()
return
}
inFlightBatchedKind3.addAll(newPubkeys)
scope.launch {
val filters =
newPubkeys.chunked(100).map { chunk ->
Filter(
kinds = listOf(ContactListEvent.KIND),
authors = chunk,
limit = chunk.size,
)
}
val filterMap = indexRelays.associateWith { filters }
val subId = newSubId()
val gate = BatchEoseGate(scope, target = indexRelays.size)
val listener =
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
this@FeedMetadataCoordinator.onEvent?.invoke(event, relay)
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
gate.notifyEose(relay)
}
}
client.subscribe(subId, filterMap, listener)
val eosedRelays = gate.awaitAll(timeoutMs)
client.unsubscribe(subId)
if (eosedRelays > 0) {
queuedKind3Pubkeys.addAll(newPubkeys)
}
inFlightBatchedKind3.removeAll(newPubkeys.toSet())
onEose()
}
}
@@ -307,5 +408,53 @@ class FeedMetadataCoordinator(
priorityQueue.clear()
queuedPubkeys.clear()
queuedNoteIds.clear()
queuedKind3Pubkeys.clear()
inFlightBatchedMetadata.clear()
inFlightBatchedKind3.clear()
}
/**
* Aggregates EOSE notifications from per-relay `onEose` callbacks
* (which the client may dispatch on `Dispatchers.IO`) via a
* [Channel]. The consumer coroutine is the sole reader/writer of the
* `seen` set, eliminating the race the previous `mutableSetOf` +
* shared-state check had — see PR #3483 review finding 6.
*
* [awaitAll] blocks up to [timeoutMs] and returns the number of
* relays that EOSE'd (may be less than [target] on timeout). The
* count feeds the retry decision in the batched loaders.
*/
private class BatchEoseGate(
private val scope: CoroutineScope,
private val target: Int,
) {
private val incoming = Channel<NormalizedRelayUrl>(Channel.UNLIMITED)
private val done = CompletableDeferred<Unit>()
@Volatile private var lastCount = 0
fun notifyEose(relay: NormalizedRelayUrl) {
incoming.trySend(relay)
}
suspend fun awaitAll(timeoutMs: Long): Int {
if (target <= 0) return 0
val consumer =
scope.launch {
val seen = mutableSetOf<NormalizedRelayUrl>()
for (relay in incoming) {
if (seen.add(relay)) {
lastCount = seen.size
if (seen.size >= target && !done.isCompleted) {
done.complete(Unit)
}
}
}
}
withTimeoutOrNull(timeoutMs) { done.await() }
incoming.close()
consumer.join()
return lastCount
}
}
}
@@ -0,0 +1,156 @@
/*
* 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.commons.relayClient.auth
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
/**
* Inline AUTH approval banner.
*
* Renders one row per pending tier-2 NIP-42 AUTH challenge with three
* actions: `[Once]` `[Always]` `[Never]`. Each press calls [onResolve]
* with the user's choice, which the parent (typically a coordinator)
* uses to complete the underlying [PendingAuthApproval.decision]
* deferred and persist the scope.
*
* Stacks up to 3 entries inline; the rest collapse into a `+N more` row
* (a future iteration may expand them on click — keep simple for now).
*
* The component is platform-agnostic and lives in `commons` so Android
* and Desktop can render the same UX once the wire-up is built on each
* platform.
*/
@Composable
fun AuthApprovalBanner(
pending: List<PendingAuthApproval>,
onResolve: (NormalizedRelayUrl, AuthApprovalScope) -> Unit,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
visible = pending.isNotEmpty(),
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
modifier = modifier,
) {
Column(modifier = Modifier.fillMaxWidth()) {
val visible = pending.take(3)
val hidden = pending.size - visible.size
visible.forEach { approval ->
AuthApprovalRow(approval = approval, onResolve = onResolve)
}
if (hidden > 0) {
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = "+$hidden more relay${if (hidden == 1) "" else "s"} pending approval",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
)
}
}
}
}
}
@Composable
private fun AuthApprovalRow(
approval: PendingAuthApproval,
onResolve: (NormalizedRelayUrl, AuthApprovalScope) -> Unit,
) {
Surface(
color = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.6f),
modifier = Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.4f)),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
symbol = MaterialSymbols.Lock,
contentDescription = null,
tint = MaterialTheme.colorScheme.onTertiaryContainer,
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = approval.relayUrl.displayUrl(),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onTertiaryContainer,
)
Text(
text =
if (approval.pendingCount > 1) {
"requires authentication for ${approval.pendingCount} messages"
} else {
"requires authentication to deliver this message"
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.8f),
)
}
Spacer(Modifier.width(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
TextButton(onClick = { onResolve(approval.relayUrl, AuthApprovalScope.ONCE) }) {
Text("Once", style = MaterialTheme.typography.labelMedium)
}
TextButton(onClick = { onResolve(approval.relayUrl, AuthApprovalScope.ALWAYS) }) {
Text("Always", style = MaterialTheme.typography.labelMedium)
}
TextButton(onClick = { onResolve(approval.relayUrl, AuthApprovalScope.BLOCKED) }) {
Text("Never", style = MaterialTheme.typography.labelMedium)
}
}
}
}
}
@@ -0,0 +1,202 @@
/*
* 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.commons.relayClient.auth
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* Persisted scope for an AUTH approval decision.
*
* `ONCE` is in-memory only — never written to disk. `ALWAYS` and `BLOCKED`
* persist via [AuthApprovalStore].
*/
enum class AuthApprovalScope {
/** Approve this session; don't persist. */
ONCE,
/** Approve indefinitely (or until the store's TTL expires the row). */
ALWAYS,
/** Reject indefinitely. Future AUTH challenges from this relay are silently dropped. */
BLOCKED,
}
/**
* The classifier verdict for a single AUTH challenge.
*
* `Allow` and `Block` are immediate. `Pending` means the user needs to decide;
* the policy hands back a [CompletableDeferred] that the UI banner completes
* once the user picks `[Once] [Always] [Never]`.
*/
sealed interface AuthApprovalDecision {
/** Auto-sign the AUTH event for this relay. */
data object Allow : AuthApprovalDecision
/** Silently drop the AUTH challenge. */
data object Block : AuthApprovalDecision
/**
* Suspend the signer until the user resolves the prompt.
*
* @property pending populated with the user's choice when the banner is
* actioned. The signer awaits this deferred; if it resolves to
* [AuthApprovalScope.BLOCKED] the AUTH is dropped, otherwise signed.
*/
data class Pending(
val pending: CompletableDeferred<AuthApprovalScope>,
) : AuthApprovalDecision
}
/**
* A pending tier-2 AUTH approval surfaced to the user.
*
* Created when the policy decides a challenge needs user consent. Subscribers
* (an `AccountAuthApprovals` ViewModel — wired in P2.5) render a banner with
* `[Once] [Always] [Never]` buttons that resolve [decision] via `complete()`.
*
* `pendingCount` lets the banner coalesce multiple challenges from the same
* relay into one row (`"<relay-url> requires authentication for 3 messages"`)
* rather than stacking duplicate banners.
*/
data class PendingAuthApproval(
val relayUrl: NormalizedRelayUrl,
val decision: CompletableDeferred<AuthApprovalScope>,
val pendingCount: Int = 1,
)
/**
* Per-account approval store. Implementations persist `ALWAYS` / `BLOCKED`
* grants (typically to a SQLite `auth_approvals` table, wired in P2.4).
*
* `getScope` returns `null` if no decision is recorded for the relay.
*/
interface AuthApprovalStore {
/** Returns the persisted decision for `relayUrl`, or `null` if unknown. */
suspend fun getScope(relayUrl: NormalizedRelayUrl): AuthApprovalScope?
/**
* Record a user decision. `ONCE` decisions are NOT persisted by contract —
* the policy caches them in-memory for the current session only.
*/
suspend fun setScope(
relayUrl: NormalizedRelayUrl,
scope: AuthApprovalScope,
)
/** Wipe all persisted approvals. Called on account delete / logout. */
suspend fun clear()
}
/**
* In-memory [AuthApprovalStore] used as a development scaffold and as the
* `ONCE` cache layer on top of a persistent store. Tier-2 banner approvals
* with `ONCE` scope live here for the session and are dropped on logout.
*/
class InMemoryAuthApprovalStore : AuthApprovalStore {
private val scopes = mutableMapOf<NormalizedRelayUrl, AuthApprovalScope>()
private val lock = Mutex()
override suspend fun getScope(relayUrl: NormalizedRelayUrl): AuthApprovalScope? = lock.withLock { scopes[relayUrl] }
override suspend fun setScope(
relayUrl: NormalizedRelayUrl,
scope: AuthApprovalScope,
) {
lock.withLock { scopes[relayUrl] = scope }
}
override suspend fun clear() {
lock.withLock { scopes.clear() }
}
}
/**
* The classifier between the relay client's `signWithAllLoggedInUsers` lambda
* and the actual signer.
*
* Two tiers:
*
* - **Tier 1 (auto-allow):** the relay is in the user's own outbox or
* NIP-17 DM-inbox set, or has a persisted `ALWAYS` grant. Sign immediately,
* no prompt. These are relays the user has already declared they trust.
* - **Tier 2 (prompt):** anything else, with the exception of relays that
* carry a persisted `BLOCKED` grant. Surface a [PendingAuthApproval] via
* [onPromptRequired] and suspend until the user resolves the
* [CompletableDeferred]. If `ONCE`, cache for this session; if `ALWAYS` or
* `BLOCKED`, persist via the store.
*
* No tier-3: every challenge is either auto-allowed, blocked by a persisted
* decision, or surfaced to the user. There is no silent third path.
*
* @property selfApprovedRelays the union of own outbox + DM-inbox + any
* account-level pre-approval. Recomputed by the caller on Account state
* changes. Tier 1 if the challenger is in this set.
* @property store persistence layer (SQLite-backed in production, in-memory in
* tests).
* @property onPromptRequired called when a [PendingAuthApproval] needs to be
* surfaced to the UI. The UI subscribes to this side-channel and completes
* the contained [CompletableDeferred] with the user's pick.
*/
class AuthApprovalPolicy(
val selfApprovedRelays: () -> Set<NormalizedRelayUrl>,
val store: AuthApprovalStore,
val onPromptRequired: (PendingAuthApproval) -> Unit,
) {
/**
* Decide what to do with an AUTH challenge from `relayUrl`.
*
* @return [AuthApprovalDecision.Allow] for tier-1 / persisted-ALWAYS,
* [AuthApprovalDecision.Block] for persisted-BLOCKED,
* [AuthApprovalDecision.Pending] (and emits to [onPromptRequired]) for
* unknown relays.
*/
suspend fun classify(relayUrl: NormalizedRelayUrl): AuthApprovalDecision {
// Persisted decision wins over tier-1: if user explicitly blocked a
// relay that happens to also be in their outbox, respect the block.
when (store.getScope(relayUrl)) {
AuthApprovalScope.ALWAYS -> return AuthApprovalDecision.Allow
AuthApprovalScope.BLOCKED -> return AuthApprovalDecision.Block
AuthApprovalScope.ONCE -> return AuthApprovalDecision.Allow
null -> Unit
}
if (relayUrl in selfApprovedRelays()) {
return AuthApprovalDecision.Allow
}
val deferred = CompletableDeferred<AuthApprovalScope>()
onPromptRequired(PendingAuthApproval(relayUrl, deferred))
return AuthApprovalDecision.Pending(deferred)
}
/** Persist (or cache) the user's choice from a [PendingAuthApproval] resolution. */
suspend fun recordDecision(
relayUrl: NormalizedRelayUrl,
scope: AuthApprovalScope,
) {
// `ONCE` lives in-memory only (the InMemoryAuthApprovalStore handles
// this transparently). `ALWAYS` and `BLOCKED` persist via the store.
store.setScope(relayUrl, scope)
}
}
@@ -0,0 +1,136 @@
/*
* 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.commons.relayClient.nip17Dm
import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* Resolves a recipient's NIP-17 inbox relays (kind:10050) for DM delivery.
*
* Three-layer lookup, in order:
*
* 1. **LocalCache hit** — the caller has already seen the user's kind:10050
* via the normal feed subscription pipeline. Cheapest; no I/O.
* 2. **In-memory LRU cache** — a prior resolve() succeeded for this pubkey
* within the TTL. Avoids re-querying indexers when the user opens a
* conversation list and clicks several recipients in sequence.
* 3. **Indexer fan-out** — query a curated set of indexer relays for the
* user's kind:10050 via [RecipientRelayFetcher]. The client passed in
* here MUST be an **unauthenticated** instance (no [RelayAuthenticator]
* attached) — otherwise an indexer's AUTH challenge would extract an
* identity-key signature from the user, turning the metadata leak
* "indexer learns who we want to DM" into "indexer learns user U wants
* to DM pubkey X".
*
* Filters to **kind:10050 only**. Per NIP-17 §Publishing, gift wraps MUST
* land on relays in the recipient's kind:10050; this resolver never
* substitutes the NIP-65 read marker as a fallback, because doing so leaks
* DMs to relays the recipient did not explicitly designate for DMs.
*
* Empty result is the canonical "we don't know where to send" signal — the
* caller should refuse to publish rather than fall back to its own relays
* (see [com.vitorpamplona.amethyst.desktop.model.DesktopIAccount.resolveDmInboxRelaysStrict]).
*
* @property unauthenticatedClient NostrClient WITHOUT a RelayAuthenticator
* attached. Use a dedicated instance — do NOT pass the app's primary
* client.
* @property indexerRelays Curated indexer set. Typically
* [com.vitorpamplona.amethyst.commons.defaults.DefaultDmIndexerRelays].
* @property localLookup Callback the resolver invokes first to check the
* LocalCache — returns the user's current kind:10050 list or null if
* unknown. Allows commons/headless callers to plug in a CLI-safe lookup.
* @property cacheTtlMs LRU cache TTL. 1h matches the brainstorm's open
* question; configurable here for tests.
* @property cacheSize LRU bound. 100 entries × ~200 bytes each is trivial
* memory; matches typical active-conversation count for power users.
*/
class DmInboxRelayResolver(
private val unauthenticatedClient: INostrClient,
private val indexerRelays: Set<NormalizedRelayUrl>,
private val localLookup: (HexKey) -> List<NormalizedRelayUrl>?,
private val cacheTtlMs: Long = 60 * 60 * 1_000L,
private val cacheSize: Int = 100,
private val nowMs: () -> Long = {
kotlin.time.Clock.System
.now()
.toEpochMilliseconds()
},
) {
private data class Entry(
val relays: List<NormalizedRelayUrl>,
val expiresAtMs: Long,
)
private val cache = linkedMapOf<HexKey, Entry>()
private val mutex = Mutex()
/**
* Resolve `pubkey`'s NIP-17 inbox relays. Returns empty list if neither
* the LocalCache nor the indexer fan-out yielded a kind:10050.
*/
suspend fun resolve(pubkey: HexKey): List<NormalizedRelayUrl> {
localLookup(pubkey)?.takeIf { it.isNotEmpty() }?.let { return it }
val now = nowMs()
mutex.withLock {
cache[pubkey]?.let { entry ->
if (entry.expiresAtMs > now) {
// Refresh LRU order on hit
cache.remove(pubkey)
cache[pubkey] = entry
return entry.relays
} else {
cache.remove(pubkey)
}
}
}
if (indexerRelays.isEmpty()) return emptyList()
val lists = RecipientRelayFetcher.fetchRelayLists(unauthenticatedClient, pubkey, indexerRelays)
// Strict: kind:10050 ONLY. No NIP-65 fallback. Empty = canonical
// "unreachable" signal; caller refuses to publish.
val relays = lists.dmInbox
mutex.withLock {
cache[pubkey] = Entry(relays, now + cacheTtlMs)
while (cache.size > cacheSize) {
cache.remove(cache.keys.iterator().next())
}
}
return relays
}
/** Evict a specific entry — e.g. when LocalCache observes a fresh kind:10050. */
suspend fun invalidate(pubkey: HexKey) {
mutex.withLock { cache.remove(pubkey) }
}
/** Wipe the entire cache — e.g. on account switch. */
suspend fun clear() {
mutex.withLock { cache.clear() }
}
}
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.commons.ui.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
@@ -62,6 +64,10 @@ data class ProfilePictureUrl(
* @param loadProfilePicture Whether to load the profile picture (false = show robohash only)
* @param loadRobohash Whether to generate robohash (false = show generic icon)
* @param useThumbnailCache Whether to use the thumbnail disk cache for faster repeated loads
* @param badge Optional overlay drawn on top of the avatar (bottom-right by
* convention). Used by Desktop for the WoT trust-score chip; Android call
* sites leave it null. When null the avatar renders as before (no extra
* `Box` wrapper).
*/
@Composable
fun UserAvatar(
@@ -73,7 +79,26 @@ fun UserAvatar(
loadProfilePicture: Boolean = true,
loadRobohash: Boolean = true,
useThumbnailCache: Boolean = false,
badge: @Composable (BoxScope.() -> Unit)? = null,
) {
if (badge != null) {
Box(modifier = modifier.size(size)) {
UserAvatar(
userHex = userHex,
pictureUrl = pictureUrl,
size = size,
modifier = Modifier,
contentDescription = contentDescription,
loadProfilePicture = loadProfilePicture,
loadRobohash = loadRobohash,
useThumbnailCache = useThumbnailCache,
badge = null,
)
badge()
}
return
}
val avatarModifier =
remember(size, modifier) {
modifier
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
@@ -46,12 +47,17 @@ import org.jetbrains.compose.resources.stringResource
/**
* A card displaying user search result with avatar, name, and nip05/pubkey.
* Shared between Android and Desktop search screens.
*
* @param badge Optional overlay drawn on top of the avatar (bottom-right
* by convention). Used by Desktop for the WoT trust-score chip; Android
* call sites leave it null. Forwarded to [UserAvatar].
*/
@Composable
fun UserSearchCard(
user: User,
onClick: () -> Unit,
modifier: Modifier = Modifier,
badge: @Composable (BoxScope.() -> Unit)? = null,
) {
Card(
modifier =
@@ -73,6 +79,7 @@ fun UserSearchCard(
pictureUrl = user.profilePicture(),
size = 40.dp,
contentDescription = stringResource(Res.string.accessibility_user_avatar),
badge = badge,
)
Column(modifier = Modifier.weight(1f)) {
@@ -55,7 +55,7 @@ enum class PromptResult {
/**
* Credential surface permanently unavailable on this device — caller
* should invoke [com.vitorpamplona.amethyst.commons.privacylock.MessagesLockState.onCredentialUnavailable].
* should invoke [com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockState.onCredentialUnavailable].
*/
Unavailable,
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.commons.ui.privacylock
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import com.vitorpamplona.amethyst.commons.privacylock.MessagesLockState
import com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockState
/**
* Observes pointer events on the Initial pass — does NOT consume them, so
@@ -35,7 +35,7 @@ import com.vitorpamplona.amethyst.commons.privacylock.MessagesLockState
* since they're not user input — preserves the "walked-away-from-desk"
* protection per brainstorm resolved Q.
*/
fun Modifier.resetIdleOnInteraction(state: MessagesLockState): Modifier =
fun Modifier.resetIdleOnInteraction(state: PrivacyLockState): Modifier =
this.pointerInput(state) {
awaitPointerEventScope {
while (true) {
@@ -0,0 +1,121 @@
/*
* 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.commons.ui.privacylock
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.privacylock.LockScope
import com.vitorpamplona.amethyst.commons.privacylock.lockStateFor
import kotlinx.coroutines.launch
/**
* Shared lock-screen surface used by [MessagesLockGate] and [WalletLockGate].
* Runs the async [CredentialPrompter] path (biometric / OS credential on
* Android + iOS). Desktop platforms use a password-input inline lock screen
* instead — see `DesktopMessagesLockGate` / `DesktopWalletLockGate`.
*
* Kept `internal` so the only public entry points are the per-scope Gates.
*/
@Composable
internal fun LockScreen(
scope: LockScope,
title: String,
subtitle: String,
unlockLabel: String,
) {
val lockState = lockStateFor(scope)
val prompter = LocalCredentialPrompter.current
val coroutineScope = rememberCoroutineScope()
LaunchedEffect(prompter) {
if (!prompter.available) {
lockState.onCredentialUnavailable()
}
}
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
Column(
modifier =
Modifier
.fillMaxSize()
.padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
symbol = MaterialSymbols.Lock,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.primary,
)
Box(modifier = Modifier.size(16.dp))
Text(
text = title,
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
)
Box(modifier = Modifier.size(8.dp))
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.widthIn(max = 320.dp),
)
Box(modifier = Modifier.size(32.dp))
Button(
onClick = {
coroutineScope.launch {
when (prompter.prompt()) {
PromptResult.Success -> lockState.onUnlockSuccess()
PromptResult.Unavailable -> lockState.onCredentialUnavailable()
else -> Unit
}
}
},
enabled = prompter.available,
) {
Text(text = unlockLabel)
}
}
}
}
@@ -20,40 +20,21 @@
*/
package com.vitorpamplona.amethyst.commons.ui.privacylock
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.privacylock.LocalMessagesLockState
import com.vitorpamplona.amethyst.commons.privacylock.LockScope
import com.vitorpamplona.amethyst.commons.privacylock.LockState
import kotlinx.coroutines.launch
import com.vitorpamplona.amethyst.commons.privacylock.lockStateFor
/**
* Wraps the Messages route and gates entry behind the credential prompt.
*
* Branch selection happens SYNCHRONOUSLY in composition — no
* [LaunchedEffect] guard — so the chat content composable never enters
* composition while [LockState.Locked]. Closes the deep-link race
* (plan §Security Hardening H1).
* [androidx.compose.runtime.LaunchedEffect] guard — so the chat content
* composable never enters composition while [LockState.Locked]. Closes the
* deep-link race (plan §Security Hardening H1).
*
* The gate is an overlay, NOT a wrapper that disposes content. While
* locked, the [content] lambda is not invoked at all; on unlock, the
@@ -61,12 +42,14 @@ import kotlinx.coroutines.launch
* `rememberSaveable` survive a lock cycle (SavedStateRegistry-backed).
* For plain `remember` state, drafts are cleared — accept this trade-off.
*
* The gate also fires [MessagesLockState.onLeaveRoute] from its
* [DisposableEffect.onDispose] block, so navigating away locks immediately.
* The gate also fires
* [com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockState.onLeaveRoute]
* from its [DisposableEffect.onDispose] block, so navigating away locks
* immediately.
*/
@Composable
fun MessagesLockGate(content: @Composable () -> Unit) {
val lockState = LocalMessagesLockState.current
val lockState = lockStateFor(LockScope.Messages)
val current by lockState.state.collectAsState()
DisposableEffect(lockState) {
@@ -74,70 +57,13 @@ fun MessagesLockGate(content: @Composable () -> Unit) {
}
when (current) {
is LockState.Locked -> LockScreen()
is LockState.Locked ->
LockScreen(
scope = LockScope.Messages,
title = "Messages locked",
subtitle = "Unlock to read or send messages.",
unlockLabel = "Unlock",
)
else -> content()
}
}
@Composable
private fun LockScreen() {
val lockState = LocalMessagesLockState.current
val prompter = LocalCredentialPrompter.current
val scope = rememberCoroutineScope()
LaunchedEffect(prompter) {
if (!prompter.available) {
lockState.onCredentialUnavailable()
}
}
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
Column(
modifier =
Modifier
.fillMaxSize()
.padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
symbol = MaterialSymbols.Lock,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.primary,
)
Box(modifier = Modifier.size(16.dp))
Text(
text = "Messages locked",
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
)
Box(modifier = Modifier.size(8.dp))
Text(
text = "Unlock to read or send messages",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.widthIn(max = 320.dp),
)
Box(modifier = Modifier.size(32.dp))
Button(
onClick = {
scope.launch {
when (prompter.prompt()) {
PromptResult.Success -> lockState.onUnlockSuccess()
PromptResult.Unavailable -> lockState.onCredentialUnavailable()
else -> Unit
}
}
},
enabled = prompter.available,
) {
Text(text = "Unlock")
}
}
}
}
@@ -0,0 +1,61 @@
/*
* 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.commons.ui.privacylock
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import com.vitorpamplona.amethyst.commons.privacylock.LockScope
import com.vitorpamplona.amethyst.commons.privacylock.LockState
import com.vitorpamplona.amethyst.commons.privacylock.lockStateFor
/**
* Wraps the Wallet route and gates entry behind the credential prompt.
*
* Behaviour mirrors [MessagesLockGate] — see that composable's KDoc for the
* deep-link race, draft persistence, and leave-route semantics. Only the
* [LockScope] and the lock-screen copy differ.
*
* Desktop apps use the platform-specific `DesktopWalletLockGate` (password
* input inline, no async CredentialPrompter round-trip); Android + iOS
* front ends use this composable directly.
*/
@Composable
fun WalletLockGate(content: @Composable () -> Unit) {
val lockState = lockStateFor(LockScope.Wallet)
val current by lockState.state.collectAsState()
DisposableEffect(lockState) {
onDispose { lockState.onLeaveRoute() }
}
when (current) {
is LockState.Locked ->
LockScreen(
scope = LockScope.Wallet,
title = "Wallet locked",
subtitle = "Unlock to see your balance and send or receive sats.",
unlockLabel = "Unlock",
)
else -> content()
}
}
@@ -43,7 +43,7 @@ fun SigningAwareButton(
tint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
) {
when (signingState.state) {
is SigningOpState.Pending -> {
is SigningOpState.Pending, is SigningOpState.Progress -> {
Box(modifier = modifier.size(32.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
@@ -37,11 +37,29 @@ sealed class SigningOpState {
data object Pending : SigningOpState()
/**
* Signing is in flight AND has a known step count — typically a NIP-17
* group send via a remote signer (bunker), where the UI can usefully show
* "Encrypting via remote signer ([current] of [total])".
*
* Treated as Pending for all is-pending checks via [isPending] below;
* existing callers that branch on `is Pending` keep working unchanged.
* New callers can render the counter when [SigningOpState] is `Progress`.
*/
data class Progress(
val current: Int,
val total: Int,
val label: String? = null,
) : SigningOpState()
data class Error(
val message: String,
) : SigningOpState()
}
/** True when signing is in flight, regardless of whether step counts are known. */
fun SigningOpState.isPending(): Boolean = this is SigningOpState.Pending || this is SigningOpState.Progress
/**
* Global signing status — any [SigningState] instance updates this when signing starts/ends.
* Observe [globalState] from a screen-level composable to show a persistent status bar.
@@ -86,7 +104,7 @@ class SigningState {
private set
suspend fun <T> execute(block: suspend () -> T): T? {
if (state is SigningOpState.Pending) return null
if (state.isPending()) return null
state = SigningOpState.Pending
GlobalSigningStatus.onPending()
errorMessage = null
@@ -114,6 +132,23 @@ class SigningState {
}
}
/**
* Update the in-flight signing state with a progress counter. Use during
* multi-step operations (NIP-17 group send via bunker, batch zaps) to
* show the user how far the in-flight op has progressed.
*
* Only takes effect while [state] is Pending or Progress — no-op
* otherwise so callers don't have to gate on Idle/Error themselves.
*/
fun updateProgress(
current: Int,
total: Int,
label: String? = null,
) {
if (!state.isPending()) return
state = SigningOpState.Progress(current, total, label)
}
private fun setError(message: String) {
errorMessage = message
state = SigningOpState.Error(message)
@@ -87,6 +87,21 @@ fun SigningStatusBar(
}
}
is SigningOpState.Progress -> {
Snackbar(
shape = RoundedCornerShape(8.dp),
containerColor = MaterialTheme.colorScheme.inverseSurface,
contentColor = MaterialTheme.colorScheme.inverseOnSurface,
modifier = Modifier.padding(horizontal = 16.dp),
) {
val label = opState.label ?: "Signing"
Text(
text = "$label (${opState.current} of ${opState.total})",
style = MaterialTheme.typography.bodyMedium,
)
}
}
is SigningOpState.Error -> {
Snackbar(
shape = RoundedCornerShape(8.dp),
@@ -25,6 +25,8 @@ import androidx.compose.ui.text.input.TextFieldValue
import com.vitorpamplona.amethyst.commons.model.IAccount
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.references.references
import com.vitorpamplona.quartz.nip10Notes.content.findHashtags
@@ -41,6 +43,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
/**
* Slim shared state for DM message composition.
@@ -56,6 +59,19 @@ class ChatNewMessageState(
val account: IAccount,
val cache: ICacheProvider,
val scope: CoroutineScope,
/**
* Optional resolver for probing kind:10050 via curated indexer relays
* when a peer's DM inbox isn't in the local cache. When provided,
* [updateRecipientRelayStatus] falls through to the resolver on a cache
* miss so the pre-send UI stops falsely reporting "recipient has no DM
* relay list" for accounts whose 10050 sits on an indexer we haven't
* subscribed to yet.
*
* When null (default, e.g. Android's ChatNewMessageViewModel which
* hasn't been wired to a resolver yet), behaviour matches the
* cache-only strict check.
*/
private val dmInboxResolver: (suspend (HexKey) -> List<NormalizedRelayUrl>?)? = null,
) {
private val _message = MutableStateFlow(TextFieldValue(""))
val message: StateFlow<TextFieldValue> = _message.asStateFlow()
@@ -86,20 +102,66 @@ class ChatNewMessageState(
}
/**
* Check if all recipients have DM relay lists.
* Messages can only be sent via NIP-17, so recipients must have
* either a DM inbox relay list (kind 10050) or NIP-65 inbox relays.
* Check whether every participant in the current room is reachable via
* NIP-17 — i.e. has a published kind:10050 that lists at least one
* relay.
*
* Uses the **strict** check ([com.vitorpamplona.amethyst.commons.model.User.dmInboxRelaysStrict],
* kind:10050 only) instead of the lenient [dmInboxRelays] that falls
* back to the NIP-65 read marker — the send path also uses strict
* resolution (see `DesktopIAccount.resolveDmInboxRelaysStrict`), and
* disagreement here caused the pre-send UI to green-light sends that
* would then fail at send time.
*
* Two-phase check:
*
* 1. Synchronous cache check — every peer's kind:10050 sits in
* [cache]. If all present with at least one relay, unblock
* immediately.
* 2. Async resolver probe (only when [dmInboxResolver] is provided) —
* for peers whose 10050 isn't cached, kick off a curated-indexer
* fan-out. If any peer's relays turn up, update the flag to
* unblock the composer without requiring the user to restart the
* conversation view.
*
* The blocking flag is set optimistically during the probe so the
* user still sees the warning until we've confirmed the peer is
* genuinely unreachable via NIP-17. This preserves the "don't allow
* silent-fail sends" invariant.
*/
fun updateRecipientRelayStatus() {
val currentRoom = _room.value
if (currentRoom != null) {
_recipientsMissingDmRelays.value =
currentRoom.users.any { hexKey ->
val user = cache.getOrCreateUser(hexKey)
user?.dmInboxRelays().isNullOrEmpty()
}
} else {
if (currentRoom == null) {
_recipientsMissingDmRelays.value = false
return
}
val missing =
currentRoom.users.filter { hexKey ->
val user = cache.getOrCreateUser(hexKey)
user?.dmInboxRelaysStrict().isNullOrEmpty()
}
if (missing.isEmpty()) {
_recipientsMissingDmRelays.value = false
return
}
// Cache miss → block optimistically, then probe indexers for the
// missing peers. If any of them turn up a kind:10050, unblock.
_recipientsMissingDmRelays.value = true
val resolver = dmInboxResolver ?: return
scope.launch {
val stillMissing =
missing.any { hexKey ->
val fanOut = resolver(hexKey)
fanOut.isNullOrEmpty()
}
// The room may have changed while we were probing; only apply
// the result if we're still looking at the same conversation.
if (_room.value == currentRoom) {
_recipientsMissingDmRelays.value = stillMissing
}
}
}
@@ -0,0 +1,46 @@
/*
* 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.commons.wot
import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.compositionLocalOf
/**
* Compose-observable score service. Provided by the Desktop app at App
* root when a user is logged in. Left null on Android and while logged
* out — leaf composables branch on `LocalWoTService.current == null` to
* skip the WoT rendering path.
*
* The badge-hide predicates (self, already-followed) are read from
* `commons.moderation.LocalSpamExemptKeys` — the same set already
* provided by the hashtag-spam filter.
*/
val LocalWoTService: ProvidableCompositionLocal<WoTService?> =
compositionLocalOf { null }
/**
* Whether the WoT service has finished its initial batch fetch (or the
* 2s startup timeout has elapsed). Read once at the App root via
* [WoTService.isReady] and provided down as a scalar so leaf composables
* don't each spawn a Flow collector.
*/
val LocalWoTReady: ProvidableCompositionLocal<Boolean> =
compositionLocalOf { false }
@@ -0,0 +1,78 @@
/*
* 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.commons.wot
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
/**
* Platform-agnostic interface between [OutboxDispatcher] and the platform's
* event cache. Desktop and `amy` each provide their own implementation —
* DesktopLocalCache on the app side, a minimal in-memory adapter over the
* amy local store on the CLI side.
*
* The dispatcher only needs three capabilities:
*
* 1. Peek at what kind-10002 events are already stored so it can skip
* Phase-1 discovery for authors whose write-relay list is already
* known (from hydration or a previous session's fetch).
* 2. Ingest a kind-10002 that just came back from an index relay so
* subsequent lookups don't re-fetch it.
* 3. Ingest a kind-0 or kind-3 that just came back from an outbox
* relay so the platform cache/UI can pick it up through the usual
* consume path.
*
* Every method must be idempotent — the dispatcher may re-fire the same
* event through the gateway if two relays happen to return the same
* addressable event.
*/
interface OutboxCacheGateway {
/**
* Returns the currently-cached kind-10002 event for [pubkey], or null
* if the platform cache doesn't have one yet.
*/
fun cachedOutbox(pubkey: HexKey): AdvertisedRelayListEvent?
/**
* Called for every kind-10002 the dispatcher receives during Phase 1.
* The gateway should route it through its normal consume path so the
* event is stored, deduped by createdAt, and picked up by any state
* holders observing the addressable-notes cache.
*/
fun onOutboxDiscovered(
event: AdvertisedRelayListEvent,
relay: NormalizedRelayUrl,
)
/**
* Called for every kind-0 (metadata) or kind-3 (contact list) the
* dispatcher receives during Phase 2 or Phase 3. The gateway should
* route it through its normal consume path — this is how new profile
* metadata and follow lists reach downstream consumers like the WoT
* service and the UI.
*/
fun onDiscoveredEvent(
event: Event,
relay: NormalizedRelayUrl,
)
}
@@ -0,0 +1,495 @@
/*
* 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.commons.wot
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip65RelayList.RelayListRecommendationProcessor
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.concurrent.Volatile
/**
* Fetches kind-0 (profile metadata) and kind-3 (contact list) events for a
* set of authors using the NIP-65 **outbox model**:
*
* 1. **Phase 1 — discover.** Ask the configured index relays (Purple Pages,
* Coracle, nos.lol, …) for the kind-10002 of each author. Merge with
* already-cached 10002s from [OutboxCacheGateway].
*
* 2. **Phase 2 — pick + fetch.** Feed the author → write-relays map into
* [RelayListRecommendationProcessor.reliableRelaySetFor] to get a
* minimal, popularity-based set of relays that covers every author.
* Open one subscription per recommended relay, filtered to that
* relay's authors, for kind-0 and/or kind-3.
*
* 3. **Phase 3 — fallback.** For any author whose kind-10002 the network
* never returned, fall back to the index-relay REQ (preserves the
* current behaviour so a coldish account doesn't lose signal).
*
* The dispatcher is single-scoped (one instance per account) so its dedup
* set survives across follow-set diffs. Call [clear] on account switch.
*
* @param client shared [INostrClient] used for every subscription
* @param scope account-lifetime scope; cancelling it cancels in-flight REQs
* @param indexRelays lazy accessor so a change through the settings UI
* takes effect on next fetch without recreating the
* dispatcher
* @param gateway platform-specific cache adapter (see [OutboxCacheGateway])
* @param perRelayTimeoutMs how long each REQ waits for its EOSE. Under
* the plan (2026-07-06): 4 s.
* @param overallTimeoutMs cap on the whole two-phase fetch. Belt against
* a phase getting stuck. Under the plan: 8 s.
* @param maxOutboxRelaysPerAuthor bound author → write-relays to the first N
* relays after the [RelayListRecommendationProcessor]
* chooses them, to keep fan-out predictable
*/
class OutboxDispatcher(
private val client: INostrClient,
private val scope: CoroutineScope,
private val indexRelays: () -> Set<NormalizedRelayUrl>,
private val gateway: OutboxCacheGateway,
private val perRelayTimeoutMs: Long = 4_000L,
private val overallTimeoutMs: Long = 20_000L,
@Suppress("UNUSED_PARAMETER") maxOutboxRelaysPerAuthor: Int = 5,
) {
/**
* Pubkeys we've already successfully fetched kind-3 for this session
* (Phase 1 or Phase 2 returned events for them). Skipping a second
* fetch is safe because a churn event from a subsequent kind-3
* republication still reaches [OutboxCacheGateway.onDiscoveredEvent]
* via other subscriptions (feed, notifications).
*/
private val kind3Succeeded = mutableSetOf<HexKey>()
/**
* Pubkeys we've already successfully fetched kind-0 for this session.
*/
private val kind0Succeeded = mutableSetOf<HexKey>()
/**
* Currently-in-flight authors — prevents rapid re-fire of the same
* fetch. Distinct from [kind3Succeeded]/[kind0Succeeded]: a zero-EOSE
* timeout rolls out of this set (allowing retry) instead of
* permanently marking the pubkey as done.
*/
private val kind3InFlight = mutableSetOf<HexKey>()
private val kind0InFlight = mutableSetOf<HexKey>()
/**
* Outcome counters. All values are aggregated across every phase of
* one [fetchKind3Only] / [fetchKind0And3] call. Callers log them for
* observability; `amy wot sync --json` also emits them so a caller
* can measure whether the outbox path is doing the work vs the
* fallback path.
*/
data class Result(
val authorsRequested: Int,
val kind10002Received: Int,
val kind3Received: Int,
val kind0Received: Int,
val outboxCoveredAuthors: Int,
val fallbackAuthors: Int,
)
/**
* Fetch kind-3 for every pubkey in [authors] via each author's outbox
* relay when known, falling back to index relays otherwise. Suspends
* until every phase EOSEs or times out.
*/
suspend fun fetchKind3Only(authors: Set<HexKey>): Result = run(authors, includeKind0 = false, includeKind3 = true)
/**
* Fetch kind-3 AND kind-0 for every pubkey in [authors]. Same phase
* pipeline; a single per-outbox-relay subscription pulls both kinds
* so we don't double the connection count.
*/
suspend fun fetchKind0And3(authors: Set<HexKey>): Result = run(authors, includeKind0 = true, includeKind3 = true)
/**
* Fetch kind-0 only. Used by the metadata preloader when it decides
* to bypass the index-relay batch for a specific author (e.g. a
* profile screen visit where the author's outbox is already cached).
*/
suspend fun fetchKind0Only(authors: Set<HexKey>): Result = run(authors, includeKind0 = true, includeKind3 = false)
/**
* Drop every dedup marker. Call on account switch so a fresh account
* doesn't inherit the previous account's "already fetched" state.
*/
fun clear() {
kind3Succeeded.clear()
kind0Succeeded.clear()
kind3InFlight.clear()
kind0InFlight.clear()
}
private suspend fun run(
authors: Set<HexKey>,
includeKind0: Boolean,
includeKind3: Boolean,
): Result {
if (authors.isEmpty()) return zeroResult(0)
val newForKind3 =
if (includeKind3) authors.filter { it !in kind3Succeeded && it !in kind3InFlight }.toSet() else emptySet()
val newForKind0 =
if (includeKind0) authors.filter { it !in kind0Succeeded && it !in kind0InFlight }.toSet() else emptySet()
if (newForKind3.isEmpty() && newForKind0.isEmpty()) {
Log.d("OutboxDispatcher") { "skip: all authors deduped (succeeded or in-flight)" }
return zeroResult(authors.size)
}
kind3InFlight.addAll(newForKind3)
kind0InFlight.addAll(newForKind0)
return try {
val result =
withTimeoutOrNull(overallTimeoutMs) {
doRun(authors, newForKind3, newForKind0, includeKind0, includeKind3)
}
if (result == null) {
Log.w("OutboxDispatcher") { "overall timeout ${overallTimeoutMs}ms exceeded — returning zero result" }
zeroResult(authors.size)
} else {
result
}
} finally {
kind3InFlight.removeAll(newForKind3)
kind0InFlight.removeAll(newForKind0)
}
}
private suspend fun doRun(
allAuthors: Set<HexKey>,
newForKind3: Set<HexKey>,
newForKind0: Set<HexKey>,
includeKind0: Boolean,
includeKind3: Boolean,
): Result {
val relayCounts = FetchCounters()
val relaysConfigured = indexRelays()
val newTargets = (newForKind3 + newForKind0)
// Split into "have cached 10002" vs "need Phase 1".
val cachedOutbox = mutableMapOf<HexKey, Set<NormalizedRelayUrl>>()
val toDiscover = mutableSetOf<HexKey>()
for (author in newTargets) {
val write =
gateway
.cachedOutbox(author)
?.writeRelaysNorm()
.orEmpty()
.toSet()
if (write.isNotEmpty()) cachedOutbox[author] = write else toDiscover.add(author)
}
Log.d("OutboxDispatcher") {
"start authors=${allAuthors.size} newKind3=${newForKind3.size} newKind0=${newForKind0.size} " +
"cachedOutbox=${cachedOutbox.size} toDiscover=${toDiscover.size} " +
"indexRelays=${relaysConfigured.size}"
}
// Phase 1 — discover kind-10002 on the index relays. runPhase1
// returns pubkey → list of (event, relay) so we can pick the
// newest event (some relays return outdated 10002s).
val discovered = mutableMapOf<HexKey, Set<NormalizedRelayUrl>>()
if (toDiscover.isNotEmpty() && relaysConfigured.isNotEmpty()) {
val (phase1Events, phase1EosedCount) = runPhase1(toDiscover, relaysConfigured)
phase1Events.forEach { (pubkey, results) ->
val newest = results.maxByOrNull { it.first.createdAt } ?: return@forEach
gateway.onOutboxDiscovered(newest.first, newest.second)
val write =
newest.first
.writeRelaysNorm()
.orEmpty()
.toSet()
if (write.isNotEmpty()) discovered[pubkey] = write
}
relayCounts.kind10002 += phase1Events.values.sumOf { it.size }
Log.d("OutboxDispatcher") {
"phase1 done eosed=$phase1EosedCount/${relaysConfigured.size} " +
"10002-events=${relayCounts.kind10002} discovered=${discovered.size}"
}
}
val outboxMap = cachedOutbox + discovered
val authorsWithOutbox = outboxMap.keys
val fallbackAuthors = newTargets - authorsWithOutbox
// Phase 2 — per-outbox-relay REQ, kind-3 and/or kind-0. All
// recommended relays are subscribed in a single call so the pool
// fans out in parallel; a per-relay 4 s timeout bounds the wait
// regardless of how many relays the recommendation set contains.
val kind3BeforePhase2 = relayCounts.kind3
val kind0BeforePhase2 = relayCounts.kind0
if (outboxMap.isNotEmpty() && (includeKind0 || includeKind3)) {
val recommendations = RelayListRecommendationProcessor.reliableRelaySetFor(outboxMap)
val phase2FilterMap =
recommendations
.mapNotNull { rec ->
val authorsForThisRelay =
rec.users.intersect(
if (includeKind0 && includeKind3) {
newTargets
} else if (includeKind3) {
newForKind3
} else {
newForKind0
},
)
if (authorsForThisRelay.isEmpty()) return@mapNotNull null
val kinds =
buildList {
if (includeKind0 && authorsForThisRelay.any { it in newForKind0 }) add(MetadataEvent.KIND)
if (includeKind3 && authorsForThisRelay.any { it in newForKind3 }) add(ContactListEvent.KIND)
}
if (kinds.isEmpty()) return@mapNotNull null
rec.relay to
authorsForThisRelay.chunked(100).map { chunk ->
Filter(
kinds = kinds,
authors = chunk,
limit = chunk.size * kinds.size,
)
}
}.toMap()
Log.d("OutboxDispatcher") { "phase2 recommendations=${recommendations.size} relays-with-work=${phase2FilterMap.size}" }
if (phase2FilterMap.isNotEmpty()) {
runPhase2Or3(phase2FilterMap, counters = relayCounts)
}
}
Log.d("OutboxDispatcher") {
"phase2 done kind3=${relayCounts.kind3 - kind3BeforePhase2} kind0=${relayCounts.kind0 - kind0BeforePhase2}"
}
// Phase 3 — index-relay fallback for authors with no 10002.
val kind3BeforePhase3 = relayCounts.kind3
val kind0BeforePhase3 = relayCounts.kind0
if (fallbackAuthors.isNotEmpty() && relaysConfigured.isNotEmpty()) {
val kinds =
buildList {
if (includeKind0 && fallbackAuthors.any { it in newForKind0 }) add(MetadataEvent.KIND)
if (includeKind3 && fallbackAuthors.any { it in newForKind3 }) add(ContactListEvent.KIND)
}
if (kinds.isNotEmpty()) {
Log.d("OutboxDispatcher") { "phase3 fallback authors=${fallbackAuthors.size} kinds=$kinds relays=${relaysConfigured.size}" }
val filters =
fallbackAuthors.chunked(100).map { chunk ->
Filter(
kinds = kinds,
authors = chunk,
limit = chunk.size * kinds.size,
)
}
val phase3FilterMap = relaysConfigured.associateWith { filters }
runPhase2Or3(phase3FilterMap, counters = relayCounts)
Log.d("OutboxDispatcher") {
"phase3 done kind3=${relayCounts.kind3 - kind3BeforePhase3} kind0=${relayCounts.kind0 - kind0BeforePhase3}"
}
}
}
// Promote to succeeded — a completed run means we've asked; even if
// an author had no publishable data we don't need to keep pounding
// relays every follow-set change.
kind3Succeeded.addAll(newForKind3)
kind0Succeeded.addAll(newForKind0)
return Result(
authorsRequested = allAuthors.size,
kind10002Received = relayCounts.kind10002,
kind3Received = relayCounts.kind3,
kind0Received = relayCounts.kind0,
outboxCoveredAuthors = authorsWithOutbox.size,
fallbackAuthors = fallbackAuthors.size,
)
}
// ------------------------------------------------------------------
private class FetchCounters {
var kind10002 = 0
var kind3 = 0
var kind0 = 0
}
/**
* Phase 1 helper. Returns a map of pubkey → list of (event, relay) so
* caller can pick the newest, plus a boolean-per-relay EOSE indicator
* (currently ignored but recorded for future retry telemetry).
*/
private suspend fun runPhase1(
pubkeys: Set<HexKey>,
relays: Set<NormalizedRelayUrl>,
): Pair<Map<HexKey, List<Pair<AdvertisedRelayListEvent, NormalizedRelayUrl>>>, Int> {
val filters =
pubkeys.chunked(100).map { chunk ->
Filter(
kinds = listOf(AdvertisedRelayListEvent.KIND),
authors = chunk,
limit = chunk.size,
)
}
val filterMap = relays.associateWith { filters }
val received = mutableMapOf<HexKey, MutableList<Pair<AdvertisedRelayListEvent, NormalizedRelayUrl>>>()
val gate = BatchEoseGate(scope, target = relays.size)
val listener =
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (event is AdvertisedRelayListEvent && event.pubKey in pubkeys) {
received
.getOrPut(event.pubKey) { mutableListOf() }
.add(event to relay)
}
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
gate.notifyEose(relay)
}
}
val subId = newSubId()
client.subscribe(subId, filterMap, listener)
val eosedCount = gate.awaitAll(perRelayTimeoutMs)
client.unsubscribe(subId)
return received to eosedCount
}
/**
* Phase 2 or Phase 3 helper. Opens a single subscription that
* fans out to every relay in [filterMap] (Phase 2 uses per-outbox-
* relay filters; Phase 3 uses the index-relay set with a shared
* fallback filter). All relays are subscribed in parallel — the
* per-relay timeout bounds the total wait regardless of relay count.
*/
private suspend fun runPhase2Or3(
filterMap: Map<NormalizedRelayUrl, List<Filter>>,
counters: FetchCounters,
) {
val gate = BatchEoseGate(scope, target = filterMap.size)
val listener =
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
when (event.kind) {
MetadataEvent.KIND -> counters.kind0++
ContactListEvent.KIND -> counters.kind3++
}
gateway.onDiscoveredEvent(event, relay)
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
gate.notifyEose(relay)
}
}
val subId = newSubId()
client.subscribe(subId, filterMap, listener)
gate.awaitAll(perRelayTimeoutMs)
client.unsubscribe(subId)
}
private fun zeroResult(requested: Int) =
Result(
authorsRequested = requested,
kind10002Received = 0,
kind3Received = 0,
kind0Received = 0,
outboxCoveredAuthors = 0,
fallbackAuthors = 0,
)
/**
* KMP-safe EOSE aggregator (same as FeedMetadataCoordinator's local
* one — duplicated locally instead of exported to keep the fix scope
* minimal). Per-relay `onEose` callbacks may run on any dispatcher
* (typically `Dispatchers.IO`) so we funnel them through a Channel
* and let a single consumer coroutine own the `seen` set.
*/
private class BatchEoseGate(
private val scope: CoroutineScope,
private val target: Int,
) {
private val incoming = Channel<NormalizedRelayUrl>(Channel.UNLIMITED)
private val done = CompletableDeferred<Unit>()
@Volatile private var lastCount = 0
fun notifyEose(relay: NormalizedRelayUrl) {
incoming.trySend(relay)
}
suspend fun awaitAll(timeoutMs: Long): Int {
if (target <= 0) return 0
val consumer =
scope.launch {
val seen = mutableSetOf<NormalizedRelayUrl>()
for (relay in incoming) {
if (seen.add(relay)) {
lastCount = seen.size
if (seen.size >= target && !done.isCompleted) {
done.complete(Unit)
}
}
}
}
withTimeoutOrNull(timeoutMs) { done.await() }
incoming.close()
consumer.join()
return lastCount
}
}
}
@@ -0,0 +1,306 @@
/*
* 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.commons.wot
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.runtime.snapshots.SnapshotStateMap
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
/**
* Friends-of-friends trust score computed from the active user's follow
* graph. For every pubkey X the score is the count of accounts in the
* active user's follow set who also follow X.
*
* ## Reactivity model
*
* Scores are exposed via a Compose-observable [SnapshotStateMap]. Consumers
* that read a **single key** (`scores[pubkey]`) recompose only when that
* key changes — this is `SnapshotStateMap`'s built-in per-key observation
* and applies whether or not the writer wraps in a snapshot block.
* Consumers that iterate the map or read `size` recompose on **any**
* mutation.
*
* The writer wraps each op in [Snapshot.withMutableSnapshot] to *coalesce*
* an op's writes into a single Compose commit — so a Kind3 op that
* touches N reverse-index targets emits one invalidation, not N. It does
* not confer additional per-key isolation on top of `SnapshotStateMap`'s
* own semantics.
*
* ## Concurrency
*
* All internal state is mutated from a single writer coroutine
* ([writerLoop]) on [writerDispatcher] (default [Dispatchers.Default]), so
* concurrent [applyKind3] / [onFollowSetChange] / [markReadyOnce] calls
* from different threads are serialized without extra locking.
*
* ## Lifecycle
*
* Call [close] on account switch / logout so the writer coroutine exits
* and the ops channel is released. Post-close ops are silently dropped.
*/
@Stable
class WoTService(
private val scope: CoroutineScope,
/** Dispatcher for the internal writer coroutine. Tests override with `Dispatchers.Unconfined` for synchronous behavior. */
private val writerDispatcher: CoroutineDispatcher = Dispatchers.Default,
) : AutoCloseable {
/**
* Sparse per-pubkey score map. Entries with count 0 are removed
* (not stored as 0) to keep the Compose subscriber tracking tight.
* Callers should read as `scores[pubkey] ?: 0`.
*/
private val _scores: SnapshotStateMap<HexKey, Int> = mutableStateMapOf()
val scores: SnapshotStateMap<HexKey, Int> get() = _scores
// Reverse index: target pubkey → set of my-follows who follow them.
private val reverseIndex = HashMap<HexKey, MutableSet<HexKey>>()
// Per-follower cached follow set (excluding self / follower itself).
// Enables diff-based updates when a follower republishes their kind-3.
private val perFollowerSnapshot = HashMap<HexKey, Set<HexKey>>()
private var myFollows: Set<HexKey> = emptySet()
private var selfPubkey: HexKey? = null
private var readyMarked = false
private var disabled = false
private val _isReady = MutableStateFlow(false)
val isReady: StateFlow<Boolean> = _isReady.asStateFlow()
private val _isDisabled = MutableStateFlow(false)
/**
* True when the active user's follow set exceeds [MAX_FOLLOWS] and WoT
* scoring has been shut off. Callers that dispatch the batch kind-3
* REQ must gate on this — a disabled service silently accepts and
* ignores all subsequent [applyKind3] calls, so a caller that keeps
* flooding kind-3s wastes bandwidth for nothing.
*/
val isDisabled: StateFlow<Boolean> = _isDisabled.asStateFlow()
private val ops = Channel<Op>(capacity = Channel.UNLIMITED)
init {
scope.launch(writerDispatcher) { writerLoop() }
}
/** Update the active user's follow set (and self pubkey). */
fun onFollowSetChange(
newFollows: Set<HexKey>,
newSelf: HexKey?,
) {
ops.trySend(Op.FollowSet(newFollows, newSelf))
}
/**
* Ingest a kind-3 event for a followed pubkey. Ignored when the
* event's author isn't in the current follow set. Follow lists are
* capped at [MAX_FOLLOWS_PER_EVENT] to bound CPU cost against a
* hostile publisher.
*/
fun applyKind3(
follower: HexKey,
follows: Set<HexKey>,
) {
val bounded =
if (follows.size > MAX_FOLLOWS_PER_EVENT) {
follows.take(MAX_FOLLOWS_PER_EVENT).toSet()
} else {
follows
}
ops.trySend(Op.Kind3(follower, bounded))
}
/**
* Mark the service as ready to render badges. Idempotent — subsequent
* calls are no-ops. Trigger from the first EOSE on the batch kind-3
* REQ, or from a startup-timeout fallback, whichever fires first.
*/
fun markReadyOnce() {
ops.trySend(Op.MarkReady)
}
/** Clear all state. Used on logout / account switch. */
fun clear() {
ops.trySend(Op.Clear)
}
/**
* Returns a plain [Map] snapshot of current scores for headless
* callers (e.g. the amy CLI) that don't run inside a Compose
* composition. O(N) copy from the underlying [SnapshotStateMap].
*/
fun scoresSnapshot(): Map<HexKey, Int> = HashMap(_scores)
private sealed interface Op {
data class FollowSet(
val newFollows: Set<HexKey>,
val newSelf: HexKey?,
) : Op
data class Kind3(
val follower: HexKey,
val follows: Set<HexKey>,
) : Op
data object MarkReady : Op
data object Clear : Op
}
private suspend fun writerLoop() {
for (op in ops) {
Snapshot.withMutableSnapshot {
when (op) {
is Op.FollowSet -> handleFollowSet(op.newFollows, op.newSelf)
is Op.Kind3 -> handleKind3(op.follower, op.follows)
Op.MarkReady -> handleMarkReady()
Op.Clear -> handleClear()
}
}
}
}
private fun handleFollowSet(
newFollows: Set<HexKey>,
newSelf: HexKey?,
) {
// Guardrail — massive follow lists don't produce a useful WoT signal.
// Do this BEFORE assigning myFollows so applyKind3's `follower in
// myFollows` gate doesn't accidentally credit anyone once the
// caller keeps pumping kind-3s in (a caller that fails to gate on
// isDisabled would otherwise fully repopulate reverseIndex/_scores
// and defeat the guardrail — see PR #3483 review finding 2).
if (newFollows.size > MAX_FOLLOWS) {
reverseIndex.clear()
perFollowerSnapshot.clear()
_scores.clear()
myFollows = emptySet()
selfPubkey = newSelf
disabled = true
_isDisabled.value = true
handleMarkReady()
return
}
val removed = myFollows - newFollows
myFollows = newFollows
selfPubkey = newSelf
// Follow set is back within limits (or was already) — re-enable if
// we had previously flipped disabled=true.
if (disabled) {
disabled = false
_isDisabled.value = false
}
// Uncredit any follower we're no longer following.
removed.forEach { follower ->
val prevFollows = perFollowerSnapshot.remove(follower) ?: return@forEach
prevFollows.forEach { target ->
val set = reverseIndex[target] ?: return@forEach
set.remove(follower)
if (set.isEmpty()) reverseIndex.remove(target)
updateScore(target)
}
}
// Added followers will be credited when their kind-3 arrives via applyKind3.
}
private fun handleKind3(
follower: HexKey,
follows: Set<HexKey>,
) {
if (disabled) return
if (follower !in myFollows) return
val old = perFollowerSnapshot[follower] ?: emptySet()
val excluded = setOfNotNull(follower, selfPubkey)
val effective = follows - excluded
val added = effective - old
val removed = old - effective
perFollowerSnapshot[follower] = effective
added.forEach { target ->
reverseIndex.getOrPut(target) { hashSetOf() }.add(follower)
updateScore(target)
}
removed.forEach { target ->
val set = reverseIndex[target] ?: return@forEach
set.remove(follower)
if (set.isEmpty()) reverseIndex.remove(target)
updateScore(target)
}
}
private fun handleMarkReady() {
if (!readyMarked) {
readyMarked = true
_isReady.value = true
}
}
private fun handleClear() {
reverseIndex.clear()
perFollowerSnapshot.clear()
_scores.clear()
myFollows = emptySet()
selfPubkey = null
readyMarked = false
_isReady.value = false
disabled = false
_isDisabled.value = false
}
/**
* Cancel the writer coroutine and release the ops channel. Call from
* account-switch / logout paths. Post-close [applyKind3] / [onFollowSetChange]
* / [markReadyOnce] / [clear] calls are silently dropped (the `trySend`
* on a closed [Channel] fails without throwing).
*
* Idempotent; safe to call multiple times.
*/
override fun close() {
ops.close()
}
private fun updateScore(target: HexKey) {
val n = reverseIndex[target]?.size ?: 0
if (n > 0) _scores[target] = n else _scores.remove(target)
}
companion object {
/** Skip WoT entirely for accounts following more than this many pubkeys. */
const val MAX_FOLLOWS = 2000
/** Cap follows per kind-3 event to bound CPU cost against a hostile publisher. */
const val MAX_FOLLOWS_PER_EVENT = 5000
}
}
@@ -29,25 +29,27 @@ import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
@OptIn(ExperimentalCoroutinesApi::class)
class MessagesLockStateTest {
class PrivacyLockStateTest {
private class FakeSettings(
lockEnabled: Boolean = false,
timer: InactivityTimer = InactivityTimer.OneMin,
password: String? = null,
) : PrivacyLockSettings {
private val mutableLockEnabled = MutableStateFlow(lockEnabled)
private val mutableTimer = MutableStateFlow(timer)
private val mutableRedaction = MutableStateFlow(DmRedactionLevel.DEFAULT)
private val mutableFirstRunSeen = MutableStateFlow(false)
private val mutablePasswordHashed = MutableStateFlow<String?>(null)
private val mutablePasswordHashed = MutableStateFlow<String?>(password)
private val mutableFailedAttempts = MutableStateFlow(0)
private val mutableLockedUntil = MutableStateFlow<Long?>(null)
override val lockEnabled: StateFlow<Boolean> = mutableLockEnabled.asStateFlow()
override val inactivityTimer: StateFlow<InactivityTimer> = mutableTimer.asStateFlow()
override val redactionLevel: StateFlow<DmRedactionLevel> = mutableRedaction.asStateFlow()
override val dmRedactionLevel: StateFlow<DmRedactionLevel> = mutableRedaction.asStateFlow()
override val firstRunCardSeen: StateFlow<Boolean> = mutableFirstRunSeen.asStateFlow()
override val passwordHashed: StateFlow<String?> = mutablePasswordHashed.asStateFlow()
override val failedUnlockAttempts: StateFlow<Int> = mutableFailedAttempts.asStateFlow()
@@ -61,7 +63,7 @@ class MessagesLockStateTest {
mutableTimer.value = timer
}
override fun setRedactionLevel(level: DmRedactionLevel) {
override fun setDmRedactionLevel(level: DmRedactionLevel) {
mutableRedaction.value = level
}
@@ -71,6 +73,8 @@ class MessagesLockStateTest {
override fun setPasswordHashed(saltAndHash: String?) {
mutablePasswordHashed.value = saltAndHash
// Mirror the production cascade — no credential means no gate.
if (saltAndHash == null && mutableLockEnabled.value) mutableLockEnabled.value = false
}
override fun setFailedUnlockAttempts(count: Int) {
@@ -86,7 +90,7 @@ class MessagesLockStateTest {
fun cold_start_with_lock_enabled_seeds_to_locked() =
runTest {
val settings = FakeSettings(lockEnabled = true)
val state = MessagesLockState(settings, backgroundScope)
val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
assertEquals(LockState.Locked, state.state.value)
}
@@ -94,7 +98,7 @@ class MessagesLockStateTest {
fun cold_start_with_lock_disabled_seeds_to_disabled() =
runTest {
val settings = FakeSettings(lockEnabled = false)
val state = MessagesLockState(settings, backgroundScope)
val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
assertEquals(LockState.Disabled, state.state.value)
}
@@ -102,7 +106,7 @@ class MessagesLockStateTest {
fun unlock_success_transitions_to_unlocked_and_idle_timer_fires() =
runTest {
val settings = FakeSettings(lockEnabled = true, timer = InactivityTimer.OneMin)
val state = MessagesLockState(settings, backgroundScope)
val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
state.onUnlockSuccess()
assertEquals(LockState.Unlocked, state.state.value)
advanceTimeBy(InactivityTimer.OneMin.millis!! + 1_000L)
@@ -113,7 +117,7 @@ class MessagesLockStateTest {
fun leave_route_locks_immediately() =
runTest {
val settings = FakeSettings(lockEnabled = true, timer = InactivityTimer.OneHour)
val state = MessagesLockState(settings, backgroundScope)
val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
state.onUnlockSuccess()
assertEquals(LockState.Unlocked, state.state.value)
state.onLeaveRoute()
@@ -124,7 +128,7 @@ class MessagesLockStateTest {
fun toggling_lock_off_transitions_to_disabled() =
runTest(UnconfinedTestDispatcher()) {
val settings = FakeSettings(lockEnabled = true)
val state = MessagesLockState(settings, backgroundScope)
val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
state.onUnlockSuccess()
assertEquals(LockState.Unlocked, state.state.value)
settings.setLockEnabled(false)
@@ -135,7 +139,7 @@ class MessagesLockStateTest {
fun never_timer_does_not_fire() =
runTest {
val settings = FakeSettings(lockEnabled = true, timer = InactivityTimer.Never)
val state = MessagesLockState(settings, backgroundScope)
val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
state.onUnlockSuccess()
advanceTimeBy(InactivityTimer.OneHour.millis!! * 2)
assertEquals(LockState.Unlocked, state.state.value)
@@ -145,7 +149,7 @@ class MessagesLockStateTest {
fun user_interaction_resets_idle_timer() =
runTest {
val settings = FakeSettings(lockEnabled = true, timer = InactivityTimer.OneMin)
val state = MessagesLockState(settings, backgroundScope)
val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
state.onUnlockSuccess()
advanceTimeBy(InactivityTimer.OneMin.millis!! - 1_000L)
state.onUserInteraction()
@@ -159,7 +163,7 @@ class MessagesLockStateTest {
fun credential_unavailable_disables_lock() =
runTest {
val settings = FakeSettings(lockEnabled = true)
val state = MessagesLockState(settings, backgroundScope)
val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
state.onCredentialUnavailable()
assertEquals(LockState.Disabled, state.state.value)
assertEquals(false, settings.lockEnabled.value)
@@ -169,11 +173,11 @@ class MessagesLockStateTest {
fun unlock_success_from_disabled_transitions_to_unlocked() =
runTest {
// First-run banner path: user enables lock + sets password while
// already viewing Messages. State is Disabled at that moment, and
// we want to stay Unlocked so the user isn't kicked to the lock
// already viewing a gated route. State is Disabled at that moment,
// and we want to stay Unlocked so the user isn't kicked to the lock
// screen right after enabling.
val settings = FakeSettings(lockEnabled = false)
val state = MessagesLockState(settings, backgroundScope)
val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
assertEquals(LockState.Disabled, state.state.value)
state.onUnlockSuccess()
assertEquals(LockState.Unlocked, state.state.value)
@@ -183,7 +187,7 @@ class MessagesLockStateTest {
fun failed_attempts_below_threshold_do_not_trip_lockout() =
runTest {
val settings = FakeSettings(lockEnabled = true)
val state = MessagesLockState(settings, backgroundScope)
val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
val now = 1_000_000L
repeat(PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES - 1) {
assertEquals(null, state.onFailedUnlockAttempt(now))
@@ -199,7 +203,7 @@ class MessagesLockStateTest {
fun fifth_failure_trips_base_lockout() =
runTest {
val settings = FakeSettings(lockEnabled = true)
val state = MessagesLockState(settings, backgroundScope)
val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
val now = 1_000_000L
repeat(PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES) {
state.onFailedUnlockAttempt(now)
@@ -212,7 +216,7 @@ class MessagesLockStateTest {
fun lockout_doubles_and_caps_at_maximum() =
runTest {
val settings = FakeSettings(lockEnabled = true)
val state = MessagesLockState(settings, backgroundScope)
val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
val now = 1_000_000L
// 5th failure → base (30s)
repeat(PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES) { state.onFailedUnlockAttempt(now) }
@@ -230,7 +234,7 @@ class MessagesLockStateTest {
fun unlock_success_clears_backoff_state() =
runTest {
val settings = FakeSettings(lockEnabled = true)
val state = MessagesLockState(settings, backgroundScope)
val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
val now = 1_000_000L
repeat(PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES) { state.onFailedUnlockAttempt(now) }
assertTrue(settings.lockedUntilEpochMs.value != null)
@@ -239,4 +243,64 @@ class MessagesLockStateTest {
assertEquals(null, settings.lockedUntilEpochMs.value)
assertEquals(0, settings.failedUnlockAttempts.value)
}
// ---- Wallet-lock reuse additions ----
@Test
fun two_scopes_have_independent_lock_state() =
runTest(UnconfinedTestDispatcher()) {
val settings = FakeSettings(lockEnabled = true)
val messages = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
val wallet = PrivacyLockState(LockScope.Wallet, settings, backgroundScope)
assertEquals(LockState.Locked, messages.state.value)
assertEquals(LockState.Locked, wallet.state.value)
messages.onUnlockSuccess()
assertEquals(LockState.Unlocked, messages.state.value)
assertEquals(LockState.Locked, wallet.state.value)
messages.onLeaveRoute()
assertEquals(LockState.Locked, messages.state.value)
assertEquals(LockState.Locked, wallet.state.value)
}
@Test
fun failed_unlock_counter_is_shared_across_scopes() =
runTest {
val settings = FakeSettings(lockEnabled = true)
val messages = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
val wallet = PrivacyLockState(LockScope.Wallet, settings, backgroundScope)
val now = 1_000_000L
// Three failures on Messages, two on Wallet → shared counter hits 5
repeat(3) { messages.onFailedUnlockAttempt(now) }
repeat(2) { wallet.onFailedUnlockAttempt(now) }
assertEquals(
PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES,
settings.failedUnlockAttempts.value,
)
// The 5th failure trips the base lockout regardless of which scope
// it came from — either scope now sees the countdown.
assertEquals(
now + PrivacyLockSettings.LOCKOUT_BASE_MS,
settings.lockedUntilEpochMs.value,
)
}
@Test
fun clearing_password_cascades_to_disable_the_master_lock() =
runTest(UnconfinedTestDispatcher()) {
val settings = FakeSettings(lockEnabled = true, password = "salt\$hash")
val messages = PrivacyLockState(LockScope.Messages, settings, backgroundScope)
val wallet = PrivacyLockState(LockScope.Wallet, settings, backgroundScope)
assertEquals(LockState.Locked, messages.state.value)
assertEquals(LockState.Locked, wallet.state.value)
// User clears the password from Settings → cascade fires
settings.setPasswordHashed(null)
assertEquals(false, settings.lockEnabled.value)
assertEquals(LockState.Disabled, messages.state.value)
assertEquals(LockState.Disabled, wallet.state.value)
assertNull(settings.passwordHashed.value)
}
}
@@ -0,0 +1,169 @@
/*
* 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.commons.relayClient.auth
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.nip42RelayAuth.tags.RelayTag
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.yield
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
/**
* End-to-end exercise of the AUTH stack: policy classification +
* RelayAuthEvent.build template + real NostrSignerInternal signing.
*
* This is the lambda-level shape that [DesktopAuthCoordinator]'s
* `signWithAllLoggedInUsers` calls into. It isolates the policy/signer
* round-trip from the live websocket layer (which has its own coverage
* in `geode/.../KtorRelayTest.kt` against a Ktor mock relay).
*
* Together with the existing AuthApprovalPolicyTest (classifier),
* PoolEventOutboxStateTest (auth-required carve-out), and
* GiftWrapRelayHintTest (NIP-17 hint placement), this covers the AUTH
* pipeline at unit granularity — the geode Ktor tests handle the
* websocket-level round-trip.
*/
class AuthApprovalEndToEndTest {
private val signer = NostrSignerInternal(KeyPair())
private val ownInbox = NormalizedRelayUrl("wss://own.inbox/")
private val unknown = NormalizedRelayUrl("wss://unknown.relay/")
private val challenge = "test-challenge-abc123"
private fun newPolicy(
ownSet: Set<NormalizedRelayUrl> = setOf(ownInbox),
onPrompt: (PendingAuthApproval) -> Unit = {},
): Pair<AuthApprovalPolicy, AuthApprovalStore> {
val store = InMemoryAuthApprovalStore()
return AuthApprovalPolicy(
selfApprovedRelays = { ownSet },
store = store,
onPromptRequired = onPrompt,
) to store
}
/**
* Coordinator's lambda shape, distilled. Returns the signed AUTH event
* (or null on Block / not-signed-by-policy).
*/
private suspend fun signWithPolicy(
relay: NormalizedRelayUrl,
policy: AuthApprovalPolicy,
): RelayAuthEvent? {
val template = RelayAuthEvent.build(relay, challenge)
val relayFromTemplate = template.tags.firstNotNullOfOrNull(RelayTag::parse)
assertEquals(relay, relayFromTemplate, "RelayAuthEvent.build must round-trip via RelayTag.parse")
return when (val decision = policy.classify(relay)) {
AuthApprovalDecision.Allow -> signer.sign(template)
AuthApprovalDecision.Block -> null
is AuthApprovalDecision.Pending -> {
val resolved = decision.pending.await()
if (resolved != AuthApprovalScope.ONCE) policy.recordDecision(relay, resolved)
if (resolved == AuthApprovalScope.BLOCKED) null else signer.sign(template)
}
}
}
@Test
fun tier1OwnInboxAutoSignsValidAuthEvent() =
runTest {
val (policy, _) = newPolicy()
val signed = signWithPolicy(ownInbox, policy)
assertNotNull(signed)
assertEquals(RelayAuthEvent.KIND, signed.kind)
assertEquals(signer.pubKey, signed.pubKey)
assertEquals(challenge, signed.challenge())
assertEquals(ownInbox, signed.relay())
}
@Test
fun tier2UnknownPromptsAndOnceResolutionSigns() =
runTest {
var prompted: PendingAuthApproval? = null
val (policy, _) = newPolicy(onPrompt = { prompted = it })
coroutineScope {
// Concurrent: lambda suspends inside policy.classify; we
// resolve the deferred from outside as the banner UI would.
val deferred = async { signWithPolicy(unknown, policy) }
yieldUntilNotNull { prompted }
prompted!!.decision.complete(AuthApprovalScope.ONCE)
val signed = deferred.await()
assertNotNull(signed)
assertEquals(unknown, signed.relay())
}
}
@Test
fun tier2BlockedResolutionReturnsNullAndPersists() =
runTest {
var prompted: PendingAuthApproval? = null
val (policy, store) = newPolicy(onPrompt = { prompted = it })
coroutineScope {
val deferred = async { signWithPolicy(unknown, policy) }
yieldUntilNotNull { prompted }
prompted!!.decision.complete(AuthApprovalScope.BLOCKED)
val signed = deferred.await()
assertNull(signed)
assertEquals(AuthApprovalScope.BLOCKED, store.getScope(unknown))
}
}
@Test
fun tier2AlwaysPersistsAndSkipsPromptNextTime() =
runTest {
var promptCount = 0
val (policy, store) =
newPolicy(onPrompt = {
it.decision.complete(AuthApprovalScope.ALWAYS)
promptCount++
})
// First call: prompts and resolves to ALWAYS.
val first = signWithPolicy(unknown, policy)
assertNotNull(first)
assertEquals(1, promptCount)
assertEquals(AuthApprovalScope.ALWAYS, store.getScope(unknown))
// Second call: should NOT prompt again.
val second = signWithPolicy(unknown, policy)
assertNotNull(second)
assertEquals(1, promptCount, "ALWAYS persisted — no second prompt")
}
}
private suspend inline fun <T> yieldUntilNotNull(crossinline supplier: () -> T?): T {
repeat(100) {
supplier()?.let { return it }
yield()
}
error("supplier never produced a value within 100 yields")
}
@@ -0,0 +1,163 @@
/*
* 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.commons.relayClient.auth
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertSame
import kotlin.test.assertTrue
class AuthApprovalPolicyTest {
private val ownOutbox = NormalizedRelayUrl("wss://own.outbox/")
private val unknown = NormalizedRelayUrl("wss://unknown.relay/")
private val blockedRelay = NormalizedRelayUrl("wss://blocked.relay/")
private fun newPolicy(
ownSet: Set<NormalizedRelayUrl> = setOf(ownOutbox),
onPrompt: (PendingAuthApproval) -> Unit = {},
): Pair<AuthApprovalPolicy, AuthApprovalStore> {
val store = InMemoryAuthApprovalStore()
val policy =
AuthApprovalPolicy(
selfApprovedRelays = { ownSet },
store = store,
onPromptRequired = onPrompt,
)
return policy to store
}
@Test
fun tier1OwnOutboxRelayIsAutoAllowed() =
runTest {
val (policy, _) = newPolicy()
val decision = policy.classify(ownOutbox)
assertSame(AuthApprovalDecision.Allow, decision)
}
@Test
fun unknownRelayPromptsAndReturnsPending() =
runTest {
val prompts = mutableListOf<PendingAuthApproval>()
val (policy, _) = newPolicy(onPrompt = { prompts += it })
val decision = policy.classify(unknown)
assertIs<AuthApprovalDecision.Pending>(decision)
assertEquals(1, prompts.size)
assertEquals(unknown, prompts.first().relayUrl)
assertSame(decision.pending, prompts.first().decision)
}
@Test
fun persistedAlwaysIsAutoAllowed() =
runTest {
val (policy, store) = newPolicy()
store.setScope(unknown, AuthApprovalScope.ALWAYS)
val decision = policy.classify(unknown)
assertSame(AuthApprovalDecision.Allow, decision)
}
@Test
fun persistedBlockedIsAutoBlockedEvenForOwnOutbox() =
runTest {
// Explicit user `[Never]` overrides tier-1 — if the user blocked a relay
// that happens to be in their outbox, respect that.
val (policy, store) = newPolicy()
store.setScope(ownOutbox, AuthApprovalScope.BLOCKED)
val decision = policy.classify(ownOutbox)
assertSame(AuthApprovalDecision.Block, decision)
}
@Test
fun recordDecisionPersistsAndChangesSubsequentClassification() =
runTest {
var promptCount = 0
val (policy, _) = newPolicy(onPrompt = { promptCount++ })
// First call prompts.
val first = policy.classify(unknown)
assertIs<AuthApprovalDecision.Pending>(first)
assertEquals(1, promptCount)
// User picks `[Always]`.
policy.recordDecision(unknown, AuthApprovalScope.ALWAYS)
// Subsequent calls return Allow without prompting.
val second = policy.classify(unknown)
assertSame(AuthApprovalDecision.Allow, second)
assertEquals(1, promptCount, "should not prompt again after Always grant")
}
@Test
fun blockedDecisionPersistsAndStaysBlocked() =
runTest {
var promptCount = 0
val (policy, _) = newPolicy(onPrompt = { promptCount++ })
// First call prompts.
policy.classify(blockedRelay)
assertEquals(1, promptCount)
// User picks `[Never]`.
policy.recordDecision(blockedRelay, AuthApprovalScope.BLOCKED)
// Subsequent classify returns Block without prompting.
val decision = policy.classify(blockedRelay)
assertSame(AuthApprovalDecision.Block, decision)
assertEquals(1, promptCount, "should not prompt again after Never")
}
@Test
fun selfApprovedRelaysIsReevaluatedPerCall() =
runTest {
// Account state changes (user adds a relay to their outbox) must take
// effect immediately — the policy reads the supplier per classify.
var ownSet = setOf<NormalizedRelayUrl>()
val policy =
AuthApprovalPolicy(
selfApprovedRelays = { ownSet },
store = InMemoryAuthApprovalStore(),
onPromptRequired = {},
)
assertIs<AuthApprovalDecision.Pending>(policy.classify(ownOutbox))
ownSet = setOf(ownOutbox)
assertSame(AuthApprovalDecision.Allow, policy.classify(ownOutbox))
}
@Test
fun storeClearWipesAllApprovals() =
runTest {
val store = InMemoryAuthApprovalStore()
store.setScope(unknown, AuthApprovalScope.ALWAYS)
store.setScope(blockedRelay, AuthApprovalScope.BLOCKED)
store.clear()
// Both relays now unknown → fresh classification prompts.
assertTrue(store.getScope(unknown) == null)
assertTrue(store.getScope(blockedRelay) == null)
}
}
@@ -0,0 +1,155 @@
/*
* 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.commons.relayClient.nip17Dm
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class DmInboxRelayResolverTest {
private val peer: HexKey = "0".repeat(64)
private val cachedRelay = NormalizedRelayUrl("wss://cached.relay/")
private val indexer = NormalizedRelayUrl("wss://indexer.example/")
private fun newResolver(
localLookup: (HexKey) -> List<NormalizedRelayUrl>?,
indexers: Set<NormalizedRelayUrl> = setOf(indexer),
now: () -> Long = { 0L },
ttlMs: Long = 60_000L,
) = DmInboxRelayResolver(
unauthenticatedClient = EmptyNostrClient(),
indexerRelays = indexers,
localLookup = localLookup,
cacheTtlMs = ttlMs,
cacheSize = 4,
nowMs = now,
)
@Test
fun localLookupHitShortCircuitsIndexerFanOut() =
runTest {
var indexerCalled = false
// The indexer would only run if RecipientRelayFetcher.fetchRelayLists ran. EmptyNostrClient returns no events,
// so even if it did, we'd get an empty list — but assert indirectly via the result.
val resolver = newResolver(localLookup = { listOf(cachedRelay) })
val result = resolver.resolve(peer)
assertEquals(listOf(cachedRelay), result)
assertTrue(!indexerCalled) // we never set this; LocalLookup returns first
}
@Test
fun emptyIndexerSetReturnsEmpty() =
runTest {
val resolver = newResolver(localLookup = { null }, indexers = emptySet())
val result = resolver.resolve(peer)
assertEquals(emptyList(), result)
}
@Test
fun emptyLocalAndEmptyIndexerYieldsEmpty() =
runTest {
// EmptyNostrClient.fetchAll returns no events → resolver yields empty.
val resolver = newResolver(localLookup = { null })
val result = resolver.resolve(peer)
assertEquals(emptyList(), result)
}
@Test
fun cacheHitWithinTtlSkipsIndexer() =
runTest {
// First call: localLookup returns null, indexer empty → caches [] for peer.
// Second call: same peer within TTL → returns cached [], no new indexer call.
var localLookupCalls = 0
val resolver =
newResolver(
localLookup = {
localLookupCalls++
null
},
)
resolver.resolve(peer)
resolver.resolve(peer)
// localLookup is invoked on every resolve (cheap), but the indexer
// fan-out + cache write only happens once. Hard to assert directly
// on RecipientRelayFetcher without a mock client; cache TTL behaviour
// is exercised below.
assertEquals(2, localLookupCalls)
}
@Test
fun cacheExpiryTriggersFreshIndexerCall() =
runTest {
var nowMs = 0L
val ttl = 1_000L
val resolver = newResolver(localLookup = { null }, now = { nowMs }, ttlMs = ttl)
resolver.resolve(peer) // caches [] with expiresAt = ttl
nowMs = ttl + 1 // past expiry
val second = resolver.resolve(peer)
assertEquals(emptyList(), second) // still empty from EmptyNostrClient — but went through the indexer path again
}
@Test
fun clearWipesAllEntries() =
runTest {
val resolver = newResolver(localLookup = { null })
resolver.resolve(peer)
resolver.clear()
// No way to introspect cache directly; assert through the resolve API
// continuing to work (would NPE if internal state were corrupt).
val result = resolver.resolve(peer)
assertEquals(emptyList(), result)
}
@Test
fun invalidateRemovesNamedEntry() =
runTest {
val resolver = newResolver(localLookup = { null })
resolver.resolve(peer)
resolver.invalidate(peer)
val result = resolver.resolve(peer)
assertEquals(emptyList(), result)
}
@Test
fun localLookupReturningEmptyListFallsThroughToCacheAndIndexer() =
runTest {
// Subtle: localLookup must return null OR a non-empty list. An EMPTY
// list from localLookup means "I know this user has no 10050" — but
// we want "I don't know" to fall through. The resolver guards with
// `takeIf { it.isNotEmpty() }`.
var localLookupCalls = 0
val resolver =
newResolver(
localLookup = {
localLookupCalls++
emptyList()
},
)
val result = resolver.resolve(peer)
assertEquals(emptyList(), result)
assertEquals(1, localLookupCalls)
}
}
@@ -63,7 +63,7 @@ class PreferencesPrivacyLockSettings(
override val lockEnabled: StateFlow<Boolean> = mutableEnabled.asStateFlow()
override val inactivityTimer: StateFlow<InactivityTimer> = mutableTimer.asStateFlow()
override val redactionLevel: StateFlow<DmRedactionLevel> = mutableRedaction.asStateFlow()
override val dmRedactionLevel: StateFlow<DmRedactionLevel> = mutableRedaction.asStateFlow()
override val firstRunCardSeen: StateFlow<Boolean> = mutableFirstRunSeen.asStateFlow()
override val passwordHashed: StateFlow<String?> = mutablePasswordHashed.asStateFlow()
override val failedUnlockAttempts: StateFlow<Int> = mutableFailedAttempts.asStateFlow()
@@ -77,7 +77,7 @@ class PreferencesPrivacyLockSettings(
// "locked UI / leaking notifications" anti-pattern).
if (enabled && mutableRedaction.value == DmRedactionLevel.Full) {
val userPickedFull = prefs.getBoolean("redaction_user_set", false)
if (!userPickedFull) setRedactionLevel(DmRedactionLevel.Generic)
if (!userPickedFull) setDmRedactionLevel(DmRedactionLevel.Generic)
}
}
@@ -86,7 +86,7 @@ class PreferencesPrivacyLockSettings(
prefs.putInt(KEY_INACTIVITY_TIMER, timer.ordinal)
}
override fun setRedactionLevel(level: DmRedactionLevel) {
override fun setDmRedactionLevel(level: DmRedactionLevel) {
mutableRedaction.value = level
prefs.putInt(KEY_REDACTION_LEVEL, level.ordinal)
prefs.putBoolean("redaction_user_set", true)
@@ -99,7 +99,15 @@ class PreferencesPrivacyLockSettings(
override fun setPasswordHashed(saltAndHash: String?) {
mutablePasswordHashed.value = saltAndHash
if (saltAndHash == null) prefs.remove(KEY_PASSWORD_HASHED) else prefs.put(KEY_PASSWORD_HASHED, saltAndHash)
if (saltAndHash == null) {
prefs.remove(KEY_PASSWORD_HASHED)
// A lock without a credential is not a valid state — cascade so the
// toggle can't stay on with nothing to verify against. Every gated
// scope transitions to Disabled via the shared `lockEnabled` flag.
if (mutableEnabled.value) setLockEnabled(false)
} else {
prefs.put(KEY_PASSWORD_HASHED, saltAndHash)
}
}
override fun setFailedUnlockAttempts(count: Int) {
@@ -164,6 +164,18 @@ class RelayLatencyTracker(
/**
* Expires pending entries older than the configured TTLs and records the TTL value as the
* sample (per the brainstorm: "punish silent relays"). Idempotent and cheap.
*
* The per-relay pending maps are `Collections.synchronizedMap(LinkedHashMap)` — their
* individual reads and writes are thread-safe, but iteration is NOT: per
* `Collections.synchronizedMap` javadoc, the caller MUST hold the returned map's
* monitor while iterating. Directly iterating triggers a
* `ConcurrentModificationException` when a producer thread (network dispatcher)
* mutates the map while the sweep is walking it — reliably reproduced on macOS
* during any relay-add on Amethyst Desktop as of 2026-07-06.
*
* Fix: iterate under `synchronized(pending)` blocks so the network dispatcher
* waits until sweep releases the monitor. The sweep is O(pending), typically
* ~single-digit entries per relay, so the hold time is negligible.
*/
override fun sweep(nowMs: Long) {
// Per-relay pending maps are `Collections.synchronizedMap(LinkedHashMap)` — the
@@ -324,7 +324,7 @@ actual class SecureKeyStorage private actual constructor() {
} else {
// Fallback for non-interactive environments (testing, etc.)
print("Enter master password: ")
readLine() ?: throw SecureStorageException("Password required for fallback storage")
readlnOrNull() ?: throw SecureStorageException("Password required for fallback storage")
}
}
return fallbackPassword!!
@@ -0,0 +1,110 @@
/*
* 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.commons.relays.index
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.prefs.Preferences
/**
* User-configurable set of relays used to fetch profile metadata
* (kind 0) and follow lists (kind 3) the "index relays" set passed
* to `FeedMetadataCoordinator` in the Desktop app and to `wot sync`
* in `amy`.
*
* Backed by [java.util.prefs.Preferences] at a fixed node
* `com/vitorpamplona/amethyst/relays/index` (JVM-user-scoped). The
* shared node means Desktop and `amy` running as the same OS user
* observe the same setting without extra plumbing the same trick
* `PreferencesHashtagSpamSettings` uses for the hashtag-spam filter.
*
* Not per-account: users typically have a single preferred set of
* index relays regardless of which account is currently logged in.
* If per-account overrides become necessary later, layer a per-user
* key on top; this class stays the base.
*
* CSV serialisation for the persisted value matches what
* `DesktopAccountRelays` uses for its categories no JSON dep, no
* `Serializable` contract. URLs are normalised via
* [RelayUrlNormalizer.normalizeOrNull] at both write and read time so
* malformed entries never enter the effective set.
*/
class PreferencesIndexRelays(
private val prefs: Preferences = Preferences.userRoot().node(NODE_NAME),
) {
private val mutableRelays: MutableStateFlow<Set<NormalizedRelayUrl>> =
MutableStateFlow(parse(prefs.get(KEY_URLS, "")))
/**
* Current user override. Empty when the user has not configured
* anything callers should route through [effective] to get the
* defaults-fallback resolved set.
*/
val relays: StateFlow<Set<NormalizedRelayUrl>> = mutableRelays.asStateFlow()
fun setRelays(new: Set<NormalizedRelayUrl>) {
mutableRelays.value = new
prefs.put(KEY_URLS, new.joinToString(",") { it.url })
}
/**
* Resolves the set the relay client should actually use the user
* override when non-empty, otherwise [DEFAULT_INDEX_RELAYS]. Never
* returns empty (unless the caller has explicitly reset both the
* override and the defaults to empty, which would require a code
* change here).
*/
fun effective(): Set<NormalizedRelayUrl> = mutableRelays.value.ifEmpty { DEFAULT_INDEX_RELAYS }
companion object {
const val NODE_NAME = "com/vitorpamplona/amethyst/relays/index"
const val KEY_URLS = "urls"
/**
* Byte-for-byte identical to `DefaultRelays.RELAYS` at
* `desktopApp/.../network/RelayStatus.kt`. Preserves current
* behaviour for users who never open the settings UI.
*
* Note: `commons/AmethystDefaults.kt` also has
* `DefaultIndexerRelayList` (Purple Pages, Coracle ) which is
* more purpose-built for indexing. Adopting it is a separate
* ticket see the plan's "Out of Scope" section.
*/
val DEFAULT_INDEX_RELAYS: Set<NormalizedRelayUrl> =
listOf(
"wss://nos.lol",
"wss://nostr.wine",
"wss://relay.noswhere.com",
"wss://relay.primal.net",
).mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
.toSet()
internal fun parse(csv: String): Set<NormalizedRelayUrl> =
csv
.split(",")
.mapNotNull { it.trim().takeIf(String::isNotEmpty) }
.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
.toSet()
}
}
@@ -0,0 +1,297 @@
/*
* 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.commons.relayClient.assemblers
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
/**
* Regression tests for PR #3483 review findings on FeedMetadataCoordinator:
*
* - Finding 5: `queuedKind3Pubkeys` was marked-on-send, so if every index
* relay timed out the pubkeys were permanently marked and subsequent
* calls short-circuited WoT stayed empty for the whole session.
* Fix: pubkeys land in `queuedKind3Pubkeys` only after 1 EOSE; on
* zero-EOSE timeout they roll out of `inFlightBatchedKind3` for retry.
*
* - Finding 6: `eoseReceived: MutableSet` was mutated from per-relay
* `onEose` callbacks running on `Dispatchers.IO` with no sync. Fix:
* `BatchEoseGate` funnels EOSE notifications through a `Channel` so a
* single consumer coroutine is the sole reader/writer of the `seen`
* set.
*/
class FeedMetadataCoordinatorTest {
private lateinit var scope: CoroutineScope
private val relay1 = NormalizedRelayUrl("wss://relay1.test/")
private val relay2 = NormalizedRelayUrl("wss://relay2.test/")
private val relay3 = NormalizedRelayUrl("wss://relay3.test/")
private val indexRelays = setOf(relay1, relay2, relay3)
@Before
fun setup() {
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
}
@After
fun teardown() {
scope.cancel()
}
private fun pubkey(seed: Int): HexKey = seed.toString(16).padStart(64, '0')
/**
* Fake client that captures subscribe/unsubscribe and lets the test
* drive EOSE notifications on any dispatcher we choose.
*/
private class ControllableClient(
private val delegate: INostrClient = EmptyNostrClient(),
) : INostrClient by delegate {
val subscriptions = mutableMapOf<String, SubscriptionListener?>()
val subscribeCalls = mutableListOf<Map<NormalizedRelayUrl, List<Filter>>>()
var unsubscribeCallCount = 0
private set
override fun subscribe(
subId: String,
filters: Map<NormalizedRelayUrl, List<Filter>>,
listener: SubscriptionListener?,
) {
subscriptions[subId] = listener
subscribeCalls.add(filters)
}
override fun unsubscribe(subId: String) {
subscriptions.remove(subId)
unsubscribeCallCount++
}
fun fireEose(relay: NormalizedRelayUrl) {
subscriptions.values.filterNotNull().forEach { it.onEose(relay, forFilters = null) }
}
}
@Test
fun `loadKind3Batched retries after zero-EOSE timeout`() =
runBlocking {
val client = ControllableClient()
val coordinator =
FeedMetadataCoordinator(
client = client,
scope = scope,
indexRelays = indexRelays,
)
val pubkeys = listOf(pubkey(1), pubkey(2), pubkey(3))
// Call 1 — no relay EOSEs; must time out.
coordinator.loadKind3Batched(pubkeys, timeoutMs = 200)
delay(350) // exceed the timeout
// Call 2 — the same pubkeys must be re-subscribed since call 1
// never got a successful EOSE. The old code would silently
// short-circuit here.
coordinator.loadKind3Batched(pubkeys, timeoutMs = 200)
delay(50) // let the launcher run
assertEquals(
"Zero-EOSE timeout must not permanently dedup pubkeys",
2,
client.subscribeCalls.size,
)
assertEquals(
"Second call must re-request the same author set",
pubkeys.size,
client.subscribeCalls[1]
.values
.first()
.first()
.authors!!
.size,
)
}
@Test
fun `loadKind3Batched short-circuits after successful EOSE`() =
runBlocking {
val client = ControllableClient()
val coordinator =
FeedMetadataCoordinator(
client = client,
scope = scope,
indexRelays = indexRelays,
)
val pubkeys = listOf(pubkey(1), pubkey(2))
coordinator.loadKind3Batched(pubkeys, timeoutMs = 1_000)
// Give the launcher time to register the listener before we fire.
delay(50)
indexRelays.forEach(client::fireEose)
delay(200) // let the coordinator finish + promote to queued
coordinator.loadKind3Batched(pubkeys, timeoutMs = 200)
delay(50)
assertEquals(
"Successful call must dedup subsequent identical calls",
1,
client.subscribeCalls.size,
)
}
@Test
fun `loadKind3Batched promotes even when only some relays EOSE`() =
runBlocking {
val client = ControllableClient()
val coordinator =
FeedMetadataCoordinator(
client = client,
scope = scope,
indexRelays = indexRelays,
)
val pubkeys = listOf(pubkey(1))
coordinator.loadKind3Batched(pubkeys, timeoutMs = 300)
delay(30)
// Only 1 of 3 EOSEs — timeout still fires but we made progress.
client.fireEose(relay1)
delay(400)
coordinator.loadKind3Batched(pubkeys, timeoutMs = 200)
delay(50)
assertEquals(
"≥1 EOSE = progress = promote to queued (avoid re-asking)",
1,
client.subscribeCalls.size,
)
}
/**
* Regression for finding 6 pumps EOSE from many dispatchers in
* parallel. The old MutableSet-based code could drop entries or throw
* ConcurrentModificationException on the internal HashSet iterator.
* BatchEoseGate must aggregate every distinct relay exactly once.
*/
@Test
fun `EOSE aggregator is safe under concurrent per-relay callbacks`() =
runBlocking {
val bigIndexSet =
(0..19).map { NormalizedRelayUrl("wss://relay$it.test/") }.toSet()
val client = ControllableClient()
val coordinator =
FeedMetadataCoordinator(
client = client,
scope = scope,
indexRelays = bigIndexSet,
)
coordinator.loadKind3Batched(listOf(pubkey(1)), timeoutMs = 2_000)
delay(50) // wait for subscription
// Fire EOSEs concurrently from many dispatchers.
val jobs =
bigIndexSet.map { relay ->
scope.launch(Dispatchers.IO) {
client.fireEose(relay)
}
}
jobs.forEach { it.join() }
// The 2nd call must short-circuit — every relay EOSE'd, so
// pubkey(1) is now in queuedKind3Pubkeys.
delay(100)
coordinator.loadKind3Batched(listOf(pubkey(1)), timeoutMs = 200)
delay(50)
assertEquals(
"Under concurrent EOSE from all relays, aggregator must reach target",
1,
client.subscribeCalls.size,
)
}
@Test
fun `loadMetadataBatched follows the same retry semantics`() =
runBlocking {
val client = ControllableClient()
val coordinator =
FeedMetadataCoordinator(
client = client,
scope = scope,
indexRelays = indexRelays,
)
val pubkeys = listOf(pubkey(1), pubkey(2))
// Call 1 — zero EOSE, timeout.
coordinator.loadMetadataBatched(pubkeys, timeoutMs = 200)
delay(350)
// Call 2 — must re-subscribe.
coordinator.loadMetadataBatched(pubkeys, timeoutMs = 200)
delay(50)
assertTrue(
"Metadata batch also retries on zero-EOSE timeout",
client.subscribeCalls.size >= 2,
)
}
@Test
fun `clear releases in-flight dedup so a fresh call always fires`() =
runBlocking {
val client = ControllableClient()
val coordinator =
FeedMetadataCoordinator(
client = client,
scope = scope,
indexRelays = indexRelays,
)
coordinator.loadKind3Batched(listOf(pubkey(1)), timeoutMs = 200)
delay(50)
// clear() must drop the in-flight tracker even mid-request.
coordinator.clear()
delay(300) // let call 1 finish + roll back
coordinator.loadKind3Batched(listOf(pubkey(1)), timeoutMs = 200)
delay(50)
assertTrue(client.subscribeCalls.size >= 2)
}
}
@@ -0,0 +1,96 @@
/*
* 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.commons.relays.index
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.util.prefs.Preferences
class PreferencesIndexRelaysTest {
private val testNode = "com/vitorpamplona/amethyst/test/relays/index_${System.currentTimeMillis()}"
private fun prefs(): Preferences = Preferences.userRoot().node(testNode)
@Before
fun setup() {
prefs().clear()
}
@After
fun teardown() {
prefs().removeNode()
}
@Test
fun defaultsWhenPreferencesUnset() {
val store = PreferencesIndexRelays(prefs())
assertTrue(store.relays.value.isEmpty())
assertEquals(PreferencesIndexRelays.DEFAULT_INDEX_RELAYS, store.effective())
}
@Test
fun setRelaysPersistsAcrossInstances() {
val store = PreferencesIndexRelays(prefs())
val urls =
listOf("wss://relay.example", "wss://index.example")
.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
.toSet()
store.setRelays(urls)
assertEquals(urls, store.relays.value)
val reloaded = PreferencesIndexRelays(prefs())
assertEquals(urls, reloaded.relays.value)
assertEquals(urls, reloaded.effective())
}
@Test
fun effectiveFallsBackWhenOverrideCleared() {
val store = PreferencesIndexRelays(prefs())
val urls =
listOf("wss://relay.example")
.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
.toSet()
store.setRelays(urls)
store.setRelays(emptySet())
assertEquals(PreferencesIndexRelays.DEFAULT_INDEX_RELAYS, store.effective())
}
@Test
fun emptyEntriesInCsvAreSkipped() {
// Plant a URL list with empty tokens (extra commas). The
// parser should skip blanks silently.
prefs().put(PreferencesIndexRelays.KEY_URLS, "wss://good.example,,wss://also-good.example,")
val store = PreferencesIndexRelays(prefs())
// Both good URLs should be present; no blank / empty entry.
assertEquals(2, store.relays.value.size)
assertTrue(store.relays.value.none { it.url.isBlank() })
}
@Test
fun defaultSetIsNotEmpty() {
// Guardrail against a future refactor accidentally clearing the constant.
assertTrue(PreferencesIndexRelays.DEFAULT_INDEX_RELAYS.isNotEmpty())
}
}
@@ -0,0 +1,383 @@
/*
* 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.commons.wot
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
/**
* Coverage for the outbox pipeline defined in
* `commons/plans/2026-07-06-fix-wot-outbox-model-and-review-fixes-plan.md`.
* Scenarios:
*
* 1. Author has a cached kind-10002 Phase 1 skipped, Phase 2 REQs
* the author's write relay directly.
* 2. Author has no cached 10002 Phase 1 discovers, Phase 2 uses the
* discovered write relays.
* 3. Author with no 10002 anywhere Phase 3 fallback to index relays.
* 4. Per-relay timeout on Phase 1 doesn't cancel Phase 2 for authors
* that already had a cached outbox.
* 5. clear() releases dedup so a fresh call always re-runs.
*/
class OutboxDispatcherTest {
private lateinit var scope: CoroutineScope
private val indexRelay1 = NormalizedRelayUrl("wss://index1.test/")
private val indexRelay2 = NormalizedRelayUrl("wss://index2.test/")
private val indexRelays = setOf(indexRelay1, indexRelay2)
private val outboxAlice = NormalizedRelayUrl("wss://alice-outbox.test/")
private val outboxBob = NormalizedRelayUrl("wss://bob-outbox.test/")
private val alice = pubkey(1)
private val bob = pubkey(2)
private val charlie = pubkey(3)
@Before
fun setup() {
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
}
@After
fun teardown() {
scope.cancel()
}
private fun pubkey(seed: Int): HexKey = seed.toString(16).padStart(64, '0')
private fun dummySig() = "0".repeat(128)
private fun outboxEventFor(
author: HexKey,
writeRelays: List<NormalizedRelayUrl>,
createdAt: Long = 1_700_000_000,
): AdvertisedRelayListEvent {
val tags = writeRelays.map { arrayOf("r", it.url, "write") }.toTypedArray()
return AdvertisedRelayListEvent(
id = "out-$author".take(64).padEnd(64, '0'),
pubKey = author,
createdAt = createdAt,
tags = tags,
content = "",
sig = dummySig(),
)
}
private fun kind3For(
author: HexKey,
follows: List<HexKey>,
) = ContactListEvent(
id = "k3-$author".take(64).padEnd(64, '0'),
pubKey = author,
createdAt = 1_700_000_100,
tags = follows.map { arrayOf("p", it) }.toTypedArray(),
content = "",
sig = dummySig(),
)
private class RecordingGateway : OutboxCacheGateway {
val cache = mutableMapOf<HexKey, AdvertisedRelayListEvent>()
val discoveredOutbox = mutableListOf<Pair<AdvertisedRelayListEvent, NormalizedRelayUrl>>()
val discoveredEvents = mutableListOf<Pair<Event, NormalizedRelayUrl>>()
override fun cachedOutbox(pubkey: HexKey): AdvertisedRelayListEvent? = cache[pubkey]
override fun onOutboxDiscovered(
event: AdvertisedRelayListEvent,
relay: NormalizedRelayUrl,
) {
cache[event.pubKey] = event
discoveredOutbox.add(event to relay)
}
override fun onDiscoveredEvent(
event: Event,
relay: NormalizedRelayUrl,
) {
discoveredEvents.add(event to relay)
}
}
/**
* Fake INostrClient that replays a scripted set of events + auto-EOSEs
* per relay when [subscribe] is called. The script is keyed by the
* REQ's `(kinds, relay)` pair so tests can seed different responses
* for Phase-1 and Phase-2 subs.
*/
private class ScriptedClient(
private val delegate: INostrClient = EmptyNostrClient(),
) : INostrClient by delegate {
// (kind, relay) → list of events to return
private val script = mutableMapOf<Pair<Int, NormalizedRelayUrl>, List<Event>>()
private val eoseNever = mutableSetOf<NormalizedRelayUrl>()
val allSubscribeCalls = mutableListOf<Map<NormalizedRelayUrl, List<Filter>>>()
fun scriptEvent(
kind: Int,
relay: NormalizedRelayUrl,
events: List<Event>,
) {
script[kind to relay] = events
}
fun neverEose(relay: NormalizedRelayUrl) {
eoseNever.add(relay)
}
override fun subscribe(
subId: String,
filters: Map<NormalizedRelayUrl, List<Filter>>,
listener: SubscriptionListener?,
) {
allSubscribeCalls.add(filters)
filters.forEach { (relay, filterList) ->
filterList.forEach { filter ->
filter.kinds?.forEach { kind ->
script[kind to relay]?.forEach { event ->
listener?.onEvent(event, isLive = false, relay = relay, forFilters = null)
}
}
}
if (relay !in eoseNever) {
listener?.onEose(relay, forFilters = null)
}
}
}
override fun unsubscribe(subId: String) { /* no-op */ }
}
@Test
fun `cached outbox skips Phase 1 and fetches directly from write relay`() =
runBlocking {
val client = ScriptedClient()
val gateway = RecordingGateway()
gateway.cache[alice] = outboxEventFor(alice, listOf(outboxAlice))
client.scriptEvent(ContactListEvent.KIND, outboxAlice, listOf(kind3For(alice, listOf(bob))))
val dispatcher =
OutboxDispatcher(
client = client,
scope = scope,
indexRelays = { indexRelays },
gateway = gateway,
perRelayTimeoutMs = 400,
overallTimeoutMs = 2_000,
)
val result = dispatcher.fetchKind3Only(setOf(alice))
assertEquals(1, result.kind3Received)
assertEquals(1, result.outboxCoveredAuthors)
assertEquals(0, result.fallbackAuthors)
assertTrue(
"Phase 2 must REQ from Alice's own outbox relay",
client.allSubscribeCalls.any { call -> outboxAlice in call.keys },
)
assertTrue(
"No Phase 1 REQ should be sent to index relays when 10002 is cached",
client.allSubscribeCalls.none { call -> indexRelays.any { it in call.keys } },
)
}
@Test
fun `Phase 1 discovers 10002 then Phase 2 fetches from the discovered write relay`() =
runBlocking {
val client = ScriptedClient()
val gateway = RecordingGateway()
val bobOutbox = outboxEventFor(bob, listOf(outboxBob))
indexRelays.forEach { rel ->
client.scriptEvent(AdvertisedRelayListEvent.KIND, rel, listOf(bobOutbox))
}
client.scriptEvent(ContactListEvent.KIND, outboxBob, listOf(kind3For(bob, listOf(alice))))
val dispatcher =
OutboxDispatcher(
client = client,
scope = scope,
indexRelays = { indexRelays },
gateway = gateway,
perRelayTimeoutMs = 400,
overallTimeoutMs = 2_000,
)
val result = dispatcher.fetchKind3Only(setOf(bob))
assertTrue("Discovered 10002 count > 0", result.kind10002Received > 0)
assertEquals(1, result.kind3Received)
assertEquals(1, result.outboxCoveredAuthors)
assertEquals(0, result.fallbackAuthors)
assertTrue(
"Gateway was told about the discovered 10002",
gateway.discoveredOutbox.any { it.first.pubKey == bob },
)
}
@Test
fun `author with no 10002 falls back to index-relay REQ`() =
runBlocking {
val client = ScriptedClient()
val gateway = RecordingGateway()
// No 10002 anywhere. Charlie's kind-3 sits only on the index relays.
indexRelays.forEach { rel ->
client.scriptEvent(ContactListEvent.KIND, rel, listOf(kind3For(charlie, listOf(alice))))
}
val dispatcher =
OutboxDispatcher(
client = client,
scope = scope,
indexRelays = { indexRelays },
gateway = gateway,
perRelayTimeoutMs = 400,
overallTimeoutMs = 2_000,
)
val result = dispatcher.fetchKind3Only(setOf(charlie))
assertEquals(1, result.fallbackAuthors)
assertEquals(0, result.outboxCoveredAuthors)
assertTrue(
"Fallback path receives the kind-3",
result.kind3Received >= 1,
)
}
@Test
fun `cached-outbox author still fetched when Phase 1 for other authors times out`() =
runBlocking {
val client = ScriptedClient()
val gateway = RecordingGateway()
// Alice has cached outbox — Phase 2 must fetch from her write relay.
gateway.cache[alice] = outboxEventFor(alice, listOf(outboxAlice))
client.scriptEvent(ContactListEvent.KIND, outboxAlice, listOf(kind3For(alice, listOf(bob))))
// Bob has no cached outbox and index relays never EOSE for Phase 1.
indexRelays.forEach(client::neverEose)
val dispatcher =
OutboxDispatcher(
client = client,
scope = scope,
indexRelays = { indexRelays },
gateway = gateway,
perRelayTimeoutMs = 200,
overallTimeoutMs = 2_000,
)
val result = dispatcher.fetchKind3Only(setOf(alice, bob))
// Alice was covered by cached outbox; Bob wasn't but Phase 1 timed
// out, so he became a fallback candidate.
assertEquals(
"Alice always covered by cached outbox",
1,
result.outboxCoveredAuthors,
)
assertTrue(result.kind3Received >= 1)
}
@Test
fun `clear releases dedup so a subsequent identical call refetches`() =
runBlocking {
val client = ScriptedClient()
val gateway = RecordingGateway()
gateway.cache[alice] = outboxEventFor(alice, listOf(outboxAlice))
client.scriptEvent(ContactListEvent.KIND, outboxAlice, listOf(kind3For(alice, listOf(bob))))
val dispatcher =
OutboxDispatcher(
client = client,
scope = scope,
indexRelays = { indexRelays },
gateway = gateway,
perRelayTimeoutMs = 400,
overallTimeoutMs = 2_000,
)
dispatcher.fetchKind3Only(setOf(alice))
val subCountAfterFirst = client.allSubscribeCalls.size
// Second call without clear() — should short-circuit.
dispatcher.fetchKind3Only(setOf(alice))
assertEquals(subCountAfterFirst, client.allSubscribeCalls.size)
// After clear(), the same call re-runs Phase 2.
dispatcher.clear()
dispatcher.fetchKind3Only(setOf(alice))
assertTrue(client.allSubscribeCalls.size > subCountAfterFirst)
}
/**
* BatchEoseGate stress inside OutboxDispatcher this is a private
* class but the observable effect (Phase 1 completes when all index
* relays EOSE, and stays within the timeout budget) is what matters.
*/
@Test
fun `EOSE aggregation is safe with many concurrent index-relay callbacks`() =
runBlocking {
val bigIndexSet = (0..15).map { NormalizedRelayUrl("wss://index$it.test/") }.toSet()
val client = ScriptedClient()
val gateway = RecordingGateway()
val dispatcher =
OutboxDispatcher(
client = client,
scope = scope,
indexRelays = { bigIndexSet },
gateway = gateway,
perRelayTimeoutMs = 1_000,
overallTimeoutMs = 3_000,
)
// Kick off a fetch and race the subscribe call. ScriptedClient
// fires EOSE inline; we simulate concurrent per-relay EOSE by
// launching multiple dispatchers as a smoke test.
val fetchJob = scope.launch { dispatcher.fetchKind3Only(setOf(alice, bob, charlie)) }
// Give the launcher a moment to enter Phase 1's subscribe.
delay(50)
fetchJob.join()
// No CME thrown, no hang past the timeout budget.
}
}
@@ -0,0 +1,286 @@
/*
* 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.commons.wot
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class WoTServiceTest {
private lateinit var scope: CoroutineScope
private lateinit var svc: WoTService
// Fixed test pubkeys for readability.
private val me = "self".padEnd(64, '0')
private val a = "aaaa".padEnd(64, '0')
private val b = "bbbb".padEnd(64, '0')
private val c = "cccc".padEnd(64, '0')
private val d = "dddd".padEnd(64, '0')
private val e = "eeee".padEnd(64, '0')
@Before
fun setup() {
scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
svc = WoTService(scope, writerDispatcher = Dispatchers.Unconfined)
}
@After
fun teardown() {
scope.cancel()
}
/**
* With `Dispatchers.Unconfined` + `Channel.UNLIMITED`, `trySend` from the
* test thread synchronously resumes the writer coroutine so no explicit
* wait is needed. This helper is a no-op we keep for future scheduler
* changes.
*/
private fun drain() = Unit
@Test
fun emptyGraphYieldsEmptyScores() {
svc.onFollowSetChange(emptySet(), me)
drain()
assertEquals(emptyMap<String, Int>(), svc.scoresSnapshot())
}
@Test
fun singleFollowerCreditsTargets() {
svc.onFollowSetChange(setOf(a), me)
svc.applyKind3(a, setOf(c, d))
drain()
assertEquals(1, svc.scoresSnapshot()[c])
assertEquals(1, svc.scoresSnapshot()[d])
}
@Test
fun overlappingFollowersSumScores() {
svc.onFollowSetChange(setOf(a, b), me)
svc.applyKind3(a, setOf(c, d))
svc.applyKind3(b, setOf(c, e))
drain()
assertEquals(2, svc.scoresSnapshot()[c])
assertEquals(1, svc.scoresSnapshot()[d])
assertEquals(1, svc.scoresSnapshot()[e])
}
@Test
fun removingFollowerDecrementsAllContributions() {
svc.onFollowSetChange(setOf(a, b), me)
svc.applyKind3(a, setOf(c, d))
svc.applyKind3(b, setOf(c, e))
drain()
// A drops out.
svc.onFollowSetChange(setOf(b), me)
drain()
assertEquals(1, svc.scoresSnapshot()[c])
// d had only A crediting it — should be gone.
assertFalse(c in svc.scoresSnapshot() && d in svc.scoresSnapshot() && svc.scoresSnapshot()[d] == null)
assertEquals(null, svc.scoresSnapshot()[d])
assertEquals(1, svc.scoresSnapshot()[e])
}
@Test
fun kind3ChurnAppliesDiff() {
svc.onFollowSetChange(setOf(a), me)
svc.applyKind3(a, setOf(c, d))
drain()
assertEquals(1, svc.scoresSnapshot()[c])
assertEquals(1, svc.scoresSnapshot()[d])
// A republishes with a different set — d removed, e added.
svc.applyKind3(a, setOf(c, e))
drain()
assertEquals(1, svc.scoresSnapshot()[c])
assertEquals(null, svc.scoresSnapshot()[d])
assertEquals(1, svc.scoresSnapshot()[e])
}
@Test
fun selfInclusionInKind3IsExcluded() {
svc.onFollowSetChange(setOf(a), me)
// A's kind-3 includes self (me) — must not inflate self's score.
svc.applyKind3(a, setOf(c, me))
drain()
assertEquals(1, svc.scoresSnapshot()[c])
assertEquals(null, svc.scoresSnapshot()[me])
}
@Test
fun followerSelfInclusionIsExcluded() {
svc.onFollowSetChange(setOf(a), me)
// A's kind-3 includes A itself — must not inflate A's own score.
svc.applyKind3(a, setOf(c, a))
drain()
assertEquals(1, svc.scoresSnapshot()[c])
assertEquals(null, svc.scoresSnapshot()[a])
}
@Test
fun kind3FromNonFollowerIsIgnored() {
svc.onFollowSetChange(setOf(a), me)
// e is NOT in my follow set — their kind-3 shouldn't credit anyone.
svc.applyKind3(e, setOf(c, d))
drain()
assertEquals(emptyMap<String, Int>(), svc.scoresSnapshot())
}
@Test
fun sparseMapDropsZeroCounts() {
svc.onFollowSetChange(setOf(a), me)
svc.applyKind3(a, setOf(c))
drain()
assertTrue(c in svc.scoresSnapshot())
// A republishes with an empty follow set.
svc.applyKind3(a, emptySet())
drain()
// c dropped to 0 → removed from map, not stored as 0.
assertFalse(c in svc.scoresSnapshot())
}
private fun fakePubkey(seed: Int): String = seed.toString(16).padStart(64, '0')
@Test
fun guardrailSkipsHugeFollowSets() {
val hugeFollows = (0..WoTService.MAX_FOLLOWS + 1).map { fakePubkey(it) }.toSet()
svc.onFollowSetChange(hugeFollows, me)
drain()
assertEquals(emptyMap<String, Int>(), svc.scoresSnapshot())
assertTrue(runBlocking { svc.isReady.first() })
assertTrue(runBlocking { svc.isDisabled.first() })
}
/**
* Regression for PR #3483 review finding 2: even after the guardrail
* trips, applyKind3 for a follower in the huge follow set used to
* repopulate reverseIndex/_scores because myFollows had already been
* assigned. Fix clears myFollows AND sets a disabled flag; both gate
* handleKind3 so the guardrail actually holds under sustained pump.
*/
@Test
fun guardrailIgnoresApplyKind3AfterTrip() {
val huge = (0..WoTService.MAX_FOLLOWS + 1).map { fakePubkey(it) }.toSet()
svc.onFollowSetChange(huge, me)
drain()
val anyFollower = huge.first()
svc.applyKind3(anyFollower, setOf(c, d, e))
drain()
assertEquals(
"Guardrail must block score repopulation via applyKind3",
emptyMap<String, Int>(),
svc.scoresSnapshot(),
)
}
@Test
fun guardrailReleasesWhenFollowSetShrinksBack() {
val huge = (0..WoTService.MAX_FOLLOWS + 1).map { fakePubkey(it) }.toSet()
svc.onFollowSetChange(huge, me)
drain()
assertTrue(runBlocking { svc.isDisabled.first() })
// User trims their follow list — dispatcher should re-engage.
svc.onFollowSetChange(setOf(a, b), me)
drain()
assertFalse(runBlocking { svc.isDisabled.first() })
// And WoT scoring resumes normally.
svc.applyKind3(a, setOf(c, d))
drain()
assertEquals(1, svc.scoresSnapshot()[c])
}
@Test
fun closeStopsAcceptingOps() {
svc.onFollowSetChange(setOf(a), me)
svc.applyKind3(a, setOf(c))
drain()
assertEquals(1, svc.scoresSnapshot()[c])
svc.close()
drain()
// Post-close writes are dropped silently.
svc.applyKind3(a, setOf(d))
drain()
assertEquals(null, svc.scoresSnapshot()[d])
// State observed before close remains readable.
assertEquals(1, svc.scoresSnapshot()[c])
}
@Test
fun closeIsIdempotent() {
svc.close()
svc.close() // should not throw
}
@Test
fun maxFollowsPerEventCap() {
svc.onFollowSetChange(setOf(a), me)
val huge = (0..WoTService.MAX_FOLLOWS_PER_EVENT + 100).map { fakePubkey(it) }.toSet()
svc.applyKind3(a, huge)
drain()
// Cap kicks in after MAX_FOLLOWS_PER_EVENT — no crash, score map bounded.
assertTrue(svc.scoresSnapshot().size <= WoTService.MAX_FOLLOWS_PER_EVENT)
}
@Test
fun markReadyOnceFiresReady() {
assertFalse(runBlocking { svc.isReady.first() })
svc.markReadyOnce()
drain()
assertTrue(runBlocking { svc.isReady.first() })
}
@Test
fun clearResetsEverything() {
svc.onFollowSetChange(setOf(a), me)
svc.applyKind3(a, setOf(c, d))
svc.markReadyOnce()
drain()
svc.clear()
drain()
assertEquals(emptyMap<String, Int>(), svc.scoresSnapshot())
assertFalse(runBlocking { svc.isReady.first() })
}
@Test
fun scoresSnapshotIsHashMapCopy() {
svc.onFollowSetChange(setOf(a), me)
svc.applyKind3(a, setOf(c))
drain()
val snap = svc.scoresSnapshot()
assertEquals(1, snap[c])
// Modifying the snapshot must not affect the service.
(snap as MutableMap<String, Int>).clear()
assertEquals(1, svc.scoresSnapshot()[c])
}
}