diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 62abe6616..5c3e0553c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -111,11 +111,14 @@ jobs: # populating the cache for the next run. # # 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. - name: Pre-fetch VLC + UPX archives env: - VLC_VERSION: "3.0.21" + VLC_VERSION: "3.0.20" UPX_VERSION: "4.2.4" run: | set -euo pipefail diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/ParticipantsGrid.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/ParticipantsGrid.kt index 0091834e1..d79d7eb29 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/ParticipantsGrid.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/ParticipantsGrid.kt @@ -57,6 +57,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.res.pluralStringResource @@ -420,28 +421,18 @@ private fun MemberCell( }, 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 = Modifier - .drawBehind { - 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) + .border(animatedRingWidth, animatedRingColor, CircleShape) .let { if (member.absent) it.alpha(0.5f) else it } val user = remember(member.pubkey) { @@ -480,58 +471,53 @@ private fun MemberCell( horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier.fillMaxWidth().padding(vertical = 4.dp), ) { - 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( + // Outer Box paints the glow halo + detached outer ring on a + // canvas that's bigger than the avatar by [ringPadding]. The + // inner Box keeps its tight-to-avatar bounds so badge corner + // alignment (TopStart, TopEnd, BottomCenter, BottomEnd) still + // tracks the avatar circle, not the padded outer area. + Box( + modifier = + Modifier.drawBehind { + val avatarRadiusPx = avatarSize.toPx() / 2f + val cx = size.width / 2f + val cy = size.height / 2f + if (animatedGlowAlpha > 0.001f) { + val extra = MAX_GLOW_RADIUS.toPx() * clampedLevel + drawCircle( + color = NEST_SPEAKING_COLOR.copy(alpha = animatedGlowAlpha), + radius = avatarRadiusPx + extra, + center = Offset(cx, cy), + ) + } + if (animatedOuterRingAlpha > 0.001f && animatedOuterRingWidth > 0.dp) { + val strokePx = animatedOuterRingWidth.toPx() + val ringRadius = avatarRadiusPx + OUTER_RING_GAP.toPx() + strokePx / 2f + drawCircle( + color = NEST_SPEAKING_COLOR.copy(alpha = animatedOuterRingAlpha), + radius = ringRadius, + center = Offset(cx, cy), + style = Stroke(width = strokePx), + ) + } + }, + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier.padding(ringPadding), + contentAlignment = Alignment.Center, + ) { + AvatarAndBadges( + member = member, + avatarSize = avatarSize, + accountViewModel = accountViewModel, + avatarModifier = avatarModifier, + onClick = onClick, + onLongClick = onLongClick, + isConnecting = isConnecting, + showMicBadge = showMicBadge, 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), ) } } @@ -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, +) { + 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 * a hand glyph at the top-right, animated in a subtle vertical diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt index 5c85b2735..33a5f1dbe 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt @@ -302,14 +302,26 @@ class NestViewModel( /** * [kotlin.time.TimeMark] of the most recent recycle the cliff - * detector triggered, or `null` if it has never fired. Acts as a - * cooldown so a single cliff event doesn't kick off multiple - * recycles back-to-back while the wrapper is mid-handshake on - * the new session — the new session has no incoming frames yet, - * which would otherwise trip the detector again immediately. + * detector triggered, or `null` if it has never fired. Combined + * with [consecutiveCliffRecycles] to drive the per-attempt + * backoff schedule in [computeStalledSpeakers] — short backoff + * after the first failed recycle so a single moq-rs cliff + * 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 + /** + * 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 * background `subscribeCatalog` collector launched in @@ -320,7 +332,19 @@ class NestViewModel( */ private val catalogJobs = mutableMapOf() private var requestedSpeakers: Set = 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 private var speaker: NestsSpeaker? = null @@ -1050,7 +1074,7 @@ class NestViewModel( /** * Wait briefly for [pubkey]'s catalog to land in [_speakerCatalogs] * 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 * arrived within [timeoutMs]) or when the catalog declares * unsupported values (channelCount outside `1..2`, or non-positive @@ -1084,7 +1108,7 @@ class NestViewModel( val channels = when { declaredChannels == null -> { - AudioFormat.CHANNELS + AudioFormat.DEFAULT_CHANNELS } declaredChannels !in 1..2 -> { @@ -1092,7 +1116,7 @@ class NestViewModel( "publisher catalog for pubkey='${pubkey.take(8)}' declares numberOfChannels=$declaredChannels " + "(only 1 / 2 supported); falling back to mono" } - AudioFormat.CHANNELS + AudioFormat.DEFAULT_CHANNELS } else -> { @@ -1389,6 +1413,7 @@ class NestViewModel( cliffDetectorJob = null lastFrameAt.clear() lastCliffRecycleAt = null + consecutiveCliffRecycles = 0 // Audio-focus observation only ends on the final teardown — // a transient disconnect+reconnect (user retry, room swap) // 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] - * coroutine that clears it once they go quiet. Called once per - * MoQ object received on the speaker's track. + * Per-frame heartbeat. Called once per MoQ object received on the + * speaker's track — i.e. once per ~20 ms regardless of whether + * 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) { if (closed) return @@ -1483,12 +1516,27 @@ class NestViewModel( lastFrameAt[pubkey] = kotlin.time.TimeSource.Monotonic .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 // overlay. Subsequent frames are no-ops here. if (_uiState.value.connectingSpeakers.contains(pubkey)) { _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)) { _uiState.update { it.copy(speakingNow = (it.speakingNow + pubkey).toPersistentSet()) } } @@ -1570,43 +1618,53 @@ class NestViewModel( announcedSpeakers = announced, lastFrameAt = lastFrameAt, lastRecycleAt = lastCliffRecycleAt, + consecutiveFailedRecycles = consecutiveCliffRecycles, cliffTimeoutMs = ROOM_AUDIO_CLIFF_TIMEOUT_MS, - cooldownMs = ROOM_AUDIO_CLIFF_COOLDOWN_MS, + postRecycleGraceMs = ROOM_AUDIO_CLIFF_RECYCLE_GRACE_MS, ) if (stalled.isEmpty()) continue + consecutiveCliffRecycles += 1 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") { "\"timeout_ms\":$ROOM_AUDIO_CLIFF_TIMEOUT_MS," + + "\"consecutive\":$consecutiveCliffRecycles," + "\"stalled\":${com.vitorpamplona.nestsclient.trace.jsonArrStr(stalled)}" } - val recycleMark = + lastCliffRecycleAt = kotlin.time.TimeSource.Monotonic .markNow() - lastCliffRecycleAt = recycleMark - // Reset `lastFrameAt` for every stalled pubkey so - // the cliff timer starts counting from the recycle - // moment, not from the old (pre-recycle) last - // frame timestamp. Without this reset the next - // tick after the cooldown immediately re-trips — - // because `lastFrameAt[pubkey].elapsedNow()` is - // still the giant pre-recycle value plus the - // cooldown window. Production logs at commit - // ea08c43 showed exactly this: 4 recycles in - // ~30 s eventually drove the relay into a - // "subscribe stream FIN before reply" loop where - // it refused fresh subscribes entirely. Using - // the recycle moment as a synthetic frame event - // gives the new session [ROOM_AUDIO_CLIFF_TIMEOUT_MS] - // wall-clock to deliver before the next cliff - // check, matching the expectation a freshly- - // attached subscription should clear inside - // that window if the relay is healthy. - for (pubkey in stalled) { - lastFrameAt[pubkey] = recycleMark + // Note: we deliberately do NOT overwrite + // `lastFrameAt[pubkey]` with the recycle moment. + // Keeping the real-frame timestamp lets the next + // tick distinguish "the recycle delivered audio, + // we then re-stalled" (lastFrameAt advances past + // lastCliffRecycleAt — counter resets in + // `onSpeakerActivity`, attempt = 0, fire-eligible + // immediately) from "the recycle did nothing, + // still no frames" (lastFrameAt unchanged, + // counter keeps growing, backoff escalates). + // The previous reset collapsed both cases into + // a flat 30 s wait, which made a single failed + // recycle sound like a 30 s+ dropout to the + // user even when retrying earlier would have + // recovered. + try { + l.recycleSession() + } catch (ce: CancellationException) { + // User left mid-recycle — exit promptly + // rather than letting the next delay() be + // 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 { com.vitorpamplona.quartz.utils.Log @@ -1641,6 +1699,20 @@ class NestViewModel( ) { if (closed) return 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() } @@ -1799,12 +1871,29 @@ sealed class BroadcastUiState { } /** - * How long a speaker stays "speaking" after their last received MoQ object. - * Roughly 12 × the 20 ms Opus frame so brief packet jitter doesn't make the - * indicator flicker. + * How long a speaker stays "speaking" after their last decoded frame + * over the [SPEAKING_LEVEL_THRESHOLD]. Roughly 12 × the 20 ms Opus + * 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 +/** + * 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 * `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 /** - * Cooldown after a cliff-detector-driven recycle. Suppresses - * back-to-back recycles while the wrapper is mid-handshake on the - * fresh session AND while the relay is recovering its - * per-subscriber forward queue. + * Post-recycle handshake grace. NEVER recycle again within this + * window of the previous recycle — the wrapper is still tearing + * down + reopening the QUIC session, so a "no frames since recycle" + * reading is just the handshake, not a relay-side cliff. * - * Production logs at commit ea08c43 showed an 8 s cooldown was - * not enough — moq-rs needed longer to recover between aggressive - * recycles, and 4 recycles in ~30 s drove the relay into a - * "subscribe stream FIN before reply" loop that refused all - * subsequent subscribes. 30 s gives the relay's per-subscriber - * 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. + * 3 s covers a typical re-handshake (drain old session UNSUB + + * UNANNOUNCE + WT_CLOSE + open new WebTransport + SUBSCRIBE + + * SUBSCRIBE_OK). Anything shorter risks recycling inside our own + * handshake window; longer wastes audible silence in the recovering- + * within-grace case. */ -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 @@ -1954,6 +2053,36 @@ const val ROOM_AUDIO_CLIFF_COOLDOWN_MS: Long = 30_000L */ 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 * 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 * [cliffTimeoutMs]. * - * Suppression: - * - if [lastRecycleAt] is non-null and less than [cooldownMs] has - * elapsed since it, returns empty (no cascading recycles while - * the wrapper is mid-handshake on a fresh session). + * Suppression (when [lastRecycleAt] is non-null): + * - while elapsed since [lastRecycleAt] is below [postRecycleGraceMs], + * never recycle — we're still inside the previous handshake window. + * - 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( activeSpeakers: Set, announcedSpeakers: Set, lastFrameAt: Map, lastRecycleAt: kotlin.time.TimeMark?, + consecutiveFailedRecycles: Int = 0, 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 { - if (lastRecycleAt != null && - lastRecycleAt.elapsedNow().inWholeMilliseconds < cooldownMs - ) { - return emptyList() + if (lastRecycleAt != null) { + val sinceRecycleMs = lastRecycleAt.elapsedNow().inWholeMilliseconds + if (sinceRecycleMs < postRecycleGraceMs) return emptyList() + if (sinceRecycleMs < backoffForAttempt(consecutiveFailedRecycles)) return emptyList() } return activeSpeakers .asSequence() diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/CliffDetectorTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/CliffDetectorTest.kt index ac51d5ce8..3aad186bb 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/CliffDetectorTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/CliffDetectorTest.kt @@ -189,21 +189,16 @@ class CliffDetectorTest { } @Test - fun cooldownSuppressesRecycleEvenWhenStalled() { - // After a recycle, the wrapper opens a fresh QUIC transport. - // The new session has no `lastFrameAt` entries yet for any - // pubkey; without a cooldown we would re-trigger on the - // very next 1 s tick because the prior tick's stalled - // 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. + fun postRecycleGraceSuppressesEvenAtAttemptZero() { + // Inside the 3 s post-recycle handshake window, never recycle + // — the wrapper is still tearing down + reopening QUIC, so + // "no frames since recycle" is just the handshake, not a + // relay-side cliff. val ts = TestTimeSource() val frameMark = ts.markNow() - ts += 4_500.milliseconds // past threshold - val recycleMark = ts.markNow() // recycle just fired - ts += 5_000.milliseconds // 5 s into cooldown — well within 30 s window + ts += 4_500.milliseconds + val recycleMark = ts.markNow() + ts += 2_000.milliseconds // inside 3 s grace val result = computeStalledSpeakers( @@ -211,21 +206,22 @@ class CliffDetectorTest { announcedSpeakers = setOf(ALICE), lastFrameAt = mapOf(ALICE to frameMark), lastRecycleAt = recycleMark, + consecutiveFailedRecycles = 0, ) assertTrue(result.isEmpty()) } @Test - fun cooldownReleasesAfterTimeoutPasses() { - // Once cooldown elapses, a still-stalled subscription - // becomes eligible to recycle again. Important for the - // case where the recycle didn't actually fix the cliff — - // we want a second attempt rather than getting wedged. + fun attemptZeroFiresImmediatelyOnceGracePasses() { + // After a recovered-then-restall pattern: counter has been + // reset by `onSpeakerActivity`, so even though there's a + // prior recycleMark, the next cliff fires as soon as the + // 3 s grace passes — no extended backoff. val ts = TestTimeSource() val frameMark = ts.markNow() ts += 4_500.milliseconds 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 = computeStalledSpeakers( @@ -233,10 +229,164 @@ class CliffDetectorTest { announcedSpeakers = setOf(ALICE), lastFrameAt = mapOf(ALICE to frameMark), lastRecycleAt = recycleMark, + consecutiveFailedRecycles = 0, ) 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 fun customTimeoutsAreHonored() { // Defaults are wired into the production VM, but the function diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModelTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModelTest.kt index 5c7415ab5..59b7a91ca 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModelTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModelTest.kt @@ -55,6 +55,7 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds /** * Drives [NestViewModel] with a fake [NestsListenerConnector] and @@ -72,6 +73,16 @@ import kotlin.test.assertTrue */ @OptIn(ExperimentalCoroutinesApi::class) 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() + @BeforeTest fun setupMainDispatcher() { Dispatchers.setMain(UnconfinedTestDispatcher()) @@ -79,12 +90,34 @@ class NestViewModelTest { @AfterTest 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() } + /** + * 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 fun connectShowsConnectingThenConnected() = - runTest { + runVmTest { val fakeListener = FakeNestsListener() val vm = newViewModel { fakeListener } @@ -103,7 +136,7 @@ class NestViewModelTest { @Test fun listenerFailedSurfacesAsUiFailed() = - runTest { + runVmTest { val fakeListener = FakeNestsListener() val vm = newViewModel { fakeListener } @@ -117,7 +150,7 @@ class NestViewModelTest { @Test fun connectorThrowsBecomesUiFailed() = - runTest { + runVmTest { val vm = newViewModel { throw NestsException("dns blew up") } vm.connect() @@ -129,7 +162,7 @@ class NestViewModelTest { @Test fun setMutedFlipsUiStateAndIsRetained() = - runTest { + runVmTest { val vm = newViewModel { FakeNestsListener() } assertFalse(vm.uiState.value.isMuted) @@ -141,7 +174,7 @@ class NestViewModelTest { @Test fun onStageNowDefaultsTrueAndSetOnStageFlipsIt() = - runTest { + runVmTest { val vm = newViewModel { FakeNestsListener() } // Defaults to true so a freshly-joined speaker advertises @@ -156,7 +189,7 @@ class NestViewModelTest { @Test fun onPresenceEventPopulatesPresencesMapAndDedupesByPubkey() = - runTest { + runVmTest { val vm = newViewModel { FakeNestsListener() } val alice = "a".repeat(64) @@ -187,7 +220,7 @@ class NestViewModelTest { @Test fun evictStalePresencesDropsOldPeers() = - runTest { + runVmTest { val vm = newViewModel { FakeNestsListener() } val alice = "a".repeat(64) val bob = "b".repeat(64) @@ -219,7 +252,7 @@ class NestViewModelTest { @Test fun onChatEventAccumulatesMessagesSortedByCreatedAt() = - runTest { + runVmTest { val vm = newViewModel { FakeNestsListener() } val alice = "a".repeat(64) @@ -255,7 +288,7 @@ class NestViewModelTest { @Test fun onChatEventDedupesByEventId() = - runTest { + runVmTest { val vm = newViewModel { FakeNestsListener() } val alice = "a".repeat(64) val msg = @@ -281,7 +314,7 @@ class NestViewModelTest { @Test fun onReactionEventGroupsByTargetAndEvictsOnTick() = - runTest { + runVmTest { val vm = newViewModel { FakeNestsListener() } val alice = "a".repeat(64) val bob = "b".repeat(64) @@ -326,7 +359,7 @@ class NestViewModelTest { @Test fun onKickFlipsWasKickedAndDisconnects() = - runTest { + runVmTest { val fakeListener = FakeNestsListener() val vm = newViewModel { fakeListener } vm.connect() @@ -343,7 +376,7 @@ class NestViewModelTest { @Test fun onKickIsIdempotent() = - runTest { + runVmTest { val fakeListener = FakeNestsListener() val vm = newViewModel { fakeListener } vm.connect() @@ -377,7 +410,7 @@ class NestViewModelTest { @Test fun connectIsIdempotentWhileConnecting() = - runTest { + runVmTest { val fakeListener = FakeNestsListener() var connectCalls = 0 val vm = @@ -395,7 +428,7 @@ class NestViewModelTest { @Test fun disconnectReturnsToIdleAndClosesListener() = - runTest { + runVmTest { val fakeListener = FakeNestsListener() val vm = newViewModel { fakeListener } @@ -411,7 +444,7 @@ class NestViewModelTest { @Test fun connectingStepMapsThroughToUiStep() = - runTest { + runVmTest { val fakeListener = FakeNestsListener() val vm = newViewModel { fakeListener } @@ -429,7 +462,7 @@ class NestViewModelTest { @Test fun speakingNowClearsOnTeardown() = - runTest { + runVmTest { val fakeListener = FakeNestsListener() val vm = newViewModel { fakeListener } @@ -466,7 +499,7 @@ class NestViewModelTest { // Wire to the test's backgroundScope so close calls run during // the test rather than escaping to the real GlobalScope. cleanupScope = backgroundScope, - ) + ).also { createdVms.add(it) } private class FakeNestsListener : NestsListener { private val mutable = MutableStateFlow(NestsListenerState.Idle) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomSpeakerCatalogTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomSpeakerCatalogTest.kt index a4941b64a..5c3471546 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomSpeakerCatalogTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomSpeakerCatalogTest.kt @@ -189,7 +189,7 @@ class RoomSpeakerCatalogTest { @Test fun stripPrefixRoundTripsCanonicalCatalog() { - // The catalog payload `MoqLiteHangCatalog.opusMono48k(...)` emits + // The catalog payload `MoqLiteHangCatalog.opus48k(...)` emits // (in `:nestsClient`) MUST round-trip through this parser — the // two classes target the same wire shape independently because // `:nestsClient` does not depend on `:commons` and vice versa. diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 9afb1a8b5..c9797b90d 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -133,7 +133,10 @@ compose.desktop { } 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) shouldIncludeAllVlcFiles.set(true) pathToCopyVlcLinuxFilesTo.set(file("src/jvmMain/appResources/linux/vlc")) diff --git a/geode/build.gradle.kts b/geode/build.gradle.kts new file mode 100644 index 000000000..3b05db1cf --- /dev/null +++ b/geode/build.gradle.kts @@ -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().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) +} diff --git a/geode/config.example.toml b/geode/config.example.toml new file mode 100644 index 000000000..5835a5527 --- /dev/null +++ b/geode/config.example.toml @@ -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" diff --git a/geode/plans/2026-05-07-connection-scaling.md b/geode/plans/2026-05-07-connection-scaling.md new file mode 100644 index 000000000..fb1f8b353 --- /dev/null +++ b/geode/plans/2026-05-07-connection-scaling.md @@ -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 10–30k. 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` for subs (likely 1–10 entries) | ~negligible | +| `NegSessionRegistry` | `HashMap` — 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` 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. diff --git a/geode/plans/2026-05-07-event-ingestion-batching.md b/geode/plans/2026-05-07-event-ingestion-batching.md new file mode 100644 index 000000000..e6df99ebf --- /dev/null +++ b/geode/plans/2026-05-07-event-ingestion-batching.md @@ -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: **~5–10× 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 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. diff --git a/geode/plans/2026-05-07-live-broadcast-fanout-index.md b/geode/plans/2026-05-07-live-broadcast-fanout-index.md new file mode 100644 index 000000000..9a0227a08 --- /dev/null +++ b/geode/plans/2026-05-07-live-broadcast-fanout-index.md @@ -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` 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>` + 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>() +private val byKind = ConcurrentHashMap>() +private val byTag = ConcurrentHashMap>() +private val unindexed = CopyOnWriteArraySet() // 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. diff --git a/geode/plans/2026-05-07-negentropy-large-corpus.md b/geode/plans/2026-05-07-negentropy-large-corpus.md new file mode 100644 index 000000000..0cb31ccd9 --- /dev/null +++ b/geode/plans/2026-05-07-negentropy-large-corpus.md @@ -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(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>` (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. diff --git a/geode/plans/README.md b/geode/plans/README.md new file mode 100644 index 000000000..6524503b3 --- /dev/null +++ b/geode/plans/README.md @@ -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) | 5–10× 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. diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/LocalRelayServer.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/LocalRelayServer.kt new file mode 100644 index 000000000..3840bf482 --- /dev/null +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/LocalRelayServer.kt @@ -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 = 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 = 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) } + } + } +} diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt new file mode 100644 index 000000000..228c40709 --- /dev/null +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt @@ -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 ` + * 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 TOML config (see config.example.toml) + * --host bind address (default from config or 0.0.0.0) + * --port tcp port (default from config or 7447, 0 to autobind) + * --path

ws path (default from config or /) + * --info NIP-11 doc file (overrides [info] section) + * --db 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) { + 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() + + 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(EmptyPolicy) { acc, p -> + if (acc === EmptyPolicy) p else acc + p + } +} + +private class Args( + private val opts: Map, + private val flags: Set, +) { + fun opt(k: String) = opts[k] + + fun flag(k: String) = k in flags +} + +private fun parseArgs(args: Array): Args { + val opts = mutableMapOf() + val flags = mutableSetOf() + 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) +} diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/Relay.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/Relay.kt new file mode 100644 index 000000000..48c339f4e --- /dev/null +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/Relay.kt @@ -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) { + 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() +} diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/RelayHub.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/RelayHub.kt new file mode 100644 index 000000000..6a6866306 --- /dev/null +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/RelayHub.kt @@ -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() + + @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 = 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/") + } +} diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/RelayInfo.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/RelayInfo.kt new file mode 100644 index 000000000..bd2d9c55e --- /dev/null +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/RelayInfo.kt @@ -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 = + 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)) + } +} diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/config/RelayConfig.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/config/RelayConfig.kt new file mode 100644 index 000000000..b1b99634d --- /dev/null +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/config/RelayConfig.kt @@ -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? = null, + val privacy_policy: String? = null, + val terms_of_service: String? = null, + val relay_countries: List? = null, + val language_tags: List? = null, + val tags: List? = 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 = emptyList(), + val pubkey_blacklist: List = emptyList(), + val kind_whitelist: List = emptyList(), + val kind_blacklist: List = 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 = 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(toml) + + /** Load a TOML config file. */ + fun fromFile(file: File): RelayConfig = mapper.decode(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}" + } + } +} diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/persistence/RelayStateStore.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/persistence/RelayStateStore.kt new file mode 100644 index 000000000..7bb7f0d49 --- /dev/null +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/persistence/RelayStateStore.kt @@ -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 = emptyList(), + val allowedPubkeys: List = emptyList(), + val bannedEvents: List = emptyList(), + val allowedKinds: List = emptyList(), + val disallowedKinds: List = emptyList(), +) + +@Serializable +data class BannedEntry( + val key: String, + val reason: String? = null, +) diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/server/Nip86HttpRoute.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/server/Nip86HttpRoute.kt new file mode 100644 index 000000000..3e1e3534e --- /dev/null +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/server/Nip86HttpRoute.kt @@ -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, + 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(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" } ?: ""), + ) + } + } +} diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/server/WebSocketSessionPump.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/server/WebSocketSessionPump.kt new file mode 100644 index 000000000..7cd700a33 --- /dev/null +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/server/WebSocketSessionPump.kt @@ -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(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 + } +} diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/GracefulShutdownTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/GracefulShutdownTest.kt new file mode 100644 index 000000000..04d55a660 --- /dev/null +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/GracefulShutdownTest.kt @@ -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(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?, + ) { + 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(UNLIMITED) + val gotEose = Channel(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?, + ) { + 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(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() + } + } +} diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/LocalRelayServerTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/LocalRelayServerTest.kt new file mode 100644 index 000000000..6489e794f --- /dev/null +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/LocalRelayServerTest.kt @@ -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( + 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?, + ) { + 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( + kotlinx.coroutines.channels.Channel.UNLIMITED, + ) + val gotEose = + kotlinx.coroutines.channels.Channel( + 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?, + ) { + ch.trySend(event) + } + + override fun onEose( + relay: com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl, + forFilters: List?, + ) { + 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") + } +} diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/Nip01ComplianceTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/Nip01ComplianceTest.kt new file mode 100644 index 000000000..7a5a865cc --- /dev/null +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/Nip01ComplianceTest.kt @@ -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> = 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(UNLIMITED) + val ch2 = Channel(UNLIMITED) + val eose1 = Channel(UNLIMITED) + val eose2 = Channel(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?, + ) { + ch1.trySend(event) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + 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?, + ) { + ch2.trySend(event) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + 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(UNLIMITED) + val gotEose = Channel(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?, + ) { + ch.trySend(event) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + 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(UNLIMITED) + val gotEose = Channel(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?, + ) { + ch.trySend(event) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + 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(UNLIMITED) + val gotEose = Channel(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?, + ) { + ch.trySend(event) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + 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() + val eosed = mutableSetOf() + val ch = Channel(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?, + ) { + received[relay] = event.content + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + 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]) + } +} diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/Nip09DeletionTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/Nip09DeletionTest.kt new file mode 100644 index 000000000..d721d6686 --- /dev/null +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/Nip09DeletionTest.kt @@ -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 { + val ch = kotlinx.coroutines.channels.Channel(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?, + ) { + ch.trySend(Either.Ev(event)) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + ch.trySend(Either.Eose) + } + }, + ) + val events = mutableListOf() + 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", + ) + } +} diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/Nip40ExpirationTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/Nip40ExpirationTest.kt new file mode 100644 index 000000000..a4dc6c3ef --- /dev/null +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/Nip40ExpirationTest.kt @@ -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", + ) + } +} diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/Nip62VanishTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/Nip62VanishTest.kt new file mode 100644 index 000000000..620c88a91 --- /dev/null +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/Nip62VanishTest.kt @@ -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") + } +} diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/Nip77NegentropyTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/Nip77NegentropyTest.kt new file mode 100644 index 000000000..d399457b0 --- /dev/null +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/Nip77NegentropyTest.kt @@ -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 = 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 { + 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() + val needIds = mutableSetOf() + 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() + } + } +} diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/admin/Nip86EndToEndTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/admin/Nip86EndToEndTest.kt new file mode 100644 index 000000000..f83ab0d85 --- /dev/null +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/admin/Nip86EndToEndTest.kt @@ -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(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(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() + } + } +} diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/config/RelayConfigTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/config/RelayConfigTest.kt new file mode 100644 index 000000000..166a38050 --- /dev/null +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/config/RelayConfigTest.kt @@ -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) + } +} diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/perf/LoadBenchmark.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/perf/LoadBenchmark.kt new file mode 100644 index 000000000..a083dd812 --- /dev/null +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/perf/LoadBenchmark.kt @@ -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() + 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?, + ) { + val now = System.nanoTime() + firstReceiveNs.compareAndSet(-1, now) + lastReceiveNs.set(now) + received.incrementAndGet() + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + 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() + } + } +} diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/persistence/PersistenceTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/persistence/PersistenceTest.kt new file mode 100644 index 000000000..cf6eacdaa --- /dev/null +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/persistence/PersistenceTest.kt @@ -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, + ) +} diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/policies/PoliciesIntegrationTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/policies/PoliciesIntegrationTest.kt new file mode 100644 index 000000000..0236e2a21 --- /dev/null +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/policies/PoliciesIntegrationTest.kt @@ -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 { + 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() + } + } +} diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/policies/PoliciesTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/policies/PoliciesTest.kt new file mode 100644 index 000000000..5e0721b9f --- /dev/null +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/policies/PoliciesTest.kt @@ -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", + ) + } +} diff --git a/geode/src/testFixtures/kotlin/com/vitorpamplona/geode/fixtures/RelayFixtures.kt b/geode/src/testFixtures/kotlin/com/vitorpamplona/geode/fixtures/RelayFixtures.kt new file mode 100644 index 000000000..7384be0c4 --- /dev/null +++ b/geode/src/testFixtures/kotlin/com/vitorpamplona/geode/fixtures/RelayFixtures.kt @@ -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 = OptimizedJsonMapper.fromJsonToEventList(loadString("nostr_vitor_short.json")) + + /** Loads `nostr_vitor_startup_data.json.gz` — the larger Vitor startup corpus. */ + fun vitorStartup(): List = OptimizedJsonMapper.fromJsonToEventList(loadGzipString("nostr_vitor_startup_data.json")) +} diff --git a/geode/src/testFixtures/kotlin/com/vitorpamplona/geode/fixtures/SyntheticEvents.kt b/geode/src/testFixtures/kotlin/com/vitorpamplona/geode/fixtures/SyntheticEvents.kt new file mode 100644 index 000000000..9e7365358 --- /dev/null +++ b/geode/src/testFixtures/kotlin/com/vitorpamplona/geode/fixtures/SyntheticEvents.kt @@ -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> = 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 = + List(count) { i -> + val seed = i + 1 + fakeEvent(idSeed = seed, kind = kind, pubKey = pubKeyOf(seed), createdAt = seed.toLong()) + } +} diff --git a/geode/src/testFixtures/kotlin/com/vitorpamplona/geode/testing/RelayClientTest.kt b/geode/src/testFixtures/kotlin/com/vitorpamplona/geode/testing/RelayClientTest.kt new file mode 100644 index 000000000..76112f2b3 --- /dev/null +++ b/geode/src/testFixtures/kotlin/com/vitorpamplona/geode/testing/RelayClientTest.kt @@ -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() + } +} diff --git a/geode/src/testFixtures/kotlin/com/vitorpamplona/geode/testing/SubscriptionTesting.kt b/geode/src/testFixtures/kotlin/com/vitorpamplona/geode/testing/SubscriptionTesting.kt new file mode 100644 index 000000000..d2c7ec2d0 --- /dev/null +++ b/geode/src/testFixtures/kotlin/com/vitorpamplona/geode/testing/SubscriptionTesting.kt @@ -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, + 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, + timeoutMillis: Long = 5_000, +): CollectResult { + val ch = Channel(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?, + ) { + ch.trySend(Signal.Ev(event)) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + ch.trySend(Signal.Eose) + } + }, + ) + + val events = mutableListOf() + 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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 28f780ce5..1429ebde0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -81,6 +81,8 @@ kotlinTest = "2.3.21" core = "1.7.0" mavenPublish = "0.36.0" sqlite = "2.6.2" +ktor = "3.4.1" +fourkoma = "1.2.0" [libraries] 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" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", 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-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" } diff --git a/nestsClient/plans/2026-05-06-stream-priority-followup.md b/nestsClient/plans/2026-05-06-stream-priority-followup.md deleted file mode 100644 index 6e9578214..000000000 --- a/nestsClient/plans/2026-05-06-stream-priority-followup.md +++ /dev/null @@ -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 (1–10 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. diff --git a/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/AudioRecordCapture.kt b/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/AudioRecordCapture.kt index b5645c85e..52b22b4ff 100644 --- a/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/AudioRecordCapture.kt +++ b/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/AudioRecordCapture.kt @@ -66,12 +66,14 @@ class AudioRecordCapture( check(!stopped) { "capture already stopped" } if (record != null) return - val channelMask = - when (AudioFormat.CHANNELS) { - 1 -> AndroidAudioFormat.CHANNEL_IN_MONO - 2 -> AndroidAudioFormat.CHANNEL_IN_STEREO - else -> error("unsupported channel count ${AudioFormat.CHANNELS}") - } + // Microphone capture is mono. The platform mic is a single-channel + // device (or a fixed multi-mic array routed through a mono mixdown + // by the AEC stack), so this isn't a per-stream configurable — + // there's no "stereo mic" path to take. Stereo broadcasts + // 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 = AudioRecord.getMinBufferSize( diff --git a/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/AudioTrackPlayer.kt b/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/AudioTrackPlayer.kt index 0cba8be7f..e32824ca5 100644 --- a/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/AudioTrackPlayer.kt +++ b/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/AudioTrackPlayer.kt @@ -83,10 +83,11 @@ class AudioTrackPlayer( * match the [com.vitorpamplona.nestsclient.audio.OpusDecoder]'s output * configuration (mono Opus → 1, stereo Opus → 2 with L/R interleaving). * Drives both the AudioTrack channel mask and the underlying buffer- - * size target. Default is [AudioFormat.CHANNELS] (mono) so existing - * call sites that don't pass a channel count keep the prior behaviour. + * size target. Default is [AudioFormat.DEFAULT_CHANNELS] (mono) so + * 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 * 250 ms target-buffer calculation. For Opus, Android's Codec2 diff --git a/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/MediaCodecOpusDecoder.kt b/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/MediaCodecOpusDecoder.kt index d9abb819f..3c84340de 100644 --- a/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/MediaCodecOpusDecoder.kt +++ b/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/MediaCodecOpusDecoder.kt @@ -46,11 +46,11 @@ import java.nio.ByteOrder * L,R interleaving). * - 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 - * don't pass a channel count continue to behave exactly as before. + * Default is [AudioFormat.DEFAULT_CHANNELS] (mono) so existing call sites + * that don't pass a channel count continue to behave exactly as before. */ class MediaCodecOpusDecoder( - private val channelCount: Int = AudioFormat.CHANNELS, + private val channelCount: Int = AudioFormat.DEFAULT_CHANNELS, /** * Source sample rate in Hz. Drives the OpusHead `inputSampleRate` * field and the MediaFormat `audio/opus` sample-rate hint. diff --git a/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/MediaCodecOpusEncoder.kt b/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/MediaCodecOpusEncoder.kt index f18f7da99..9ad2b7504 100644 --- a/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/MediaCodecOpusEncoder.kt +++ b/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/MediaCodecOpusEncoder.kt @@ -31,18 +31,33 @@ import java.nio.ByteOrder * encoder shipped later). One instance per outgoing track. * * 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. * - 20 ms frames (the encoder requires the input buffer to hold one frame * 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( + private val channelCount: Int = AudioFormat.DEFAULT_CHANNELS, private val targetBitrate: Int = DEFAULT_BITRATE_BPS, ) : OpusEncoder { + init { + require(channelCount in 1..2) { + "MediaCodecOpusEncoder supports mono (1) or stereo (2) only, got $channelCount" + } + } + private val codec: MediaCodec = try { 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() } } catch (t: Throwable) { @@ -195,12 +210,15 @@ class MediaCodecOpusEncoder( */ 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 .createAudioFormat( MediaFormat.MIMETYPE_AUDIO_OPUS, AudioFormat.SAMPLE_RATE_HZ, - AudioFormat.CHANNELS, + channelCount, ).apply { setInteger(MediaFormat.KEY_BIT_RATE, bitrate) setInteger(MediaFormat.KEY_PCM_ENCODING, android.media.AudioFormat.ENCODING_PCM_16BIT) diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/AudioBroadcastConfig.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/AudioBroadcastConfig.kt new file mode 100644 index 000000000..348db1978 --- /dev/null +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/AudioBroadcastConfig.kt @@ -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" + } + } +} diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/MoqLiteNestsSpeaker.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/MoqLiteNestsSpeaker.kt index 0d02d1051..74c0766af 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/MoqLiteNestsSpeaker.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/MoqLiteNestsSpeaker.kt @@ -61,6 +61,17 @@ class MoqLiteNestsSpeaker internal constructor( * Defaults to [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, HotSwappablePublisherSource { override val state: StateFlow = mutableState.asStateFlow() @@ -157,7 +168,11 @@ class MoqLiteNestsSpeaker internal constructor( // practice the relay's SUBSCRIBE bidi takes a network // round-trip after our ANNOUNCE Active, so this is safe // 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 { runCatching { catalogPublisher.send(catalogJson) diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsConnect.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsConnect.kt index bcdcd83c3..bc5f3888c 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsConnect.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsConnect.kt @@ -188,6 +188,13 @@ suspend fun connectNestsSpeaker( framesPerGroup: Int = com.vitorpamplona.nestsclient.audio .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 { val state = MutableStateFlow( @@ -252,6 +259,7 @@ suspend fun connectNestsSpeaker( scope = scope, mutableState = state, framesPerGroup = framesPerGroup, + broadcastConfig = broadcastConfig, ) } diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/ReconnectingNestsSpeaker.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/ReconnectingNestsSpeaker.kt index 897a93656..8cfd57665 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/ReconnectingNestsSpeaker.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/ReconnectingNestsSpeaker.kt @@ -103,6 +103,13 @@ suspend fun connectReconnectingNestsSpeaker( speakerPubkeyHex: String, captureFactory: () -> AudioCapture, 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(), /** * Proactive JWT refresh window. moq-auth issues bearer tokens @@ -135,6 +142,7 @@ suspend fun connectReconnectingNestsSpeaker( speakerPubkeyHex = speakerPubkeyHex, captureFactory = captureFactory, encoderFactory = encoderFactory, + broadcastConfig = broadcastConfig, ) }, ): NestsSpeaker { @@ -292,6 +300,7 @@ suspend fun connectReconnectingNestsSpeaker( scope = scope, captureFactory = captureFactory, encoderFactory = encoderFactory, + broadcastConfig = broadcastConfig, ) } @@ -320,6 +329,7 @@ private class ReconnectingSpeakerHandle( */ private val captureFactory: () -> AudioCapture, private val encoderFactory: () -> OpusEncoder, + private val broadcastConfig: AudioBroadcastConfig, ) : NestsSpeaker { override val state: StateFlow = mutableState.asStateFlow() @@ -350,6 +360,7 @@ private class ReconnectingSpeakerHandle( scope = scope, captureFactory = captureFactory, encoderFactory = encoderFactory, + broadcastConfig = broadcastConfig, onLevel = onLevel, onClose = { closed -> if (activeBroadcast === closed) activeBroadcast = null @@ -412,6 +423,14 @@ private class ReissuingBroadcastHandle( private val scope: CoroutineScope, private val captureFactory: () -> AudioCapture, 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 * `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 // to subscribe to. Mirror of [MoqLiteNestsSpeaker.startBroadcasting]'s // 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 newCatalogPublisher = try { diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/audio/Audio.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/audio/Audio.kt index c2be91b94..05a2feb82 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/audio/Audio.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/audio/Audio.kt @@ -23,15 +23,27 @@ package com.vitorpamplona.nestsclient.audio /** * PCM audio format the audio pipeline produces and consumes. * - * Listener-only flow runs at 48 kHz mono signed-16-bit, matching the nests - * Opus profile (RFC 6716 wideband at the codec's native rate). The whole - * pipeline is hardcoded to this format for now — when nests starts varying - * codec settings, this becomes a per-room negotiated value out of the - * `/api/v1/nests/` response. + * Sample rate + frame cadence are pipeline-wide invariants (48 kHz Opus, + * 20 ms frames) and live as flat constants. Channel count is **per-stream**: + * a microphone is mono, a stereo broadcast is two-channel, and a single + * listener may simultaneously decode tracks of different shapes. Call sites + * 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 { 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. */ const val FRAME_SIZE_SAMPLES: Int = 960 diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteHangCatalog.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteHangCatalog.kt index e52b75c08..7f07fc6eb 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteHangCatalog.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteHangCatalog.kt @@ -106,42 +106,27 @@ internal data class MoqLiteHangCatalog( 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 - * Opus rendition under [audioTrackName], matching the encoder - * config in [com.vitorpamplona.nestsclient.audio.OpusEncoder] - * (48 kHz mono). + * Opus rendition under [audioTrackName] at 48 kHz with + * [numberOfChannels] (1 = mono, 2 = stereo L/R interleaved), + * matching the encoder config in + * [com.vitorpamplona.nestsclient.audio.OpusEncoder]. * * The rendition map is keyed by the moq-lite track name a * subscriber should subscribe to for this rendition's frames — * for nests audio rooms that's the same string the publisher * publishes audio frames on * (`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 = - MoqLiteHangCatalog( + fun opus48k( + 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( renditions = @@ -151,12 +136,49 @@ internal data class MoqLiteHangCatalog( codec = "opus", container = Container(kind = "legacy"), sampleRate = 48_000, - numberOfChannels = 1, + numberOfChannels = numberOfChannels, 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, 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 = diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteSession.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteSession.kt index 3dc3825ed..944eb1ae1 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteSession.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteSession.kt @@ -1037,6 +1037,13 @@ class MoqLiteSession internal constructor( // 150 ms late still falls inside hang's default ~200 ms // jitter buffer) and avoids the dropout entirely. 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(MoqLiteCodec.encodeGroupHeader(MoqLiteGroupHeader(subscribeId, sequence))) return uni diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/transport/FakeWebTransport.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/transport/FakeWebTransport.kt index 4c79793f2..875bcd568 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/transport/FakeWebTransport.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/transport/FakeWebTransport.kt @@ -162,6 +162,8 @@ class FakeBidiStream internal constructor( override suspend fun finish() { write.close() } + + override fun setPriority(priority: Int) = Unit } class FakeReadStream internal constructor( @@ -186,4 +188,6 @@ private class ChannelWriteStream( override suspend fun finish() { channel.close() } + + override fun setPriority(priority: Int) = Unit } diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/transport/WebTransportSession.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/transport/WebTransportSession.kt index e0c952f39..f581ea986 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/transport/WebTransportSession.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/transport/WebTransportSession.kt @@ -112,6 +112,22 @@ interface WebTransportWriteStream { /** Half-close the write side (FIN). No further writes after this call. */ 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) } /** diff --git a/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteHangCatalogTest.kt b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteHangCatalogTest.kt index 138bb17b1..1742f3138 100644 --- a/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteHangCatalogTest.kt +++ b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteHangCatalogTest.kt @@ -25,7 +25,7 @@ import kotlin.test.assertEquals class MoqLiteHangCatalogTest { @Test - fun opusMono48kEmitsCanonicalHangShape() { + fun opus48kMonoEmitsCanonicalHangShape() { // Byte-exact assertion: `:commons`'s `RoomSpeakerCatalogTest` // round-trips this same string against the parser. If either // side drifts from the kixelated/hang wire shape, both tests @@ -35,17 +35,49 @@ class MoqLiteHangCatalogTest { "\"codec\":\"opus\",\"container\":{\"kind\":\"legacy\"}," + "\"sampleRate\":48000,\"numberOfChannels\":1,\"jitter\":20}}}}" 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) } @Test fun renditionKeyMatchesCallerSuppliedTrackName() { 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 // name — the watcher uses this string verbatim as the // SUBSCRIBE.track on the audio subscription. 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)) + } } diff --git a/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/transport/QuicWebTransportFactory.kt b/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/transport/QuicWebTransportFactory.kt index 4f5fe7e59..019ef6e21 100644 --- a/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/transport/QuicWebTransportFactory.kt +++ b/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/transport/QuicWebTransportFactory.kt @@ -365,6 +365,10 @@ private class QuicBidiStreamAdapter( stream.send.finish() driver.wakeup() } + + override fun setPriority(priority: Int) { + stream.priority = priority + } } private class QuicReadStreamAdapter( @@ -392,6 +396,10 @@ private class QuicUniWriteStreamAdapter( stream.send.finish() driver.wakeup() } + + override fun setPriority(priority: Int) { + stream.priority = priority + } } /** 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") 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 } diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index b591f62f7..f1b7f6721 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -180,6 +180,14 @@ kotlin { dependencies { implementation(libs.kotlin.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 { // sources publishing is always enabled by the Kotlin Multiplatform plugin configure( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CountResultKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CountResultKSerializer.kt index 27cc372c3..9cb90e4d8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CountResultKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CountResultKSerializer.kt @@ -42,7 +42,7 @@ object CountResultKSerializer : KSerializer { override val descriptor: SerialDescriptor = buildClassSerialDescriptor("CountResult") { element("count") - element("pubkey") + element("approximate") } override fun serialize( @@ -56,8 +56,8 @@ object CountResultKSerializer : KSerializer { fun serializeToElement(value: CountResult): JsonObject = buildJsonObject { put("count", value.count) - // Matches Jackson's CountResultSerializer which writes "pubkey" for approximate - put("pubkey", value.approximate) + // NIP-45: include "approximate" only when true. + if (value.approximate) put("approximate", true) value.hll?.let { put("hll", HyperLogLog.encode(it)) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt index 4edf4ee4f..800420e49 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt @@ -68,12 +68,10 @@ object MessageKSerializer : KSerializer { } is OkMessage -> { + // NIP-01 wire format: ["OK", , , ] add(JsonPrimitive(value.eventId)) - // Jackson writes success as a string, not boolean - add(JsonPrimitive(value.success.toString())) - if (value.message.isNotBlank()) { - add(JsonPrimitive(value.message)) - } + add(JsonPrimitive(value.success)) + add(JsonPrimitive(value.message)) } is AuthMessage -> { @@ -90,6 +88,8 @@ object MessageKSerializer : KSerializer { } is CountMessage -> { + // NIP-45 wire format: ["COUNT", , ] + add(JsonPrimitive(value.queryId)) add(CountResultKSerializer.serializeToElement(value.result)) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/LiveEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/LiveEventStore.kt index 5e1cf9999..00d2436f9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/LiveEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/LiveEventStore.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.store.IEventStore import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.onSubscription /** * A reactive event store that combines historical data retrieval with live event streaming. @@ -56,19 +57,68 @@ class LiveEventStore( onEach: (Event) -> Unit, onEose: () -> Unit, ) { - // 1. Replay stored events matching filters. - store.query(filters, onEach) - - // 2. Signal end of stored events. - onEose() - - // 3. Stream live events until cancelled. - newEventStream.collect { newEvent -> - if (filters.any { it.match(newEvent) }) { - onEach(newEvent) - } + // Order matters: register the live collector BEFORE replaying + // stored events and signalling EOSE. Otherwise an event emitted + // between EOSE and `collect` is lost because [newEventStream] has + // replay=0. The race is only occasionally visible for kinds the + // store persists (insert latency masks it) but fires reliably for + // ephemeral kinds (20000-29999) where insert is a no-op — and + // ephemeral events MUST still reach matching live subscribers per + // NIP-01. + // + // 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? = 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) = 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 = 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): List { + if (filters.size == 1) return snapshotQuery(filters[0]) + val seen = HashSet() + val merged = ArrayList() + for (f in filters) { + for (e in store.query(f)) { + if (seen.add(e.id)) merged += e + } + } + return merged + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NegSessionRegistry.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NegSessionRegistry.kt new file mode 100644 index 000000000..f85b723d0 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NegSessionRegistry.kt @@ -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() + + /** + * 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}")) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt index dd43f03d4..d72ff491c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt @@ -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.EventCmd 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.cache.LargeCache +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.launch @@ -53,6 +57,9 @@ class RelaySession( ) : AutoCloseable { private val subscriptions = LargeCache() + /** NIP-77 negentropy state for this connection. */ + private val negentropy = NegSessionRegistry(store, ::send) + private fun addSubscription( subId: String, job: Job, @@ -67,6 +74,7 @@ class RelaySession( fun cancelAllSubscriptions() { subscriptions.forEach { _, job -> job.cancel() } subscriptions.clear() + negentropy.clear() } fun send(message: Message) { @@ -107,6 +115,9 @@ class RelaySession( is ReqCmd -> handleReq(cmd) is CloseCmd -> handleClose(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()}")) } } @@ -178,7 +189,7 @@ class RelaySession( }, onEose = { send(EoseMessage(cmd.subId)) }, ) - } catch (_: kotlinx.coroutines.CancellationException) { + } catch (_: CancellationException) { // Subscription was closed – this is expected. } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/inprocess/InProcessWebSocket.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/inprocess/InProcessWebSocket.kt new file mode 100644 index 000000000..1c4bb53c3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/inprocess/InProcessWebSocket.kt @@ -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? = 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(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 + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/EmptyPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/EmptyPolicy.kt index fe7429a43..cd1106436 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/EmptyPolicy.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/EmptyPolicy.kt @@ -20,28 +20,11 @@ */ 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 { - 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 -} +object EmptyPolicy : PassThroughPolicy() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/KindAllowDenyPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/KindAllowDenyPolicy.kt new file mode 100644 index 000000000..77331db71 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/KindAllowDenyPolicy.kt @@ -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 = emptySet(), + val deny: Set = emptySet(), +) : PassThroughPolicy() { + override fun accept(cmd: EventCmd): PolicyResult { + 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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/PassThroughPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/PassThroughPolicy.kt new file mode 100644 index 000000000..09e8d4760 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/PassThroughPolicy.kt @@ -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 = PolicyResult.Accepted(cmd) + + override fun accept(cmd: ReqCmd): PolicyResult = PolicyResult.Accepted(cmd) + + override fun accept(cmd: CountCmd): PolicyResult = PolicyResult.Accepted(cmd) + + override fun accept(cmd: AuthCmd): PolicyResult = PolicyResult.Accepted(cmd) + + override fun canSendToSession(event: Event): Boolean = true +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/PubkeyAllowDenyPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/PubkeyAllowDenyPolicy.kt new file mode 100644 index 000000000..199c86a97 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/PubkeyAllowDenyPolicy.kt @@ -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 = emptySet(), + deny: Set = emptySet(), +) : PassThroughPolicy() { + private val allow = allow.mapTo(HashSet()) { it.lowercase() } + private val deny = deny.mapTo(HashSet()) { it.lowercase() } + + override fun accept(cmd: EventCmd): PolicyResult { + 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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/RejectFutureEventsPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/RejectFutureEventsPolicy.kt new file mode 100644 index 000000000..34f008208 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/RejectFutureEventsPolicy.kt @@ -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 { + 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) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformation.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformation.kt index 2478fcaea..9558d8998 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformation.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformation.kt @@ -27,7 +27,7 @@ import kotlinx.serialization.Serializable @Stable @Serializable -class Nip11RelayInformation( +data class Nip11RelayInformation( val id: String? = null, val name: String? = null, val description: String? = null, @@ -59,7 +59,7 @@ class Nip11RelayInformation( @Stable @Serializable - class RelayInformationFee( + data class RelayInformationFee( val amount: Int? = null, val unit: String? = null, val period: Int? = null, @@ -68,7 +68,7 @@ class Nip11RelayInformation( @Stable @Serializable - class RelayInformationFees( + data class RelayInformationFees( val admission: List? = null, val subscription: List? = null, val publication: List? = null, @@ -76,7 +76,7 @@ class Nip11RelayInformation( @Stable @Serializable - class RelayInformationLimitation( + data class RelayInformationLimitation( val max_message_length: Int? = null, val max_subscriptions: Int? = null, val max_filters: Int? = null, @@ -96,7 +96,7 @@ class Nip11RelayInformation( @Stable @Serializable - class RelayInformationRetentionData( + data class RelayInformationRetentionData( val kinds: ArrayList? = null, val time: Int? = null, val count: Int? = null, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanListPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanListPolicy.kt new file mode 100644 index 000000000..398f93d7d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanListPolicy.kt @@ -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 { + 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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanStore.kt new file mode 100644 index 000000000..ba38eff15 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanStore.kt @@ -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 = emptyMap(), + val allowedPubkeys: Map = emptyMap(), + val bannedEventIds: Map = emptyMap(), + val allowedKinds: Set = emptySet(), + val disallowedKinds: Set = 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> = + 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> = + 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> = + 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 = state.load().allowedKinds.sorted() + + fun listDisallowedKinds(): List = 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> = emptyList(), + allowedPubkeys: List> = emptyList(), + bannedEvents: List> = emptyList(), + allowedKinds: List = emptyList(), + disallowedKinds: List = 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(), + ), + ) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86Server.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86Server.kt new file mode 100644 index 000000000..7b5734f82 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86Server.kt @@ -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 = + 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 List.toJsonArray(serializer: KSerializer): JsonElement = rpcJson.encodeToJsonElement(ListSerializer(serializer), this) + +private fun JsonArray.stringPair(): Pair? { + 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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip98HttpAuth/Nip98AuthVerifier.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip98HttpAuth/Nip98AuthVerifier.kt new file mode 100644 index 000000000..b88bcccc6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip98HttpAuth/Nip98AuthVerifier.kt @@ -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 ` header. + * + * NIP-98 reuses kind 27235 events with `u`, `method`, and (for bodies) + * `payload` tags. Verification checks: + * 1. Header is `Nostr `. + * 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 = + object : LinkedHashMap(64, 0.75f, true) { + override fun removeEldestEntry(eldest: Map.Entry?): 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 ' 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 + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerAuthTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerAuthTest.kt index 4e7200643..76d2ddd78 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerAuthTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerAuthTest.kt @@ -163,7 +163,7 @@ class NostrServerAuthTest { val okMessages = collector.rawMessagesContaining("OK") assertEquals(1, okMessages.size) - assertTrue(okMessages[0].contains("\"true\"")) + assertTrue(okMessages[0].contains(",true,")) assertTrue((session.policy as FullAuthPolicy).isAuthenticated()) assertTrue(session.policy.authenticatedUsers.contains(pubkey)) @@ -184,7 +184,7 @@ class NostrServerAuthTest { val okMessages = collector.rawMessagesContaining("OK") assertEquals(1, okMessages.size) - assertTrue(okMessages[0].contains("\"false\"")) + assertTrue(okMessages[0].contains(",false,")) assertTrue(okMessages[0].contains("challenge")) assertFalse((session.policy as FullAuthPolicy).isAuthenticated()) @@ -209,7 +209,7 @@ class NostrServerAuthTest { val okMessages = collector.rawMessagesContaining("OK") assertEquals(1, okMessages.size) - assertTrue(okMessages[0].contains("\"false\"")) + assertTrue(okMessages[0].contains(",false,")) assertTrue(okMessages[0].contains("relay url")) assertFalse((session.policy as FullAuthPolicy).isAuthenticated()) @@ -238,7 +238,7 @@ class NostrServerAuthTest { val okMessages = collector.rawMessagesContaining("OK") assertEquals(1, okMessages.size) - assertTrue(okMessages[0].contains("\"false\"")) + assertTrue(okMessages[0].contains(",false,")) assertTrue(okMessages[0].contains("created_at")) assertFalse((session.policy as FullAuthPolicy).isAuthenticated()) @@ -307,8 +307,8 @@ class NostrServerAuthTest { val okMessages = collector.rawMessagesContaining("OK") assertEquals(2, okMessages.size) - assertTrue(okMessages[0].contains("\"true\"")) - assertTrue(okMessages[1].contains("\"true\"")) + assertTrue(okMessages[0].contains(",true,")) + assertTrue(okMessages[1].contains(",true,")) val authedPubkeys = (session.policy as FullAuthPolicy).authenticatedUsers assertEquals(2, authedPubkeys.size) @@ -334,7 +334,7 @@ class NostrServerAuthTest { val okMessages = collector.rawMessagesContaining("OK") assertEquals(1, okMessages.size) - assertTrue(okMessages[0].contains("\"false\"")) + assertTrue(okMessages[0].contains(",false,")) assertTrue(okMessages[0].contains("auth-required:")) server.close() @@ -394,7 +394,7 @@ class NostrServerAuthTest { val okMessages = collector.rawMessagesContaining("OK") assertEquals(1, okMessages.size) - assertTrue(okMessages[0].contains("\"true\"")) + assertTrue(okMessages[0].contains(",true,")) // Now EVENT should work val event = testEvent() @@ -402,7 +402,7 @@ class NostrServerAuthTest { val allOk = collector.rawMessagesContaining("OK") assertEquals(2, allOk.size) - assertTrue(allOk[1].contains("\"true\"")) + assertTrue(allOk[1].contains(",true,")) // REQ should work session.receive("""["REQ","sub1",{"kinds":[1]}]""") @@ -428,7 +428,7 @@ class NostrServerAuthTest { val okMessages = collector.rawMessagesContaining("OK") assertEquals(1, okMessages.size) - assertTrue(okMessages[0].contains("\"true\"")) + assertTrue(okMessages[0].contains(",true,")) server.close() } @@ -461,14 +461,14 @@ class NostrServerAuthTest { // Kind 1 should be accepted without auth val note = testEvent(hexId(1), kind = 1) 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 val dm = testEvent(hexId(2), kind = 4) session.receive("""["EVENT",${dm.toJson()}]""") val okMessages = collector.rawMessagesContaining("OK") assertEquals(2, okMessages.size) - assertTrue(okMessages[1].contains("\"false\"")) + assertTrue(okMessages[1].contains(",false,")) assertTrue(okMessages[1].contains("auth-required:")) server.close() diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerTest.kt index 3e033cf49..3217b994a 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerTest.kt @@ -109,7 +109,7 @@ class NostrServerTest { val okMessages = collector.rawMessagesContaining("OK") assertEquals(1, okMessages.size) - assertTrue(okMessages[0].contains("\"true\"")) + assertTrue(okMessages[0].contains(",true,")) // Event should be in store val stored = store.query(Filter(ids = listOf(event.id))) @@ -135,8 +135,8 @@ class NostrServerTest { val okMessages = collector.rawMessagesContaining("OK") assertEquals(2, okMessages.size) - assertTrue(okMessages[0].contains("\"true\"")) - assertTrue(okMessages[1].contains("\"false\"")) + assertTrue(okMessages[0].contains(",true,")) + assertTrue(okMessages[1].contains(",false,")) server.close() } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/CountResultSerializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/CountResultSerializer.kt index efc63b229..131c30d71 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/CountResultSerializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/CountResultSerializer.kt @@ -30,9 +30,12 @@ class CountResultSerializer : StdSerializer(CountResult::class.java gen: JsonGenerator, provider: SerializerProvider, ) { + // NIP-45 result object: { "count": , "approximate": ? }. gen.writeStartObject() gen.writeNumberField("count", result.count) - gen.writeBooleanField("pubkey", result.approximate) + if (result.approximate) { + gen.writeBooleanField("approximate", true) + } gen.writeEndObject() } } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt index b0ca6557a..9048fe774 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt @@ -50,11 +50,11 @@ class MessageSerializer : StdSerializer(Message::class.java) { } is OkMessage -> { + // NIP-01 wire format: ["OK", , , ] + // The third element is a JSON boolean, not a string. gen.writeString(msg.eventId) - gen.writeString(msg.success.toString()) - if (msg.message.isNotBlank()) { - gen.writeString(msg.message) - } + gen.writeBoolean(msg.success) + gen.writeString(msg.message) } is AuthMessage -> { @@ -71,6 +71,8 @@ class MessageSerializer : StdSerializer(Message::class.java) { } is CountMessage -> { + // NIP-45 wire format: ["COUNT", , ] + gen.writeString(msg.queryId) countSerializer.serialize(msg.result, gen, provider) } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/BaseNostrClientTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/BaseNostrClientTest.kt deleted file mode 100644 index 1431b7af5..000000000 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/BaseNostrClientTest.kt +++ /dev/null @@ -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 } - } -} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFirstEventTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFirstEventTest.kt index 033ead5d1..5149c7ad1 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFirstEventTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientFirstEventTest.kt @@ -19,39 +19,41 @@ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ 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.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst 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 kotlin.test.Test import kotlin.test.assertEquals -class NostrClientFirstEventTest : BaseNostrClientTest() { +class NostrClientFirstEventTest : RelayClientTest() { @Test fun testDownloadFirstEvent() = runBlocking { - val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - val client = NostrClient(socketBuilder, appScope) + val pubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + + 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 = client.fetchFirst( - relay = "wss://nos.lol", - filter = - Filter( - kinds = listOf(MetadataEvent.KIND), - authors = listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), - ), + relay = defaultRelayUrl, + filter = Filter(kinds = listOf(MetadataEvent.KIND), authors = listOf(pubKey)), ) - client.disconnect() - appScope.cancel() - assertEquals(MetadataEvent.KIND, event?.kind) - assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", event?.pubKey) + assertEquals(pubKey, event?.pubKey) } } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt index d7412a0b3..8a0aad1d6 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt @@ -19,17 +19,14 @@ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ 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.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.filters.Filter 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.Factory.UNLIMITED import kotlinx.coroutines.runBlocking @@ -37,12 +34,11 @@ import kotlinx.coroutines.withTimeoutOrNull import kotlin.test.Test import kotlin.test.assertEquals -class NostrClientManualSubTest : BaseNostrClientTest() { +class NostrClientManualSubTest : RelayClientTest() { @Test fun testEoseAfter100Events() = runBlocking { - val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - val client = NostrClient(socketBuilder, appScope) + defaultRelay.preload(SyntheticEvents.batch(150, kind = MetadataEvent.KIND)) val resultChannel = Channel(UNLIMITED) val events = mutableListOf() @@ -67,18 +63,11 @@ class NostrClientManualSubTest : BaseNostrClientTest() { } } - val filters = - mapOf( - RelayUrlNormalizer.normalize("wss://nos.lol") to - listOf( - Filter( - kinds = listOf(MetadataEvent.KIND), - limit = 100, - ), - ), - ) - - client.subscribe(mySubId, filters, listener) + client.subscribe( + mySubId, + mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(MetadataEvent.KIND), limit = 100))), + listener, + ) withTimeoutOrNull(10000) { while (events.size < 101) { @@ -88,11 +77,7 @@ class NostrClientManualSubTest : BaseNostrClientTest() { } resultChannel.close() - client.unsubscribe(mySubId) - client.disconnect() - - appScope.cancel() assertEquals(101, events.size) assertEquals(true, events.take(100).all { it.length == 64 }) diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientQueryCountTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientQueryCountTest.kt index 5851bcb0f..6d0a1248a 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientQueryCountTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientQueryCountTest.kt @@ -19,73 +19,70 @@ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ 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.filters.Filter 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 kotlin.test.Test +import kotlin.test.assertEquals -class NostrClientQueryCountTest : BaseNostrClientTest() { - val fiatjaf = "wss://pyramid.fiatjaf.com".normalizeRelayUrl() - val utxo = "wss://news.utxo.one".normalizeRelayUrl() +class NostrClientQueryCountTest : RelayClientTest() { + private val relayA = "ws://127.0.0.1:7771/".normalizeRelayUrl() + private val relayB = "ws://127.0.0.1:7772/".normalizeRelayUrl() - val metadata = Filter(kinds = listOf(0)) - val outboxRelays = Filter(kinds = listOf(10002)) + private val metadata = Filter(kinds = listOf(0)) + 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 fun testQueryCountSuspend() = runBlocking { - val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - val client = NostrClient(socketBuilder, appScope) - - val result = client.count(fiatjaf, metadata) - - assertTrue((result?.count ?: 0) > 1) - - client.disconnect() - appScope.cancel() + seed() + val result = client.count(relayA, metadata) + assertEquals(5, result?.count) } @Test fun testQueryCountSuspendAllEvents() = runBlocking { - val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - val client = NostrClient(socketBuilder, appScope) - - val result = client.count(fiatjaf, Filter()) - - assertTrue((result?.count ?: 0) > 1) - - client.disconnect() - appScope.cancel() + seed() + val result = client.count(relayA, Filter()) + assertEquals(8, result?.count) } @Test fun testQueryCountSuspendMultipleRelays() = runBlocking { - val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - val client = NostrClient(socketBuilder, appScope) - + seed() val results = client.count( mapOf( - fiatjaf to listOf(metadata, outboxRelays), - utxo to listOf(metadata, outboxRelays), + relayA to listOf(metadata, outboxRelays), + relayB to listOf(metadata, outboxRelays), ), ) - results.forEach { (url, countResult) -> - println("${url.url}: ${countResult.count}") - assertTrue(countResult.count > 1) - } - - client.disconnect() - appScope.cancel() + assertEquals(8, results[relayA]?.count) + assertEquals(9, results[relayB]?.count) } } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt index ea8da446b..21876a67d 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt @@ -19,21 +19,18 @@ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ 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.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener 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.EventMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message 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.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.Factory.UNLIMITED import kotlinx.coroutines.coroutineScope @@ -43,12 +40,29 @@ import kotlinx.coroutines.withTimeoutOrNull import kotlin.test.Test import kotlin.test.assertEquals -class NostrClientRepeatSubTest : BaseNostrClientTest() { +class NostrClientRepeatSubTest : RelayClientTest() { @Test fun testRepeatSubEvents() = runBlocking { - val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - val client = NostrClient(socketBuilder, appScope) + // Each replaceable kind needs unique pubkeys. + 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(UNLIMITED) val events = mutableListOf() @@ -80,38 +94,11 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() { client.addConnectionListener(listener) - val filters = - mapOf( - RelayUrlNormalizer.normalize("wss://nos.lol") to - listOf( - Filter( - kinds = listOf(MetadataEvent.KIND), - limit = 100, - ), - ), - ) - + val filters = mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(MetadataEvent.KIND), limit = 100))) val filtersShouldIgnore = - mapOf( - RelayUrlNormalizer.normalize("wss://nos.lol") to - listOf( - Filter( - kinds = listOf(AdvertisedRelayListEvent.KIND), - limit = 500, - ), - ), - ) - + mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(AdvertisedRelayListEvent.KIND), limit = 500))) val filtersShouldSendAfterEOSE = - mapOf( - RelayUrlNormalizer.normalize("wss://nos.lol") to - listOf( - Filter( - kinds = listOf(AdvertisedRelayListEvent.KIND), - limit = 10, - ), - ), - ) + mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(AdvertisedRelayListEvent.KIND), limit = 10))) coroutineScope { launch { @@ -140,29 +127,18 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() { client.unsubscribe(mySubId) client.removeConnectionListener(listener) - client.disconnect() - - appScope.cancel() // 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. - // First sub: <= 100 metadata events, then EOSE. - // Second sub: <= 10 advertised relay list events, then EOSE. val firstEose = events.indexOf("EOSE") val lastEose = events.lastIndexOf("EOSE") - // both EOSEs must be present and distinct assertEquals(true, firstEose >= 0) assertEquals(true, lastEose > firstEose) - // last entry is the second EOSE (loop stops on it) assertEquals(events.size - 1, lastEose) - // first sub stays within its limit (allow +1 for relay quirks) 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) - // everything before the first EOSE is an event id 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 }) } } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientReqBypassingRelayLimitsTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientReqBypassingRelayLimitsTest.kt index 6ffb0de81..9b961b7ff 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientReqBypassingRelayLimitsTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientReqBypassingRelayLimitsTest.kt @@ -19,79 +19,82 @@ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ 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.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.filters.Filter 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 kotlin.test.Test import kotlin.test.assertEquals -class NostrClientReqBypassingRelayLimitsTest : BaseNostrClientTest() { +class NostrClientReqBypassingRelayLimitsTest : RelayClientTest() { @Test fun testDownloadFromRelayReturnsMetadataEvents() = runBlocking { - val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - val client = NostrClient(socketBuilder, appScope) + // Each event needs a unique pubkey so replaceable kind 0 doesn't + // 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() - // nos.lol returns only 500 events per req val totalFound = client.fetchAllPages( - relay = "wss://nos.lol", - filters = - listOf( - Filter( - kinds = listOf(MetadataEvent.KIND), - limit = 1000, - ), - ), + relay = defaultRelayUrl, + filters = listOf(Filter(kinds = listOf(MetadataEvent.KIND), limit = 1000)), ) { event -> events.add(event) } - client.disconnect() - delay(500) - appScope.cancel() - - assertEquals(1000, totalFound, "Expected 1000 events from wss://nos.lol") - assertEquals(1000, events.size, "Events list should be 1000 events") + assertEquals(1000, totalFound) + assertEquals(1000, events.size) events.forEach { event -> - assertEquals(MetadataEvent.KIND, event.kind, "All events should be kind ${MetadataEvent.KIND}") + assertEquals(MetadataEvent.KIND, event.kind) } } @Test fun testDownloadFromRelayReturnsMetadataAndContactListEvents() = runBlocking { - val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - val client = NostrClient(socketBuilder, appScope) + val metadata = + (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() val contactListEvents = mutableListOf() - // nos.lol returns only 500 events per req val totalFound = client.fetchAllPages( - relay = "wss://nos.lol", + relay = defaultRelayUrl, filters = listOf( - Filter( - kinds = listOf(MetadataEvent.KIND), - limit = 1000, - ), - Filter( - kinds = listOf(ContactListEvent.KIND), - limit = 1500, - ), + Filter(kinds = listOf(MetadataEvent.KIND), limit = 1000), + Filter(kinds = listOf(ContactListEvent.KIND), limit = 1500), ), ) { event -> if (event.kind == MetadataEvent.KIND) { @@ -102,18 +105,8 @@ class NostrClientReqBypassingRelayLimitsTest : BaseNostrClientTest() { } } - client.disconnect() - delay(500) - appScope.cancel() - - 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}") - } + assertEquals(2500, totalFound) + assertEquals(1000, metadataEvents.size) + assertEquals(1500, contactListEvents.size) } } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSendAndWaitTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSendAndWaitTest.kt index b9a17d219..fc9acdc6e 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSendAndWaitTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSendAndWaitTest.kt @@ -19,47 +19,31 @@ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ package com.vitorpamplona.quartz.nip01Core.relay + +import com.vitorpamplona.geode.testing.RelayClientTest 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.signers.NostrSignerInternal 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.Test import kotlin.test.assertEquals -class NostrClientSendAndWaitTest : BaseNostrClientTest() { +class NostrClientSendAndWaitTest : RelayClientTest() { @Test fun testSendAndWaitForResponse() = runBlocking { - val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - val client = NostrClient(socketBuilder, appScope) - val randomSigner = NostrSignerInternal(KeyPair()) - val event = randomSigner.sign(TextNoteEvent.build("Hello World")) - val resultDamus = - client.publishAndConfirm( - event = event, - relayList = setOf("wss://nostr.bitcoiner.social".normalizeRelayUrl()), - ) + val relayA = "ws://127.0.0.1:7771/".normalizeRelayUrl() + val relayB = "ws://127.0.0.1:7772/".normalizeRelayUrl() - val resultNos = - client.publishAndConfirm( - event = event, - relayList = setOf("wss://nos.lol".normalizeRelayUrl()), - ) + val resultA = client.publishAndConfirm(event = event, relayList = setOf(relayA)) + val resultB = client.publishAndConfirm(event = event, relayList = setOf(relayB)) - client.disconnect() - appScope.cancel() - - assertEquals(true, resultDamus) - assertEquals(true, resultNos) + assertEquals(true, resultA) + assertEquals(true, resultB) } } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionAsFlowTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionAsFlowTest.kt index 3bf5e6699..b749a7d90 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionAsFlowTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionAsFlowTest.kt @@ -19,18 +19,16 @@ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ 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.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.filters.Filter import com.vitorpamplona.quartz.utils.Log -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceUntilIdle @@ -38,7 +36,7 @@ import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals -class NostrClientSubscriptionAsFlowTest : BaseNostrClientTest() { +class NostrClientSubscriptionAsFlowTest : RelayClientTest() { fun List.printDates(): String { val starting = this[0].createdAt return joinToString { (it.createdAt - starting).toString() } @@ -48,17 +46,12 @@ class NostrClientSubscriptionAsFlowTest : BaseNostrClientTest() { @Test fun testNostrClientSubscriptionAsFlow() = runTest { - val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - val client = NostrClient(socketBuilder, appScope) + defaultRelay.preload(SyntheticEvents.batch(20, kind = MetadataEvent.KIND)) val flow = client.subscribeAsFlow( - relay = "wss://nos.lol", - filter = - Filter( - kinds = listOf(MetadataEvent.KIND), - limit = 10, - ), + relay = defaultRelayUrl, + filter = Filter(kinds = listOf(MetadataEvent.KIND), limit = 10), ) var feedStates = listOf() @@ -75,10 +68,7 @@ class NostrClientSubscriptionAsFlowTest : BaseNostrClientTest() { advanceUntilIdle() } - job.cancel() // Cancel the collection job - - client.disconnect() - appScope.cancel() + job.cancel() assertEquals(10, feedStates.size) } @@ -87,17 +77,12 @@ class NostrClientSubscriptionAsFlowTest : BaseNostrClientTest() { @Test fun testNostrClientSubscriptionAsFlowDebouncing() = runTest { - val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - val client = NostrClient(socketBuilder, appScope) + defaultRelay.preload(SyntheticEvents.batch(20, kind = MetadataEvent.KIND)) val flow = client.subscribeAsFlow( - relay = "wss://nos.lol", - filter = - Filter( - kinds = listOf(MetadataEvent.KIND), - limit = 10, - ), + relay = defaultRelayUrl, + filter = Filter(kinds = listOf(MetadataEvent.KIND), limit = 10), ) var feedStates = listOf() @@ -109,15 +94,11 @@ class NostrClientSubscriptionAsFlowTest : BaseNostrClientTest() { } } - // Advance the test dispatcher to ensure emissions are processed while (feedStates.size < 10) { advanceUntilIdle() } - job.cancel() // Cancel the collection job - - client.disconnect() - appScope.cancel() + job.cancel() assertEquals(10, feedStates.size) } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionTest.kt index 74e3a0215..9ec9bfc54 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionTest.kt @@ -19,16 +19,13 @@ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ 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.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.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.Factory.UNLIMITED import kotlinx.coroutines.runBlocking @@ -36,12 +33,11 @@ import kotlinx.coroutines.withTimeoutOrNull import kotlin.test.Test import kotlin.test.assertEquals -class NostrClientSubscriptionTest : BaseNostrClientTest() { +class NostrClientSubscriptionTest : RelayClientTest() { @Test fun testNostrClientSubscription() = runBlocking { - val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - val client = NostrClient(socketBuilder, appScope) + defaultRelay.preload(SyntheticEvents.batch(150, kind = MetadataEvent.KIND)) val resultChannel = Channel(UNLIMITED) val events = mutableSetOf() @@ -49,15 +45,7 @@ class NostrClientSubscriptionTest : BaseNostrClientTest() { val sub = StaticSubscription( client, - mapOf( - RelayUrlNormalizer.normalize("wss://nos.lol") to - listOf( - Filter( - kinds = listOf(MetadataEvent.KIND), - limit = 100, - ), - ), - ), + mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(MetadataEvent.KIND), limit = 100))), ) { event -> assertEquals(MetadataEvent.KIND, event.kind) resultChannel.trySend(event) @@ -71,12 +59,8 @@ class NostrClientSubscriptionTest : BaseNostrClientTest() { } resultChannel.close() - sub.close() - client.disconnect() - appScope.cancel() - assertEquals(100, events.size) } } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionUntilEoseAsFlowTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionUntilEoseAsFlowTest.kt index c8054a6f7..9f8ce103c 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionUntilEoseAsFlowTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionUntilEoseAsFlowTest.kt @@ -19,18 +19,16 @@ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ 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.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.filters.Filter import com.vitorpamplona.quartz.utils.Log -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceUntilIdle @@ -38,7 +36,7 @@ import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals -class NostrClientSubscriptionUntilEoseAsFlowTest : BaseNostrClientTest() { +class NostrClientSubscriptionUntilEoseAsFlowTest : RelayClientTest() { fun List.printDates(): String { val starting = this[0].createdAt return joinToString { (it.createdAt - starting).toString() } @@ -48,17 +46,12 @@ class NostrClientSubscriptionUntilEoseAsFlowTest : BaseNostrClientTest() { @Test fun testNostrClientSubscriptionUntilEoseAsFlow() = runTest { - val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - val client = NostrClient(socketBuilder, appScope) + defaultRelay.preload(SyntheticEvents.batch(20, kind = MetadataEvent.KIND)) val flow = client.fetchAsFlow( - relay = "wss://nos.lol", - filter = - Filter( - kinds = listOf(MetadataEvent.KIND), - limit = 10, - ), + relay = defaultRelayUrl, + filter = Filter(kinds = listOf(MetadataEvent.KIND), limit = 10), ) var feedStates = listOf() @@ -70,15 +63,11 @@ class NostrClientSubscriptionUntilEoseAsFlowTest : BaseNostrClientTest() { } } - // Advance the test dispatcher to ensure emissions are processed while (feedStates.size < 10) { advanceUntilIdle() } - job.cancel() // Cancel the collection job - - client.disconnect() - appScope.cancel() + job.cancel() assertEquals(10, feedStates.size) } @@ -87,17 +76,12 @@ class NostrClientSubscriptionUntilEoseAsFlowTest : BaseNostrClientTest() { @Test fun testNostrClientSubscriptionUntilEoseAsFlowDebouncing() = runTest { - val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - val client = NostrClient(socketBuilder, appScope) + defaultRelay.preload(SyntheticEvents.batch(20, kind = MetadataEvent.KIND)) val flow = client.fetchAsFlow( - relay = "wss://nos.lol", - filter = - Filter( - kinds = listOf(MetadataEvent.KIND), - limit = 10, - ), + relay = defaultRelayUrl, + filter = Filter(kinds = listOf(MetadataEvent.KIND), limit = 10), ) var feedStates = listOf() @@ -109,15 +93,11 @@ class NostrClientSubscriptionUntilEoseAsFlowTest : BaseNostrClientTest() { } } - // Advance the test dispatcher to ensure emissions are processed while (feedStates.size < 10) { advanceUntilIdle() } - job.cancel() // Cancel the collection job - - client.disconnect() - appScope.cancel() + job.cancel() assertEquals(10, feedStates.size) } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanStoreTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanStoreTest.kt new file mode 100644 index 000000000..66bfdb0f2 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/BanStoreTest.kt @@ -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')]) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86ServerTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86ServerTest.kt new file mode 100644 index 000000000..9911f1355 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/server/Nip86ServerTest.kt @@ -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 { + 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")) + } + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip98HttpAuth/Nip98AuthVerifierTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip98HttpAuth/Nip98AuthVerifierTest.kt new file mode 100644 index 000000000..2323410d5 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip98HttpAuth/Nip98AuthVerifierTest.kt @@ -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 { + 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(r) + assertEquals(pubkey, r.pubkey) + } + + @Test + fun missingHeaderReturnsMissing() { + runBlocking { + val r = verifier.verify(null, "POST", "http://x/", null) + assertIs(r) + } + } + + @Test + fun wrongSchemeIsMalformed() { + runBlocking { + val r = verifier.verify("Bearer abc", "POST", "http://x/", null) + assertIs(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(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(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(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(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(r) + assertTrue(r.reason.contains("kind")) + } + } +} diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionWriter.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionWriter.kt index 96bd15f8f..7b5598738 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionWriter.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionWriter.kt @@ -410,46 +410,74 @@ private fun buildApplicationPacket( // insertion-ordered and stays in sync with the streams map. val streamsView = conn.streamsListLocked() if (streamsView.isNotEmpty()) { - val start = conn.streamRoundRobinStart % streamsView.size - for (i in streamsView.indices) { - if (packetBudget <= 64) break - val stream = streamsView[(start + i) % streamsView.size] - val streamRemaining = (stream.sendCredit - stream.send.sentOffset).coerceAtLeast(0L) - // Skip if both stream and connection have no credit; FIN-only - // (zero-byte) chunks may still go through because they don't - // consume credit. - if (streamRemaining <= 0L && connBudget <= 0L && !stream.send.finPending) continue - val effectiveCap = minOf(streamRemaining, connBudget) - 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 + // Strict priority across tiers, round-robin within each tier. + // Higher-priority streams (e.g. moq-lite newer-sequence group + // streams) ALWAYS drain ahead of lower-priority ones; the + // rotating start-index only rotates among same-priority peers. + // This is the spec-aligned shape — applying the rotation + // globally over the sorted list would flip cross-tier order on + // alternating drains, defeating the priority hint entirely. + // + // Default priority is 0; if every stream is at the default, all + // streams form a single tier and iteration order matches the + // pre-priority round-robin behaviour exactly. + // + // Cost: O(N log N) per drain pass plus one transient sorted + // list. N is small (1–10 in the moq-lite audio path); if it + // ever grows enough to matter, switch to an indirect index sort + // or maintain an incrementally-sorted view on setPriority. + val sorted = + if (streamsView.size > 1) streamsView.sortedByDescending { it.priority } else streamsView + val rotation = conn.streamRoundRobinStart + var tierStart = 0 + outer@ while (tierStart < sorted.size) { + // Walk the contiguous run of same-priority streams. + val tierPriority = sorted[tierStart].priority + var tierEnd = tierStart + 1 + while (tierEnd < sorted.size && sorted[tierEnd].priority == tierPriority) tierEnd++ + val tierSize = tierEnd - tierStart + val tierRotation = if (tierSize > 1) rotation % tierSize else 0 + for (k in 0 until tierSize) { + if (packetBudget <= 64) break@outer + val stream = sorted[tierStart + ((tierRotation + k) % tierSize)] + val streamRemaining = (stream.sendCredit - stream.send.sentOffset).coerceAtLeast(0L) + // Skip if both stream and connection have no credit; FIN-only + // (zero-byte) chunks may still go through because they don't + // consume credit. + if (streamRemaining <= 0L && connBudget <= 0L && !stream.send.finPending) continue + val effectiveCap = minOf(streamRemaining, connBudget) + 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 diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/stream/QuicStream.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/stream/QuicStream.kt index b77095970..05317cb3b 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/stream/QuicStream.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/stream/QuicStream.kt @@ -47,6 +47,27 @@ class QuicStream( val send = SendBuffer(bestEffort = bestEffort) 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. * diff --git a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/InMemoryQuicPipe.kt b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/InMemoryQuicPipe.kt index 80749b9f9..9957825b1 100644 --- a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/InMemoryQuicPipe.kt +++ b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/InMemoryQuicPipe.kt @@ -327,6 +327,46 @@ class InMemoryQuicPipe( */ fun buildServerApplicationPacket(frames: List): 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? { + 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 { val proto = PacketProtection( diff --git a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/QuicConnectionWriterTest.kt b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/QuicConnectionWriterTest.kt index d08b49ef2..006cda755 100644 --- a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/QuicConnectionWriterTest.kt +++ b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/QuicConnectionWriterTest.kt @@ -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() + 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().map { it.streamId } + assertEquals(expected, ids, "round $round round-robin order") + } + } + } + @Test fun writer_respects_connection_level_send_credit_cap() { // Audit-4 #9: pre-fix the writer ignored sendConnectionFlowCredit diff --git a/settings.gradle b/settings.gradle index 38a632ba1..6d88537ff 100644 --- a/settings.gradle +++ b/settings.gradle @@ -34,6 +34,7 @@ rootProject.name = "Amethyst" include ':amethyst' include ':benchmark' include ':quartz' +include ':geode' include ':commons' include ':ammolite' include ':quic'