fix(concord,chat): address audit findings (mentions, re-index perf, auth coupling, cleanups)

Follows the code-review of the dual-reply + Concord work:

1. Public-chat/relay-group minichat replies no longer drop @-mentions, hashtags,
   URL references, quotes, custom emojis, attachments, content-warning or
   expiration: ChannelNewMessageViewModel.createTemplate's MINICHAT branch now runs
   after NewMessageTagger and builds the kind-1111 from tagger.message with the same
   enrichment the inline path uses (parent author is already tagged by replyBuilder).
2. refreshConcordChannelIndex no longer runs a full re-index + ban rescan on every
   ingested message: the revision collector is sample(500)-throttled, coalescing
   bursts into at most one pass per window.
3. Concord stream-key AUTH is no longer blocked behind the user-auth prompt: on a
   relay that hosts our planes, the ASK path is DISMISSed so the derived stream-key
   AUTHs return immediately instead of waiting on (or being dropped by) a dialog.
4. Account.sendMinichatReply evaluates chat.relays() once instead of twice.
5. AuthCoordinator caches the per-stream-key NostrSignerSync by secret, so the
   secp256k1 keypair isn't re-derived for every plane on every relay challenge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
This commit is contained in:
Claude
2026-07-12 22:15:04 +00:00
parent 4324a8b8e4
commit fe780a4220
3 changed files with 42 additions and 17 deletions
@@ -329,6 +329,7 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
@@ -2015,10 +2016,10 @@ class Account(
val rootEvent = rootNote.event ?: return false
gatherers?.firstNotNullOfOrNull { it as? PublicChatChannel }?.let { chat ->
val relays = chat.relays().ifEmpty { outboxRelays.flow.value }
val signed = signer.sign(CommentEvent.replyBuilder(text, EventHintBundle(rootEvent, chat.relays().firstOrNull())))
val relays = chat.relays()
val signed = signer.sign(CommentEvent.replyBuilder(text, EventHintBundle(rootEvent, relays.firstOrNull())))
cache.justConsumeMyOwnEvent(signed)
client.publish(signed, relays)
client.publish(signed, relays.ifEmpty { outboxRelays.flow.value })
return true
}
@@ -4804,9 +4805,12 @@ class Account(
// Keep Concord channel metadata (community name/icon, membership) live across the whole
// app — not just the hub screen — so the Messages tab renders each channel's community
// chip, and per-community bans apply, as soon as a Control Plane folds.
// chip, and per-community bans apply, as soon as a Control Plane folds. The revision bumps
// on every ingested message, so sample() coalesces bursts into at most one full re-index
// per window instead of re-scanning every channel's notes per message.
scope.launch {
concordSessions.revision.collect { refreshConcordChannelIndex() }
@OptIn(kotlinx.coroutines.FlowPreview::class)
concordSessions.revision.sample(500).collect { refreshConcordChannelIndex() }
}
scope.launch {
@@ -24,6 +24,8 @@ import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthContext
import com.vitorpamplona.amethyst.isDebug
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator
@@ -33,6 +35,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import java.util.concurrent.ConcurrentHashMap
class ScreenAuthAccount(
val account: Account,
@@ -87,10 +90,13 @@ class AuthCoordinator(
}
val currentLedgers = relayLedgers
// Ask the user (only in the ASK case) and fold every account's verdict into one
// decision plus an optional per-relay override to remember.
// decision plus an optional per-relay override to remember. When this relay hosts our
// Concord planes, never block the derived stream-key AUTH behind a user prompt: skip
// the user-auth ASK (DISMISS) so we return the stream AUTHs immediately instead of
// waiting on — or being dropped by — a dialog the user may never answer.
val outcome =
AuthDecisionResolver.resolve(currentLedgers.map { it.decide(context) }) {
promptBus.requestDecision(relayUrl, context.purposes)
if (streamAuths.isNotEmpty()) UserAuthChoice.DISMISS else promptBus.requestDecision(relayUrl, context.purposes)
}
outcome.remember?.let { decision ->
currentLedgers.firstOrNull()?.setDecision(relayUrl.url, decision)
@@ -146,7 +152,9 @@ class AuthCoordinator(
if (secrets.isEmpty()) return emptyList()
return secrets.mapNotNull { secret ->
try {
NostrSignerSync(KeyPair(privKey = secret)).sign(authTemplate)
// Cache the signer by secret so we don't re-derive the secp256k1 keypair for every
// plane on every relay challenge/reconnect.
streamSigners.getOrPut(secret.toHexKey()) { NostrSignerSync(KeyPair(privKey = secret)) }.sign(authTemplate)
} catch (e: Exception) {
Log.e("AuthCoordinator", "Failed to sign a Concord stream-key AUTH", e)
null
@@ -154,6 +162,9 @@ class AuthCoordinator(
}
}
// stream secret (hex) -> its local signer. Bounded by joined communities × channels.
private val streamSigners = ConcurrentHashMap<HexKey, NostrSignerSync>()
fun destroy() {
receiver.destroy()
}
@@ -434,15 +434,6 @@ open class ChannelNewMessageViewModel :
private suspend fun createTemplate(): EventTemplate<out Event>? {
val channel = channel ?: return null
// A minichat reply is a kind-1111 thread comment rooted at the parent, independent of the
// channel type; NIP-29 groups additionally carry the `h` tag so the relay scopes it.
val minichatParent = replyTo.value?.takeIf { replyMode.value == ReplyMode.MINICHAT }?.event
if (minichatParent != null) {
return CommentEvent.replyBuilder(message.text.toString(), EventHintBundle(minichatParent, channel.relays().firstOrNull())) {
if (channel is RelayGroupChannel) hTag(channel.groupId.id)
}
}
val messageText = message.text.toString()
val tagger =
NewMessageTagger(
@@ -463,6 +454,25 @@ open class ChannelNewMessageViewModel :
val contentWarningReason = if (wantsToMarkAsSensitive) contentWarningDescription else null
val localExpirationDate = if (wantsExpirationDate) expirationDate else null
// A minichat reply is a kind-1111 thread comment rooted at the parent, independent of the
// channel type (NIP-29 groups additionally carry the `h` tag). It carries the same mention/
// hashtag/quote/emoji/attachment enrichment an inline message does — built from tagger.message,
// not the raw text — so replying in a thread never silently drops any of them.
val minichatParent = replyTo.value?.takeIf { replyMode.value == ReplyMode.MINICHAT }?.event
if (minichatParent != null) {
return CommentEvent.replyBuilder(tagger.message, EventHintBundle(minichatParent, channelRelays.firstOrNull())) {
if (channel is RelayGroupChannel) hTag(channel.groupId.id)
hashtags(findHashtags(tagger.message))
references(findURLs(tagger.message))
quotes(findNostrUris(tagger.message))
contentWarningReason?.let { contentWarning(it) }
localExpirationDate?.let { expiration(it) }
geoHash?.let { geohash(it) }
emojis(emojis)
imetas(usedAttachments)
}
}
return when {
channel is PublicChatChannel -> {
val replyingToEvent = replyTo.value?.toEventHint<ChannelMessageEvent>()