This commit is contained in:
Vitor Pamplona
2026-05-07 10:38:08 -04:00
96 changed files with 8477 additions and 836 deletions
+5 -2
View File
@@ -111,11 +111,14 @@ jobs:
# populating the cache for the next run. # populating the cache for the next run.
# #
# Versions are pinned to match desktopApp/build.gradle.kts (vlcVersion # Versions are pinned to match desktopApp/build.gradle.kts (vlcVersion
# = 3.0.21) and the vlc-setup extension default (upxVersion = 4.2.4). # = 3.0.20) and the vlc-setup extension default (upxVersion = 4.2.4).
# NOTE: vlcVersion lags behind upstream VLC because the Linux plugins on
# Maven Central (ir.mahozad:vlc-plugins-linux) are only published for
# 3.0.20 / 3.0.20-2. Bump only after the Maven artifact is republished.
# macOS does not download UPX — UPX cannot compress .dylib files. # macOS does not download UPX — UPX cannot compress .dylib files.
- name: Pre-fetch VLC + UPX archives - name: Pre-fetch VLC + UPX archives
env: env:
VLC_VERSION: "3.0.21" VLC_VERSION: "3.0.20"
UPX_VERSION: "4.2.4" UPX_VERSION: "4.2.4"
run: | run: |
set -euo pipefail set -euo pipefail
@@ -57,6 +57,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
@@ -420,28 +421,18 @@ private fun MemberCell(
}, },
label = "speaker-outer-ring-width", label = "speaker-outer-ring-width",
) )
// Reserve enough space around the avatar Box to fit the outer ring
// and glow halo. Without this padding the rings clip against the
// surrounding Surface / LazyVerticalGrid bounds (most visibly at
// the top edge for the first row, where the stage card's rounded
// corner cuts into the glow). The glow extends up to MAX_GLOW_RADIUS
// past the avatar; the outer ring extends OUTER_RING_GAP +
// OUTER_RING_MAX_WIDTH past it.
val ringPadding =
maxOf(MAX_GLOW_RADIUS.value, (OUTER_RING_GAP + OUTER_RING_MAX_WIDTH).value).dp
val avatarModifier = val avatarModifier =
Modifier Modifier
.drawBehind { .border(animatedRingWidth, animatedRingColor, CircleShape)
if (animatedGlowAlpha > 0.001f) {
val baseRadius = size.minDimension / 2f
val extra = MAX_GLOW_RADIUS.toPx() * clampedLevel
drawCircle(
color = NEST_SPEAKING_COLOR.copy(alpha = animatedGlowAlpha),
radius = baseRadius + extra,
)
}
if (animatedOuterRingAlpha > 0.001f && animatedOuterRingWidth > 0.dp) {
val baseRadius = size.minDimension / 2f
val strokePx = animatedOuterRingWidth.toPx()
val ringRadius = baseRadius + OUTER_RING_GAP.toPx() + strokePx / 2f
drawCircle(
color = NEST_SPEAKING_COLOR.copy(alpha = animatedOuterRingAlpha),
radius = ringRadius,
style = Stroke(width = strokePx),
)
}
}.border(animatedRingWidth, animatedRingColor, CircleShape)
.let { if (member.absent) it.alpha(0.5f) else it } .let { if (member.absent) it.alpha(0.5f) else it }
val user = val user =
remember(member.pubkey) { remember(member.pubkey) {
@@ -480,58 +471,53 @@ private fun MemberCell(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier.fillMaxWidth().padding(vertical = 4.dp), modifier = modifier.fillMaxWidth().padding(vertical = 4.dp),
) { ) {
Box(contentAlignment = Alignment.Center) { // Outer Box paints the glow halo + detached outer ring on a
ClickableUserPicture( // canvas that's bigger than the avatar by [ringPadding]. The
baseUserHex = member.pubkey, // inner Box keeps its tight-to-avatar bounds so badge corner
size = avatarSize, // alignment (TopStart, TopEnd, BottomCenter, BottomEnd) still
accountViewModel = accountViewModel, // tracks the avatar circle, not the padded outer area.
modifier = avatarModifier, Box(
onClick = onClick, modifier =
onLongClick = onLongClick, Modifier.drawBehind {
) val avatarRadiusPx = avatarSize.toPx() / 2f
if (isConnecting) { val cx = size.width / 2f
CircularProgressIndicator( val cy = size.height / 2f
modifier = Modifier.size(avatarSize - 8.dp), if (animatedGlowAlpha > 0.001f) {
strokeWidth = 2.dp, val extra = MAX_GLOW_RADIUS.toPx() * clampedLevel
color = MaterialTheme.colorScheme.primary, drawCircle(
) color = NEST_SPEAKING_COLOR.copy(alpha = animatedGlowAlpha),
} radius = avatarRadiusPx + extra,
val role = member.role center = Offset(cx, cy),
if (role == ROLE.HOST || role == ROLE.MODERATOR) { )
RoleBadge( }
role = role, if (animatedOuterRingAlpha > 0.001f && animatedOuterRingWidth > 0.dp) {
modifier = Modifier.align(Alignment.TopStart), val strokePx = animatedOuterRingWidth.toPx()
) val ringRadius = avatarRadiusPx + OUTER_RING_GAP.toPx() + strokePx / 2f
} drawCircle(
if (member.handRaised) { color = NEST_SPEAKING_COLOR.copy(alpha = animatedOuterRingAlpha),
HandRaiseBadge( radius = ringRadius,
modifier = Modifier.align(Alignment.TopEnd), center = Offset(cx, cy),
) style = Stroke(width = strokePx),
} )
// Show the mic badge for any on-stage speaker that has }
// an audio state to surface — currently broadcasting },
// (`publishing=1`) OR mic-muted (`muted=1, publishing=0`). contentAlignment = Alignment.Center,
// Gating only on `publishing` would hide the muted icon ) {
// the moment the user mutes, which is exactly when it's Box(
// supposed to appear. modifier = Modifier.padding(ringPadding),
if (showMicBadge && (member.publishing || member.muted == true)) { contentAlignment = Alignment.Center,
MicStateBadge( ) {
AvatarAndBadges(
member = member,
avatarSize = avatarSize,
accountViewModel = accountViewModel,
avatarModifier = avatarModifier,
onClick = onClick,
onLongClick = onLongClick,
isConnecting = isConnecting,
showMicBadge = showMicBadge,
isSpeaking = isSpeaking, isSpeaking = isSpeaking,
isMuted = member.muted == true,
modifier = Modifier.align(Alignment.BottomCenter),
)
}
// Reactions float over the avatar's bottom-right corner so
// a 👏 burst no longer pushes the username down and reflows
// neighbouring cells. The mic badge sits at BottomCenter,
// so BottomEnd + a small outward offset keeps them clear.
if (reactions.isNotEmpty()) {
SpeakerReactionOverlay(
reactions = reactions, reactions = reactions,
modifier =
Modifier
.align(Alignment.BottomEnd)
.offset(x = 6.dp, y = 6.dp),
) )
} }
} }
@@ -545,6 +531,76 @@ private fun MemberCell(
} }
} }
@Composable
private fun AvatarAndBadges(
member: RoomMember,
avatarSize: Dp,
accountViewModel: AccountViewModel,
avatarModifier: Modifier,
onClick: ((String) -> Unit)?,
onLongClick: ((String) -> Unit)?,
isConnecting: Boolean,
showMicBadge: Boolean,
isSpeaking: Boolean,
reactions: List<RoomReaction>,
) {
Box(contentAlignment = Alignment.Center) {
ClickableUserPicture(
baseUserHex = member.pubkey,
size = avatarSize,
accountViewModel = accountViewModel,
modifier = avatarModifier,
onClick = onClick,
onLongClick = onLongClick,
)
if (isConnecting) {
CircularProgressIndicator(
modifier = Modifier.size(avatarSize - 8.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary,
)
}
val role = member.role
if (role == ROLE.HOST || role == ROLE.MODERATOR) {
RoleBadge(
role = role,
modifier = Modifier.align(Alignment.TopStart),
)
}
if (member.handRaised) {
HandRaiseBadge(
modifier = Modifier.align(Alignment.TopEnd),
)
}
// Show the mic badge for any on-stage speaker that has
// an audio state to surface — currently broadcasting
// (`publishing=1`) OR mic-muted (`muted=1, publishing=0`).
// Gating only on `publishing` would hide the muted icon
// the moment the user mutes, which is exactly when it's
// supposed to appear.
if (showMicBadge && (member.publishing || member.muted == true)) {
MicStateBadge(
isSpeaking = isSpeaking,
isMuted = member.muted == true,
modifier = Modifier.align(Alignment.BottomCenter),
)
}
// Reactions float over the avatar's bottom-right corner so
// a 👏 burst no longer pushes the username down and reflows
// neighbouring cells. The mic badge sits at BottomCenter,
// so BottomEnd + a small outward offset keeps them clear.
if (reactions.isNotEmpty()) {
SpeakerReactionOverlay(
reactions = reactions,
modifier =
Modifier
.align(Alignment.BottomEnd)
.offset(x = 6.dp, y = 6.dp),
)
}
}
}
/** /**
* Hand-raise indicator overlaid on the avatar — yellow circle with * Hand-raise indicator overlaid on the avatar — yellow circle with
* a hand glyph at the top-right, animated in a subtle vertical * a hand glyph at the top-right, animated in a subtle vertical
@@ -302,14 +302,26 @@ class NestViewModel(
/** /**
* [kotlin.time.TimeMark] of the most recent recycle the cliff * [kotlin.time.TimeMark] of the most recent recycle the cliff
* detector triggered, or `null` if it has never fired. Acts as a * detector triggered, or `null` if it has never fired. Combined
* cooldown so a single cliff event doesn't kick off multiple * with [consecutiveCliffRecycles] to drive the per-attempt
* recycles back-to-back while the wrapper is mid-handshake on * backoff schedule in [computeStalledSpeakers] — short backoff
* the new session — the new session has no incoming frames yet, * after the first failed recycle so a single moq-rs cliff
* which would otherwise trip the detector again immediately. * recovers within a few seconds, escalating to the
* [ROOM_AUDIO_CLIFF_BACKOFF_MAX_MS] cap if the relay stays
* stalled across multiple recycles.
*/ */
private var lastCliffRecycleAt: kotlin.time.TimeMark? = null private var lastCliffRecycleAt: kotlin.time.TimeMark? = null
/**
* Number of consecutive `recycleSession()` calls the detector has
* issued without seeing a real frame in between. Reset to 0 in
* [onSpeakerActivity] when any speaker delivers a frame, so a
* recovered-then-restall pattern re-enters with attempt = 0
* (immediate recycle eligible) rather than inheriting backoff
* from a prior failed-recycle streak.
*/
private var consecutiveCliffRecycles: Int = 0
/** /**
* Per-speaker catalog-fetch coroutines. Each entry is the * Per-speaker catalog-fetch coroutines. Each entry is the
* background `subscribeCatalog` collector launched in * background `subscribeCatalog` collector launched in
@@ -320,7 +332,19 @@ class NestViewModel(
*/ */
private val catalogJobs = mutableMapOf<String, Job>() private val catalogJobs = mutableMapOf<String, Job>()
private var requestedSpeakers: Set<String> = emptySet() private var requestedSpeakers: Set<String> = emptySet()
private var closed = false
/**
* `@Volatile` because [closed] is read by background coroutines
* (cliff-detector, observe* loops, audio-focus + network observers)
* but written from `leave()` / `onCleared()` which can fire from
* the Activity destroy callback on a different stack frame than
* the coroutine that's about to suspend in [delay] / [collect].
* Without the marker, a coroutine that resumes from cancellation
* could read a stale `closed = false` even though the user has
* already left the room — visible in the trace as
* `cliff-detector EXITED ... closed=false` after a Leave press.
*/
@Volatile private var closed = false
// Speaker / publisher path // Speaker / publisher path
private var speaker: NestsSpeaker? = null private var speaker: NestsSpeaker? = null
@@ -1050,7 +1074,7 @@ class NestViewModel(
/** /**
* Wait briefly for [pubkey]'s catalog to land in [_speakerCatalogs] * Wait briefly for [pubkey]'s catalog to land in [_speakerCatalogs]
* and pick the audio config (channel count + sample rate) for the * and pick the audio config (channel count + sample rate) for the
* decoder + AudioTrack. Falls back to [AudioFormat.CHANNELS] / * decoder + AudioTrack. Falls back to [AudioFormat.DEFAULT_CHANNELS] /
* [AudioFormat.SAMPLE_RATE_HZ] on timeout (the catalog never * [AudioFormat.SAMPLE_RATE_HZ] on timeout (the catalog never
* arrived within [timeoutMs]) or when the catalog declares * arrived within [timeoutMs]) or when the catalog declares
* unsupported values (channelCount outside `1..2`, or non-positive * unsupported values (channelCount outside `1..2`, or non-positive
@@ -1084,7 +1108,7 @@ class NestViewModel(
val channels = val channels =
when { when {
declaredChannels == null -> { declaredChannels == null -> {
AudioFormat.CHANNELS AudioFormat.DEFAULT_CHANNELS
} }
declaredChannels !in 1..2 -> { declaredChannels !in 1..2 -> {
@@ -1092,7 +1116,7 @@ class NestViewModel(
"publisher catalog for pubkey='${pubkey.take(8)}' declares numberOfChannels=$declaredChannels " + "publisher catalog for pubkey='${pubkey.take(8)}' declares numberOfChannels=$declaredChannels " +
"(only 1 / 2 supported); falling back to mono" "(only 1 / 2 supported); falling back to mono"
} }
AudioFormat.CHANNELS AudioFormat.DEFAULT_CHANNELS
} }
else -> { else -> {
@@ -1389,6 +1413,7 @@ class NestViewModel(
cliffDetectorJob = null cliffDetectorJob = null
lastFrameAt.clear() lastFrameAt.clear()
lastCliffRecycleAt = null lastCliffRecycleAt = null
consecutiveCliffRecycles = 0
// Audio-focus observation only ends on the final teardown — // Audio-focus observation only ends on the final teardown —
// a transient disconnect+reconnect (user retry, room swap) // a transient disconnect+reconnect (user retry, room swap)
// keeps the bus subscription alive so a focus loss that // keeps the bus subscription alive so a focus loss that
@@ -1469,9 +1494,17 @@ class NestViewModel(
} }
/** /**
* Mark [pubkey] as currently speaking and (re)arm a [SPEAKING_TIMEOUT_MS] * Per-frame heartbeat. Called once per MoQ object received on the
* coroutine that clears it once they go quiet. Called once per * speaker's track — i.e. once per ~20 ms regardless of whether
* MoQ object received on the speaker's track. * that frame contains actual speech, silence, or background noise.
*
* Bumps the cliff-detector timestamp so an active stream keeps
* resetting the relay-forward-queue stall watchdog, and clears
* the per-speaker buffering overlay the first time a frame lands.
*
* NB: this does NOT mark the speaker as "speaking right now".
* That signal is energy-gated and lives in [onAudioLevel] —
* mic-on with no voice MUST NOT light up the green ring.
*/ */
private fun onSpeakerActivity(pubkey: String) { private fun onSpeakerActivity(pubkey: String) {
if (closed) return if (closed) return
@@ -1483,12 +1516,27 @@ class NestViewModel(
lastFrameAt[pubkey] = lastFrameAt[pubkey] =
kotlin.time.TimeSource.Monotonic kotlin.time.TimeSource.Monotonic
.markNow() .markNow()
speakingExpiryJobs[pubkey]?.cancel() // A real frame proves the most recent recycle (if any) actually
// fixed the cliff. Reset the consecutive-failed counter so a
// future re-stall starts from attempt 0 (immediate-fire) rather
// than inheriting a long backoff from the prior streak.
if (consecutiveCliffRecycles != 0) consecutiveCliffRecycles = 0
// First frame for this subscription — clear the buffering // First frame for this subscription — clear the buffering
// overlay. Subsequent frames are no-ops here. // overlay. Subsequent frames are no-ops here.
if (_uiState.value.connectingSpeakers.contains(pubkey)) { if (_uiState.value.connectingSpeakers.contains(pubkey)) {
_uiState.update { it.copy(connectingSpeakers = (it.connectingSpeakers - pubkey).toPersistentSet()) } _uiState.update { it.copy(connectingSpeakers = (it.connectingSpeakers - pubkey).toPersistentSet()) }
} }
}
/**
* Mark [pubkey] as currently speaking and (re)arm a
* [SPEAKING_TIMEOUT_MS] coroutine that clears the flag once their
* audio drops back below the threshold for that long. Called from
* [onAudioLevel] only when the decoded peak is loud enough to
* read as speech (see [SPEAKING_LEVEL_THRESHOLD]).
*/
private fun markSpeaking(pubkey: String) {
speakingExpiryJobs[pubkey]?.cancel()
if (!_uiState.value.speakingNow.contains(pubkey)) { if (!_uiState.value.speakingNow.contains(pubkey)) {
_uiState.update { it.copy(speakingNow = (it.speakingNow + pubkey).toPersistentSet()) } _uiState.update { it.copy(speakingNow = (it.speakingNow + pubkey).toPersistentSet()) }
} }
@@ -1570,43 +1618,53 @@ class NestViewModel(
announcedSpeakers = announced, announcedSpeakers = announced,
lastFrameAt = lastFrameAt, lastFrameAt = lastFrameAt,
lastRecycleAt = lastCliffRecycleAt, lastRecycleAt = lastCliffRecycleAt,
consecutiveFailedRecycles = consecutiveCliffRecycles,
cliffTimeoutMs = ROOM_AUDIO_CLIFF_TIMEOUT_MS, cliffTimeoutMs = ROOM_AUDIO_CLIFF_TIMEOUT_MS,
cooldownMs = ROOM_AUDIO_CLIFF_COOLDOWN_MS, postRecycleGraceMs = ROOM_AUDIO_CLIFF_RECYCLE_GRACE_MS,
) )
if (stalled.isEmpty()) continue if (stalled.isEmpty()) continue
consecutiveCliffRecycles += 1
com.vitorpamplona.quartz.utils.Log.w("NestRx") { com.vitorpamplona.quartz.utils.Log.w("NestRx") {
"cliff-detector: announced+subscribed but silent for ≥${ROOM_AUDIO_CLIFF_TIMEOUT_MS}ms — recycling session. stalled=$stalled" "cliff-detector: announced+subscribed but silent for ≥${ROOM_AUDIO_CLIFF_TIMEOUT_MS}ms — recycling session " +
"(consecutive=$consecutiveCliffRecycles). stalled=$stalled"
} }
com.vitorpamplona.nestsclient.trace.NestsTrace.emit("cliff_recycle") { com.vitorpamplona.nestsclient.trace.NestsTrace.emit("cliff_recycle") {
"\"timeout_ms\":$ROOM_AUDIO_CLIFF_TIMEOUT_MS," + "\"timeout_ms\":$ROOM_AUDIO_CLIFF_TIMEOUT_MS," +
"\"consecutive\":$consecutiveCliffRecycles," +
"\"stalled\":${com.vitorpamplona.nestsclient.trace.jsonArrStr(stalled)}" "\"stalled\":${com.vitorpamplona.nestsclient.trace.jsonArrStr(stalled)}"
} }
val recycleMark = lastCliffRecycleAt =
kotlin.time.TimeSource.Monotonic kotlin.time.TimeSource.Monotonic
.markNow() .markNow()
lastCliffRecycleAt = recycleMark // Note: we deliberately do NOT overwrite
// Reset `lastFrameAt` for every stalled pubkey so // `lastFrameAt[pubkey]` with the recycle moment.
// the cliff timer starts counting from the recycle // Keeping the real-frame timestamp lets the next
// moment, not from the old (pre-recycle) last // tick distinguish "the recycle delivered audio,
// frame timestamp. Without this reset the next // we then re-stalled" (lastFrameAt advances past
// tick after the cooldown immediately re-trips — // lastCliffRecycleAt — counter resets in
// because `lastFrameAt[pubkey].elapsedNow()` is // `onSpeakerActivity`, attempt = 0, fire-eligible
// still the giant pre-recycle value plus the // immediately) from "the recycle did nothing,
// cooldown window. Production logs at commit // still no frames" (lastFrameAt unchanged,
// ea08c43 showed exactly this: 4 recycles in // counter keeps growing, backoff escalates).
// ~30 s eventually drove the relay into a // The previous reset collapsed both cases into
// "subscribe stream FIN before reply" loop where // a flat 30 s wait, which made a single failed
// it refused fresh subscribes entirely. Using // recycle sound like a 30 s+ dropout to the
// the recycle moment as a synthetic frame event // user even when retrying earlier would have
// gives the new session [ROOM_AUDIO_CLIFF_TIMEOUT_MS] // recovered.
// wall-clock to deliver before the next cliff try {
// check, matching the expectation a freshly- l.recycleSession()
// attached subscription should clear inside } catch (ce: CancellationException) {
// that window if the relay is healthy. // User left mid-recycle — exit promptly
for (pubkey in stalled) { // rather than letting the next delay() be
lastFrameAt[pubkey] = recycleMark // the one to honor the cancel. `runCatching`
// would have swallowed this CE and run one
// more loop body before exiting.
throw ce
} catch (_: Throwable) {
// Any other recycle failure is best-effort;
// keep the loop running so a follow-up tick
// can re-detect the cliff and retry.
} }
runCatching { l.recycleSession() }
} }
} finally { } finally {
com.vitorpamplona.quartz.utils.Log com.vitorpamplona.quartz.utils.Log
@@ -1641,6 +1699,20 @@ class NestViewModel(
) { ) {
if (closed) return if (closed) return
rawAudioLevels[pubkey] = level rawAudioLevels[pubkey] = level
// Energy-gated speaking detector. The MoQ track delivers a
// frame every ~20 ms while the mic is open, even when the
// speaker is silent or only picking up room noise — gating the
// green ring on "frame arrived" therefore lights it up the
// moment the mic is unmuted, not when there's actually a voice
// on it. The decoded peak amplitude (`peakAmplitude` in
// nestsClient/audio/Amplitude.kt) gives us the signal we need:
// background noise / breath stays under a few percent of full
// scale, while even a quiet voice clears [SPEAKING_LEVEL_THRESHOLD].
// The 250 ms expiry already wired up in [markSpeaking] gives
// the indicator natural hysteresis between syllables.
if (level >= SPEAKING_LEVEL_THRESHOLD) {
markSpeaking(pubkey)
}
startLevelEmitter() startLevelEmitter()
} }
@@ -1799,12 +1871,29 @@ sealed class BroadcastUiState {
} }
/** /**
* How long a speaker stays "speaking" after their last received MoQ object. * How long a speaker stays "speaking" after their last decoded frame
* Roughly 12 × the 20 ms Opus frame so brief packet jitter doesn't make the * over the [SPEAKING_LEVEL_THRESHOLD]. Roughly 12 × the 20 ms Opus
* indicator flicker. * frame so brief packet jitter and inter-syllable pauses don't make
* the indicator flicker between adjacent words.
*/ */
const val SPEAKING_TIMEOUT_MS: Long = 250L const val SPEAKING_TIMEOUT_MS: Long = 250L
/**
* Minimum decoded peak amplitude (normalized to `[0, 1]`) that counts
* as "this person is actually speaking right now". Frames whose peak
* lands below this threshold are treated as silence / room tone /
* breath — they keep the per-speaker subscription healthy (the cliff
* heartbeat in [NestViewModel.onSpeakerActivity] still fires) but do
* NOT light up the green speaking ring.
*
* 0.06 ≈ -24 dBFS, comfortably above the typical residential-mic
* noise floor (~-40 to -30 dBFS) while still tripping on a quiet
* voice. Tuned in conjunction with [SPEAKING_TIMEOUT_MS]: a single
* loud frame is enough to arm the indicator; ≥ 250 ms below the
* threshold drops it.
*/
const val SPEAKING_LEVEL_THRESHOLD: Float = 0.06f
/** /**
* How long [NestViewModel.openSubscription] waits for the publisher's * How long [NestViewModel.openSubscription] waits for the publisher's
* `catalog.json` to land before constructing the decoder + AudioTrack. * `catalog.json` to land before constructing the decoder + AudioTrack.
@@ -1926,24 +2015,34 @@ const val ROOM_AUDIO_CLIFF_TIMEOUT_MS: Long = 2_500L
const val ROOM_AUDIO_CLIFF_CHECK_INTERVAL_MS: Long = 1_000L const val ROOM_AUDIO_CLIFF_CHECK_INTERVAL_MS: Long = 1_000L
/** /**
* Cooldown after a cliff-detector-driven recycle. Suppresses * Post-recycle handshake grace. NEVER recycle again within this
* back-to-back recycles while the wrapper is mid-handshake on the * window of the previous recycle the wrapper is still tearing
* fresh session AND while the relay is recovering its * down + reopening the QUIC session, so a "no frames since recycle"
* per-subscriber forward queue. * reading is just the handshake, not a relay-side cliff.
* *
* Production logs at commit ea08c43 showed an 8 s cooldown was * 3 s covers a typical re-handshake (drain old session UNSUB +
* not enough — moq-rs needed longer to recover between aggressive * UNANNOUNCE + WT_CLOSE + open new WebTransport + SUBSCRIBE +
* recycles, and 4 recycles in ~30 s drove the relay into a * SUBSCRIBE_OK). Anything shorter risks recycling inside our own
* "subscribe stream FIN before reply" loop that refused all * handshake window; longer wastes audible silence in the recovering-
* subsequent subscribes. 30 s gives the relay's per-subscriber * within-grace case.
* forward task pool time to drain stale pending writes from the
* previous subscription before the new subscribe lands. The
* audible-gap cost rises from ~5 s to ~30 s in the worst case
* (a fully-stalled relay), but the trade is correct: 30 s of
* silence + recovered audio beats endless silence with the relay
* locked out.
*/ */
const val ROOM_AUDIO_CLIFF_COOLDOWN_MS: Long = 30_000L const val ROOM_AUDIO_CLIFF_RECYCLE_GRACE_MS: Long = 3_000L
/**
* Maximum backoff for the consecutive-failed-recycle schedule —
* the cap that [defaultCliffBackoffMs] saturates at.
*
* The earlier flat 30 s cooldown was motivated by production logs
* at commit ea08c43 where 4 back-to-back recycles in ~30 s drove
* moq-rs into a "subscribe stream FIN before reply" loop that
* refused all subsequent subscribes. The replacement schedule
* (5 s → 12 s → 24 s → 30 s, reset on first real frame) keeps
* the same final-state protection — by the 4th consecutive failed
* recycle we're spaced 30 s apart — while letting the *recovering*
* case (your trace: 1st recycle worked, 2nd cliff fires later)
* retry within ~5 s instead of the previous 30 s of dead air.
*/
const val ROOM_AUDIO_CLIFF_BACKOFF_MAX_MS: Long = 30_000L
/** /**
* Diagnostic log frequency for the cliff detector. Emit a state-dump * Diagnostic log frequency for the cliff detector. Emit a state-dump
@@ -1954,6 +2053,36 @@ const val ROOM_AUDIO_CLIFF_COOLDOWN_MS: Long = 30_000L
*/ */
private const val CLIFF_DIAG_LOG_EVERY: Long = 5L private const val CLIFF_DIAG_LOG_EVERY: Long = 5L
/**
* Default backoff schedule for consecutive failed recycles. Returns
* the minimum elapsed time since [NestViewModel.lastCliffRecycleAt]
* before the [attempt + 1]-th recycle is permitted.
*
* attempt 0 → 0 ms (no recycle yet — first cliff fires immediately)
* attempt 1 → 5 000 ms (one prior recycle, no audio since)
* attempt 2 → 12 000 ms
* attempt 3 → 24 000 ms
* attempt 4+ → 30 000 ms (cap, [ROOM_AUDIO_CLIFF_BACKOFF_MAX_MS])
*
* Resets to 0 on the first real frame after a recycle (see
* [NestViewModel.onSpeakerActivity]) — so a recover-then-restall
* pattern always gets the immediate-fire treatment again rather
* than inheriting backoff from the prior failed-recycle streak.
*
* Cumulative wall-clock at attempt N: 0 → 5 → 17 → 41 → 71 s.
* 4 consecutive recycles span ~41 s — meaningfully slower than the
* "4 in ~30 s" pattern that wedged moq-rs in commit ea08c43, while
* still letting the typical 1-failure case retry inside 5 s.
*/
internal fun defaultCliffBackoffMs(attempt: Int): Long =
when {
attempt <= 0 -> 0L
attempt == 1 -> 5_000L
attempt == 2 -> 12_000L
attempt == 3 -> 24_000L
else -> ROOM_AUDIO_CLIFF_BACKOFF_MAX_MS
}
/** /**
* Pure logic of the cliff detector — extracted so headless tests can * Pure logic of the cliff detector — extracted so headless tests can
* exercise it with a [kotlin.time.TestTimeSource] without standing up * exercise it with a [kotlin.time.TestTimeSource] without standing up
@@ -1974,23 +2103,30 @@ private const val CLIFF_DIAG_LOG_EVERY: Long = 5L
* - the elapsed time since that last object is at or past * - the elapsed time since that last object is at or past
* [cliffTimeoutMs]. * [cliffTimeoutMs].
* *
* Suppression: * Suppression (when [lastRecycleAt] is non-null):
* - if [lastRecycleAt] is non-null and less than [cooldownMs] has * - while elapsed since [lastRecycleAt] is below [postRecycleGraceMs],
* elapsed since it, returns empty (no cascading recycles while * never recycle — we're still inside the previous handshake window.
* the wrapper is mid-handshake on a fresh session). * - while elapsed is below [backoffForAttempt]([consecutiveFailedRecycles]),
* suppress as well: the per-attempt schedule throttles
* consecutive failed recycles so we don't hammer the relay.
* - the consecutive counter is reset by the caller on the first
* real frame after a recycle, so a recovered-then-restall
* case always re-enters with attempt = 0 and fires immediately.
*/ */
internal fun computeStalledSpeakers( internal fun computeStalledSpeakers(
activeSpeakers: Set<String>, activeSpeakers: Set<String>,
announcedSpeakers: Set<String>, announcedSpeakers: Set<String>,
lastFrameAt: Map<String, kotlin.time.TimeMark>, lastFrameAt: Map<String, kotlin.time.TimeMark>,
lastRecycleAt: kotlin.time.TimeMark?, lastRecycleAt: kotlin.time.TimeMark?,
consecutiveFailedRecycles: Int = 0,
cliffTimeoutMs: Long = ROOM_AUDIO_CLIFF_TIMEOUT_MS, cliffTimeoutMs: Long = ROOM_AUDIO_CLIFF_TIMEOUT_MS,
cooldownMs: Long = ROOM_AUDIO_CLIFF_COOLDOWN_MS, postRecycleGraceMs: Long = ROOM_AUDIO_CLIFF_RECYCLE_GRACE_MS,
backoffForAttempt: (Int) -> Long = ::defaultCliffBackoffMs,
): List<String> { ): List<String> {
if (lastRecycleAt != null && if (lastRecycleAt != null) {
lastRecycleAt.elapsedNow().inWholeMilliseconds < cooldownMs val sinceRecycleMs = lastRecycleAt.elapsedNow().inWholeMilliseconds
) { if (sinceRecycleMs < postRecycleGraceMs) return emptyList()
return emptyList() if (sinceRecycleMs < backoffForAttempt(consecutiveFailedRecycles)) return emptyList()
} }
return activeSpeakers return activeSpeakers
.asSequence() .asSequence()
@@ -189,21 +189,16 @@ class CliffDetectorTest {
} }
@Test @Test
fun cooldownSuppressesRecycleEvenWhenStalled() { fun postRecycleGraceSuppressesEvenAtAttemptZero() {
// After a recycle, the wrapper opens a fresh QUIC transport. // Inside the 3 s post-recycle handshake window, never recycle
// The new session has no `lastFrameAt` entries yet for any // — the wrapper is still tearing down + reopening QUIC, so
// pubkey; without a cooldown we would re-trigger on the // "no frames since recycle" is just the handshake, not a
// very next 1 s tick because the prior tick's stalled // relay-side cliff.
// pubkeys still age past the threshold (their lastFrameAt
// hasn't been updated by the new session yet). 30 s cooldown
// covers the typical reconnect handshake AND gives moq-rs
// time to drain its per-subscriber forward queue from the
// prior subscription before the new subscribe lands.
val ts = TestTimeSource() val ts = TestTimeSource()
val frameMark = ts.markNow() val frameMark = ts.markNow()
ts += 4_500.milliseconds // past threshold ts += 4_500.milliseconds
val recycleMark = ts.markNow() // recycle just fired val recycleMark = ts.markNow()
ts += 5_000.milliseconds // 5 s into cooldown — well within 30 s window ts += 2_000.milliseconds // inside 3 s grace
val result = val result =
computeStalledSpeakers( computeStalledSpeakers(
@@ -211,21 +206,22 @@ class CliffDetectorTest {
announcedSpeakers = setOf(ALICE), announcedSpeakers = setOf(ALICE),
lastFrameAt = mapOf(ALICE to frameMark), lastFrameAt = mapOf(ALICE to frameMark),
lastRecycleAt = recycleMark, lastRecycleAt = recycleMark,
consecutiveFailedRecycles = 0,
) )
assertTrue(result.isEmpty()) assertTrue(result.isEmpty())
} }
@Test @Test
fun cooldownReleasesAfterTimeoutPasses() { fun attemptZeroFiresImmediatelyOnceGracePasses() {
// Once cooldown elapses, a still-stalled subscription // After a recovered-then-restall pattern: counter has been
// becomes eligible to recycle again. Important for the // reset by `onSpeakerActivity`, so even though there's a
// case where the recycle didn't actually fix the cliff — // prior recycleMark, the next cliff fires as soon as the
// we want a second attempt rather than getting wedged. // 3 s grace passes — no extended backoff.
val ts = TestTimeSource() val ts = TestTimeSource()
val frameMark = ts.markNow() val frameMark = ts.markNow()
ts += 4_500.milliseconds ts += 4_500.milliseconds
val recycleMark = ts.markNow() val recycleMark = ts.markNow()
ts += 30_001.milliseconds // 1 ms past 30 s cooldown ts += 3_500.milliseconds // past 3 s grace, attempt=0 schedule = 0 ms
val result = val result =
computeStalledSpeakers( computeStalledSpeakers(
@@ -233,10 +229,164 @@ class CliffDetectorTest {
announcedSpeakers = setOf(ALICE), announcedSpeakers = setOf(ALICE),
lastFrameAt = mapOf(ALICE to frameMark), lastFrameAt = mapOf(ALICE to frameMark),
lastRecycleAt = recycleMark, lastRecycleAt = recycleMark,
consecutiveFailedRecycles = 0,
) )
assertEquals(listOf(ALICE), result) assertEquals(listOf(ALICE), result)
} }
@Test
fun attemptOneBackoffSuppressesUntilFiveSeconds() {
// First failed recycle: schedule says wait 5 s before next.
// 4 s in: still suppressed.
val ts = TestTimeSource()
val frameMark = ts.markNow()
ts += 4_500.milliseconds
val recycleMark = ts.markNow()
ts += 4_000.milliseconds
val result =
computeStalledSpeakers(
activeSpeakers = setOf(ALICE),
announcedSpeakers = setOf(ALICE),
lastFrameAt = mapOf(ALICE to frameMark),
lastRecycleAt = recycleMark,
consecutiveFailedRecycles = 1,
)
assertTrue(result.isEmpty())
}
@Test
fun attemptOneBackoffReleasesAtFiveSeconds() {
val ts = TestTimeSource()
val frameMark = ts.markNow()
ts += 4_500.milliseconds
val recycleMark = ts.markNow()
ts += 5_000.milliseconds // exactly at attempt-1 boundary
val result =
computeStalledSpeakers(
activeSpeakers = setOf(ALICE),
announcedSpeakers = setOf(ALICE),
lastFrameAt = mapOf(ALICE to frameMark),
lastRecycleAt = recycleMark,
consecutiveFailedRecycles = 1,
)
assertEquals(listOf(ALICE), result)
}
@Test
fun attemptTwoBackoffSuppressesUntilTwelveSeconds() {
val ts = TestTimeSource()
val frameMark = ts.markNow()
ts += 4_500.milliseconds
val recycleMark = ts.markNow()
ts += 11_000.milliseconds
val result =
computeStalledSpeakers(
activeSpeakers = setOf(ALICE),
announcedSpeakers = setOf(ALICE),
lastFrameAt = mapOf(ALICE to frameMark),
lastRecycleAt = recycleMark,
consecutiveFailedRecycles = 2,
)
assertTrue(result.isEmpty())
}
@Test
fun attemptTwoBackoffReleasesPastTwelveSeconds() {
val ts = TestTimeSource()
val frameMark = ts.markNow()
ts += 4_500.milliseconds
val recycleMark = ts.markNow()
ts += 12_500.milliseconds
val result =
computeStalledSpeakers(
activeSpeakers = setOf(ALICE),
announcedSpeakers = setOf(ALICE),
lastFrameAt = mapOf(ALICE to frameMark),
lastRecycleAt = recycleMark,
consecutiveFailedRecycles = 2,
)
assertEquals(listOf(ALICE), result)
}
@Test
fun attemptFourCapsAtThirtySecondMax() {
// Fourth and beyond consecutive failed recycle: backoff
// saturates at the 30 s cap (matching the original flat
// cooldown — by this point we ARE the moq-rs-protection
// case the old constant existed for).
val ts = TestTimeSource()
val frameMark = ts.markNow()
ts += 4_500.milliseconds
val recycleMark = ts.markNow()
ts += 25_000.milliseconds // past attempt-3 (24 s), inside attempt-4 cap (30 s)
val resultStillSuppressed =
computeStalledSpeakers(
activeSpeakers = setOf(ALICE),
announcedSpeakers = setOf(ALICE),
lastFrameAt = mapOf(ALICE to frameMark),
lastRecycleAt = recycleMark,
consecutiveFailedRecycles = 4,
)
assertTrue(resultStillSuppressed.isEmpty())
ts += 6_000.milliseconds // now past 30 s cap
val resultReleased =
computeStalledSpeakers(
activeSpeakers = setOf(ALICE),
announcedSpeakers = setOf(ALICE),
lastFrameAt = mapOf(ALICE to frameMark),
lastRecycleAt = recycleMark,
consecutiveFailedRecycles = 4,
)
assertEquals(listOf(ALICE), resultReleased)
}
@Test
fun customBackoffFunctionIsHonored() {
// A test can override the schedule (e.g. shorter intervals
// for unit tests that don't want to march through 30 s of
// virtual time) without mutating the production constants.
val ts = TestTimeSource()
val frameMark = ts.markNow()
ts += 3_000.milliseconds
val recycleMark = ts.markNow()
ts += 1_000.milliseconds // past grace=500, past tightBackoff(1)=750
val tightBackoff = { attempt: Int -> if (attempt <= 0) 0L else 750L }
val result =
computeStalledSpeakers(
activeSpeakers = setOf(ALICE),
announcedSpeakers = setOf(ALICE),
lastFrameAt = mapOf(ALICE to frameMark),
lastRecycleAt = recycleMark,
consecutiveFailedRecycles = 1,
postRecycleGraceMs = 500L,
backoffForAttempt = tightBackoff,
)
assertEquals(listOf(ALICE), result)
}
@Test
fun defaultBackoffSchedulePinsValues() {
// Pin the production schedule so a future tweak is visible
// in code review. attempt 0 → immediate, 1 → 5 s, 2 → 12 s,
// 3 → 24 s, 4+ → 30 s cap. Cumulative wall-clock to the
// Nth recycle: 0, 5, 17, 41, 71 s — slower than the
// 4-recycles-in-30 s pattern that wedged moq-rs in
// commit ea08c43.
assertEquals(0L, defaultCliffBackoffMs(0))
assertEquals(5_000L, defaultCliffBackoffMs(1))
assertEquals(12_000L, defaultCliffBackoffMs(2))
assertEquals(24_000L, defaultCliffBackoffMs(3))
assertEquals(30_000L, defaultCliffBackoffMs(4))
assertEquals(30_000L, defaultCliffBackoffMs(10))
}
@Test @Test
fun customTimeoutsAreHonored() { fun customTimeoutsAreHonored() {
// Defaults are wired into the production VM, but the function // Defaults are wired into the production VM, but the function
@@ -55,6 +55,7 @@ import kotlin.test.assertEquals
import kotlin.test.assertFalse import kotlin.test.assertFalse
import kotlin.test.assertIs import kotlin.test.assertIs
import kotlin.test.assertTrue import kotlin.test.assertTrue
import kotlin.time.Duration.Companion.seconds
/** /**
* Drives [NestViewModel] with a fake [NestsListenerConnector] and * Drives [NestViewModel] with a fake [NestsListenerConnector] and
@@ -72,6 +73,16 @@ import kotlin.test.assertTrue
*/ */
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class NestViewModelTest { class NestViewModelTest {
// Tracks every VM created by `newViewModel` so each test can dispose them
// before runTest exits. `connect()` starts an infinite cliff-detector
// loop in viewModelScope; without an explicit teardown that loop spins
// forever under runTest's virtual scheduler and the test wedges until
// the real-time runTest deadline (60 s × N tests => :commons:jvmTest
// hangs). [runVmTest] wraps each test body with a finally that calls
// `disconnect()` (which cancels cliffDetectorJob via teardown()) so the
// scheduler can become idle and runTest can return.
private val createdVms = mutableListOf<NestViewModel>()
@BeforeTest @BeforeTest
fun setupMainDispatcher() { fun setupMainDispatcher() {
Dispatchers.setMain(UnconfinedTestDispatcher()) Dispatchers.setMain(UnconfinedTestDispatcher())
@@ -79,12 +90,34 @@ class NestViewModelTest {
@AfterTest @AfterTest
fun resetMainDispatcher() { fun resetMainDispatcher() {
// Belt-and-braces: if a test crashes before its finally runs, this
// still tears down so the next test starts clean.
createdVms.forEach { runCatching { it.disconnect() } }
createdVms.clear()
Dispatchers.resetMain() Dispatchers.resetMain()
} }
/**
* Test wrapper that guarantees every VM created via [newViewModel] is
* disconnected before [runTest] tries to drain the test scheduler. The
* 10-second [runTest] timeout is a safety net — a healthy test in this
* class finishes in milliseconds; if we trip the timeout it almost
* certainly means a new code path is leaving viewModelScope coroutines
* running and needs its own teardown call.
*/
private fun runVmTest(body: suspend TestScope.() -> Unit) =
runTest(timeout = 10.seconds) {
try {
body()
} finally {
createdVms.forEach { runCatching { it.disconnect() } }
createdVms.clear()
}
}
@Test @Test
fun connectShowsConnectingThenConnected() = fun connectShowsConnectingThenConnected() =
runTest { runVmTest {
val fakeListener = FakeNestsListener() val fakeListener = FakeNestsListener()
val vm = newViewModel { fakeListener } val vm = newViewModel { fakeListener }
@@ -103,7 +136,7 @@ class NestViewModelTest {
@Test @Test
fun listenerFailedSurfacesAsUiFailed() = fun listenerFailedSurfacesAsUiFailed() =
runTest { runVmTest {
val fakeListener = FakeNestsListener() val fakeListener = FakeNestsListener()
val vm = newViewModel { fakeListener } val vm = newViewModel { fakeListener }
@@ -117,7 +150,7 @@ class NestViewModelTest {
@Test @Test
fun connectorThrowsBecomesUiFailed() = fun connectorThrowsBecomesUiFailed() =
runTest { runVmTest {
val vm = newViewModel { throw NestsException("dns blew up") } val vm = newViewModel { throw NestsException("dns blew up") }
vm.connect() vm.connect()
@@ -129,7 +162,7 @@ class NestViewModelTest {
@Test @Test
fun setMutedFlipsUiStateAndIsRetained() = fun setMutedFlipsUiStateAndIsRetained() =
runTest { runVmTest {
val vm = newViewModel { FakeNestsListener() } val vm = newViewModel { FakeNestsListener() }
assertFalse(vm.uiState.value.isMuted) assertFalse(vm.uiState.value.isMuted)
@@ -141,7 +174,7 @@ class NestViewModelTest {
@Test @Test
fun onStageNowDefaultsTrueAndSetOnStageFlipsIt() = fun onStageNowDefaultsTrueAndSetOnStageFlipsIt() =
runTest { runVmTest {
val vm = newViewModel { FakeNestsListener() } val vm = newViewModel { FakeNestsListener() }
// Defaults to true so a freshly-joined speaker advertises // Defaults to true so a freshly-joined speaker advertises
@@ -156,7 +189,7 @@ class NestViewModelTest {
@Test @Test
fun onPresenceEventPopulatesPresencesMapAndDedupesByPubkey() = fun onPresenceEventPopulatesPresencesMapAndDedupesByPubkey() =
runTest { runVmTest {
val vm = newViewModel { FakeNestsListener() } val vm = newViewModel { FakeNestsListener() }
val alice = "a".repeat(64) val alice = "a".repeat(64)
@@ -187,7 +220,7 @@ class NestViewModelTest {
@Test @Test
fun evictStalePresencesDropsOldPeers() = fun evictStalePresencesDropsOldPeers() =
runTest { runVmTest {
val vm = newViewModel { FakeNestsListener() } val vm = newViewModel { FakeNestsListener() }
val alice = "a".repeat(64) val alice = "a".repeat(64)
val bob = "b".repeat(64) val bob = "b".repeat(64)
@@ -219,7 +252,7 @@ class NestViewModelTest {
@Test @Test
fun onChatEventAccumulatesMessagesSortedByCreatedAt() = fun onChatEventAccumulatesMessagesSortedByCreatedAt() =
runTest { runVmTest {
val vm = newViewModel { FakeNestsListener() } val vm = newViewModel { FakeNestsListener() }
val alice = "a".repeat(64) val alice = "a".repeat(64)
@@ -255,7 +288,7 @@ class NestViewModelTest {
@Test @Test
fun onChatEventDedupesByEventId() = fun onChatEventDedupesByEventId() =
runTest { runVmTest {
val vm = newViewModel { FakeNestsListener() } val vm = newViewModel { FakeNestsListener() }
val alice = "a".repeat(64) val alice = "a".repeat(64)
val msg = val msg =
@@ -281,7 +314,7 @@ class NestViewModelTest {
@Test @Test
fun onReactionEventGroupsByTargetAndEvictsOnTick() = fun onReactionEventGroupsByTargetAndEvictsOnTick() =
runTest { runVmTest {
val vm = newViewModel { FakeNestsListener() } val vm = newViewModel { FakeNestsListener() }
val alice = "a".repeat(64) val alice = "a".repeat(64)
val bob = "b".repeat(64) val bob = "b".repeat(64)
@@ -326,7 +359,7 @@ class NestViewModelTest {
@Test @Test
fun onKickFlipsWasKickedAndDisconnects() = fun onKickFlipsWasKickedAndDisconnects() =
runTest { runVmTest {
val fakeListener = FakeNestsListener() val fakeListener = FakeNestsListener()
val vm = newViewModel { fakeListener } val vm = newViewModel { fakeListener }
vm.connect() vm.connect()
@@ -343,7 +376,7 @@ class NestViewModelTest {
@Test @Test
fun onKickIsIdempotent() = fun onKickIsIdempotent() =
runTest { runVmTest {
val fakeListener = FakeNestsListener() val fakeListener = FakeNestsListener()
val vm = newViewModel { fakeListener } val vm = newViewModel { fakeListener }
vm.connect() vm.connect()
@@ -377,7 +410,7 @@ class NestViewModelTest {
@Test @Test
fun connectIsIdempotentWhileConnecting() = fun connectIsIdempotentWhileConnecting() =
runTest { runVmTest {
val fakeListener = FakeNestsListener() val fakeListener = FakeNestsListener()
var connectCalls = 0 var connectCalls = 0
val vm = val vm =
@@ -395,7 +428,7 @@ class NestViewModelTest {
@Test @Test
fun disconnectReturnsToIdleAndClosesListener() = fun disconnectReturnsToIdleAndClosesListener() =
runTest { runVmTest {
val fakeListener = FakeNestsListener() val fakeListener = FakeNestsListener()
val vm = newViewModel { fakeListener } val vm = newViewModel { fakeListener }
@@ -411,7 +444,7 @@ class NestViewModelTest {
@Test @Test
fun connectingStepMapsThroughToUiStep() = fun connectingStepMapsThroughToUiStep() =
runTest { runVmTest {
val fakeListener = FakeNestsListener() val fakeListener = FakeNestsListener()
val vm = newViewModel { fakeListener } val vm = newViewModel { fakeListener }
@@ -429,7 +462,7 @@ class NestViewModelTest {
@Test @Test
fun speakingNowClearsOnTeardown() = fun speakingNowClearsOnTeardown() =
runTest { runVmTest {
val fakeListener = FakeNestsListener() val fakeListener = FakeNestsListener()
val vm = newViewModel { fakeListener } val vm = newViewModel { fakeListener }
@@ -466,7 +499,7 @@ class NestViewModelTest {
// Wire to the test's backgroundScope so close calls run during // Wire to the test's backgroundScope so close calls run during
// the test rather than escaping to the real GlobalScope. // the test rather than escaping to the real GlobalScope.
cleanupScope = backgroundScope, cleanupScope = backgroundScope,
) ).also { createdVms.add(it) }
private class FakeNestsListener : NestsListener { private class FakeNestsListener : NestsListener {
private val mutable = MutableStateFlow<NestsListenerState>(NestsListenerState.Idle) private val mutable = MutableStateFlow<NestsListenerState>(NestsListenerState.Idle)
@@ -189,7 +189,7 @@ class RoomSpeakerCatalogTest {
@Test @Test
fun stripPrefixRoundTripsCanonicalCatalog() { fun stripPrefixRoundTripsCanonicalCatalog() {
// The catalog payload `MoqLiteHangCatalog.opusMono48k(...)` emits // The catalog payload `MoqLiteHangCatalog.opus48k(...)` emits
// (in `:nestsClient`) MUST round-trip through this parser — the // (in `:nestsClient`) MUST round-trip through this parser — the
// two classes target the same wire shape independently because // two classes target the same wire shape independently because
// `:nestsClient` does not depend on `:commons` and vice versa. // `:nestsClient` does not depend on `:commons` and vice versa.
+4 -1
View File
@@ -133,7 +133,10 @@ compose.desktop {
} }
vlcSetup { vlcSetup {
vlcVersion.set("3.0.21") // Pinned to 3.0.20 because the Linux VLC plugins on Maven Central
// (ir.mahozad:vlc-plugins-linux) have not been republished for 3.0.21 — the
// latest there is 3.0.20-2. Using 3.0.21 makes vlcDownload 404 on Linux CI.
vlcVersion.set("3.0.20")
shouldCompressVlcFiles.set(true) shouldCompressVlcFiles.set(true)
shouldIncludeAllVlcFiles.set(true) shouldIncludeAllVlcFiles.set(true)
pathToCopyVlcLinuxFilesTo.set(file("src/jvmMain/appResources/linux/vlc")) pathToCopyVlcLinuxFilesTo.set(file("src/jvmMain/appResources/linux/vlc"))
+87
View File
@@ -0,0 +1,87 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.jetbrainsKotlinJvm)
alias(libs.plugins.serialization)
application
`java-test-fixtures`
}
application {
mainClass.set("com.vitorpamplona.geode.MainKt")
applicationName = "geode"
}
kotlin {
jvmToolchain(21)
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
}
}
sourceSets {
main {
kotlin.srcDir("src/main/kotlin")
}
test {
kotlin.srcDir("src/test/kotlin")
}
// The `java-test-fixtures` plugin auto-creates a `testFixtures`
// source set; we just point it at our Kotlin layout so the
// `geode.fixtures` (synthetic events) and `geode.testing`
// (RelayClientTest, collectUntilEose) packages don't ship in
// production jars but are still usable by every consumer's test
// source via `testImplementation(testFixtures(project(":geode")))`.
named("testFixtures") {
kotlin.srcDir("src/testFixtures/kotlin")
}
}
tasks.withType<Test>().configureEach {
// Forward `-DrunLoadBenchmark=true` to the test JVM so the
// perf.LoadBenchmark tests opt in. Off by default — load tests
// are noisy and slow.
systemProperty("runLoadBenchmark", System.getProperty("runLoadBenchmark") ?: "false")
// Show println output from test JVM so the benchmark numbers are
// actually visible without grepping the report XML.
testLogging {
showStandardStreams =
(System.getProperty("runLoadBenchmark") == "true")
events("standard_out")
}
}
dependencies {
api(project(":quartz"))
implementation(libs.kotlinx.coroutines.core)
implementation(libs.jackson.module.kotlin)
implementation(libs.kotlinx.serialization.json)
// Bundled SQLite driver — Relay's default in-memory EventStore creates
// an in-memory DB at runtime.
implementation(libs.androidx.sqlite.bundled.jvm)
// Ktor server engine + WebSocket plugin so Relay can serve real ws://
// traffic. CIO is the coroutine-based engine — lighter than Netty.
api(libs.ktor.server.core)
api(libs.ktor.server.cio)
api(libs.ktor.server.websockets)
// TOML parsing for the operator config file. Mirrors the section
// layout of nostr-rs-relay's config.toml so existing operators can
// port their configs nearly verbatim.
implementation(libs.fourkoma)
// testFixtures: code in src/testFixtures/kotlin (RelayClientTest +
// synthetic event builders). Not shipped in the production jar but
// exposed to consumers via testImplementation(testFixtures(...)).
testFixturesApi(project(":quartz"))
testFixturesApi(libs.junit)
testFixturesImplementation(libs.kotlinx.coroutines.core)
testImplementation(libs.kotlin.test)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.secp256k1.kmp.jni.jvm)
testImplementation(libs.okhttp)
}
+82
View File
@@ -0,0 +1,82 @@
# Example config for geode. Section layout mirrors
# nostr-rs-relay's config.toml so existing operators can port across.
#
# Run with:
# ./gradlew :geode:run --args="--config /etc/geode.toml"
#
# CLI flags override individual values: e.g. `--port 8888` wins over
# `[network].port`.
[info]
# The wss:// URL clients use to reach this relay (mandatory for NIP-42
# AUTH challenges). If not set, the relay synthesises one from the
# [network] section.
relay_url = "wss://relay.example.com/"
name = "Example Geode"
description = "A geode deployment."
contact = "admin@example.com"
# Operator pubkey (NIP-11). Optional.
# pubkey = "..."
# Override the supported NIPs advertised on the NIP-11 endpoint. If
# omitted, the relay advertises the NIPs it actually implements.
# supported_nips = [1, 9, 11, 40, 42, 45, 50, 62]
[network]
host = "0.0.0.0"
port = 7447
path = "/"
[database]
# True keeps an in-memory SQLite db (events vanish on restart). Useful
# for tests; set false + `file = "..."` for persistent storage.
in_memory = false
file = "/var/lib/geode/events.db"
[options]
# Drop events whose Schnorr signature does not verify. Strongly
# recommended for any relay accepting traffic from real clients.
# Verify Schnorr signatures on every EVENT. Default: true. Disable
# only for trusted-input scenarios (test fixtures, mirror replays).
# verify_signatures = true
# Require clients to NIP-42 AUTH before REQ/EVENT/COUNT.
require_auth = false
# Reject events whose `created_at` is more than this many seconds in
# the future. Enforced by RejectFutureEventsPolicy.
# reject_future_seconds = 1800
[limits]
# Maximum WebSocket frame size. Frames larger than this are dropped at
# the WS layer. (max_ws_message_bytes maps to the same setting since
# Ktor's WebSockets plugin only exposes per-frame caps.)
# max_ws_message_bytes = 1048576
# max_ws_frame_bytes = 1048576
[authorization]
# Allow / deny lists. Allow is a permissive ceiling; deny still
# removes specific entries inside it. Enforced by Pubkey/KindAllowDenyPolicy.
# pubkey_whitelist = ["abcdef...64hex..."]
# pubkey_blacklist = []
# kind_whitelist = [0, 1, 3, 7, 1059, 30023]
# kind_blacklist = [4]
[admin]
# NIP-86 relay management API. When `pubkeys` is non-empty, the relay
# accepts HTTP POST application/nostr+json+rpc on the same URL,
# authenticated with NIP-98 HTTP-Auth. Only events signed by one of
# the listed pubkeys can run admin RPCs (banpubkey / banevent /
# changerelayname / …). Empty (the default) disables the endpoint.
# pubkeys = ["abcdef...64hex..."]
#
# Canonical URL the relay is reachable at, e.g. behind a reverse proxy.
# NIP-98 binds requests to this URL via the `u` tag. **Required** in
# any production deployment — without it, an attacker can spoof the
# Host header to bypass URL binding.
# public_url = "https://relay.example.com/"
# Path for the JSON snapshot that persists NIP-86 admin state (ban
# lists + the live NIP-11 doc) across restarts. When unset, admin
# state is in-memory only and forgotten on every restart. Convention
# is to place this next to the SQLite event-store file.
# state_file = "/var/lib/geode/events.db.admin.json"
@@ -0,0 +1,93 @@
# Connection scaling: pushing past 2 000
## Problem
Current measurement (`LoadBenchmark.connectionsHeldOpen`): **~2 000
concurrent connections** before file-descriptor pressure / Ktor CIO
event-loop saturation. Real-world relays (e.g. nostr.wine, nos.lol)
sustain 1030k. Geode shouldn't be the bottleneck for an Amethyst-
adjacent operator who scales beyond a thousand-user community.
## What's spending memory per connection today
| Cost | Per connection | At 5 000 conns |
| ----------------------- | -------------------------------------------------------- | -------------- |
| `outQueue` Channel | 8 192 string slots × ~8 b ref | ~320 MB pinned |
| `RelaySession` | `LargeCache<String, Job>` for subs (likely 110 entries) | ~negligible |
| `NegSessionRegistry` | `HashMap<String, NegentropyServerSession>` — usually 0 | ~negligible |
| Ktor CIO buffers | TCP read + write buffers | ~10 MB |
| Per-session writer Job | one coroutine | ~few KB |
The `outQueue` reservation is the dominant cost. The 8 192 was sized
for a worst case "thousands of subscriptions, one event matches all" —
but at 5 000 connections we've over-provisioned by ~300 MB just on
the channel array, even though most connections never fan out.
## Sketch
### A — adaptive outQueue capacity
Start every connection with `INITIAL_OUTGOING_BUFFER = 64`. When the
producer side trySends and we observe queue depth crossing a high-water
mark (e.g. 75% full), grow the channel up to `MAX_OUTGOING_BUFFER =
8192`. This is not how `kotlinx.coroutines.channels.Channel` is
structured (capacity is fixed at construction), so the implementation
is "swap in a wider channel under a per-session lock when watermark
trips" — drains the old, then routes new sends through the new.
Expected: 90% of connections never fan out, so they stay at 64 slots
× ~512 B per ref ≈ 32 KB. At 5 000 conns that's ~160 MB → ~5 MB.
Hot-fanout connections still get the 2 MB cap.
### B — per-relay event-loop pool sizing
Ktor CIO defaults to one event-loop thread per available CPU.
Beyond a few thousand connections, this becomes the bottleneck — and
none of geode's per-connection work is CPU-bound (it's mostly waiting
on incoming frames). Tune CIO via:
```kotlin
embeddedServer(CIO, ...) {
connectionGroupSize = max(2, Runtime.getRuntime().availableProcessors() / 2)
workerGroupSize = max(4, Runtime.getRuntime().availableProcessors())
callGroupSize = max(8, Runtime.getRuntime().availableProcessors() * 4)
}
```
Expose these through `RelayConfig.NetworkSection` so an operator on a
big VM can lift them.
### C — reduce per-message JSON allocations
`OptimizedJsonMapper.fromJsonToCommand` allocates a `JsonNode` tree per
incoming frame. At 10k connections with 1 msg/s each that's 10k tree
allocations/sec. Investigate streaming Jackson + reusing `ObjectMapper`
per session, or using kotlinx-serialization's lower-overhead path.
This is more of a quartz-level change than geode-specific, but
geode's load benchmark is the right place to measure it.
## How to verify
Add to `geode.perf.LoadBenchmark`:
- `connectionsHeldOpen10k` — opens 10 000 idle WebSocket connections;
asserts no FD exhaustion + RSS stays under 1 GB.
- `connectionsHeldOpenWithFanout` — 5 000 idle subscribers,
10 EPS published; measures p99 fanout latency at scale.
The current `connectionsHeldOpen` benchmark stays as the baseline
floor (~2 000 conns).
## Risks
- **Adaptive channel swap is fiddly**: drains under the producer's nose
must preserve OK ordering. A simpler alternative: keep capacity fixed,
but lazily allocate a small `ArrayDeque<String>` only when the first
message is sent. Channels in kotlinx.coroutines do allocate up-front.
- **Bumping CIO group sizes can hurt**: more threads can mean worse
L1/L2 locality. Always benchmark before/after, don't trust
intuitive sizing.
- **OS-level FD limit**: per-process FD limit on Linux defaults to
1024 in many environments. Document the `ulimit -n` requirement
for operators targeting >1k connections.
@@ -0,0 +1,91 @@
# Event ingestion: write batching + pipelined OK
## Problem
EVENT acceptance is the hot path on a busy relay — every published note,
every reaction, every DM lands here. Today the per-event flow is fully
serial:
1. `RelaySession.handleEvent` (`quartz/nip01Core/relay/server/RelaySession.kt:131`)
awaits `policy.accept(cmd)` (Schnorr verify if `VerifyPolicy` is in
the stack — ~0.1 ms on JVM).
2. Awaits `store.insert(cmd.event)` — a single SQLite write, guarded by
the connection-pool writer mutex (`SQLiteConnectionPool`).
3. Sends `OkMessage` back through the writer coroutine.
`LoadBenchmark.publishThroughputSingleClient` measured **~760 EPS**;
the concurrent variant **~2000 EPS** (limited by SQLite writer mutex
contention, not WS throughput).
## Constraints we must keep
- **OK ordering**: NIP-01 requires the OK reply to follow its EVENT.
We cannot reply OK before the insert decision (the OK carries
accepted/rejected + reason).
- **Durability semantics**: clients reasonably assume `OK true` means
"stored." Batching must not make us reply OK before fsync.
- **Per-connection FIFO**: a publisher that sends three EVENTs in a
row expects three OKs in that order. Reordering across connections
is fine.
## Sketch
### Tier 1 — SQLite WAL + group commit (cheap win)
Confirm `PRAGMA journal_mode=WAL` + `PRAGMA synchronous=NORMAL` on the
event-store DB; group commits across the writer mutex's hold window.
Today each insert is its own transaction. Wrap N inserts (or a 5 ms
budget, whichever first) in a single transaction managed by the writer
coroutine. On commit, fan back N OK replies.
Implementation lives in quartz's `EventStore` / `SQLiteConnectionPool`,
not geode — but geode owns the benchmark and validates the gain.
Expected: **~510× write throughput** on a fast SSD. SQLite group
commit is well-trodden territory (nostr-rs-relay, strfry both do it).
### Tier 2 — pipelined OK over multiple in-flight EVENTs
`RelaySession.receive` is currently single-flight: one EVENT in,
process, OK out, next EVENT. Allow a connection to push N EVENTs
concurrently, dispatch them to a per-connection ingest pipeline, and
serialise OKs back in arrival order via a small commit log.
A `Channel<EventCmd> with capacity = INGEST_PIPELINE_DEPTH` per
connection, drained by a coroutine that batches into the group-commit
above. OK responses are written to an `outQueue.send()` already — so
the pipeline just needs to record arrival order and emit OKs in that
order after each batch commits.
Expected: hides the verify+insert latency behind another EVENT's
parse, gets us closer to network-bound throughput.
### Tier 3 — eager Schnorr verify off the writer thread
`VerifyPolicy` is in the policy stack and runs synchronously on
`receive`. Move it into the ingest pipeline so verification of EVENT N+1
runs concurrently with the SQLite commit of EVENT N. secp256k1 verify
is parallelisable; the writer should never block on it.
## How to verify
Add to `geode.perf.LoadBenchmark`:
- `publishGroupCommitSingleClient` — same workload as the current
single-client benchmark, asserts >5000 EPS.
- `publishPipelinedSingleClient` — sends 100 EVENTs without awaiting
intermediate OKs; measures end-to-end and OK-ordering correctness.
Existing benchmarks stay as the regression floor.
## Risks
- **Group commit windows**: if a single bad event in the batch fails
validation, we must not roll back the good ones. The batch needs
per-row commit semantics (row-level errors → row-level OK false).
- **Backpressure on slow disks**: deeper pipelines on slow storage
amplify out-of-memory pressure. Cap the in-flight queue depth and
apply existing slow-client backpressure if it fills.
- **Replay protection**: the existing dedupe table needs to see the
event before commit, not after — keep that check inside the writer
coroutine.
@@ -0,0 +1,100 @@
# Live broadcast: indexed filter matching for fanout
## Problem
Every accepted EVENT runs through `LiveEventStore.newEventStream`
(`quartz/nip01Core/relay/server/LiveEventStore.kt:43`) — a
`MutableSharedFlow<Event>` that every active subscription collects.
Each subscriber's collector then calls:
```kotlin
if (filters.any { it.match(newEvent) }) onEach(newEvent)
```
That's **O(N_subscribers × N_filters_per_sub)** per published event.
With 5k connections × ~3 filters average that's 15k Filter.match
calls per EVENT — and each `Filter.match` itself walks `kinds`,
`authors`, tag prefixes, since/until, etc. At 2k EPS ingest that's
~30M comparisons/sec.
Two specific cost shapes:
1. **Filters that almost never match.** Most subscriptions are scoped
to a small author list. Today every published EVENT walks every
such subscription to learn that. A `HashMap<HexKey, MutableList<Sub>>`
keyed by author would cut this to O(1) average for the dominant case.
2. **Pseudo-broadcast filters** (`{kinds: [1]}` with no other
constraint) match almost everything. There's no avoiding the
per-subscriber notification, but at least the index lookup is
cheap.
`LoadBenchmark.fanoutLatency` already measures this — current
results are not yet noted in tree, but back-of-envelope says fanout
becomes the dominant cost above ~2k subscribers.
## Sketch
A new `LiveBroadcastIndex` inside `LiveEventStore`:
```kotlin
private val byAuthor = ConcurrentHashMap<HexKey, MutableSet<Subscription>>()
private val byKind = ConcurrentHashMap<Int, MutableSet<Subscription>>()
private val byTag = ConcurrentHashMap<TagKey, MutableSet<Subscription>>()
private val unindexed = CopyOnWriteArraySet<Subscription>() // subs with no
// narrowing field
```
Each `RelaySession.handleReq` registers its `Subscription` (a tuple of
filters + the existing `EventMessage` send callback) into whichever
buckets each filter narrows on. A filter with `kinds=[1] and
authors=[a,b]` registers into `byKind[1]` AND `byAuthor[a]`,
`byAuthor[b]` — broadcast unions the resulting candidate sets.
On EVENT arrival:
1. Build the candidate set: union of `byAuthor[event.pubkey]`,
`byKind[event.kind]`, every `byTag[(letter, value)]` for the
event's single-letter tags, plus `unindexed`.
2. Run the existing `Filter.match` on each candidate to handle
negative constraints (`since`, `until`, `limit` already-reached,
composite predicates).
3. Send.
Expected: **>10× speedup** on fanout for realistic subscriptions.
Worst case (all filters in `unindexed`) degrades to current behaviour.
## Where it lives
`quartz/nip01Core/relay/server/LiveBroadcastIndex.kt` — protocol-level,
reusable by any relay embed. `RelaySession.handleReq` registers/
unregisters; `LiveEventStore.insert` calls
`index.candidatesFor(event)`.
## How to verify
Add `geode.perf.LoadBenchmark.fanoutScaling`:
- N connections, each subscribes to `{authors: [pk_i], kinds: [1]}`.
- Publish 10k EVENTs from a producer connection; each event matches
exactly one subscriber.
- Measure end-to-end latency p50/p99 for N ∈ {100, 1000, 5000}.
Without the index, p99 grows roughly linearly with N. With the
index, p99 should be flat up to a much higher N.
## Risks
- **Subscription churn**: re-subscribing on every page (the way some
client features work) means many index insert/remove operations.
`ConcurrentHashMap` value-set operations need to be lock-free or
finely locked; benchmark this path explicitly.
- **Tag explosion**: an EVENT with many `e`/`p` tags hits many tag
buckets. Cap candidate-set union work or short-circuit when the
union saturates.
- **Memory**: the index is a per-bucket set of subscription handles.
At 5k subs × average 3 narrowing fields, ~15k entries — negligible.
- **Correctness fence**: the index must see new subscriptions before
the next EVENT broadcast. Today `RelaySession.handleReq` writes its
`Job` into a `LargeCache` then launches the collector. Order of
operations needs to be revisited so the index is updated atomically
with the collector being ready.
@@ -0,0 +1,100 @@
# NIP-77 negentropy at scale: snapshot memory + chunked replay
## Problem
`RelaySession` delegates NEG-OPEN to `NegSessionRegistry.open`
(`quartz/nip01Core/relay/server/NegSessionRegistry.kt`), which calls
`store.snapshotQuery(filters)` and feeds the **entire** result list
into `NegentropyServerSession`. For a relay holding 5 M events that
match a broad NEG-OPEN filter (`{kinds: [1, 7]}`), this is 5 M
`Event` objects materialised in memory before the first NEG-MSG goes
out.
The negentropy library itself is fine — it pivots into a sealed
`StorageVector` (id + createdAt only, ~40 bytes/entry). But the
`store.query<Event>(f)` step that produces the input materialises full
`Event` objects with content, tags, sig — call it ~1 KB/event. 5 M ×
1 KB = 5 GB transient pressure per concurrent NEG-OPEN.
Two operator-visible symptoms:
1. NEG-OPEN with a broad filter spikes JVM heap; under load, GC pause
stalls every other handler on the same process.
2. NEG-OPEN latency before the first NEG-MSG response is O(N) — for
large stores the client waits seconds for what should be a
millisecond round-trip.
## Sketch
### A — id-and-time-only snapshot path
Negentropy only needs `(createdAt, id)` pairs. Add a streaming
`IEventStore.queryIdAndTime(filter)` that returns
`Sequence<Pair<Long, ByteArray>>` (or a `Flow` of small chunks) —
no content/tags/sig, no Event allocation. SQLite path is a SELECT
on `event_headers` (the `created_at`, `id` columns are already
indexed for query plans).
```kotlin
suspend fun snapshotIdsForNegentropy(filter: Filter): IdTimeStream
```
`NegentropyServerSession` is rewritten to take that stream and feed
it directly into the `StorageVector`. Memory drops from O(N × 1 KB)
to O(N × 40 B) — a 25× reduction; for 5 M events, ~200 MB instead
of 5 GB.
### B — bounded-window subscriptions
Most NEG-OPENs from real Nostr clients want the last 30 days, not
"everything." If the client doesn't supply `since`, the server can
default to a configurable horizon (e.g. 90 days) and surface this in
the NIP-11 `limitation.negentropy_max_lookback_seconds` field.
Operators can lift the cap; clients reading the doc know the bound.
This is a NIP-spec-adjacent question more than a code change — needs
a comment on whether the spec allows it. nostr-rs-relay does this
already.
### C — frame-size cap on NEG-MSG
`NegentropyServerSession` is constructed with `frameSizeLimit = 0`
(no limit). At very large reconciliations the message can grow large.
Set a default `frameSizeLimit = 64 * 1024` (matching the typical WS
frame budget) so NEG-MSGs don't blow past `[limits].max_ws_frame_bytes`.
The library already supports this — pure config change in
`NegSessionRegistry.open`.
### D — concurrent NEG-OPEN cap
A NEG-OPEN holds session state until NEG-CLOSE (or connection close).
Today nothing caps the number of concurrent open negentropy sessions
per connection. A misbehaving (or hostile) client could open thousands
and pin RAM. Add `MAX_NEG_SESSIONS_PER_CONNECTION = 16`, send NEG-ERR
on overflow.
## How to verify
Add to `geode.perf.LoadBenchmark`:
- `negentropyOpenLatencyLargeCorpus` — preload 1 M events (use
fixtures), measure NEG-OPEN → first NEG-MSG latency. Target <100 ms.
- `negentropyMemoryPressure` — open 10 concurrent NEG-OPENs on the
same large corpus; measure RSS delta, target <500 MB.
## Risks
- **`Sequence`/`Flow` over SQLite cursor**: holding a cursor open
across the full sync is fragile if the client stalls. Materialise
to a smaller in-memory list (just (id, createdAt)) once, reuse for
the lifetime of the session. Memory bound is the same.
- **Defaulting `since` is a behaviour change**: existing clients that
expect "everything" silently get a bounded window. Either (a) make
it opt-in via `RelayConfig.NegentropySection.default_lookback_seconds
= null`, (b) advertise the cap in NIP-11 so well-behaved clients
read it.
- **Frame-size cap can break older clients**: the NIP-77 reference
implementation (kmp-negentropy) handles this gracefully — multi-frame
reconciliation is in spec — but field-test against a known-working
client (e.g. nstart, primal-cache) before flipping the default.
+19
View File
@@ -0,0 +1,19 @@
# geode plans
Performance-focused design docs for future work. Each file is a
self-contained sketch — problem statement, observed numbers, proposed
fix, how to verify, risks. None of these are committed work; they're
the queue.
Ordered roughly by expected impact:
| Plan | Headline gain |
| ---- | ------------- |
| [2026-05-07-event-ingestion-batching.md](2026-05-07-event-ingestion-batching.md) | 510× write EPS via SQLite group commit + ingest pipelining |
| [2026-05-07-live-broadcast-fanout-index.md](2026-05-07-live-broadcast-fanout-index.md) | >10× fanout speedup at >2 000 subscribers |
| [2026-05-07-connection-scaling.md](2026-05-07-connection-scaling.md) | 2 000 → 10 000+ concurrent connections |
| [2026-05-07-negentropy-large-corpus.md](2026-05-07-negentropy-large-corpus.md) | 25× lower memory + faster NEG-OPEN on M-event corpora |
Verification target for each plan is a new method on
`geode.perf.LoadBenchmark` (gated by `-DrunLoadBenchmark=true`) so
regressions show up in the regular CI matrix once they're enabled.
@@ -0,0 +1,275 @@
/*
* 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.geode
import com.vitorpamplona.geode.server.Nip86HttpRoute
import com.vitorpamplona.geode.server.WebSocketSessionPump
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage
import com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.nip86RelayManagement.server.Nip86Server
import com.vitorpamplona.quartz.nip98HttpAuth.Nip98AuthVerifier
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.install
import io.ktor.server.cio.CIO
import io.ktor.server.cio.CIOApplicationEngine
import io.ktor.server.engine.embeddedServer
import io.ktor.server.request.header
import io.ktor.server.response.respondText
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.routing
import io.ktor.server.websocket.WebSockets
import io.ktor.server.websocket.webSocket
import kotlinx.coroutines.runBlocking
import java.util.concurrent.ConcurrentHashMap
/**
* Hosts a [Relay] over a real `ws://` endpoint backed by Ktor + CIO.
*
* Use this when something other than the in-process
* [com.vitorpamplona.quartz.nip01Core.relay.server.inprocess.InProcessWebSocket]
* needs to talk to the relay — Android instrumented tests, the `cli`
* tooling, external clients, or a standalone "run a Nostr relay"
* process.
*
* For unit-test wiring inside a single JVM, prefer [RelayHub] + the
* in-process socket — same protocol, no socket overhead.
*
* Lifecycle:
* ```
* val server = LocalRelayServer(Relay(url = ...)).start()
* println("listening on ${server.url}")
* // ... do stuff ...
* server.stop()
* ```
*/
class LocalRelayServer(
val relay: Relay,
val host: String = "127.0.0.1",
/** Pass 0 to let the OS pick a free port. Read [url] after [start] to learn it. */
val port: Int = 0,
val path: String = "/",
/**
* Per-frame size cap; mirrors `[limits].max_ws_frame_bytes` in the
* config. Frames larger than this are rejected at the WebSocket
* layer, which is the only layer that sees the raw bytes. `null`
* uses Ktor's default (~1 MiB).
*/
val maxFrameBytes: Long? = null,
/**
* Pubkeys allowed to call NIP-86 admin RPCs. Empty (the default)
* disables the admin endpoint entirely — POSTs return 403.
* Otherwise: HTTP POSTs to [path] with `Content-Type:
* application/nostr+json+rpc` are dispatched to [Nip86Server],
* gated by NIP-98 HTTP-Auth membership in this set.
*/
val adminPubkeys: Set<HexKey> = emptySet(),
/**
* Canonical public URL the relay is reachable at, e.g.
* `https://relay.example.com/`. NIP-98 admin requests must sign
* the **same** URL string they're sending to. When the relay sits
* behind TLS termination or a reverse proxy, the `Host` header
* the relay sees does not match what the client signs, so the
* verifier must compare against this configured value.
*
* `null` (the default) falls back to the request's `Host` header
* with `http://` — fine for local-loopback unit tests, **NOT
* SAFE** in a public deployment because an attacker can spoof
* `Host` and bind their signature to any URL.
*/
val publicUrl: String? = null,
/**
* Maximum body size accepted on the NIP-86 POST endpoint, in
* bytes. Bounded *before* auth verification because we read the
* body to compute its sha256 for NIP-98's payload binding —
* unbounded reads would let an unauthenticated attacker stream
* gigabytes and OOM the relay. 1 MiB easily fits any plausible
* RPC payload.
*/
val maxAdminBodyBytes: Int = 1 shl 20,
) {
private val infoHolder =
object : Nip86Server.InfoHolder {
override fun get(): Nip11RelayInformation = relay.info.document
override fun set(info: Nip11RelayInformation) {
relay.updateInfo { info }
}
}
private val nip86Server = Nip86Server(banStore = relay.banStore, infoHolder = infoHolder, store = relay.store)
private val nip86Route =
Nip86HttpRoute(
server = nip86Server,
verifier = Nip98AuthVerifier(),
allowList = adminPubkeys.mapTo(HashSet()) { it.lowercase() },
maxBodyBytes = maxAdminBodyBytes,
signedUrlFor = { call ->
publicUrl ?: ("http://" + (call.request.header(HttpHeaders.Host) ?: "$host:$resolvedPort") + path)
},
)
private var engine: CIOApplicationEngine? = null
private var resolvedPort: Int = -1
/**
* Set when [stop] begins. Once true, the WebSocket handler refuses
* new upgrades — Ktor's `engine.stop` will eventually do this too,
* but Ktor's grace window means new connections can land between
* `notifyShutdown` and the actual port-close, missing the NOTICE
* we just sent to existing clients.
*/
@Volatile
private var shuttingDown: Boolean = false
/**
* Active client sessions, registered when their WebSocket handler
* runs and removed on disconnect. Exposed (read-only) so [stop] can
* NOTICE every connected client during graceful drain, and so tests
* can assert lifecycle bookkeeping.
*/
private val activeSessions: MutableSet<RelaySession> = ConcurrentHashMap.newKeySet()
/** Number of WebSocket sessions currently connected to the server. */
val activeSessionCount: Int get() = activeSessions.size
/** `ws://host:port/path` — only valid after [start]. */
val url: String
get() {
check(resolvedPort != -1) { "Server not started" }
return "ws://$host:$resolvedPort$path"
}
/**
* Binds the Ktor engine. Returns once the engine reports ready, so
* [url] is safe to read on the very next line.
*/
fun start(): LocalRelayServer {
val server =
embeddedServer(CIO, host = host, port = port) {
install(WebSockets) {
maxFrameBytes?.let { maxFrameSize = it }
}
routing {
// NIP-11: GET on the relay URL with Accept:
// application/nostr+json returns the relay info doc.
// We mount this *before* the webSocket route so Ktor
// serves NIP-11 for plain HTTP GETs and only upgrades
// to a WebSocket when the request is a WS upgrade.
get(path) {
val accept = call.request.header(HttpHeaders.Accept).orEmpty()
if (accept.contains("application/nostr+json")) {
call.response.headers.append("Access-Control-Allow-Origin", "*")
call.respondText(
relay.info.json,
ContentType.parse("application/nostr+json"),
)
} else {
call.respondText(
"Use a Nostr client (NIP-01 WebSocket) or send Accept: application/nostr+json (NIP-11).",
ContentType.Text.Plain,
HttpStatusCode.UpgradeRequired,
)
}
}
// NIP-86: POST application/nostr+json+rpc with a NIP-98
// signed Authorization header → JSON-RPC dispatch.
post(path) {
nip86Route.handle(call)
}
webSocket(path) {
if (shuttingDown) {
// Just return — Ktor closes the WS for us.
return@webSocket
}
WebSocketSessionPump(this).pump(
server = relay.server,
registerSession = activeSessions::add,
unregisterSession = activeSessions::remove,
)
}
}
}
server.start(wait = false)
engine = server.engine
// Ktor 3.x made resolvedConnectors() suspend. We block here so
// start() returns synchronously with [url] readable on the next line.
resolvedPort =
runBlocking {
server.engine
.resolvedConnectors()
.first()
.port
}
return this
}
/**
* Graceful shutdown. Safe to call multiple times.
*
* 1. Sends a NOTICE("closing: …") to every currently-connected
* client so well-behaved clients know to reconnect later.
* 2. Stops the Ktor engine: rejects new connections immediately,
* then waits up to [gracePeriodMillis] for active WebSocket
* handlers to finish whatever they're processing (so an in-flight
* `EVENT` lands its `OK` reply before the socket dies). After
* the grace window, in-progress handlers are cancelled and the
* engine waits up to [timeoutMillis] - [gracePeriodMillis] for
* that cancellation to complete.
*
* Defaults to 5 s grace / 10 s total — generous enough that a
* SQLite write + reply round-trip can land for typical event
* sizes. Override either with a tighter budget if your operator
* knows their workload.
*/
fun stop(
gracePeriodMillis: Long = 5_000,
timeoutMillis: Long = 10_000,
) {
val e = engine ?: return
// Order: (1) refuse new connections so they don't slip in and
// miss the NOTICE; (2) NOTICE every existing session so
// well-behaved clients reconnect later; (3) hand off to Ktor
// for the grace + timeout dance.
shuttingDown = true
notifyShutdown()
e.stop(gracePeriodMillis, timeoutMillis)
engine = null
resolvedPort = -1
}
/**
* Best-effort NOTICE to every active client. Failures are
* swallowed — a flaky socket on its way out is exactly the case
* where a NOTICE will fail anyway, and the client's read of the
* close frame is the authoritative shutdown signal.
*/
private fun notifyShutdown() {
val notice = NoticeMessage("closing: relay is shutting down — please reconnect later")
activeSessions.forEach { session ->
runCatching { session.send(notice) }
}
}
}
@@ -0,0 +1,218 @@
/*
* 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.geode
import com.vitorpamplona.geode.config.RelayConfig
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.KindAllowDenyPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PubkeyAllowDenyPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RejectFutureEventsPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyPolicy
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
import java.io.File
/**
* Standalone entry point.
*
* Run with:
* ./gradlew :geode:run --args="--config /etc/geode.toml"
* or
* java -cp ... com.vitorpamplona.geode.MainKt --port 7447 --verify
*
* Configuration precedence (highest to lowest):
* 1. CLI flags (`--host`, `--port`, …)
* 2. TOML file passed via `--config <path>`
* 3. Built-in defaults (host=0.0.0.0, port=7447, in-memory db, …)
*
* Every section is enforced: `[info]` populates the NIP-11 doc,
* `[network]` controls the bind, `[database]` chooses the SQLite path,
* `[options]` toggles AUTH/verify/future-skew, `[limits]` and
* `[authorization]` plug into the relay's policy stack.
*
* CLI flags:
* --config <file> TOML config (see config.example.toml)
* --host <addr> bind address (default from config or 0.0.0.0)
* --port <n> tcp port (default from config or 7447, 0 to autobind)
* --path <p> ws path (default from config or /)
* --info <file> NIP-11 doc file (overrides [info] section)
* --db <file> sqlite db path (overrides [database].file)
* --auth require NIP-42 AUTH (sets options.require_auth = true)
* --no-verify DO NOT verify event signatures (off by default
* verify is on; use only for trusted-input
* scenarios like fixture replay).
*/
fun main(args: Array<String>) {
val a = parseArgs(args)
val config: RelayConfig =
a
.opt("--config")
?.let { RelayConfig.fromFile(File(it)) }
?: RelayConfig()
val host = a.opt("--host") ?: config.network.host
val port = a.opt("--port")?.toInt() ?: config.network.port
val path = a.opt("--path") ?: config.network.path
val cliInfoFile = a.opt("--info")?.let { File(it) }
val dbFile = a.opt("--db") ?: config.database.file?.takeUnless { config.database.in_memory }
val requireAuth = a.flag("--auth") || config.options.require_auth
// Verify is on by default; only disable when the operator explicitly
// opts out (CLI `--no-verify` or `[options].verify_signatures = false`
// in the config).
val verifySigs = !a.flag("--no-verify") && config.options.verify_signatures
// Advertised URL: explicit `info.relay_url` wins, then build from
// host/port/path. 0.0.0.0 bind → 127.0.0.1 in the URL so NIP-42
// challenges are well-formed.
val advertisedHost = if (host == "0.0.0.0") "127.0.0.1" else host
val advertisedUrl =
(config.info.relay_url ?: "ws://$advertisedHost:$port$path").normalizeRelayUrl()
val info =
cliInfoFile?.let { RelayInfo.fromFile(it) }
?: config.resolveInfo(advertisedUrl)
val store: IEventStore = EventStore(dbName = dbFile, relay = advertisedUrl)
val policyBuilder: () -> IRelayPolicy = {
composePolicy(config, advertisedUrl, requireAuth, verifySigs)
}
val stateFile = config.admin.state_file?.let { File(it) }
val relay = Relay(advertisedUrl, store, info, policyBuilder, stateFile = stateFile)
// Frame cap honors max_ws_frame_bytes when set; max_ws_message_bytes
// is treated as the same cap (Ktor's WebSockets plugin only exposes
// a single per-frame limit; multi-frame messages remain unbounded).
val frameLimit =
(config.limits.max_ws_frame_bytes ?: config.limits.max_ws_message_bytes)?.toLong()
val server =
LocalRelayServer(
relay,
host = host,
port = port,
path = path,
maxFrameBytes = frameLimit,
adminPubkeys = config.admin.pubkeys.toSet(),
publicUrl = config.admin.public_url,
).start()
Runtime.getRuntime().addShutdownHook(
Thread {
// Each step wrapped so a throw in `server.stop()` doesn't
// skip `relay.close()` (which closes the SQLite store).
runCatching { server.stop() }
runCatching { relay.close() }
},
)
println("geode listening on ${server.url}")
println("NIP-11 info doc: curl -H 'Accept: application/nostr+json' http://$advertisedHost:$port$path")
// Park the main thread; shutdown hook handles teardown.
Thread.currentThread().join()
}
/**
* Builds the policy stack for one connection from the config.
*
* Order matters — cheap rejection paths run before expensive ones:
* 1. AUTH (drops everything if not authenticated)
* 2. Future-timestamp + allow/deny lists
* 3. Signature verification (most expensive — Schnorr verify)
*/
private fun composePolicy(
config: RelayConfig,
advertisedUrl: com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl,
requireAuth: Boolean,
verifySigs: Boolean,
): IRelayPolicy {
val pieces = mutableListOf<IRelayPolicy>()
if (requireAuth) {
pieces += FullAuthPolicy(advertisedUrl)
}
config.options.reject_future_seconds?.let { secs ->
pieces += RejectFutureEventsPolicy(secs)
}
val auth = config.authorization
if (auth.kind_whitelist.isNotEmpty() || auth.kind_blacklist.isNotEmpty()) {
pieces += KindAllowDenyPolicy(auth.kind_whitelist.toSet(), auth.kind_blacklist.toSet())
}
if (auth.pubkey_whitelist.isNotEmpty() || auth.pubkey_blacklist.isNotEmpty()) {
pieces += PubkeyAllowDenyPolicy(auth.pubkey_whitelist.toSet(), auth.pubkey_blacklist.toSet())
}
if (verifySigs) {
pieces += VerifyPolicy
}
return pieces.fold<IRelayPolicy, IRelayPolicy>(EmptyPolicy) { acc, p ->
if (acc === EmptyPolicy) p else acc + p
}
}
private class Args(
private val opts: Map<String, String>,
private val flags: Set<String>,
) {
fun opt(k: String) = opts[k]
fun flag(k: String) = k in flags
}
private fun parseArgs(args: Array<String>): Args {
val opts = mutableMapOf<String, String>()
val flags = mutableSetOf<String>()
var i = 0
while (i < args.size) {
val a = args[i]
if (a.startsWith("--")) {
// Support both `--key value` and `--key=value`. Splitting
// on the first `=` lets operators paste config values that
// happen to contain `=` (e.g. NIP-11 contact emails) by
// using the space-separated form.
val eq = a.indexOf('=')
if (eq > 0) {
opts[a.substring(0, eq)] = a.substring(eq + 1)
i += 1
} else {
val next = args.getOrNull(i + 1)
if (next != null && !next.startsWith("--")) {
opts[a] = next
i += 2
} else {
flags += a
i += 1
}
}
} else {
i += 1
}
}
return Args(opts, flags)
}
@@ -0,0 +1,192 @@
/*
* 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.geode
import com.vitorpamplona.geode.persistence.BannedEntry
import com.vitorpamplona.geode.persistence.RelayPersistedState
import com.vitorpamplona.geode.persistence.RelayStateStore
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.nip86RelayManagement.server.BanListPolicy
import com.vitorpamplona.quartz.nip86RelayManagement.server.BanStore
import kotlinx.coroutines.SupervisorJob
import java.io.File
import kotlin.coroutines.CoroutineContext
/**
* A self-contained Nostr relay scoped to a single URL. Wraps a [NostrServer]
* over an [EventStore] (defaults to an in-memory SQLite database).
*
* Speaks NIP-01 (REQ/EVENT/EOSE/CLOSE), NIP-11 (relay info via [info]),
* NIP-42 (AUTH — supply [policyBuilder] = `{ FullAuthPolicy(url) }` or
* stack one with [com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy.plus]),
* NIP-45 (COUNT) and NIP-50 (search via the SQLite FTS index).
*
* Two transports:
* - [com.vitorpamplona.quartz.nip01Core.relay.server.inprocess.InProcessWebSocket] /
* [RelayHub] — no socket, fastest path, ideal
* for unit tests inside one JVM.
* - [LocalRelayServer] — Ktor `embeddedServer` listening on a real port.
* Use when external clients need to connect (`cli`, instrumented tests,
* standalone deployment).
*/
class Relay(
val url: NormalizedRelayUrl,
val store: IEventStore = EventStore(dbName = null, relay = url),
info: RelayInfo = RelayInfo.default(url),
policyBuilder: () -> IRelayPolicy = { EmptyPolicy },
parentContext: CoroutineContext = SupervisorJob(),
/**
* Optional path for the operator-state JSON snapshot. When set,
* the file is loaded at boot to seed [info] and [banStore], and
* rewritten atomically on every NIP-86 mutation and
* [updateInfo] call so admin actions survive restarts.
*
* Convention: place next to the SQLite event-store file
* (e.g. `events.db` → `events.db.admin.json`). `null` keeps
* everything in memory only — fine for tests.
*/
stateFile: File? = null,
) : AutoCloseable {
private val stateStore: RelayStateStore? = stateFile?.let { RelayStateStore(it) }
/**
* NIP-11 doc. Mutable so NIP-86 admin RPCs (`changerelayname`,
* `changerelaydescription`, `changerelayicon`) can swap the doc
* atomically. Readers (the NIP-11 GET endpoint) re-read on every
* request so changes are visible immediately, no restart needed.
*
* If a [RelayStateStore] is configured and the snapshot exists,
* the persisted info doc takes precedence over the constructor
* default — operators expect their last `changerelayname` to
* survive a restart.
*/
@Volatile
var info: RelayInfo =
stateStore?.load()?.info?.let { RelayInfo(it) } ?: info
private set
/** Mutates the live NIP-11 doc. Called by [Nip86Server]. */
fun updateInfo(transform: (Nip11RelayInformation) -> Nip11RelayInformation) {
info = RelayInfo(transform(info.document))
snapshot()
}
/**
* Runtime-mutable ban / allow lists. NIP-86 RPC handlers in
* [Nip86Server] mutate this; the policy stack consults it on
* every accept call via [BanListPolicy].
*/
val banStore: BanStore = BanStore(onMutation = { snapshot() })
init {
// Seed the in-memory ban state from disk *without* triggering
// [snapshot] on every entry — the snapshot is exactly what we
// just loaded.
stateStore?.load()?.let { snap ->
banStore.seedFromSnapshot(
bannedPubkeys = snap.bannedPubkeys.map { it.key to it.reason },
allowedPubkeys = snap.allowedPubkeys.map { it.key to it.reason },
bannedEvents = snap.bannedEvents.map { it.key to it.reason },
allowedKinds = snap.allowedKinds,
disallowedKinds = snap.disallowedKinds,
)
}
}
/**
* Writes the current state (NIP-11 doc + ban lists) to disk.
* No-op when no `stateFile` was configured.
*
* Best-effort: any I/O failure is logged to stderr and swallowed
* so an unwritable disk doesn't take the relay down. Operators
* monitor for missing snapshots out-of-band.
*/
fun snapshot() {
val s = stateStore ?: return
runCatching {
s.save(
RelayPersistedState(
info = info.document,
bannedPubkeys = banStore.listBannedPubkeys().map { (k, r) -> BannedEntry(k, r) },
allowedPubkeys = banStore.listAllowedPubkeys().map { (k, r) -> BannedEntry(k, r) },
bannedEvents = banStore.listBannedEvents().map { (k, r) -> BannedEntry(k, r) },
allowedKinds = banStore.listAllowedKinds(),
disallowedKinds = banStore.listDisallowedKinds(),
),
)
}.onFailure {
System.err.println("warning: failed to write relay state file: ${it.message}")
}
}
val server =
NostrServer(
store,
// Always prepend a BanListPolicy so NIP-86 admin actions
// bite. When the operator-supplied builder returns
// [EmptyPolicy] we use the dynamic policy alone; otherwise
// we stack them so both layers must accept.
policyBuilder = {
val user = policyBuilder()
if (user === EmptyPolicy) BanListPolicy(banStore) else user + BanListPolicy(banStore)
},
parentContext,
)
/**
* Inserts events directly into the underlying store, bypassing the wire protocol.
*
* Use this for **pre-test setup** — events that exist before any client connects.
* It does NOT broadcast to active subscriptions. For sending events that should
* fan out to live subscribers (post-EOSE), use [publish] instead.
*/
suspend fun preload(events: Iterable<Event>) {
events.forEach { store.insert(it) }
}
/** @see preload(Iterable) */
suspend fun preload(vararg events: Event) = preload(events.toList())
/**
* Publishes an event through the relay's session machinery so it both lands
* in the store and fans out to active subscriptions matching its filters
* (mirrors what a real client would do via an `EVENT` command).
*/
suspend fun publish(event: Event) {
val session = server.connect { /* ignore OK echo */ }
try {
session.receive(OptimizedJsonMapper.toJson(EventCmd(event)))
} finally {
session.close()
}
}
override fun close() = server.close()
}
@@ -0,0 +1,101 @@
/*
* 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.geode
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.inprocess.InProcessWebSocket
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder
import java.util.concurrent.ConcurrentHashMap
/**
* Registry of [Relay] instances keyed by relay URL. Implements
* [WebsocketBuilder] so it can be plugged into
* [com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient] in place of
* `BasicOkHttpWebSocket.Builder` to redirect every outbound connection to an
* in-memory relay.
*
* Usage:
* ```
* val hub = RelayHub()
* val relay = hub.getOrCreate("ws://test.relay/")
* runBlocking { relay.preload(listOf(event1, event2)) }
* val client = NostrClient(hub, scope)
* ```
*
* Unknown URLs auto-create an empty relay so a single hub can transparently
* back any number of test endpoints.
*/
class RelayHub(
private val defaultPolicy: () -> IRelayPolicy = { EmptyPolicy },
) : WebsocketBuilder,
AutoCloseable {
private val relays = ConcurrentHashMap<NormalizedRelayUrl, Relay>()
@Volatile
private var closed = false
fun getOrCreate(url: NormalizedRelayUrl): Relay {
check(!closed) { "RelayHub has been closed" }
return relays.getOrPut(url) {
Relay(url = url, policyBuilder = defaultPolicy)
}
}
fun getOrCreate(url: String): Relay = getOrCreate(RelayUrlNormalizer.normalize(url))
fun get(url: NormalizedRelayUrl): Relay? = relays[url]
fun urls(): Set<NormalizedRelayUrl> = relays.keys.toSet()
override fun build(
url: NormalizedRelayUrl,
out: WebSocketListener,
): WebSocket = InProcessWebSocket(getOrCreate(url).server, out)
/**
* Idempotent. Sets the closed flag first so concurrent
* `getOrCreate` calls fail-fast — otherwise a relay created
* between iteration and clear would leak (its store would never
* be closed).
*/
override fun close() {
closed = true
relays.values.forEach { runCatching { it.close() } }
relays.clear()
}
companion object {
/**
* Default URL for tests that only need one relay. The URL itself
* has no semantic meaning — it's just a stable key into the hub
* — but it normalises through [RelayUrlNormalizer] (loopback) so
* the production [com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer]
* accepts it. Prefer this over typing `"ws://127.0.0.1:7770/"`
* everywhere.
*/
val DEFAULT_URL: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/")
}
}
@@ -0,0 +1,86 @@
/*
* 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.geode
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import java.io.File
/**
* Relay-side handle for the NIP-11 information document. Wraps the
* client-side [Nip11RelayInformation] model and provides loaders for
* config files plus a default doc that advertises the NIPs this relay
* actually implements.
*/
data class RelayInfo(
val document: Nip11RelayInformation,
) {
/** Pre-rendered JSON, ready to write into the HTTP response body. */
val json: String by lazy { JsonMapper.toJson(document) }
companion object {
const val NAME = "geode"
const val DESCRIPTION = "Embedded Nostr relay from the Amethyst quartz library."
const val SOFTWARE = "https://github.com/vitorpamplona/amethyst/tree/main/geode"
const val VERSION = "1.08.0"
/**
* NIPs this relay implements out of the box. Single source of
* truth — both [default] and [com.vitorpamplona.geode.config.RelayConfig.resolveInfo]
* consult this list. Add a NIP here when its handler is wired
* into [com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession]
* (or in this module's policy stack).
*
* Currently:
* - 1 NIP-01 basic
* - 9 NIP-09 deletion (DeletionRequestModule)
* - 11 NIP-11 this doc
* - 40 NIP-40 expiration (ExpirationModule)
* - 42 NIP-42 AUTH (when policy enables)
* - 45 NIP-45 COUNT
* - 50 NIP-50 search (SQLite FTS)
* - 62 NIP-62 right to vanish
* - 77 NIP-77 negentropy reconciliation
* - 86 NIP-86 relay management API (when admin pubkeys configured)
*/
val SUPPORTED_NIPS: List<String> =
listOf("1", "9", "11", "40", "42", "45", "50", "62", "77", "86")
/** Pre-built default for `Relay(url = ...)` — advertises the supported NIPs. */
fun default(url: NormalizedRelayUrl): RelayInfo =
RelayInfo(
Nip11RelayInformation(
name = NAME,
description = DESCRIPTION,
software = SOFTWARE,
version = VERSION,
supported_nips = SUPPORTED_NIPS,
),
)
/** Loads a NIP-11 doc from a JSON file (e.g. a relay operator's config). */
fun fromFile(file: File): RelayInfo = RelayInfo(Nip11RelayInformation.fromJson(file.readText()))
/** Parses a NIP-11 doc from a raw JSON string. */
fun fromJson(json: String): RelayInfo = RelayInfo(Nip11RelayInformation.fromJson(json))
}
}
@@ -0,0 +1,190 @@
/*
* 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.geode.config
import cc.ekblad.toml.decode
import cc.ekblad.toml.tomlMapper
import com.vitorpamplona.geode.RelayInfo
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import java.io.File
/**
* Operator-facing configuration. Section layout matches nostr-rs-relay's
* `config.toml` so existing configs can be ported with little churn.
*
* Every section is optional; values not set fall back to sensible
* defaults (or, for fields also exposed on the CLI, the CLI value wins).
*/
data class RelayConfig(
val info: InfoSection = InfoSection(),
val network: NetworkSection = NetworkSection(),
val database: DatabaseSection = DatabaseSection(),
val options: OptionsSection = OptionsSection(),
val limits: LimitsSection = LimitsSection(),
val authorization: AuthorizationSection = AuthorizationSection(),
val admin: AdminSection = AdminSection(),
) {
/**
* Maps the `[info]` section into a [RelayInfo] used by the NIP-11
* endpoint. `relay_url` and CLI overrides take precedence.
*/
fun resolveInfo(advertisedUrl: NormalizedRelayUrl): RelayInfo =
RelayInfo(
Nip11RelayInformation(
name = info.name ?: RelayInfo.NAME,
description = info.description ?: RelayInfo.DESCRIPTION,
pubkey = info.pubkey,
contact = info.contact,
icon = info.icon,
software = info.software ?: RelayInfo.SOFTWARE,
version = info.version ?: RelayInfo.VERSION,
supported_nips =
info.supported_nips?.map(Int::toString)
?: RelayInfo.SUPPORTED_NIPS,
privacy_policy = info.privacy_policy,
terms_of_service = info.terms_of_service,
relay_countries = info.relay_countries,
language_tags = info.language_tags,
tags = info.tags,
),
).also {
// Touch [advertisedUrl] so the parameter isn't unused — we keep
// it in the signature because future fields (e.g. self-pubkey
// selection, fee URLs) will want it.
advertisedUrl.url
}
data class InfoSection(
val relay_url: String? = null,
val name: String? = null,
val description: String? = null,
val pubkey: String? = null,
val contact: String? = null,
val icon: String? = null,
val software: String? = null,
val version: String? = null,
/** NIP numbers as ints (e.g. `[1, 9, 11]`). Stringified at render time. */
val supported_nips: List<Int>? = null,
val privacy_policy: String? = null,
val terms_of_service: String? = null,
val relay_countries: List<String>? = null,
val language_tags: List<String>? = null,
val tags: List<String>? = null,
)
data class NetworkSection(
val host: String = "0.0.0.0",
val port: Int = 7447,
val path: String = "/",
)
data class DatabaseSection(
/** True keeps an in-memory SQLite db (default — events vanish on restart). */
val in_memory: Boolean = true,
/** Filesystem path for a persistent SQLite db. Ignored when [in_memory] is true. */
val file: String? = null,
)
data class OptionsSection(
/** Reject events whose `created_at` is more than this many seconds in the future. */
val reject_future_seconds: Int? = null,
/** Require NIP-42 AUTH for REQ/EVENT/COUNT. */
val require_auth: Boolean = false,
/**
* Drop events whose Schnorr signature does not verify. **Defaults
* to `true`**: any relay accepting traffic from real clients
* should verify signatures, and verifying-by-default closes the
* footgun of forgetting the flag. Set explicitly to `false` only
* for trusted-input scenarios (test fixtures, mirror replays).
*/
val verify_signatures: Boolean = true,
)
data class LimitsSection(
val max_ws_message_bytes: Int? = null,
val max_ws_frame_bytes: Int? = null,
)
data class AuthorizationSection(
val pubkey_whitelist: List<String> = emptyList(),
val pubkey_blacklist: List<String> = emptyList(),
val kind_whitelist: List<Int> = emptyList(),
val kind_blacklist: List<Int> = emptyList(),
)
/**
* NIP-86 relay management API. When [pubkeys] is non-empty,
* `LocalRelayServer` exposes a POST endpoint at the relay path
* that accepts JSON-RPC admin requests authenticated via NIP-98
* HTTP-Auth. Only requests signed by one of these pubkeys are
* dispatched.
*
* [public_url] is the canonical URL the relay is reachable at,
* e.g. `https://relay.example.com/`. NIP-98's URL binding compares
* the signed `u` tag against this — without it, an attacker can
* spoof the `Host` header to bind their signature to any URL.
* Required when running behind TLS termination or a reverse proxy.
*/
data class AdminSection(
val pubkeys: List<String> = emptyList(),
val public_url: String? = null,
/**
* Path for the JSON snapshot that persists NIP-86 admin state
* (ban lists + the live NIP-11 doc) across restarts. When
* unset, admin state is in-memory only.
*
* Convention: place next to the SQLite event-store file —
* e.g. `[database].file = "/var/lib/geode/events.db"`
* pairs with `[admin].state_file = "/var/lib/geode/events.db.admin.json"`.
*/
val state_file: String? = null,
)
companion object {
private val mapper = tomlMapper { }
/** Parse a TOML string. */
fun fromToml(toml: String): RelayConfig = mapper.decode<RelayConfig>(toml)
/** Load a TOML config file. */
fun fromFile(file: File): RelayConfig = mapper.decode<RelayConfig>(file.toPath())
/**
* Returns the URL the relay advertises in NIP-11 and NIP-42
* challenges. Picks (in order):
* 1. `info.relay_url` from the config
* 2. The `network` section's host/port/path (with 0.0.0.0 → 127.0.0.1)
* 3. The CLI override (handled in `Main.kt`).
*/
fun advertisedUrl(config: RelayConfig): NormalizedRelayUrl =
(
config.info.relay_url
?: defaultUrl(config.network)
).normalizeRelayUrl()
private fun defaultUrl(net: NetworkSection): String {
val host = if (net.host == "0.0.0.0") "127.0.0.1" else net.host
return "ws://$host:${net.port}${net.path}"
}
}
}
@@ -0,0 +1,98 @@
/*
* 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.geode.persistence
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
/**
* On-disk snapshot of the relay's *operator-mutable* state — the
* NIP-11 info doc (so `changerelayname/description/icon` survive a
* restart) and the NIP-86 ban / allow / kind lists.
*
* One JSON file per relay. Lives next to the SQLite event store by
* convention, but the path is configurable independently. Atomic
* write via temp + atomic rename so a crash mid-save can never leave
* the file half-written.
*
* The schema below intentionally mirrors NIP-86 list responses
* (`pubkey + reason`, `id + reason`) so a future operator-tools CLI
* can read these straight from disk without translation.
*/
class RelayStateStore(
val file: File,
) {
/** Load the snapshot from disk, or `null` if the file does not yet exist. */
@Synchronized
fun load(): RelayPersistedState? {
if (!file.exists()) return null
return try {
json.decodeFromString(RelayPersistedState.serializer(), file.readText())
} catch (e: Exception) {
// Corrupt file — log to stderr and refuse to overwrite. The
// operator chooses whether to fix or delete; we don't blow
// away their state silently.
System.err.println("warning: failed to read relay state file ${file.absolutePath}: ${e.message}")
null
}
}
/** Atomically write the snapshot. */
@Synchronized
fun save(state: RelayPersistedState) {
file.parentFile?.let { if (!it.exists()) it.mkdirs() }
val tmp = File(file.parentFile ?: file.absoluteFile.parentFile, "${file.name}.tmp")
tmp.writeText(json.encodeToString(RelayPersistedState.serializer(), state))
Files.move(
tmp.toPath(),
file.toPath(),
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE,
)
}
private val json =
Json {
prettyPrint = true
encodeDefaults = false
ignoreUnknownKeys = true
}
}
@Serializable
data class RelayPersistedState(
val info: Nip11RelayInformation? = null,
val bannedPubkeys: List<BannedEntry> = emptyList(),
val allowedPubkeys: List<BannedEntry> = emptyList(),
val bannedEvents: List<BannedEntry> = emptyList(),
val allowedKinds: List<Int> = emptyList(),
val disallowedKinds: List<Int> = emptyList(),
)
@Serializable
data class BannedEntry(
val key: String,
val reason: String? = null,
)
@@ -0,0 +1,181 @@
/*
* 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.geode.server
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Response
import com.vitorpamplona.quartz.nip86RelayManagement.server.Nip86Server
import com.vitorpamplona.quartz.nip98HttpAuth.Nip98AuthVerifier
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.ApplicationCall
import io.ktor.server.request.header
import io.ktor.server.request.receiveChannel
import io.ktor.server.response.respondText
import io.ktor.utils.io.readAvailable
/**
* NIP-86 admin POST handler. Owns the gating order:
* 1. 403 if no admin pubkey list is configured (endpoint disabled).
* 2. 413 if body exceeds [maxBodyBytes] (declared or actual).
* 3. 401 if the NIP-98 Authorization header is missing/invalid.
* 4. 403 if the verified pubkey isn't in [allowList].
* 5. 400 if the body isn't a valid Nip86Request.
* 6. 200 with a Nip86Response JSON body otherwise.
*
* The [signedUrlFor] callback resolves what URL the client must have
* signed in their NIP-98 token. Operators configure the canonical
* `publicUrl`; loopback tests fall back to the request's `Host`
* header. We pass it as a callback rather than a string so the route
* doesn't need to know about Ktor request internals.
*/
internal class Nip86HttpRoute(
private val server: Nip86Server,
private val verifier: Nip98AuthVerifier,
private val allowList: Set<HexKey>,
private val maxBodyBytes: Int,
private val signedUrlFor: (ApplicationCall) -> String,
) {
suspend fun handle(call: ApplicationCall) {
if (allowList.isEmpty()) {
call.respondText(
"NIP-86 management API is not enabled on this relay.",
ContentType.Text.Plain,
HttpStatusCode.Forbidden,
)
return
}
val body = readBoundedBody(call) ?: return
val pubkey = verifyAuth(call, body) ?: return
if (pubkey.lowercase() !in allowList) {
call.respondText(
"pubkey is not on the admin list",
ContentType.Text.Plain,
HttpStatusCode.Forbidden,
)
return
}
val req =
try {
JsonMapper.fromJson<Nip86Request>(body.decodeToString())
} catch (e: Exception) {
call.respondText(
"invalid Nip86Request: ${e.message ?: e::class.simpleName}",
ContentType.Text.Plain,
HttpStatusCode.BadRequest,
)
return
}
val response: Nip86Response = server.dispatch(req)
audit(pubkey, req, response)
call.respondText(
JsonMapper.toJson(response),
ContentType.parse("application/nostr+json+rpc"),
HttpStatusCode.OK,
)
}
private suspend fun readBoundedBody(call: ApplicationCall): ByteArray? {
val declared = call.request.headers[HttpHeaders.ContentLength]?.toLongOrNull()
if (declared != null && declared > maxBodyBytes) {
call.respondText(
"request body exceeds $maxBodyBytes-byte cap",
ContentType.Text.Plain,
HttpStatusCode.PayloadTooLarge,
)
return null
}
val ch = call.receiveChannel()
val buf = ByteArray(maxBodyBytes + 1)
var pos = 0
while (pos <= maxBodyBytes) {
val read = ch.readAvailable(buf, pos, buf.size - pos)
if (read <= 0) break
pos += read
}
if (pos > maxBodyBytes) {
call.respondText(
"request body exceeds $maxBodyBytes-byte cap",
ContentType.Text.Plain,
HttpStatusCode.PayloadTooLarge,
)
return null
}
return buf.copyOfRange(0, pos)
}
private suspend fun verifyAuth(
call: ApplicationCall,
body: ByteArray,
): HexKey? {
val header = call.request.header(HttpHeaders.Authorization)
val verification = verifier.verify(header, method = "POST", url = signedUrlFor(call), body = body)
return when (verification) {
is Nip98AuthVerifier.Result.Verified -> {
verification.pubkey
}
Nip98AuthVerifier.Result.Missing -> {
call.response.headers.append(HttpHeaders.WWWAuthenticate, Nip98AuthVerifier.SCHEME.trim())
call.respondText(
"missing Authorization header (NIP-98)",
ContentType.Text.Plain,
HttpStatusCode.Unauthorized,
)
null
}
is Nip98AuthVerifier.Result.Malformed -> {
call.respondText(
"invalid NIP-98 Authorization: ${verification.reason}",
ContentType.Text.Plain,
HttpStatusCode.Unauthorized,
)
null
}
}
}
/**
* Audit log: structured single line so an operator can grep
* "nip86" / pubkey / method without a logging framework
* dependency. Best-effort — a missing log line shouldn't fail
* the response.
*/
private fun audit(
pubkey: HexKey,
req: Nip86Request,
response: Nip86Response,
) {
runCatching {
System.err.println(
"nip86 audit pubkey=$pubkey method=${req.method} ok=${response.error == null}" +
(response.error?.let { " error=$it" } ?: ""),
)
}
}
}
@@ -0,0 +1,114 @@
/*
* 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.geode.server
import com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer
import com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession
import io.ktor.server.websocket.DefaultWebSocketServerSession
import io.ktor.websocket.Frame
import io.ktor.websocket.readText
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.ClosedSendChannelException
import kotlinx.coroutines.channels.consumeEach
import kotlinx.coroutines.launch
/**
* Per-WebSocket pump that owns the bounded outbound queue and the
* writer coroutine. Pulled out of `LocalRelayServer` so that file
* stays focused on Ktor wiring; the slow-client / backpressure
* policy now lives next to the data structures it manages.
*
* Lifecycle:
* 1. `connect(server, registerSession)` opens a [RelaySession],
* registers it with the supplied callback, and starts the
* writer coroutine that drains [outQueue] into [outgoing].
* 2. `pump()` reads inbound frames until the socket closes.
* 3. `finally`-style teardown closes the queue, cancels the
* writer, unregisters the session, and closes it.
*
* Slow-client policy: when [outQueue] fills, [SESSION_OUTGOING_BUFFER]
* frames behind, the connection is dropped rather than silently
* losing EVENT/EOSE — silent drop would corrupt NIP-01.
*/
internal class WebSocketSessionPump(
private val ws: DefaultWebSocketServerSession,
) {
private val outQueue = Channel<String>(capacity = SESSION_OUTGOING_BUFFER)
private var droppedForBackpressure = false
suspend fun pump(
server: NostrServer,
registerSession: (RelaySession) -> Unit,
unregisterSession: (RelaySession) -> Unit,
) {
val writerJob =
ws.launch {
try {
for (json in outQueue) {
ws.outgoing.send(Frame.Text(json))
}
} catch (_: ClosedSendChannelException) {
// socket closed — outer handler runs normal teardown.
}
}
val session =
server.connect { json ->
val res = outQueue.trySend(json)
if (!res.isSuccess && !res.isClosed) {
// Buffer is full → slow client. Mark + close the
// queue; the writer drains, then the outer handler
// closes the WS session.
droppedForBackpressure = true
outQueue.close()
}
}
registerSession(session)
try {
ws.incoming.consumeEach { frame ->
if (droppedForBackpressure) return@consumeEach
if (frame is Frame.Text) {
session.receive(frame.readText())
}
}
} finally {
outQueue.close()
writerJob.cancel()
unregisterSession(session)
session.close()
}
}
companion object {
/**
* Per-session outbound buffer size. When a slow client falls
* this many frames behind, we close their connection rather
* than silently dropping further frames (which would corrupt
* NIP-01 by missing EVENT/EOSE messages).
*
* Sized to hold fan-out for a connection holding several
* thousand subscriptions when one event matches all of them
* — the realistic upper bound for a relay client. At ~250B
* per frame this caps per-session memory at ~2 MiB before
* we drop the connection.
*/
const val SESSION_OUTGOING_BUFFER: Int = 8192
}
}
@@ -0,0 +1,224 @@
/*
* 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.geode
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.OkHttpClient
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
/**
* Tests [LocalRelayServer.stop] honours the graceful-shutdown contract:
* 1. Active clients receive a `NOTICE` warning of imminent shutdown.
* 2. The active session counter accurately tracks open WS sessions.
* 3. After `stop()` returns, no sessions remain registered.
*/
class GracefulShutdownTest {
private lateinit var relay: Relay
private lateinit var server: LocalRelayServer
private lateinit var scope: CoroutineScope
private lateinit var client: NostrClient
private val httpClient = OkHttpClient.Builder().build()
@BeforeTest
fun setup() {
val placeholder = "ws://127.0.0.1:7771/".normalizeRelayUrl()
relay = Relay(url = placeholder)
server = LocalRelayServer(relay, host = "127.0.0.1", port = 0).start()
scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val builder = BasicOkHttpWebSocket.Builder { _ -> httpClient }
client = NostrClient(builder, scope)
}
@AfterTest
fun teardown() {
client.disconnect()
scope.cancel()
// server may already be stopped by the test; calling stop()
// again is a no-op.
server.stop(gracePeriodMillis = 200, timeoutMillis = 500)
relay.close()
}
@Test
fun activeSessionCountTracksConnectAndDisconnect() =
runBlocking {
assertEquals(0, server.activeSessionCount, "no clients yet")
// Open a connection by subscribing — wait for EOSE so we
// know the WebSocket handshake completed and the relay
// session has registered.
val gotEose = Channel<Unit>(UNLIMITED)
val relayUrl = server.url.normalizeRelayUrl()
client.subscribe(
"track-1",
mapOf(relayUrl to listOf(Filter(kinds = listOf(1)))),
object : SubscriptionListener {
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
gotEose.trySend(Unit)
}
},
)
withTimeout(5000) { gotEose.receive() }
assertEquals(1, server.activeSessionCount, "one connected session")
client.unsubscribe("track-1")
client.disconnect()
// Disconnect happens asynchronously on the relay side; allow
// a short window for the handler's `finally` block to run.
withTimeoutOrNull(2000) {
while (server.activeSessionCount > 0) kotlinx.coroutines.delay(10)
}
assertEquals(0, server.activeSessionCount, "session must be removed after disconnect")
}
@Test
fun stopSendsShutdownNoticeToActiveClients() =
runBlocking {
val noticeChannel = Channel<NoticeMessage>(UNLIMITED)
val gotEose = Channel<Unit>(UNLIMITED)
val listener =
object : RelayConnectionListener {
override fun onIncomingMessage(
relay: IRelayClient,
msgStr: String,
msg: Message,
) {
if (msg is NoticeMessage) noticeChannel.trySend(msg)
}
}
client.addConnectionListener(listener)
val relayUrl = server.url.normalizeRelayUrl()
client.subscribe(
"notice-watch",
mapOf(relayUrl to listOf(Filter(kinds = listOf(1)))),
object : SubscriptionListener {
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
gotEose.trySend(Unit)
}
},
)
withTimeout(5000) { gotEose.receive() }
assertEquals(1, server.activeSessionCount)
// Trigger graceful shutdown.
server.stop(gracePeriodMillis = 1_000, timeoutMillis = 2_000)
val notice = withTimeout(5000) { noticeChannel.receive() }
assertNotNull(notice)
assertTrue(
notice.message.startsWith("closing:"),
"expected NOTICE to start with 'closing:', got '${notice.message}'",
)
}
@Test
fun stopIsIdempotent() {
// First call shuts the engine down.
server.stop(gracePeriodMillis = 100, timeoutMillis = 500)
// Second call must be a safe no-op (no exception).
server.stop(gracePeriodMillis = 100, timeoutMillis = 500)
}
/**
* Sanity check on the grace window: a bare-bones ws client that
* connects and never sends anything should *receive* the shutdown
* NOTICE before the server fully closes the socket. Uses Ktor's
* client-agnostic OkHttp transport directly so we can observe the
* raw frames.
*/
@Test
fun rawWsClientObservesNoticeBeforeServerCloses() =
runBlocking {
val httpUrl =
server.url
.replace("ws://", "http://")
val request =
okhttp3.Request
.Builder()
.url(httpUrl)
.build()
val frames = Channel<String>(UNLIMITED)
val socket =
httpClient.newWebSocket(
request,
object : okhttp3.WebSocketListener() {
override fun onMessage(
ws: okhttp3.WebSocket,
text: String,
) {
frames.trySend(text)
}
},
)
try {
// Wait until the relay sees the connection.
withTimeoutOrNull(2000) {
while (server.activeSessionCount == 0) kotlinx.coroutines.delay(10)
}
assertEquals(1, server.activeSessionCount)
server.stop(gracePeriodMillis = 1_000, timeoutMillis = 2_000)
val text = withTimeout(3000) { frames.receive() }
assertTrue(
text.contains("\"NOTICE\"") && text.contains("closing"),
"expected a NOTICE frame, got: $text",
)
} finally {
socket.cancel()
}
}
}
@@ -0,0 +1,411 @@
/*
* 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.geode
import com.vitorpamplona.geode.fixtures.SyntheticEvents
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.count
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
import okhttp3.Request
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
/**
* End-to-end tests that drive a real `ws://` connection between the
* production [NostrClient] (over OkHttp) and the [LocalRelayServer]
* (Ktor + CIO). These prove the relay implements:
*
* - NIP-01 wire protocol (REQ/EVENT/EOSE) over real WebSockets
* - NIP-11 relay info doc on HTTP GET with `Accept: application/nostr+json`
* - NIP-42 AUTH (when [FullAuthPolicy] is enabled, REQ is rejected
* until the client authenticates)
* - NIP-45 COUNT
* - NIP-50 search via the SQLite FTS index
*
* Tests use port 0 for autobind to avoid conflicts when multiple suites
* run in parallel.
*/
class LocalRelayServerTest {
private lateinit var relay: Relay
private lateinit var server: LocalRelayServer
private lateinit var scope: CoroutineScope
private lateinit var client: NostrClient
private val httpClient = OkHttpClient.Builder().build()
@BeforeTest
fun setup() {
// Bind to 127.0.0.1:0 — the OS picks a free port. Note: the URL
// must be resolvable by the Nostr URL normalizer, which only
// accepts loopback addresses. 127.0.0.1 qualifies.
val placeholderUrl = "ws://127.0.0.1:7771/".normalizeRelayUrl()
relay = Relay(url = placeholderUrl)
server = LocalRelayServer(relay, host = "127.0.0.1", port = 0).start()
scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val builder = BasicOkHttpWebSocket.Builder { _ -> httpClient }
client = NostrClient(builder, scope)
}
@AfterTest
fun teardown() {
client.disconnect()
scope.cancel()
server.stop()
relay.close()
}
@Test
fun nip01_realWebSocketRoundtrip() =
runBlocking {
val pubkey = SyntheticEvents.hexId(1)
relay.preload(
SyntheticEvents.fakeEvent(
idSeed = 42,
kind = MetadataEvent.KIND,
pubKey = pubkey,
content = """{"name":"vitor"}""",
),
)
val event =
client.fetchFirst(
relay = server.url,
filter = Filter(kinds = listOf(MetadataEvent.KIND), authors = listOf(pubkey)),
)
assertNotNull(event)
assertEquals(MetadataEvent.KIND, event.kind)
assertEquals(pubkey, event.pubKey)
}
@Test
fun nip11_returnsInfoDocOnHttpGetWithNostrAcceptHeader() {
val httpUrl = server.url.replace("ws://", "http://")
val response =
httpClient
.newCall(
Request
.Builder()
.url(httpUrl)
.header("Accept", "application/nostr+json")
.build(),
).execute()
response.use {
assertEquals(200, it.code)
val body = it.body.string()
val info = Nip11RelayInformation.fromJson(body)
assertEquals("geode", info.name)
assertTrue(info.supported_nips!!.contains("11"), "NIP-11 must be advertised")
assertTrue(info.supported_nips!!.contains("1"), "NIP-01 must be advertised")
}
}
@Test
fun nip45_countOverRealWebSocket() =
runBlocking {
// Each event needs a unique pubkey so kind-0 (replaceable)
// doesn't collapse them all to one row.
relay.preload(
(1..7).map {
SyntheticEvents.fakeEvent(
idSeed = it,
kind = MetadataEvent.KIND,
pubKey = SyntheticEvents.hexId(1000 + it),
)
},
)
val result =
client.count(
relay = server.url.normalizeRelayUrl(),
filter = Filter(kinds = listOf(MetadataEvent.KIND)),
)
assertEquals(7, result?.count)
}
@Test
fun nip50_searchHitsFtsIndex() =
runBlocking {
val signer =
com.vitorpamplona.quartz.nip01Core.signers
.NostrSignerSync(KeyPair())
relay.preload(
signer.sign(TextNoteEvent.build("How do I write a kotlin coroutine?")),
signer.sign(TextNoteEvent.build("My favorite recipe for pancakes")),
signer.sign(TextNoteEvent.build("Another note about kotlin")),
)
val matches =
client
.count(
relay = server.url.normalizeRelayUrl(),
filter = Filter(search = "kotlin"),
)?.count
// Two of the three notes mention "kotlin".
assertEquals(2, matches)
}
@Test
fun nip42_authRejectsReqUntilClientAuthenticates() =
runBlocking {
// Spin up a second relay that requires AUTH. Bind on a
// separate port so it doesn't collide with [setup]'s server.
val authUrl = "ws://127.0.0.1:7772/".normalizeRelayUrl()
val authRelay = Relay(authUrl, policyBuilder = { FullAuthPolicy(authUrl) })
val authServer = LocalRelayServer(authRelay, host = "127.0.0.1", port = 0).start()
try {
val signer =
com.vitorpamplona.quartz.nip01Core.signers
.NostrSignerSync(KeyPair())
authRelay.preload(signer.sign(TextNoteEvent.build("hello")))
// Without AUTH, publishAndConfirm should fail (relay
// returns OK false / "auth-required").
val noAuthEvent = signer.sign(TextNoteEvent.build("denied"))
val ok =
client.publishAndConfirm(
event = noAuthEvent,
relayList = setOf(authServer.url.normalizeRelayUrl()),
)
assertEquals(false, ok, "FullAuthPolicy must reject EVENT before AUTH")
} finally {
authServer.stop()
authRelay.close()
}
}
/**
* Successful NIP-42 AUTH must unlock REQ/EVENT/COUNT. We can't bind
* the server on a known port AND configure the policy with that URL
* unless we discover the port first — so reserve a free TCP port
* before constructing the relay, then bind to that exact port so
* the policy's `relay` field matches what `RelayAuthenticator`
* sends in the AUTH event's `relay` tag.
*/
@Test
fun nip42_successfulAuthUnlocksPublishing() =
runBlocking {
val freePort =
java.net.ServerSocket(0).use { it.localPort }
val authUrl = "ws://127.0.0.1:$freePort/".normalizeRelayUrl()
val authRelay = Relay(authUrl, policyBuilder = { FullAuthPolicy(authUrl) })
val authServer = LocalRelayServer(authRelay, host = "127.0.0.1", port = freePort).start()
try {
val signer =
com.vitorpamplona.quartz.nip01Core.signers
.NostrSignerSync(KeyPair())
// RelayAuthenticator hooks the client: when the relay
// sends the AUTH challenge, it auto-signs and replies.
val authenticator =
com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator(
client = client,
scope = scope,
) { template ->
listOf(signer.sign(template))
}
try {
// Trigger the AUTH dance: subscribe to anything,
// which the relay rejects with `auth-required:` →
// [RelayAuthenticator] catches the challenge, signs
// and sends the AUTH event, the relay's OK true
// makes the client re-sync filters, and the second
// REQ succeeds and EOSEs.
val gotEose =
kotlinx.coroutines.channels.Channel<Unit>(
kotlinx.coroutines.channels.Channel.UNLIMITED,
)
client.subscribe(
"auth-warmup",
mapOf(authUrl to listOf(Filter(kinds = listOf(1)))),
object : com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener {
override fun onEose(
relay: com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
gotEose.trySend(Unit)
}
},
)
kotlinx.coroutines.withTimeout(5000) { gotEose.receive() }
client.unsubscribe("auth-warmup")
val event = signer.sign(TextNoteEvent.build("after-auth"))
val ok =
client.publishAndConfirm(
event = event,
relayList = setOf(authUrl),
)
assertEquals(true, ok, "after AUTH succeeds, publishing must work")
} finally {
authenticator.destroy()
}
} finally {
authServer.stop()
authRelay.close()
}
}
/**
* Regression for the OkMessage wire format (the Jackson serializer
* was writing `success` as a JSON string). `publishAndConfirm`
* relies on parsing the OK response — if the relay's serialization
* regresses, this test catches it.
*/
@Test
fun nip01_okMessageRoundtripWithEmptyAndNonEmptyMessage() =
runBlocking {
val signer =
com.vitorpamplona.quartz.nip01Core.signers
.NostrSignerSync(KeyPair())
val event = signer.sign(TextNoteEvent.build("ok-roundtrip"))
val ok =
client.publishAndConfirm(
event = event,
relayList = setOf(server.url.normalizeRelayUrl()),
)
assertEquals(true, ok, "successful insert must round-trip OK true on the wire")
// Duplicate insert returns OK false; this also exercises the
// "non-empty message" branch of the serializer.
val ok2 =
client.publishAndConfirm(
event = event,
relayList = setOf(server.url.normalizeRelayUrl()),
)
assertEquals(false, ok2, "duplicate insert must round-trip OK false")
}
/**
* A custom config's `[info]` section flows through to the NIP-11
* doc returned on the HTTP endpoint.
*/
@Test
fun nip11_servesConfigDrivenInfoDoc() {
val freePort =
java.net.ServerSocket(0).use { it.localPort }
val customUrl = "ws://127.0.0.1:$freePort/".normalizeRelayUrl()
val customInfo =
RelayInfo(
Nip11RelayInformation(
name = "custom-relay-name",
contact = "ops@example.com",
description = "Custom from config",
supported_nips = listOf("1", "11", "42"),
),
)
val customRelay = Relay(customUrl, info = customInfo)
val customServer =
LocalRelayServer(customRelay, host = "127.0.0.1", port = freePort).start()
try {
val httpUrl = customServer.url.replace("ws://", "http://")
val response =
httpClient
.newCall(
Request
.Builder()
.url(httpUrl)
.header("Accept", "application/nostr+json")
.build(),
).execute()
response.use {
assertEquals(200, it.code)
val info = Nip11RelayInformation.fromJson(it.body.string())
assertEquals("custom-relay-name", info.name)
assertEquals("ops@example.com", info.contact)
assertEquals(listOf("1", "11", "42"), info.supported_nips)
}
} finally {
customServer.stop()
customRelay.close()
}
}
@Test
fun nip01_closeStopsLiveSubscription() =
runBlocking {
val ch =
kotlinx.coroutines.channels.Channel<com.vitorpamplona.quartz.nip01Core.core.Event>(
kotlinx.coroutines.channels.Channel.UNLIMITED,
)
val gotEose =
kotlinx.coroutines.channels.Channel<Unit>(
kotlinx.coroutines.channels.Channel.UNLIMITED,
)
client.subscribe(
"close-test",
mapOf(server.url.normalizeRelayUrl() to listOf(Filter(kinds = listOf(1)))),
object : com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener {
override fun onEvent(
event: com.vitorpamplona.quartz.nip01Core.core.Event,
isLive: Boolean,
relay: com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
ch.trySend(event)
}
override fun onEose(
relay: com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
gotEose.trySend(Unit)
}
},
)
kotlinx.coroutines.withTimeout(5000) { gotEose.receive() }
// CLOSE the subscription, then publish a matching event over
// the wire. The unsubscribed client must NOT receive it.
client.unsubscribe("close-test")
val signer =
com.vitorpamplona.quartz.nip01Core.signers
.NostrSignerSync(KeyPair())
val late = signer.sign(TextNoteEvent.build("post-close"))
relay.publish(late)
val seen = kotlinx.coroutines.withTimeoutOrNull(500) { ch.receive() }
assertEquals(null, seen, "events arriving after CLOSE must not reach the unsubscribed client")
}
}
@@ -0,0 +1,554 @@
/*
* 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.geode
import com.vitorpamplona.geode.fixtures.SyntheticEvents
import com.vitorpamplona.geode.testing.RelayClientTest
import com.vitorpamplona.geode.testing.collectUntilEose
import com.vitorpamplona.geode.testing.collectUntilEoseMulti
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
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.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Compatibility suite that drives the in-process relay through the same
* `NostrClient` + WebSocket abstraction production code uses. These tests
* are how we validate that:
*
* 1. The relay implements NIP-01 correctly (REQ/EVENT/EOSE/CLOSE/COUNT,
* replaceable + parameterized-replaceable, filter matching, multi-relay
* pools).
* 2. The bridge between [com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket]
* and [com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer] preserves
* wire ordering and lifecycle.
*
* The same suite should be runnable, conceptually, against any
* spec-compliant relay (nostr-rs-relay, strfry, khatru, …) — only the
* `socketBuilder` and the relay URL would change.
*/
class Nip01ComplianceTest : RelayClientTest() {
private suspend fun preload(vararg events: Event) {
defaultRelay.preload(*events)
}
private fun fakeEvent(
idSeed: Int,
kind: Int = 1,
pubKey: String = SyntheticEvents.hexId(0),
createdAt: Long = idSeed.toLong(),
tags: Array<Array<String>> = emptyArray(),
content: String = "",
) = SyntheticEvents.fakeEvent(idSeed, kind, pubKey, createdAt, content, tags)
// -- Subscriptions -------------------------------------------------------
/** REQ returns events matching kinds, then EOSE, in order. */
@Test
fun reqByKindReturnsMatchesThenEose() =
runBlocking {
preload(
fakeEvent(1, kind = 1),
fakeEvent(2, kind = 4),
fakeEvent(3, kind = 1),
)
val (events, eose) = client.collectUntilEose(defaultRelayUrl, Filter(kinds = listOf(1)))
assertEquals(2, events.size)
assertEquals(setOf(SyntheticEvents.hexId(1), SyntheticEvents.hexId(3)), events.map { it.id }.toSet())
assertTrue(eose, "EOSE should fire after stored events")
}
/** REQ honours `limit` — newest first, capped to limit. */
@Test
fun reqRespectsLimitAndOrdersNewestFirst() =
runBlocking {
preload(
fakeEvent(1, kind = 1, createdAt = 100),
fakeEvent(2, kind = 1, createdAt = 200),
fakeEvent(3, kind = 1, createdAt = 300),
fakeEvent(4, kind = 1, createdAt = 400),
)
val (events, _) = client.collectUntilEose(defaultRelayUrl, Filter(kinds = listOf(1), limit = 2))
assertEquals(2, events.size)
assertEquals(400L, events[0].createdAt)
assertEquals(300L, events[1].createdAt)
}
/** REQ with `authors` filters by pubkey. */
@Test
fun reqFiltersByAuthors() =
runBlocking {
val alice = SyntheticEvents.hexId(101)
val bob = SyntheticEvents.hexId(102)
preload(
fakeEvent(1, kind = 1, pubKey = alice),
fakeEvent(2, kind = 1, pubKey = bob),
fakeEvent(3, kind = 1, pubKey = alice),
)
val (events, _) = client.collectUntilEose(defaultRelayUrl, Filter(authors = listOf(alice)))
assertEquals(2, events.size)
assertTrue(events.all { it.pubKey == alice })
}
/** REQ with `ids` returns only the requested events. */
@Test
fun reqFiltersByIds() =
runBlocking {
preload(fakeEvent(1), fakeEvent(2), fakeEvent(3))
val (events, _) = client.collectUntilEose(defaultRelayUrl, Filter(ids = listOf(SyntheticEvents.hexId(2))))
assertEquals(1, events.size)
assertEquals(SyntheticEvents.hexId(2), events[0].id)
}
/** REQ with `since`/`until` filters on createdAt. */
@Test
fun reqFiltersBySinceAndUntil() =
runBlocking {
preload(
fakeEvent(1, createdAt = 100),
fakeEvent(2, createdAt = 200),
fakeEvent(3, createdAt = 300),
fakeEvent(4, createdAt = 400),
)
val (events, _) = client.collectUntilEose(defaultRelayUrl, Filter(since = 150L, until = 350L))
assertEquals(setOf(200L, 300L), events.map { it.createdAt }.toSet())
}
/** REQ with single-letter `#e` tag filter matches events whose tag values intersect. */
@Test
fun reqFiltersByETag() =
runBlocking {
val target = SyntheticEvents.hexId(999)
preload(
fakeEvent(1, tags = arrayOf(arrayOf("e", target))),
fakeEvent(2, tags = arrayOf(arrayOf("e", SyntheticEvents.hexId(7)))),
fakeEvent(3, tags = arrayOf(arrayOf("e", target), arrayOf("p", SyntheticEvents.hexId(8)))),
)
val (events, _) = client.collectUntilEose(defaultRelayUrl, Filter(tags = mapOf("e" to listOf(target))))
assertEquals(setOf(SyntheticEvents.hexId(1), SyntheticEvents.hexId(3)), events.map { it.id }.toSet())
}
/** REQ with `#p` tag filter — same single-letter machinery as `#e`. */
@Test
fun reqFiltersByPTag() =
runBlocking {
val targetPubkey = SyntheticEvents.hexId(2222)
preload(
fakeEvent(1, tags = arrayOf(arrayOf("p", targetPubkey))),
fakeEvent(2, tags = arrayOf(arrayOf("p", SyntheticEvents.hexId(3333)))),
fakeEvent(3, tags = arrayOf(arrayOf("p", targetPubkey), arrayOf("e", SyntheticEvents.hexId(99)))),
)
val (events, _) = client.collectUntilEose(defaultRelayUrl, Filter(tags = mapOf("p" to listOf(targetPubkey))))
assertEquals(setOf(SyntheticEvents.hexId(1), SyntheticEvents.hexId(3)), events.map { it.id }.toSet())
}
/** REQ with a generic single-letter tag (e.g. `#t`) for hashtags. */
@Test
fun reqFiltersByGenericSingleLetterTag() =
runBlocking {
preload(
fakeEvent(1, tags = arrayOf(arrayOf("t", "nostr"))),
fakeEvent(2, tags = arrayOf(arrayOf("t", "bitcoin"))),
fakeEvent(3, tags = arrayOf(arrayOf("t", "nostr"), arrayOf("t", "kotlin"))),
)
val (events, _) = client.collectUntilEose(defaultRelayUrl, Filter(tags = mapOf("t" to listOf("nostr"))))
assertEquals(setOf(SyntheticEvents.hexId(1), SyntheticEvents.hexId(3)), events.map { it.id }.toSet())
}
/**
* Multi-value tag filter — matches events with tag value in the OR
* set. NIP-01 says values inside a single filter list are OR'd.
*/
@Test
fun reqTagFilterValuesAreOred() =
runBlocking {
val a = SyntheticEvents.hexId(101)
val b = SyntheticEvents.hexId(102)
preload(
fakeEvent(1, tags = arrayOf(arrayOf("e", a))),
fakeEvent(2, tags = arrayOf(arrayOf("e", b))),
fakeEvent(3, tags = arrayOf(arrayOf("e", SyntheticEvents.hexId(999)))),
)
val (events, _) = client.collectUntilEose(defaultRelayUrl, Filter(tags = mapOf("e" to listOf(a, b))))
assertEquals(setOf(SyntheticEvents.hexId(1), SyntheticEvents.hexId(2)), events.map { it.id }.toSet())
}
// -- Multi-filter REQ ----------------------------------------------------
/**
* NIP-01: filters within a single REQ are OR'd. The relay returns
* events matching ANY of the filters, deduplicated.
*/
@Test
fun reqMultipleFiltersAreOred() =
runBlocking {
preload(
fakeEvent(1, kind = 1),
fakeEvent(2, kind = 4),
fakeEvent(3, kind = 7),
)
val (events, _) =
client.collectUntilEoseMulti(
defaultRelayUrl,
listOf(
Filter(kinds = listOf(1)),
Filter(kinds = listOf(7)),
),
)
assertEquals(
setOf(SyntheticEvents.hexId(1), SyntheticEvents.hexId(3)),
events.map { it.id }.toSet(),
)
}
/**
* Two subscriptions on the same connection are independent — each
* gets its own EOSE and its own event stream.
*/
@Test
fun multipleSubscriptionsOnOneConnectionAreIndependent() =
runBlocking {
preload(
fakeEvent(1, kind = 1),
fakeEvent(2, kind = 4),
)
val ch1 = Channel<Event>(UNLIMITED)
val ch2 = Channel<Event>(UNLIMITED)
val eose1 = Channel<Unit>(UNLIMITED)
val eose2 = Channel<Unit>(UNLIMITED)
client.subscribe(
"sub-A",
mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(1)))),
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
ch1.trySend(event)
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
eose1.trySend(Unit)
}
},
)
client.subscribe(
"sub-B",
mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(4)))),
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
ch2.trySend(event)
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
eose2.trySend(Unit)
}
},
)
withTimeout(5000) {
eose1.receive()
eose2.receive()
}
// sub-A only saw kind=1, sub-B only saw kind=4.
val a = withTimeoutOrNull(100) { ch1.receive() }
val b = withTimeoutOrNull(100) { ch2.receive() }
assertEquals(SyntheticEvents.hexId(1), a?.id)
assertEquals(SyntheticEvents.hexId(2), b?.id)
client.unsubscribe("sub-A")
client.unsubscribe("sub-B")
}
// -- Replaceable + addressable ------------------------------------------
/** Kind 0 is replaceable by `(pubkey, kind)` — newer wins. */
@Test
fun replaceableEventsKeepNewestPerPubkey() =
runBlocking {
val pubkey = SyntheticEvents.hexId(50)
preload(
fakeEvent(1, kind = 0, pubKey = pubkey, createdAt = 100, content = "old"),
fakeEvent(2, kind = 0, pubKey = pubkey, createdAt = 200, content = "new"),
)
val (events, _) = client.collectUntilEose(defaultRelayUrl, Filter(kinds = listOf(0), authors = listOf(pubkey)))
assertEquals(1, events.size)
assertEquals("new", events[0].content)
}
/**
* Addressable events (kind 30000-39999, NIP-01 §"Kinds") are replaced
* by `(pubkey, kind, d)`. Uses a real signed [LongTextNoteEvent] because
* the SQLite store dispatches on the typed `AddressableEvent` subclass
* to extract the d-tag — synthetic plain `Event`s aren't recognised.
*/
@Test
fun parameterizedReplaceableEventsKeepNewestPerDTag() =
runBlocking {
val signer = NostrSignerSync(KeyPair())
val v1 = signer.sign(LongTextNoteEvent.build("old", "title", dTag = "list-a", createdAt = 100))
val v2 = signer.sign(LongTextNoteEvent.build("new", "title", dTag = "list-a", createdAt = 200))
val v3 = signer.sign(LongTextNoteEvent.build("list-b", "title", dTag = "list-b", createdAt = 100))
preload(v1, v2, v3)
val (events, _) =
client.collectUntilEose(
defaultRelayUrl,
Filter(kinds = listOf(LongTextNoteEvent.KIND), authors = listOf(signer.pubKey)),
)
assertEquals(2, events.size)
assertEquals(setOf("new", "list-b"), events.map { it.content }.toSet())
}
// -- Live updates --------------------------------------------------------
/** A subscription receives new matching events that arrive after EOSE. */
@Test
fun liveSubscriptionReceivesPostEoseEvents() =
runBlocking {
val ch = Channel<Event>(UNLIMITED)
val gotEose = Channel<Unit>(UNLIMITED)
client.subscribe(
"live-1",
mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(1)))),
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
ch.trySend(event)
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
gotEose.trySend(Unit)
}
},
)
withTimeout(5000) { gotEose.receive() }
// Inject an event through the wire path (not preload — that bypasses
// the live broadcast that subscriptions feed off of).
defaultRelay.publish(fakeEvent(99, kind = 1, content = "live"))
val received = withTimeout(5000) { ch.receive() }
assertEquals("live", received.content)
client.unsubscribe("live-1")
}
/** Non-matching live events are not pushed to a subscription. */
@Test
fun liveSubscriptionIgnoresNonMatchingEvents() =
runBlocking {
val ch = Channel<Event>(UNLIMITED)
val gotEose = Channel<Unit>(UNLIMITED)
client.subscribe(
"live-2",
mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(1)))),
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
ch.trySend(event)
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
gotEose.trySend(Unit)
}
},
)
withTimeout(5000) { gotEose.receive() }
defaultRelay.publish(fakeEvent(98, kind = 4, content = "off-topic"))
val seen = withTimeoutOrNull(500) { ch.receive() }
assertNull(seen, "kind 4 should not match a kind-1 subscription")
client.unsubscribe("live-2")
}
// -- Ephemeral events (NIP-01: kinds 20000-29999) ----------------------
/**
* Ephemeral events MUST be forwarded to active subscriptions whose
* filters match, even though the relay does not persist them.
*/
@Test
fun ephemeralEventForwardedToActiveSubscription() =
runBlocking {
val ch = Channel<Event>(UNLIMITED)
val gotEose = Channel<Unit>(UNLIMITED)
client.subscribe(
"eph-1",
mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(20_001)))),
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
ch.trySend(event)
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
gotEose.trySend(Unit)
}
},
)
withTimeout(5000) { gotEose.receive() }
defaultRelay.publish(fakeEvent(70, kind = 20_001, content = "ephemeral-payload"))
val received = withTimeout(5000) { ch.receive() }
assertEquals("ephemeral-payload", received.content)
assertEquals(20_001, received.kind)
client.unsubscribe("eph-1")
}
/**
* Ephemeral events MUST NOT be persisted. A REQ issued after the
* event was published returns nothing.
*/
@Test
fun ephemeralEventIsNotStoredAndDoesNotShowOnFollowupReq() =
runBlocking {
// Publish ephemeral first — no live subscriber listening.
defaultRelay.publish(fakeEvent(71, kind = 20_002, content = "vanish"))
// Late subscriber: should see EOSE with no events.
val (events, eose) = client.collectUntilEose(defaultRelayUrl, Filter(kinds = listOf(20_002)))
assertTrue(eose, "EOSE must fire for an ephemeral kind even if zero events match")
assertEquals(0, events.size, "Ephemeral events must not be persisted")
}
// -- Multi-relay --------------------------------------------------------
/** A single client can hold subscriptions against multiple relays simultaneously. */
@Test
fun multiRelayPoolReturnsContentFromEachRelay() =
runBlocking {
val relayA = RelayUrlNormalizer.normalize("ws://127.0.0.1:7771/")
val relayB = RelayUrlNormalizer.normalize("ws://127.0.0.1:7772/")
hub.getOrCreate(relayA).preload(fakeEvent(1, kind = 1, content = "from-a"))
hub.getOrCreate(relayB).preload(fakeEvent(2, kind = 1, content = "from-b"))
val received = mutableMapOf<NormalizedRelayUrl, String>()
val eosed = mutableSetOf<NormalizedRelayUrl>()
val ch = Channel<Unit>(UNLIMITED)
client.subscribe(
"multi-1",
mapOf(
relayA to listOf(Filter(kinds = listOf(1))),
relayB to listOf(Filter(kinds = listOf(1))),
),
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
received[relay] = event.content
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
eosed += relay
if (eosed.size == 2) ch.trySend(Unit)
}
},
)
withTimeout(5000) { ch.receive() }
client.unsubscribe("multi-1")
assertEquals("from-a", received[relayA])
assertEquals("from-b", received[relayB])
}
}
@@ -0,0 +1,190 @@
/*
* 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.geode
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
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.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* Verifies NIP-09 deletion request behavior end-to-end through
* `NostrClient` → `RelayHub`. The relay's
* [com.vitorpamplona.quartz.nip01Core.store.sqlite.DeletionRequestModule]
* is responsible for honouring kind-5 events:
*
* 1. Existing events targeted by id are removed from the store.
* 2. A SQL trigger blocks re-insertion of any event that matches a
* stored kind-5 deletion (so a malicious relay can't sneak the
* deleted event back in via another connection).
* 3. Cross-author deletion is silently ignored: a kind-5 event from
* pubkey X cannot delete pubkey Y's events.
*/
class Nip09DeletionTest {
private lateinit var hub: RelayHub
private lateinit var scope: CoroutineScope
private lateinit var client: NostrClient
private val relayUrl: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/")
@BeforeTest
fun setup() {
hub = RelayHub()
scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
client = NostrClient(hub, scope)
}
@AfterTest
fun teardown() {
client.disconnect()
scope.cancel()
hub.close()
}
private suspend fun query(filter: Filter): List<Event> {
val ch = kotlinx.coroutines.channels.Channel<Either>(kotlinx.coroutines.channels.Channel.UNLIMITED)
val subId = "sub-${System.nanoTime()}"
client.subscribe(
subId,
mapOf(relayUrl to listOf(filter)),
object : com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
ch.trySend(Either.Ev(event))
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
ch.trySend(Either.Eose)
}
},
)
val events = mutableListOf<Event>()
kotlinx.coroutines.withTimeout(5000) {
while (true) {
when (val msg = ch.receive()) {
is Either.Ev -> events += msg.event
Either.Eose -> return@withTimeout
}
}
}
client.unsubscribe(subId)
return events
}
private sealed interface Either {
data class Ev(
val event: Event,
) : Either
object Eose : Either
}
@Test
fun deletionRemovesTargetedEventFromStore() =
runBlocking {
val signer = NostrSignerSync(KeyPair())
val note = signer.sign(TextNoteEvent.build("delete me"))
val deletion = signer.sign(DeletionEvent.build(listOf(note), createdAt = note.createdAt + 1))
// Publish original, confirm it's stored.
assertEquals(true, client.publishAndConfirm(note, setOf(relayUrl)))
assertEquals(1, query(Filter(ids = listOf(note.id))).size)
// Publish deletion; the store removes the targeted event.
assertEquals(true, client.publishAndConfirm(deletion, setOf(relayUrl)))
assertEquals(0, query(Filter(ids = listOf(note.id))).size, "event must be gone")
}
@Test
fun deletionEventItselfIsStoredAndQueryable() =
runBlocking {
val signer = NostrSignerSync(KeyPair())
val note = signer.sign(TextNoteEvent.build("a"))
val deletion = signer.sign(DeletionEvent.build(listOf(note), createdAt = note.createdAt + 1))
client.publishAndConfirm(note, setOf(relayUrl))
client.publishAndConfirm(deletion, setOf(relayUrl))
val results = query(Filter(kinds = listOf(DeletionEvent.KIND), authors = listOf(signer.pubKey)))
assertEquals(1, results.size)
assertEquals(deletion.id, results[0].id)
}
@Test
fun reinsertingADeletedEventIsRejected() =
runBlocking {
val signer = NostrSignerSync(KeyPair())
val note = signer.sign(TextNoteEvent.build("once-upon-a-time"))
val deletion = signer.sign(DeletionEvent.build(listOf(note), createdAt = note.createdAt + 1))
client.publishAndConfirm(note, setOf(relayUrl))
client.publishAndConfirm(deletion, setOf(relayUrl))
// Trying to reinsert the same event must fail — relay returns OK false.
val ok = client.publishAndConfirm(note, setOf(relayUrl))
assertEquals(false, ok, "reinserting a deleted event must be blocked")
}
@Test
fun crossAuthorDeletionDoesNotRemoveOtherUsersEvents() =
runBlocking {
val alice = NostrSignerSync(KeyPair())
val mallory = NostrSignerSync(KeyPair())
val aliceNote = alice.sign(TextNoteEvent.build("alice-private"))
client.publishAndConfirm(aliceNote, setOf(relayUrl))
assertEquals(1, query(Filter(ids = listOf(aliceNote.id))).size)
// Mallory tries to delete Alice's event: relay accepts the
// kind-5 event itself (it's just an event), but the SQL DELETE
// is owner-scoped, so Alice's event survives.
val malloryDelete =
mallory.sign(DeletionEvent.build(listOf(aliceNote), createdAt = aliceNote.createdAt + 1))
client.publishAndConfirm(malloryDelete, setOf(relayUrl))
assertEquals(
1,
query(Filter(ids = listOf(aliceNote.id))).size,
"Mallory's deletion must NOT remove Alice's event",
)
}
}
@@ -0,0 +1,165 @@
/*
* 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.geode
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
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.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip40Expiration.expiration
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
/**
* NIP-40 expiration:
* - The relay rejects EVENTs whose `expiration` tag is in the past
* (the SQLite store's `insertEvent` raises on `event.isExpired()`).
* - Events with a future expiration are stored and queryable until
* the operator calls `deleteExpiredEvents()` (or sufficient time
* passes that `isExpired()` returns true on read).
*/
class Nip40ExpirationTest {
private lateinit var hub: RelayHub
private lateinit var scope: CoroutineScope
private lateinit var client: NostrClient
private val relayUrl: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/")
@BeforeTest
fun setup() {
hub = RelayHub()
scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
client = NostrClient(hub, scope)
}
@AfterTest
fun teardown() {
client.disconnect()
scope.cancel()
hub.close()
}
@Test
fun expiredEventOnArrivalIsRejectedWithOkFalse() =
runBlocking {
val signer = NostrSignerSync(KeyPair())
// expiration set to a past timestamp.
val past = TimeUtils.now() - 60
val event =
signer.sign(
TextNoteEvent.build("expired") {
expiration(past)
},
)
val ok = client.publishAndConfirm(event, setOf(relayUrl))
assertEquals(false, ok, "expired-on-arrival event must be rejected")
}
@Test
fun nonExpiredEventIsStoredAndRetrievable() =
runBlocking {
val signer = NostrSignerSync(KeyPair())
val future = TimeUtils.now() + 3600
val event =
signer.sign(
TextNoteEvent.build("not-yet-expired") {
expiration(future)
},
)
assertEquals(true, client.publishAndConfirm(event, setOf(relayUrl)))
val fetched =
client.fetchFirst(
relay = relayUrl,
filter = Filter(ids = listOf(event.id)),
)
assertNotNull(fetched)
assertEquals(event.id, fetched.id)
}
@Test
fun deleteExpiredEventsRemovesThemFromStore() =
runBlocking {
val signer = NostrSignerSync(KeyPair())
val now = TimeUtils.now()
// Two events: one expires in the past, one in the future.
// We set the past one to be in the past relative to NOW, but
// not so far that the original `insertEvent` rejects on
// arrival. The trick: use a createdAt slightly in the past
// and an expiration also slightly in the past, both still
// recent enough that the relay accepts the event but
// `deleteExpiredEvents()` will sweep it.
//
// Actually `insertEvent` rejects on `isExpired()` —
// [past] timestamps fail at insert. So instead we publish a
// non-expired event (long-lived) and a barely-non-expired
// one (1s out), then sleep 2s and call sweep.
val longLived =
signer.sign(TextNoteEvent.build("keep-me") { expiration(now + 3600) })
val shortLived =
signer.sign(TextNoteEvent.build("sweep-me") { expiration(now + 1) })
client.publishAndConfirm(longLived, setOf(relayUrl))
client.publishAndConfirm(shortLived, setOf(relayUrl))
// Both currently in the store.
assertNotNull(
client.fetchFirst(relay = relayUrl, filter = Filter(ids = listOf(longLived.id))),
)
assertNotNull(
client.fetchFirst(relay = relayUrl, filter = Filter(ids = listOf(shortLived.id))),
)
// Wait until shortLived is past its expiration, then sweep.
// SQLite's unixepoch() is integer seconds, so we need a full
// second's gap from the (now + 1) expiration; bump to 2.5s
// to absorb thread-scheduling jitter on busy CI runners.
kotlinx.coroutines.delay(2500)
hub.getOrCreate(relayUrl).store.deleteExpiredEvents()
// Long-lived survives.
assertNotNull(
client.fetchFirst(relay = relayUrl, filter = Filter(ids = listOf(longLived.id))),
)
// Short-lived is gone.
assertEquals(
null,
client.fetchFirst(relay = relayUrl, filter = Filter(ids = listOf(shortLived.id))),
"deleteExpiredEvents() must purge the short-lived event",
)
}
}
@@ -0,0 +1,144 @@
/*
* 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.geode
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
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.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
/**
* NIP-62 right-to-vanish:
* - A kind-62 event scoped to a relay URL cascades-deletes ALL of the
* author's earlier events on that relay.
* - After the vanish, attempts to insert OLDER events from that author
* are rejected (the SQL `reject_events_on_event_vanish` trigger).
* - Newer events from the same author (createdAt > vanish.createdAt)
* can still be published — the user is asking the relay to forget
* their past, not to ban them.
* - A vanish from author A does not affect author B's events.
*/
class Nip62VanishTest {
private lateinit var hub: RelayHub
private lateinit var scope: CoroutineScope
private lateinit var client: NostrClient
private val relayUrl: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/")
@BeforeTest
fun setup() {
hub = RelayHub()
scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
client = NostrClient(hub, scope)
}
@AfterTest
fun teardown() {
client.disconnect()
scope.cancel()
hub.close()
}
@Test
fun vanishCascadesPriorEventsFromSameAuthor() =
runBlocking {
val signer = NostrSignerSync(KeyPair())
val now = TimeUtils.now()
val a = signer.sign(TextNoteEvent.build("first", createdAt = now - 100))
val b = signer.sign(TextNoteEvent.build("second", createdAt = now - 50))
client.publishAndConfirm(a, setOf(relayUrl))
client.publishAndConfirm(b, setOf(relayUrl))
assertNotNull(client.fetchFirst(relay = relayUrl, filter = Filter(ids = listOf(a.id))))
assertNotNull(client.fetchFirst(relay = relayUrl, filter = Filter(ids = listOf(b.id))))
val vanish =
signer.sign(
RequestToVanishEvent.build(
relay = relayUrl,
reason = "GDPR cleanup",
createdAt = now,
),
)
assertEquals(true, client.publishAndConfirm(vanish, setOf(relayUrl)))
assertNull(client.fetchFirst(relay = relayUrl, filter = Filter(ids = listOf(a.id))))
assertNull(client.fetchFirst(relay = relayUrl, filter = Filter(ids = listOf(b.id))))
}
@Test
fun vanishBlocksReinsertionOfOlderEvents() =
runBlocking {
val signer = NostrSignerSync(KeyPair())
val now = TimeUtils.now()
val vanish =
signer.sign(
RequestToVanishEvent.build(relay = relayUrl, createdAt = now),
)
client.publishAndConfirm(vanish, setOf(relayUrl))
// Older event from the same author must be rejected.
val older = signer.sign(TextNoteEvent.build("comeback", createdAt = now - 100))
val ok = client.publishAndConfirm(older, setOf(relayUrl))
assertEquals(false, ok, "events older than the vanish must be rejected")
}
@Test
fun vanishDoesNotAffectOtherAuthors() =
runBlocking {
val alice = NostrSignerSync(KeyPair())
val bob = NostrSignerSync(KeyPair())
val now = TimeUtils.now()
val aliceNote = alice.sign(TextNoteEvent.build("alice", createdAt = now - 100))
val bobNote = bob.sign(TextNoteEvent.build("bob", createdAt = now - 100))
client.publishAndConfirm(aliceNote, setOf(relayUrl))
client.publishAndConfirm(bobNote, setOf(relayUrl))
val aliceVanish =
alice.sign(RequestToVanishEvent.build(relay = relayUrl, createdAt = now))
client.publishAndConfirm(aliceVanish, setOf(relayUrl))
assertNull(client.fetchFirst(relay = relayUrl, filter = Filter(ids = listOf(aliceNote.id))))
val bobStillThere =
client.fetchFirst(relay = relayUrl, filter = Filter(ids = listOf(bobNote.id)))
assertEquals(bobNote.id, bobStillThere?.id, "bob's events must survive alice's vanish")
}
}
@@ -0,0 +1,273 @@
/*
* 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.geode
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
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.WebSocketListener
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip77Negentropy.NegErrMessage
import com.vitorpamplona.quartz.nip77Negentropy.NegMsgMessage
import com.vitorpamplona.quartz.nip77Negentropy.NegentropySession
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* End-to-end NIP-77 reconciliation through `RelayHub` + the
* in-process WebSocket bridge.
*
* - The relay is preloaded with a known set of events.
* - A [NegentropySession] is initialised with a partially-overlapping
* set on the client side.
* - We drive the NEG-OPEN / NEG-MSG round trips manually until
* `processMessage` reports completion.
* - We assert that `haveIds` (events the client has that the relay
* doesn't) and `needIds` (events the relay has that the client
* doesn't) cover exactly the symmetric difference.
*
* NEG-CLOSE is exercised separately — sending NEG-MSG after CLOSE
* must surface a NEG-ERR from the relay.
*/
class Nip77NegentropyTest {
private lateinit var hub: RelayHub
private val relayUrl: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/")
@BeforeTest
fun setup() {
hub = RelayHub()
}
@AfterTest
fun teardown() {
hub.close()
}
/**
* Bare-bones WS client that captures every server message into a
* channel and exposes a `send` that goes straight at the in-process
* bridge. We don't need NostrClient's filter-management for these
* tests — we drive the wire.
*/
private class WireClient(
hub: RelayHub,
url: NormalizedRelayUrl,
) {
val incoming: Channel<String> = Channel(UNLIMITED)
private val ws =
hub.build(
url,
object : WebSocketListener {
override fun onOpen(
pingMillis: Int,
compression: Boolean,
) {}
override fun onMessage(text: String) {
incoming.trySend(text)
}
override fun onClosed(
code: Int,
reason: String,
) {
incoming.close()
}
override fun onFailure(
t: Throwable,
code: Int?,
response: String?,
) {
incoming.close(t)
}
},
)
init {
ws.connect()
}
fun send(json: String) {
check(ws.send(json)) { "send returned false" }
}
fun close() {
ws.disconnect()
}
}
private suspend fun WireClient.nextMessage(timeoutMs: Long = 5_000): Message {
val raw = withTimeout(timeoutMs) { incoming.receive() }
return OptimizedJsonMapper.fromJsonToMessage(raw)
}
/** Generates [count] signed text notes with monotonic createdAt. */
private fun makeEvents(count: Int): List<Event> {
val signer = NostrSignerSync(KeyPair())
val now = 1_700_000_000L
return List(count) { i ->
signer.sign(TextNoteEvent.build("event-$i", createdAt = now + i))
}
}
@Test
fun negentropyComputesSymmetricDifference() =
runBlocking {
// Universe of 10 events. Relay has events [0..7], client has [3..9] —
// overlap [3..7], relay-only [0..2], client-only [8..9].
val all = makeEvents(10)
val relayEvents = all.subList(0, 8)
val clientEvents = all.subList(3, 10)
hub.getOrCreate(relayUrl).preload(relayEvents)
val client = WireClient(hub, relayUrl)
try {
val session =
NegentropySession(
subId = "neg-1",
filter = Filter(kinds = listOf(1)),
localEvents = clientEvents,
)
// Step 1: send NEG-OPEN.
val openCmd = session.open()
client.send(OptimizedJsonMapper.toJson(openCmd))
// Step 2: drive NEG-MSG round trips until reconciliation completes.
val haveIds = mutableSetOf<String>()
val needIds = mutableSetOf<String>()
var safety = 32
while (safety-- > 0) {
val response = client.nextMessage()
if (response is NegErrMessage) {
kotlin.test.fail("relay sent NEG-ERR: ${response.reason}")
}
response as NegMsgMessage
val result = session.processMessage(response.message)
haveIds += result.haveIds
needIds += result.needIds
if (result.isComplete()) break
client.send(OptimizedJsonMapper.toJson(result.nextCmd!!))
}
assertTrue(safety > 0, "reconciliation did not converge in 32 rounds")
// Step 3: verify the symmetric difference.
val expectedNeed = relayEvents.subList(0, 3).map { it.id }.toSet() // [0..2]
val expectedHave = clientEvents.subList(5, 7).map { it.id }.toSet() // [8..9]
assertEquals(expectedNeed, needIds, "client should NEED events 0..2 from relay")
assertEquals(expectedHave, haveIds, "client should HAVE events 8..9 to send to relay")
} finally {
client.close()
}
}
@Test
fun negCloseFreesServerStateAndReopenWorks() =
runBlocking {
val all = makeEvents(5)
hub.getOrCreate(relayUrl).preload(all)
val client = WireClient(hub, relayUrl)
try {
// First session.
val s1 = NegentropySession("neg", Filter(kinds = listOf(1)), localEvents = emptyList())
client.send(OptimizedJsonMapper.toJson(s1.open()))
val response = client.nextMessage() as NegMsgMessage
val r1 = s1.processMessage(response.message)
// Client had nothing, so it needs all 5 from the relay.
assertEquals(5, r1.needIds.size)
// Close.
client.send(OptimizedJsonMapper.toJson(s1.close()))
// Re-OPEN with the same subId and an empty client store —
// server must build a new session and respond. If the
// close didn't free state, this would either error or
// continue the previous reconciliation.
val s2 = NegentropySession("neg", Filter(kinds = listOf(1)), localEvents = emptyList())
client.send(OptimizedJsonMapper.toJson(s2.open()))
val resp2 = client.nextMessage() as NegMsgMessage
val r2 = s2.processMessage(resp2.message)
assertEquals(5, r2.needIds.size)
} finally {
client.close()
}
}
@Test
fun negMsgWithoutOpenReturnsNegErr() =
runBlocking {
val client = WireClient(hub, relayUrl)
try {
// Synthesise a stray NEG-MSG for a sub-id that was never opened.
val raw = """["NEG-MSG","ghost-sub","00"]"""
client.send(raw)
val response = client.nextMessage()
assertTrue(response is NegErrMessage, "expected NEG-ERR, got ${response::class.simpleName}")
assertEquals("ghost-sub", response.subId)
assertTrue(response.reason.contains("no negentropy session"))
} finally {
client.close()
}
}
@Test
fun negOpenWithSameSubIdReplacesPriorSession() =
runBlocking {
val a = makeEvents(3)
val b = makeEvents(2)
hub.getOrCreate(relayUrl).preload(a + b)
val client = WireClient(hub, relayUrl)
try {
// First open with localEvents = a; next we'll re-open
// and confirm the new session sees a fresh state.
val first = NegentropySession("dup", Filter(kinds = listOf(1)), localEvents = a)
client.send(OptimizedJsonMapper.toJson(first.open()))
client.nextMessage() as NegMsgMessage // discard
// Re-OPEN with same subId, different localEvents.
val second = NegentropySession("dup", Filter(kinds = listOf(1)), localEvents = a + b)
client.send(OptimizedJsonMapper.toJson(second.open()))
val resp = client.nextMessage() as NegMsgMessage
val r = second.processMessage(resp.message)
// Client now has every event the relay has → nothing to need.
assertEquals(0, r.needIds.size)
assertEquals(0, r.haveIds.size)
} finally {
client.close()
}
}
}
@@ -0,0 +1,232 @@
/*
* 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.geode.admin
import com.vitorpamplona.geode.LocalRelayServer
import com.vitorpamplona.geode.Relay
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request
import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Drives a real `LocalRelayServer` over HTTP and proves the NIP-86
* admin RPC flow works end-to-end: NIP-98 auth, admin allow-list
* gate, ban mutation, and the resulting policy effect on a follow-up
* EVENT publish.
*/
class Nip86EndToEndTest {
private lateinit var relay: Relay
private lateinit var server: LocalRelayServer
private lateinit var scope: CoroutineScope
private lateinit var nostrClient: NostrClient
private val httpClient = OkHttpClient.Builder().build()
private val admin = NostrSignerSync(KeyPair())
private val outsider = NostrSignerSync(KeyPair())
private val targetUser = NostrSignerSync(KeyPair())
@BeforeTest
fun setup() {
val placeholder = "ws://127.0.0.1:7771/".normalizeRelayUrl()
relay = Relay(url = placeholder)
server =
LocalRelayServer(
relay = relay,
host = "127.0.0.1",
port = 0,
adminPubkeys = setOf(admin.pubKey),
).start()
scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val builder = BasicOkHttpWebSocket.Builder { _ -> httpClient }
nostrClient = NostrClient(builder, scope)
}
@AfterTest
fun teardown() {
nostrClient.disconnect()
scope.cancel()
server.stop(gracePeriodMillis = 200, timeoutMillis = 500)
relay.close()
}
private val httpUrl get() = server.url.replace("ws://", "http://")
/** Sends a NIP-86 RPC request signed by [signer] and returns the raw HTTP response. */
private fun rpc(
request: Nip86Request,
signer: NostrSignerSync,
): okhttp3.Response {
val body = JsonMapper.toJson(request).encodeToByteArray()
val authTemplate =
HTTPAuthorizationEvent.build(url = httpUrl, method = "POST", file = body)
val authToken = signer.sign(authTemplate).toAuthToken()
return httpClient
.newCall(
Request
.Builder()
.url(httpUrl)
.post(body.toRequestBody("application/nostr+json+rpc".toMediaType()))
.header("Authorization", authToken)
.build(),
).execute()
}
@Test
fun supportedMethodsListsTheServersMethods() {
rpc(Nip86Request.supportedMethods(), admin).use {
assertEquals(200, it.code)
val json = JsonMapper.fromJson<com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Response>(it.body.string())
val arr = json.result as JsonArray
val names = arr.map { e -> e.jsonPrimitive.content }
assertTrue(names.contains("supportedmethods"))
assertTrue(names.contains("banpubkey"))
}
}
@Test
fun foreignSignerReturns403() {
rpc(Nip86Request.supportedMethods(), outsider).use {
assertEquals(403, it.code)
}
}
@Test
fun missingAuthHeaderReturns401() {
val body = JsonMapper.toJson(Nip86Request.supportedMethods()).encodeToByteArray()
httpClient
.newCall(
Request
.Builder()
.url(httpUrl)
.post(body.toRequestBody("application/nostr+json+rpc".toMediaType()))
.build(),
).execute()
.use {
assertEquals(401, it.code)
assertTrue(it.headers["WWW-Authenticate"]?.startsWith("Nostr") == true)
}
}
@Test
fun banPubkeyBlocksSubsequentEventsFromThatAuthor() =
runBlocking {
val relayUrl = server.url.normalizeRelayUrl()
// Baseline: targetUser can publish.
val before = nostrClient.publishAndConfirm(targetUser.sign(TextNoteEvent.build("first")), setOf(relayUrl))
assertEquals(true, before)
// Admin bans them.
rpc(Nip86Request.banPubkey(targetUser.pubKey, "spam"), admin).use {
assertEquals(200, it.code)
val resp = JsonMapper.fromJson<com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Response>(it.body.string())
assertEquals(true, (resp.result as JsonPrimitive).boolean)
}
// Subsequent EVENT from the banned author is rejected.
val after = nostrClient.publishAndConfirm(targetUser.sign(TextNoteEvent.build("second")), setOf(relayUrl))
assertEquals(false, after, "BanListPolicy must reject events from banned pubkeys")
}
@Test
fun changeRelayNameFlowsToNip11Endpoint() {
rpc(Nip86Request.changeRelayName("renamed-by-admin"), admin).use {
assertEquals(200, it.code)
}
// Read the NIP-11 endpoint and confirm the new name is live.
val response =
httpClient
.newCall(
Request
.Builder()
.url(httpUrl)
.header("Accept", "application/nostr+json")
.build(),
).execute()
response.use {
val info = Nip11RelayInformation.fromJson(it.body.string())
assertEquals("renamed-by-admin", info.name)
}
}
@Test
fun adminEndpointDisabledWhenNoPubkeysConfigured() =
runBlocking {
// Spin up a *separate* server with no admin pubkeys.
val placeholder = "ws://127.0.0.1:7771/".normalizeRelayUrl()
val openRelay = Relay(url = placeholder)
val openServer =
LocalRelayServer(openRelay, host = "127.0.0.1", port = 0).start()
try {
val openHttpUrl = openServer.url.replace("ws://", "http://")
val body =
JsonMapper.toJson(Nip86Request.supportedMethods()).encodeToByteArray()
val authToken =
admin
.sign(
HTTPAuthorizationEvent.build(url = openHttpUrl, method = "POST", file = body),
).toAuthToken()
httpClient
.newCall(
Request
.Builder()
.url(openHttpUrl)
.post(body.toRequestBody("application/nostr+json+rpc".toMediaType()))
.header("Authorization", authToken)
.build(),
).execute()
.use {
assertEquals(403, it.code)
}
} finally {
openServer.stop(gracePeriodMillis = 100, timeoutMillis = 500)
openRelay.close()
}
}
}
@@ -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.geode.config
import java.io.File
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class RelayConfigTest {
@Test
fun emptyTomlYieldsAllDefaults() {
val c = RelayConfig.fromToml("")
assertEquals("0.0.0.0", c.network.host)
assertEquals(7447, c.network.port)
assertEquals("/", c.network.path)
assertEquals(true, c.database.in_memory)
assertEquals(false, c.options.require_auth)
// Verify is on by default — operators have to opt out explicitly.
assertEquals(true, c.options.verify_signatures)
assertTrue(c.authorization.pubkey_whitelist.isEmpty())
}
@Test
fun verifySignaturesCanBeExplicitlyDisabled() {
val c = RelayConfig.fromToml("[options]\nverify_signatures = false")
assertEquals(false, c.options.verify_signatures)
}
@Test
fun parsesAllSectionsTogether() {
val toml =
"""
[info]
relay_url = "wss://relay.example.com/"
name = "Example"
contact = "ops@example.com"
supported_nips = [1, 9, 11, 42]
[network]
host = "127.0.0.1"
port = 9988
path = "/relay"
[database]
in_memory = false
file = "/var/lib/quartz-relay/events.db"
[options]
verify_signatures = true
require_auth = true
reject_future_seconds = 1800
[limits]
max_ws_frame_bytes = 1048576
[authorization]
pubkey_blacklist = ["aaaa", "bbbb"]
kind_blacklist = [4, 1059]
""".trimIndent()
val c = RelayConfig.fromToml(toml)
assertEquals("wss://relay.example.com/", c.info.relay_url)
assertEquals("Example", c.info.name)
assertEquals(listOf(1, 9, 11, 42), c.info.supported_nips)
assertEquals("127.0.0.1", c.network.host)
assertEquals(9988, c.network.port)
assertEquals("/relay", c.network.path)
assertEquals(false, c.database.in_memory)
assertEquals("/var/lib/quartz-relay/events.db", c.database.file)
assertEquals(true, c.options.verify_signatures)
assertEquals(true, c.options.require_auth)
assertEquals(1800, c.options.reject_future_seconds)
assertEquals(1_048_576, c.limits.max_ws_frame_bytes)
assertEquals(listOf("aaaa", "bbbb"), c.authorization.pubkey_blacklist)
assertEquals(listOf(4, 1059), c.authorization.kind_blacklist)
}
@Test
fun supportedNipsRenderedAsStringsInNip11Doc() {
val c =
RelayConfig.fromToml(
"""
[info]
supported_nips = [1, 11, 42]
""".trimIndent(),
)
val info =
c.resolveInfo(
"ws://127.0.0.1:7447/".let {
com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
.normalize(it)
},
)
assertEquals(listOf("1", "11", "42"), info.document.supported_nips)
}
@Test
fun loadsTheBundledExampleConfigCleanly() {
// The example file lives at the module root so operators have a
// canonical reference. Read it via a relative path resolved
// against the working directory (gradle runs tests from the
// module dir).
val candidates =
listOf(
File("config.example.toml"),
File("geode/config.example.toml"),
)
val example =
candidates.firstOrNull { it.exists() }
?: error(
"config.example.toml not found in any of: ${candidates.joinToString { it.absolutePath }}",
)
val c = RelayConfig.fromFile(example)
assertEquals("wss://relay.example.com/", c.info.relay_url)
assertEquals(true, c.options.verify_signatures)
assertEquals(false, c.database.in_memory)
assertNotNull(c.database.file)
}
@Test
fun missingSectionsAreOptional() {
val c = RelayConfig.fromToml("[info]\nname = \"only-info\"")
assertEquals("only-info", c.info.name)
// Defaults preserved for unspecified sections.
assertEquals(7447, c.network.port)
assertEquals(true, c.database.in_memory)
}
}
@@ -0,0 +1,333 @@
/*
* 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.geode.perf
import com.vitorpamplona.geode.LocalRelayServer
import com.vitorpamplona.geode.Relay
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
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.normalizeRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import okhttp3.OkHttpClient
import java.util.concurrent.atomic.AtomicLong
import kotlin.test.Test
import kotlin.time.measureTime
/**
* Single-process load tests that report real numbers for:
* - WebSocket connection establishment rate
* - Concurrent subscription steady-state count
* - Live-event fanout latency to N subscribers
* - End-to-end EVENT publish throughput (EVENT → store → OK)
*
* Disabled by default — runs only when `runLoadBenchmark` system
* property is set. Add `-DrunLoadBenchmark=true` to a Gradle test
* invocation. Skipped under the normal test run because (a) numbers
* vary on busy CI runners, and (b) some scenarios spin up thousands
* of sockets which is rude on shared infra.
*/
class LoadBenchmark {
private val enabled = System.getProperty("runLoadBenchmark") == "true"
private inline fun benchmark(
name: String,
block: () -> Unit,
) {
if (!enabled) {
println("[skip] $name — set -DrunLoadBenchmark=true to enable")
return
}
println("--- $name ---")
block()
}
/**
* How many concurrent WebSocket *connections* can we hold open?
* Each test client opens a raw WS, sends one REQ, expects EOSE.
* Stays connected after that.
*/
@Test
fun connectionsHeldOpen() =
benchmark("connections held open") {
for (target in listOf(100, 500, 1_000, 2_000, 5_000, 10_000)) {
runBenchmarkServer { server, http ->
val httpUrl =
okhttp3.Request
.Builder()
.url(server.url.replace("ws://", "http://"))
.build()
val sockets = java.util.concurrent.CopyOnWriteArrayList<okhttp3.WebSocket>()
val opened = AtomicLong()
val gotEose = AtomicLong()
val opens =
measureTime {
repeat(target) {
val ws =
http.newWebSocket(
httpUrl,
object : okhttp3.WebSocketListener() {
override fun onOpen(
webSocket: okhttp3.WebSocket,
response: okhttp3.Response,
) {
opened.incrementAndGet()
webSocket.send(
"""["REQ","s",{"kinds":[1],"limit":1}]""",
)
}
override fun onMessage(
webSocket: okhttp3.WebSocket,
text: String,
) {
if (text.startsWith("[\"EOSE\"")) {
gotEose.incrementAndGet()
}
}
},
)
sockets += ws
}
// Wait for either EOSE on every connection or 60s deadline.
val deadline = System.currentTimeMillis() + 60_000
while (gotEose.get() < target && System.currentTimeMillis() < deadline) {
Thread.sleep(50)
}
}
// Let activeSessionCount settle.
Thread.sleep(200)
println(
"target=$target opened=${opened.get()} eosed=${gotEose.get()} " +
"active=${server.activeSessionCount} elapsedMs=${opens.inWholeMilliseconds}",
)
sockets.forEach { runCatching { it.cancel() } }
if (gotEose.get() < target) {
println(" --> degradation at $target; stopping ramp-up")
return@runBenchmarkServer
}
}
}
}
/**
* One publisher sends 10k events serially. Measures the round-trip
* `EVENT` → `OK true` time, which is dominated by SQLite write
* throughput + the write side of the policy stack.
*/
@Test
fun publishThroughputSingleClient() =
benchmark("publish throughput single client") {
runBenchmarkServer { server, http ->
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(BasicOkHttpWebSocket.Builder { _ -> http }, scope)
try {
val signer = NostrSignerSync(KeyPair())
val relayUrl = server.url.normalizeRelayUrl()
val n = 10_000
var ok = 0
val elapsed =
measureTime {
runBlocking {
repeat(n) { i ->
val event = signer.sign(TextNoteEvent.build("hello $i"))
if (client.publishAndConfirm(event, setOf(relayUrl))) ok++
}
}
}
val eps = (n * 1000.0) / elapsed.inWholeMilliseconds
println("events=$n ok=$ok elapsedMs=${elapsed.inWholeMilliseconds} eps=${"%.0f".format(eps)}")
} finally {
client.disconnect()
scope.cancel()
}
}
}
/**
* One publisher, N subscribers. Publishes one EVENT and measures
* fan-out latency: time from publish to last subscriber receiving.
*/
@Test
fun fanoutLatency() =
benchmark("fanout latency") {
for (subs in listOf(100, 500, 1_000, 2_000)) {
runBenchmarkServer { server, http ->
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val subClient = NostrClient(BasicOkHttpWebSocket.Builder { _ -> http }, scope)
val pubClient = NostrClient(BasicOkHttpWebSocket.Builder { _ -> http }, scope)
try {
val relayUrl = server.url.normalizeRelayUrl()
val received = AtomicLong()
val firstReceiveNs = AtomicLong(-1)
val lastReceiveNs = AtomicLong(-1)
// Set up `subs` subscribers.
val eosed = AtomicLong()
repeat(subs) { i ->
subClient.subscribe(
"fanout-$i",
mapOf(relayUrl to listOf(Filter(kinds = listOf(1)))),
object : SubscriptionListener {
override fun onEvent(
event: com.vitorpamplona.quartz.nip01Core.core.Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
val now = System.nanoTime()
firstReceiveNs.compareAndSet(-1, now)
lastReceiveNs.set(now)
received.incrementAndGet()
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
eosed.incrementAndGet()
}
},
)
}
runBlocking {
withTimeout(60_000) {
while (eosed.get() < subs) kotlinx.coroutines.delay(50)
}
}
println("$subs subs ready; publishing one event...")
val signer = NostrSignerSync(KeyPair())
val event = signer.sign(TextNoteEvent.build("fanout"))
val publishStart = System.nanoTime()
runBlocking {
pubClient.publishAndConfirm(event, setOf(relayUrl))
}
val publishEnd = System.nanoTime()
// Wait for fanout to complete.
runBlocking {
withTimeout(60_000) {
while (received.get() < subs) kotlinx.coroutines.delay(10)
}
}
val firstFanoutMs = (firstReceiveNs.get() - publishStart) / 1_000_000.0
val lastFanoutMs = (lastReceiveNs.get() - publishStart) / 1_000_000.0
val publishMs = (publishEnd - publishStart) / 1_000_000.0
println(
"subs=$subs publishMs=${"%.1f".format(publishMs)} " +
"fanoutFirstMs=${"%.1f".format(firstFanoutMs)} " +
"fanoutLastMs=${"%.1f".format(lastFanoutMs)} " +
"received=${received.get()}/$subs",
)
} finally {
subClient.disconnect()
pubClient.disconnect()
scope.cancel()
}
}
}
}
/**
* Many concurrent publishers, each on their own WebSocket. Tells
* us whether the SQLite single-writer bottleneck is the floor or
* if there's contention upstream.
*/
@Test
fun publishThroughputConcurrent() =
benchmark("publish throughput concurrent") {
for (parallel in listOf(2, 4, 8, 16, 32)) {
runBenchmarkServer { server, http ->
val total = 5_000
val perThread = total / parallel
val ok = AtomicLong()
val elapsed =
measureTime {
val threads =
(0 until parallel).map { tid ->
Thread {
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(BasicOkHttpWebSocket.Builder { _ -> http }, scope)
try {
val signer = NostrSignerSync(KeyPair())
val relayUrl = server.url.normalizeRelayUrl()
runBlocking {
repeat(perThread) { i ->
val ev = signer.sign(TextNoteEvent.build("hi-$tid-$i"))
if (client.publishAndConfirm(ev, setOf(relayUrl))) ok.incrementAndGet()
}
}
} finally {
client.disconnect()
scope.cancel()
}
}.also { it.start() }
}
threads.forEach { it.join() }
}
val eps = (ok.get() * 1000.0) / elapsed.inWholeMilliseconds
println(
"parallel=$parallel total=${ok.get()}/$total elapsedMs=${elapsed.inWholeMilliseconds} eps=${"%.0f".format(eps)}",
)
}
}
}
/** Spin up an isolated relay + http client per scenario. */
private inline fun runBenchmarkServer(block: (LocalRelayServer, OkHttpClient) -> Unit) {
val placeholder = "ws://127.0.0.1:7771/".normalizeRelayUrl()
val relay = Relay(url = placeholder)
val server = LocalRelayServer(relay, host = "127.0.0.1", port = 0).start()
val http =
OkHttpClient
.Builder()
// Don't bottleneck on the OkHttp dispatcher when we
// open thousands of WS connections from one client.
.dispatcher(
okhttp3.Dispatcher().apply {
maxRequests = 100_000
maxRequestsPerHost = 100_000
},
).build()
try {
block(server, http)
} finally {
http.dispatcher.executorService.shutdownNow()
server.stop(gracePeriodMillis = 200, timeoutMillis = 1_000)
relay.close()
}
}
}
@@ -0,0 +1,208 @@
/*
* 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.geode.persistence
import com.vitorpamplona.geode.Relay
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import java.io.File
import java.nio.file.Files
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class PersistenceTest {
private lateinit var dir: File
private lateinit var stateFile: File
private val url = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/")
@BeforeTest
fun setup() {
dir = Files.createTempDirectory("quartz-relay-persist-").toFile()
stateFile = File(dir, "admin.json")
}
@AfterTest
fun teardown() {
dir.deleteRecursively()
}
@Test
fun firstBootWritesNothingUntilFirstMutation() {
val relay = Relay(url = url, stateFile = stateFile)
try {
// No mutation yet → file does not exist.
assertTrue(!stateFile.exists(), "fresh relay must not eagerly write a snapshot")
} finally {
relay.close()
}
}
@Test
fun banPubkeyTriggersSnapshotAndSurvivesRestart() {
val pk = "a".repeat(64)
val r1 = Relay(url = url, stateFile = stateFile)
try {
r1.banStore.banPubkey(pk, "spam")
} finally {
r1.close()
}
assertTrue(stateFile.exists(), "snapshot must be written after a mutation")
// Fresh relay reads the snapshot and sees the ban.
val r2 = Relay(url = url, stateFile = stateFile)
try {
assertTrue(r2.banStore.isBanned(pk))
assertEquals("spam", r2.banStore.listBannedPubkeys()[0].second)
} finally {
r2.close()
}
}
@Test
fun updateInfoSurvivesRestart() {
val r1 = Relay(url = url, stateFile = stateFile)
try {
r1.updateInfo { it.copy(name = "renamed") }
} finally {
r1.close()
}
val r2 = Relay(url = url, stateFile = stateFile)
try {
assertEquals("renamed", r2.info.document.name)
} finally {
r2.close()
}
}
@Test
fun allowKindRoundTripsAcrossRestart() {
val r1 = Relay(url = url, stateFile = stateFile)
try {
r1.banStore.allowKind(1)
r1.banStore.allowKind(7)
r1.banStore.disallowKind(4)
} finally {
r1.close()
}
val r2 = Relay(url = url, stateFile = stateFile)
try {
assertEquals(listOf(1, 7), r2.banStore.listAllowedKinds())
assertEquals(listOf(4), r2.banStore.listDisallowedKinds())
} finally {
r2.close()
}
}
@Test
fun corruptStateFileIsTolerated() {
stateFile.writeText("not valid json {")
// Should not throw — just log and start fresh.
val r = Relay(url = url, stateFile = stateFile)
try {
assertTrue(r.banStore.listBannedPubkeys().isEmpty())
assertTrue(r.banStore.listAllowedKinds().isEmpty())
} finally {
r.close()
}
}
@Test
fun snapshotWriteIsAtomicViaTempFile() {
// After a mutation completes, no `.tmp` file should remain.
val r = Relay(url = url, stateFile = stateFile)
try {
r.banStore.banPubkey("b".repeat(64))
val tmp = File(dir, "admin.json.tmp")
assertTrue(!tmp.exists(), "tmp file must be moved into place, not left behind")
} finally {
r.close()
}
}
@Test
fun missingStateFileMeansInMemoryOnly() {
val r = Relay(url = url) // no stateFile
try {
r.banStore.banPubkey("c".repeat(64))
// No snapshot path → nothing on disk in our temp dir.
assertNull(dir.list()?.firstOrNull { it.startsWith("admin") })
} finally {
r.close()
}
}
@Test
fun stateStoreRoundTripsAllSections() {
val ss = RelayStateStore(stateFile)
val state =
RelayPersistedState(
info = Nip11RelayInformation(name = "x", description = "y"),
bannedPubkeys = listOf(BannedEntry("aa", "spam"), BannedEntry("bb", null)),
allowedPubkeys = listOf(BannedEntry("cc", "trusted")),
bannedEvents = listOf(BannedEntry("dd", "off-topic")),
allowedKinds = listOf(1, 7),
disallowedKinds = listOf(4, 1059),
)
ss.save(state)
val loaded = ss.load()!!
assertEquals("x", loaded.info!!.name)
assertEquals(2, loaded.bannedPubkeys.size)
assertEquals(listOf(1, 7), loaded.allowedKinds)
assertEquals(listOf(4, 1059), loaded.disallowedKinds)
}
/**
* Manual `Nip11RelayInformation.copy` — the class isn't a data
* class so Kotlin doesn't generate one. We only need `name` here.
*/
private fun Nip11RelayInformation.copy(name: String? = this.name) =
Nip11RelayInformation(
id = id,
name = name,
description = description,
icon = icon,
pubkey = pubkey,
self = self,
contact = contact,
supported_nips = supported_nips,
supported_nip_extensions = supported_nip_extensions,
software = software,
version = version,
limitation = limitation,
relay_countries = relay_countries,
language_tags = language_tags,
tags = tags,
posting_policy = posting_policy,
privacy_policy = privacy_policy,
terms_of_service = terms_of_service,
payments_url = payments_url,
retention = retention,
fees = fees,
nip50 = nip50,
supported_grasps = supported_grasps,
)
}
@@ -0,0 +1,127 @@
/*
* 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.geode.policies
import com.vitorpamplona.geode.RelayHub
import com.vitorpamplona.geode.fixtures.SyntheticEvents
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.KindAllowDenyPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PubkeyAllowDenyPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RejectFutureEventsPolicy
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* End-to-end through `NostrClient → RelayHub → Relay` with the policies
* actually wired into the relay. Proves an EVENT command sent on the
* wire surfaces an OK false response when the policy rejects.
*/
class PoliciesIntegrationTest {
private val relayUrl: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/")
private lateinit var scope: CoroutineScope
@BeforeTest
fun setup() {
scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
}
@AfterTest
fun teardown() {
scope.cancel()
}
/** Spin up a hub whose only relay uses the supplied policy factory. */
private fun hubWith(policyFactory: () -> IRelayPolicy): Pair<NostrClient, RelayHub> {
val hub = RelayHub(defaultPolicy = policyFactory)
// Materialise the relay so the URL resolves in the hub.
hub.getOrCreate(relayUrl)
return NostrClient(hub, scope) to hub
}
@Test
fun kindBlacklistRejectsKind4OverWire() =
runBlocking {
val (client, hub) = hubWith { KindAllowDenyPolicy(deny = setOf(4)) }
try {
val signer = NostrSignerSync(KeyPair())
val ok = client.publishAndConfirm(signer.sign(TextNoteEvent.build("ok")), setOf(relayUrl))
assertEquals(true, ok, "kind 1 must pass")
// Synthetic kind-4 event — the relay's deny list rejects it.
val kind4 = SyntheticEvents.fakeEvent(idSeed = 999, kind = 4, pubKey = signer.pubKey)
val rejected = client.publishAndConfirm(kind4, setOf(relayUrl))
assertEquals(false, rejected, "kind 4 must be rejected")
} finally {
client.disconnect()
hub.close()
}
}
@Test
fun pubkeyAllowListRejectsForeignAuthorOverWire() =
runBlocking {
val alice = NostrSignerSync(KeyPair())
val mallory = NostrSignerSync(KeyPair())
val (client, hub) = hubWith { PubkeyAllowDenyPolicy(allow = setOf(alice.pubKey)) }
try {
val accepted = client.publishAndConfirm(alice.sign(TextNoteEvent.build("hi")), setOf(relayUrl))
assertEquals(true, accepted)
val denied = client.publishAndConfirm(mallory.sign(TextNoteEvent.build("nope")), setOf(relayUrl))
assertEquals(false, denied)
} finally {
client.disconnect()
hub.close()
}
}
@Test
fun rejectFutureEventsBlocksFarFutureCreatedAtOverWire() =
runBlocking {
// Use a fixed clock so the policy decision is deterministic.
val frozen = 1_000_000L
val (client, hub) =
hubWith { RejectFutureEventsPolicy(maxFutureSeconds = 60, now = { frozen }) }
try {
val signer = NostrSignerSync(KeyPair())
val nearby = signer.sign(TextNoteEvent.build("ok", createdAt = frozen + 30))
assertEquals(true, client.publishAndConfirm(nearby, setOf(relayUrl)))
val tooFar = signer.sign(TextNoteEvent.build("nope", createdAt = frozen + 3600))
assertEquals(false, client.publishAndConfirm(tooFar, setOf(relayUrl)))
} finally {
client.disconnect()
hub.close()
}
}
}
@@ -0,0 +1,175 @@
/*
* 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.geode.policies
import com.vitorpamplona.geode.fixtures.SyntheticEvents
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.KindAllowDenyPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PubkeyAllowDenyPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RejectFutureEventsPolicy
import kotlin.test.Test
import kotlin.test.assertTrue
import kotlin.test.fail
/**
* Per-policy unit tests. Each policy gets a small, focused suite that
* proves accept/reject behaviour at the boundaries (empty config,
* single hit, collision between allow + deny, etc.).
*
* The end-to-end "policy is applied through the Ktor server" coverage
* lives in `LocalRelayServerTest` / `Nip01ComplianceTest` — these tests
* just exercise the policy in isolation.
*/
class PoliciesTest {
private fun event(
kind: Int = 1,
pubKey: String = SyntheticEvents.hexId(1),
createdAt: Long = 1000L,
content: String = "",
) = SyntheticEvents.fakeEvent(idSeed = 1, kind = kind, pubKey = pubKey, createdAt = createdAt, content = content)
private fun assertAccepted(result: PolicyResult<*>) {
if (result is PolicyResult.Rejected) fail("expected Accepted, got Rejected: ${result.reason}")
}
private fun assertRejected(
result: PolicyResult<*>,
reasonContains: String? = null,
) {
when (result) {
is PolicyResult.Accepted -> {
fail("expected Rejected, got Accepted")
}
is PolicyResult.Rejected -> {
reasonContains?.let {
assertTrue(
result.reason.contains(it),
"expected reason to contain '$it', got '${result.reason}'",
)
}
}
}
}
// -- KindAllowDenyPolicy -------------------------------------------------
@Test
fun kindPolicyEmptyListsAreNoOp() {
val p = KindAllowDenyPolicy()
assertAccepted(p.accept(EventCmd(event(kind = 1))))
assertAccepted(p.accept(EventCmd(event(kind = 99))))
}
@Test
fun kindAllowListExcludesEverythingElse() {
val p = KindAllowDenyPolicy(allow = setOf(1, 7))
assertAccepted(p.accept(EventCmd(event(kind = 1))))
assertAccepted(p.accept(EventCmd(event(kind = 7))))
assertRejected(p.accept(EventCmd(event(kind = 4))), reasonContains = "kind 4 not allowed")
}
@Test
fun kindDenyListBlocksLastWordOverAllowList() {
// When both lists are set, allow is a permissive ceiling and
// deny still removes specific kinds inside it.
val p = KindAllowDenyPolicy(allow = setOf(1, 4, 7), deny = setOf(4))
assertAccepted(p.accept(EventCmd(event(kind = 1))))
assertRejected(p.accept(EventCmd(event(kind = 4))), reasonContains = "kind 4 denied")
assertRejected(p.accept(EventCmd(event(kind = 999))), reasonContains = "not allowed")
}
// -- PubkeyAllowDenyPolicy ----------------------------------------------
@Test
fun pubkeyAllowList() {
val alice = SyntheticEvents.hexId(101)
val mallory = SyntheticEvents.hexId(102)
val p = PubkeyAllowDenyPolicy(allow = setOf(alice))
assertAccepted(p.accept(EventCmd(event(pubKey = alice))))
assertRejected(p.accept(EventCmd(event(pubKey = mallory))), reasonContains = "not on allow")
}
@Test
fun pubkeyDenyList() {
val alice = SyntheticEvents.hexId(101)
val mallory = SyntheticEvents.hexId(102)
val p = PubkeyAllowDenyPolicy(deny = setOf(mallory))
assertAccepted(p.accept(EventCmd(event(pubKey = alice))))
assertRejected(p.accept(EventCmd(event(pubKey = mallory))), reasonContains = "denied")
}
@Test
fun pubkeyMatchIsCaseInsensitive() {
val pk = "ABCDEF".padEnd(64, '0')
val p = PubkeyAllowDenyPolicy(deny = setOf(pk.lowercase()))
// Event arrives with the upper-case form; policy must match.
assertRejected(p.accept(EventCmd(event(pubKey = pk))))
}
// -- RejectFutureEventsPolicy -------------------------------------------
@Test
fun futureEventsBeyondSkewAreRejected() {
val now = 1_000_000L
val p = RejectFutureEventsPolicy(maxFutureSeconds = 60, now = { now })
assertAccepted(p.accept(EventCmd(event(createdAt = now + 60))))
assertAccepted(p.accept(EventCmd(event(createdAt = now))))
assertAccepted(p.accept(EventCmd(event(createdAt = now - 9999)))) // past is fine
assertRejected(p.accept(EventCmd(event(createdAt = now + 61))), reasonContains = "future")
}
@Test
fun futureEventsZeroSkewMeansOnlyPastOrPresent() {
val now = 1_000L
val p = RejectFutureEventsPolicy(maxFutureSeconds = 0, now = { now })
assertAccepted(p.accept(EventCmd(event(createdAt = now))))
assertRejected(p.accept(EventCmd(event(createdAt = now + 1))))
}
// -- Stack composition --------------------------------------------------
/**
* Verifies that policies compose via `IRelayPolicy.plus` so an
* EVENT must clear every policy in the stack to be accepted.
*/
@Test
fun stackedPoliciesAllMustAccept() {
val now = 1_000L
val stack =
(KindAllowDenyPolicy(allow = setOf(1)) as com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy) +
RejectFutureEventsPolicy(maxFutureSeconds = 10, now = { now })
// Allowed kind, in window — accepted.
assertAccepted(stack.accept(EventCmd(event(kind = 1, createdAt = now))))
// Allowed kind, future timestamp — rejected by RejectFuture.
assertRejected(
stack.accept(EventCmd(event(kind = 1, createdAt = now + 1000))),
reasonContains = "future",
)
// Disallowed kind — rejected by KindPolicy regardless of timestamp.
assertRejected(
stack.accept(EventCmd(event(kind = 99, createdAt = now))),
reasonContains = "not allowed",
)
}
}
@@ -0,0 +1,71 @@
/*
* 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.geode.fixtures
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import java.util.zip.GZIPInputStream
/**
* Loaders for test event corpora bundled in `quartz/src/commonTest/resources/`.
* Lookup order:
* 1. The `TEST_RESOURCES_ROOT` environment variable (set by quartz's Gradle
* config to the absolute path of `commonTest/resources`).
* 2. The classpath, for callers that copy fixtures into their own
* `src/test/resources`.
*/
object RelayFixtures {
/** Reads a fixture file as a UTF-8 string. */
fun loadString(name: String): String {
val envRoot = System.getenv("TEST_RESOURCES_ROOT")
if (envRoot != null) {
val file = java.io.File(envRoot, name)
if (file.exists()) return file.readText()
}
val cp = RelayFixtures::class.java.classLoader?.getResourceAsStream(name)
if (cp != null) return cp.bufferedReader().use { it.readText() }
throw IllegalArgumentException(
"Fixture not found: $name. Set TEST_RESOURCES_ROOT or place on classpath.",
)
}
/** Reads a gzipped fixture file as a UTF-8 string. */
fun loadGzipString(name: String): String {
val envRoot = System.getenv("TEST_RESOURCES_ROOT")
if (envRoot != null) {
val file = java.io.File(envRoot, name)
if (file.exists()) {
return GZIPInputStream(file.inputStream()).bufferedReader().use { it.readText() }
}
}
val cp = RelayFixtures::class.java.classLoader?.getResourceAsStream(name)
if (cp != null) return GZIPInputStream(cp).bufferedReader().use { it.readText() }
throw IllegalArgumentException(
"Fixture not found: $name. Set TEST_RESOURCES_ROOT or place on classpath.",
)
}
/** Loads `nostr_vitor_short.json` — the small handcrafted Vitor corpus. */
fun vitorShort(): List<Event> = OptimizedJsonMapper.fromJsonToEventList(loadString("nostr_vitor_short.json"))
/** Loads `nostr_vitor_startup_data.json.gz` — the larger Vitor startup corpus. */
fun vitorStartup(): List<Event> = OptimizedJsonMapper.fromJsonToEventList(loadGzipString("nostr_vitor_startup_data.json"))
}
@@ -0,0 +1,66 @@
/*
* 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.geode.fixtures
import com.vitorpamplona.quartz.nip01Core.core.Event
/**
* Generators for cheap, deterministic, structurally valid Nostr events.
*
* The signatures and ids produced here are *not* cryptographically valid —
* the in-process relay's default [com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy]
* doesn't verify them, and neither does the underlying SQLite event store.
* Use these when a test only needs to exercise relay logic (filter matching,
* limits, EOSE, live updates) and not the cryptographic layer.
*/
object SyntheticEvents {
/** A pubkey/sig pair that's syntactically valid (64/128 hex chars) but signs nothing. */
private val DEFAULT_PUBKEY = "0".repeat(64)
private val FAKE_SIG = "0".repeat(128)
/** Hex padding to 64 chars so deterministic ids look like real event ids. */
fun hexId(seed: Int): String = seed.toString().padStart(64, '0')
fun fakeEvent(
idSeed: Int,
kind: Int = 1,
pubKey: String = DEFAULT_PUBKEY,
createdAt: Long = idSeed.toLong(),
content: String = "",
tags: Array<Array<String>> = emptyArray(),
): Event = Event(hexId(idSeed), pubKey, createdAt, kind, tags, content, FAKE_SIG)
/**
* Returns [count] events of [kind] with monotonic [createdAt] starting at 1
* and a *distinct* pubkey per event. Distinct pubkeys are essential for
* replaceable kinds (0, 3, 10000-19999): without them, the relay collapses
* the whole batch to one row per (kind, pubkey).
*/
fun batch(
count: Int,
kind: Int = 1,
pubKeyOf: (Int) -> String = { hexId(1_000_000 + it) },
): List<Event> =
List(count) { i ->
val seed = i + 1
fakeEvent(idSeed = seed, kind = kind, pubKey = pubKeyOf(seed), createdAt = seed.toLong())
}
}
@@ -0,0 +1,78 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.geode.testing
import com.vitorpamplona.geode.Relay
import com.vitorpamplona.geode.RelayHub
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import org.junit.After
/**
* Base class for tests that drive a real [NostrClient] against an
* in-process [RelayHub]. Owns the lifecycle of the four pieces every
* such test needs:
*
* - [hub] — the registry of in-process relays (also serves as
* `WebsocketBuilder` for [NostrClient]).
* - [scope] — application coroutine scope for the client.
* - [client] — a [NostrClient] wired to [hub] and [scope].
* - [defaultRelay] / [defaultRelayUrl] — convenience handles for the
* single-relay case (the most common in tests).
*
* Cleanup happens in [tearDownRelayClientTest], registered with
* JUnit's [@After][After], so an assertion failure does NOT leak the
* scope, the SQLite event store, or the WebSocket bridge — a recurring
* problem with the previous "clean up at the end of the test body"
* pattern.
*
* Subclasses that need their own setup/teardown should add their own
* `@Before` / `@After` methods; JUnit runs all of them.
*
* Multi-relay tests use [hub] directly:
* ```
* val relayA = RelayUrlNormalizer.normalize("ws://relay-a/")
* hub.getOrCreate(relayA).preload(eventA)
* hub.getOrCreate(relayB).preload(eventB)
* ```
*/
open class RelayClientTest {
val hub: RelayHub = RelayHub()
val scope: CoroutineScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client: NostrClient = NostrClient(hub, scope)
/** Stable URL for the single-relay case — see [RelayHub.DEFAULT_URL]. */
val defaultRelayUrl: NormalizedRelayUrl get() = RelayHub.DEFAULT_URL
/** Lazy handle to the relay at [defaultRelayUrl]. Auto-created on first read. */
val defaultRelay: Relay get() = hub.getOrCreate(defaultRelayUrl)
@After
fun tearDownRelayClientTest() {
client.disconnect()
scope.cancel()
hub.close()
}
}
@@ -0,0 +1,121 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.geode.testing
import com.vitorpamplona.quartz.nip01Core.core.Event
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 kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import kotlinx.coroutines.withTimeout
/** Tagged result of [collectUntilEose]: stored events plus whether EOSE actually arrived. */
data class CollectResult(
val events: List<Event>,
val eoseReceived: Boolean,
)
/**
* Subscribe with [filter] on a single [relay], drain the
* historical-replay phase, and return when EOSE arrives. The
* subscription is closed before this returns. The pattern that 80% of
* REQ-style tests need.
*
* ```
* val (events, eose) = client.collectUntilEose(defaultRelayUrl, Filter(kinds = listOf(1)))
* assertEquals(20, events.size)
* assertTrue(eose)
* ```
*
* @param timeoutMillis time to wait for EOSE before failing the test.
* Default 5 s — generous for in-process; tighten if needed.
*/
suspend fun NostrClient.collectUntilEose(
relay: NormalizedRelayUrl,
filter: Filter,
timeoutMillis: Long = 5_000,
): CollectResult = collectUntilEoseMulti(relay, listOf(filter), timeoutMillis)
/**
* Multi-filter variant. NIP-01 allows a REQ to carry several filters
* that the relay OR's together. EOSE fires once after the union of all
* filters has been replayed.
*/
suspend fun NostrClient.collectUntilEoseMulti(
relay: NormalizedRelayUrl,
filters: List<Filter>,
timeoutMillis: Long = 5_000,
): CollectResult {
val ch = Channel<Signal>(UNLIMITED)
val subId = "test-sub-${nextSubId()}"
subscribe(
subId,
mapOf(relay to filters),
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
ch.trySend(Signal.Ev(event))
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
ch.trySend(Signal.Eose)
}
},
)
val events = mutableListOf<Event>()
var eose = false
try {
withTimeout(timeoutMillis) {
while (!eose) {
when (val msg = ch.receive()) {
is Signal.Ev -> events += msg.event
Signal.Eose -> eose = true
}
}
}
} finally {
unsubscribe(subId)
}
return CollectResult(events, eose)
}
private sealed interface Signal {
data class Ev(
val event: Event,
) : Signal
object Eose : Signal
}
/** Monotonic counter for unique sub-ids inside a JVM. */
private var subIdSeq: Int = 0
private fun nextSubId(): Int = ++subIdSeq
+6
View File
@@ -81,6 +81,8 @@ kotlinTest = "2.3.21"
core = "1.7.0" core = "1.7.0"
mavenPublish = "0.36.0" mavenPublish = "0.36.0"
sqlite = "2.6.2" sqlite = "2.6.2"
ktor = "3.4.1"
fourkoma = "1.2.0"
[libraries] [libraries]
abedElazizShe-video-compressor-fork = { group = "com.github.davotoula", name = "LightCompressor-enhanced", version.ref = "lightcompressor-enhanced" } abedElazizShe-video-compressor-fork = { group = "com.github.davotoula", name = "LightCompressor-enhanced", version.ref = "lightcompressor-enhanced" }
@@ -175,6 +177,10 @@ negentropy-kmp = { module = "com.vitorpamplona.negentropy:kmp-negentropy", versi
net-thauvin-erik-urlencoder-lib = { module = "net.thauvin.erik.urlencoder:urlencoder-lib", version.ref = "netUrlencoderLibVersion" } net-thauvin-erik-urlencoder-lib = { module = "net.thauvin.erik.urlencoder:urlencoder-lib", version.ref = "netUrlencoderLibVersion" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttpCoroutines = { group = "com.squareup.okhttp3", name = "okhttp-coroutines", version.ref = "okhttp" } okhttpCoroutines = { group = "com.squareup.okhttp3", name = "okhttp-coroutines", version.ref = "okhttp" }
ktor-server-core = { group = "io.ktor", name = "ktor-server-core", version.ref = "ktor" }
ktor-server-cio = { group = "io.ktor", name = "ktor-server-cio", version.ref = "ktor" }
ktor-server-websockets = { group = "io.ktor", name = "ktor-server-websockets", version.ref = "ktor" }
fourkoma = { module = "cc.ekblad:4koma", version.ref = "fourkoma" }
secp256k1-kmp-common = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp", version.ref = "secp256k1KmpJniAndroid" } secp256k1-kmp-common = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp", version.ref = "secp256k1KmpJniAndroid" }
secp256k1-kmp-jni-android = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-android", version.ref = "secp256k1KmpJniAndroid" } secp256k1-kmp-jni-android = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-android", version.ref = "secp256k1KmpJniAndroid" }
secp256k1-kmp-jni-jvm = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-jvm", version.ref = "secp256k1KmpJniAndroid" } secp256k1-kmp-jni-jvm = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-jvm", version.ref = "secp256k1KmpJniAndroid" }
@@ -1,151 +0,0 @@
# Stream priority for moq-lite group uni streams (T11.3 follow-up)
**Status**: deferred — flagged in T11 (drop `bestEffort=true`), not landed.
## Why
`T11` removed `bestEffort=true` from `MoqLiteSession.openGroupStream`
because the QUIC contract it relied on (drop lost ranges silently
without `RESET_STREAM`) created undeliverable streams that wedge
peer reassembly buffers — the actual user-visible bug was a 30 s
silent dropout per lost packet on lossy networks.
`bestEffort=true` was incidentally providing a "newer-groups-skip-
queued-retransmits" effect: the writer never queued retransmits in
the first place, so under congestion the loss budget naturally
biased toward dropping older lost ranges. With `bestEffort=true`
gone, all retransmits are now queued, and under sustained
congestion the writer drains streams in `streamRoundRobinStart`
order — which can mean the listener catches up on a stale group
when a fresh one would be more useful.
The kixelated reference (`moq-rs`'s `Publisher::serve_group` in
`rs/moq-lite/src/lite/publisher.rs:347-406`) addresses this by
calling `stream.set_priority(priority.current())` on every group
stream and biasing the writer toward higher-priority (newer)
streams. We should do the same.
## Target shape
### `:quic` API
Add a stable priority hook to `QuicStream`:
```kotlin
class QuicStream(...) {
@Volatile
var priority: Int = 0
// Higher drains first under contention. Default 0 = unchanged
// round-robin behaviour.
}
```
Expose at the WebTransport layer:
```kotlin
interface WebTransportWriteStream {
fun setPriority(priority: Int)
}
```
### `QuicConnectionWriter` — the load-bearing change
Today's send-frame loop (`QuicConnectionWriter.kt:411-416`):
```kotlin
val streamsView = conn.streamsListLocked()
val start = conn.streamRoundRobinStart % streamsView.size
for (i in streamsView.indices) {
val stream = streamsView[(start + i) % streamsView.size]
// ...
}
```
Replace with priority-then-round-robin:
```kotlin
val streamsView = conn.streamsListLocked()
val sorted = streamsView.sortedByDescending { it.priority }
val start = conn.streamRoundRobinStart % sorted.size
for (i in sorted.indices) {
val stream = sorted[(start + i) % sorted.size]
// ...
}
```
Sort is stable; same-priority streams retain insertion order, so the
existing round-robin behaviour holds within a priority tier. Higher-
priority streams always come first in the iteration.
Cost: O(N log N) per drain pass, where N is active local-initiated
streams. N is small (110 in the moq-lite audio path); the
allocation of `sorted` per pass is the only real cost. If that
shows up in profiling, switch to `kotlin.collections.IntArray`-
backed indirect sort or maintain a priority-sorted list incrementally
on `setPriority` calls.
### moq-lite wiring
`MoqLiteSession.openGroupStream:1022-1037`:
```kotlin
internal suspend fun openGroupStream(
subscribeId: Long,
sequence: Long,
): WebTransportWriteStream {
val uni = transport.openUniStream()
uni.setPriority(sequence.coerceAtMost(Int.MAX_VALUE.toLong()).toInt())
uni.write(Varint.encode(MoqLiteDataType.Group.code))
uni.write(MoqLiteCodec.encodeGroupHeader(MoqLiteGroupHeader(subscribeId, sequence)))
return uni
}
```
Newer groups have higher sequence → higher priority → drain first
under congestion. The saturation conversion handles broadcasts that
run long enough for `sequence` to exceed `Int.MAX_VALUE` (≈ 71
years at our 1 group/sec production cadence; defensive only).
## Test
Add a unit test in `:quic` that builds a `QuicConnection`, opens
two streams, sets `.priority = 0` on the first and `.priority = 1`
on the second, queues bytes on both, and verifies the higher-
priority stream's bytes hit the wire first. Doesn't need to fake
real flow-control backpressure — pinning the iteration order via
the writer's emitted-frames tape is sufficient to catch the
regression case ("a later refactor accidentally re-introduces
round-robin order").
A second test in `:nestsClient` that opens 3 group streams and
asserts later-sequence streams drain before earlier ones is
nice-to-have but secondary; the `:quic`-level test pins the load-
bearing invariant.
## Why deferred
The change is small in lines but touches the QUIC writer's hot
path. Rebasing a future `:quic` retransmit / pacing change onto a
priority-sorted iteration order is doable but the diff conflicts
get noisy. Better landed as a focused PR after the current
interop-work series stabilises.
Risk profile:
- **Bugs**: subtle starvation risk if a high-priority stream
always has `streamRemaining > 0` — round-robin tiebreaker within
a priority tier mitigates this for same-priority case, but the
cross-tier case needs deliberate thought (do we want strict
priority or weighted?).
- **Performance**: per-pass sort allocation. Negligible for N≤10
but worth measuring if N grows.
- **Compat**: streams without an explicit priority default to 0,
matching today's behaviour. Existing tests should pass unchanged.
## When to land
After the catalog interop series is fully verified (production
audio with no degradation under realistic conditions), and ideally
alongside a `:quic` perf review pass that touches the same code
path. Not blocking on any audio bug today — `T11`'s reliable-
delivery fix is the actual correctness change; this is the
spec-aligned hardening.
@@ -66,12 +66,14 @@ class AudioRecordCapture(
check(!stopped) { "capture already stopped" } check(!stopped) { "capture already stopped" }
if (record != null) return if (record != null) return
val channelMask = // Microphone capture is mono. The platform mic is a single-channel
when (AudioFormat.CHANNELS) { // device (or a fixed multi-mic array routed through a mono mixdown
1 -> AndroidAudioFormat.CHANNEL_IN_MONO // by the AEC stack), so this isn't a per-stream configurable —
2 -> AndroidAudioFormat.CHANNEL_IN_STEREO // there's no "stereo mic" path to take. Stereo broadcasts
else -> error("unsupported channel count ${AudioFormat.CHANNELS}") // synthesise their second channel elsewhere (e.g. a stereo audio
} // capture for music playback) and bypass [AudioRecordCapture]
// entirely.
val channelMask = AndroidAudioFormat.CHANNEL_IN_MONO
val minBuffer = val minBuffer =
AudioRecord.getMinBufferSize( AudioRecord.getMinBufferSize(
@@ -83,10 +83,11 @@ class AudioTrackPlayer(
* match the [com.vitorpamplona.nestsclient.audio.OpusDecoder]'s output * match the [com.vitorpamplona.nestsclient.audio.OpusDecoder]'s output
* configuration (mono Opus → 1, stereo Opus → 2 with L/R interleaving). * configuration (mono Opus → 1, stereo Opus → 2 with L/R interleaving).
* Drives both the AudioTrack channel mask and the underlying buffer- * Drives both the AudioTrack channel mask and the underlying buffer-
* size target. Default is [AudioFormat.CHANNELS] (mono) so existing * size target. Default is [AudioFormat.DEFAULT_CHANNELS] (mono) so
* call sites that don't pass a channel count keep the prior behaviour. * existing call sites that don't pass a channel count keep the prior
* behaviour.
*/ */
private val channelCount: Int = AudioFormat.CHANNELS, private val channelCount: Int = AudioFormat.DEFAULT_CHANNELS,
/** /**
* PCM sample rate in Hz. Drives the AudioTrack output rate and the * PCM sample rate in Hz. Drives the AudioTrack output rate and the
* 250 ms target-buffer calculation. For Opus, Android's Codec2 * 250 ms target-buffer calculation. For Opus, Android's Codec2
@@ -46,11 +46,11 @@ import java.nio.ByteOrder
* L,R interleaving). * L,R interleaving).
* - CSD-1 / CSD-2: pre-skip + seek pre-roll, both zero (we don't seek). * - CSD-1 / CSD-2: pre-skip + seek pre-roll, both zero (we don't seek).
* *
* Default is [AudioFormat.CHANNELS] (mono) so existing call sites that * Default is [AudioFormat.DEFAULT_CHANNELS] (mono) so existing call sites
* don't pass a channel count continue to behave exactly as before. * that don't pass a channel count continue to behave exactly as before.
*/ */
class MediaCodecOpusDecoder( class MediaCodecOpusDecoder(
private val channelCount: Int = AudioFormat.CHANNELS, private val channelCount: Int = AudioFormat.DEFAULT_CHANNELS,
/** /**
* Source sample rate in Hz. Drives the OpusHead `inputSampleRate` * Source sample rate in Hz. Drives the OpusHead `inputSampleRate`
* field and the MediaFormat `audio/opus` sample-rate hint. * field and the MediaFormat `audio/opus` sample-rate hint.
@@ -31,18 +31,33 @@ import java.nio.ByteOrder
* encoder shipped later). One instance per outgoing track. * encoder shipped later). One instance per outgoing track.
* *
* Configuration: * Configuration:
* - 48 kHz mono input PCM 16-bit (matches [AudioFormat]). * - 48 kHz PCM 16-bit input (matches [AudioFormat.SAMPLE_RATE_HZ]).
* - [channelCount] — 1 (mono) or 2 (stereo, L/R interleaved). Drives
* the MediaFormat channel count; the supplied PCM frame must hold
* `FRAME_SIZE_SAMPLES * channelCount` samples per call.
* - Target bitrate ~32 kbit/s VBR — high-quality wideband speech. * - Target bitrate ~32 kbit/s VBR — high-quality wideband speech.
* - 20 ms frames (the encoder requires the input buffer to hold one frame * - 20 ms frames (the encoder requires the input buffer to hold one frame
* at a time for low latency). * at a time for low latency).
*
* Default is [AudioFormat.DEFAULT_CHANNELS] (mono) so existing call sites
* that don't pass a channel count keep the prior behaviour. Pair with a
* matching [com.vitorpamplona.nestsclient.AudioBroadcastConfig] on the
* speaker so the published catalog declares the same channel count.
*/ */
class MediaCodecOpusEncoder( class MediaCodecOpusEncoder(
private val channelCount: Int = AudioFormat.DEFAULT_CHANNELS,
private val targetBitrate: Int = DEFAULT_BITRATE_BPS, private val targetBitrate: Int = DEFAULT_BITRATE_BPS,
) : OpusEncoder { ) : OpusEncoder {
init {
require(channelCount in 1..2) {
"MediaCodecOpusEncoder supports mono (1) or stereo (2) only, got $channelCount"
}
}
private val codec: MediaCodec = private val codec: MediaCodec =
try { try {
MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_OPUS).apply { MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_OPUS).apply {
configure(buildFormat(targetBitrate), null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) configure(buildFormat(channelCount, targetBitrate), null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
start() start()
} }
} catch (t: Throwable) { } catch (t: Throwable) {
@@ -195,12 +210,15 @@ class MediaCodecOpusEncoder(
*/ */
private const val MAX_CSD_SKIPS_PER_CALL: Int = 4 private const val MAX_CSD_SKIPS_PER_CALL: Int = 4
private fun buildFormat(bitrate: Int): MediaFormat = private fun buildFormat(
channelCount: Int,
bitrate: Int,
): MediaFormat =
MediaFormat MediaFormat
.createAudioFormat( .createAudioFormat(
MediaFormat.MIMETYPE_AUDIO_OPUS, MediaFormat.MIMETYPE_AUDIO_OPUS,
AudioFormat.SAMPLE_RATE_HZ, AudioFormat.SAMPLE_RATE_HZ,
AudioFormat.CHANNELS, channelCount,
).apply { ).apply {
setInteger(MediaFormat.KEY_BIT_RATE, bitrate) setInteger(MediaFormat.KEY_BIT_RATE, bitrate)
setInteger(MediaFormat.KEY_PCM_ENCODING, android.media.AudioFormat.ENCODING_PCM_16BIT) setInteger(MediaFormat.KEY_PCM_ENCODING, android.media.AudioFormat.ENCODING_PCM_16BIT)
@@ -0,0 +1,62 @@
/*
* 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.nestsclient
/**
* Per-broadcast audio shape negotiated between the speaker, the catalog
* the relay forwards, and the listeners that subscribe to the audio
* track.
*
* Threaded through [connectNestsSpeaker] / [connectReconnectingNestsSpeaker]
* into [MoqLiteNestsSpeaker.startBroadcasting] so a stereo broadcaster
* can pick a stereo Opus rendition without forcing every existing mono
* call site to grow a parameter. The catalog factory in
* [com.vitorpamplona.nestsclient.moq.lite.MoqLiteHangCatalog.opus48kJsonBytes]
* keys on this shape so the JSON wire bytes stay byte-stable per shape.
*
* **Caller contract:** [channelCount] MUST match the channel count of
* the [com.vitorpamplona.nestsclient.audio.OpusEncoder] the caller
* provides via the speaker's `encoderFactory` AND the channel count of
* the PCM frames the caller's [com.vitorpamplona.nestsclient.audio.AudioCapture]
* produces. Mismatches surface as decoder errors on the listener side
* (CSD-0 channel byte vs interleaved PCM layout disagree) and produce
* either silence or downmix-with-clicks depending on the listener
* implementation. There is no in-broadcast renegotiation — pick the
* shape at speaker open time.
*
* Default is mono (the historical shape). Callers that don't pass a
* config keep the prior behaviour.
*
* @property channelCount 1 (mono) or 2 (stereo, L/R interleaved).
* Drives the catalog's `numberOfChannels` field. Multi-rendition
* catalogs (e.g. one mono + one stereo on the same broadcast) are
* out of scope here — model that as two separate catalog factories
* if it becomes useful.
*/
data class AudioBroadcastConfig(
val channelCount: Int = 1,
) {
init {
require(channelCount in 1..2) {
"AudioBroadcastConfig supports mono (1) or stereo (2) only, got $channelCount"
}
}
}
@@ -61,6 +61,17 @@ class MoqLiteNestsSpeaker internal constructor(
* Defaults to [NestMoqLiteBroadcaster.DEFAULT_FRAMES_PER_GROUP]. * Defaults to [NestMoqLiteBroadcaster.DEFAULT_FRAMES_PER_GROUP].
*/ */
private val framesPerGroup: Int = NestMoqLiteBroadcaster.DEFAULT_FRAMES_PER_GROUP, private val framesPerGroup: Int = NestMoqLiteBroadcaster.DEFAULT_FRAMES_PER_GROUP,
/**
* Per-broadcast audio shape (channel count, future bitrate
* variants). Threaded into the catalog payload the speaker emits
* on the `catalog.json` track, so listeners see the shape the
* caller's encoder actually produces. Caller MUST construct the
* encoder + capture with a matching channel layout — see
* [AudioBroadcastConfig] for the contract. Defaults to mono so
* existing call sites that don't pass a config keep the prior
* behaviour.
*/
private val broadcastConfig: AudioBroadcastConfig = AudioBroadcastConfig(),
) : NestsSpeaker, ) : NestsSpeaker,
HotSwappablePublisherSource { HotSwappablePublisherSource {
override val state: StateFlow<NestsSpeakerState> = mutableState.asStateFlow() override val state: StateFlow<NestsSpeakerState> = mutableState.asStateFlow()
@@ -157,7 +168,11 @@ class MoqLiteNestsSpeaker internal constructor(
// practice the relay's SUBSCRIBE bidi takes a network // practice the relay's SUBSCRIBE bidi takes a network
// round-trip after our ANNOUNCE Active, so this is safe // round-trip after our ANNOUNCE Active, so this is safe
// even though the setter is non-suspending. // even though the setter is non-suspending.
val catalogJson = MoqLiteHangCatalog.OPUS_MONO_48K_AUDIO_DATA_JSON_BYTES val catalogJson =
MoqLiteHangCatalog.opus48kJsonBytes(
audioTrackName = MoqLiteNestsListener.AUDIO_TRACK,
numberOfChannels = broadcastConfig.channelCount,
)
catalogPublisher.setOnNewSubscriber { catalogPublisher.setOnNewSubscriber {
runCatching { runCatching {
catalogPublisher.send(catalogJson) catalogPublisher.send(catalogJson)
@@ -188,6 +188,13 @@ suspend fun connectNestsSpeaker(
framesPerGroup: Int = framesPerGroup: Int =
com.vitorpamplona.nestsclient.audio com.vitorpamplona.nestsclient.audio
.NestMoqLiteBroadcaster.DEFAULT_FRAMES_PER_GROUP, .NestMoqLiteBroadcaster.DEFAULT_FRAMES_PER_GROUP,
/**
* Per-broadcast audio shape. Defaults to mono so existing call sites
* keep the prior behaviour. Caller is responsible for matching the
* channel count to the encoder + capture they pass — see
* [AudioBroadcastConfig].
*/
broadcastConfig: AudioBroadcastConfig = AudioBroadcastConfig(),
): NestsSpeaker { ): NestsSpeaker {
val state = val state =
MutableStateFlow<NestsSpeakerState>( MutableStateFlow<NestsSpeakerState>(
@@ -252,6 +259,7 @@ suspend fun connectNestsSpeaker(
scope = scope, scope = scope,
mutableState = state, mutableState = state,
framesPerGroup = framesPerGroup, framesPerGroup = framesPerGroup,
broadcastConfig = broadcastConfig,
) )
} }
@@ -103,6 +103,13 @@ suspend fun connectReconnectingNestsSpeaker(
speakerPubkeyHex: String, speakerPubkeyHex: String,
captureFactory: () -> AudioCapture, captureFactory: () -> AudioCapture,
encoderFactory: () -> OpusEncoder, encoderFactory: () -> OpusEncoder,
/**
* Per-broadcast audio shape, threaded into [connectNestsSpeaker]
* (and therefore into the catalog payload) AND used by the
* hot-swap pump's per-session catalog publisher so the wire shape
* stays consistent across JWT-refresh recycles. Defaults to mono.
*/
broadcastConfig: AudioBroadcastConfig = AudioBroadcastConfig(),
policy: NestsReconnectPolicy = NestsReconnectPolicy(), policy: NestsReconnectPolicy = NestsReconnectPolicy(),
/** /**
* Proactive JWT refresh window. moq-auth issues bearer tokens * Proactive JWT refresh window. moq-auth issues bearer tokens
@@ -135,6 +142,7 @@ suspend fun connectReconnectingNestsSpeaker(
speakerPubkeyHex = speakerPubkeyHex, speakerPubkeyHex = speakerPubkeyHex,
captureFactory = captureFactory, captureFactory = captureFactory,
encoderFactory = encoderFactory, encoderFactory = encoderFactory,
broadcastConfig = broadcastConfig,
) )
}, },
): NestsSpeaker { ): NestsSpeaker {
@@ -292,6 +300,7 @@ suspend fun connectReconnectingNestsSpeaker(
scope = scope, scope = scope,
captureFactory = captureFactory, captureFactory = captureFactory,
encoderFactory = encoderFactory, encoderFactory = encoderFactory,
broadcastConfig = broadcastConfig,
) )
} }
@@ -320,6 +329,7 @@ private class ReconnectingSpeakerHandle(
*/ */
private val captureFactory: () -> AudioCapture, private val captureFactory: () -> AudioCapture,
private val encoderFactory: () -> OpusEncoder, private val encoderFactory: () -> OpusEncoder,
private val broadcastConfig: AudioBroadcastConfig,
) : NestsSpeaker { ) : NestsSpeaker {
override val state: StateFlow<NestsSpeakerState> = mutableState.asStateFlow() override val state: StateFlow<NestsSpeakerState> = mutableState.asStateFlow()
@@ -350,6 +360,7 @@ private class ReconnectingSpeakerHandle(
scope = scope, scope = scope,
captureFactory = captureFactory, captureFactory = captureFactory,
encoderFactory = encoderFactory, encoderFactory = encoderFactory,
broadcastConfig = broadcastConfig,
onLevel = onLevel, onLevel = onLevel,
onClose = { closed -> onClose = { closed ->
if (activeBroadcast === closed) activeBroadcast = null if (activeBroadcast === closed) activeBroadcast = null
@@ -412,6 +423,14 @@ private class ReissuingBroadcastHandle(
private val scope: CoroutineScope, private val scope: CoroutineScope,
private val captureFactory: () -> AudioCapture, private val captureFactory: () -> AudioCapture,
private val encoderFactory: () -> OpusEncoder, private val encoderFactory: () -> OpusEncoder,
/**
* Per-broadcast audio shape, used to pick the catalog payload the
* per-session catalog publisher emits. The shape is constant for
* the wrapper's lifetime (we don't renegotiate mid-broadcast), so
* caching the payload bytes once on the [opus48kJsonBytes]
* memoiser is enough.
*/
private val broadcastConfig: AudioBroadcastConfig,
/** /**
* Forwarded to the underlying broadcaster (hot-swap path) or * Forwarded to the underlying broadcaster (hot-swap path) or
* `sp.startBroadcasting` (legacy path) so the local-speaking ring * `sp.startBroadcasting` (legacy path) so the local-speaking ring
@@ -586,7 +605,11 @@ private class ReissuingBroadcastHandle(
// and any watcher that attaches AFTER the recycle sees nothing // and any watcher that attaches AFTER the recycle sees nothing
// to subscribe to. Mirror of [MoqLiteNestsSpeaker.startBroadcasting]'s // to subscribe to. Mirror of [MoqLiteNestsSpeaker.startBroadcasting]'s
// catalog setup; same JSON, same emit-on-subscribe pattern. // catalog setup; same JSON, same emit-on-subscribe pattern.
val catalogPayload = MoqLiteHangCatalog.OPUS_MONO_48K_AUDIO_DATA_JSON_BYTES val catalogPayload =
MoqLiteHangCatalog.opus48kJsonBytes(
audioTrackName = MoqLiteNestsListener.AUDIO_TRACK,
numberOfChannels = broadcastConfig.channelCount,
)
val priorCatalogPublisher = hotSwapCatalogPublisher val priorCatalogPublisher = hotSwapCatalogPublisher
val newCatalogPublisher = val newCatalogPublisher =
try { try {
@@ -23,15 +23,27 @@ package com.vitorpamplona.nestsclient.audio
/** /**
* PCM audio format the audio pipeline produces and consumes. * PCM audio format the audio pipeline produces and consumes.
* *
* Listener-only flow runs at 48 kHz mono signed-16-bit, matching the nests * Sample rate + frame cadence are pipeline-wide invariants (48 kHz Opus,
* Opus profile (RFC 6716 wideband at the codec's native rate). The whole * 20 ms frames) and live as flat constants. Channel count is **per-stream**:
* pipeline is hardcoded to this format for now — when nests starts varying * a microphone is mono, a stereo broadcast is two-channel, and a single
* codec settings, this becomes a per-room negotiated value out of the * listener may simultaneously decode tracks of different shapes. Call sites
* `/api/v1/nests/<room>` response. * either inline `1` (the device is intrinsically mono — e.g. the production
* mic) or take a `channelCount` parameter, plumbed in from a catalog (on
* the listener side) or from an [com.vitorpamplona.nestsclient.AudioBroadcastConfig]
* (on the speaker side). [DEFAULT_CHANNELS] is what call sites use as the
* factory default when they want the historical mono behaviour.
*/ */
object AudioFormat { object AudioFormat {
const val SAMPLE_RATE_HZ: Int = 48_000 const val SAMPLE_RATE_HZ: Int = 48_000
const val CHANNELS: Int = 1
/**
* Default channel count for call sites that don't yet thread a
* per-stream override through. Mono — matches what the pipeline
* shipped before stereo support landed. Use this only as a default
* value; do NOT assume every audio track is mono just because this
* exists.
*/
const val DEFAULT_CHANNELS: Int = 1
/** 20 ms at 48 kHz. */ /** 20 ms at 48 kHz. */
const val FRAME_SIZE_SAMPLES: Int = 960 const val FRAME_SIZE_SAMPLES: Int = 960
@@ -106,42 +106,27 @@ internal data class MoqLiteHangCatalog(
explicitNulls = false explicitNulls = false
} }
/**
* Cached canonical-shape catalog JSON bytes for the default
* Opus mono 48 kHz audio track ([MoqLiteNestsListener.AUDIO_TRACK]
* keyed under `audio.renditions["audio/data"]`). The catalog is
* a fixed string for the whole publisher lifetime — caching
* avoids re-running kotlinx.serialization on every
* [com.vitorpamplona.nestsclient.MoqLiteNestsSpeaker.startBroadcasting]
* call and every JWT-refresh hot-swap iteration in
* [com.vitorpamplona.nestsclient.connectReconnectingNestsSpeaker].
*
* Hard-coded to track name `"audio/data"` because that's the
* only track Amethyst publishes today; if a future caller
* needs a different name, fall back to
* [opusMono48k] + [encodeJsonBytes].
*/
val OPUS_MONO_48K_AUDIO_DATA_JSON_BYTES: ByteArray =
opusMono48k("audio/data").encodeJsonBytes()
/** /**
* Canonical Amethyst speaker catalog: a single `legacy`-container * Canonical Amethyst speaker catalog: a single `legacy`-container
* Opus rendition under [audioTrackName], matching the encoder * Opus rendition under [audioTrackName] at 48 kHz with
* config in [com.vitorpamplona.nestsclient.audio.OpusEncoder] * [numberOfChannels] (1 = mono, 2 = stereo L/R interleaved),
* (48 kHz mono). * matching the encoder config in
* [com.vitorpamplona.nestsclient.audio.OpusEncoder].
* *
* The rendition map is keyed by the moq-lite track name a * The rendition map is keyed by the moq-lite track name a
* subscriber should subscribe to for this rendition's frames — * subscriber should subscribe to for this rendition's frames —
* for nests audio rooms that's the same string the publisher * for nests audio rooms that's the same string the publisher
* publishes audio frames on * publishes audio frames on
* (`MoqLiteNestsListener.AUDIO_TRACK`). * (`MoqLiteNestsListener.AUDIO_TRACK`).
*
* If [com.vitorpamplona.nestsclient.audio.OpusEncoder] becomes
* parameterised in the future, this factory should take the
* encoder's config rather than hard-coding 48 kHz mono.
*/ */
fun opusMono48k(audioTrackName: String): MoqLiteHangCatalog = fun opus48k(
MoqLiteHangCatalog( audioTrackName: String,
numberOfChannels: Int = 1,
): MoqLiteHangCatalog {
require(numberOfChannels in 1..2) {
"opus48k supports mono (1) or stereo (2) only, got $numberOfChannels"
}
return MoqLiteHangCatalog(
audio = audio =
Audio( Audio(
renditions = renditions =
@@ -151,12 +136,49 @@ internal data class MoqLiteHangCatalog(
codec = "opus", codec = "opus",
container = Container(kind = "legacy"), container = Container(kind = "legacy"),
sampleRate = 48_000, sampleRate = 48_000,
numberOfChannels = 1, numberOfChannels = numberOfChannels,
jitter = OPUS_FRAME_DURATION_MS, jitter = OPUS_FRAME_DURATION_MS,
), ),
), ),
), ),
) )
}
/**
* Memoised JSON bytes for [opus48k]. The catalog is a fixed
* string for the whole publisher lifetime — caching avoids
* re-running kotlinx.serialization on every
* [com.vitorpamplona.nestsclient.MoqLiteNestsSpeaker.startBroadcasting]
* call and every JWT-refresh hot-swap iteration in
* [com.vitorpamplona.nestsclient.connectReconnectingNestsSpeaker].
*
* Keyed by `(trackName, numberOfChannels)` so adding a new shape
* (stereo, future bitrate variants) doesn't multiply the
* constant count. The cache is populated on first request per
* shape and never evicted — at most a handful of entries per
* process lifetime, all small.
*
* Thread-safety: the operation is idempotent — two threads
* computing the same shape land identical bytes — so a
* non-locking [HashMap] is acceptable in commonMain. Worst case
* the second writer overwrites with an equal value; cache
* readers always see at least one fully-published value
* because the JVM's default visibility for non-volatile object
* references is good enough for the "may compute twice"
* tolerance we have here.
*/
private val cachedJsonBytes: HashMap<Pair<String, Int>, ByteArray> = HashMap()
fun opus48kJsonBytes(
audioTrackName: String,
numberOfChannels: Int = 1,
): ByteArray {
val key = audioTrackName to numberOfChannels
cachedJsonBytes[key]?.let { return it }
val bytes = opus48k(audioTrackName, numberOfChannels).encodeJsonBytes()
cachedJsonBytes[key] = bytes
return bytes
}
/** /**
* Opus frame duration in milliseconds — 960 samples / 48 kHz = * Opus frame duration in milliseconds — 960 samples / 48 kHz =
@@ -1037,6 +1037,13 @@ class MoqLiteSession internal constructor(
// 150 ms late still falls inside hang's default ~200 ms // 150 ms late still falls inside hang's default ~200 ms
// jitter buffer) and avoids the dropout entirely. // jitter buffer) and avoids the dropout entirely.
val uni = transport.openUniStream() val uni = transport.openUniStream()
// Mirror moq-rs `Publisher::serve_group`
// (`rs/moq-lite/src/lite/publisher.rs`): newer groups get higher
// priority so the QUIC writer drains fresh audio first when
// retransmits queue up under loss. Saturating cast guards a
// theoretical broadcast that runs long enough for `sequence` to
// exceed Int.MAX_VALUE (≈ 71 years at 1 group/sec); defensive only.
uni.setPriority(sequence.coerceAtMost(Int.MAX_VALUE.toLong()).toInt())
uni.write(Varint.encode(MoqLiteDataType.Group.code)) uni.write(Varint.encode(MoqLiteDataType.Group.code))
uni.write(MoqLiteCodec.encodeGroupHeader(MoqLiteGroupHeader(subscribeId, sequence))) uni.write(MoqLiteCodec.encodeGroupHeader(MoqLiteGroupHeader(subscribeId, sequence)))
return uni return uni
@@ -162,6 +162,8 @@ class FakeBidiStream internal constructor(
override suspend fun finish() { override suspend fun finish() {
write.close() write.close()
} }
override fun setPriority(priority: Int) = Unit
} }
class FakeReadStream internal constructor( class FakeReadStream internal constructor(
@@ -186,4 +188,6 @@ private class ChannelWriteStream(
override suspend fun finish() { override suspend fun finish() {
channel.close() channel.close()
} }
override fun setPriority(priority: Int) = Unit
} }
@@ -112,6 +112,22 @@ interface WebTransportWriteStream {
/** Half-close the write side (FIN). No further writes after this call. */ /** Half-close the write side (FIN). No further writes after this call. */
suspend fun finish() suspend fun finish()
/**
* Hint to the transport about this stream's drain priority relative
* to other streams on the same session. Higher value drains first
* under congestion; same-priority streams keep round-robin order.
* Default 0 = unchanged round-robin behaviour.
*
* moq-lite uses this to bias the writer toward newer group streams
* (sequence-numbered, fresher audio) so a backlog of retransmits on
* an older group doesn't starve the listener of fresh frames. See
* `Publisher::serve_group` in `rs/moq-lite/src/lite/publisher.rs`.
*
* Implementations that don't model priority (e.g. the in-memory
* fake) MAY treat this as a no-op.
*/
fun setPriority(priority: Int)
} }
/** /**
@@ -25,7 +25,7 @@ import kotlin.test.assertEquals
class MoqLiteHangCatalogTest { class MoqLiteHangCatalogTest {
@Test @Test
fun opusMono48kEmitsCanonicalHangShape() { fun opus48kMonoEmitsCanonicalHangShape() {
// Byte-exact assertion: `:commons`'s `RoomSpeakerCatalogTest` // Byte-exact assertion: `:commons`'s `RoomSpeakerCatalogTest`
// round-trips this same string against the parser. If either // round-trips this same string against the parser. If either
// side drifts from the kixelated/hang wire shape, both tests // side drifts from the kixelated/hang wire shape, both tests
@@ -35,17 +35,49 @@ class MoqLiteHangCatalogTest {
"\"codec\":\"opus\",\"container\":{\"kind\":\"legacy\"}," + "\"codec\":\"opus\",\"container\":{\"kind\":\"legacy\"}," +
"\"sampleRate\":48000,\"numberOfChannels\":1,\"jitter\":20}}}}" "\"sampleRate\":48000,\"numberOfChannels\":1,\"jitter\":20}}}}"
val actual = val actual =
MoqLiteHangCatalog.opusMono48k("audio/data").encodeJsonBytes().decodeToString() MoqLiteHangCatalog.opus48k("audio/data").encodeJsonBytes().decodeToString()
assertEquals(expected, actual)
}
@Test
fun opus48kStereoEmitsTwoChannelHangShape() {
// Stereo broadcasts (kixelated/moq web publisher with a stereo
// AudioContext) declare numberOfChannels = 2. Listeners pick
// this up via the catalog and configure their decoder + sink
// for L/R interleaved PCM.
val expected =
"{\"audio\":{\"renditions\":{\"audio/data\":{" +
"\"codec\":\"opus\",\"container\":{\"kind\":\"legacy\"}," +
"\"sampleRate\":48000,\"numberOfChannels\":2,\"jitter\":20}}}}"
val actual =
MoqLiteHangCatalog
.opus48k("audio/data", numberOfChannels = 2)
.encodeJsonBytes()
.decodeToString()
assertEquals(expected, actual) assertEquals(expected, actual)
} }
@Test @Test
fun renditionKeyMatchesCallerSuppliedTrackName() { fun renditionKeyMatchesCallerSuppliedTrackName() {
val actual = val actual =
MoqLiteHangCatalog.opusMono48k("custom/track").encodeJsonBytes().decodeToString() MoqLiteHangCatalog.opus48k("custom/track").encodeJsonBytes().decodeToString()
// The rendition map MUST be keyed on the caller-supplied track // The rendition map MUST be keyed on the caller-supplied track
// name — the watcher uses this string verbatim as the // name — the watcher uses this string verbatim as the
// SUBSCRIBE.track on the audio subscription. // SUBSCRIBE.track on the audio subscription.
assertEquals(true, actual.contains("\"custom/track\":{")) assertEquals(true, actual.contains("\"custom/track\":{"))
} }
@Test
fun opus48kJsonBytesMemoisesPerShape() {
// Same shape → same byte array reference (fast-path on every
// subsequent broadcast / hot-swap iteration).
val a = MoqLiteHangCatalog.opus48kJsonBytes("audio/data", 1)
val b = MoqLiteHangCatalog.opus48kJsonBytes("audio/data", 1)
assertEquals(true, a === b)
// Different shape → different bytes; both stay cached.
val stereo = MoqLiteHangCatalog.opus48kJsonBytes("audio/data", 2)
assertEquals(true, a !== stereo)
assertEquals(true, stereo === MoqLiteHangCatalog.opus48kJsonBytes("audio/data", 2))
}
} }
@@ -365,6 +365,10 @@ private class QuicBidiStreamAdapter(
stream.send.finish() stream.send.finish()
driver.wakeup() driver.wakeup()
} }
override fun setPriority(priority: Int) {
stream.priority = priority
}
} }
private class QuicReadStreamAdapter( private class QuicReadStreamAdapter(
@@ -392,6 +396,10 @@ private class QuicUniWriteStreamAdapter(
stream.send.finish() stream.send.finish()
driver.wakeup() driver.wakeup()
} }
override fun setPriority(priority: Int) {
stream.priority = priority
}
} }
/** Adapter for a WT peer-initiated uni stream whose prefix has been stripped. */ /** Adapter for a WT peer-initiated uni stream whose prefix has been stripped. */
@@ -431,4 +439,14 @@ private class StrippedWtBidiStreamAdapter(
?: error("peer-initiated bidi stream has no finish — demux didn't wire one") ?: error("peer-initiated bidi stream has no finish — demux didn't wire one")
finish() finish()
} }
/**
* No-op: peer-initiated bidi streams arrive through the demux as a
* [com.vitorpamplona.quic.webtransport.StrippedWtStream] which exposes
* only `send`/`finish` closures, not the underlying [QuicStream]. The
* moq-lite priority use case targets locally-opened uni group streams
* only, so this path doesn't need to model priority — see the
* [WebTransportWriteStream.setPriority] contract.
*/
override fun setPriority(priority: Int) = Unit
} }
+17
View File
@@ -180,6 +180,14 @@ kotlin {
dependencies { dependencies {
implementation(libs.kotlin.test) implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test) implementation(libs.kotlinx.coroutines.test)
// In-process Nostr relay (geode) so JVM/Android host
// tests don't need network access or a Rust toolchain.
// testFixtures (RelayClientTest base, fixtures,
// collectUntilEose) are wired below at the top-level
// `dependencies` block — the KMP source-set DSL
// doesn't expose the `testFixtures(...)` consumer.
implementation(project(":geode"))
} }
} }
@@ -341,6 +349,15 @@ kotlin {
} }
} }
// testFixtures(...) consumer lives outside the KMP source-set DSL —
// the KMP source-set `dependencies { }` block uses
// `KotlinDependencyHandler`, which does not expose the
// `testFixtures(...)` projection. The standard Gradle dependency
// configuration name (`jvmAndroidTestImplementation`) does work here.
dependencies {
"jvmAndroidTestImplementation"(testFixtures(project(":geode")))
}
mavenPublishing { mavenPublishing {
// sources publishing is always enabled by the Kotlin Multiplatform plugin // sources publishing is always enabled by the Kotlin Multiplatform plugin
configure( configure(
@@ -42,7 +42,7 @@ object CountResultKSerializer : KSerializer<CountResult> {
override val descriptor: SerialDescriptor = override val descriptor: SerialDescriptor =
buildClassSerialDescriptor("CountResult") { buildClassSerialDescriptor("CountResult") {
element<Int>("count") element<Int>("count")
element<Boolean>("pubkey") element<Boolean>("approximate")
} }
override fun serialize( override fun serialize(
@@ -56,8 +56,8 @@ object CountResultKSerializer : KSerializer<CountResult> {
fun serializeToElement(value: CountResult): JsonObject = fun serializeToElement(value: CountResult): JsonObject =
buildJsonObject { buildJsonObject {
put("count", value.count) put("count", value.count)
// Matches Jackson's CountResultSerializer which writes "pubkey" for approximate // NIP-45: include "approximate" only when true.
put("pubkey", value.approximate) if (value.approximate) put("approximate", true)
value.hll?.let { put("hll", HyperLogLog.encode(it)) } value.hll?.let { put("hll", HyperLogLog.encode(it)) }
} }
@@ -68,12 +68,10 @@ object MessageKSerializer : KSerializer<Message> {
} }
is OkMessage -> { is OkMessage -> {
// NIP-01 wire format: ["OK", <event_id>, <true|false>, <message>]
add(JsonPrimitive(value.eventId)) add(JsonPrimitive(value.eventId))
// Jackson writes success as a string, not boolean add(JsonPrimitive(value.success))
add(JsonPrimitive(value.success.toString())) add(JsonPrimitive(value.message))
if (value.message.isNotBlank()) {
add(JsonPrimitive(value.message))
}
} }
is AuthMessage -> { is AuthMessage -> {
@@ -90,6 +88,8 @@ object MessageKSerializer : KSerializer<Message> {
} }
is CountMessage -> { is CountMessage -> {
// NIP-45 wire format: ["COUNT", <query_id>, <count_result>]
add(JsonPrimitive(value.queryId))
add(CountResultKSerializer.serializeToElement(value.result)) add(CountResultKSerializer.serializeToElement(value.result))
} }
@@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.onSubscription
/** /**
* A reactive event store that combines historical data retrieval with live event streaming. * A reactive event store that combines historical data retrieval with live event streaming.
@@ -56,19 +57,68 @@ class LiveEventStore(
onEach: (Event) -> Unit, onEach: (Event) -> Unit,
onEose: () -> Unit, onEose: () -> Unit,
) { ) {
// 1. Replay stored events matching filters. // Order matters: register the live collector BEFORE replaying
store.query(filters, onEach) // stored events and signalling EOSE. Otherwise an event emitted
// between EOSE and `collect` is lost because [newEventStream] has
// 2. Signal end of stored events. // replay=0. The race is only occasionally visible for kinds the
onEose() // store persists (insert latency masks it) but fires reliably for
// ephemeral kinds (20000-29999) where insert is a no-op — and
// 3. Stream live events until cancelled. // ephemeral events MUST still reach matching live subscribers per
newEventStream.collect { newEvent -> // NIP-01.
if (filters.any { it.match(newEvent) }) { //
onEach(newEvent) // Side effect of registering the collector first: an event
} // inserted *during* `store.query` will be both replayed by the
// store AND emitted to the live stream. We dedupe by tracking
// ids seen during the historical replay and skipping them on
// the live path. The set is dropped after EOSE so live-only
// events don't accumulate memory.
var inHistoricalPhase = true
var seenIds: HashSet<String>? = HashSet()
val historicalOnEach: (Event) -> Unit = { event ->
seenIds?.add(event.id)
onEach(event)
} }
newEventStream
.onSubscription {
store.query(filters, historicalOnEach)
onEose()
// Free the dedupe set once we've crossed EOSE: from
// here on the live stream is the only source of
// events, so duplicates aren't possible.
inHistoricalPhase = false
seenIds = null
}.collect { newEvent ->
if (inHistoricalPhase && seenIds?.contains(newEvent.id) == true) return@collect
if (filters.any { it.match(newEvent) }) {
onEach(newEvent)
}
}
} }
suspend fun count(filters: List<Filter>) = store.count(filters) suspend fun count(filters: List<Filter>) = store.count(filters)
/**
* One-shot snapshot query. Used by NIP-77 negentropy: the server
* needs the full set of event ids matching the filter at the
* moment the NEG-OPEN arrives, not a streamed/live result.
*/
suspend fun snapshotQuery(filter: Filter): List<Event> = store.query(filter)
/**
* Multi-filter snapshot. Unions the per-filter results and
* deduplicates by event id so an event matching N filters is
* yielded once. Used by NIP-77 NEG-OPEN when the policy stack
* rewrote the single incoming filter into several.
*/
suspend fun snapshotQuery(filters: List<Filter>): List<Event> {
if (filters.size == 1) return snapshotQuery(filters[0])
val seen = HashSet<String>()
val merged = ArrayList<Event>()
for (f in filters) {
for (e in store.query<Event>(f)) {
if (seen.add(e.id)) merged += e
}
}
return merged
}
} }
@@ -0,0 +1,115 @@
/*
* 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.server
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
import com.vitorpamplona.quartz.nip77Negentropy.NegCloseCmd
import com.vitorpamplona.quartz.nip77Negentropy.NegErrMessage
import com.vitorpamplona.quartz.nip77Negentropy.NegMsgCmd
import com.vitorpamplona.quartz.nip77Negentropy.NegOpenCmd
import com.vitorpamplona.quartz.nip77Negentropy.NegentropyServerSession
/**
* Per-connection NIP-77 negentropy state and dispatch.
*
* Owns the map of active reconciliation sessions keyed by NEG-OPEN
* subId, and the open/msg/close handlers. Pulled out of [RelaySession]
* so the connection class only routes commands while this class owns
* the negentropy lifecycle and error mapping.
*
* Plain [HashMap] is sufficient because the registry is mutated only
* from [RelaySession.receive] that path is single-threaded per the
* WebSocket handler contract.
*/
class NegSessionRegistry(
private val store: LiveEventStore,
private val send: (Message) -> Unit,
) {
private val sessions = HashMap<String, NegentropyServerSession>()
/**
* Open a reconciliation session. The relay snapshots its matching
* events at this instant concurrent inserts during the sync are
* not surfaced; clients re-open if they want fresh state.
*
* Access control reuses the REQ policy hook: a relay that requires
* AUTH or has kind/pubkey allow-deny lists applies the same rules
* to NEG-OPEN as it does to subscription REQs.
*/
suspend fun open(
cmd: NegOpenCmd,
policy: IRelayPolicy,
) {
val gate = policy.accept(ReqCmd(cmd.subId, listOf(cmd.filter)))
if (gate is PolicyResult.Rejected) {
send(NegErrMessage(cmd.subId, gate.reason))
return
}
val filters = (gate as PolicyResult.Accepted).cmd.filters
// NIP-77: same-subId OPEN replaces any prior session.
sessions.remove(cmd.subId)
val events = store.snapshotQuery(filters)
val session = NegentropyServerSession(cmd.subId, events)
sessions[cmd.subId] = session
runMessage(cmd.subId, session) { it.processMessage(cmd.initialMessage) }
}
fun msg(cmd: NegMsgCmd) {
val session = sessions[cmd.subId]
if (session == null) {
send(NegErrMessage(cmd.subId, "error: no negentropy session for ${cmd.subId}"))
return
}
runMessage(cmd.subId, session) { it.processMessage(cmd.message) }
}
/**
* Spec: clients send NEG-CLOSE to free server-side state.
* Silent no-op if the session is unknown there's no authoritative
* error response in NIP-77 for an unknown close.
*/
fun close(cmd: NegCloseCmd) {
sessions.remove(cmd.subId)
}
/** Dropped on `RelaySession.cancelAllSubscriptions`. */
fun clear() {
sessions.clear()
}
private inline fun runMessage(
subId: String,
session: NegentropyServerSession,
block: (NegentropyServerSession) -> Message?,
) {
try {
val response = block(session)
if (response != null) send(response)
} catch (e: Exception) {
sessions.remove(subId)
send(NegErrMessage(subId, "error: ${e.message ?: e::class.simpleName}"))
}
}
}
@@ -34,8 +34,12 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
import com.vitorpamplona.quartz.nip77Negentropy.NegCloseCmd
import com.vitorpamplona.quartz.nip77Negentropy.NegMsgCmd
import com.vitorpamplona.quartz.nip77Negentropy.NegOpenCmd
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.cache.LargeCache import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -53,6 +57,9 @@ class RelaySession(
) : AutoCloseable { ) : AutoCloseable {
private val subscriptions = LargeCache<String, Job>() private val subscriptions = LargeCache<String, Job>()
/** NIP-77 negentropy state for this connection. */
private val negentropy = NegSessionRegistry(store, ::send)
private fun addSubscription( private fun addSubscription(
subId: String, subId: String,
job: Job, job: Job,
@@ -67,6 +74,7 @@ class RelaySession(
fun cancelAllSubscriptions() { fun cancelAllSubscriptions() {
subscriptions.forEach { _, job -> job.cancel() } subscriptions.forEach { _, job -> job.cancel() }
subscriptions.clear() subscriptions.clear()
negentropy.clear()
} }
fun send(message: Message) { fun send(message: Message) {
@@ -107,6 +115,9 @@ class RelaySession(
is ReqCmd -> handleReq(cmd) is ReqCmd -> handleReq(cmd)
is CloseCmd -> handleClose(cmd) is CloseCmd -> handleClose(cmd)
is CountCmd -> handleCount(cmd) is CountCmd -> handleCount(cmd)
is NegOpenCmd -> negentropy.open(cmd, policy)
is NegMsgCmd -> negentropy.msg(cmd)
is NegCloseCmd -> negentropy.close(cmd)
else -> send(NoticeMessage("error: unsupported command ${cmd.label()}")) else -> send(NoticeMessage("error: unsupported command ${cmd.label()}"))
} }
} }
@@ -178,7 +189,7 @@ class RelaySession(
}, },
onEose = { send(EoseMessage(cmd.subId)) }, onEose = { send(EoseMessage(cmd.subId)) },
) )
} catch (_: kotlinx.coroutines.CancellationException) { } catch (_: CancellationException) {
// Subscription was closed this is expected. // Subscription was closed this is expected.
} }
} }
@@ -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.nip01Core.relay.server.inprocess
import com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer
import com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import kotlinx.coroutines.launch
/**
* In-memory implementation of [WebSocket] that talks directly to a
* [NostrServer] without touching the network. Each instance opens one
* [RelaySession] on [connect] and routes:
*
* - Outbound (`send`) server `RelaySession.receive()` via an inbound
* channel drained by a single coroutine, preserving message order
* per the [WebSocketListener] contract.
* - Server-side `send` callbacks [WebSocketListener.onMessage].
*
* Use this to wire a `NostrClient` to an embedded server in unit tests
* or single-JVM scenarios without paying for a real TCP socket. Because
* it implements [WebSocket], it slots into anywhere a `WebsocketBuilder`
* expects.
*
* Reconnect-after-disconnect is supported: each [connect] creates a
* fresh scope + drain channel so a previous [disconnect] (which
* cancels both) doesn't leave a dead drainer behind.
*/
class InProcessWebSocket(
private val server: NostrServer,
private val out: WebSocketListener,
) : WebSocket {
private var scope: CoroutineScope? = null
private var incoming: Channel<String>? = null
private var drainJob: Job? = null
private var session: RelaySession? = null
override fun needsReconnect(): Boolean = session == null
override fun connect() {
if (session != null) return
val newScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
val newIncoming = Channel<String>(UNLIMITED)
val s = server.connect { json -> out.onMessage(json) }
scope = newScope
incoming = newIncoming
session = s
drainJob =
newScope.launch {
for (msg in newIncoming) {
s.receive(msg)
}
}
out.onOpen(0, false)
}
override fun disconnect() {
val s = session ?: return
session = null
incoming?.close()
incoming = null
drainJob = null
scope?.cancel()
scope = null
s.close()
out.onClosed(1000, "client disconnect")
}
override fun send(msg: String): Boolean {
// Capture the current channel reference: we want to fail
// (return false) if the socket was disconnected, even if a
// racing thread is mid-`connect()`.
val ch = incoming ?: return false
return ch.trySend(msg).isSuccess
}
}
@@ -20,28 +20,11 @@
*/ */
package com.vitorpamplona.quartz.nip01Core.relay.server.policies package com.vitorpamplona.quartz.nip01Core.relay.server.policies
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult
/** /**
* Allows all commands without authentication. This is the default policy. * Allows all commands without authentication. The default policy.
*
* Singleton form of [PassThroughPolicy] for callers that want a
* shared no-op (saves an allocation and lets the relay shortcut
* `policy === EmptyPolicy` checks when composing stacks).
*/ */
object EmptyPolicy : IRelayPolicy { object EmptyPolicy : PassThroughPolicy()
override fun onConnect(send: (Message) -> Unit) { }
override fun accept(cmd: EventCmd) = PolicyResult.Accepted(cmd)
override fun accept(cmd: ReqCmd) = PolicyResult.Accepted(cmd)
override fun accept(cmd: CountCmd) = PolicyResult.Accepted(cmd)
override fun accept(cmd: AuthCmd) = PolicyResult.Accepted(cmd)
override fun canSendToSession(event: Event) = true
}
@@ -0,0 +1,52 @@
/*
* 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.server.policies
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult
/**
* Operator-controlled kind allow/deny list. Mirrors nostr-rs-relay's
* `[authorization].kind_whitelist` / `kind_blacklist`.
*
* - When [allow] is non-empty, only events whose kind is in [allow]
* are accepted; everything else is rejected.
* - When [deny] is non-empty, events whose kind is in [deny] are
* rejected.
* - Both lists may be empty (no-op pass-through).
* - When both are set, allow is checked first (deny inside allow is
* still denied, matching nostr-rs-relay's precedence).
*/
class KindAllowDenyPolicy(
val allow: Set<Int> = emptySet(),
val deny: Set<Int> = emptySet(),
) : PassThroughPolicy() {
override fun accept(cmd: EventCmd): PolicyResult<EventCmd> {
val k = cmd.event.kind
if (allow.isNotEmpty() && k !in allow) {
return PolicyResult.Rejected("blocked: kind $k not allowed")
}
if (k in deny) {
return PolicyResult.Rejected("blocked: kind $k denied")
}
return PolicyResult.Accepted(cmd)
}
}
@@ -0,0 +1,53 @@
/*
* 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.server.policies
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult
/**
* Convenience base that accepts everything by default. Subclasses
* override only the hook(s) they actually enforce so the call sites
* stay readable.
*
* Concrete (not abstract) so [EmptyPolicy] can subclass it as a
* singleton and external code that just wants a no-op policy can
* instantiate this directly.
*/
open class PassThroughPolicy : IRelayPolicy {
override fun onConnect(send: (Message) -> Unit) {}
override fun accept(cmd: EventCmd): PolicyResult<EventCmd> = PolicyResult.Accepted(cmd)
override fun accept(cmd: ReqCmd): PolicyResult<ReqCmd> = PolicyResult.Accepted(cmd)
override fun accept(cmd: CountCmd): PolicyResult<CountCmd> = PolicyResult.Accepted(cmd)
override fun accept(cmd: AuthCmd): PolicyResult<AuthCmd> = PolicyResult.Accepted(cmd)
override fun canSendToSession(event: Event): Boolean = true
}
@@ -0,0 +1,56 @@
/*
* 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.server.policies
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult
/**
* Operator-controlled author allow/deny list. Mirrors nostr-rs-relay's
* `[authorization].pubkey_whitelist` / `pubkey_blacklist`.
*
* - [allow] non-empty: only events from listed pubkeys are accepted.
* This is the "private relay" mode.
* - [deny] non-empty: events from listed pubkeys are rejected.
* - Empty lists are no-op pass-through.
* - When both are set, allow is checked first.
*
* Pubkeys are matched case-insensitively (lowercased on entry).
*/
class PubkeyAllowDenyPolicy(
allow: Set<HexKey> = emptySet(),
deny: Set<HexKey> = emptySet(),
) : PassThroughPolicy() {
private val allow = allow.mapTo(HashSet()) { it.lowercase() }
private val deny = deny.mapTo(HashSet()) { it.lowercase() }
override fun accept(cmd: EventCmd): PolicyResult<EventCmd> {
val pk = cmd.event.pubKey.lowercase()
if (allow.isNotEmpty() && pk !in allow) {
return PolicyResult.Rejected("blocked: pubkey not on allow list")
}
if (pk in deny) {
return PolicyResult.Rejected("blocked: pubkey is denied")
}
return PolicyResult.Accepted(cmd)
}
}
@@ -0,0 +1,55 @@
/*
* 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.server.policies
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* Rejects events whose `created_at` is more than [maxFutureSeconds]
* seconds in the future relative to the relay's clock. Mirrors
* nostr-rs-relay's `[options].reject_future_seconds`.
*
* Catches both clock-skew accidents and intentional far-future
* timestamps used to push events to the top of newest-first feeds.
*
* The current time is read from [TimeUtils.now] (epoch seconds), the
* same source the [com.vitorpamplona.quartz.nip40Expiration.isExpired]
* check uses, so the relay's "future" and "expired" decisions agree.
*/
class RejectFutureEventsPolicy(
val maxFutureSeconds: Int,
private val now: () -> Long = { TimeUtils.now() },
) : PassThroughPolicy() {
init {
require(maxFutureSeconds >= 0) { "maxFutureSeconds must be >= 0, got $maxFutureSeconds" }
}
override fun accept(cmd: EventCmd): PolicyResult<EventCmd> {
val skew = cmd.event.createdAt - now()
return if (skew > maxFutureSeconds) {
PolicyResult.Rejected("invalid: created_at is $skew seconds in the future (max $maxFutureSeconds)")
} else {
PolicyResult.Accepted(cmd)
}
}
}
@@ -27,7 +27,7 @@ import kotlinx.serialization.Serializable
@Stable @Stable
@Serializable @Serializable
class Nip11RelayInformation( data class Nip11RelayInformation(
val id: String? = null, val id: String? = null,
val name: String? = null, val name: String? = null,
val description: String? = null, val description: String? = null,
@@ -59,7 +59,7 @@ class Nip11RelayInformation(
@Stable @Stable
@Serializable @Serializable
class RelayInformationFee( data class RelayInformationFee(
val amount: Int? = null, val amount: Int? = null,
val unit: String? = null, val unit: String? = null,
val period: Int? = null, val period: Int? = null,
@@ -68,7 +68,7 @@ class Nip11RelayInformation(
@Stable @Stable
@Serializable @Serializable
class RelayInformationFees( data class RelayInformationFees(
val admission: List<RelayInformationFee>? = null, val admission: List<RelayInformationFee>? = null,
val subscription: List<RelayInformationFee>? = null, val subscription: List<RelayInformationFee>? = null,
val publication: List<RelayInformationFee>? = null, val publication: List<RelayInformationFee>? = null,
@@ -76,7 +76,7 @@ class Nip11RelayInformation(
@Stable @Stable
@Serializable @Serializable
class RelayInformationLimitation( data class RelayInformationLimitation(
val max_message_length: Int? = null, val max_message_length: Int? = null,
val max_subscriptions: Int? = null, val max_subscriptions: Int? = null,
val max_filters: Int? = null, val max_filters: Int? = null,
@@ -96,7 +96,7 @@ class Nip11RelayInformation(
@Stable @Stable
@Serializable @Serializable
class RelayInformationRetentionData( data class RelayInformationRetentionData(
val kinds: ArrayList<Int>? = null, val kinds: ArrayList<Int>? = null,
val time: Int? = null, val time: Int? = null,
val count: Int? = null, val count: Int? = null,
@@ -0,0 +1,59 @@
/*
* 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.nip86RelayManagement.server
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PassThroughPolicy
/**
* Reads the live [BanStore] on every EVENT and rejects events that
* violate any of: banned-event-id, banned-pubkey, missing from a
* non-empty pubkey allow list, or kind disallowed / not in the kind
* allow list.
*
* This is the runtime-mutable counterpart of the static
* [com.vitorpamplona.quartz.nip01Core.relay.server.policies.KindAllowDenyPolicy] +
* [com.vitorpamplona.quartz.nip01Core.relay.server.policies.PubkeyAllowDenyPolicy]
* both layers compose: the event must clear all stacked policies.
* NIP-86 admin RPC mutations land in the [BanStore]; the static
* policies stay frozen at boot-time config values.
*/
class BanListPolicy(
val banStore: BanStore,
) : PassThroughPolicy() {
override fun accept(cmd: EventCmd): PolicyResult<EventCmd> {
val ev = cmd.event
if (banStore.isBannedEvent(ev.id)) {
return PolicyResult.Rejected("blocked: event id is banned")
}
if (banStore.isBanned(ev.pubKey)) {
return PolicyResult.Rejected("blocked: pubkey is banned")
}
if (banStore.hasAllowList() && !banStore.isAllowedPubkey(ev.pubKey)) {
return PolicyResult.Rejected("blocked: pubkey is not on the allow list")
}
if (!banStore.isKindAllowed(ev.kind)) {
return PolicyResult.Rejected("blocked: kind ${ev.kind} not allowed")
}
return PolicyResult.Accepted(cmd)
}
}
@@ -0,0 +1,187 @@
/*
* 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.nip86RelayManagement.server
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlin.concurrent.atomics.AtomicReference
import kotlin.concurrent.atomics.ExperimentalAtomicApi
/**
* Lock-free runtime state for the NIP-86 management API. Holds the
* ban/allow lists that [BanListPolicy] consults on every accept call,
* plus an [onMutation] hook so the relay can persist the latest
* snapshot whenever an admin RPC mutates state.
*
* Each entry carries an optional reason string so list-* RPCs can echo
* back why an admin took the action useful for audit trails.
*
* Persistence is intentionally NOT inside this class; supply
* [onMutation] to flush to disk (or wherever) and use [seedFromSnapshot]
* at boot to load. `null` keeps the store in-memory only.
*
* Concurrency: state is held in a single [AtomicReference] and mutated
* via copy-on-write CAS loops. Reads are wait-free single-load atomic.
* The data structures are tiny (operator-controlled) so the per-write
* map copy is negligible.
*/
@OptIn(ExperimentalAtomicApi::class)
class BanStore(
private val onMutation: (() -> Unit)? = null,
) {
/**
* Single immutable snapshot of all ban/allow state. Combined into
* one object so kind allow/disallow lock-step (allow adds to
* allowedKinds AND removes from disallowedKinds) is naturally
* atomic no possibility of an interleaved reader observing a
* kind in both sets.
*/
private data class State(
val bannedPubkeys: Map<HexKey, String?> = emptyMap(),
val allowedPubkeys: Map<HexKey, String?> = emptyMap(),
val bannedEventIds: Map<HexKey, String?> = emptyMap(),
val allowedKinds: Set<Int> = emptySet(),
val disallowedKinds: Set<Int> = emptySet(),
)
private val state = AtomicReference(State())
private inline fun mutate(transform: (State) -> State) {
while (true) {
val current = state.load()
if (state.compareAndSet(current, transform(current))) break
}
onMutation?.invoke()
}
// -- Pubkey ban list -----------------------------------------------------
fun banPubkey(
pubkey: HexKey,
reason: String? = null,
) = mutate { it.copy(bannedPubkeys = it.bannedPubkeys + (pubkey.lowercase() to reason)) }
fun unbanPubkey(pubkey: HexKey) = mutate { it.copy(bannedPubkeys = it.bannedPubkeys - pubkey.lowercase()) }
fun isBanned(pubkey: HexKey): Boolean = pubkey.lowercase() in state.load().bannedPubkeys
fun listBannedPubkeys(): List<Pair<HexKey, String?>> =
state
.load()
.bannedPubkeys.entries
.map { it.key to it.value }
// -- Pubkey allow list ---------------------------------------------------
fun allowPubkey(
pubkey: HexKey,
reason: String? = null,
) = mutate { it.copy(allowedPubkeys = it.allowedPubkeys + (pubkey.lowercase() to reason)) }
fun unallowPubkey(pubkey: HexKey) = mutate { it.copy(allowedPubkeys = it.allowedPubkeys - pubkey.lowercase()) }
fun isAllowedPubkey(pubkey: HexKey): Boolean = pubkey.lowercase() in state.load().allowedPubkeys
fun listAllowedPubkeys(): List<Pair<HexKey, String?>> =
state
.load()
.allowedPubkeys.entries
.map { it.key to it.value }
fun hasAllowList(): Boolean = state.load().allowedPubkeys.isNotEmpty()
// -- Event id ban list ---------------------------------------------------
fun banEvent(
eventId: HexKey,
reason: String? = null,
) = mutate { it.copy(bannedEventIds = it.bannedEventIds + (eventId.lowercase() to reason)) }
/** Removes an event id from the ban list. Mirrors NIP-86 `allowevent`. */
fun allowEvent(eventId: HexKey) = mutate { it.copy(bannedEventIds = it.bannedEventIds - eventId.lowercase()) }
fun isBannedEvent(eventId: HexKey): Boolean = eventId.lowercase() in state.load().bannedEventIds
fun listBannedEvents(): List<Pair<HexKey, String?>> =
state
.load()
.bannedEventIds.entries
.map { it.key to it.value }
// -- Kind allow / deny --------------------------------------------------
/**
* `allowKind` and `disallowKind` are symmetric: each adds to its
* own set AND removes the kind from the opposite set. Otherwise
* an `allowKind(K)` after a `disallowKind(K)` would leave K in
* both sets and stay blocked, surprising operators.
*/
fun allowKind(kind: Int) =
mutate {
it.copy(
allowedKinds = it.allowedKinds + kind,
disallowedKinds = it.disallowedKinds - kind,
)
}
fun disallowKind(kind: Int) =
mutate {
it.copy(
allowedKinds = it.allowedKinds - kind,
disallowedKinds = it.disallowedKinds + kind,
)
}
fun listAllowedKinds(): List<Int> = state.load().allowedKinds.sorted()
fun listDisallowedKinds(): List<Int> = state.load().disallowedKinds.sorted()
fun isKindAllowed(kind: Int): Boolean {
val s = state.load()
if (kind in s.disallowedKinds) return false
if (s.allowedKinds.isEmpty()) return true
return kind in s.allowedKinds
}
/**
* Bulk-load state without firing [onMutation]. Used at startup to
* seed the in-memory state from a persisted snapshot we don't
* want every individual `put` to trigger another disk write. After
* this call the store behaves exactly as if every entry had been
* mutated through the public API.
*/
fun seedFromSnapshot(
bannedPubkeys: List<Pair<HexKey, String?>> = emptyList(),
allowedPubkeys: List<Pair<HexKey, String?>> = emptyList(),
bannedEvents: List<Pair<HexKey, String?>> = emptyList(),
allowedKinds: List<Int> = emptyList(),
disallowedKinds: List<Int> = emptyList(),
) {
state.store(
State(
bannedPubkeys = bannedPubkeys.associate { (k, r) -> k.lowercase() to r },
allowedPubkeys = allowedPubkeys.associate { (k, r) -> k.lowercase() to r },
bannedEventIds = bannedEvents.associate { (k, r) -> k.lowercase() to r },
allowedKinds = allowedKinds.toSet(),
disallowedKinds = disallowedKinds.toSet(),
),
)
}
}
@@ -0,0 +1,256 @@
/*
* 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.nip86RelayManagement.server
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.AllowedPubkey
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedEvent
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedPubkey
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Method
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Response
import com.vitorpamplona.quartz.utils.Hex
import kotlinx.coroutines.CancellationException
import kotlinx.serialization.KSerializer
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.int
/**
* Server-side dispatcher for the NIP-86 relay management API.
*
* Holds the [BanStore] (mutated by ban/allow methods), an [InfoHolder]
* for the live NIP-11 doc (mutated by `changerelay*` methods, which
* atomically swap it), and an optional [IEventStore] so `banevent`
* can also delete the offending event from the store.
*
* Transport-agnostic relay implementations call [dispatch] from
* whatever HTTP route they expose (e.g. POST `application/nostr+json+rpc`),
* and in-process tests can build a [Nip86Request] directly.
*
* [supportedMethods] is the canonical list this server actually
* implements; methods returned outside of it are no-ops and a NIP-86
* client must not advertise them.
*/
class Nip86Server(
val banStore: BanStore,
/**
* Read-write access to the relay's NIP-11 info doc. The dispatcher
* mutates this when an admin calls `changerelayname` /
* `changerelaydescription` / `changerelayicon`. Relay code reading
* the doc (e.g. the NIP-11 endpoint) must consult this object on
* every request, not cache it.
*/
private val infoHolder: InfoHolder,
private val store: IEventStore? = null,
) {
/** Pluggable container so the relay's NIP-11 doc can be swapped at runtime. */
interface InfoHolder {
fun get(): Nip11RelayInformation
fun set(info: Nip11RelayInformation)
}
val supportedMethods: List<String> =
listOf(
Nip86Method.SUPPORTED_METHODS,
Nip86Method.BAN_PUBKEY,
Nip86Method.UNBAN_PUBKEY,
Nip86Method.LIST_BANNED_PUBKEYS,
Nip86Method.ALLOW_PUBKEY,
Nip86Method.UNALLOW_PUBKEY,
Nip86Method.LIST_ALLOWED_PUBKEYS,
Nip86Method.BAN_EVENT,
Nip86Method.ALLOW_EVENT,
Nip86Method.LIST_BANNED_EVENTS,
Nip86Method.ALLOW_KIND,
Nip86Method.DISALLOW_KIND,
Nip86Method.LIST_ALLOWED_KINDS,
Nip86Method.CHANGE_RELAY_NAME,
Nip86Method.CHANGE_RELAY_DESCRIPTION,
Nip86Method.CHANGE_RELAY_ICON,
)
/**
* Dispatches a single RPC request. Synchronous-looking but does
* suspend internally for the `banevent` event-store delete path.
*/
suspend fun dispatch(req: Nip86Request): Nip86Response =
runCatching {
when (req.method) {
Nip86Method.SUPPORTED_METHODS -> {
ok(buildJsonArray { supportedMethods.forEach { add(JsonPrimitive(it)) } })
}
Nip86Method.BAN_PUBKEY -> {
withHexAndReason(req, "pubkey") { pk, reason -> banStore.banPubkey(pk, reason) }
}
Nip86Method.UNBAN_PUBKEY -> {
withHex(req, "pubkey") { pk -> banStore.unbanPubkey(pk) }
}
Nip86Method.LIST_BANNED_PUBKEYS -> {
ok(banStore.listBannedPubkeys().map { (pk, r) -> BannedPubkey(pk, r) }.toJsonArray(BannedPubkey.serializer()))
}
Nip86Method.ALLOW_PUBKEY -> {
withHexAndReason(req, "pubkey") { pk, reason -> banStore.allowPubkey(pk, reason) }
}
Nip86Method.UNALLOW_PUBKEY -> {
withHex(req, "pubkey") { pk -> banStore.unallowPubkey(pk) }
}
Nip86Method.LIST_ALLOWED_PUBKEYS -> {
ok(banStore.listAllowedPubkeys().map { (pk, r) -> AllowedPubkey(pk, r) }.toJsonArray(AllowedPubkey.serializer()))
}
Nip86Method.BAN_EVENT -> {
withHexAndReason(req, "event_id") { id, reason ->
banStore.banEvent(id, reason)
// Also remove the event from the store if present.
store?.delete(Filter(ids = listOf(id)))
}
}
Nip86Method.ALLOW_EVENT -> {
withHex(req, "event_id") { id -> banStore.allowEvent(id) }
}
Nip86Method.LIST_BANNED_EVENTS -> {
ok(banStore.listBannedEvents().map { (id, r) -> BannedEvent(id, r) }.toJsonArray(BannedEvent.serializer()))
}
Nip86Method.ALLOW_KIND -> {
withInt(req, "kind") { k -> banStore.allowKind(k) }
}
Nip86Method.DISALLOW_KIND -> {
withInt(req, "kind") { k -> banStore.disallowKind(k) }
}
Nip86Method.LIST_ALLOWED_KINDS -> {
ok(buildJsonArray { banStore.listAllowedKinds().forEach { add(JsonPrimitive(it)) } })
}
Nip86Method.CHANGE_RELAY_NAME -> {
withString(req, "name") { name -> rewriteInfo { it.copy(name = name) } }
}
Nip86Method.CHANGE_RELAY_DESCRIPTION -> {
withString(req, "description") { desc -> rewriteInfo { it.copy(description = desc) } }
}
Nip86Method.CHANGE_RELAY_ICON -> {
withString(req, "icon_url") { icon -> rewriteInfo { it.copy(icon = icon) } }
}
else -> {
Nip86Response(error = "method not supported: ${req.method}")
}
}
}.getOrElse { e ->
// CancellationException must propagate so structured
// concurrency works — swallowing it would let a parent
// cancellation be reported as a benign RPC error.
if (e is CancellationException) throw e
Nip86Response(error = "internal: ${e.message ?: e::class.simpleName}")
}
private inline fun withHex(
req: Nip86Request,
label: String,
action: (String) -> Unit,
): Nip86Response {
val (value, _) = req.params.stringPair() ?: return malformed("expected [$label]")
if (!Hex.isHex64(value)) return malformed("$label must be 64-char hex")
action(value)
return okTrue
}
private suspend inline fun withHexAndReason(
req: Nip86Request,
label: String,
action: suspend (String, String?) -> Unit,
): Nip86Response {
val (value, reason) = req.params.stringPair() ?: return malformed("expected [$label, reason?]")
if (!Hex.isHex64(value)) return malformed("$label must be 64-char hex")
action(value, reason)
return okTrue
}
private inline fun withInt(
req: Nip86Request,
label: String,
action: (Int) -> Unit,
): Nip86Response {
val v = req.params.firstInt() ?: return malformed("expected [$label]")
action(v)
return okTrue
}
private inline fun withString(
req: Nip86Request,
label: String,
action: (String) -> Unit,
): Nip86Response {
val v = req.params.firstString() ?: return malformed("expected [$label]")
action(v)
return okTrue
}
private fun rewriteInfo(transform: (Nip11RelayInformation) -> Nip11RelayInformation) {
infoHolder.set(transform(infoHolder.get()))
}
}
private fun malformed(reason: String) = Nip86Response(error = "invalid params: $reason")
private fun ok(j: JsonElement) = Nip86Response(result = j, error = null)
private val okTrue = ok(JsonPrimitive(true))
private val rpcJson = Json { encodeDefaults = false }
private fun <T> List<T>.toJsonArray(serializer: KSerializer<T>): JsonElement = rpcJson.encodeToJsonElement(ListSerializer(serializer), this)
private fun JsonArray.stringPair(): Pair<String, String?>? {
val first = (getOrNull(0) as? JsonPrimitive)?.contentOrNull() ?: return null
val second = (getOrNull(1) as? JsonPrimitive)?.contentOrNull()
return first to second
}
private fun JsonArray.firstString(): String? = (getOrNull(0) as? JsonPrimitive)?.contentOrNull()
private fun JsonArray.firstInt(): Int? =
runCatching {
(this[0] as? JsonPrimitive)?.int
}.getOrNull()
private fun JsonPrimitive.contentOrNull(): String? = if (this == JsonNull) null else content
@@ -0,0 +1,177 @@
/*
* 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.nip98HttpAuth
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.verify
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.sha256.sha256
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.abs
/**
* Server-side counterpart to [HTTPAuthorizationEvent]. Verifies a
* NIP-98 `Authorization: Nostr <base64-event>` header.
*
* NIP-98 reuses kind 27235 events with `u`, `method`, and (for bodies)
* `payload` tags. Verification checks:
* 1. Header is `Nostr <base64>`.
* 2. Decoded body is a kind-27235 event with a valid Schnorr signature.
* 3. The event's `created_at` is within ±[toleranceSeconds] of now.
* 4. The `method` tag matches the HTTP method.
* 5. The `u` tag matches the requested URL.
* 6. If a body is present, the `payload` tag matches `sha256(body)` hex.
*
* Returns the verified pubkey on success; a [Result.Malformed] /
* [Result.Missing] otherwise (the caller turns these into 401/403).
*/
class Nip98AuthVerifier(
private val now: () -> Long = { TimeUtils.now() },
/** Allowed clock skew in seconds. NIP-98 says 60. */
private val toleranceSeconds: Long = 60,
) {
/**
* 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
* 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 seenLock = Mutex()
@OptIn(ExperimentalEncodingApi::class)
suspend fun verify(
authorizationHeader: String?,
method: String,
url: String,
body: ByteArray?,
): Result {
if (authorizationHeader.isNullOrBlank()) return Result.Missing
if (!authorizationHeader.startsWith(SCHEME)) return Result.Malformed("expected '$SCHEME <base64>' header")
val token = authorizationHeader.substring(SCHEME.length).trim()
val json =
try {
Base64.decode(token).decodeToString()
} catch (_: IllegalArgumentException) {
return Result.Malformed("token is not valid base64")
}
val event =
try {
OptimizedJsonMapper.fromJson(json)
} catch (_: Exception) {
return Result.Malformed("token does not decode to a Nostr event")
}
if (event.kind != HTTPAuthorizationEvent.KIND) {
return Result.Malformed("event kind ${event.kind} != ${HTTPAuthorizationEvent.KIND}")
}
if (!event.verify()) return Result.Malformed("bad event signature or id")
val nowSec = now()
val skew = abs(event.createdAt - nowSec)
if (skew > toleranceSeconds) {
return Result.Malformed("created_at is ${skew}s away from now (max ${toleranceSeconds}s)")
}
// Re-wrap as the typed event so the tag accessors work.
val auth =
HTTPAuthorizationEvent(
event.id,
event.pubKey,
event.createdAt,
event.tags,
event.content,
event.sig,
)
if (!auth.method().equals(method, ignoreCase = true)) {
return Result.Malformed("method mismatch: expected $method, got ${auth.method()}")
}
if (auth.url() != url) {
return Result.Malformed("url mismatch: expected $url, got ${auth.url()}")
}
if (body != null && body.isNotEmpty()) {
val expected = sha256(body).toHexKey()
if (auth.payloadHash() != expected) {
return Result.Malformed("payload hash mismatch")
}
}
// Replay check — done LAST so we don't burn a one-shot id on a
// request that would otherwise have failed signature/url/etc.
val expiry = nowSec + 2 * toleranceSeconds
seenLock.withLock {
// Evict expired entries while we hold the lock. Insertion
// order (LinkedHashMap default) tracks expiry order
// because every entry's expiry = now + 2·tolerance, so
// the first non-expired entry guarantees no later entry
// is expired either.
val it = seenEventIds.entries.iterator()
while (it.hasNext()) {
if (it.next().value <= nowSec) it.remove() else break
}
if (seenEventIds.put(event.id, expiry) != null) {
return Result.Malformed("replay: this NIP-98 token has already been used")
}
}
return Result.Verified(event.pubKey)
}
sealed interface Result {
data class Verified(
val pubkey: HexKey,
) : Result
object Missing : Result
data class Malformed(
val reason: String,
) : Result
}
companion object {
const val SCHEME = "Nostr "
/**
* Cap on the in-memory replay-cache size. With a 60s tolerance
* an attacker would need to push >MAX/120 verified requests per
* second (one new id per ~120 ms) to evict legitimate entries.
* 1024 is generous for an admin endpoint.
*/
const val MAX_REPLAY_ENTRIES = 1024
}
}
@@ -163,7 +163,7 @@ class NostrServerAuthTest {
val okMessages = collector.rawMessagesContaining("OK") val okMessages = collector.rawMessagesContaining("OK")
assertEquals(1, okMessages.size) assertEquals(1, okMessages.size)
assertTrue(okMessages[0].contains("\"true\"")) assertTrue(okMessages[0].contains(",true,"))
assertTrue((session.policy as FullAuthPolicy).isAuthenticated()) assertTrue((session.policy as FullAuthPolicy).isAuthenticated())
assertTrue(session.policy.authenticatedUsers.contains(pubkey)) assertTrue(session.policy.authenticatedUsers.contains(pubkey))
@@ -184,7 +184,7 @@ class NostrServerAuthTest {
val okMessages = collector.rawMessagesContaining("OK") val okMessages = collector.rawMessagesContaining("OK")
assertEquals(1, okMessages.size) assertEquals(1, okMessages.size)
assertTrue(okMessages[0].contains("\"false\"")) assertTrue(okMessages[0].contains(",false,"))
assertTrue(okMessages[0].contains("challenge")) assertTrue(okMessages[0].contains("challenge"))
assertFalse((session.policy as FullAuthPolicy).isAuthenticated()) assertFalse((session.policy as FullAuthPolicy).isAuthenticated())
@@ -209,7 +209,7 @@ class NostrServerAuthTest {
val okMessages = collector.rawMessagesContaining("OK") val okMessages = collector.rawMessagesContaining("OK")
assertEquals(1, okMessages.size) assertEquals(1, okMessages.size)
assertTrue(okMessages[0].contains("\"false\"")) assertTrue(okMessages[0].contains(",false,"))
assertTrue(okMessages[0].contains("relay url")) assertTrue(okMessages[0].contains("relay url"))
assertFalse((session.policy as FullAuthPolicy).isAuthenticated()) assertFalse((session.policy as FullAuthPolicy).isAuthenticated())
@@ -238,7 +238,7 @@ class NostrServerAuthTest {
val okMessages = collector.rawMessagesContaining("OK") val okMessages = collector.rawMessagesContaining("OK")
assertEquals(1, okMessages.size) assertEquals(1, okMessages.size)
assertTrue(okMessages[0].contains("\"false\"")) assertTrue(okMessages[0].contains(",false,"))
assertTrue(okMessages[0].contains("created_at")) assertTrue(okMessages[0].contains("created_at"))
assertFalse((session.policy as FullAuthPolicy).isAuthenticated()) assertFalse((session.policy as FullAuthPolicy).isAuthenticated())
@@ -307,8 +307,8 @@ class NostrServerAuthTest {
val okMessages = collector.rawMessagesContaining("OK") val okMessages = collector.rawMessagesContaining("OK")
assertEquals(2, okMessages.size) assertEquals(2, okMessages.size)
assertTrue(okMessages[0].contains("\"true\"")) assertTrue(okMessages[0].contains(",true,"))
assertTrue(okMessages[1].contains("\"true\"")) assertTrue(okMessages[1].contains(",true,"))
val authedPubkeys = (session.policy as FullAuthPolicy).authenticatedUsers val authedPubkeys = (session.policy as FullAuthPolicy).authenticatedUsers
assertEquals(2, authedPubkeys.size) assertEquals(2, authedPubkeys.size)
@@ -334,7 +334,7 @@ class NostrServerAuthTest {
val okMessages = collector.rawMessagesContaining("OK") val okMessages = collector.rawMessagesContaining("OK")
assertEquals(1, okMessages.size) assertEquals(1, okMessages.size)
assertTrue(okMessages[0].contains("\"false\"")) assertTrue(okMessages[0].contains(",false,"))
assertTrue(okMessages[0].contains("auth-required:")) assertTrue(okMessages[0].contains("auth-required:"))
server.close() server.close()
@@ -394,7 +394,7 @@ class NostrServerAuthTest {
val okMessages = collector.rawMessagesContaining("OK") val okMessages = collector.rawMessagesContaining("OK")
assertEquals(1, okMessages.size) assertEquals(1, okMessages.size)
assertTrue(okMessages[0].contains("\"true\"")) assertTrue(okMessages[0].contains(",true,"))
// Now EVENT should work // Now EVENT should work
val event = testEvent() val event = testEvent()
@@ -402,7 +402,7 @@ class NostrServerAuthTest {
val allOk = collector.rawMessagesContaining("OK") val allOk = collector.rawMessagesContaining("OK")
assertEquals(2, allOk.size) assertEquals(2, allOk.size)
assertTrue(allOk[1].contains("\"true\"")) assertTrue(allOk[1].contains(",true,"))
// REQ should work // REQ should work
session.receive("""["REQ","sub1",{"kinds":[1]}]""") session.receive("""["REQ","sub1",{"kinds":[1]}]""")
@@ -428,7 +428,7 @@ class NostrServerAuthTest {
val okMessages = collector.rawMessagesContaining("OK") val okMessages = collector.rawMessagesContaining("OK")
assertEquals(1, okMessages.size) assertEquals(1, okMessages.size)
assertTrue(okMessages[0].contains("\"true\"")) assertTrue(okMessages[0].contains(",true,"))
server.close() server.close()
} }
@@ -461,14 +461,14 @@ class NostrServerAuthTest {
// Kind 1 should be accepted without auth // Kind 1 should be accepted without auth
val note = testEvent(hexId(1), kind = 1) val note = testEvent(hexId(1), kind = 1)
session.receive("""["EVENT",${note.toJson()}]""") session.receive("""["EVENT",${note.toJson()}]""")
assertTrue(collector.rawMessagesContaining("OK")[0].contains("\"true\"")) assertTrue(collector.rawMessagesContaining("OK")[0].contains(",true,"))
// Kind 4 should be rejected without auth // Kind 4 should be rejected without auth
val dm = testEvent(hexId(2), kind = 4) val dm = testEvent(hexId(2), kind = 4)
session.receive("""["EVENT",${dm.toJson()}]""") session.receive("""["EVENT",${dm.toJson()}]""")
val okMessages = collector.rawMessagesContaining("OK") val okMessages = collector.rawMessagesContaining("OK")
assertEquals(2, okMessages.size) assertEquals(2, okMessages.size)
assertTrue(okMessages[1].contains("\"false\"")) assertTrue(okMessages[1].contains(",false,"))
assertTrue(okMessages[1].contains("auth-required:")) assertTrue(okMessages[1].contains("auth-required:"))
server.close() server.close()
@@ -109,7 +109,7 @@ class NostrServerTest {
val okMessages = collector.rawMessagesContaining("OK") val okMessages = collector.rawMessagesContaining("OK")
assertEquals(1, okMessages.size) assertEquals(1, okMessages.size)
assertTrue(okMessages[0].contains("\"true\"")) assertTrue(okMessages[0].contains(",true,"))
// Event should be in store // Event should be in store
val stored = store.query<Event>(Filter(ids = listOf(event.id))) val stored = store.query<Event>(Filter(ids = listOf(event.id)))
@@ -135,8 +135,8 @@ class NostrServerTest {
val okMessages = collector.rawMessagesContaining("OK") val okMessages = collector.rawMessagesContaining("OK")
assertEquals(2, okMessages.size) assertEquals(2, okMessages.size)
assertTrue(okMessages[0].contains("\"true\"")) assertTrue(okMessages[0].contains(",true,"))
assertTrue(okMessages[1].contains("\"false\"")) assertTrue(okMessages[1].contains(",false,"))
server.close() server.close()
} }
@@ -30,9 +30,12 @@ class CountResultSerializer : StdSerializer<CountResult>(CountResult::class.java
gen: JsonGenerator, gen: JsonGenerator,
provider: SerializerProvider, provider: SerializerProvider,
) { ) {
// NIP-45 result object: { "count": <int>, "approximate": <bool>? }.
gen.writeStartObject() gen.writeStartObject()
gen.writeNumberField("count", result.count) gen.writeNumberField("count", result.count)
gen.writeBooleanField("pubkey", result.approximate) if (result.approximate) {
gen.writeBooleanField("approximate", true)
}
gen.writeEndObject() gen.writeEndObject()
} }
} }
@@ -50,11 +50,11 @@ class MessageSerializer : StdSerializer<Message>(Message::class.java) {
} }
is OkMessage -> { is OkMessage -> {
// NIP-01 wire format: ["OK", <event_id>, <true|false>, <message>]
// The third element is a JSON boolean, not a string.
gen.writeString(msg.eventId) gen.writeString(msg.eventId)
gen.writeString(msg.success.toString()) gen.writeBoolean(msg.success)
if (msg.message.isNotBlank()) { gen.writeString(msg.message)
gen.writeString(msg.message)
}
} }
is AuthMessage -> { is AuthMessage -> {
@@ -71,6 +71,8 @@ class MessageSerializer : StdSerializer<Message>(Message::class.java) {
} }
is CountMessage -> { is CountMessage -> {
// NIP-45 wire format: ["COUNT", <query_id>, <count_result>]
gen.writeString(msg.queryId)
countSerializer.serialize(msg.result, gen, provider) countSerializer.serialize(msg.result, gen, provider)
} }
@@ -1,54 +0,0 @@
/*
* 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
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
class DefaultContentTypeInterceptor(
private val userAgentHeader: String,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest: Request = chain.request()
val requestWithUserAgent: Request =
originalRequest
.newBuilder()
.header("User-Agent", userAgentHeader)
.build()
return chain.proceed(requestWithUserAgent)
}
}
open class BaseNostrClientTest {
companion object {
val rootClient =
OkHttpClient
.Builder()
.followRedirects(true)
.followSslRedirects(true)
.addInterceptor(DefaultContentTypeInterceptor("Amethyst/v1.05"))
.build()
val socketBuilder = BasicOkHttpWebSocket.Builder { url -> rootClient }
}
}
@@ -19,39 +19,41 @@
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.quartz.nip01Core.relay package com.vitorpamplona.quartz.nip01Core.relay
import com.vitorpamplona.geode.testing.RelayClientTest
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
class NostrClientFirstEventTest : BaseNostrClientTest() { class NostrClientFirstEventTest : RelayClientTest() {
@Test @Test
fun testDownloadFirstEvent() = fun testDownloadFirstEvent() =
runBlocking { runBlocking {
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) val pubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"
val client = NostrClient(socketBuilder, appScope)
val seed =
Event(
id = "a".repeat(64),
pubKey = pubKey,
createdAt = 1000L,
kind = MetadataEvent.KIND,
tags = emptyArray(),
content = """{"name":"vitor"}""",
sig = "b".repeat(128),
)
defaultRelay.preload(seed)
val event = val event =
client.fetchFirst( client.fetchFirst(
relay = "wss://nos.lol", relay = defaultRelayUrl,
filter = filter = Filter(kinds = listOf(MetadataEvent.KIND), authors = listOf(pubKey)),
Filter(
kinds = listOf(MetadataEvent.KIND),
authors = listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"),
),
) )
client.disconnect()
appScope.cancel()
assertEquals(MetadataEvent.KIND, event?.kind) assertEquals(MetadataEvent.KIND, event?.kind)
assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", event?.pubKey) assertEquals(pubKey, event?.pubKey)
} }
} }
@@ -19,17 +19,14 @@
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.quartz.nip01Core.relay package com.vitorpamplona.quartz.nip01Core.relay
import com.vitorpamplona.geode.fixtures.SyntheticEvents
import com.vitorpamplona.geode.testing.RelayClientTest
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@@ -37,12 +34,11 @@ import kotlinx.coroutines.withTimeoutOrNull
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
class NostrClientManualSubTest : BaseNostrClientTest() { class NostrClientManualSubTest : RelayClientTest() {
@Test @Test
fun testEoseAfter100Events() = fun testEoseAfter100Events() =
runBlocking { runBlocking {
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) defaultRelay.preload(SyntheticEvents.batch(150, kind = MetadataEvent.KIND))
val client = NostrClient(socketBuilder, appScope)
val resultChannel = Channel<String>(UNLIMITED) val resultChannel = Channel<String>(UNLIMITED)
val events = mutableListOf<String>() val events = mutableListOf<String>()
@@ -67,18 +63,11 @@ class NostrClientManualSubTest : BaseNostrClientTest() {
} }
} }
val filters = client.subscribe(
mapOf( mySubId,
RelayUrlNormalizer.normalize("wss://nos.lol") to mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(MetadataEvent.KIND), limit = 100))),
listOf( listener,
Filter( )
kinds = listOf(MetadataEvent.KIND),
limit = 100,
),
),
)
client.subscribe(mySubId, filters, listener)
withTimeoutOrNull(10000) { withTimeoutOrNull(10000) {
while (events.size < 101) { while (events.size < 101) {
@@ -88,11 +77,7 @@ class NostrClientManualSubTest : BaseNostrClientTest() {
} }
resultChannel.close() resultChannel.close()
client.unsubscribe(mySubId) client.unsubscribe(mySubId)
client.disconnect()
appScope.cancel()
assertEquals(101, events.size) assertEquals(101, events.size)
assertEquals(true, events.take(100).all { it.length == 64 }) assertEquals(true, events.take(100).all { it.length == 64 })
@@ -19,73 +19,70 @@
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.quartz.nip01Core.relay package com.vitorpamplona.quartz.nip01Core.relay
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.geode.fixtures.SyntheticEvents
import com.vitorpamplona.geode.testing.RelayClientTest
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.count import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.count
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import junit.framework.TestCase.assertTrue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals
class NostrClientQueryCountTest : BaseNostrClientTest() { class NostrClientQueryCountTest : RelayClientTest() {
val fiatjaf = "wss://pyramid.fiatjaf.com".normalizeRelayUrl() private val relayA = "ws://127.0.0.1:7771/".normalizeRelayUrl()
val utxo = "wss://news.utxo.one".normalizeRelayUrl() private val relayB = "ws://127.0.0.1:7772/".normalizeRelayUrl()
val metadata = Filter(kinds = listOf(0)) private val metadata = Filter(kinds = listOf(0))
val outboxRelays = Filter(kinds = listOf(10002)) private val outboxRelays = Filter(kinds = listOf(10002))
private suspend fun seed() {
// 5 metadata + 3 outbox relay events on A, 2 metadata + 7 outbox on B.
// Each event needs a distinct (kind, pubkey, dTag) to avoid replaceable-event collisions.
fun pk(seed: Int) = SyntheticEvents.hexId(seed)
hub.getOrCreate(relayA).preload(
(1..5).map { SyntheticEvents.fakeEvent(idSeed = it, kind = 0, pubKey = pk(it)) },
)
hub.getOrCreate(relayA).preload(
(1..3).map { SyntheticEvents.fakeEvent(idSeed = 1000 + it, kind = 10002, pubKey = pk(1000 + it)) },
)
hub.getOrCreate(relayB).preload(
(1..2).map { SyntheticEvents.fakeEvent(idSeed = 2000 + it, kind = 0, pubKey = pk(2000 + it)) },
)
hub.getOrCreate(relayB).preload(
(1..7).map { SyntheticEvents.fakeEvent(idSeed = 3000 + it, kind = 10002, pubKey = pk(3000 + it)) },
)
}
@Test @Test
fun testQueryCountSuspend() = fun testQueryCountSuspend() =
runBlocking { runBlocking {
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) seed()
val client = NostrClient(socketBuilder, appScope) val result = client.count(relayA, metadata)
assertEquals(5, result?.count)
val result = client.count(fiatjaf, metadata)
assertTrue((result?.count ?: 0) > 1)
client.disconnect()
appScope.cancel()
} }
@Test @Test
fun testQueryCountSuspendAllEvents() = fun testQueryCountSuspendAllEvents() =
runBlocking { runBlocking {
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) seed()
val client = NostrClient(socketBuilder, appScope) val result = client.count(relayA, Filter())
assertEquals(8, result?.count)
val result = client.count(fiatjaf, Filter())
assertTrue((result?.count ?: 0) > 1)
client.disconnect()
appScope.cancel()
} }
@Test @Test
fun testQueryCountSuspendMultipleRelays() = fun testQueryCountSuspendMultipleRelays() =
runBlocking { runBlocking {
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) seed()
val client = NostrClient(socketBuilder, appScope)
val results = val results =
client.count( client.count(
mapOf( mapOf(
fiatjaf to listOf(metadata, outboxRelays), relayA to listOf(metadata, outboxRelays),
utxo to listOf(metadata, outboxRelays), relayB to listOf(metadata, outboxRelays),
), ),
) )
results.forEach { (url, countResult) -> assertEquals(8, results[relayA]?.count)
println("${url.url}: ${countResult.count}") assertEquals(9, results[relayB]?.count)
assertTrue(countResult.count > 1)
}
client.disconnect()
appScope.cancel()
} }
} }
@@ -19,21 +19,18 @@
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.quartz.nip01Core.relay package com.vitorpamplona.quartz.nip01Core.relay
import com.vitorpamplona.geode.fixtures.SyntheticEvents
import com.vitorpamplona.geode.testing.RelayClientTest
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
@@ -43,12 +40,29 @@ import kotlinx.coroutines.withTimeoutOrNull
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
class NostrClientRepeatSubTest : BaseNostrClientTest() { class NostrClientRepeatSubTest : RelayClientTest() {
@Test @Test
fun testRepeatSubEvents() = fun testRepeatSubEvents() =
runBlocking { runBlocking {
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) // Each replaceable kind needs unique pubkeys.
val client = NostrClient(socketBuilder, appScope) defaultRelay.preload(
(1..150).map {
SyntheticEvents.fakeEvent(
idSeed = it,
kind = MetadataEvent.KIND,
pubKey = SyntheticEvents.hexId(it),
)
},
)
defaultRelay.preload(
(1..50).map {
SyntheticEvents.fakeEvent(
idSeed = 100_000 + it,
kind = AdvertisedRelayListEvent.KIND,
pubKey = SyntheticEvents.hexId(100_000 + it),
)
},
)
val resultChannel = Channel<String>(UNLIMITED) val resultChannel = Channel<String>(UNLIMITED)
val events = mutableListOf<String>() val events = mutableListOf<String>()
@@ -80,38 +94,11 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() {
client.addConnectionListener(listener) client.addConnectionListener(listener)
val filters = val filters = mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(MetadataEvent.KIND), limit = 100)))
mapOf(
RelayUrlNormalizer.normalize("wss://nos.lol") to
listOf(
Filter(
kinds = listOf(MetadataEvent.KIND),
limit = 100,
),
),
)
val filtersShouldIgnore = val filtersShouldIgnore =
mapOf( mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(AdvertisedRelayListEvent.KIND), limit = 500)))
RelayUrlNormalizer.normalize("wss://nos.lol") to
listOf(
Filter(
kinds = listOf(AdvertisedRelayListEvent.KIND),
limit = 500,
),
),
)
val filtersShouldSendAfterEOSE = val filtersShouldSendAfterEOSE =
mapOf( mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(AdvertisedRelayListEvent.KIND), limit = 10)))
RelayUrlNormalizer.normalize("wss://nos.lol") to
listOf(
Filter(
kinds = listOf(AdvertisedRelayListEvent.KIND),
limit = 10,
),
),
)
coroutineScope { coroutineScope {
launch { launch {
@@ -140,29 +127,18 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() {
client.unsubscribe(mySubId) client.unsubscribe(mySubId)
client.removeConnectionListener(listener) client.removeConnectionListener(listener)
client.disconnect()
appScope.cancel()
// The relay may return up to limit events before EOSE; some relays return // The relay may return up to limit events before EOSE; some relays return
// one extra past the requested limit, so don't assert on the exact count. // one extra past the requested limit, so don't assert on the exact count.
// First sub: <= 100 metadata events, then EOSE.
// Second sub: <= 10 advertised relay list events, then EOSE.
val firstEose = events.indexOf("EOSE") val firstEose = events.indexOf("EOSE")
val lastEose = events.lastIndexOf("EOSE") val lastEose = events.lastIndexOf("EOSE")
// both EOSEs must be present and distinct
assertEquals(true, firstEose >= 0) assertEquals(true, firstEose >= 0)
assertEquals(true, lastEose > firstEose) assertEquals(true, lastEose > firstEose)
// last entry is the second EOSE (loop stops on it)
assertEquals(events.size - 1, lastEose) assertEquals(events.size - 1, lastEose)
// first sub stays within its limit (allow +1 for relay quirks)
assertEquals(true, firstEose in 1..101) assertEquals(true, firstEose in 1..101)
// second sub stays within its limit (allow +1 for relay quirks)
assertEquals(true, (lastEose - firstEose - 1) in 1..11) assertEquals(true, (lastEose - firstEose - 1) in 1..11)
// everything before the first EOSE is an event id
assertEquals(true, events.take(firstEose).all { it.length == 64 }) assertEquals(true, events.take(firstEose).all { it.length == 64 })
// everything between the two EOSEs is an event id
assertEquals(true, events.subList(firstEose + 1, lastEose).all { it.length == 64 }) assertEquals(true, events.subList(firstEose + 1, lastEose).all { it.length == 64 })
} }
} }
@@ -19,79 +19,82 @@
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.quartz.nip01Core.relay package com.vitorpamplona.quartz.nip01Core.relay
import com.vitorpamplona.geode.fixtures.SyntheticEvents
import com.vitorpamplona.geode.testing.RelayClientTest
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPages import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPages
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
class NostrClientReqBypassingRelayLimitsTest : BaseNostrClientTest() { class NostrClientReqBypassingRelayLimitsTest : RelayClientTest() {
@Test @Test
fun testDownloadFromRelayReturnsMetadataEvents() = fun testDownloadFromRelayReturnsMetadataEvents() =
runBlocking { runBlocking {
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) // Each event needs a unique pubkey so replaceable kind 0 doesn't
val client = NostrClient(socketBuilder, appScope) // collapse them all to one row.
val corpus =
(1..1000).map {
SyntheticEvents.fakeEvent(
idSeed = it,
kind = MetadataEvent.KIND,
pubKey = SyntheticEvents.hexId(it),
)
}
defaultRelay.preload(corpus)
val events = mutableListOf<Event>() val events = mutableListOf<Event>()
// nos.lol returns only 500 events per req
val totalFound = val totalFound =
client.fetchAllPages( client.fetchAllPages(
relay = "wss://nos.lol", relay = defaultRelayUrl,
filters = filters = listOf(Filter(kinds = listOf(MetadataEvent.KIND), limit = 1000)),
listOf(
Filter(
kinds = listOf(MetadataEvent.KIND),
limit = 1000,
),
),
) { event -> ) { event ->
events.add(event) events.add(event)
} }
client.disconnect() assertEquals(1000, totalFound)
delay(500) assertEquals(1000, events.size)
appScope.cancel()
assertEquals(1000, totalFound, "Expected 1000 events from wss://nos.lol")
assertEquals(1000, events.size, "Events list should be 1000 events")
events.forEach { event -> events.forEach { event ->
assertEquals(MetadataEvent.KIND, event.kind, "All events should be kind ${MetadataEvent.KIND}") assertEquals(MetadataEvent.KIND, event.kind)
} }
} }
@Test @Test
fun testDownloadFromRelayReturnsMetadataAndContactListEvents() = fun testDownloadFromRelayReturnsMetadataAndContactListEvents() =
runBlocking { runBlocking {
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) val metadata =
val client = NostrClient(socketBuilder, appScope) (1..1000).map {
SyntheticEvents.fakeEvent(
idSeed = it,
kind = MetadataEvent.KIND,
pubKey = SyntheticEvents.hexId(it),
)
}
val contacts =
(1..1500).map {
SyntheticEvents.fakeEvent(
idSeed = 100_000 + it,
kind = ContactListEvent.KIND,
pubKey = SyntheticEvents.hexId(100_000 + it),
)
}
defaultRelay.preload(metadata + contacts)
val metadataEvents = mutableListOf<Event>() val metadataEvents = mutableListOf<Event>()
val contactListEvents = mutableListOf<Event>() val contactListEvents = mutableListOf<Event>()
// nos.lol returns only 500 events per req
val totalFound = val totalFound =
client.fetchAllPages( client.fetchAllPages(
relay = "wss://nos.lol", relay = defaultRelayUrl,
filters = filters =
listOf( listOf(
Filter( Filter(kinds = listOf(MetadataEvent.KIND), limit = 1000),
kinds = listOf(MetadataEvent.KIND), Filter(kinds = listOf(ContactListEvent.KIND), limit = 1500),
limit = 1000,
),
Filter(
kinds = listOf(ContactListEvent.KIND),
limit = 1500,
),
), ),
) { event -> ) { event ->
if (event.kind == MetadataEvent.KIND) { if (event.kind == MetadataEvent.KIND) {
@@ -102,18 +105,8 @@ class NostrClientReqBypassingRelayLimitsTest : BaseNostrClientTest() {
} }
} }
client.disconnect() assertEquals(2500, totalFound)
delay(500) assertEquals(1000, metadataEvents.size)
appScope.cancel() assertEquals(1500, contactListEvents.size)
assertEquals(2500, totalFound, "Expected 1000 events from wss://nos.lol")
assertEquals(1000, metadataEvents.size, "Events list should be 1000 events")
assertEquals(1500, contactListEvents.size, "Events list should be 1000 events")
metadataEvents.forEach { event ->
assertEquals(MetadataEvent.KIND, event.kind, "All events should be kind ${MetadataEvent.KIND}")
}
contactListEvents.forEach { event ->
assertEquals(ContactListEvent.KIND, event.kind, "All events should be kind ${ContactListEvent.KIND}")
}
} }
} }
@@ -19,47 +19,31 @@
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.quartz.nip01Core.relay package com.vitorpamplona.quartz.nip01Core.relay
import com.vitorpamplona.geode.testing.RelayClientTest
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
class NostrClientSendAndWaitTest : BaseNostrClientTest() { class NostrClientSendAndWaitTest : RelayClientTest() {
@Test @Test
fun testSendAndWaitForResponse() = fun testSendAndWaitForResponse() =
runBlocking { runBlocking {
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(socketBuilder, appScope)
val randomSigner = NostrSignerInternal(KeyPair()) val randomSigner = NostrSignerInternal(KeyPair())
val event = randomSigner.sign(TextNoteEvent.build("Hello World")) val event = randomSigner.sign(TextNoteEvent.build("Hello World"))
val resultDamus = val relayA = "ws://127.0.0.1:7771/".normalizeRelayUrl()
client.publishAndConfirm( val relayB = "ws://127.0.0.1:7772/".normalizeRelayUrl()
event = event,
relayList = setOf("wss://nostr.bitcoiner.social".normalizeRelayUrl()),
)
val resultNos = val resultA = client.publishAndConfirm(event = event, relayList = setOf(relayA))
client.publishAndConfirm( val resultB = client.publishAndConfirm(event = event, relayList = setOf(relayB))
event = event,
relayList = setOf("wss://nos.lol".normalizeRelayUrl()),
)
client.disconnect() assertEquals(true, resultA)
appScope.cancel() assertEquals(true, resultB)
assertEquals(true, resultDamus)
assertEquals(true, resultNos)
} }
} }
@@ -19,18 +19,16 @@
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.quartz.nip01Core.relay package com.vitorpamplona.quartz.nip01Core.relay
import com.vitorpamplona.geode.fixtures.SyntheticEvents
import com.vitorpamplona.geode.testing.RelayClientTest
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.subscribeAsFlow import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.subscribeAsFlow
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.advanceUntilIdle
@@ -38,7 +36,7 @@ import kotlinx.coroutines.test.runTest
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
class NostrClientSubscriptionAsFlowTest : BaseNostrClientTest() { class NostrClientSubscriptionAsFlowTest : RelayClientTest() {
fun List<Event>.printDates(): String { fun List<Event>.printDates(): String {
val starting = this[0].createdAt val starting = this[0].createdAt
return joinToString { (it.createdAt - starting).toString() } return joinToString { (it.createdAt - starting).toString() }
@@ -48,17 +46,12 @@ class NostrClientSubscriptionAsFlowTest : BaseNostrClientTest() {
@Test @Test
fun testNostrClientSubscriptionAsFlow() = fun testNostrClientSubscriptionAsFlow() =
runTest { runTest {
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) defaultRelay.preload(SyntheticEvents.batch(20, kind = MetadataEvent.KIND))
val client = NostrClient(socketBuilder, appScope)
val flow = val flow =
client.subscribeAsFlow( client.subscribeAsFlow(
relay = "wss://nos.lol", relay = defaultRelayUrl,
filter = filter = Filter(kinds = listOf(MetadataEvent.KIND), limit = 10),
Filter(
kinds = listOf(MetadataEvent.KIND),
limit = 10,
),
) )
var feedStates = listOf<Event>() var feedStates = listOf<Event>()
@@ -75,10 +68,7 @@ class NostrClientSubscriptionAsFlowTest : BaseNostrClientTest() {
advanceUntilIdle() advanceUntilIdle()
} }
job.cancel() // Cancel the collection job job.cancel()
client.disconnect()
appScope.cancel()
assertEquals(10, feedStates.size) assertEquals(10, feedStates.size)
} }
@@ -87,17 +77,12 @@ class NostrClientSubscriptionAsFlowTest : BaseNostrClientTest() {
@Test @Test
fun testNostrClientSubscriptionAsFlowDebouncing() = fun testNostrClientSubscriptionAsFlowDebouncing() =
runTest { runTest {
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) defaultRelay.preload(SyntheticEvents.batch(20, kind = MetadataEvent.KIND))
val client = NostrClient(socketBuilder, appScope)
val flow = val flow =
client.subscribeAsFlow( client.subscribeAsFlow(
relay = "wss://nos.lol", relay = defaultRelayUrl,
filter = filter = Filter(kinds = listOf(MetadataEvent.KIND), limit = 10),
Filter(
kinds = listOf(MetadataEvent.KIND),
limit = 10,
),
) )
var feedStates = listOf<Event>() var feedStates = listOf<Event>()
@@ -109,15 +94,11 @@ class NostrClientSubscriptionAsFlowTest : BaseNostrClientTest() {
} }
} }
// Advance the test dispatcher to ensure emissions are processed
while (feedStates.size < 10) { while (feedStates.size < 10) {
advanceUntilIdle() advanceUntilIdle()
} }
job.cancel() // Cancel the collection job job.cancel()
client.disconnect()
appScope.cancel()
assertEquals(10, feedStates.size) assertEquals(10, feedStates.size)
} }
@@ -19,16 +19,13 @@
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.quartz.nip01Core.relay package com.vitorpamplona.quartz.nip01Core.relay
import com.vitorpamplona.geode.fixtures.SyntheticEvents
import com.vitorpamplona.geode.testing.RelayClientTest
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.StaticSubscription import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.StaticSubscription
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@@ -36,12 +33,11 @@ import kotlinx.coroutines.withTimeoutOrNull
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
class NostrClientSubscriptionTest : BaseNostrClientTest() { class NostrClientSubscriptionTest : RelayClientTest() {
@Test @Test
fun testNostrClientSubscription() = fun testNostrClientSubscription() =
runBlocking { runBlocking {
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) defaultRelay.preload(SyntheticEvents.batch(150, kind = MetadataEvent.KIND))
val client = NostrClient(socketBuilder, appScope)
val resultChannel = Channel<Event>(UNLIMITED) val resultChannel = Channel<Event>(UNLIMITED)
val events = mutableSetOf<Event>() val events = mutableSetOf<Event>()
@@ -49,15 +45,7 @@ class NostrClientSubscriptionTest : BaseNostrClientTest() {
val sub = val sub =
StaticSubscription( StaticSubscription(
client, client,
mapOf( mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(MetadataEvent.KIND), limit = 100))),
RelayUrlNormalizer.normalize("wss://nos.lol") to
listOf(
Filter(
kinds = listOf(MetadataEvent.KIND),
limit = 100,
),
),
),
) { event -> ) { event ->
assertEquals(MetadataEvent.KIND, event.kind) assertEquals(MetadataEvent.KIND, event.kind)
resultChannel.trySend(event) resultChannel.trySend(event)
@@ -71,12 +59,8 @@ class NostrClientSubscriptionTest : BaseNostrClientTest() {
} }
resultChannel.close() resultChannel.close()
sub.close() sub.close()
client.disconnect()
appScope.cancel()
assertEquals(100, events.size) assertEquals(100, events.size)
} }
} }
@@ -19,18 +19,16 @@
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.quartz.nip01Core.relay package com.vitorpamplona.quartz.nip01Core.relay
import com.vitorpamplona.geode.fixtures.SyntheticEvents
import com.vitorpamplona.geode.testing.RelayClientTest
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.fetchAsFlow import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.fetchAsFlow
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.advanceUntilIdle
@@ -38,7 +36,7 @@ import kotlinx.coroutines.test.runTest
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
class NostrClientSubscriptionUntilEoseAsFlowTest : BaseNostrClientTest() { class NostrClientSubscriptionUntilEoseAsFlowTest : RelayClientTest() {
fun List<Event>.printDates(): String { fun List<Event>.printDates(): String {
val starting = this[0].createdAt val starting = this[0].createdAt
return joinToString { (it.createdAt - starting).toString() } return joinToString { (it.createdAt - starting).toString() }
@@ -48,17 +46,12 @@ class NostrClientSubscriptionUntilEoseAsFlowTest : BaseNostrClientTest() {
@Test @Test
fun testNostrClientSubscriptionUntilEoseAsFlow() = fun testNostrClientSubscriptionUntilEoseAsFlow() =
runTest { runTest {
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) defaultRelay.preload(SyntheticEvents.batch(20, kind = MetadataEvent.KIND))
val client = NostrClient(socketBuilder, appScope)
val flow = val flow =
client.fetchAsFlow( client.fetchAsFlow(
relay = "wss://nos.lol", relay = defaultRelayUrl,
filter = filter = Filter(kinds = listOf(MetadataEvent.KIND), limit = 10),
Filter(
kinds = listOf(MetadataEvent.KIND),
limit = 10,
),
) )
var feedStates = listOf<Event>() var feedStates = listOf<Event>()
@@ -70,15 +63,11 @@ class NostrClientSubscriptionUntilEoseAsFlowTest : BaseNostrClientTest() {
} }
} }
// Advance the test dispatcher to ensure emissions are processed
while (feedStates.size < 10) { while (feedStates.size < 10) {
advanceUntilIdle() advanceUntilIdle()
} }
job.cancel() // Cancel the collection job job.cancel()
client.disconnect()
appScope.cancel()
assertEquals(10, feedStates.size) assertEquals(10, feedStates.size)
} }
@@ -87,17 +76,12 @@ class NostrClientSubscriptionUntilEoseAsFlowTest : BaseNostrClientTest() {
@Test @Test
fun testNostrClientSubscriptionUntilEoseAsFlowDebouncing() = fun testNostrClientSubscriptionUntilEoseAsFlowDebouncing() =
runTest { runTest {
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) defaultRelay.preload(SyntheticEvents.batch(20, kind = MetadataEvent.KIND))
val client = NostrClient(socketBuilder, appScope)
val flow = val flow =
client.fetchAsFlow( client.fetchAsFlow(
relay = "wss://nos.lol", relay = defaultRelayUrl,
filter = filter = Filter(kinds = listOf(MetadataEvent.KIND), limit = 10),
Filter(
kinds = listOf(MetadataEvent.KIND),
limit = 10,
),
) )
var feedStates = listOf<Event>() var feedStates = listOf<Event>()
@@ -109,15 +93,11 @@ class NostrClientSubscriptionUntilEoseAsFlowTest : BaseNostrClientTest() {
} }
} }
// Advance the test dispatcher to ensure emissions are processed
while (feedStates.size < 10) { while (feedStates.size < 10) {
advanceUntilIdle() advanceUntilIdle()
} }
job.cancel() // Cancel the collection job job.cancel()
client.disconnect()
appScope.cancel()
assertEquals(10, feedStates.size) assertEquals(10, feedStates.size)
} }
@@ -0,0 +1,96 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip86RelayManagement.server
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class BanStoreTest {
@Test
fun pubkeyBanIsCaseInsensitive() {
val s = BanStore()
s.banPubkey("ABCDEF1234".padEnd(64, '0'), "spam")
assertTrue(s.isBanned("abcdef1234".padEnd(64, '0')))
s.unbanPubkey("abcdef1234".padEnd(64, '0'))
assertFalse(s.isBanned("ABCDEF1234".padEnd(64, '0')))
}
@Test
fun allowListEmptyMeansEveryoneAllowed() {
val s = BanStore()
assertFalse(s.hasAllowList())
// No allow list → policy decision is purely deny-based; the
// store doesn't say a pubkey IS allowed unless it's listed.
assertFalse(s.isAllowedPubkey("aaaa".padEnd(64, '0')))
}
@Test
fun allowListNonEmptyTracksMembers() {
val s = BanStore()
s.allowPubkey("aa".padEnd(64, '0'), "trusted")
assertTrue(s.hasAllowList())
assertTrue(s.isAllowedPubkey("aa".padEnd(64, '0')))
assertFalse(s.isAllowedPubkey("bb".padEnd(64, '0')))
s.unallowPubkey("aa".padEnd(64, '0'))
assertFalse(s.hasAllowList())
}
@Test
fun eventBanRoundTrip() {
val s = BanStore()
s.banEvent("ee".padEnd(64, '0'), "policy")
assertTrue(s.isBannedEvent("EE".padEnd(64, '0')))
s.allowEvent("ee".padEnd(64, '0'))
assertFalse(s.isBannedEvent("ee".padEnd(64, '0')))
}
@Test
fun kindAllowDenyRules() {
val s = BanStore()
// Empty allow + empty deny → every kind is allowed.
assertTrue(s.isKindAllowed(1))
s.allowKind(1)
s.allowKind(7)
// Allow non-empty → only listed kinds are allowed.
assertTrue(s.isKindAllowed(1))
assertFalse(s.isKindAllowed(4))
s.disallowKind(7)
// Disallowing a kind removes it from the allow list and blocks.
assertFalse(s.isKindAllowed(7))
assertTrue(s.isKindAllowed(1))
assertEquals(listOf(1), s.listAllowedKinds())
assertEquals(listOf(7), s.listDisallowedKinds())
}
@Test
fun listsReflectStateForAuditTrail() {
val s = BanStore()
s.banPubkey("aa".padEnd(64, '0'), "spam")
s.banPubkey("bb".padEnd(64, '0'), null)
val banned = s.listBannedPubkeys().toMap()
assertEquals("spam", banned["aa".padEnd(64, '0')])
assertEquals(null, banned["bb".padEnd(64, '0')])
}
}
@@ -0,0 +1,202 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip86RelayManagement.server
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.AllowedPubkey
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedEvent
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedPubkey
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Method
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonPrimitive
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class Nip86ServerTest {
private fun fixture(): Triple<Nip86Server, BanStore, Holder> {
val store = BanStore()
val holder = Holder(Nip11RelayInformation(name = "before", description = "before-desc"))
val server = Nip86Server(banStore = store, infoHolder = holder, store = null)
return Triple(server, store, holder)
}
private class Holder(
var current: Nip11RelayInformation,
) : Nip86Server.InfoHolder {
override fun get() = current
override fun set(info: Nip11RelayInformation) {
current = info
}
}
private val pk = "a".repeat(64)
private val pk2 = "b".repeat(64)
private val eventId = "c".repeat(64)
@Test
fun supportedMethodsRoundTrip() {
runBlocking {
val (server, _, _) = fixture()
val resp = server.dispatch(Nip86Request.supportedMethods())
assertNull(resp.error)
val arr = resp.result as JsonArray
val names = arr.map { it.jsonPrimitive.content }
assertTrue(Nip86Method.SUPPORTED_METHODS in names)
assertTrue(Nip86Method.BAN_PUBKEY in names)
assertTrue(Nip86Method.CHANGE_RELAY_NAME in names)
}
}
@Test
fun banPubkeyMutatesStoreAndListsRoundTripWithReason() {
runBlocking {
val (server, banStore, _) = fixture()
val ok = server.dispatch(Nip86Request.banPubkey(pk, "spam"))
assertEquals(true, (ok.result as JsonPrimitive).boolean)
assertTrue(banStore.isBanned(pk))
val list = server.dispatch(Nip86Request.listBannedPubkeys())
val parsed =
kotlinx.serialization.json.Json
.decodeFromJsonElement(
kotlinx.serialization.builtins.ListSerializer(BannedPubkey.serializer()),
list.result as JsonArray,
)
assertEquals(1, parsed.size)
assertEquals(pk, parsed[0].pubkey)
assertEquals("spam", parsed[0].reason)
server.dispatch(Nip86Request.unbanPubkey(pk))
assertTrue(banStore.listBannedPubkeys().isEmpty())
}
}
@Test
fun allowPubkeyAndListRoundTrip() {
runBlocking {
val (server, banStore, _) = fixture()
server.dispatch(Nip86Request.allowPubkey(pk, "trusted"))
server.dispatch(Nip86Request.allowPubkey(pk2))
assertTrue(banStore.hasAllowList())
val resp = server.dispatch(Nip86Request.listAllowedPubkeys())
val list =
kotlinx.serialization.json.Json
.decodeFromJsonElement(
kotlinx.serialization.builtins.ListSerializer(AllowedPubkey.serializer()),
resp.result as JsonArray,
)
assertEquals(2, list.size)
assertEquals(setOf(pk, pk2), list.map { it.pubkey }.toSet())
}
}
@Test
fun banEventMarksIdAndDeletesFromStoreWhenStorePresent() {
runBlocking {
val (server, banStore, _) = fixture()
server.dispatch(Nip86Request.banEvent(eventId, "off-topic"))
assertTrue(banStore.isBannedEvent(eventId))
val resp = server.dispatch(Nip86Request.listBannedEvents())
val list =
kotlinx.serialization.json.Json
.decodeFromJsonElement(
kotlinx.serialization.builtins.ListSerializer(BannedEvent.serializer()),
resp.result as JsonArray,
)
assertEquals(1, list.size)
assertEquals(eventId, list[0].id)
assertEquals("off-topic", list[0].reason)
// allowevent (which is "unban") removes the entry.
server.dispatch(Nip86Request.allowEvent(eventId))
assertTrue(banStore.listBannedEvents().isEmpty())
}
}
@Test
fun allowKindAndDisallowKind() {
runBlocking {
val (server, banStore, _) = fixture()
server.dispatch(Nip86Request.allowKind(1))
server.dispatch(Nip86Request.allowKind(7))
server.dispatch(Nip86Request.disallowKind(4))
val list = server.dispatch(Nip86Request.listAllowedKinds())
val ints = (list.result as JsonArray).map { it.jsonPrimitive.int }
assertEquals(listOf(1, 7), ints)
assertTrue(banStore.isKindAllowed(1))
assertTrue(banStore.isKindAllowed(7))
assertEquals(false, banStore.isKindAllowed(4))
assertEquals(false, banStore.isKindAllowed(99))
}
}
@Test
fun changeRelayNameDescriptionIconRewriteInfoDoc() {
runBlocking {
val (server, _, holder) = fixture()
assertEquals("before", holder.current.name)
server.dispatch(Nip86Request.changeRelayName("after"))
assertEquals("after", holder.current.name)
server.dispatch(Nip86Request.changeRelayDescription("nice relay"))
assertEquals("nice relay", holder.current.description)
server.dispatch(Nip86Request.changeRelayIcon("https://x/icon.png"))
assertEquals("https://x/icon.png", holder.current.icon)
}
}
@Test
fun unsupportedMethodReturnsError() {
runBlocking {
val (server, _, _) = fixture()
val resp = server.dispatch(Nip86Request(method = "frobnicate"))
assertNotNull(resp.error)
assertTrue(resp.error!!.contains("frobnicate"))
}
}
@Test
fun missingParamsAreReportedAsErrors() {
runBlocking {
val (server, _, _) = fixture()
// banpubkey requires at least one positional param.
val resp = server.dispatch(Nip86Request(method = Nip86Method.BAN_PUBKEY))
assertNotNull(resp.error)
assertTrue(resp.error!!.startsWith("invalid params"))
}
}
}
@@ -0,0 +1,134 @@
/*
* 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.nip98HttpAuth
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertTrue
class Nip98AuthVerifierTest {
private val verifier = Nip98AuthVerifier(now = { 1_000L })
private fun signedToken(
url: String,
method: String,
body: ByteArray? = null,
signer: NostrSignerSync = NostrSignerSync(KeyPair()),
createdAt: Long = 1_000L,
): Pair<String, String> {
val template = HTTPAuthorizationEvent.build(url = url, method = method, file = body, createdAt = createdAt)
val signed = signer.sign(template)
return signer.pubKey to signed.toAuthToken()
}
@Test
fun verifiesAValidPostWithBody() =
runBlocking {
val body = "hello".encodeToByteArray()
val (pubkey, header) = signedToken("http://x/", "POST", body)
val r = verifier.verify(header, "POST", "http://x/", body)
assertIs<Nip98AuthVerifier.Result.Verified>(r)
assertEquals(pubkey, r.pubkey)
}
@Test
fun missingHeaderReturnsMissing() {
runBlocking {
val r = verifier.verify(null, "POST", "http://x/", null)
assertIs<Nip98AuthVerifier.Result.Missing>(r)
}
}
@Test
fun wrongSchemeIsMalformed() {
runBlocking {
val r = verifier.verify("Bearer abc", "POST", "http://x/", null)
assertIs<Nip98AuthVerifier.Result.Malformed>(r)
assertTrue(r.reason.contains("Nostr"))
}
}
@Test
fun urlMismatchIsMalformed() {
runBlocking {
val (_, header) = signedToken("http://x/", "POST")
val r = verifier.verify(header, "POST", "http://y/", null)
assertIs<Nip98AuthVerifier.Result.Malformed>(r)
assertTrue(r.reason.contains("url mismatch"))
}
}
@Test
fun methodMismatchIsMalformed() {
runBlocking {
val (_, header) = signedToken("http://x/", "POST")
val r = verifier.verify(header, "GET", "http://x/", null)
assertIs<Nip98AuthVerifier.Result.Malformed>(r)
assertTrue(r.reason.contains("method mismatch"))
}
}
@Test
fun payloadHashMismatchIsMalformed() {
runBlocking {
val (_, header) = signedToken("http://x/", "POST", "alpha".encodeToByteArray())
val r = verifier.verify(header, "POST", "http://x/", "beta".encodeToByteArray())
assertIs<Nip98AuthVerifier.Result.Malformed>(r)
assertTrue(r.reason.contains("payload hash"))
}
}
@Test
fun staleCreatedAtIsMalformed() {
runBlocking {
// Verifier's clock is fixed at 1_000; sign a token created 5
// minutes earlier — outside the 60s tolerance.
val (_, header) = signedToken("http://x/", "POST", createdAt = 1_000L - 600)
val r = verifier.verify(header, "POST", "http://x/", null)
assertIs<Nip98AuthVerifier.Result.Malformed>(r)
assertTrue(r.reason.contains("created_at"))
}
}
@Test
fun nonAuthEventKindIsMalformed() {
runBlocking {
// Build a kind-1 event by hand and shove it into the header — it
// must be rejected because NIP-98 specifically uses kind 27235.
val signer = NostrSignerSync(KeyPair())
val template =
com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
.build("not an auth event")
val signed = signer.sign(template)
val token =
"Nostr " +
kotlin.io.encoding.Base64
.encode(signed.toJson().encodeToByteArray())
val r = verifier.verify(token, "POST", "http://x/", null)
assertIs<Nip98AuthVerifier.Result.Malformed>(r)
assertTrue(r.reason.contains("kind"))
}
}
}
@@ -410,46 +410,74 @@ private fun buildApplicationPacket(
// insertion-ordered and stays in sync with the streams map. // insertion-ordered and stays in sync with the streams map.
val streamsView = conn.streamsListLocked() val streamsView = conn.streamsListLocked()
if (streamsView.isNotEmpty()) { if (streamsView.isNotEmpty()) {
val start = conn.streamRoundRobinStart % streamsView.size // Strict priority across tiers, round-robin within each tier.
for (i in streamsView.indices) { // Higher-priority streams (e.g. moq-lite newer-sequence group
if (packetBudget <= 64) break // streams) ALWAYS drain ahead of lower-priority ones; the
val stream = streamsView[(start + i) % streamsView.size] // rotating start-index only rotates among same-priority peers.
val streamRemaining = (stream.sendCredit - stream.send.sentOffset).coerceAtLeast(0L) // This is the spec-aligned shape — applying the rotation
// Skip if both stream and connection have no credit; FIN-only // globally over the sorted list would flip cross-tier order on
// (zero-byte) chunks may still go through because they don't // alternating drains, defeating the priority hint entirely.
// consume credit. //
if (streamRemaining <= 0L && connBudget <= 0L && !stream.send.finPending) continue // Default priority is 0; if every stream is at the default, all
val effectiveCap = minOf(streamRemaining, connBudget) // streams form a single tier and iteration order matches the
val maxBytes = // pre-priority round-robin behaviour exactly.
minOf(packetBudget - 32, effectiveCap.coerceAtMost(Int.MAX_VALUE.toLong()).toInt()) //
val chunk = stream.send.takeChunk(maxBytes = maxBytes) ?: continue // Cost: O(N log N) per drain pass plus one transient sorted
if (chunk.data.isNotEmpty() || chunk.fin) { // list. N is small (110 in the moq-lite audio path); if it
frames += // ever grows enough to matter, switch to an indirect index sort
StreamFrame( // or maintain an incrementally-sorted view on setPriority.
streamId = stream.streamId, val sorted =
offset = chunk.offset, if (streamsView.size > 1) streamsView.sortedByDescending { it.priority } else streamsView
data = chunk.data, val rotation = conn.streamRoundRobinStart
fin = chunk.fin, var tierStart = 0
explicitLength = true, outer@ while (tierStart < sorted.size) {
) // Walk the contiguous run of same-priority streams.
// Step C of the deferred-follow-ups pass: track this val tierPriority = sorted[tierStart].priority
// STREAM emission so RFC 9002 retransmit can re-queue var tierEnd = tierStart + 1
// the byte range on loss. SendBuffer.markLost (commit B) while (tierEnd < sorted.size && sorted[tierEnd].priority == tierPriority) tierEnd++
// moves the range from in-flight back to the retransmit val tierSize = tierEnd - tierStart
// queue, and the next takeChunk replays it. val tierRotation = if (tierSize > 1) rotation % tierSize else 0
tokens += for (k in 0 until tierSize) {
RecoveryToken.Stream( if (packetBudget <= 64) break@outer
streamId = stream.streamId, val stream = sorted[tierStart + ((tierRotation + k) % tierSize)]
offset = chunk.offset, val streamRemaining = (stream.sendCredit - stream.send.sentOffset).coerceAtLeast(0L)
length = chunk.data.size.toLong(), // Skip if both stream and connection have no credit; FIN-only
fin = chunk.fin, // (zero-byte) chunks may still go through because they don't
) // consume credit.
packetBudget -= chunk.data.size + 32 if (streamRemaining <= 0L && connBudget <= 0L && !stream.send.finPending) continue
connBudget -= chunk.data.size val effectiveCap = minOf(streamRemaining, connBudget)
conn.sendConnectionFlowConsumed += chunk.data.size val maxBytes =
minOf(packetBudget - 32, effectiveCap.coerceAtMost(Int.MAX_VALUE.toLong()).toInt())
val chunk = stream.send.takeChunk(maxBytes = maxBytes) ?: continue
if (chunk.data.isNotEmpty() || chunk.fin) {
frames +=
StreamFrame(
streamId = stream.streamId,
offset = chunk.offset,
data = chunk.data,
fin = chunk.fin,
explicitLength = true,
)
// Step C of the deferred-follow-ups pass: track this
// STREAM emission so RFC 9002 retransmit can re-queue
// the byte range on loss. SendBuffer.markLost (commit B)
// moves the range from in-flight back to the retransmit
// queue, and the next takeChunk replays it.
tokens +=
RecoveryToken.Stream(
streamId = stream.streamId,
offset = chunk.offset,
length = chunk.data.size.toLong(),
fin = chunk.fin,
)
packetBudget -= chunk.data.size + 32
connBudget -= chunk.data.size
conn.sendConnectionFlowConsumed += chunk.data.size
}
} }
tierStart = tierEnd
} }
conn.streamRoundRobinStart = (start + 1) % streamsView.size conn.streamRoundRobinStart = (rotation + 1) % streamsView.size
} }
if (frames.isEmpty()) return null if (frames.isEmpty()) return null
@@ -47,6 +47,27 @@ class QuicStream(
val send = SendBuffer(bestEffort = bestEffort) val send = SendBuffer(bestEffort = bestEffort)
val receive = ReceiveBuffer() val receive = ReceiveBuffer()
/**
* Send-side scheduling priority. The connection writer's drain loop
* iterates streams by descending priority; same-priority streams keep
* their existing round-robin order. Higher value = drains first under
* congestion. Default 0 matches pre-priority round-robin behaviour for
* every existing call site.
*
* Used by moq-lite group streams: the publisher assigns each new group
* a priority equal to its sequence number so that newer groups
* (fresher audio) drain ahead of older ones when retransmits queue up
* on a lossy link. Mirrors `Publisher::serve_group` in
* `rs/moq-lite/src/lite/publisher.rs` (`stream.set_priority`).
*
* `@Volatile` because callers (e.g. moq-lite's openGroupStream)
* assign from arbitrary coroutines while the writer reads it under
* [com.vitorpamplona.quic.connection.QuicConnection.lock] during a
* drain pass.
*/
@Volatile
var priority: Int = 0
/** /**
* Bytes received and confirmed contiguous, exposed as a flow to the consumer. * Bytes received and confirmed contiguous, exposed as a flow to the consumer.
* *
@@ -327,6 +327,46 @@ class InMemoryQuicPipe(
*/ */
fun buildServerApplicationPacket(frames: List<com.vitorpamplona.quic.frame.Frame>): ByteArray? = buildServerApplicationDatagram(frames) fun buildServerApplicationPacket(frames: List<com.vitorpamplona.quic.frame.Frame>): ByteArray? = buildServerApplicationDatagram(frames)
/**
* Decrypt the application-level (1-RTT) packet inside a client-emitted
* datagram and return its frames in wire order. Test-only helper for
* assertions that depend on per-frame ordering inside a packet (e.g.
* stream priority scheduling). Walks past any coalesced long-header
* packets (Initial / Handshake) at the front of the datagram, since the
* client may still flush ACKs at those levels post-handshake. Returns
* null if no short-header packet is present or decryption fails.
*/
fun decryptClientApplicationFrames(datagram: ByteArray): List<com.vitorpamplona.quic.frame.Frame>? {
if (datagram.isEmpty()) return null
var offset = 0
while (offset < datagram.size) {
val first = datagram[offset].toInt() and 0xFF
if ((first and 0x80) == 0) {
val proto = serverApplicationRx ?: return null
val parsed =
ShortHeaderPacket.parseAndDecrypt(
bytes = datagram,
offset = offset,
dcidLen = serverScid.length,
aead = proto.aead,
key = proto.key,
iv = proto.iv,
hp = proto.hp,
hpKey = proto.hpKey,
largestReceivedInSpace = applicationPnSpace.largestReceived,
) ?: return null
applicationPnSpace.observeInbound(parsed.packet.packetNumber, 0L)
return decodeFrames(parsed.packet.payload)
}
// Long header — skip past it using the encoded length field so
// we can inspect any short-header packet that was coalesced
// after it.
val peeked = LongHeaderPacket.peekHeader(datagram, offset) ?: return null
offset += peeked.totalLength
}
return null
}
private fun buildServerInitialPacket(crypto: ByteArray): ByteArray { private fun buildServerInitialPacket(crypto: ByteArray): ByteArray {
val proto = val proto =
PacketProtection( PacketProtection(
@@ -110,6 +110,86 @@ class QuicConnectionWriterTest {
} }
} }
@Test
fun writer_drains_higher_priority_streams_before_lower_priority() {
// T11.3 follow-up: priority must dominate iteration order on
// EVERY drain, not just the first. The naive "sort by priority
// then apply the existing rotating start globally" shape looks
// right at a glance but the rotating start advances on every
// drain, so cross-tier ordering flips on alternate drains and
// the priority hint is silently defeated. The correct shape is
// strict priority across tiers + round-robin only within a
// tier, which we pin here by draining TWICE and asserting the
// higher-priority stream's StreamFrame lands first in BOTH
// packets. Low-priority stream is opened FIRST so insertion-
// order can't accidentally pass for priority ordering.
runBlocking {
val (client, pipe) = connectedClient()
val low = client.openBidiStream()
val high = client.openBidiStream()
low.priority = 0
high.priority = 10
repeat(2) { round ->
low.send.enqueue(ByteArray(200) { 0xAA.toByte() })
high.send.enqueue(ByteArray(200) { 0xBB.toByte() })
val datagram = drainOutbound(client, nowMillis = 0L)
assertNotNull(datagram, "drain on round $round must emit a packet")
val frames = pipe.decryptClientApplicationFrames(datagram)
assertNotNull(frames, "decrypt must succeed on round $round")
val streamFrames = frames.filterIsInstance<StreamFrame>()
assertEquals(
2,
streamFrames.size,
"expected one StreamFrame per stream on round $round, got $streamFrames",
)
assertEquals(
high.streamId,
streamFrames[0].streamId,
"higher-priority stream must drain first on round $round; " +
"saw ${streamFrames.map { it.streamId }}",
)
assertEquals(low.streamId, streamFrames[1].streamId, "low second on round $round")
}
}
}
@Test
fun writer_round_robins_within_a_priority_tier() {
// Regression guard for the tier-local round-robin: same-priority
// streams must still rotate so an early-opened stream doesn't
// monopolise a packet's stream-frame slot indefinitely. We open
// three streams at the default (0) priority and verify the
// rotating start advances by one per drain, matching the
// pre-priority behaviour.
runBlocking {
val (client, pipe) = connectedClient()
val a = client.openBidiStream()
val b = client.openBidiStream()
val c = client.openBidiStream()
// All default priority — single tier, three streams.
val expectedRotation =
listOf(
listOf(a.streamId, b.streamId, c.streamId),
listOf(b.streamId, c.streamId, a.streamId),
listOf(c.streamId, a.streamId, b.streamId),
)
for ((round, expected) in expectedRotation.withIndex()) {
a.send.enqueue(ByteArray(64) { 0xA1.toByte() })
b.send.enqueue(ByteArray(64) { 0xB2.toByte() })
c.send.enqueue(ByteArray(64) { 0xC3.toByte() })
val datagram = drainOutbound(client, nowMillis = 0L)
assertNotNull(datagram, "drain $round must emit a packet")
val frames = pipe.decryptClientApplicationFrames(datagram)
assertNotNull(frames, "decrypt must succeed on round $round")
val ids = frames.filterIsInstance<StreamFrame>().map { it.streamId }
assertEquals(expected, ids, "round $round round-robin order")
}
}
}
@Test @Test
fun writer_respects_connection_level_send_credit_cap() { fun writer_respects_connection_level_send_credit_cap() {
// Audit-4 #9: pre-fix the writer ignored sendConnectionFlowCredit // Audit-4 #9: pre-fix the writer ignored sendConnectionFlowCredit
+1
View File
@@ -34,6 +34,7 @@ rootProject.name = "Amethyst"
include ':amethyst' include ':amethyst'
include ':benchmark' include ':benchmark'
include ':quartz' include ':quartz'
include ':geode'
include ':commons' include ':commons'
include ':ammolite' include ':ammolite'
include ':quic' include ':quic'