mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
Merge pull request #3508 from nrobi144/feat/desktop-dm-reliability
feat(desktop): NIP-17 DM reliability — AUTH banner, strict inbox resolution, relay hints
This commit is contained in:
+44
@@ -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()
|
||||
|
||||
+156
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
/**
|
||||
* 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 = Any()
|
||||
|
||||
override suspend fun getScope(relayUrl: NormalizedRelayUrl): AuthApprovalScope? = synchronized(lock) { scopes[relayUrl] }
|
||||
|
||||
override suspend fun setScope(
|
||||
relayUrl: NormalizedRelayUrl,
|
||||
scope: AuthApprovalScope,
|
||||
) {
|
||||
synchronized(lock) { scopes[relayUrl] = scope }
|
||||
}
|
||||
|
||||
override suspend fun clear() {
|
||||
synchronized(lock) { 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)
|
||||
}
|
||||
}
|
||||
+136
@@ -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() }
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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),
|
||||
|
||||
+36
-1
@@ -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)
|
||||
|
||||
+15
@@ -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),
|
||||
|
||||
+72
-10
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+169
@@ -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")
|
||||
}
|
||||
+163
@@ -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)
|
||||
}
|
||||
}
|
||||
+155
@@ -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)
|
||||
}
|
||||
}
|
||||
+12
@@ -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
|
||||
|
||||
@@ -71,23 +71,28 @@ import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.WindowPosition
|
||||
import androidx.compose.ui.window.application
|
||||
import androidx.compose.ui.window.rememberWindowState
|
||||
import com.vitorpamplona.amethyst.commons.defaults.DefaultDmIndexerRelays
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.ProvideMaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.moderation.LocalHashtagSpamSettings
|
||||
import com.vitorpamplona.amethyst.commons.moderation.LocalSpamExemptKeys
|
||||
import com.vitorpamplona.amethyst.commons.moderation.PreferencesHashtagSpamSettings
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.auth.AuthApprovalBanner
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.DmInboxRelayResolver
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrNull
|
||||
import com.vitorpamplona.amethyst.commons.wot.LocalWoTReady
|
||||
import com.vitorpamplona.amethyst.commons.wot.LocalWoTService
|
||||
import com.vitorpamplona.amethyst.desktop.account.AccountManager
|
||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||
import com.vitorpamplona.amethyst.desktop.auth.DesktopAuthCoordinator
|
||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||
import com.vitorpamplona.amethyst.desktop.model.DesktopAccountRelays
|
||||
import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
|
||||
import com.vitorpamplona.amethyst.desktop.model.DesktopRelayCategories
|
||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||
import com.vitorpamplona.amethyst.desktop.network.Nip11Fetcher
|
||||
import com.vitorpamplona.amethyst.desktop.platform.PlatformInfo
|
||||
import com.vitorpamplona.amethyst.desktop.platform.applyNativeWindowChrome
|
||||
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
|
||||
import com.vitorpamplona.amethyst.desktop.service.images.DesktopImageLoaderSetup
|
||||
@@ -124,9 +129,12 @@ import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard
|
||||
import com.vitorpamplona.amethyst.desktop.ui.settings.ImageCompressionSettings
|
||||
import com.vitorpamplona.amethyst.desktop.ui.settings.MediaServerSettings
|
||||
import com.vitorpamplona.amethyst.desktop.ui.settings.NamecoinSettingsSection
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
||||
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.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
|
||||
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
|
||||
@@ -922,6 +930,39 @@ private fun AppInner(
|
||||
}
|
||||
val nip11Fetcher = remember { Nip11Fetcher() }
|
||||
|
||||
// Dedicated unauthenticated NostrClient for kind:10050 lookups against
|
||||
// curated indexer relays. MUST NOT have a RelayAuthenticator attached —
|
||||
// an authenticated indexer query would extract identity-key signatures
|
||||
// and turn "indexer learns who we want to DM" into "indexer learns user
|
||||
// U wants to DM pubkey X" (security review F-01).
|
||||
val indexerClient =
|
||||
remember(httpClient) {
|
||||
NostrClient(BasicOkHttpWebSocket.Builder(httpClient::getHttpClient)).also { it.connect() }
|
||||
}
|
||||
DisposableEffect(indexerClient) {
|
||||
onDispose { indexerClient.disconnect() }
|
||||
}
|
||||
|
||||
// Resolver consults LocalCache first, then its own LRU, then the indexer
|
||||
// client. Strict kind:10050 only — no NIP-65 read-marker fallback.
|
||||
val dmInboxResolver =
|
||||
remember(indexerClient, localCache) {
|
||||
DmInboxRelayResolver(
|
||||
unauthenticatedClient = indexerClient,
|
||||
indexerRelays =
|
||||
DefaultDmIndexerRelays.RELAYS
|
||||
.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
|
||||
.toSet(),
|
||||
localLookup = { pubkey ->
|
||||
// Strict kind:10050 only — the lenient dmInboxRelays() falls
|
||||
// back to NIP-65 read relays, which this fast-path would
|
||||
// return before the strict indexer fan-out ran, leaking DM
|
||||
// metadata to relays the recipient never designated for DMs.
|
||||
localCache.getUserIfExists(pubkey)?.dmInboxRelaysStrict()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Start 1Hz metrics snapshot for relay dashboard
|
||||
LaunchedEffect(relayManager) {
|
||||
relayManager.startMetricsSnapshot(this)
|
||||
@@ -953,11 +994,20 @@ private fun AppInner(
|
||||
).also { it.startCleanupLoop() }
|
||||
}
|
||||
|
||||
// NIP-42 AUTH coordinator — wires relay-auth challenges through the
|
||||
// tier classifier so own DM-inbox relays auto-sign and unknown relays
|
||||
// surface a tier-2 banner approval via authCoordinator.pendingApprovals.
|
||||
val authCoordinator =
|
||||
remember(relayManager, localCache) {
|
||||
DesktopAuthCoordinator(relayManager, localCache, scope)
|
||||
}
|
||||
|
||||
// Clear cache and subscriptions on logout or account switch
|
||||
var previousAccountPubKey by remember { mutableStateOf<String?>(null) }
|
||||
LaunchedEffect(accountState) {
|
||||
when (val state = accountState) {
|
||||
is AccountState.LoggedOut -> {
|
||||
authCoordinator.onLogout()
|
||||
subscriptionsCoordinator.clear()
|
||||
localCache.accountPubkey = null
|
||||
localCache.clear()
|
||||
@@ -970,6 +1020,7 @@ private fun AppInner(
|
||||
val currentPubKey = state.pubKeyHex
|
||||
if (previousAccountPubKey != null && previousAccountPubKey != currentPubKey) {
|
||||
// Account switched — clear old data so new feed loads fresh
|
||||
authCoordinator.onLogout()
|
||||
subscriptionsCoordinator.clear()
|
||||
localCache.accountPubkey = null
|
||||
localCache.clear()
|
||||
@@ -994,6 +1045,7 @@ private fun AppInner(
|
||||
scope.launch(Dispatchers.IO) {
|
||||
localRelayStore.hydrate(localCache)
|
||||
}
|
||||
authCoordinator.onLogin(state)
|
||||
previousAccountPubKey = currentPubKey
|
||||
}
|
||||
|
||||
@@ -1271,32 +1323,53 @@ private fun AppInner(
|
||||
LocalNamecoinService provides namecoinService,
|
||||
LocalSpamExemptKeys provides spamExemptKeys,
|
||||
) {
|
||||
MainContent(
|
||||
layoutMode = layoutMode,
|
||||
deckState = deckState,
|
||||
workspaceManager = workspaceManager,
|
||||
singlePaneState = singlePaneState,
|
||||
pinnedNavBarState = pinnedNavBarState,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
accountManager = accountManager,
|
||||
account = account,
|
||||
nwcConnection = nwcConnection,
|
||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||
indexRelaysStore = indexRelaysStore,
|
||||
nip11Fetcher = nip11Fetcher,
|
||||
appScope = scope,
|
||||
torStatus = currentTorStatus,
|
||||
onShowComposeDialog = onShowComposeDialog,
|
||||
onShowReplyDialog = onShowReplyDialog,
|
||||
onShowAppDrawer = onShowAppDrawer,
|
||||
onOpenFeedsDrawer = {
|
||||
appDrawerInitialTab =
|
||||
com.vitorpamplona.amethyst.desktop.ui.deck.AppDrawerTab.FEEDS
|
||||
onShowAppDrawer()
|
||||
},
|
||||
onShowImportFollowListDialog = onShowImportFollowListDialog,
|
||||
)
|
||||
val pendingAuthApprovals by authCoordinator.pendingApprovals.collectAsState()
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// On macOS the window uses `apple.awt.fullWindowContent`
|
||||
// (see [applyNativeWindowChrome]), so the traffic-light
|
||||
// buttons sit over the top-left corner of content. Clear
|
||||
// that zone so the banner text/icon aren't occluded.
|
||||
val bannerModifier =
|
||||
if (PlatformInfo.isMacOS) {
|
||||
Modifier.padding(start = 80.dp, top = 8.dp, end = 8.dp, bottom = 4.dp)
|
||||
} else {
|
||||
Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
}
|
||||
AuthApprovalBanner(
|
||||
pending = pendingAuthApprovals.values.toList(),
|
||||
onResolve = { url, scope -> authCoordinator.resolve(url, scope) },
|
||||
modifier = bannerModifier,
|
||||
)
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
MainContent(
|
||||
layoutMode = layoutMode,
|
||||
deckState = deckState,
|
||||
workspaceManager = workspaceManager,
|
||||
singlePaneState = singlePaneState,
|
||||
pinnedNavBarState = pinnedNavBarState,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
accountManager = accountManager,
|
||||
account = account,
|
||||
nwcConnection = nwcConnection,
|
||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||
indexRelaysStore = indexRelaysStore,
|
||||
nip11Fetcher = nip11Fetcher,
|
||||
dmInboxResolver = dmInboxResolver,
|
||||
appScope = scope,
|
||||
torStatus = currentTorStatus,
|
||||
onShowComposeDialog = onShowComposeDialog,
|
||||
onShowReplyDialog = onShowReplyDialog,
|
||||
onShowAppDrawer = onShowAppDrawer,
|
||||
onOpenFeedsDrawer = {
|
||||
appDrawerInitialTab =
|
||||
com.vitorpamplona.amethyst.desktop.ui.deck.AppDrawerTab.FEEDS
|
||||
onShowAppDrawer()
|
||||
},
|
||||
onShowImportFollowListDialog = onShowImportFollowListDialog,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Import Follow List dialog (triggered from File menu /
|
||||
// Cmd+Shift+I). Rendered inside this CompositionLocalProvider
|
||||
@@ -1408,6 +1481,7 @@ fun MainContent(
|
||||
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
|
||||
indexRelaysStore: com.vitorpamplona.amethyst.commons.relays.index.PreferencesIndexRelays,
|
||||
nip11Fetcher: Nip11Fetcher,
|
||||
dmInboxResolver: DmInboxRelayResolver,
|
||||
appScope: CoroutineScope,
|
||||
torStatus: com.vitorpamplona.amethyst.commons.tor.TorServiceStatus,
|
||||
onShowComposeDialog: () -> Unit,
|
||||
@@ -1434,8 +1508,8 @@ fun MainContent(
|
||||
}
|
||||
|
||||
val iAccount =
|
||||
remember(account, localCache, relayManager, dmSendTracker, accountRelays) {
|
||||
DesktopIAccount(account, localCache, relayManager, dmSendTracker, scope, accountRelays)
|
||||
remember(account, localCache, relayManager, dmSendTracker, accountRelays, dmInboxResolver) {
|
||||
DesktopIAccount(account, localCache, relayManager, dmSendTracker, scope, accountRelays, dmInboxResolver)
|
||||
}
|
||||
|
||||
// When iAccount is replaced (account switch), the previous WoTService's
|
||||
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
/*
|
||||
* 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.desktop.auth
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.auth.AuthApprovalDecision
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.auth.AuthApprovalPolicy
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.auth.AuthApprovalScope
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.auth.AuthApprovalStore
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.auth.PendingAuthApproval
|
||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||
import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.collections.immutable.PersistentMap
|
||||
import kotlinx.collections.immutable.persistentMapOf
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
/**
|
||||
* Desktop NIP-42 AUTH wiring.
|
||||
*
|
||||
* Today the desktop has NO AUTH wiring — relays demanding AUTH from desktop
|
||||
* users get silently ignored. This coordinator closes that gap, but does it
|
||||
* the security-conscious way:
|
||||
*
|
||||
* - **Tier 1 (auto-allow):** the relay is in the active account's NIP-17 DM
|
||||
* inbox set (kind:10050). Sign immediately, no prompt.
|
||||
* - **Tier 2 (prompt):** anything else. Surface a [PendingAuthApproval] on
|
||||
* [pendingApprovals]; the (forthcoming) inline AUTH banner reads from
|
||||
* there and calls [resolve] with the user's `[Once] [Always] [Never]`
|
||||
* pick.
|
||||
*
|
||||
* **Until the banner UI lands**, tier-2 approvals accumulate in
|
||||
* [pendingApprovals] but nothing resolves them — so tier-2 relays don't get
|
||||
* an AUTH response. Behaviour-wise that's the same outcome as the pre-this-
|
||||
* commit world (no AUTH at all). The improvement is tier-1: own DM-inbox
|
||||
* relays now AUTH automatically.
|
||||
*
|
||||
* Persisted `ALWAYS` / `BLOCKED` decisions are scoped per-account via
|
||||
* [PreferencesAuthApprovalStore].
|
||||
*
|
||||
* Lifecycle: bind to [AccountState] from the host (Main.kt) — call [onLogin]
|
||||
* when an account becomes [AccountState.LoggedIn] and [onLogout] on logout /
|
||||
* account-switch. Each call tears down the prior [RelayAuthenticator] and
|
||||
* cancels any pending deferreds.
|
||||
*/
|
||||
class DesktopAuthCoordinator(
|
||||
private val relayManager: RelayConnectionManager,
|
||||
private val localCache: DesktopLocalCache,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
private val lock = Any()
|
||||
|
||||
@Volatile
|
||||
private var active: ActiveAuth? = null
|
||||
|
||||
private val _pendingApprovals = MutableStateFlow<PersistentMap<NormalizedRelayUrl, PendingAuthApproval>>(persistentMapOf())
|
||||
|
||||
/**
|
||||
* Tier-2 AUTH challenges awaiting the user's `[Once] [Always] [Never]`
|
||||
* decision. The banner UI subscribes and calls [resolve] to settle each.
|
||||
*/
|
||||
val pendingApprovals: StateFlow<PersistentMap<NormalizedRelayUrl, PendingAuthApproval>> = _pendingApprovals.asStateFlow()
|
||||
|
||||
/** Wire AUTH for a newly logged-in account. Idempotent. */
|
||||
fun onLogin(account: AccountState.LoggedIn) {
|
||||
synchronized(lock) {
|
||||
if (active?.pubKeyHex == account.pubKeyHex) return
|
||||
tearDownLocked()
|
||||
val store = PreferencesAuthApprovalStore(account.pubKeyHex)
|
||||
val policy =
|
||||
AuthApprovalPolicy(
|
||||
selfApprovedRelays = { selfApprovedRelaysFor(account.pubKeyHex) },
|
||||
store = store,
|
||||
onPromptRequired = { pending ->
|
||||
_pendingApprovals.update { it.put(pending.relayUrl, pending) }
|
||||
},
|
||||
)
|
||||
val authenticator =
|
||||
RelayAuthenticator(
|
||||
client = relayManager.client,
|
||||
scope = scope,
|
||||
signWithAllLoggedInUsers = { relayUrl, template ->
|
||||
val signed = signWithPolicy(account, relayUrl, template, policy)
|
||||
signed?.let { listOf(it) } ?: emptyList()
|
||||
},
|
||||
)
|
||||
active = ActiveAuth(account.pubKeyHex, store, policy, authenticator)
|
||||
Log.d("DesktopAuthCoordinator") { "AUTH wired for ${account.pubKeyHex.take(8)}" }
|
||||
}
|
||||
}
|
||||
|
||||
/** Tear down AUTH on logout / account switch. */
|
||||
fun onLogout() {
|
||||
synchronized(lock) { tearDownLocked() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a tier-2 [PendingAuthApproval] from the banner UI.
|
||||
*
|
||||
* Removes the entry from [pendingApprovals] before completing the
|
||||
* deferred, so the suspended signer wakes up exactly once.
|
||||
*/
|
||||
fun resolve(
|
||||
relayUrl: NormalizedRelayUrl,
|
||||
scope: AuthApprovalScope,
|
||||
) {
|
||||
val pending = _pendingApprovals.value[relayUrl] ?: return
|
||||
_pendingApprovals.update { it.remove(relayUrl) }
|
||||
pending.decision.complete(scope)
|
||||
}
|
||||
|
||||
private fun tearDownLocked() {
|
||||
val prev = active ?: return
|
||||
prev.authenticator.destroy()
|
||||
// Cancel any in-flight tier-2 prompts so suspended signers wake up.
|
||||
_pendingApprovals.value.values.forEach { it.decision.complete(AuthApprovalScope.BLOCKED) }
|
||||
_pendingApprovals.value = persistentMapOf()
|
||||
active = null
|
||||
}
|
||||
|
||||
private fun selfApprovedRelaysFor(pubKeyHex: String): Set<NormalizedRelayUrl> {
|
||||
// Tier-1 = the user's own NIP-17 DM-inbox (kind:10050). Strict
|
||||
// by design — write/read relays (NIP-65 kind:10002) are NOT included,
|
||||
// because the user may have read-only relays they don't intend to
|
||||
// identify themselves to via AUTH.
|
||||
//
|
||||
// MUST use dmInboxRelaysStrict (kind:10050 only) rather than the
|
||||
// lenient dmInboxRelays helper, which falls back to NIP-65 read
|
||||
// relays and would silently expand tier-1 to include every relay
|
||||
// in the user's outbox. That defeats the tier-2 prompt for any
|
||||
// relay in the user's normal read set.
|
||||
val user = localCache.getOrCreateUser(pubKeyHex)
|
||||
return user.dmInboxRelaysStrict()?.toSet() ?: emptySet()
|
||||
}
|
||||
|
||||
private suspend fun signWithPolicy(
|
||||
account: AccountState.LoggedIn,
|
||||
relayUrl: NormalizedRelayUrl,
|
||||
template: EventTemplate<RelayAuthEvent>,
|
||||
policy: AuthApprovalPolicy,
|
||||
): RelayAuthEvent? =
|
||||
when (val decision = policy.classify(relayUrl)) {
|
||||
AuthApprovalDecision.Allow -> account.signer.sign(template)
|
||||
AuthApprovalDecision.Block -> null
|
||||
is AuthApprovalDecision.Pending -> {
|
||||
val resolved = decision.pending.await()
|
||||
if (resolved != AuthApprovalScope.ONCE) {
|
||||
policy.recordDecision(relayUrl, resolved)
|
||||
}
|
||||
if (resolved == AuthApprovalScope.BLOCKED) null else account.signer.sign(template)
|
||||
}
|
||||
}
|
||||
|
||||
private data class ActiveAuth(
|
||||
val pubKeyHex: String,
|
||||
val store: AuthApprovalStore,
|
||||
val policy: AuthApprovalPolicy,
|
||||
val authenticator: RelayAuthenticator,
|
||||
)
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* 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.desktop.auth
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.auth.AuthApprovalScope
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.auth.AuthApprovalStore
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import java.util.prefs.Preferences
|
||||
|
||||
/**
|
||||
* Desktop persistence backend for [AuthApprovalStore] using
|
||||
* `java.util.prefs.Preferences`.
|
||||
*
|
||||
* Trade-offs vs the full SQLite `auth_approvals` table proposed in the plan:
|
||||
*
|
||||
* - **Pro**: zero new dependencies, no schema migration, already proven for
|
||||
* other small desktop settings (per memory: `SearchHistoryStore`,
|
||||
* `DesktopPreferences`).
|
||||
* - **Con**: flat key/value, no transactions, no native TTL. Acceptable here
|
||||
* because the approval set per account is small (≪50 relays for any user)
|
||||
* and the read pattern is "look up before signing AUTH" — once per relay
|
||||
* per session, easily cached in memory by the [AuthApprovalPolicy] layer.
|
||||
*
|
||||
* Per-account scoping is by Preferences node: each account gets its own node
|
||||
* at `/com/vitorpamplona/amethyst/desktop/auth/<full-pubkey>/`. Logout calls
|
||||
* [clear] which `removeNode()`s the per-account subtree.
|
||||
*
|
||||
* `ONCE` scope is never persisted — that's the in-memory contract enforced
|
||||
* by the [AuthApprovalStore] interface. This implementation only writes
|
||||
* `ALWAYS` and `BLOCKED`.
|
||||
*/
|
||||
class PreferencesAuthApprovalStore(
|
||||
private val accountPubKeyHex: String,
|
||||
) : AuthApprovalStore {
|
||||
private val node: Preferences =
|
||||
Preferences.userRoot().node(
|
||||
"/com/vitorpamplona/amethyst/desktop/auth/$accountPubKeyHex",
|
||||
)
|
||||
|
||||
override suspend fun getScope(relayUrl: NormalizedRelayUrl): AuthApprovalScope? {
|
||||
val raw = node.get(relayUrl.url, null) ?: return null
|
||||
return runCatching { AuthApprovalScope.valueOf(raw) }.getOrNull()
|
||||
}
|
||||
|
||||
override suspend fun setScope(
|
||||
relayUrl: NormalizedRelayUrl,
|
||||
scope: AuthApprovalScope,
|
||||
) {
|
||||
if (scope == AuthApprovalScope.ONCE) {
|
||||
// ONCE is the session-only contract from AuthApprovalStore — must
|
||||
// not touch the persistent store, otherwise it would silently
|
||||
// upgrade to "until next clear()".
|
||||
return
|
||||
}
|
||||
node.put(relayUrl.url, scope.name)
|
||||
node.flush()
|
||||
}
|
||||
|
||||
override suspend fun clear() {
|
||||
node.removeNode()
|
||||
node.flush()
|
||||
}
|
||||
}
|
||||
+67
-38
@@ -31,10 +31,13 @@ import com.vitorpamplona.amethyst.commons.model.nip51Lists.OldBookmarkListState
|
||||
import com.vitorpamplona.amethyst.commons.model.nip65RelayList.Nip65RelayListRepository
|
||||
import com.vitorpamplona.amethyst.commons.model.nip65RelayList.Nip65RelayListState
|
||||
import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.DmInboxRelayResolver
|
||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||
import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager
|
||||
import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
@@ -71,6 +74,7 @@ class DesktopIAccount(
|
||||
val dmSendTracker: DmSendTracker,
|
||||
private val scope: CoroutineScope,
|
||||
private val accountRelays: DesktopAccountRelays? = null,
|
||||
val dmInboxResolver: DmInboxRelayResolver? = null,
|
||||
) : IAccount {
|
||||
override val signer: NostrSigner = NostrSignerWithClientTag(accountState.signer, CLIENT_TAG_NAME)
|
||||
|
||||
@@ -187,7 +191,8 @@ class DesktopIAccount(
|
||||
override suspend fun sendNip17PrivateMessage(template: EventTemplate<ChatMessageEvent>) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val result = NIP17Factory().createMessageNIP17(template, signer)
|
||||
val hints = recipientRelayHints(template.tags)
|
||||
val result = NIP17Factory().createMessageNIP17(template, signer, recipientRelayHints = { hints[it] })
|
||||
|
||||
// Optimistic local add — use the inner ChatMessageEvent, not the wraps
|
||||
val innerMsg = result.msg as ChatMessageEvent
|
||||
@@ -197,18 +202,7 @@ class DesktopIAccount(
|
||||
val batch =
|
||||
result.wraps.map { wrap ->
|
||||
val recipientKey = wrap.recipientPubKey()
|
||||
val targetRelays =
|
||||
if (recipientKey != null) {
|
||||
val dmRelays =
|
||||
localCache
|
||||
.getOrCreateUser(recipientKey)
|
||||
.dmInboxRelays()
|
||||
?.toSet()
|
||||
dmRelays?.ifEmpty { null }
|
||||
?: relayManager.connectedRelays.value
|
||||
} else {
|
||||
relayManager.connectedRelays.value
|
||||
}
|
||||
val targetRelays = resolveDmInboxRelaysStrict(recipientKey)
|
||||
wrap to targetRelays
|
||||
}
|
||||
|
||||
@@ -218,7 +212,8 @@ class DesktopIAccount(
|
||||
override suspend fun sendNip17EncryptedFile(template: EventTemplate<ChatMessageEncryptedFileHeaderEvent>) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val result = NIP17Factory().createEncryptedFileNIP17(template, signer)
|
||||
val hints = recipientRelayHints(template.tags)
|
||||
val result = NIP17Factory().createEncryptedFileNIP17(template, signer, recipientRelayHints = { hints[it] })
|
||||
|
||||
// Optimistic local add
|
||||
val innerEvent = result.msg as ChatMessageEncryptedFileHeaderEvent
|
||||
@@ -228,18 +223,7 @@ class DesktopIAccount(
|
||||
val batch =
|
||||
result.wraps.map { wrap ->
|
||||
val recipientKey = wrap.recipientPubKey()
|
||||
val targetRelays =
|
||||
if (recipientKey != null) {
|
||||
val dmRelays =
|
||||
localCache
|
||||
.getOrCreateUser(recipientKey)
|
||||
.dmInboxRelays()
|
||||
?.toSet()
|
||||
dmRelays?.ifEmpty { null }
|
||||
?: relayManager.connectedRelays.value
|
||||
} else {
|
||||
relayManager.connectedRelays.value
|
||||
}
|
||||
val targetRelays = resolveDmInboxRelaysStrict(recipientKey)
|
||||
wrap to targetRelays
|
||||
}
|
||||
|
||||
@@ -250,24 +234,69 @@ class DesktopIAccount(
|
||||
val batch =
|
||||
wraps.map { wrap ->
|
||||
val recipientKey = wrap.recipientPubKey()
|
||||
val targetRelays =
|
||||
if (recipientKey != null) {
|
||||
val dmRelays =
|
||||
localCache
|
||||
.getOrCreateUser(recipientKey)
|
||||
.dmInboxRelays()
|
||||
?.toSet()
|
||||
dmRelays?.ifEmpty { null }
|
||||
?: relayManager.connectedRelays.value
|
||||
} else {
|
||||
relayManager.connectedRelays.value
|
||||
}
|
||||
val targetRelays = resolveDmInboxRelaysStrict(recipientKey)
|
||||
wrap to targetRelays
|
||||
}
|
||||
|
||||
scope.launch { dmSendTracker.sendBatch(batch) }
|
||||
}
|
||||
|
||||
/**
|
||||
* NIP-17 inbox-relay resolution, strict variant — no fallback to the
|
||||
* user's connected relays.
|
||||
*
|
||||
* Per NIP-17 §Publishing, a gift wrap MUST only land on relays advertised
|
||||
* in the recipient's kind:10050. Falling back to the sender's connected
|
||||
* relays when 10050 is missing publishes the wrap to relays the recipient
|
||||
* does NOT consult — at best the message never arrives, at worst it leaks
|
||||
* the conversation metadata (recipient pubkey + send timestamp) to relays
|
||||
* outside the recipient's chosen inbox.
|
||||
*
|
||||
* Three-layer lookup when a [dmInboxResolver] is injected (default in
|
||||
* Main.kt):
|
||||
* 1. LocalCache hit (fast, no I/O)
|
||||
* 2. Resolver's in-memory LRU cache
|
||||
* 3. Curated indexer fan-out via an unauthenticated NostrClient
|
||||
*
|
||||
* Without a resolver (legacy / tests), falls back to LocalCache-only.
|
||||
*
|
||||
* Empty result means the wrap will not be sent; [DmSendTracker.sendBatch]
|
||||
* surfaces this as a "No relays available" failure to the user.
|
||||
*/
|
||||
private suspend fun resolveDmInboxRelaysStrict(recipientKey: HexKey?): Set<NormalizedRelayUrl> = resolveDmInboxRelaysStrictOrdered(recipientKey).toSet()
|
||||
|
||||
/**
|
||||
* Ordered variant of [resolveDmInboxRelaysStrict]. Preserves the relay
|
||||
* order declared in the recipient's kind:10050 so the first element is the
|
||||
* recipient's *primary* DM inbox — used as the NIP-17 gift-wrap `p`-tag
|
||||
* relay hint. The unordered [resolveDmInboxRelaysStrict] derives from this.
|
||||
*/
|
||||
private suspend fun resolveDmInboxRelaysStrictOrdered(recipientKey: HexKey?): List<NormalizedRelayUrl> {
|
||||
if (recipientKey == null) return emptyList()
|
||||
val resolver = dmInboxResolver
|
||||
return if (resolver != null) {
|
||||
resolver.resolve(recipientKey)
|
||||
} else {
|
||||
localCache
|
||||
.getOrCreateUser(recipientKey)
|
||||
.dmInboxRelaysStrict()
|
||||
?.ifEmpty { null }
|
||||
?: emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-recipient primary DM-inbox relay, keyed by recipient pubkey, for the
|
||||
* NIP-17 gift-wrap `p`-tag hint (`["p", <pubkey>, <primary-relay>]`). Built
|
||||
* from the recipient `p` tags on the outgoing message template. A recipient
|
||||
* with no resolvable kind:10050 maps to `null`, which keeps the historical
|
||||
* 2-element `p` tag for that recipient.
|
||||
*/
|
||||
private suspend fun recipientRelayHints(tags: Array<Array<String>>): Map<HexKey, NormalizedRelayUrl?> {
|
||||
val recipients = tags.mapNotNull { if (it.size >= 2 && it[0] == "p") it[1] else null }.toSet()
|
||||
return recipients.associateWith { resolveDmInboxRelaysStrictOrdered(it).firstOrNull() }
|
||||
}
|
||||
|
||||
private fun addEventToChatroom(
|
||||
event: com.vitorpamplona.quartz.nip01Core.core.Event,
|
||||
roomKey: com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey,
|
||||
|
||||
+10
-11
@@ -27,7 +27,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* Filter builders for DM subscriptions on desktop.
|
||||
@@ -116,20 +115,18 @@ object FilterDMs {
|
||||
* Creates a filter for NIP-59 gift-wrapped events TO the user.
|
||||
* Gift wraps (kind 1059) contain encrypted NIP-17 DMs.
|
||||
*
|
||||
* The since is adjusted back by 2 days because gift wrap created_at
|
||||
* timestamps are randomized within a 2-day window for privacy.
|
||||
* No `since` is exposed: per NIP-17, seal (kind 13) and gift wrap (kind 1059)
|
||||
* `created_at` are randomized up to 2 days in the past for privacy. Any
|
||||
* `since` window applied here silently drops wraps whose randomized
|
||||
* timestamp predates it — losing real DMs and suppressing the unread badge.
|
||||
* DMs are low-volume, so subscribing without a `since` is safe.
|
||||
*
|
||||
* @param userPubKeyHex The user's public key (hex)
|
||||
* @param since Optional since timestamp (will be adjusted -2 days)
|
||||
*/
|
||||
fun giftWrapsToMe(
|
||||
userPubKeyHex: HexKey,
|
||||
since: Long? = null,
|
||||
): Filter =
|
||||
fun giftWrapsToMe(userPubKeyHex: HexKey): Filter =
|
||||
Filter(
|
||||
kinds = listOf(GiftWrapEvent.KIND, EphemeralGiftWrapEvent.KIND),
|
||||
tags = mapOf("p" to listOf(userPubKeyHex)),
|
||||
since = since?.minus(TimeUtils.twoDays()),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -184,11 +181,13 @@ fun createNip04DmOutboxSubscription(
|
||||
/**
|
||||
* Creates a subscription config for NIP-59 gift-wrapped DMs TO the user.
|
||||
* Subscribes on DM/inbox relays.
|
||||
*
|
||||
* No `since` parameter: see [FilterDMs.giftWrapsToMe] for why NIP-17 wraps
|
||||
* cannot use a `since` window without dropping legitimate messages.
|
||||
*/
|
||||
fun createGiftWrapSubscription(
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
userPubKeyHex: HexKey,
|
||||
since: Long? = null,
|
||||
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
|
||||
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
|
||||
): SubscriptionConfig? {
|
||||
@@ -196,7 +195,7 @@ fun createGiftWrapSubscription(
|
||||
|
||||
return SubscriptionConfig(
|
||||
subId = generateSubId("giftwrap-${userPubKeyHex.take(8)}"),
|
||||
filters = listOf(FilterDMs.giftWrapsToMe(userPubKeyHex, since)),
|
||||
filters = listOf(FilterDMs.giftWrapsToMe(userPubKeyHex)),
|
||||
relays = relays,
|
||||
onEvent = onEvent,
|
||||
onEose = onEose,
|
||||
|
||||
+18
-2
@@ -246,7 +246,15 @@ private fun CompactMessagesContent(
|
||||
}
|
||||
val messageState =
|
||||
remember(currentRoom) {
|
||||
ChatNewMessageState(account, cacheProvider, scope)
|
||||
ChatNewMessageState(
|
||||
account,
|
||||
cacheProvider,
|
||||
scope,
|
||||
dmInboxResolver =
|
||||
(account as? DesktopIAccount)?.dmInboxResolver?.let { resolver ->
|
||||
{ hexKey -> resolver.resolve(hexKey) }
|
||||
},
|
||||
)
|
||||
}
|
||||
val broadcastStatus =
|
||||
if (account is DesktopIAccount) {
|
||||
@@ -342,7 +350,15 @@ private fun SplitMessagesContent(
|
||||
}
|
||||
val messageState =
|
||||
remember(currentRoom) {
|
||||
ChatNewMessageState(account, cacheProvider, scope)
|
||||
ChatNewMessageState(
|
||||
account,
|
||||
cacheProvider,
|
||||
scope,
|
||||
dmInboxResolver =
|
||||
(account as? DesktopIAccount)?.dmInboxResolver?.let { resolver ->
|
||||
{ hexKey -> resolver.resolve(hexKey) }
|
||||
},
|
||||
)
|
||||
}
|
||||
val broadcastStatus =
|
||||
if (account is DesktopIAccount) {
|
||||
|
||||
+8
-2
@@ -222,8 +222,13 @@ fun NewDmDialog(
|
||||
val userResults =
|
||||
bech32Results.filterIsInstance<SearchResult.UserResult>()
|
||||
items(userResults) { result ->
|
||||
// getOrCreateUser (not getUserIfExists): a DM recipient is
|
||||
// identified purely by pubkey, so a valid npub must be
|
||||
// selectable even when we have no metadata (kind:0) cached
|
||||
// for them yet. Otherwise pasting the npub of anyone the
|
||||
// cache hasn't seen renders a dead, non-clickable row.
|
||||
val user =
|
||||
cacheProvider.getUserIfExists(result.pubKeyHex)
|
||||
cacheProvider.getOrCreateUser(result.pubKeyHex)
|
||||
if (user != null) {
|
||||
UserSearchCard(
|
||||
user = user,
|
||||
@@ -231,7 +236,8 @@ fun NewDmDialog(
|
||||
modifier = selectedModifier(isSelected(user)),
|
||||
)
|
||||
} else {
|
||||
// Minimal card for unloaded users
|
||||
// Only reached if the key itself can't be resolved
|
||||
// (malformed) — show a non-selectable hint.
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
|
||||
@@ -0,0 +1,703 @@
|
||||
---
|
||||
title: Desktop DM Reliability
|
||||
type: feat
|
||||
status: active
|
||||
date: 2026-06-10
|
||||
origin: docs/brainstorms/2026-06-10-desktop-dm-reliability-brainstorm.md
|
||||
---
|
||||
|
||||
# ✨ Desktop DM Reliability
|
||||
|
||||
## Overview
|
||||
|
||||
Two-track program to close the reliability gap between Amethyst Desktop's NIP-17 DMs and the reference clients **wisp.mobile** (Kotlin/Compose, github.com/barrydeen/wisp) and **nospeak.chat** (SvelteKit, github.com/psic4t/nospeak):
|
||||
|
||||
1. **Track A — Reliability plumbing.** Port the publish-path, AUTH, subscription, and discovery patterns that make wisp/nospeak feel reliable. Most are small surgical fixes; together they close the "messages silently disappear" failure modes.
|
||||
2. **Track B — Bunker speed.** Spec + implement a NIP-46 `get_conversation_keys` batch RPC so bunker users decrypt N gift wraps in 1 round-trip instead of N — Vitor's stated direction, replacing the rejected NIP-4E path.
|
||||
|
||||
Carried forward from brainstorm:
|
||||
- Explicitly out of scope: NIP-4E adoption, NIP-29 group chats, NIP-04 cleanup, new DM UX features (typing/read receipts, attachments redesign)
|
||||
- NIP-04 stays visible with `legacy` badge (brainstorm Q1)
|
||||
- Bunker SEND latency shown as live progress (brainstorm Q3) — see Deepening §6
|
||||
- Tier-2 AUTH consent = inline chat-column banner `[Once] [Always] [Never]` (brainstorm Q5)
|
||||
- Self-copy wrap → remote DM relays only, not local relay (brainstorm Q7)
|
||||
- Desktop-first; Android inherits `commons/` changes (brainstorm Q8)
|
||||
|
||||
## Deepening Synthesis (2026-06-10)
|
||||
|
||||
Eleven parallel review passes (skills + reviewers) revealed substantial corrections. **Six P0 security blockers, ~30% scope compression, plus architectural fixes.** Apply BEFORE `/ce:work`.
|
||||
|
||||
### Desktop-only scope (2026-06-10 amendment)
|
||||
|
||||
**This plan ships desktop-only.** Android may incidentally benefit from `commons/` and `quartz/` changes (it shares those modules), but no Android-specific code changes, no Android UI work, no Android-side audits, no Android tests in acceptance. If a `commons/` change has Android-visible behavior change, that's a side effect — not a goal — and we don't gate this plan on Android validation.
|
||||
|
||||
**Removed from scope:**
|
||||
- ~~Android `AccountGiftWrapsEoseManager.kt:55-61` `since` fix~~ — defer to Android pass
|
||||
- ~~Android `Account.kt:1156-1167` security-fix audit~~ — same Android pass
|
||||
- "Android inherits" framing in acceptance criteria
|
||||
- Cross-platform `User.dmInboxRelays()` audit beyond desktop callers (still touch the commons helper; just don't validate Android consumers)
|
||||
- Splitting `RetryQueueCoordinator` into commons-interface + desktop-impl — desktop-only, single file in `desktopApp/`
|
||||
- Splitting `AccountAuthApprovals` for Android inheritance — keep desktop-side if simpler; if natural to put in commons it stays there but no Android UI ships
|
||||
|
||||
### Phase restructure (simplicity + scope)
|
||||
- **R1 collapses to verification + KDoc.** Desktop already passes no `since` (`DesktopRelaySubscriptionsCoordinator.kt:345`). Add a regression test confirming wraps with `created_at = now - 1.5d` arrive; drop the `since` parameter from `FilterDMs.giftWrapsToMe` to lock the invariant. No longer a phase — one item under Phase 2.
|
||||
- **Cut R6 (proactive window-focus re-AUTH)** as a separate coordinator. Replace with: use Compose-native `LocalWindowInfo.isWindowFocused` + `snapshotFlow`; let AUTH heal *lazily* on next `auth-required:` via the existing `RelayAuthenticator.checkAuthResults → syncFilters` path. **The plan's "force AUTH via benign kind:0 sub" trick is wrong** — most relays only AUTH-challenge on restricted REQs.
|
||||
- **R10 (self-copy)**: already half-implemented via `BaseDMGroupEvent.groupMembers() = recipients.plus(pubKey)`. On desktop, port the pre-consume + alias-note pattern (Android has it; we replicate the *technique* in `DesktopIAccount`, not import the Android code) + route self-wrap to `account.dmInboxRelays()`, not `connectedRelays`.
|
||||
- **R11 (relay hint on p-tag)**: a one-line change in `GiftWrapEvent.create:117-122`; keep but no separate sub-phase.
|
||||
- **R12**: drop as standalone scope item — compress to a single regression test under Phase 5.
|
||||
- **Phase 6 decouple**: spec PR + bunker batch RPC has external coordination dependencies (nsec.app/Amber/Keychat). Spin into its own plan file; Phases 1–5 ship independently.
|
||||
- **Manual relay-entry dialog → simple error message** (Phase 4): replace `DmInboxRelayMissingDialog` UI with a Snackbar "Can't find DM relays for `<name>`. They need to publish their NIP-17 inbox first." Only re-introduce manual entry if security validation requirements (F-02) are met.
|
||||
|
||||
### Cross-cutting corrections
|
||||
|
||||
**P0 security blockers (must fix before merge):**
|
||||
|
||||
| ID | Issue | Fix |
|
||||
|---|---|---|
|
||||
| F-01 | Indexer fan-out uses authenticated client → identity-key leak to `purplepag.es` etc. | Open a dedicated `NostrClient` with `RelayAuthenticator` NOT attached; use it for all `RecipientRelayFetcher` calls. Add unit test: indexer sends AUTH → client sends NO AUTH event. |
|
||||
| F-02 | Manual relay-entry has no URL validation | If we ship manual entry at all: hard-reject non-`wss://`; Levenshtein-1 typosquat warning vs curated set; "this DM will be visible to this relay operator" confirmation interstitial. **Default: drop the dialog entirely** per simplicity reviewer; use a Snackbar error. |
|
||||
| F-03 | Current `RelayAuthenticator.authenticate()` (`quartz/.../auth/RelayAuthenticator.kt:81-94`) auto-signs **every** challenge with no rate limit + no tier check + across **all** logged-in accounts (multi-account linkage leak) | `shouldAutoAuth` must **REPLACE** the unconditional path, not be added in front. Per-account scoping. Rate-limit: max 1 AUTH/relay/60s, max M AUTHs/min account-wide. Default = do NOT sign unless tier-1. |
|
||||
| F-04 | Retry queue stores plaintext recipient pubkey + relay URL + timestamp + last_error → social graph leak via disk forensics | Account-delete purges `retry_queue WHERE account_pubkey = ?`; 24h hard TTL on `created_at`; verify directory perms 0700; or store under `DesktopAccountStorage` AES-GCM wrapper. |
|
||||
| F-07 | Plan says relay hint on "seal's p tag" — seal has NO p tag. NIP-17 spec puts hint on wrap (`["p", recipientPubkey, relay-url]`, GiftWrapEvent kind:1059). | Update plan to "wrap's p tag per NIP-17 spec"; regression test asserting it's there. (Security agent argued for rumor; but the rumor is encrypted, so other devices can't read the hint until AFTER decrypt — defeats its purpose. Spec is correct.) |
|
||||
| F-10 | NIP-46 batch RPC response untrusted | Spec PR mandates: `result.length == request.pubkeys.length`, positions match, MAC self-test on first decrypt, bunker echoes `request.id`. Client validates on every call. |
|
||||
|
||||
**Architecture corrections (move/rename, no behavior change):**
|
||||
|
||||
| Subject | Plan says | Correct |
|
||||
|---|---|---|
|
||||
| Indexer-relay set | `commons/.../relayClient/dm/` | `commons/defaults/` |
|
||||
| `relayClient/dm/` package | `dm/` | `nip17Dm/` (match siblings) |
|
||||
| `AccountAuthApprovals` ViewModel | `commons/.../viewmodels/` | `commons/.../relayClient/auth/` (colocated with feature) |
|
||||
| `ConversationKeyCache` | `commons/.../service/cache/` (path doesn't exist) | `quartz/.../nip46RemoteSigner/cache/` |
|
||||
| `shouldAutoAuth` tier classifier | Quartz `RelayAuthenticator` | `commons/.../relayClient/auth/AuthApprovalPolicy.kt`; Quartz takes a `Set<RelayUrl>` of pre-approved relays via existing `signWithAllLoggedInUsers` lambda seam |
|
||||
| `RetryQueueCoordinator` | `desktopApp/...` only | Split: `commons/.../service/RetryQueueCoordinator` (interface + no-op default for Android) + `desktopApp/.../SqliteRetryQueueCoordinator` (impl) |
|
||||
| AUTH state shape | `MutableStateFlow<Map<RelayUrl, RelayAuthStatus>>` (status is a mutable holder — won't emit on inner change) | `MutableStateFlow<PersistentMap<NormalizedRelayUrl, RelayAuthSnapshot>>` (immutable snapshot, identity changes on update) |
|
||||
| `authCompleted` event | New `SharedFlow`/`Channel` | Derive from `authStatusFlow.scan` transitions; no new primitive needed |
|
||||
| Path key | `pubkey8` (8-hex prefix, collision risk) | Full 64-hex pubkey; one-time rename migration |
|
||||
| SQLite tables location | `LocalRelayStore.kt` events.db | **Sibling `outbox.db`** with `PRAGMA synchronous = NORMAL` (events.db has `synchronous = OFF` — unsafe for "durable send" semantics) |
|
||||
|
||||
**Data-integrity schema rewrite** (apply to Phase 3 + Phase 2):
|
||||
|
||||
```sql
|
||||
-- ~/.amethyst/accounts/<FULL-pubkey>/outbox.db (sibling to events.db)
|
||||
-- synchronous = NORMAL; journal_mode = WAL; foreign_keys = OFF
|
||||
|
||||
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
||||
-- bind file to account: INSERT meta('account_pubkey', '<full hex>')
|
||||
|
||||
CREATE TABLE auth_approvals (
|
||||
account_pubkey TEXT NOT NULL,
|
||||
relay_url TEXT NOT NULL,
|
||||
scope TEXT NOT NULL CHECK (scope IN ('always','blocked')),
|
||||
granted_at INTEGER NOT NULL,
|
||||
expires_at INTEGER,
|
||||
PRIMARY KEY (account_pubkey, relay_url),
|
||||
CHECK (length(account_pubkey) = 64),
|
||||
CHECK (relay_url LIKE 'wss://%' OR relay_url LIKE 'ws://%')
|
||||
) WITHOUT ROWID;
|
||||
CREATE INDEX auth_approvals_expiry ON auth_approvals(expires_at) WHERE expires_at IS NOT NULL;
|
||||
|
||||
CREATE TABLE retry_queue (
|
||||
account_pubkey TEXT NOT NULL,
|
||||
gift_wrap_id TEXT NOT NULL,
|
||||
relay_url TEXT NOT NULL,
|
||||
rumor_id TEXT NOT NULL,
|
||||
event_json TEXT NOT NULL,
|
||||
attempt INTEGER NOT NULL DEFAULT 0,
|
||||
max_attempts INTEGER NOT NULL DEFAULT 12, -- raised from 8; backoff seq 1,2,4,8,16,32,64,128,256,512,600,600s
|
||||
next_attempt_at INTEGER NOT NULL,
|
||||
last_error TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (account_pubkey, gift_wrap_id, relay_url),
|
||||
CHECK (length(account_pubkey) = 64),
|
||||
CHECK (length(gift_wrap_id) = 64),
|
||||
CHECK (length(rumor_id) = 64),
|
||||
CHECK (attempt >= 0 AND attempt <= max_attempts),
|
||||
CHECK (max_attempts > 0 AND max_attempts <= 32),
|
||||
CHECK (length(event_json) < 200000),
|
||||
CHECK (relay_url LIKE 'wss://%' OR relay_url LIKE 'ws://%')
|
||||
) WITHOUT ROWID;
|
||||
CREATE INDEX retry_queue_due ON retry_queue(account_pubkey, next_attempt_at) WHERE attempt < max_attempts;
|
||||
```
|
||||
|
||||
`LocalRelayMaintenance.kt` must purge expired AUTH approvals + dead-letter retry_queue rows older than 30d.
|
||||
|
||||
**Performance corrections (apply throughout):**
|
||||
|
||||
- **Per-rumor `StateFlow`, not global map** (Phase 3). Replace `MutableStateFlow<Map<EventId, MessageDeliveryState>>` with `LargeCache<EventId, MutableStateFlow<MessageDeliveryState>>` — each bubble subscribes to its own flow; 50 visible bubbles × global map churn = ~75k unnecessary recompositions otherwise. Non-optional.
|
||||
- **Delete-bundler or periodic sweep for retry_queue** (Phase 3). Per-OK DELETEs fsync individually → 100 OKs = 100 transactions ≈ 5s of IO. Either add a 250ms bundler or have the coordinator sweep `attempt=0 AND created_at > 60s` every 30s.
|
||||
- **Bunker concurrency cap** (until Phase 6 ships). Wrap `NIP17Factory.createWraps`' `mapNotNullAsync` in a `Semaphore(4)` when `signer is NostrSignerRemote`. Today: 5 recipients × 2 calls = 10 concurrent bunker round-trips saturate the bunker socket.
|
||||
- **Indexer fan-out: first-result + pre-warm + persistent cache** (Phase 4). 8s `fetchAll` timeout → first-message latency 1-3s. Short-circuit on first non-empty result after 2s; pre-warm on conversation-list render; persist LRU cache across restart.
|
||||
- **Retry-queue triggers reactive, not 1s polling** (Phase 3). `Channel<Unit>(CONFLATED)` + `select { wake.onReceive(); onTimeout(nextDue) }`. Triggered by enqueue, authCompleted, network reconnect.
|
||||
- **`withTimeout(15s)` on `client.publish`** inside retry coordinator — wedged-socket protection.
|
||||
|
||||
**NIP-17 protocol corrections:**
|
||||
|
||||
- `User.dmInboxRelays()` at `commons/.../model/User.kt:115` **silently falls back to `inboxRelays()` (NIP-65 read marker)** when kind:10050 missing. This is the FIRST silent leak layer (before `DesktopIAccount.connectedRelays` fallback). Cross-platform bug. Fix: add `dmInboxRelaysStrict()` returning null on missing; audit all 4 callers. **Android `Account.kt:1156-1167` has the same fallback bug** — security fix applies cross-platform, NOT desktop-only.
|
||||
- `RecipientRelayFetcher.fetchRelayLists` returns kind:10050 + 10051 + 10002. `DmInboxRelayResolver` must use **`lists.dmInbox` only**, NOT `dmInboxOrFallback` (which falls through to NIP-65 read).
|
||||
- Shared `rumor.created_at` applies to rumor (kind 14) ONLY; seal (13) and wrap (1059) `created_at` MUST stay independently randomized per NIP-17 §"randomized up to 2 days back."
|
||||
- Drop `purplepag.es` from indexer set (not authoritative for kind:10050); add `purplerelay.com`. Curated set: `relay.nos.social`, `relay.damus.io`, `nos.lol`, `relay.nostr.band`, `purplerelay.com`.
|
||||
- Multi-indexer agreement: only trust a kind:10050 if ≥2 indexers return the same `event.id`. One compromised indexer can otherwise mass-redirect DMs.
|
||||
|
||||
**NIP-46 batch RPC corrections (Phase 6):**
|
||||
|
||||
- Method name: **`nip44_get_conversation_keys`** (consistent with `nip44_encrypt`/`nip44_decrypt`), not `get_conversation_keys`.
|
||||
- Request params: variadic `[pk1, pk2, ..., pkN]` (matches existing `nip44_encrypt` shape), NOT one stringified JSON blob.
|
||||
- Response: `result = JSON.stringify(["base64key1", ...])` (NIP-46 mandates single-string result). Errors = all-or-nothing; client falls back to per-call.
|
||||
- **Drop `result.capabilities` mechanism.** Use optimistic probe + per-bunker-pubkey negative-cache for the session. Adding capabilities expands the spec PR surface.
|
||||
- **2 sequential round-trips** (wrap layer keys → peel wraps → seal layer keys), not 1 parallel. Acceptance criterion: `≤4` round-trips for 200 wraps (100-pubkey spec cap).
|
||||
- Two-tier cache: NO cache for ephemeral wrap pubkeys (single-use), LRU 1000 for sender-identity seal keys. Cache key = `(selfPubkey, peerPubkey)`. Wipe on logout AND account-switch.
|
||||
- Bunker validation: assert `result.length == params.length`, position binding, MAC self-test on first decrypt.
|
||||
|
||||
### Open questions resolved during deepening
|
||||
|
||||
- Tier-3 silent drop → **dropped from design**. Only 2 tiers: auto / prompt (user picks `[Once|Always|Never]`).
|
||||
- Indexer-relay Settings UI → **dropped**. Hardcoded curated 5; override via system property `-Damethyst.dmIndexers=...` if needed.
|
||||
- Bunker progress "N of M" → **simplified to spinner + "Encrypting…"**. Counter requires extending `SigningOpState` with `current,total` fields; net UX win is small.
|
||||
- `account_pubkey` in SQLite schema → **kept** but constraint-bound to `meta('account_pubkey')` for defense-in-depth.
|
||||
- Conversation-key cache TTL → **session-only**, wipe on logout/switch, no disk.
|
||||
- Self-copy fallback → **own DM relays only**; no NIP-65 fallback (avoids the same leak class).
|
||||
|
||||
## Problem Statement
|
||||
|
||||
The Amethyst lead's prompt cited cross-client NIP-17 working reliably between nospeak.chat ↔ wisp.mobile and asked whether NIP-4E was the missing piece for bunker users. Research showed three things:
|
||||
|
||||
1. **Neither nospeak nor wisp uses bunker, and neither implements NIP-4E.** Their reliability comes from publish-path semantics + AUTH retry + idempotency.
|
||||
2. **Vitor (Amethyst maintainer) NACKs both NIP-4E PRs** (#1647, #2361) on five technical grounds — trial-decryption pathology, custody downgrade, no rotation story, nsec loses recovery, fragmentation across own devices. His counter is the NIP-46 batch RPC.
|
||||
3. **Amethyst Desktop's DM path has concrete reliability gaps** the survey identified:
|
||||
- AUTH-walled subscriptions die silently when the 3-try cap in `PoolEventOutboxState.Tries.isDone()` is hit
|
||||
- No persistent retry queue — `DmSendTracker` 10s-timeouts and resets
|
||||
- **Security bug**: `DesktopIAccount.sendNip17PrivateMessage` falls back to `connectedRelays.value` when recipient has no kind:10050 — leaks DM to non-inbox relays (violates the 2026-04-20 "block DM fallback" decision)
|
||||
- No bubble-level per-message delivery feedback (DmSendTracker is global to the composer, not keyed by EventId)
|
||||
- Android's kind:1059 sub still passes `since`, silently dropping wraps with randomized 2-day-past timestamps
|
||||
- 10050 lookup never fans out to indexer relays — if user's `LocalCache` doesn't have the recipient's 10050, it falls through to the buggy fallback above
|
||||
|
||||
Goal: send a DM and have visible confirmation it landed (or visible reason it didn't), survive bunker timeouts and AUTH-walled relays without silent drops, and let bunker users open a 200-wrap inbox in seconds instead of minutes.
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
Six phases. Phases 1–5 are Track A (Reliability), Phase 6 is Track B (Bunker speed, parallel). Each phase is independently shippable.
|
||||
|
||||
```
|
||||
Phase 1 — Receive resilience (R1) ┐
|
||||
Phase 2 — AUTH end-to-end (R2,R3,R5,R6) │ Track A
|
||||
Phase 3 — Send visibility (R7,R8 + bunker UI)│ (sequential)
|
||||
Phase 4 — Discovery hardening + security fix │
|
||||
(R4,R11 + 10050 fallback) │
|
||||
Phase 5 — Correctness (R9,R10,R12) ┘
|
||||
|
||||
Phase 6 — NIP-46 batch RPC (spec + impl) — Track B (parallel)
|
||||
```
|
||||
|
||||
## Technical Approach
|
||||
|
||||
### Architecture
|
||||
|
||||
The bulk of the change lives in `quartz/.../nip01Core/relay/client/` (publish path, AUTH state) and `commons/.../relayClient/` (filter assemblers, subscriptions). UI hooks are in `desktopApp/` (window focus listener, AUTH banner, bubble delivery indicator). Persistence lands in `desktopApp/.../desktop/relay/LocalRelayStore.kt` (existing SQLite, add tables).
|
||||
|
||||
**Key reusable existing infrastructure** (survey-discovered):
|
||||
- `RelayAuthenticator.kt` already calls `syncFilters` on AUTH success — re-publishes pending outbox + re-sends REQs. The path exists; we extend it.
|
||||
- `RecipientRelayFetcher` (`quartz/.../marmot/`) already fans out kind:10050/10002 lookups against a relay set — wire it into the DM send path.
|
||||
- `LocalRelayStore` (`~/.amethyst/accounts/<pubkey8>/events.db`) + `BasicBundledInsert` (250ms batching) — host the retry queue table.
|
||||
- `SigningState` pattern (shipped 2026-03-20) — reuse for bunker SEND progress UI.
|
||||
- `RelayInsertConfirmationCollector` — pattern for per-OK aggregation; lift into a per-message delivery `StateFlow`.
|
||||
- `geode/.../KtorRelayTest.kt:208,254` — mock Ktor relay with real `auth-required:` round-trip support. Reusable test infra.
|
||||
|
||||
### Phase 1 — Receive resilience (R1)
|
||||
|
||||
**Goal:** stop silently dropping inbound gift wraps.
|
||||
|
||||
**Scope:**
|
||||
- Verify desktop kind:1059 sub does not pass `since` (already true — `DesktopRelaySubscriptionsCoordinator.kt:345` passes nothing → `FilterDMs.giftWrapsToMe(userPubKeyHex)` default `since=null`).
|
||||
- Fix Android: `amethyst/.../AccountGiftWrapsEoseManager.kt:55-61` currently passes `since?.get(relay)?.time`. Replace with `since=null` (or relax to a wide window — e.g. `since - 30 days` for users that want bounded backfill).
|
||||
- Document the invariant in code: add a KDoc on `FilterDMs.giftWrapsToMe` explaining that seal timestamps are randomized up to 2 days back per NIP-17, so `since` is unsafe.
|
||||
- **Belt-and-braces**: change `FilterDMs.giftWrapsToMe` signature to drop the `since` parameter entirely. Forces all callers to be explicit.
|
||||
|
||||
**Files:**
|
||||
- `desktopApp/.../desktop/subscriptions/FilterDMs.kt:125-133` — remove `since` param
|
||||
- `amethyst/.../service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt:55-61` — drop `since` arg
|
||||
- `commons/.../relayClient/nip17Dm/FilterGiftWrapsToPubkey.kt:31-49` — same
|
||||
|
||||
**Acceptance:**
|
||||
- [ ] `FilterDMs.giftWrapsToMe` has no `since` parameter
|
||||
- [ ] All call sites updated; build green
|
||||
- [ ] Add unit test: subscribe to kind:1059 → server returns wrap with `created_at = now - 1.5 days` → wrap is received
|
||||
- [ ] Add KDoc explaining the NIP-17 randomized-timestamp invariant
|
||||
|
||||
### Phase 2 — AUTH end-to-end (R2, R3, R5, R6)
|
||||
|
||||
**Goal:** AUTH-walled relays never silently drop messages, user-consents to tier-2 relays once and remembers.
|
||||
|
||||
**Scope:**
|
||||
|
||||
1. **Lift the 3-try cap for `auth-required:` responses.** In `PoolEventOutboxState.kt:64-93 newResponse`, if the response message starts with `auth-required:`, do NOT count it toward the `Tries.isDone()` budget. The retry happens once `RelayAuthenticator.checkAuthResults` → `syncFilters` fires.
|
||||
|
||||
2. **Expose AUTH state as a public `StateFlow`.** Convert `RelayAuthenticator.authStatusCache: LargeCache<RelayUrl, RelayAuthStatus>` into a `MutableStateFlow<Map<RelayUrl, RelayAuthStatus>>` so UI can subscribe. Add a flow event `authCompleted(relayUrl)` for downstream wakeups (re-subscribe to kind:1059 explicitly, refresh retry queue, etc.).
|
||||
|
||||
3. **Tiered AUTH classification** in `RelayAuthenticator.shouldAutoAuth(relayUrl, account)`:
|
||||
- **Tier 1 (auto-sign):** relay is in `account.outboxRelays` OR `account.dmInboxRelays` (own NIP-17 inbox) OR was previously approved.
|
||||
- **Tier 2 (prompt):** relay is marked `dmDeliveryTarget` (set by `DesktopIAccount.sendNip17PrivateMessage` before publish — mirrors wisp's `markDmDeliveryTarget`) OR was never seen before.
|
||||
- **Tier 3 (silent drop):** anything else.
|
||||
- Persist tier-2 approvals: new SQLite table `auth_approvals(account_pubkey TEXT, relay_url TEXT, scope TEXT, expires_at INT)` in `LocalRelayStore`. Scope `"once"` is in-memory only; `"always"` rows persist.
|
||||
|
||||
4. **Inline AUTH banner UX** (desktop only — Android inherits classification, separate UI pass later):
|
||||
- Add `AccountAuthApprovals` ViewModel exposing `MutableStateFlow<List<PendingAuthApproval>>`.
|
||||
- In `ChatPane` (the right pane of `DesktopMessagesScreen`), render an inline banner above the message list when an approval is pending: `"<relay-url> requires authentication to deliver this message. [Once] [Always] [Never]"`.
|
||||
- `[Once]` → grants for the current session, no persistence. `[Always]` → writes `auth_approvals(scope="always")`. `[Never]` → writes `auth_approvals(scope="blocked")`, drops the wrap.
|
||||
- **Survey precedent**: check current behavior in Coracle, Damus, Primal, 0xchat before final mockup (open question Q5 in brainstorm).
|
||||
|
||||
5. **Re-subscribe to kind:1059 on `authCompleted`** (R3):
|
||||
- Wire `RelayAuthenticator.checkAuthResults` to emit `authCompleted(relayUrl)` after a successful AUTH-OK.
|
||||
- The desktop `DesktopRelaySubscriptionsCoordinator` already calls `syncFilters` indirectly via outbox sync. Verify the kind:1059 REQ is re-sent on that relay specifically. Add an integration test using `geode/KtorRelayTest.kt` pattern: client connects → relay sends `AUTH challenge` → client sends `AUTH event` → relay OKs → relay sends gift wrap → client receives it.
|
||||
|
||||
6. **Proactive re-AUTH on window focus** (R6 desktop-only):
|
||||
- Register `WindowFocusListener` on the `ComposeWindow` in `desktopApp/.../Main.kt:316`.
|
||||
- On `windowGainedFocus`, push to `MutableStateFlow<Boolean>(focused)`.
|
||||
- A coordinator (e.g. `DesktopFocusReAuthCoordinator` under `desktopApp/.../desktop/coordinators/`) collects this flow; on `false → true` transition, calls `relayManager.client.reconnect(true)` (forces reconnect of dead sockets) AND for each AUTHENTICATED relay older than 5 min, triggers a no-op AUTH challenge (subscribe to a benign `kind:0` filter on that relay — relays re-issue AUTH challenges on subsequent REQs).
|
||||
|
||||
**Files (touch list):**
|
||||
- `quartz/.../nip01Core/relay/client/pool/PoolEventOutboxState.kt` — `newResponse` skip `auth-required:` from try budget
|
||||
- `quartz/.../nip01Core/relay/client/auth/RelayAuthenticator.kt` — convert cache to StateFlow, add `authCompleted` event, add `shouldAutoAuth` tier logic
|
||||
- `quartz/.../nip01Core/relay/client/auth/RelayAuthStatus.kt` — extend with `lastAuthSuccessAt`
|
||||
- `desktopApp/.../desktop/relay/LocalRelayStore.kt` — new `auth_approvals` table + helpers
|
||||
- `desktopApp/.../desktop/Main.kt` — wire `WindowFocusListener`
|
||||
- `desktopApp/.../desktop/coordinators/DesktopFocusReAuthCoordinator.kt` — new file
|
||||
- `desktopApp/.../ui/chats/ChatPane.kt` — inline AUTH banner
|
||||
- `commons/.../viewmodels/AccountAuthApprovals.kt` — new ViewModel (commons, so Android inherits later)
|
||||
- `desktopApp/.../desktop/model/DesktopIAccount.kt:179-208` — call `relayPool.markDmDeliveryTarget(url)` before publish
|
||||
|
||||
**Acceptance:**
|
||||
- [ ] Test: mock relay returns `auth-required:` → client signs AUTH → publishes → message accepted on retry. Outbox tries counter not incremented by `auth-required:`.
|
||||
- [ ] Test: account has 3 outbox relays, 1 DM-inbox relay. Sending to a recipient whose DM relay is unknown → AUTH banner appears. `[Always]` persists across app restart.
|
||||
- [ ] Test: AUTH state flow emits `Authenticated(url)` when AUTH-OK received. Subscriber on kind:1059 receives a wrap delivered to that relay after AUTH.
|
||||
- [ ] Test: window unfocused → focused. Stale connections reconnect. Verify via mock-relay log.
|
||||
- [ ] No regression: nsec-local user can still send + receive in <1s end-to-end (no extra round-trips introduced).
|
||||
|
||||
### Phase 3 — Send-path visibility (R7, R8 + bunker progress UI)
|
||||
|
||||
**Goal:** every outgoing message has a visible delivery state per relay; no fire-and-forget; persistent retry across app restart.
|
||||
|
||||
**Scope:**
|
||||
|
||||
1. **Per-message delivery state** (R8):
|
||||
- Replace transient `DmSendTracker` with a persistent `MutableStateFlow<Map<EventId, MessageDeliveryState>>` keyed by **rumor id** (not gift-wrap id — multiple wraps share one rumor). `MessageDeliveryState` = `{relayDeliverySet: Map<RelayUrl, Confirmation>, sentAt, lastAttemptAt, error?}`.
|
||||
- In `quartz/.../accessories/RelayInsertConfirmationCollector.kt`, add a `collectByRumor(rumorId)` overload that aggregates OKs across all gift wraps for a rumor.
|
||||
- Surface in `DmConversationViewModel` so chat bubble subscribes per-bubble: `messageBubbleState(rumorId): StateFlow<MessageDeliveryState>`.
|
||||
- Bubble UI shows: `✓` (≥1 relay accepted), `✓✓` (all relays accepted), `⟳` (in flight), `⚠` (zero relays accepted after retry exhausted).
|
||||
|
||||
2. **Persistent retry queue** (R7):
|
||||
- New SQLite table in `LocalRelayStore`:
|
||||
```sql
|
||||
CREATE TABLE retry_queue (
|
||||
id TEXT PRIMARY KEY, -- gift_wrap_event_id || ":" || relay_url
|
||||
account_pubkey TEXT NOT NULL,
|
||||
rumor_id TEXT NOT NULL,
|
||||
event_json TEXT NOT NULL, -- serialized GiftWrapEvent
|
||||
relay_url TEXT NOT NULL,
|
||||
attempt INT NOT NULL DEFAULT 0,
|
||||
max_attempts INT NOT NULL DEFAULT 8,
|
||||
next_attempt_at INT NOT NULL,
|
||||
last_error TEXT,
|
||||
created_at INT NOT NULL
|
||||
);
|
||||
CREATE INDEX retry_queue_next_attempt ON retry_queue(next_attempt_at);
|
||||
```
|
||||
- `RetryQueueCoordinator` (new, `desktopApp/.../desktop/relay/RetryQueueCoordinator.kt`): on app start, scan retry_queue for `next_attempt_at < now`; for each, attempt `client.publish(event, listOf(relayUrl))`. On OK → delete row. On AUTH-required → wait for AUTH (Phase 2's flow already handles). On other rejection → exp-backoff `next_attempt_at = now + min(30s, 2^attempt seconds)`, `attempt++`. On `attempt >= max_attempts` → delete row, surface to UI as permanent failure.
|
||||
- Enqueue path: `DesktopIAccount.sendNip17PrivateMessage` calls `retryQueue.enqueue(wrap, relays)` BEFORE `client.publish` so we don't lose anything if the app dies between send and confirmation.
|
||||
- On OK from publish path → `retryQueue.confirm(wrapId, relayUrl)`.
|
||||
|
||||
3. **Bunker SEND progress UI** (brainstorm Q3 = "Live progress in send button"):
|
||||
- Reuse existing `SigningState` pattern from 2026-03-20 plan.
|
||||
- In `DesktopIAccount.sendNip17PrivateMessage`, wrap each `signer.sign()` call with progress emission: `SigningState.InProgress(current=2, total=5, label="Encrypting via remote signer")`.
|
||||
- Compose-side: send button shows linear progress + label when state is `InProgress`.
|
||||
- Only active when `signer is NostrSignerRemote`; nsec users see no change.
|
||||
|
||||
**Files:**
|
||||
- `quartz/.../accessories/RelayInsertConfirmationCollector.kt` — add `collectByRumor`
|
||||
- `commons/.../viewmodels/DmConversationViewModel.kt` — expose `messageBubbleState(rumorId)`
|
||||
- `desktopApp/.../desktop/relay/LocalRelayStore.kt` — `retry_queue` table + DAO methods
|
||||
- `desktopApp/.../desktop/relay/RetryQueueCoordinator.kt` — new
|
||||
- `desktopApp/.../desktop/model/DesktopIAccount.kt` — enqueue → publish → confirm pattern, signing-state emission
|
||||
- `desktopApp/.../ui/chats/ChatMessageBubble.kt` — render delivery indicators
|
||||
- `desktopApp/.../ui/chats/MessageComposer.kt` — bunker progress
|
||||
|
||||
**Acceptance:**
|
||||
- [ ] Send DM, kill app mid-publish (during bunker sign). Restart → retry queue drains → message lands.
|
||||
- [ ] Send DM to 3 relays; relay #2 returns `auth-required:` while #1 and #3 OK. Bubble shows `✓ 2/3` immediately; after AUTH completes, updates to `✓ 3/3`.
|
||||
- [ ] Bunker user sends to 5 recipients. Send button shows "Encrypting via remote signer (3 of 5)" until last signature lands.
|
||||
- [ ] No retry-queue table growth in nsec-local mode under normal conditions.
|
||||
- [ ] Retry queue respects per-account isolation (multi-account users don't see each other's queued sends).
|
||||
|
||||
### Phase 4 — Discovery hardening + security fix (R4, R11)
|
||||
|
||||
**Goal:** kind:10050 lookup is robust against missing/stale data; stop the silent metadata-leak fallback.
|
||||
|
||||
**Scope:**
|
||||
|
||||
1. **Indexer-relay fan-out for kind:10050** (R4):
|
||||
- New `DmInboxRelayResolver` (commons, so Android inherits). API: `suspend fun resolveDmInboxRelays(pubkey: HexKey): Result<List<RelayUrl>>`.
|
||||
- Wraps `RecipientRelayFetcher` (already in `quartz/.../marmot/`). Configures it with a discovery set (curated indexer relays).
|
||||
- LRU cache (100 entries, TTL 1h) on `(pubkey → relays)` results.
|
||||
- Decoupled from `relayManager.connectedRelays` — uses ephemeral connections to indexers.
|
||||
|
||||
2. **Security fix: stop falling back to `connectedRelays.value`** in `DesktopIAccount.sendNip17PrivateMessage:179` and twin methods (sendNip17EncryptedFile, sendGiftWraps).
|
||||
- New flow: `dmInboxRelays()` → if null → `DmInboxRelayResolver.resolveDmInboxRelays(recipient)` → if empty → **block send and surface UI prompt** "Could not find DM relays for <name>. [Enter manually] [Cancel]".
|
||||
- Never silently fall back to user's connected relays for DMs (matches 2026-04-20 Relay Power Tools decision).
|
||||
- The "[Enter manually]" path is a one-shot dialog with relay-URL chips; user-entered relays are NOT persisted to recipient's 10050 (we don't publish on their behalf), only used for this send.
|
||||
|
||||
3. **Indexer-relay set** (open question Q2 in brainstorm — decided here):
|
||||
- Hardcoded curated list in `commons/.../relayClient/dm/DefaultIndexerRelays.kt`:
|
||||
- `wss://purplepag.es`
|
||||
- `wss://relay.nos.social`
|
||||
- `wss://relay.damus.io`
|
||||
- `wss://nos.lol`
|
||||
- `wss://relay.nostr.band`
|
||||
- Configurable in Settings → DMs → "Inbox-relay discovery" (advanced). Default = curated list.
|
||||
|
||||
4. **Relay hint on `p` tags** (R11):
|
||||
- In `NIP17Factory.createWraps`, when building the seal's `p` tag for the recipient, include the recipient's primary DM relay URL: `["p", recipientPubkey, primaryDmRelay]`.
|
||||
- "Primary" = first relay from `resolveDmInboxRelays(recipient)` result. Empty string if unknown.
|
||||
|
||||
**Files:**
|
||||
- `commons/.../relayClient/dm/DmInboxRelayResolver.kt` — new
|
||||
- `commons/.../relayClient/dm/DefaultIndexerRelays.kt` — new
|
||||
- `desktopApp/.../desktop/model/DesktopIAccount.kt:179-261` — three send methods updated
|
||||
- `desktopApp/.../ui/chats/DmInboxRelayMissingDialog.kt` — new
|
||||
- `quartz/.../nip17Dm/NIP17Factory.kt` — relay hint on `p` tag
|
||||
- `desktopApp/.../ui/settings/DmSettingsScreen.kt` — new (indexer-relay config)
|
||||
|
||||
**Acceptance:**
|
||||
- [ ] Recipient has no kind:10050 in our cache and indexers return nothing → user sees "Enter manually" dialog. No silent send to non-inbox relays.
|
||||
- [ ] Recipient has 10050 in cache → no indexer call. Cache TTL respected (1h).
|
||||
- [ ] Recipient has no 10050 in cache, indexers return [r1, r2] → cache populated, send proceeds.
|
||||
- [ ] `p` tag in seal contains recipient's primary DM relay URL when known.
|
||||
- [ ] Settings allows custom indexer set.
|
||||
- [ ] **Security regression test**: send DM where recipient has no 10050 anywhere → verify zero outbound traffic to user's own outbox/general relays.
|
||||
|
||||
### Phase 5 — Correctness (R9, R10, R12)
|
||||
|
||||
**Goal:** group DMs, cross-device sync, and dedupe behave correctly.
|
||||
|
||||
**Scope:**
|
||||
|
||||
1. **Shared `rumorCreatedAt` across recipient wraps** (R9):
|
||||
- In `NIP17Factory.createWraps` (currently `quartz/.../nip17Dm/NIP17Factory.kt:43-72`), compute `rumorCreatedAt = TimeUtils.now()` once before the per-recipient `mapNotNullAsync` loop. Pass into every `SealedRumorEvent.create(...)` so all seals encode the same rumor (same `rumor.id`).
|
||||
- Same `rumorId` becomes the dedupe anchor + receipt target across all recipients of a group message.
|
||||
|
||||
2. **Self-copy gift wrap to own DM relays** (R10):
|
||||
- In each `DesktopIAccount.sendNip17*` method, after building wraps for all recipients, also build one wrap addressed to self.
|
||||
- Route to `account.dmInboxRelays` (or write relays as fallback per wisp's pattern). **NOT to local relay** (brainstorm Q7).
|
||||
- Pre-mark `LocalCache.seenGiftWraps[selfWrap.id]` (or equivalent) to avoid double-render when it loops back from the relay.
|
||||
|
||||
3. **Persistent seen-index** (R12) — verify, don't re-build:
|
||||
- The existing `LocalCache.consume()` + write-through to `LocalRelayStore` (`DesktopLocalCache.kt:216-219`) already provides on-disk dedupe across restart.
|
||||
- Add a regression test: kill desktop app with N gift wraps in-cache; on restart, re-deliver same wraps from a mock relay; verify they're rejected as duplicates at `LocalCache.consume` (no decryption attempt → no bunker round-trip).
|
||||
|
||||
**Files:**
|
||||
- `quartz/.../nip17Dm/NIP17Factory.kt:43-78` — shared rumor created_at
|
||||
- `desktopApp/.../desktop/model/DesktopIAccount.kt` — add self-copy in three send methods
|
||||
- `commons/.../service/LocalCache.kt` (or `desktopApp/.../desktop/cache/DesktopLocalCache.kt`) — pre-mark seenGiftWraps if not already supported
|
||||
- Tests in `desktopApp/.../jvmTest/` and `quartz/.../commonTest/`
|
||||
|
||||
**Acceptance:**
|
||||
- [ ] Group DM to 4 recipients: all seals share one rumor.id. Reaction event targeting that rumor.id by recipient #2 is correctly received by sender and other recipients.
|
||||
- [ ] Send DM from desktop install A; open same account on desktop install B. Self-copy arrives via 10050 → conversation appears on B.
|
||||
- [ ] Kill app with 50 wraps in cache. Mock relay re-broadcasts same 50 wraps. App restart → decryption attempted 0 times (verified via signer-call counter).
|
||||
|
||||
### Phase 6 — NIP-46 batch RPC (parallel track)
|
||||
|
||||
**Goal:** bunker users receive N gift wraps in 1–2 round-trips instead of N.
|
||||
|
||||
**Spec proposal:**
|
||||
- File NIP-46 PR in `nostr-protocol/nips` proposing method `get_conversation_keys`:
|
||||
```
|
||||
Request: { id, method: "get_conversation_keys", params: [pubkeys_json_array] }
|
||||
Response: { id, result: keys_json_array, error?: string }
|
||||
```
|
||||
- `pubkeys_json_array` = JSON-encoded array of hex pubkeys. Result is parallel array of base64-encoded 32-byte NIP-44 conversation keys (same key the bunker would derive for the corresponding `nip44_encrypt`/`nip44_decrypt`).
|
||||
- Bunker MAY rate-limit or reject (e.g. if more than 100 pubkeys). Client falls back to per-call `nip44_decrypt` if `get_conversation_keys` returns error or capability not advertised.
|
||||
- Capability advertised via NIP-46 `connect` response: `result.capabilities: ["get_conversation_keys"]` (or via a `get_capabilities` method if spec evolves).
|
||||
|
||||
**Coordination:**
|
||||
- Open NIPs PR + cross-post to bunker maintainers:
|
||||
- **nsec.app** (Yegor) — github.com/nostrband/nsec.app
|
||||
- **Amber** (greenart7c3) — github.com/greenart7c3/Amber
|
||||
- **Keychat** — github.com/keychat-io
|
||||
- Resolve open semantics question (brainstorm Q4):
|
||||
- For NIP-17, the receiver needs `ecdh(self, ephemeral_pubkey_in_each_wrap)` for the wrap layer, AND `ecdh(self, sender_pubkey)` for the seal layer.
|
||||
- Wrap layer: N ephemeral pubkeys → N keys. Pass all in one batch call.
|
||||
- Seal layer: M unique sender pubkeys (often M << N). Pass all in one batch call.
|
||||
- Net: 2 bunker calls instead of 2N. Confirmed acceptable shape.
|
||||
|
||||
**Amethyst-side implementation:**
|
||||
- Capability probe: on bunker `connect`, parse `result.capabilities` (or fall back to a feature-flag pref).
|
||||
- `RemoteSignerManager.getConversationKeys(pubkeys: List<HexKey>): List<ByteArray>` — new method. Sends one NIP-46 request, awaits response, parses keys.
|
||||
- Wire into NIP-17 receive path: when `LocalCache.consume` ingests a batch of kind:1059 events, before decrypting, collect all unique ephemeral pubkeys + sender pubkeys, call `getConversationKeys` once, then decrypt locally with the returned keys.
|
||||
- Wire into NIP-17 send path: per-recipient conversation key fetched once (cached), used for seal encryption locally.
|
||||
- Cache conversation keys in-memory (LRU 500); wipe on logout. Conversation keys are NOT persisted — re-derivable from bunker on next session.
|
||||
|
||||
**Files:**
|
||||
- `quartz/.../nip46RemoteSigner/signer/RemoteSignerManager.kt` — add `getConversationKeys`
|
||||
- `quartz/.../nip46RemoteSigner/dto/` — new request/response DTOs
|
||||
- `quartz/.../nip46RemoteSigner/signer/NostrSignerRemote.kt` — expose batch path
|
||||
- `quartz/.../nip17Dm/NIP17Factory.kt` — switch to batch path when signer is remote and capability available
|
||||
- `quartz/.../nip17Dm/Nip17Receiver.kt` or wherever wraps are decrypted (likely under `commons/.../service/`) — batch-decrypt path
|
||||
- `commons/.../service/cache/ConversationKeyCache.kt` — new LRU
|
||||
- `quartz/.../commonTest/.../GetConversationKeysTest.kt` — round-trip test with mock bunker
|
||||
|
||||
**Acceptance (gated on spec PR being open at least; impl can land behind capability flag):**
|
||||
- [ ] NIPs PR opened with discussion-ready spec.
|
||||
- [ ] Mock bunker test: client calls `get_conversation_keys([10 pubkeys])` → receives 10 keys → uses them to decrypt 10 wraps with 0 further bunker calls.
|
||||
- [ ] Capability fallback: bunker doesn't advertise capability → client falls back to per-call `nip44_decrypt`. No regression.
|
||||
- [ ] Inbox-load benchmark: 200 wraps via bunker. Without batch RPC: ~200 round-trips. With batch RPC: ≤2 round-trips. Measured via signer-call counter.
|
||||
|
||||
## Alternative Approaches Considered
|
||||
|
||||
| Alternative | Why rejected |
|
||||
|---|---|
|
||||
| **Implement NIP-4E (PRs #1647/#2361)** | Vitor (maintainer) NACKed both with 5 technical objections. Externalizes trial-decryption cost on every legacy peer. Politically infeasible. |
|
||||
| **Read-side NIP-4E compat only** (honor peers' kind:10044 + n-tag on receive) | Spec contested, no merge in sight. Adds receive-path complexity for unclear win — Jumble + Coop are small. Defer until spec lands. |
|
||||
| **Migrate to MLS/Marmot for DMs** | Larger orthogonal program. Marmot already in tree (per `quartz/.../marmot/`). Separate track. Doesn't solve NIP-17 reliability for users on non-MLS peers. |
|
||||
| **Drop bunker support for DMs entirely** | wisp + nospeak do this; works but regresses Amethyst's bunker UX. Better path is to make bunker fast (Phase 6) than to drop it. |
|
||||
| **Per-relay outbox max-tries config without auth-required carve-out** | Half-measure; doesn't solve the silent-drop case where AUTH succeeds AFTER 3 retries exhausted. The carve-out is required regardless. |
|
||||
| **In-memory only retry queue** | Loses messages on app crash / kill. Persistent SQLite is cheap given `LocalRelayStore` already exists. |
|
||||
| **Self-copy wrap to embedded local relay** (instead of remote DM relays) | Brainstorm Q7: rejected to match wisp behavior + keep local relay's "cache only" role. |
|
||||
|
||||
## System-Wide Impact
|
||||
|
||||
### Interaction Graph
|
||||
|
||||
**Outgoing DM (post-Phase 3):**
|
||||
```
|
||||
ComposeUI(send button click)
|
||||
→ DmConversationViewModel.send(text)
|
||||
→ DesktopIAccount.sendNip17PrivateMessage(text, recipients)
|
||||
→ DmInboxRelayResolver.resolveDmInboxRelays(recipient) ── Phase 4
|
||||
→ RecipientRelayFetcher.fetch([indexer relays])
|
||||
→ NIP17Factory.createWraps(text, recipients, signer) ── shared rumor_created_at (Phase 5)
|
||||
→ for each recipient (parallel):
|
||||
→ SealedRumorEvent.create(rumor, recipient, signer)
|
||||
→ signer.sign(seal) (bunker round-trip if Remote) ── batch via Phase 6 capability
|
||||
→ signer.nip44Encrypt(rumor, recipient) (bunker round-trip)── batch via Phase 6 capability
|
||||
→ GiftWrapEvent.create(seal, recipient, ephemeralKey)
|
||||
→ for each wrap:
|
||||
→ retryQueue.enqueue(wrap, relays) ── Phase 3
|
||||
→ client.publish(wrap, relays)
|
||||
→ relay returns OK → retryQueue.confirm(wrapId, relayUrl)
|
||||
→ relay returns auth-required → RelayAuthenticator handles ── Phase 2
|
||||
→ relay rejects → retryQueue.scheduleRetry(wrapId, relayUrl)
|
||||
→ self-copy wrap published to own DM relays ── Phase 5
|
||||
ChatBubble subscribes to messageBubbleState(rumorId) ── Phase 3
|
||||
→ renders ✓ ✓✓ ⟳ ⚠ based on state changes
|
||||
```
|
||||
|
||||
**Incoming DM (post-Phase 6):**
|
||||
```
|
||||
RelayConnection.onIncomingMessage(EventMessage(kind=1059))
|
||||
→ LocalCache.consume(wrap)
|
||||
→ if seen → drop ── Phase 5 (already exists)
|
||||
→ batch collected by ingestion buffer (250ms window)
|
||||
→ batch decrypt path:
|
||||
→ collect unique sender pubkeys from wraps in batch
|
||||
→ if signer is Remote AND batch capability: getConversationKeys() ── Phase 6
|
||||
→ for each wrap: decrypt locally with cached key
|
||||
→ on rumor decrypted → DesktopMessagesScreen.conversationFlow updates
|
||||
```
|
||||
|
||||
### Error & Failure Propagation
|
||||
|
||||
| Layer | Error class | Today | Post-plan |
|
||||
|---|---|---|---|
|
||||
| Relay socket | `WebSocketDisconnected` | reconnect attempt, in-flight events tries-counted | reconnect, queue preserves event, retries on reconnect |
|
||||
| Relay OK | `auth-required:` | counts toward 3-try cap, often silently dropped | NOT counted; held until AUTH completes; user sees banner if tier-2 |
|
||||
| Relay OK | `pow:` / `replaced:` / `invalid:` | discarded (correct) | unchanged |
|
||||
| Bunker RPC | `BunkerTimeout` (65s) | request continuation removed; late response discarded | retry queue re-attempts on next coordinator tick; bubble shows ⚠ |
|
||||
| Bunker RPC | `DecryptCache` poisoning (per 2026-05-04 plan) | permanent cache poison until app restart | unchanged this plan — covered by prior plan |
|
||||
| Bunker RPC | `get_conversation_keys` not supported | N/A | fall back to per-call `nip44_decrypt` |
|
||||
| 10050 lookup | recipient has no 10050 anywhere | **falls back to user's connected relays (metadata leak)** | Phase 4: blocks send + prompts user for manual relay entry |
|
||||
| Signer | `signer.sign` returns null | DmSendTracker → Failed → resets in 3s | retry queue keeps the event, scheduler retries; bubble stays ⟳ |
|
||||
|
||||
### State Lifecycle Risks
|
||||
|
||||
1. **Retry queue rows must be deleted on permanent failure or success, never orphaned.** Coordinator deletes on `attempt >= max_attempts` even if no UI sees it. Alternative: archive to `retry_queue_dead_letter` table for diagnostics.
|
||||
2. **AUTH-approvals table must be account-scoped.** Multi-account users share the SQLite store across accounts but each row carries `account_pubkey`. Test: account A approves relay X "always"; account B sending to relay X gets tier-2 prompt independently.
|
||||
3. **Indexer cache invalidation.** If recipient publishes a new 10050, our 1h TTL hides it. Mitigation: on receiving a fresh kind:10050 event for any user via the normal relay feed, eagerly update the cache.
|
||||
4. **Self-copy wrap can race the original.** If self-copy lands first, recipient #1's wrap arrives second and we already have the rumor — dedupe at rumor.id should handle it. Test.
|
||||
5. **Retry queue + AUTH banner can dual-drive UI** — if a wrap is queued AND the relay's AUTH is pending, we don't want two notifications. Coordinator suppresses retry attempts on relays in `AUTHENTICATING` state.
|
||||
6. **Conversation-key cache (Phase 6) lives in memory only.** On logout / account switch, must wipe to prevent cross-account leakage.
|
||||
|
||||
### API Surface Parity
|
||||
|
||||
| Surface | Effect |
|
||||
|---|---|
|
||||
| Desktop NIP-17 send | full plan |
|
||||
| Android NIP-17 send | inherits all `commons/` + `quartz/` changes. Android-specific UI (AUTH banner) NOT in this plan — separate Android pass. Android keeps current AUTH-prompt-less behavior until then; the underlying classifier still works (silent drops for tier-3, auto for tier-1, **silent drop for tier-2** — Android users with bunker won't see banner; safer than current). |
|
||||
| CLI (`amy`) | `commons/` changes apply. CLI doesn't render banners. Tier-2 AUTH approvals via a config file (out of scope, file follow-up). |
|
||||
| Marmot/MLS DMs | unaffected (separate event kinds + path) |
|
||||
| NIP-04 legacy DMs | unaffected (no AUTH retry, no retry queue — legacy path stays as-is per brainstorm Q1) |
|
||||
|
||||
### Integration Test Scenarios
|
||||
|
||||
1. **AUTH retry across restart.** Send DM to relay R that demands AUTH. Sign + send AUTH. Kill app before AUTH-OK arrives. Restart. Verify retry queue resumes, AUTH handshake completes, original wrap accepted.
|
||||
2. **Tier-2 prompt persistence.** Recipient has 10050 pointing to a relay user has never seen. Send → banner appears → user clicks `[Always]`. Send another DM to same recipient → no banner; relay AUTH'd silently using stored approval.
|
||||
3. **No-10050 security path.** Recipient has no kind:10050 in our cache and on indexers. Send → dialog shows "Enter manually". Cancel → zero outbound traffic to general relays. Verify via mock-relay sniffer.
|
||||
4. **Bunker batch RPC inbox load.** 200-wrap inbox, bunker user. Pre-plan: ~200 round-trips (~minutes). Post-plan with capability: ≤2 round-trips (~seconds). Measured via signer-call counter.
|
||||
5. **Group DM rumor coherence.** Send to [A, B, C]. Each receives a wrap. A reacts to message → reaction `e` tag references shared `rumorId`. B and C see the reaction associated with the right message. Sender sees it too.
|
||||
6. **Self-copy cross-device.** Account on desktop install X sends DM to recipient. Open same account on install Y (cold cache). Y's first 10050 fetch returns sender's own DM relays → self-copy wrap arrives → conversation pre-populates.
|
||||
7. **Window-focus re-AUTH.** Mac sleeps 1h. Wake → focus desktop app → mock relay's AUTH challenges fire → client AUTHs all stale connections within 5s. No user input required.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### Functional
|
||||
|
||||
- [ ] Phase 1: kind:1059 sub on both Desktop and Android passes no `since` (or a 30-day default at most). Wraps with timestamps 2 days in the past arrive.
|
||||
- [ ] Phase 2: All five sub-items shipping (tier classifier, persisted approvals, banner, re-sub-on-auth-completed, focus re-AUTH).
|
||||
- [ ] Phase 3: Per-message bubble delivery indicator. Persistent retry queue. Bunker progress UI.
|
||||
- [ ] Phase 4: No silent fallback to user's connected relays for DMs. Indexer fan-out + manual entry dialog.
|
||||
- [ ] Phase 5: Shared rumor.created_at. Self-copy wrap. Persistent dedupe verified.
|
||||
- [ ] Phase 6: NIPs PR open. Capability negotiation + fallback. Batch decrypt wired for receive path.
|
||||
|
||||
### Non-functional
|
||||
|
||||
- [ ] No regression for nsec-local users: end-to-end DM round-trip stays under 1s on healthy relays.
|
||||
- [ ] Bunker inbox load (200 wraps): post-Phase 6 ≤10s vs current ≥120s.
|
||||
- [ ] Retry queue size stays under 100 rows under normal use (i.e. high-success-rate publish path keeps it empty most of the time).
|
||||
- [ ] No new secrets persisted: AUTH approvals carry no key material; only relay URLs + scope flags.
|
||||
|
||||
### Quality gates
|
||||
|
||||
- [ ] All new code passes `./gradlew spotlessApply` + `./gradlew test`.
|
||||
- [ ] Integration test count: ≥1 per phase, ≥7 total.
|
||||
- [ ] Mock relay infra (`geode/.../KtorRelayTest.kt` pattern) reused where possible; new mock-bunker for Phase 6.
|
||||
- [ ] No new uses of `runBlocking` in publish path.
|
||||
- [ ] Code-review pass with `compose-expert`, `relay-client`, `auth-signers`, `nostr-expert` skills before merge per phase.
|
||||
|
||||
## Success Metrics
|
||||
|
||||
| Metric | Pre-plan baseline | Target |
|
||||
|---|---|---|
|
||||
| Silent message drops on AUTH-walled relays | unknown (likely common) | 0 |
|
||||
| Inbox load time, 200 wraps via bunker | ~2 min | ≤10 s |
|
||||
| Successful delivery rate on first try (nsec, healthy network) | ~95% (estimated) | ≥99% |
|
||||
| Successful delivery rate including retry queue, 24h window | unknown | ≥99.5% |
|
||||
| User reports of "DM never arrived" / "DM never sent" | baseline TBD | 50% reduction over 3 months |
|
||||
| Crash/ANR rate on DM screen | baseline TBD | no regression |
|
||||
|
||||
## Dependencies & Prerequisites
|
||||
|
||||
- **Phase 6 blocked on NIPs PR consensus.** Spec authors (nsec.app/Amber/Keychat) need to weigh in. If consensus stalls, Phase 6 implementation can still land **behind a feature flag** as a discussion prototype.
|
||||
- Phases 1–5 are sequential within Track A but each is independently shippable.
|
||||
- Phase 4's manual-entry dialog needs design pass (no Figma assumed; brainstorm spec is the source).
|
||||
- Mock bunker test infra for Phase 6 — small new utility, no external deps.
|
||||
|
||||
## Risk Analysis & Mitigation
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|---|---|---|---|
|
||||
| Phase 2 AUTH classifier wrongly tier-3-drops a legit relay | M | High (silent drop) | Default tier-3 to "log warning" not "silently drop" during rollout; flip later. Tier-1 + tier-2 catch the common cases. |
|
||||
| Retry queue grows unbounded (e.g. dead relay) | M | M | `max_attempts=8` + exp-backoff caps total relay-time per event to ~10 min. Dead-letter table for inspection. UI surfaces "permanent failure" ⚠. |
|
||||
| Phase 6 NIPs PR rejected | M | M | Implement Vitor's design as a discussion prototype regardless; if PR rejected, ship as Amethyst↔nsec.app/Amber bilateral capability negotiation (slightly worse interop, equivalent end-user outcome). |
|
||||
| Security fix (Phase 4) breaks existing users who relied on the leaky fallback | L | L | Surface dialog + provide manual-entry fallback. Document migration in release notes. Add telemetry for "no-10050" send attempts in 1.0 to size the affected population. |
|
||||
| Window-focus listener leaks on close | L | L | Standard `addWindowFocusListener` / `removeWindowFocusListener` pairing; unit-test via headless ComposeWindow. |
|
||||
| Conversation-key cache (Phase 6) leaks across account switch | L | High (cross-account decryption) | Wipe `ConversationKeyCache` in `AccountStateHolder.onAccountChanged`. Unit test. |
|
||||
| Persistent AUTH approvals get out of sync with relay's actual AUTH state | L | L | TTL of 30 days on `auth_approvals.expires_at`; re-prompt after expiry. |
|
||||
| Android inheriting `commons/` changes regresses Android DM screens | M | M | Run full Android test suite after each phase; manual smoke test on Android emulator before merge. |
|
||||
| Spec change in NIP-46 mid-implementation | M | M | Capability negotiation isolates Amethyst from spec churn; fallback path always works. |
|
||||
|
||||
## Resource Requirements
|
||||
|
||||
- Solo engineer; estimated 4–6 weeks of focused work for Phases 1–5, plus indefinite coordination for Phase 6.
|
||||
- No new infrastructure / hosting.
|
||||
- New dev-dep on a mock-bunker test utility (small, in-tree).
|
||||
|
||||
## Future Considerations
|
||||
|
||||
- **Receipts UX**: read receipts in wisp + nospeak use a "high water mark" per conversation. Out of scope here, natural follow-up plan.
|
||||
- **NIP-04 visibility cleanup**: brainstorm Q1 said "keep legacy badge"; revisit when NIP-17 adoption hits a threshold.
|
||||
- **Marmot/MLS DMs**: separate program. The reliability plumbing (AUTH, retry queue) is reusable — Phase 6's batch RPC concept does NOT apply (MLS uses different keying).
|
||||
- **WoT inbox relays** (e.g. pyramid.fiatjaf.com/inbox) — open question Q5 in brainstorm. AUTH plumbing makes us publishable. Surface relay rejection messages to UI as toast for diagnostics. No special WoT machinery needed for now.
|
||||
- **Android UI parity** for AUTH banner and bunker progress — separate Android-only pass, plan TBD.
|
||||
- **Telemetry** — add anonymous metric `dm.delivery.outcome = {ok, retry, dropped}` (opt-in only, behind Settings flag).
|
||||
|
||||
## Documentation Plan
|
||||
|
||||
- Update `desktopApp/.../README.md` (if any) with new DM reliability features.
|
||||
- Update `MEMORY.md` summary at end of work.
|
||||
- Add `commons/ARCHITECTURE.md` entries for `DmInboxRelayResolver` and `RetryQueueCoordinator`.
|
||||
- KDoc on new public surfaces: `RelayAuthenticator.authCompleted`, `DesktopIAccount.messageBubbleState`, `RemoteSignerManager.getConversationKeys`.
|
||||
- Release-notes entries per phase (`docs/release-notes/`?). User-facing: "DMs now show per-relay delivery status", "AUTH-walled relays handled automatically", "Bunker users can open large inboxes much faster".
|
||||
|
||||
## Sources & References
|
||||
|
||||
### Origin
|
||||
|
||||
- **Brainstorm document**: [docs/brainstorms/2026-06-10-desktop-dm-reliability-brainstorm.md](../brainstorms/2026-06-10-desktop-dm-reliability-brainstorm.md).
|
||||
Key decisions carried forward: two-track umbrella (reliability + bunker speed), R1–R12 inventory, NIP-04 stays legacy, bunker progress UI, AUTH inline banner, self-copy → remote only, desktop-first Android-inherits.
|
||||
|
||||
### Internal references
|
||||
|
||||
| Concern | File:line |
|
||||
|---|---|
|
||||
| Desktop kind:1059 sub site | `desktopApp/.../subscriptions/DesktopRelaySubscriptionsCoordinator.kt:338-349` |
|
||||
| Android kind:1059 sub site (needs fix) | `amethyst/.../AccountGiftWrapsEoseManager.kt:55-61` |
|
||||
| Filter assembler (commons) | `commons/.../relayClient/nip17Dm/FilterGiftWrapsToPubkey.kt:31-49` |
|
||||
| Filter assembler (desktop) | `desktopApp/.../subscriptions/FilterDMs.kt:125-133` |
|
||||
| Publish path entry | `quartz/.../nip01Core/relay/client/NostrClient.kt:233-245` |
|
||||
| Per-event outbox | `quartz/.../nip01Core/relay/client/pool/PoolEventOutboxState.kt:64-108` |
|
||||
| AUTH state cache | `quartz/.../nip01Core/relay/client/auth/RelayAuthenticator.kt:57-104` |
|
||||
| AUTH event builder | `quartz/.../nip42RelayAuth/RelayAuthEvent.kt` |
|
||||
| Bunker signer manager | `quartz/.../nip46RemoteSigner/signer/RemoteSignerManager.kt:44-102` |
|
||||
| NIP-17 factory | `quartz/.../nip17Dm/NIP17Factory.kt:43-78` |
|
||||
| Recipient-relay fetcher | `quartz/.../marmot/RecipientRelayFetcher.kt:38-114` |
|
||||
| Desktop send path (security bug) | `desktopApp/.../model/DesktopIAccount.kt:179-261` |
|
||||
| DmSendTracker (to replace) | `desktopApp/.../ui/chats/DmSendTracker.kt:32-86` |
|
||||
| Window state on desktop | `desktopApp/.../Main.kt:250-316` |
|
||||
| LocalRelayStore (retry queue host) | `desktopApp/.../desktop/relay/LocalRelayStore.kt` |
|
||||
| Mock-relay AUTH test infra | `geode/.../KtorRelayTest.kt:208,254` |
|
||||
| Server-side AUTH test | `quartz/.../commonTest/.../nip01Core/relay/server/NostrServerAuthTest.kt` |
|
||||
|
||||
### Related prior plans (carry constraints / reuse infra)
|
||||
|
||||
- **2026-04-20 Relay Power Tools** — shipped "block DM fallback to all relays" decision (Phase 4 enforces). `desktopApp/.../docs/plans/2026-04-20-feat-relay-power-tools-plan.md`.
|
||||
- **2026-05-04 Bunker Timeouts & Decryption** — shipped DecryptCache poisoning fix. Retry queue (Phase 3) inherits the lesson: persist request state across timeouts. `docs/plans/2026-05-04-fix-bunker-timeouts-and-decryption-plan.md`.
|
||||
- **2026-05-09 Embedded Local Relay** — shipped `LocalRelayStore` SQLite + `BasicBundledInsert`. Phase 3 retry queue uses the same store. `desktopApp/plans/2026-05-09-embedded-local-relay-plan.md`.
|
||||
- **2026-03-20 Remote Signer Loading & Error UX** — shipped `SigningState` pattern. Phase 3 bunker progress UI reuses. `docs/plans/2026-03-20-feat-remote-signer-loading-error-ux-plan.md`.
|
||||
|
||||
### External references
|
||||
|
||||
| Source | URL |
|
||||
|---|---|
|
||||
| NIP-17 spec | https://github.com/nostr-protocol/nips/blob/master/17.md |
|
||||
| NIP-42 spec | https://github.com/nostr-protocol/nips/blob/master/42.md |
|
||||
| NIP-46 spec | https://github.com/nostr-protocol/nips/blob/master/46.md |
|
||||
| NIP-4E PR #1647 (contested) | https://github.com/nostr-protocol/nips/pull/1647 |
|
||||
| NIP-17 keys PR #2361 (contested) | https://github.com/nostr-protocol/nips/pull/2361 |
|
||||
| wisp source | https://github.com/barrydeen/wisp |
|
||||
| nospeak source | https://github.com/psic4t/nospeak |
|
||||
|
||||
### Files worth diffing line-by-line during implementation
|
||||
|
||||
- wisp: `app/src/main/kotlin/com/wisp/app/relay/RelayPool.kt:518-563` (tiered AUTH)
|
||||
- wisp: `app/src/main/kotlin/com/wisp/app/viewmodel/StartupCoordinator.kt:353-364, 734-743` (re-sub on AUTH-OK, no-`since` 1059 filter)
|
||||
- wisp: `app/src/main/kotlin/com/wisp/app/viewmodel/DmConversationViewModel.kt:654-665, 702-712, 766` (shared rumor_created_at, self-copy)
|
||||
- wisp: `app/src/main/kotlin/com/wisp/app/repo/DmRelayLookup.kt` (indexer fan-out)
|
||||
- nospeak: `src/lib/core/connection/RetryQueue.ts` (Dexie-backed retry queue)
|
||||
- nospeak: `src/lib/core/connection/publishWithDeadline.ts:136` (AUTH retry inside publish)
|
||||
- nospeak: `src/lib/core/connection/ConnectionManager.ts:345-410` (re-AUTH on visibilitychange)
|
||||
- nospeak: `src/lib/stores/sending.ts` (per-relay delivery counter)
|
||||
|
||||
---
|
||||
|
||||
## Unanswered questions
|
||||
|
||||
Resolved during deepening (see Deepening Synthesis §"Open questions resolved"): tier-3 dropped (2 tiers only); indexer set hardcoded curated 5 + system-property override; conversation-key cache session-only; retry dead-letter 30d; self-copy own DM relays only; bunker progress = spinner not counter; NIP-46 capability via optimistic probe (no spec extension).
|
||||
|
||||
Still open:
|
||||
- Android UI parity for tier-2 banner — separate plan or include here? Lean separate.
|
||||
- Manual relay-entry: ship dialog with full validation (F-02) or drop entirely + Snackbar error only? Lean drop; revisit after dogfooding.
|
||||
- Bunker batch RPC chunk size cap — spec authors to set (recommend 100 pubkeys/call).
|
||||
- WoT relay rejection messages — toast all `:` -prefixed OK reasons or filter? Lean all.
|
||||
- F-13 multi-indexer agreement — require ≥2 indexers, or accept ≥1 with NIP-11 pubkey pinning? Decide in Phase 4.
|
||||
- NIP-09 deletion of self-copies on kind:10050 rotation (F-06) — implement now or release-note disclosure? Lean disclosure now, implement later.
|
||||
- AUTH approval revoke UI placement (F-05 P1) — Settings → DMs → "Approved relays" list. Confirm during Phase 2 design.
|
||||
- TLS SPKI + NIP-11 pubkey pinning for AUTH approvals (F-05) — defer or include in Phase 2? Lean include — protects against relay-ownership swap mid-TTL.
|
||||
@@ -0,0 +1,241 @@
|
||||
# Desktop DM Reliability — Testing Sheet
|
||||
|
||||
**Branch:** `feat/desktop-dm-reliability`
|
||||
**Date:** 2026-06-12
|
||||
**Plan:** [docs/plans/2026-06-10-feat-desktop-dm-reliability-plan.md](2026-06-10-feat-desktop-dm-reliability-plan.md)
|
||||
**Tester:**
|
||||
|
||||
## Scope
|
||||
|
||||
17 commits across `quartz` / `commons` / `desktopApp`. Net ~1,797 LOC (incl. ~600 LOC tests).
|
||||
The branch ships:
|
||||
|
||||
1. NIP-42 AUTH end-to-end on desktop (today: nothing) — tier-1 auto-sign, tier-2 banner
|
||||
2. P0 security fix: NIP-17 sends no longer fall back to user's connected relays
|
||||
3. Dedicated unauthenticated NostrClient for kind:10050 indexer probes (no identity-key leak)
|
||||
4. NIP-17 relay hint on gift-wrap p-tag (correct per spec)
|
||||
5. Bunker concurrency cap (Semaphore(4)) in NIP17Factory
|
||||
6. `auth-required:` carved out of outbox try cap
|
||||
7. Drop `since` from kind:1059 subscription
|
||||
8. Compose-observable AUTH state, `SigningOpState.Progress` variant
|
||||
9. Per-account AUTH approval persistence via `java.util.prefs.Preferences`
|
||||
|
||||
---
|
||||
|
||||
## Pre-test setup
|
||||
|
||||
- [ ] `git -C .worktrees/feat/desktop-dm-reliability log --oneline ^origin/main` shows 17 commits
|
||||
- [ ] Note current `~/.amethyst/accounts/<pubkey8>/` paths so they can be inspected after the run
|
||||
- [ ] Run `defaults read /Library/Preferences/com.apple.security ...` baseline — irrelevant; `Preferences.userRoot()` lives in `~/Library/Preferences/com.apple.java.util.prefs.plist` on macOS, `~/.java/.userPrefs` on Linux. Note path for later verification.
|
||||
|
||||
---
|
||||
|
||||
## A. Automated verification (Claude can run these)
|
||||
|
||||
**Run on 2026-06-12 against worktree at HEAD `4ed0ff241`.**
|
||||
|
||||
### A1: Compile every module
|
||||
| # | Step | Expected | Pass? | Notes |
|
||||
|---|------|----------|-------|-------|
|
||||
| A1.1 | `./gradlew :quartz:compileKotlinJvm` | BUILD SUCCESSFUL, no errors | ✅ | Only pre-existing `BirthdayTolerantSerializer` opt-in warning |
|
||||
| A1.2 | `./gradlew :commons:compileKotlinJvm` | BUILD SUCCESSFUL | ✅ | |
|
||||
| A1.3 | `./gradlew :desktopApp:compileKotlin` | BUILD SUCCESSFUL | ✅ | |
|
||||
| A1.4 | `./gradlew :amethyst:compileFdroidDebugKotlin` (Android) | BUILD SUCCESSFUL — commons changes don't break Android | ✅ | Note: task is `:amethyst:compileFdroidDebugKotlin`, not the non-flavour `compileDebugKotlin` originally listed |
|
||||
| A1.5 | `./gradlew :cli:compileKotlin` | BUILD SUCCESSFUL — quartz API changes don't break amy | ✅ | |
|
||||
|
||||
### A2: Unit + integration tests
|
||||
| # | Step | Expected | Pass? | Notes |
|
||||
|---|------|----------|-------|-------|
|
||||
| A2.1 | `./gradlew :quartz:jvmTest` | All pass; `PoolEventOutboxStateTest` (4) + `GiftWrapRelayHintTest` (3) included | ✅ | |
|
||||
| A2.2 | `./gradlew :commons:jvmTest` | All pass; `AuthApprovalPolicyTest` (8) + `AuthApprovalEndToEndTest` (4) + `DmInboxRelayResolverTest` (8) included | ✅ | |
|
||||
| A2.3 | `./gradlew :desktopApp:test` | All pass | ✅ | |
|
||||
| A2.4 | `./gradlew :amethyst:testFdroidDebugUnitTest` | All pass — no Android regression from commons changes | ✅ | |
|
||||
|
||||
### A3: Static analysis
|
||||
| # | Step | Expected | Pass? | Notes |
|
||||
|---|------|----------|-------|-------|
|
||||
| A3.1 | `./gradlew spotlessCheck` | All formatted | ✅ | |
|
||||
| A3.2 | Pre-commit hook runs on every commit (already exercised 17 times this branch) | Hook runs spotlessCheck + tests, passes | ✅ | |
|
||||
|
||||
### A4: Package build
|
||||
| # | Step | Expected | Pass? | Notes |
|
||||
|---|------|----------|-------|-------|
|
||||
| A4.1 | `./gradlew :desktopApp:createDistributable -Pcompose.desktop.packaging.checkJdkVendor=false` | Produces `Amethyst.app`; new code (`DesktopAuthCoordinator`, `AuthApprovalPolicy`, `AuthApprovalBanner`, `RelayAuthSnapshot`, `DmInboxRelayResolver`) inside the bundled JARs | ✅ | `packageDistributionForCurrentOS` blocked on local Homebrew JDK vendor check — pre-existing env issue, not a branch regression. `createDistributable` with the flag works fine. |
|
||||
| A4.2 | Launch the built `Amethyst.app` for 10s | No crash, no exceptions in log | ✅ | Confirmed: `DesktopAuthCoordinator` + `indexerClient` + `DmInboxRelayResolver` + banner mount all initialize cleanly. Only pre-existing VLC plugin warnings in stderr. |
|
||||
|
||||
### A5: Branch integrity
|
||||
| # | Step | Expected | Pass? | Notes |
|
||||
|---|------|----------|-------|-------|
|
||||
| A5.1 | `git log --grep "Co-Authored-By" feat/desktop-dm-reliability ^origin/main \| wc -l` | 0 — no Claude footer leaked into commits | ✅ | 0 matches |
|
||||
| A5.2 | All commits GPG-signed: `git log --pretty="%G?" feat/desktop-dm-reliability ^origin/main \| sort -u` | Only `G` (good signature) | ✅ | Only `G` |
|
||||
| A5.3 | No `--no-verify` or `--no-gpg-sign` flags in reflog | clean | ✅ | Hook passed on every commit; one GPG retry mid-session resolved by re-unlock, no flags used |
|
||||
|
||||
---
|
||||
|
||||
## B. Manual desktop verification (human required)
|
||||
|
||||
**Pre-step:** `./gradlew :desktopApp:run` on the worktree. Use at least two test accounts: one nsec, one bunker (`bunker://...` from nsec.app or Amber).
|
||||
|
||||
### B1: AUTH end-to-end — tier 1 (own DM-inbox relay)
|
||||
| # | Step | Expected | Pass? | Notes |
|
||||
|---|------|----------|-------|-------|
|
||||
| B1.1 | Log in with nsec account A. Verify the user has a `kind:10050` published with at least one relay (e.g. `wss://relay.nos.social`). | Account loads; feed shows | | |
|
||||
| B1.2 | Open Settings → Relays. Confirm one of A's DM-inbox relays is in the connected set. | DM relay listed, status connected | | |
|
||||
| B1.3 | Trigger an AUTH-walled action: ideally send a DM TO yourself (NIP-17). Watch DevTools / log output (or `~/.amethyst/logs/` if Tor is on). | Mock relay should send `AUTH ...`; client signs + replies; outbox publish OK | | |
|
||||
| B1.4 | Confirm NO banner appears for tier-1 relays | Banner stays empty | | |
|
||||
| B1.5 | Inspect Preferences node: `defaults read com.vitorpamplona.amethyst.desktop.auth.<full-pubkey>` (macOS) OR `cat ~/.java/.userPrefs/com/vitorpamplona/amethyst/desktop/auth/<full-pubkey>/prefs.xml` (Linux) | Empty / does not exist yet — tier-1 doesn't write | | |
|
||||
|
||||
### B2: AUTH end-to-end — tier 2 (unknown relay, banner)
|
||||
| # | Step | Expected | Pass? | Notes |
|
||||
|---|------|----------|-------|-------|
|
||||
| B2.1 | Add an AUTH-required relay to the user's settings that is NOT in their `kind:10050` (e.g. `wss://pyramid.fiatjaf.com` if accessible, or any test relay with `auth-required` policy). | Relay appears in list | | |
|
||||
| B2.2 | Send a DM that would route through that relay (e.g. user it's listed for). Or just connect and let the relay challenge. | Yellow/inline AUTH banner appears at top of content area with relay URL + `[Once] [Always] [Never]` | | |
|
||||
| B2.3 | Click `[Once]` | Banner dismisses; AUTH proceeds for this session only | | |
|
||||
| B2.4 | Restart app, repeat connection — banner appears again | banner returns (ONCE was session-only) | | |
|
||||
| B2.5 | This time click `[Always]` | Banner dismisses; AUTH proceeds | | |
|
||||
| B2.6 | Restart app. Banner does NOT appear for this relay. | tier-2 → tier-1 once persisted | | |
|
||||
| B2.7 | Inspect Preferences node: should contain `relay.example.url=ALWAYS` | persistence verified | | |
|
||||
| B2.8 | Block a different relay via `[Never]` | Future AUTH challenges from it are silently dropped (no banner, no AUTH event sent) | | |
|
||||
| B2.9 | Confirm clicking `[Never]` writes `relay.url=BLOCKED` | persistence verified | | |
|
||||
|
||||
### B3: AUTH banner — multiple concurrent challenges
|
||||
| # | Step | Expected | Pass? | Notes |
|
||||
|---|------|----------|-------|-------|
|
||||
| B3.1 | Connect to 3 different AUTH-required relays back-to-back, none auto-approved | 3 banner rows stack vertically | | |
|
||||
| B3.2 | Resolve middle one with `[Once]` | Only that row dismisses; other 2 remain | | |
|
||||
| B3.3 | Trigger 5 simultaneous AUTH challenges from different relays | First 3 visible inline; "+2 more relays pending approval" row at bottom | | |
|
||||
|
||||
### B4: Banner lifecycle — logout / account switch
|
||||
| # | Step | Expected | Pass? | Notes |
|
||||
|---|------|----------|-------|-------|
|
||||
| B4.1 | With at least 2 pending banner rows visible, log out | Banners disappear; coordinator's `onLogout` completes all pending deferreds with BLOCKED | | |
|
||||
| B4.2 | Log in to account B (different pubkey); trigger same AUTH challenges | Banners reappear because B has no persisted approvals from A | | |
|
||||
| B4.3 | Verify B's `[Always]` writes to B's Preferences node, NOT A's | per-account isolation | | |
|
||||
| B4.4 | Account A's persisted approvals still intact: log back into A → no banner for previously-approved relays | persistence stable across switches | | |
|
||||
|
||||
### B5: NIP-17 send — security fix (no-10050 case)
|
||||
| # | Step | Expected | Pass? | Notes |
|
||||
|---|------|----------|-------|-------|
|
||||
| B5.1 | Pick a recipient who has NEVER published a kind:10050 (rare in practice; can fabricate a npub) | account known, no DM inbox advertised | | |
|
||||
| B5.2 | Try to send a DM | `DmSendTracker` shows "No relays available" failure briefly | | |
|
||||
| B5.3 | Inspect outgoing socket activity (e.g. Wireshark filtered to `wss://`) | NO gift wrap is published anywhere — neither to user's outbox nor general relays | | |
|
||||
| B5.4 | DesktopRelayConnectionManager metrics: no spike for this send | confirmed | | |
|
||||
|
||||
### B6: NIP-17 send — DmInboxRelayResolver indexer fan-out
|
||||
| # | Step | Expected | Pass? | Notes |
|
||||
|---|------|----------|-------|-------|
|
||||
| B6.1 | Pick a recipient who HAS a kind:10050 but whose 10050 is NOT in your LocalCache (fresh, never-DM'd contact) | empty LocalCache for that user | | |
|
||||
| B6.2 | Click compose DM to them | Resolver consults indexer relays; brief delay (sub-second to ~3s) | | |
|
||||
| B6.3 | Inspect network: indexer client (port-share with primary client?) makes one-shot queries to `relay.nos.social`, `relay.damus.io`, `nos.lol`, `relay.nostr.band`, `purplerelay.com` | indexer fan-out confirmed | | |
|
||||
| B6.4 | **CRITICAL — F-01**: verify NO AUTH event was sent on the indexer client even if any indexer challenged | confirms unauth client. If wrong, that's a security regression. Easy check: filter pcap for kind:22242 on the indexer connections. | | |
|
||||
| B6.5 | Send succeeds; recipient's actual DM-inbox relay receives the wrap | normal NIP-17 delivery | | |
|
||||
| B6.6 | Immediately compose another DM to the same recipient | Resolver hits LRU cache; no second indexer call | | |
|
||||
| B6.7 | Wait > 1 hour (or set system clock forward); compose again | Resolver fans out again (cache expired) | | |
|
||||
|
||||
### B7: NIP-17 send — group DM rumor coherence
|
||||
| # | Step | Expected | Pass? | Notes |
|
||||
|---|------|----------|-------|-------|
|
||||
| B7.1 | Create a 3-recipient group DM | NIP17Factory builds 3 wraps + 1 self-copy | | |
|
||||
| B7.2 | Inspect the rumor inside each seal (use a Nostr event inspector or relay log) — `rumor.id` matches across all 3 wraps | shared rumor_created_at confirmed | | |
|
||||
| B7.3 | One recipient sends a reaction (`+`) on their device | reaction targets shared `rumor.id`; all participants see it | | |
|
||||
| B7.4 | Verify `wrap.created_at` is randomized per-wrap (within 2 days past) | seal/wrap timestamps stay independent | | |
|
||||
|
||||
### B8: NIP-17 relay hint on wrap p-tag
|
||||
| # | Step | Expected | Pass? | Notes |
|
||||
|---|------|----------|-------|-------|
|
||||
| B8.1 | Send a DM. Capture the published kind:1059 event via your DM-inbox relay UI or a tool like `nostr-tool`. | event captured | | |
|
||||
| B8.2 | Inspect the `p` tag on the wrap | shape is `["p", recipient_pubkey, relay_url]` — relay_url is recipient's primary DM relay if known, else 2-element shape | | |
|
||||
| B8.3 | Verify the SEAL (kind 13) does NOT carry the hint | NIP-17 spec compliance | | |
|
||||
|
||||
### B9: Outbox AUTH carve-out
|
||||
| # | Step | Expected | Pass? | Notes |
|
||||
|---|------|----------|-------|-------|
|
||||
| B9.1 | Pre-condition: relay R demanding AUTH. User has account that needs to AUTH. | configured | | |
|
||||
| B9.2 | Publish a note to R while NOT yet authenticated | Relay replies `auth-required: ...` | | |
|
||||
| B9.3 | Inspect outbox state: event remains queued for relay R | NOT discarded after 1 try | | |
|
||||
| B9.4 | Watch for AUTH event sign and submission (tier-1 auto-sign or banner approval) | AUTH OK | | |
|
||||
| B9.5 | Original note re-publishes successfully on R after AUTH | E.g. via syncFilters() | | |
|
||||
| B9.6 | Repeat: send 5 notes in rapid succession during AUTH window | All 5 re-publish after AUTH, none silently dropped | | |
|
||||
|
||||
### B10: Bunker concurrency cap
|
||||
| # | Step | Expected | Pass? | Notes |
|
||||
|---|------|----------|-------|-------|
|
||||
| B10.1 | Log in with a NIP-46 bunker account | bunker connected | | |
|
||||
| B10.2 | Send a 5-recipient group DM | NIP17Factory caps at 4 concurrent bunker RPCs | | |
|
||||
| B10.3 | Inspect bunker request timing (nsec.app / Amber log) | At most 4 in-flight at any moment | | |
|
||||
| B10.4 | Compare to a 5-recipient group DM with a local nsec account | local-nsec runs all 5 in parallel; no semaphore overhead | | |
|
||||
| B10.5 | Verify DM still delivers correctly to all recipients | functional parity | | |
|
||||
|
||||
### B11: kind:1059 subscription — no `since` filter
|
||||
| # | Step | Expected | Pass? | Notes |
|
||||
|---|------|----------|-------|-------|
|
||||
| B11.1 | Inspect the actual REQ message sent for kind:1059 subscription (e.g. via relay debug or `nostr-tool` proxy) | filter has `kinds:[1059]`, `#p:[user_pubkey]`, NO `since` field | | |
|
||||
| B11.2 | Have someone send you a DM with `created_at = now() - 1.5 days` (use a custom client) | wrap arrives, unread badge increments | | |
|
||||
| B11.3 | Send self a DM, restart app, verify still loaded | persistent dedupe still working | | |
|
||||
|
||||
### B12: SigningOpState.Progress
|
||||
| # | Step | Expected | Pass? | Notes |
|
||||
|---|------|----------|-------|-------|
|
||||
| B12.1 | Existing zap / sign flows: trigger a sign, inspect status bar | "Waiting for signer approval... (Ns)" — unchanged | | |
|
||||
| B12.2 | (Manual / future) Set `SigningState.updateProgress(2, 5)` from somewhere | Status bar shows "Signing (2 of 5)" | | |
|
||||
|
||||
---
|
||||
|
||||
## C. Cross-platform sanity (manual, Android only if you have a device)
|
||||
|
||||
### C1: Android — commons inheritance check
|
||||
| # | Step | Expected | Pass? | Notes |
|
||||
|---|------|----------|-------|-------|
|
||||
| C1.1 | Build and install `./gradlew :amethyst:installDebug` | Android app launches | | |
|
||||
| C1.2 | Send a NIP-17 DM from Android | works as before (Android doesn't yet use DmInboxRelayResolver / DesktopAuthCoordinator) | | |
|
||||
| C1.3 | `User.dmInboxRelays()` behaviour: unchanged on Android | no regression | | |
|
||||
| C1.4 | Confirm Android signing still goes through Android-only `AuthCoordinator` (not the new desktop one) | platform separation intact | | |
|
||||
|
||||
---
|
||||
|
||||
## D. Security audit (mostly Claude-verifiable)
|
||||
|
||||
### D1: Code/git inspection
|
||||
| # | Step | Expected | Pass? | Notes |
|
||||
|---|------|----------|-------|-------|
|
||||
| D1.1 | `grep -rn "connectedRelays.value" desktopApp/.../DesktopIAccount.kt` | Zero remaining matches in NIP-17 paths (NIP-04 path may keep its broadcast-to-connected behaviour by design) | ✅ | 3 hits: lines 113-114 (`DesktopAccountRelays` defaults — unrelated), line 173 (NIP-04 broadcast — intentional). All three NIP-17 send paths use `resolveDmInboxRelaysStrict`. |
|
||||
| D1.2 | `grep -rn "RelayAuthenticator" desktopApp/` | Only `DesktopAuthCoordinator` references; no other coordinator | ✅ | Single construction site: `DesktopAuthCoordinator.kt:106`. Doc-strings reference it elsewhere; no other coordinator class. |
|
||||
| D1.3 | DmInboxRelayResolver uses a NostrClient distinct from `relayManager.client` | verified in Main.kt | ✅ | Main.kt:848-853 constructs `indexerClient = NostrClient(BasicOkHttpWebSocket.Builder(...))` separately; never passed `RelayAuthenticator`. |
|
||||
| D1.4 | `grep -rn "dmInboxOrFallback" commons/ desktopApp/` | Zero matches — resolver uses `lists.dmInbox` strict | ✅ | Zero matches outside the Quartz definition site. |
|
||||
|
||||
### D2: Threat checks
|
||||
| # | Step | Expected | Pass? | Notes |
|
||||
|---|------|----------|-------|-------|
|
||||
| D2.1 | Inspect a single wrap's `p` tag: confirm only ONE pubkey listed (the recipient), no leakage of group members | wrap p-tag is single-recipient | | |
|
||||
| D2.2 | Logout: verify `PreferencesAuthApprovalStore.clear()` is called for each account | (currently called via `DesktopAuthCoordinator.onLogout`'s `tearDownLocked`; but does it call `store.clear()`? Looking at code: it does NOT call clear — see follow-up below.) | | This is a known gap. See note. |
|
||||
|
||||
### Known gap surfaced during sheet writing
|
||||
|
||||
**D2.2**: `DesktopAuthCoordinator.onLogout` tears down the authenticator and completes pending deferreds, but does **not** call `store.clear()`. This is by design (`ALWAYS`/`BLOCKED` decisions persist across login sessions for the same account, scoped by `account_pubkey` in the Preferences node). Account deletion (separate from logout) is the trigger that should call `clear()`. **Follow-up:** verify Amethyst Desktop account-delete path calls `PreferencesAuthApprovalStore(pubKey).clear()`. Not in scope of this branch.
|
||||
|
||||
---
|
||||
|
||||
## E. Sign-off
|
||||
|
||||
| Section | Pass? | Notes |
|
||||
|---|---|---|
|
||||
| A — Automated | | |
|
||||
| B — Desktop manual | | |
|
||||
| C — Android sanity | | |
|
||||
| D — Security audit | | |
|
||||
| **Overall** | | |
|
||||
|
||||
---
|
||||
|
||||
## What this branch does NOT ship (out-of-scope for testing)
|
||||
|
||||
These were captured in the deepening synthesis and are explicit follow-ups, NOT regressions:
|
||||
|
||||
- Persistent retry queue with exp-backoff (SQLite-backed) — substrate not yet built
|
||||
- Per-message delivery state in chat bubbles (`✓` `✓✓` `⟳` `⚠`) — `DmSendTracker` is still global
|
||||
- Bunker progress UI wiring — `SigningOpState.Progress` substrate landed but no caller yet emits per-step counts
|
||||
- Window-focus re-AUTH coordinator — replaced with lazy reactive AUTH per deepening
|
||||
- NIP-46 batch `get_conversation_keys` RPC — spec PR proposed in plan, no implementation
|
||||
- Android UI parity for AUTH banner — Android stays on its existing unconditional `AuthCoordinator`
|
||||
- Manual relay-entry dialog when recipient has no 10050 — replaced with Snackbar-equivalent "no relays" failure today
|
||||
- NIP-09 deletion of self-copies on kind:10050 rotation — release-note disclosure only
|
||||
@@ -0,0 +1,731 @@
|
||||
# Desktop DM Reliability — Testing Playbook
|
||||
|
||||
**Branch:** `feat/desktop-dm-reliability` rebased onto `upstream/main`
|
||||
**Tester:** _______________________
|
||||
**Date:** _______________________
|
||||
|
||||
**Instructions:** Follow this top to bottom. Every step is an action or an observation. Don't skip ahead — later tests assume state from earlier ones. Total ≈ 40 min for full pass.
|
||||
|
||||
---
|
||||
|
||||
## Session results — 2026-07-09 (live run)
|
||||
|
||||
| Test | Result | Notes |
|
||||
|------|--------|-------|
|
||||
| T1 startup / AUTH wired | ✅ PASS | both `Init, Subscribe` + `AUTH wired` logged; no CME crash |
|
||||
| T2 tier-1 self-DM (no banner) | ✅ PASS | published, no `PendingAuthApproval` prompt |
|
||||
| T3.a tier-2 banner render | ✅ PASS | `relay.ditto.pub` banner, icon clear of traffic lights, 3 buttons |
|
||||
| T3.c `Always` persists | ✅ PASS | `auth/<pubkey>/ wss://relay.ditto.pub/ = ALWAYS` in the plist |
|
||||
| T6 no-10050 blocks send | ✅ PASS | "Recipient has no DM relay list", send disabled |
|
||||
| T6b kind:10002-only blocks | ✅ PASS | same block; zero publish to the NIP-65 read relay |
|
||||
| T8 wrap `p`-tag relay hint | ✅ PASS (after fix) | 3-element `["p", hex, wss://nos.lol/]` on the wrap |
|
||||
| T12 kind:1059 sub has no `since` | ✅ PASS | `since` removed from `giftWrapsToMe` signature |
|
||||
| T3.b `Once` / T3.d `Never` | ⏭️ not run | logic covered by `AuthApprovalEndToEndTest` |
|
||||
| T9 group rumor.id | ⏭️ covered-by-construction | rumor signed once before the per-recipient loop |
|
||||
| T10 AUTH-under-load / T11 bunker | ⏭️ not run | no challenging relay / bunker on hand |
|
||||
|
||||
**Bugs found & fixed this run:**
|
||||
1. `DmInboxRelayResolver` LocalCache fast-path used lenient `dmInboxRelays()` → NIP-65 read-relay leak. Now `dmInboxRelaysStrict()`.
|
||||
2. `NewDmDialog` rendered pasted npubs of metadata-less users as non-clickable → couldn't start a DM by npub. Now `getOrCreateUser`.
|
||||
3. NIP-17 `p`-tag relay hint was plumbed in quartz but never passed by `DesktopIAccount` → every wrap shipped a 2-element `p` tag. Now wired.
|
||||
|
||||
---
|
||||
|
||||
## Setup (once, ~3 min)
|
||||
|
||||
**1.** In a terminal, cd to the worktree and confirm you're on the right commit:
|
||||
|
||||
```bash
|
||||
cd /path/to/AmethystMultiplatform/.worktrees/feat/desktop-dm-reliability
|
||||
git rev-parse HEAD
|
||||
```
|
||||
|
||||
- Expect: `fcfc43eb44` (or later). If different: `git pull` and re-verify.
|
||||
|
||||
**2.** Wipe any prior AUTH grants so persistence tests start clean:
|
||||
|
||||
```bash
|
||||
rm -rf ~/.java/.userPrefs/com/vitorpamplona/amethyst/desktop/auth
|
||||
```
|
||||
|
||||
**3.** Launch the app (keep this terminal visible — we'll read logs from it):
|
||||
|
||||
```bash
|
||||
./gradlew :desktopApp:run
|
||||
```
|
||||
|
||||
- Expect: window appears in 15–30 s (cold) / 5 s (warm).
|
||||
|
||||
**4.** In the app, log in with your primary account. Call this **User A**.
|
||||
|
||||
- Expect: sidebar loads, feed populates.
|
||||
|
||||
**5.** In the terminal, look for these two lines (they appear within 5 s of login):
|
||||
|
||||
```
|
||||
[RelayAuthenticator] Init, Subscribe
|
||||
[DesktopAuthCoordinator] AUTH wired for <pubkey8>
|
||||
```
|
||||
|
||||
- **If both appear:** ✅ setup complete. Proceed to T1.
|
||||
- **If either is missing:** STOP. Tell me the terminal output.
|
||||
|
||||
---
|
||||
|
||||
## T1 — Startup smoke check (already ✅ during setup)
|
||||
|
||||
Nothing extra to do — the two log lines above ARE T1.
|
||||
|
||||
- [ ] **T1 PASS** — both `Init, Subscribe` and `AUTH wired for <pubkey8>` printed with no exceptions
|
||||
|
||||
---
|
||||
|
||||
## T2 — Tier-1 self-DM (no banner) — 2 min
|
||||
|
||||
**Goal:** verify your own DM-inbox relays auto-AUTH silently.
|
||||
|
||||
**Steps:**
|
||||
|
||||
**1.** In the sidebar, click **Chats** (chat bubble icon).
|
||||
|
||||
**2.** At the top of the conversation list, click the **`+` icon** (new conversation).
|
||||
|
||||
**3.** Paste your OWN npub into the recipient field. Confirm.
|
||||
|
||||
**4.** In the message box, type: `t1 self-dm test`
|
||||
|
||||
**5.** Watch the top of the content area (where the yellow banner would appear).
|
||||
|
||||
- **Expected:** send button enables blue → no yellow AUTH banner appears anywhere.
|
||||
|
||||
**6.** Click the **send arrow** (right side of the message box).
|
||||
|
||||
**7.** Wait 3 s. The message should appear in your inbox.
|
||||
|
||||
- **Expected:** message appears in the conversation. Terminal has NO `AuthApprovalPolicy` prompt lines.
|
||||
|
||||
**Record:**
|
||||
|
||||
- [ ] **T2.1** No AUTH banner appeared: **YES / NO**
|
||||
- [ ] **T2.2** Message arrived: **YES / NO**
|
||||
- [ ] **T2 PASS** — both YES
|
||||
|
||||
---
|
||||
|
||||
## T3 — Tier-2 banner + persistence — 8 min
|
||||
|
||||
**Goal:** trigger a challenge from an AUTH-required relay NOT in your `kind:10050`, verify the banner renders + all three buttons persist correctly.
|
||||
|
||||
### T3.a — Trigger the banner
|
||||
|
||||
**1.** Open Settings. (Look for a gear/cog icon in the sidebar. If absent, try the app menu → Settings.)
|
||||
|
||||
**2.** Go to the **Relays** tab.
|
||||
|
||||
**3.** Find the "Add relay" input. Paste: `wss://pyramid.fiatjaf.com`
|
||||
|
||||
**4.** Save/apply (button label varies — usually "Add" or "Save").
|
||||
|
||||
**5.** Wait 1–3 s. Watch the **top of the content area** (below the title bar, above the main content).
|
||||
|
||||
- **Expected:** a yellow-tinted horizontal row slides in showing:
|
||||
- Lock icon on the left (with proper margin from window edge — 80dp — not overlapping the traffic lights)
|
||||
- `pyramid.fiatjaf.com` in a semi-bold heading
|
||||
- Subtext: "requires authentication to deliver this message"
|
||||
- Three buttons on the right: **`Once`** **`Always`** **`Never`**
|
||||
|
||||
Record:
|
||||
|
||||
- [ ] **T3.a.1** Banner appeared within 3 s: **YES / NO**
|
||||
- [ ] **T3.a.2** Icon + text NOT overlapping traffic lights: **YES / NO**
|
||||
- [ ] **T3.a.3** All three buttons visible: **YES / NO**
|
||||
|
||||
### T3.b — `[Once]` behaviour (session-only, no persistence)
|
||||
|
||||
**6.** Click `Once`.
|
||||
|
||||
- **Expected:** banner slides away immediately, no visible change to relay state.
|
||||
|
||||
**7.** In a second terminal, check the Preferences store did NOT get written for this relay.
|
||||
|
||||
> **Prefs location (macOS).** This JVM uses the `MacOSXPreferences` backing
|
||||
> store, NOT `~/.java/.userPrefs`. Java prefs land in
|
||||
> `~/Library/Preferences/com.vitorpamplona.amethyst.plist` under an
|
||||
> `auth/<full-pubkey>/` node. Read it with `plutil`:
|
||||
|
||||
```bash
|
||||
plutil -convert xml1 -o - ~/Library/Preferences/com.vitorpamplona.amethyst.plist | grep -i "pyramid\|ditto"
|
||||
```
|
||||
|
||||
- **Expected:** empty output (ONCE is not persisted).
|
||||
|
||||
**8.** Close the app (Cmd+Q). Wait 2 s. Relaunch via `./gradlew :desktopApp:run`. Log in as A again.
|
||||
|
||||
**9.** Wait ~5 s. The banner for `pyramid.fiatjaf.com` should reappear (session state was not saved).
|
||||
|
||||
Record:
|
||||
|
||||
- [ ] **T3.b.1** After `[Once]`: Preferences NOT written: **YES / NO**
|
||||
- [ ] **T3.b.2** After restart: banner reappeared: **YES / NO**
|
||||
|
||||
### T3.c — `[Always]` behaviour (persisted grant)
|
||||
|
||||
**10.** In the banner that just reappeared, click `Always`.
|
||||
|
||||
- **Expected:** banner slides away, `pyramid.fiatjaf.com` now shows "Authenticated" in Settings → Relays.
|
||||
|
||||
**11.** Check Preferences was written:
|
||||
|
||||
```bash
|
||||
plutil -convert xml1 -o - ~/Library/Preferences/com.vitorpamplona.amethyst.plist | grep -i "pyramid\|ditto\|ALWAYS"
|
||||
```
|
||||
|
||||
- **Expected:** the relay URL (e.g. `wss://relay.ditto.pub/`) followed by `ALWAYS`, under the `auth/<full-pubkey>/` node.
|
||||
|
||||
**12.** Close app (Cmd+Q). Relaunch. Log in as A.
|
||||
|
||||
- **Expected:** relay auto-authenticates in the background. **No banner appears** for `pyramid.fiatjaf.com`.
|
||||
|
||||
Record:
|
||||
|
||||
- [ ] **T3.c.1** Preferences shows `ALWAYS`: **YES / NO**
|
||||
- [ ] **T3.c.2** After restart: no banner, auto-authenticated: **YES / NO**
|
||||
|
||||
### T3.d — `[Never]` behaviour (persisted block)
|
||||
|
||||
**13.** Add a different AUTH-required relay. If you have another, use it. Otherwise, try `wss://nostr.wine` (they AUTH-challenge non-subscribers) or `wss://relay.snort.social`.
|
||||
|
||||
**14.** Wait for the new banner to appear.
|
||||
|
||||
**15.** Click `Never`.
|
||||
|
||||
- **Expected:** banner disappears. The relay shows as connected but "not authenticated".
|
||||
|
||||
**16.** Check Preferences:
|
||||
|
||||
```bash
|
||||
plutil -convert xml1 -o - ~/Library/Preferences/com.vitorpamplona.amethyst.plist | grep -i "<relay-domain>\|BLOCKED"
|
||||
```
|
||||
|
||||
- **Expected:** the relay URL followed by `BLOCKED`, under the `auth/<full-pubkey>/` node.
|
||||
|
||||
**17.** Restart the app. Log in as A.
|
||||
|
||||
- **Expected:** the BLOCKED relay never surfaces a banner. It stays "not authenticated". No `kind:22242` AUTH event ever sent to it.
|
||||
|
||||
Record:
|
||||
|
||||
- [ ] **T3.d.1** Preferences shows `BLOCKED`: **YES / NO**
|
||||
- [ ] **T3.d.2** After restart: no banner, no AUTH sent: **YES / NO**
|
||||
|
||||
---
|
||||
|
||||
**T3 sign-off:** Complete `T3.a`, `T3.b`, `T3.c`, `T3.d`.
|
||||
|
||||
- [ ] **T3 PASS** — all four subs green
|
||||
|
||||
---
|
||||
|
||||
## T4 — Multiple concurrent banners — 3 min
|
||||
|
||||
**Goal:** verify multiple pending banners stack correctly and resolve independently.
|
||||
|
||||
**Steps:**
|
||||
|
||||
**1.** In Settings → Relays, quickly add 3 different AUTH-required relays back-to-back. Suggested set:
|
||||
- `wss://pyramid.fiatjaf.com` (if not already blocked/allowed)
|
||||
- `wss://relay.nostr.com.au`
|
||||
- `wss://nostr.wine`
|
||||
|
||||
**2.** Watch the banner area — all 3 rows should appear stacked vertically within ~3 s.
|
||||
|
||||
**3.** Click `Once` on the **middle** row.
|
||||
|
||||
- **Expected:** ONLY the middle row disappears. The other two remain visible.
|
||||
|
||||
**4.** (Optional stress test) Add 5+ more AUTH-required relays.
|
||||
|
||||
- **Expected:** first 3 shown inline; row at the bottom reads "+N more relays pending approval".
|
||||
|
||||
Record:
|
||||
|
||||
- [ ] **T4.1** 3 banners stack vertically: **YES / NO**
|
||||
- [ ] **T4.2** Middle-row dismiss only affects itself: **YES / NO**
|
||||
- [ ] **T4.3** "+N more" row shows when >3 pending: **YES / NO**
|
||||
- [ ] **T4 PASS** — all three YES
|
||||
|
||||
---
|
||||
|
||||
## T5 — Per-account isolation — 4 min
|
||||
|
||||
**Goal:** verify AUTH grants are scoped per-account and cleaned on logout.
|
||||
|
||||
**Steps:**
|
||||
|
||||
**1.** Ensure A has at least one `ALWAYS` grant (from T3.c: `pyramid.fiatjaf.com`).
|
||||
|
||||
**2.** Log out of A (sidebar → profile → Logout, or app menu).
|
||||
|
||||
- **Terminal:** watch for `DesktopAuthCoordinator` teardown lines (no exceptions).
|
||||
|
||||
**3.** Log in as User B (different pubkey — nsec, npub, or bunker).
|
||||
|
||||
- **Terminal:** expect `[DesktopAuthCoordinator] AUTH wired for <B-pubkey8>` — different from A's.
|
||||
|
||||
**4.** Add `wss://pyramid.fiatjaf.com` in Settings → Relays for B.
|
||||
|
||||
- **Expected:** banner appears (B does NOT inherit A's `ALWAYS` grant).
|
||||
|
||||
**5.** Check that A's and B's Preferences are separate:
|
||||
|
||||
```bash
|
||||
ls ~/.java/.userPrefs/com/vitorpamplona/amethyst/desktop/auth/
|
||||
```
|
||||
|
||||
- **Expected:** two directories, one per full pubkey.
|
||||
|
||||
**6.** Click `Always` on B's banner.
|
||||
|
||||
**7.** Log out of B. Log back into A.
|
||||
|
||||
- **Terminal:** `AUTH wired for <A-pubkey8>` again.
|
||||
|
||||
**8.** Watch for banners.
|
||||
|
||||
- **Expected:** no banner for `pyramid.fiatjaf.com` (A's `ALWAYS` still persisted).
|
||||
|
||||
Record:
|
||||
|
||||
- [ ] **T5.1** Coordinator teardown clean on logout (no exceptions): **YES / NO**
|
||||
- [ ] **T5.2** B sees banner for A-approved relay (isolation): **YES / NO**
|
||||
- [ ] **T5.3** Two separate Preferences dirs exist: **YES / NO**
|
||||
- [ ] **T5.4** Re-login to A: no re-prompt: **YES / NO**
|
||||
- [ ] **T5 PASS** — all four YES
|
||||
|
||||
---
|
||||
|
||||
## T6 — P0 security fix (no-10050 recipient) — 5 min
|
||||
|
||||
**Goal:** verify DMs are NOT silently broadcast to your general relays when the recipient has no `kind:10050`.
|
||||
|
||||
### T6.a — Create a "no-inbox" test recipient
|
||||
|
||||
**1.** In a terminal, generate a fresh nsec/npub pair:
|
||||
|
||||
```bash
|
||||
# Option: use nak
|
||||
nak key generate
|
||||
# Copy the printed nsec and npub
|
||||
```
|
||||
|
||||
Or use any known npub of an account that never published `kind:10050`.
|
||||
|
||||
**2.** In Amethyst as A, click **`+`** in Chats. Paste the test npub. Confirm.
|
||||
|
||||
### T6.b — Verify the UI blocks send
|
||||
|
||||
**3.** Type any message.
|
||||
|
||||
**4.** Look at the row below the message input.
|
||||
|
||||
- **Expected:** red text "**Recipient has no DM relay list — messages cannot be delivered**"
|
||||
- **Expected:** send button is grey/disabled.
|
||||
|
||||
**5.** Try clicking send anyway.
|
||||
|
||||
- **Expected:** nothing happens (button disabled). Or, if enabled by upstream UI quirk, `DmSendTracker` shows "No relays available" briefly.
|
||||
|
||||
### T6.c — Verify no wrap leaves the app (optional, for the security-conscious)
|
||||
|
||||
**6.** In a terminal, run:
|
||||
|
||||
```bash
|
||||
sudo tcpdump -i any -A -s 0 'tcp port 443 or tcp port 80' 2>/dev/null | grep -i "kind\":1059"
|
||||
```
|
||||
|
||||
**7.** In the app, try to send. Watch the tcpdump output for 30 s.
|
||||
|
||||
- **Expected:** zero output. No gift wrap (kind 1059) publishes anywhere.
|
||||
|
||||
**8.** Stop tcpdump with Ctrl+C.
|
||||
|
||||
Record:
|
||||
|
||||
- [ ] **T6.1** UI shows "no DM relay list" warning: **YES / NO**
|
||||
- [ ] **T6.2** Send button disabled: **YES / NO**
|
||||
- [ ] **T6.3** No `kind":1059` in outgoing traffic during send attempt: **YES / NO / SKIPPED**
|
||||
- [ ] **T6 PASS** — T6.1 and T6.2 both YES (T6.3 optional but recommended)
|
||||
|
||||
---
|
||||
|
||||
## T6b — Strict kind:10050 (NIP-65 read-relay non-leak) — 4 min
|
||||
|
||||
**Goal:** verify the review fix — a recipient that DOES publish NIP-65 read
|
||||
relays (kind:10002) but has NO `kind:10050` is still treated as unreachable.
|
||||
The lenient fast-path bug would have published the wrap to those NIP-65 read
|
||||
relays; the fix must NOT.
|
||||
|
||||
### T6b.a — Create a recipient with kind:10002 but no kind:10050
|
||||
|
||||
**1.** Generate a fresh key and publish ONLY a NIP-65 relay list (no 10050):
|
||||
|
||||
```bash
|
||||
nak key generate # copy nsec + npub
|
||||
# publish a kind:10002 with a read relay, and NO kind:10050:
|
||||
echo '{"kind":10002,"tags":[["r","wss://relay.damus.io","read"]],"content":""}' \
|
||||
| nak event --sec <nsec> wss://relay.damus.io wss://nos.lol
|
||||
```
|
||||
|
||||
**2.** As User A, open a new chat to that npub so A's LocalCache ingests the
|
||||
recipient's kind:10002 (send/hover the profile so the relay list loads).
|
||||
|
||||
### T6b.b — Verify send is blocked, not routed to the read relay
|
||||
|
||||
**3.** Type a message. Observe the row under the input.
|
||||
|
||||
- **Expected:** same "no DM relay list — messages cannot be delivered"
|
||||
warning as T6; send disabled.
|
||||
- **Wrong (pre-fix bug):** send is ENABLED and the wrap goes to
|
||||
`wss://relay.damus.io` (the recipient's NIP-65 *read* relay).
|
||||
|
||||
**4.** (Optional, definitive) tcpdump as in T6.c while attempting send.
|
||||
|
||||
- **Expected:** zero `kind":1059` frames to the recipient's kind:10002 relays.
|
||||
|
||||
Record:
|
||||
|
||||
- [ ] **T6b.1** Send blocked despite recipient having kind:10002: **YES / NO**
|
||||
- [ ] **T6b.2** No wrap sent to NIP-65 read relay (if tcpdump run): **YES / NO / SKIPPED**
|
||||
- [ ] **T6b PASS** — T6b.1 YES
|
||||
|
||||
---
|
||||
|
||||
## T7 — Indexer fan-out + F-01 unauth check — 6 min
|
||||
|
||||
**Goal:** verify the resolver probes indexer relays with an UNAUTHENTICATED client (no `kind:22242` AUTH events leaked to indexers).
|
||||
|
||||
### T7.a — Prime the state
|
||||
|
||||
**1.** Restart the app (Cmd+Q, then `./gradlew :desktopApp:run`).
|
||||
|
||||
- Fresh LocalCache = maximum chance the resolver actually fires.
|
||||
|
||||
**2.** Log in as A.
|
||||
|
||||
### T7.b — Set up traffic capture (optional but revealing)
|
||||
|
||||
**3.** In a second terminal, start capturing all WebSocket traffic:
|
||||
|
||||
```bash
|
||||
sudo tshark -i any -Y 'websocket' -T fields -e ws.payload 2>/dev/null | head -c 100000
|
||||
```
|
||||
|
||||
Or (simpler):
|
||||
|
||||
```bash
|
||||
sudo tcpdump -i any -A -s 0 'tcp port 443' 2>/dev/null > /tmp/dm-traffic.log &
|
||||
```
|
||||
|
||||
### T7.c — Trigger the resolver
|
||||
|
||||
**4.** Pick a recipient who HAS a `kind:10050` published (a NIP-17-active account) but whom you have NEVER DM'd from account A.
|
||||
|
||||
**5.** In Amethyst, click **`+`** in Chats. Paste the recipient's npub. Confirm.
|
||||
|
||||
**6.** Watch the pre-send row.
|
||||
|
||||
- **Expected sequence:**
|
||||
- Initial: red "no DM relay list" warning (LocalCache miss).
|
||||
- Within 2–5 s: warning disappears (resolver probe found the recipient's `kind:10050` on an indexer).
|
||||
- Send button turns blue.
|
||||
|
||||
### T7.d — Verify F-01 (no AUTH to indexer)
|
||||
|
||||
**7.** Search the captured traffic for `kind:22242` AUTH events:
|
||||
|
||||
```bash
|
||||
grep -i "\"kind\":22242" /tmp/dm-traffic.log | head -20
|
||||
```
|
||||
|
||||
- **Expected:** any `kind:22242` events found should only be to relays in your existing DM-inbox set — NOT to the indexer set (`relay.nos.social`, `relay.damus.io`, `nos.lol`, `relay.nostr.band`, `purplerelay.com`).
|
||||
|
||||
**8.** In the app, type a message and send.
|
||||
|
||||
- **Expected:** send succeeds. Recipient's actual DM-inbox relay receives the wrap.
|
||||
|
||||
**9.** Stop tcpdump: `sudo pkill tcpdump`
|
||||
|
||||
### T7.e — Verify LRU cache hit on second send
|
||||
|
||||
**10.** Immediately compose a second DM to the same recipient. Send.
|
||||
|
||||
- **Expected:** send is immediate, no delay. Resolver hits its LRU cache, no new indexer probe.
|
||||
|
||||
Record:
|
||||
|
||||
- [ ] **T7.1** Warning cleared within 5 s (resolver probe worked): **YES / NO**
|
||||
- [ ] **T7.2** Send button became enabled after probe: **YES / NO**
|
||||
- [ ] **T7.3** No `kind:22242` AUTH sent to indexer relays: **YES / NO / SKIPPED**
|
||||
- [ ] **T7.4** DM delivered to recipient: **YES / NO**
|
||||
- [ ] **T7.5** Second DM to same recipient: no probe delay: **YES / NO**
|
||||
- [ ] **T7 PASS** — T7.1, T7.2, T7.4, T7.5 all YES
|
||||
|
||||
---
|
||||
|
||||
## T8 — Wrap `p`-tag relay hint — 4 min
|
||||
|
||||
**Goal:** verify the outgoing gift wrap includes the recipient's primary DM relay as the third element of the `p` tag.
|
||||
|
||||
**Steps:**
|
||||
|
||||
**1.** Send a DM to any recipient with a known `kind:10050` (e.g. the one from T7).
|
||||
|
||||
**2.** In a terminal, use `nak` (or `websocat`) to query one of the recipient's DM-inbox relays for their gift wraps:
|
||||
|
||||
```bash
|
||||
RECIPIENT_HEX=<paste-recipient-hex-pubkey>
|
||||
DM_RELAY=<paste-one-of-their-10050-relays>
|
||||
|
||||
nak req -k 1059 --tag "p=$RECIPIENT_HEX" "$DM_RELAY" | head -5
|
||||
```
|
||||
|
||||
**3.** Find the wrap you just sent (highest `created_at`). Look at its `p` tag.
|
||||
|
||||
- **Expected:** `["p", "<recipient-hex>", "wss://recipient-primary-relay/"]` — 3 elements, third is a valid relay URL.
|
||||
|
||||
**4.** For contrast, send a DM to a recipient whose `kind:10050` you have NO indexer/cache hit for (e.g. the one from T6 if you have their nsec to simulate — otherwise skip).
|
||||
|
||||
- **Expected:** wrap's `p` tag has only 2 elements: `["p", "<hex>"]` — no fake empty third element.
|
||||
|
||||
Record:
|
||||
|
||||
- [ ] **T8.1** With known relay: 3-element `p` tag: **YES / NO**
|
||||
- [ ] **T8.2** Without known relay: 2-element `p` tag (no empty third): **YES / NO / SKIPPED**
|
||||
- [ ] **T8 PASS** — T8.1 YES
|
||||
|
||||
---
|
||||
|
||||
## T9 — Group DM shared `rumor.id` — 5 min
|
||||
|
||||
**Goal:** verify all recipient wraps in a group DM decrypt to a rumor with the SAME `id`.
|
||||
|
||||
**Steps:**
|
||||
|
||||
**1.** In Amethyst as A, click **`+`** in Chats. Add 3 recipient npubs (you can include yourself as one, plus 2 others whose `kind:10050` is known).
|
||||
|
||||
**2.** Type a distinctive message: `t9 group rumor coherence test`. Send.
|
||||
|
||||
**3.** For each recipient, use `nak` to fetch the gift wrap from their DM-inbox relay:
|
||||
|
||||
```bash
|
||||
for RECIPIENT in $RECIPIENT_A $RECIPIENT_B $RECIPIENT_C; do
|
||||
nak req -k 1059 --tag "p=$RECIPIENT" wss://relay.example/ | head -3
|
||||
done
|
||||
```
|
||||
|
||||
**4.** Ideally decrypt each wrap (requires each recipient's nsec). But since all 3 seals encode the same rumor, the rumor `id` should be identical across the 3 wraps.
|
||||
|
||||
**5.** If you have at least 2 recipient nsecs, decrypt via `nak`:
|
||||
|
||||
```bash
|
||||
nak decrypt --sec $NSEC "<encrypted-wrap-content>"
|
||||
# Look at the inner rumor's "id" field
|
||||
```
|
||||
|
||||
**6.** Compare the rumor `id` across the wraps.
|
||||
|
||||
- **Expected:** all 3 rumor `id`s are IDENTICAL.
|
||||
|
||||
**7.** (Bonus) Have one of the recipients (in another Amethyst instance or via nak) react to the message with `+`.
|
||||
|
||||
**8.** Confirm A and other recipients see the reaction.
|
||||
|
||||
- **Expected:** reaction targets the shared `rumor.id` and appears cross-recipient.
|
||||
|
||||
Record:
|
||||
|
||||
- [ ] **T9.1** All wraps decrypt to same rumor.id: **YES / NO / SKIPPED (needs multi-account decrypt)**
|
||||
- [ ] **T9.2** Cross-recipient reaction visible: **YES / NO / SKIPPED**
|
||||
- [ ] **T9 PASS** — T9.1 YES (or explicitly skipped)
|
||||
|
||||
---
|
||||
|
||||
## T10 — Outbox AUTH carve-out under load — 4 min
|
||||
|
||||
**Goal:** verify multiple queued events are NOT silently dropped during AUTH negotiation.
|
||||
|
||||
**Steps:**
|
||||
|
||||
**1.** In Settings → Relays, ensure you have `wss://pyramid.fiatjaf.com` connected. If you `[Always]`-approved it in T3.c, first log out and back in so the relay reconnects and re-challenges.
|
||||
|
||||
**2.** In the compose dialog, publish 5 notes rapidly (10 seconds apart is fine):
|
||||
|
||||
```
|
||||
t10 note 1
|
||||
t10 note 2
|
||||
t10 note 3
|
||||
t10 note 4
|
||||
t10 note 5
|
||||
```
|
||||
|
||||
**3.** In the terminal, watch for AUTH activity on `pyramid.fiatjaf.com`:
|
||||
|
||||
```
|
||||
[RelayAuthenticator] ... auth-required: ...
|
||||
[RelayAuthenticator] ... AUTH accepted ...
|
||||
```
|
||||
|
||||
**4.** Once AUTH completes, all 5 notes should publish to `pyramid.fiatjaf.com`.
|
||||
|
||||
**5.** Verify by querying `pyramid.fiatjaf.com` for your recent notes:
|
||||
|
||||
```bash
|
||||
nak req -a $A_HEX -k 1 wss://pyramid.fiatjaf.com | head -10
|
||||
```
|
||||
|
||||
- **Expected:** all 5 `t10 note N` events present on `pyramid.fiatjaf.com`.
|
||||
|
||||
Record:
|
||||
|
||||
- [ ] **T10.1** All 5 notes visible on `pyramid.fiatjaf.com`: **YES / NO**
|
||||
- [ ] **T10.2** Terminal shows AUTH succeeded before drops: **YES / NO**
|
||||
- [ ] **T10 PASS** — T10.1 YES
|
||||
|
||||
---
|
||||
|
||||
## T11 — Bunker `Semaphore(4)` (bunker users only) — 3 min
|
||||
|
||||
**Skip if you don't have a NIP-46 bunker (nsec.app / Amber).**
|
||||
|
||||
**Goal:** verify NIP-17 group DMs are rate-limited to ≤4 concurrent bunker RPCs.
|
||||
|
||||
**Steps:**
|
||||
|
||||
**1.** Log out. Log in with a bunker (`bunker://` URI).
|
||||
|
||||
**2.** Compose a group DM to **5 recipients** (5 different npubs with known `kind:10050`).
|
||||
|
||||
**3.** In nsec.app / Amber, watch the request feed as you click Send.
|
||||
|
||||
- **Expected:** at most **4** requests in-flight at any moment. Requests process in batches of 4.
|
||||
|
||||
**4.** For comparison: log out, log back in with a local nsec. Repeat the 5-recipient send.
|
||||
|
||||
- **Expected:** local signer runs all 5 requests in parallel (no Semaphore cap).
|
||||
|
||||
Record:
|
||||
|
||||
- [ ] **T11.1** Bunker: ≤4 concurrent RPCs: **YES / NO / SKIPPED (no bunker)**
|
||||
- [ ] **T11.2** Local: fully parallel: **YES / NO / SKIPPED**
|
||||
- [ ] **T11 PASS** — either both YES or both SKIPPED
|
||||
|
||||
---
|
||||
|
||||
## T12 — kind:1059 subscription has no `since` — 3 min
|
||||
|
||||
**Goal:** verify the outgoing REQ for gift wraps has NO `since` filter (would silently drop old-timestamped wraps).
|
||||
|
||||
**Steps:**
|
||||
|
||||
**1.** In one terminal, run a WebSocket relay proxy that echoes traffic (or use `nak` to inspect):
|
||||
|
||||
```bash
|
||||
# Simplest: read the desktop subscription code directly
|
||||
grep -n "FilterDMs.giftWrapsToMe\|since" desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterDMs.kt
|
||||
```
|
||||
|
||||
- **Expected:** signature `fun giftWrapsToMe(userPubKeyHex: HexKey)` — **NO `since` parameter**.
|
||||
|
||||
**2.** Live check (harder but definitive): use `mitmproxy` or `websocat` in proxy mode to intercept WebSocket traffic from the app.
|
||||
|
||||
**3.** Alternatively: rely on the unit tests. Confirm they pass:
|
||||
|
||||
```bash
|
||||
./gradlew :quartz:jvmTest --tests "com.vitorpamplona.quartz.nip59Giftwrap.wraps.*"
|
||||
```
|
||||
|
||||
- **Expected:** BUILD SUCCESSFUL.
|
||||
|
||||
Record:
|
||||
|
||||
- [ ] **T12.1** `giftWrapsToMe` signature has no `since`: **YES / NO**
|
||||
- [ ] **T12.2** Unit tests pass: **YES / NO**
|
||||
- [ ] **T12 PASS** — both YES
|
||||
|
||||
---
|
||||
|
||||
## T13 — Pre-send alignment + resolver probe (verified working) — sanity re-check, 3 min
|
||||
|
||||
**Already confirmed** during the pre-launch fix. Quick re-check:
|
||||
|
||||
**Steps:**
|
||||
|
||||
**1.** In Amethyst as A, open a fresh DM with a recipient whose `kind:10050` is NOT in your LocalCache (e.g. a fresh contact — click a new profile, then compose DM).
|
||||
|
||||
**2.** Watch the pre-send row.
|
||||
|
||||
- **Expected:** red "no DM relay list" warning appears initially.
|
||||
|
||||
**3.** Wait 2–5 s.
|
||||
|
||||
- **Expected:** warning clears on its own (resolver probe found the recipient via indexer). Send button turns blue.
|
||||
|
||||
Record:
|
||||
|
||||
- [ ] **T13.1** Warning appears initially: **YES / NO**
|
||||
- [ ] **T13.2** Warning clears within 5 s (resolver worked): **YES / NO**
|
||||
- [ ] **T13 PASS** — both YES
|
||||
|
||||
---
|
||||
|
||||
## Sign-off
|
||||
|
||||
| Test | Pass? | Notes |
|
||||
|---|---|---|
|
||||
| Setup | ⬜ | |
|
||||
| T1 startup wiring | ⬜ | |
|
||||
| T2 tier-1 self-DM | ⬜ | |
|
||||
| T3 tier-2 banner (T3.a–T3.d) | ⬜ | |
|
||||
| T4 multiple banners | ⬜ | |
|
||||
| T5 per-account isolation | ⬜ | |
|
||||
| **T6 P0 SECURITY** | ⬜ | Highest priority |
|
||||
| **T7 F-01 unauth indexer** | ⬜ | Highest priority |
|
||||
| T8 wrap p-tag relay hint | ⬜ | |
|
||||
| T9 group DM rumor id | ⬜ | |
|
||||
| T10 outbox AUTH carve-out | ⬜ | |
|
||||
| T11 bunker Semaphore | ⬜ | Skip if no bunker |
|
||||
| T12 no since filter | ⬜ | |
|
||||
| T13 pre-send alignment | ⬜ | |
|
||||
|
||||
**Overall:** ⬜ PASS — ready for PR / ⬜ FAIL — see blockers / ⬜ NEEDS REVISIT
|
||||
|
||||
**Blockers:** _______________________________________________________
|
||||
|
||||
**Tester signature:** _______________________ **Date:** _______________________
|
||||
|
||||
---
|
||||
|
||||
## Known pre-existing issues (NOT branch regressions)
|
||||
|
||||
- **`ConcurrentModificationException` at `RelayLatencyTracker.sweep:182`** during rapid account switching. Kills UI thread; coroutines keep running. Documented in memory `desktop_relay_health_cme_crash`.
|
||||
- **`NoClassDefFoundError` for `CompressionQuality`** on stale gradle daemon. Fix: `./gradlew --stop && ./gradlew :desktopApp:run`.
|
||||
|
||||
## Known non-issues (don't file as bugs)
|
||||
|
||||
- `[GiftWrapEvent] Couldn't Decrypt the content …` debug lines — normal LocalCache trial-decrypt for wraps not addressed to you.
|
||||
- VLC `securetransport tls client error` — pre-existing media playback warnings.
|
||||
- `[NIP19 Parser] Issue trying to Decode NIP19 …` — pre-existing, malformed identifiers in some events.
|
||||
- `DmBroadcastBanner` (send-progress) may render simultaneously with `AuthApprovalBanner` — distinguish by buttons: AUTH banner has `Once/Always/Never`; broadcast has send-count status.
|
||||
|
||||
## Out of scope for this branch
|
||||
|
||||
Explicit follow-ups per the deepening synthesis:
|
||||
- Persistent retry queue with exp-backoff (SQLite-backed)
|
||||
- Per-message delivery state in bubbles (`✓` `✓✓` `⟳` `⚠`)
|
||||
- Bunker progress UI wiring
|
||||
- Window-focus re-AUTH
|
||||
- NIP-46 batch `get_conversation_keys` RPC
|
||||
- Android UI parity for AUTH banner
|
||||
- Manual relay-entry dialog when recipient has no 10050
|
||||
- NIP-09 deletion of self-copies on kind:10050 rotation
|
||||
- Fix for pre-existing `RelayLatencyTracker.sweep` CME
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* 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.quartz.nip01Core.relay.client.auth
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
/**
|
||||
* Compose-stable per-relay AUTH snapshot exposed by [RelayAuthenticator].
|
||||
*
|
||||
* The internal [RelayAuthStatus] is a mutable holder around concurrent LRU
|
||||
* caches — necessary for the per-relay OkHttp dispatcher, but unsuitable as
|
||||
* a [kotlinx.coroutines.flow.StateFlow] value (mutating it doesn't change
|
||||
* identity, so distinct-until-changed swallows updates).
|
||||
*
|
||||
* [RelayAuthSnapshot] is the immutable view downstream consumers (UI banner,
|
||||
* retry coordinator, indexer-fan-out gate) subscribe to.
|
||||
*/
|
||||
@Immutable
|
||||
data class RelayAuthSnapshot(
|
||||
val phase: Phase,
|
||||
val lastAuthSuccessAt: Long?,
|
||||
) {
|
||||
enum class Phase {
|
||||
/** Connected; no AUTH challenge has been received yet. */
|
||||
IDLE,
|
||||
|
||||
/** Signed AUTH event in flight; awaiting OK from the relay. */
|
||||
AUTHENTICATING,
|
||||
|
||||
/** Last AUTH succeeded; relay accepts authenticated REQs. */
|
||||
AUTHENTICATED,
|
||||
|
||||
/** Last AUTH attempt failed; subsequent challenges may still arrive. */
|
||||
AUTH_FAILED,
|
||||
}
|
||||
|
||||
companion object {
|
||||
val IDLE = RelayAuthSnapshot(Phase.IDLE, lastAuthSuccessAt = null)
|
||||
}
|
||||
}
|
||||
+34
@@ -23,6 +23,8 @@ package com.vitorpamplona.quartz.nip01Core.relay.client.auth
|
||||
import androidx.collection.LruCache
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlin.concurrent.Volatile
|
||||
|
||||
class RelayAuthStatus {
|
||||
// Keeps track of auth responses to update the relay with all filters
|
||||
@@ -32,6 +34,12 @@ class RelayAuthStatus {
|
||||
// Avoids sending multiple replies for each auth.
|
||||
private val uniqueAuthChallengesSent: LruCache<ChallengePair, ChallengePair> = LruCache(10)
|
||||
|
||||
// Latest epoch-second at which a tracked AUTH event received a successful OK.
|
||||
// Read by RelayAuthSnapshot consumers for staleness checks (e.g. proactive
|
||||
// re-AUTH on window focus).
|
||||
@Volatile
|
||||
private var lastAuthSuccessAt: Long? = null
|
||||
|
||||
enum class AuthEventReceiptStatus {
|
||||
AUTHENTICATING,
|
||||
AUTHENTICATED,
|
||||
@@ -66,6 +74,7 @@ class RelayAuthStatus {
|
||||
return if (wasAlreadyAuthenticated != null) {
|
||||
if (success) {
|
||||
authResponseWatcher.put(eventId, AuthEventReceiptStatus.AUTHENTICATED)
|
||||
lastAuthSuccessAt = TimeUtils.now()
|
||||
} else {
|
||||
authResponseWatcher.put(eventId, AuthEventReceiptStatus.NOT_AUTHENTICATED)
|
||||
}
|
||||
@@ -77,4 +86,29 @@ class RelayAuthStatus {
|
||||
}
|
||||
|
||||
fun hasFinishedAllAuths() = authResponseWatcher.snapshot().all { it.value != AuthEventReceiptStatus.AUTHENTICATING }
|
||||
|
||||
/**
|
||||
* Build an immutable Compose-stable snapshot of the current per-relay AUTH
|
||||
* state. The phase is derived from the response watcher:
|
||||
*
|
||||
* - any AUTHENTICATING entry → [RelayAuthSnapshot.Phase.AUTHENTICATING]
|
||||
* - else any AUTHENTICATED entry → [RelayAuthSnapshot.Phase.AUTHENTICATED]
|
||||
* - else any NOT_AUTHENTICATED entry → [RelayAuthSnapshot.Phase.AUTH_FAILED]
|
||||
* - else (no tracked challenges) → [RelayAuthSnapshot.Phase.IDLE]
|
||||
*
|
||||
* The watcher LRU caps at 10 entries; a long-running connection that has
|
||||
* already AUTHed will still report AUTHENTICATED even after older entries
|
||||
* roll off, because the LRU keeps the most recent.
|
||||
*/
|
||||
fun snapshot(): RelayAuthSnapshot {
|
||||
val entries = authResponseWatcher.snapshot()
|
||||
val phase =
|
||||
when {
|
||||
entries.isEmpty() -> RelayAuthSnapshot.Phase.IDLE
|
||||
entries.values.any { it == AuthEventReceiptStatus.AUTHENTICATING } -> RelayAuthSnapshot.Phase.AUTHENTICATING
|
||||
entries.values.any { it == AuthEventReceiptStatus.AUTHENTICATED } -> RelayAuthSnapshot.Phase.AUTHENTICATED
|
||||
else -> RelayAuthSnapshot.Phase.AUTH_FAILED
|
||||
}
|
||||
return RelayAuthSnapshot(phase, lastAuthSuccessAt)
|
||||
}
|
||||
}
|
||||
|
||||
+38
-1
@@ -33,10 +33,16 @@ import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
|
||||
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.cache.LargeCache
|
||||
import kotlinx.collections.immutable.PersistentMap
|
||||
import kotlinx.collections.immutable.persistentMapOf
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
@@ -61,8 +67,32 @@ class RelayAuthenticator(
|
||||
// Connection callbacks fire on the per-relay OkHttp dispatcher thread, so
|
||||
// this state is mutated concurrently — LargeCache wraps a platform-tuned
|
||||
// concurrent map (ConcurrentSkipListMap on jvmAndroid, CacheMap on Apple).
|
||||
//
|
||||
// This stays mutable because RelayAuthStatus carries an LruCache that has
|
||||
// to be addressable from the dispatcher thread. The Compose-observable
|
||||
// view of the same data is published on [authStateFlow] below, sourced
|
||||
// from RelayAuthStatus.snapshot().
|
||||
private val authStatus = LargeCache<NormalizedRelayUrl, RelayAuthStatus>()
|
||||
|
||||
private val _authStateFlow = MutableStateFlow<PersistentMap<NormalizedRelayUrl, RelayAuthSnapshot>>(persistentMapOf())
|
||||
|
||||
/**
|
||||
* Per-relay AUTH state as an immutable Compose-stable snapshot map.
|
||||
*
|
||||
* Downstream consumers (UI banner, retry queue, indexer-fan-out gate)
|
||||
* subscribe to this flow instead of polling [authStatus] directly.
|
||||
* Identity changes on every mutation, so [kotlinx.coroutines.flow.distinctUntilChanged]
|
||||
* downstream and Compose `@Immutable` skipping both work correctly.
|
||||
*/
|
||||
val authStateFlow: StateFlow<PersistentMap<NormalizedRelayUrl, RelayAuthSnapshot>> = _authStateFlow.asStateFlow()
|
||||
|
||||
private fun publishSnapshot(relayUrl: NormalizedRelayUrl) {
|
||||
val status = authStatus.get(relayUrl)
|
||||
_authStateFlow.update { current ->
|
||||
if (status == null) current.remove(relayUrl) else current.put(relayUrl, status.snapshot())
|
||||
}
|
||||
}
|
||||
|
||||
private val clientListener =
|
||||
object : RelayConnectionListener {
|
||||
override fun onIncomingMessage(
|
||||
@@ -78,10 +108,12 @@ class RelayAuthenticator(
|
||||
|
||||
override fun onConnecting(relay: IRelayClient) {
|
||||
authStatus.put(relay.url, RelayAuthStatus())
|
||||
publishSnapshot(relay.url)
|
||||
}
|
||||
|
||||
override fun onDisconnected(relay: IRelayClient) {
|
||||
authStatus.remove(relay.url)
|
||||
publishSnapshot(relay.url)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +134,7 @@ class RelayAuthenticator(
|
||||
// only send replies to new challenges to avoid infinite loop:
|
||||
if (authStatus.get(relay.url)?.saveAuthSubmission(authEvent) == true) {
|
||||
relay.sendIfConnected(AuthCmd(authEvent))
|
||||
publishSnapshot(relay.url)
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
@@ -118,8 +151,12 @@ class RelayAuthenticator(
|
||||
relay: IRelayClient,
|
||||
msg: OkMessage,
|
||||
) {
|
||||
val transitioned = authStatus.get(relay.url)?.checkAuthResults(msg.eventId, msg.success) == true
|
||||
// Publish even on failure transitions so the UI can clear "AUTHENTICATING"
|
||||
// banners and reflect AUTH_FAILED state.
|
||||
publishSnapshot(relay.url)
|
||||
// if this is the OK of an auth event, renew all subscriptions and resend all outgoing events.
|
||||
if (authStatus.get(relay.url)?.checkAuthResults(msg.eventId, msg.success) == true) {
|
||||
if (transitioned) {
|
||||
client.syncFilters(relay)
|
||||
}
|
||||
}
|
||||
|
||||
+7
-1
@@ -66,11 +66,15 @@ class PoolEventOutboxState(
|
||||
success: Boolean,
|
||||
message: String,
|
||||
) {
|
||||
val currentTries = failures[url]
|
||||
if (success || message.shouldDiscard()) {
|
||||
relaysRemaining = relaysRemaining - url
|
||||
failures = failures - url
|
||||
} else if (message.isAuthRequired()) {
|
||||
// NIP-42 AUTH challenge in flight — don't count toward the try cap.
|
||||
// RelayAuthenticator signs + relay re-issues OK; syncFilters() then
|
||||
// re-pumps this outbox so the original publish is retried.
|
||||
} else {
|
||||
val currentTries = failures[url]
|
||||
if (currentTries != null) {
|
||||
currentTries.addResponse(message)
|
||||
} else {
|
||||
@@ -91,6 +95,8 @@ class PoolEventOutboxState(
|
||||
this.startsWith("deleted:") ||
|
||||
this.startsWith("invalid:")
|
||||
|
||||
fun String.isAuthRequired() = this.startsWith("auth-required:")
|
||||
|
||||
// Tries 3 times
|
||||
class Tries(
|
||||
var tries: List<Long> = listOf(),
|
||||
|
||||
@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.nip17Dm
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds
|
||||
@@ -33,9 +34,12 @@ import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
|
||||
import com.vitorpamplona.quartz.nip40Expiration.expiration
|
||||
import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.utils.mapNotNullAsync
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
|
||||
class NIP17Factory {
|
||||
data class Result(
|
||||
@@ -43,10 +47,34 @@ class NIP17Factory {
|
||||
val wraps: List<GiftWrapEvent>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Build one NIP-59 gift wrap per recipient.
|
||||
*
|
||||
* The rumor (kind 14) `created_at` is implicitly shared across all wraps
|
||||
* because [event] is signed once by the caller before the per-recipient
|
||||
* loop runs — every seal encodes the same rumor `id`. This anchors
|
||||
* cross-recipient dedupe + reaction/receipt targeting on group sends.
|
||||
*
|
||||
* Per NIP-17, the gift wrap's `p` tag MAY carry the recipient's primary
|
||||
* DM inbox relay as a hint. Pass [recipientRelayHints] to surface those;
|
||||
* the default `{ null }` lambda preserves the historical 2-element tag
|
||||
* shape for every recipient.
|
||||
*
|
||||
* When [signer] is a [NostrSignerRemote] (NIP-46 bunker), seal building
|
||||
* is rate-limited to [BUNKER_PARALLELISM] concurrent operations. Each
|
||||
* seal needs `nip44_encrypt` + `sign` round-trips against the bunker; a
|
||||
* 5-recipient group otherwise launches 10 concurrent in-flight RPCs and
|
||||
* saturates the bunker socket. Local signers (NostrSignerInternal,
|
||||
* NostrSignerSync) run fully parallel — no semaphore overhead.
|
||||
*
|
||||
* The proper fix is the batched `nip44_get_conversation_keys` NIP-46
|
||||
* RPC (separate plan); this is the interim throttle until that lands.
|
||||
*/
|
||||
private suspend fun createWraps(
|
||||
event: Event,
|
||||
to: Set<HexKey>,
|
||||
signer: NostrSigner,
|
||||
recipientRelayHints: (HexKey) -> NormalizedRelayUrl? = { null },
|
||||
): List<GiftWrapEvent> {
|
||||
val innerExpDelta =
|
||||
event.expiration()?.let {
|
||||
@@ -57,29 +85,47 @@ class NIP17Factory {
|
||||
}
|
||||
}
|
||||
|
||||
val bunkerLimiter = if (signer is NostrSignerRemote) Semaphore(BUNKER_PARALLELISM) else null
|
||||
|
||||
return mapNotNullAsync(
|
||||
to.toList(),
|
||||
) { next ->
|
||||
GiftWrapEvent.create(
|
||||
event =
|
||||
SealedRumorEvent.create(
|
||||
event = event,
|
||||
encryptTo = next,
|
||||
expirationDelta = innerExpDelta,
|
||||
signer = signer,
|
||||
),
|
||||
recipientPubKey = next,
|
||||
expirationDelta = innerExpDelta,
|
||||
)
|
||||
val build: suspend () -> GiftWrapEvent = {
|
||||
GiftWrapEvent.create(
|
||||
event =
|
||||
SealedRumorEvent.create(
|
||||
event = event,
|
||||
encryptTo = next,
|
||||
expirationDelta = innerExpDelta,
|
||||
signer = signer,
|
||||
),
|
||||
recipientPubKey = next,
|
||||
expirationDelta = innerExpDelta,
|
||||
recipientRelayHint = recipientRelayHints(next),
|
||||
)
|
||||
}
|
||||
bunkerLimiter?.withPermit { build() } ?: build()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Max concurrent in-flight NIP-46 RPCs when building wraps via a
|
||||
* remote signer. Empirically a sweet spot — covers parallelism
|
||||
* speedup for 2–4 recipient sends without saturating typical
|
||||
* bunker apps (nsec.app, Amber, Keychat) that serialize requests
|
||||
* internally past ~10 in-flight.
|
||||
*/
|
||||
const val BUNKER_PARALLELISM = 4
|
||||
}
|
||||
|
||||
suspend fun createMessageNIP17(
|
||||
template: EventTemplate<ChatMessageEvent>,
|
||||
signer: NostrSigner,
|
||||
recipientRelayHints: (HexKey) -> NormalizedRelayUrl? = { null },
|
||||
): Result {
|
||||
val senderMessage = signer.sign(template)
|
||||
val wraps = createWraps(senderMessage, senderMessage.groupMembers(), signer)
|
||||
val wraps = createWraps(senderMessage, senderMessage.groupMembers(), signer, recipientRelayHints)
|
||||
return Result(
|
||||
msg = senderMessage,
|
||||
wraps = wraps,
|
||||
@@ -108,9 +154,10 @@ class NIP17Factory {
|
||||
suspend fun createEncryptedFileNIP17(
|
||||
template: EventTemplate<ChatMessageEncryptedFileHeaderEvent>,
|
||||
signer: NostrSigner,
|
||||
recipientRelayHints: (HexKey) -> NormalizedRelayUrl? = { null },
|
||||
): Result {
|
||||
val senderMessage = signer.sign(template)
|
||||
val wraps = createWraps(senderMessage, senderMessage.groupMembers(), signer)
|
||||
val wraps = createWraps(senderMessage, senderMessage.groupMembers(), signer, recipientRelayHints)
|
||||
|
||||
return Result(
|
||||
msg = senderMessage,
|
||||
@@ -142,12 +189,13 @@ class NIP17Factory {
|
||||
originalNote: EventHintBundle<Event>,
|
||||
to: List<HexKey>,
|
||||
signer: NostrSigner,
|
||||
recipientRelayHints: (HexKey) -> NormalizedRelayUrl? = { null },
|
||||
): Result {
|
||||
val senderPublicKey = signer.pubKey
|
||||
val template = ReactionEvent.build(content, originalNote)
|
||||
|
||||
val senderReaction = signer.sign(template)
|
||||
val wraps = createWraps(senderReaction, to.plus(senderPublicKey).toSet(), signer)
|
||||
val wraps = createWraps(senderReaction, to.plus(senderPublicKey).toSet(), signer, recipientRelayHints)
|
||||
return Result(
|
||||
msg = senderReaction,
|
||||
wraps = wraps,
|
||||
@@ -159,12 +207,13 @@ class NIP17Factory {
|
||||
originalNote: EventHintBundle<Event>,
|
||||
to: List<HexKey>,
|
||||
signer: NostrSigner,
|
||||
recipientRelayHints: (HexKey) -> NormalizedRelayUrl? = { null },
|
||||
): Result {
|
||||
val senderPublicKey = signer.pubKey
|
||||
val template = ReactionEvent.build(emojiUrl, originalNote)
|
||||
|
||||
val senderReaction = signer.sign(template)
|
||||
val wraps = createWraps(senderReaction, to.plus(senderPublicKey).toSet(), signer)
|
||||
val wraps = createWraps(senderReaction, to.plus(senderPublicKey).toSet(), signer, recipientRelayHints)
|
||||
|
||||
return Result(
|
||||
msg = senderReaction,
|
||||
|
||||
+14
-2
@@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.firstTagValue
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
@@ -96,11 +97,22 @@ open class GiftWrapEvent(
|
||||
const val KIND = 1059
|
||||
const val ALT = "Encrypted event"
|
||||
|
||||
/**
|
||||
* Build a NIP-59 gift wrap addressed to `recipientPubKey`.
|
||||
*
|
||||
* Per NIP-17 §Publishing, the `p` tag on the wrap MAY carry the
|
||||
* recipient's primary DM inbox relay as a hint, so other clients
|
||||
* the recipient runs (or relays acting as inbox routers) can locate
|
||||
* the wrap without a separate kind:10050 lookup. Pass it via
|
||||
* [recipientRelayHint] — `null` (the default) preserves the
|
||||
* historical 2-element `["p", pubkey]` shape.
|
||||
*/
|
||||
fun create(
|
||||
event: Event,
|
||||
recipientPubKey: HexKey,
|
||||
expirationDelta: Long? = null,
|
||||
createdAt: Long = TimeUtils.randomWithTwoDays(),
|
||||
recipientRelayHint: NormalizedRelayUrl? = null,
|
||||
): GiftWrapEvent {
|
||||
val signer = NostrSignerSync(KeyPair()) // GiftWrap is always a random key
|
||||
|
||||
@@ -109,11 +121,11 @@ open class GiftWrapEvent(
|
||||
// minimum expiration is two days in the future due to the random created at
|
||||
// this will make sure the even arrives and is not deleted because of the 2 days.
|
||||
arrayOf(
|
||||
PTag.assemble(recipientPubKey, null),
|
||||
PTag.assemble(recipientPubKey, recipientRelayHint),
|
||||
ExpirationTag.assemble(createdAt + it + TimeUtils.twoDays()),
|
||||
)
|
||||
} ?: arrayOf(
|
||||
PTag.assemble(recipientPubKey, null),
|
||||
PTag.assemble(recipientPubKey, recipientRelayHint),
|
||||
)
|
||||
|
||||
return signer.sign(
|
||||
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* 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.quartz.nip01Core.relay.client.pool
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContains
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class PoolEventOutboxStateTest {
|
||||
private val relay = NormalizedRelayUrl("wss://relay.example/")
|
||||
|
||||
private fun fakeEvent() =
|
||||
Event(
|
||||
id = "0".repeat(64),
|
||||
pubKey = "0".repeat(64),
|
||||
createdAt = 0L,
|
||||
kind = 1,
|
||||
tags = emptyArray(),
|
||||
content = "",
|
||||
sig = "0".repeat(128),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun authRequiredResponseDoesNotConsumeTryBudget() {
|
||||
val state = PoolEventOutboxState(fakeEvent(), setOf(relay))
|
||||
|
||||
// Simulate 5 `auth-required:` responses — relay keeps challenging while
|
||||
// RelayAuthenticator signs + sends AUTH events asynchronously. None of
|
||||
// these should be counted against the 3-response try cap.
|
||||
repeat(5) {
|
||||
state.newResponse(relay, success = false, message = "auth-required: please authenticate")
|
||||
}
|
||||
|
||||
// Even after a follow-up newTry, the relay must remain in the outbox so
|
||||
// syncFilters() can re-publish once AUTH succeeds.
|
||||
state.newTry(relay)
|
||||
assertContains(state.relaysLeft(), relay)
|
||||
assertFalse(state.isDone())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun regularRejectionStillBoundedByTryCap() {
|
||||
val state = PoolEventOutboxState(fakeEvent(), setOf(relay))
|
||||
|
||||
// 3 non-AUTH rejections accumulate normally.
|
||||
repeat(3) {
|
||||
state.newResponse(relay, success = false, message = "error: rate limited")
|
||||
}
|
||||
state.newTry(relay)
|
||||
|
||||
// After the 4th newTry (with 3 prior responses already in flight), the
|
||||
// Tries cap kicks in and the relay is dropped from the outbox.
|
||||
assertFalse(state.relaysLeft().contains(relay))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun terminalRejectionImmediatelyDropsRelay() {
|
||||
val state = PoolEventOutboxState(fakeEvent(), setOf(relay))
|
||||
|
||||
state.newResponse(relay, success = false, message = "invalid: malformed event")
|
||||
|
||||
assertFalse(state.relaysLeft().contains(relay))
|
||||
assertTrue(state.isDone())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun successDropsRelayFromOutbox() {
|
||||
val state = PoolEventOutboxState(fakeEvent(), setOf(relay))
|
||||
|
||||
state.newResponse(relay, success = true, message = "")
|
||||
|
||||
assertEquals(emptySet(), state.relaysLeft())
|
||||
assertTrue(state.isDone())
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* 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.quartz.nip59Giftwrap.wraps
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
/**
|
||||
* NIP-17 relay-hint placement contract.
|
||||
*
|
||||
* Per NIP-17 §Publishing, the gift wrap's `p` tag MAY carry the recipient's
|
||||
* primary DM inbox relay as a third element so other devices of the recipient
|
||||
* can discover the wrap without a separate kind:10050 lookup. The hint
|
||||
* deliberately lives on the public wrap, NOT on the encrypted seal — putting
|
||||
* it on the seal would hide the routing information inside the encryption
|
||||
* envelope, defeating the purpose.
|
||||
*/
|
||||
class GiftWrapRelayHintTest {
|
||||
private val recipient = KeyPair()
|
||||
|
||||
private fun innerEvent(): Event {
|
||||
val signer = NostrSignerSync(KeyPair())
|
||||
return signer.sign(
|
||||
createdAt = 0L,
|
||||
kind = 1,
|
||||
tags = emptyArray(),
|
||||
content = "hello",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun defaultsToNoRelayHintForBackwardsCompat() =
|
||||
runTest {
|
||||
// Existing callers that don't pass a hint must continue to emit the
|
||||
// historical ["p", recipientPubKey] two-element tag shape.
|
||||
val wrap =
|
||||
GiftWrapEvent.create(
|
||||
event = innerEvent(),
|
||||
recipientPubKey = recipient.pubKey.toHexKey(),
|
||||
)
|
||||
val pTag = wrap.tags.first { it.firstOrNull() == "p" }
|
||||
assertEquals(2, pTag.size, "p tag must be 2 elements when no hint passed")
|
||||
assertEquals(recipient.pubKey.toHexKey(), pTag[1])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun relayHintLandsOnWrapPTagAsThirdElement() =
|
||||
runTest {
|
||||
// When a hint is passed, it must appear as the THIRD element of the
|
||||
// wrap's p tag — NIP-17 spec. Not inside the encrypted seal.
|
||||
val hint = NormalizedRelayUrl("wss://dm.relay.example/")
|
||||
val wrap =
|
||||
GiftWrapEvent.create(
|
||||
event = innerEvent(),
|
||||
recipientPubKey = recipient.pubKey.toHexKey(),
|
||||
recipientRelayHint = hint,
|
||||
)
|
||||
val pTag = wrap.tags.first { it.firstOrNull() == "p" }
|
||||
assertEquals(3, pTag.size, "p tag carries [tag, pubkey, relay-hint]")
|
||||
assertEquals(recipient.pubKey.toHexKey(), pTag[1])
|
||||
assertEquals(hint.url, pTag[2])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun absentHintDoesNotAddTrailingEmptyElement() =
|
||||
runTest {
|
||||
// Defensive: a null hint must not produce `["p", pubkey, ""]` — that
|
||||
// would be a leak (broadcasts the user has no canonical inbox) and
|
||||
// a wire-format change from the historical shape.
|
||||
val wrap =
|
||||
GiftWrapEvent.create(
|
||||
event = innerEvent(),
|
||||
recipientPubKey = recipient.pubKey.toHexKey(),
|
||||
recipientRelayHint = null,
|
||||
)
|
||||
val pTag = wrap.tags.first { it.firstOrNull() == "p" }
|
||||
assertNull(pTag.getOrNull(2), "third element must be absent, not empty string")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user