fix(quartz): make commonMain compile for Kotlin/Native (iOS)

@Volatile resolves via kotlin.jvm on JVM but needs an explicit
kotlin.concurrent.Volatile import on Native; Dispatchers.IO is internal
on Native, so the in-process WebSocket switches to Dispatchers.Default
(no blocking I/O on that path); and Native's LinkedHashMap is final
without removeEldestEntry, so Nip98AuthVerifier's replay cache now caps
itself with an explicit insertion-order eviction loop (access-order was
unused — the cache is write-only).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-05-12 22:03:19 -04:00
parent 2eec53472a
commit c51c5f665b
4 changed files with 15 additions and 7 deletions
@@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlin.concurrent.Volatile
class PoolEventOutbox {
// @Volatile so the polling path (INostrClient.pendingPublishRelaysFor)
@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.nip01Core.relay.client.pool
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlin.concurrent.Volatile
class PoolEventOutboxState(
val event: Event,
@@ -65,7 +65,7 @@ class InProcessWebSocket(
override fun connect() {
if (session != null) return
val newScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
val newScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val newIncoming = Channel<String>(UNLIMITED)
val s = server.connect { json -> out.onMessage(json) }
@@ -55,18 +55,15 @@ class Nip98AuthVerifier(
) {
/**
* Recently-accepted event ids → expiry epoch second. Bounded to
* [MAX_REPLAY_ENTRIES] (LRU eviction); each entry expires after
* `2 × toleranceSeconds` (twice the accepted window so a token
* [MAX_REPLAY_ENTRIES] (insertion-order eviction); each entry expires
* after `2 × toleranceSeconds` (twice the accepted window so a token
* can't be reused by an attacker who buffers across the boundary).
*
* Guarded by [seenLock] so the eviction sweep + insertion are
* atomic. We use a coroutine [Mutex] so the type works in KMP
* commonMain (no `synchronized` block).
*/
private val seenEventIds: LinkedHashMap<String, Long> =
object : LinkedHashMap<String, Long>(64, 0.75f, true) {
override fun removeEldestEntry(eldest: Map.Entry<String, Long>?): Boolean = size > MAX_REPLAY_ENTRIES
}
private val seenEventIds: LinkedHashMap<String, Long> = LinkedHashMap()
private val seenLock = Mutex()
@@ -146,6 +143,15 @@ class Nip98AuthVerifier(
if (seenEventIds.put(event.id, expiry) != null) {
return Result.Malformed("replay: this NIP-98 token has already been used")
}
// Cap entries: drop oldest by insertion order. Equivalent to
// the JDK LinkedHashMap.removeEldestEntry hook we used before,
// but works in KMP commonMain.
while (seenEventIds.size > MAX_REPLAY_ENTRIES) {
val eldest = seenEventIds.keys.iterator()
if (!eldest.hasNext()) break
eldest.next()
eldest.remove()
}
}
return Result.Verified(event.pubKey)