Merge branch 'main' of https://github.com/vitorpamplona/amethyst
This commit is contained in:
@@ -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
|
||||
|
||||
+126
-70
@@ -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<RoomReaction>,
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
ClickableUserPicture(
|
||||
baseUserHex = member.pubkey,
|
||||
size = avatarSize,
|
||||
accountViewModel = accountViewModel,
|
||||
modifier = avatarModifier,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
)
|
||||
if (isConnecting) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(avatarSize - 8.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
val role = member.role
|
||||
if (role == ROLE.HOST || role == ROLE.MODERATOR) {
|
||||
RoleBadge(
|
||||
role = role,
|
||||
modifier = Modifier.align(Alignment.TopStart),
|
||||
)
|
||||
}
|
||||
if (member.handRaised) {
|
||||
HandRaiseBadge(
|
||||
modifier = Modifier.align(Alignment.TopEnd),
|
||||
)
|
||||
}
|
||||
// Show the mic badge for any on-stage speaker that has
|
||||
// an audio state to surface — currently broadcasting
|
||||
// (`publishing=1`) OR mic-muted (`muted=1, publishing=0`).
|
||||
// Gating only on `publishing` would hide the muted icon
|
||||
// the moment the user mutes, which is exactly when it's
|
||||
// supposed to appear.
|
||||
if (showMicBadge && (member.publishing || member.muted == true)) {
|
||||
MicStateBadge(
|
||||
isSpeaking = isSpeaking,
|
||||
isMuted = member.muted == true,
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
)
|
||||
}
|
||||
// Reactions float over the avatar's bottom-right corner so
|
||||
// a 👏 burst no longer pushes the username down and reflows
|
||||
// neighbouring cells. The mic badge sits at BottomCenter,
|
||||
// so BottomEnd + a small outward offset keeps them clear.
|
||||
if (reactions.isNotEmpty()) {
|
||||
SpeakerReactionOverlay(
|
||||
reactions = reactions,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.offset(x = 6.dp, y = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand-raise indicator overlaid on the avatar — yellow circle with
|
||||
* a hand glyph at the top-right, animated in a subtle vertical
|
||||
|
||||
+202
-66
@@ -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<String, Job>()
|
||||
private var requestedSpeakers: Set<String> = emptySet()
|
||||
private var closed = false
|
||||
|
||||
/**
|
||||
* `@Volatile` because [closed] is read by background coroutines
|
||||
* (cliff-detector, observe* loops, audio-focus + network observers)
|
||||
* but written from `leave()` / `onCleared()` which can fire from
|
||||
* the Activity destroy callback on a different stack frame than
|
||||
* the coroutine that's about to suspend in [delay] / [collect].
|
||||
* Without the marker, a coroutine that resumes from cancellation
|
||||
* could read a stale `closed = false` even though the user has
|
||||
* already left the room — visible in the trace as
|
||||
* `cliff-detector EXITED ... closed=false` after a Leave press.
|
||||
*/
|
||||
@Volatile private var closed = false
|
||||
|
||||
// Speaker / publisher path
|
||||
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<String>,
|
||||
announcedSpeakers: Set<String>,
|
||||
lastFrameAt: Map<String, kotlin.time.TimeMark>,
|
||||
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<String> {
|
||||
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()
|
||||
|
||||
+169
-19
@@ -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
|
||||
|
||||
+50
-17
@@ -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<NestViewModel>()
|
||||
|
||||
@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>(NestsListenerState.Idle)
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.jetbrainsKotlinJvm)
|
||||
alias(libs.plugins.serialization)
|
||||
application
|
||||
`java-test-fixtures`
|
||||
}
|
||||
|
||||
application {
|
||||
mainClass.set("com.vitorpamplona.geode.MainKt")
|
||||
applicationName = "geode"
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_21)
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main {
|
||||
kotlin.srcDir("src/main/kotlin")
|
||||
}
|
||||
test {
|
||||
kotlin.srcDir("src/test/kotlin")
|
||||
}
|
||||
// The `java-test-fixtures` plugin auto-creates a `testFixtures`
|
||||
// source set; we just point it at our Kotlin layout so the
|
||||
// `geode.fixtures` (synthetic events) and `geode.testing`
|
||||
// (RelayClientTest, collectUntilEose) packages don't ship in
|
||||
// production jars but are still usable by every consumer's test
|
||||
// source via `testImplementation(testFixtures(project(":geode")))`.
|
||||
named("testFixtures") {
|
||||
kotlin.srcDir("src/testFixtures/kotlin")
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
// Forward `-DrunLoadBenchmark=true` to the test JVM so the
|
||||
// perf.LoadBenchmark tests opt in. Off by default — load tests
|
||||
// are noisy and slow.
|
||||
systemProperty("runLoadBenchmark", System.getProperty("runLoadBenchmark") ?: "false")
|
||||
// Show println output from test JVM so the benchmark numbers are
|
||||
// actually visible without grepping the report XML.
|
||||
testLogging {
|
||||
showStandardStreams =
|
||||
(System.getProperty("runLoadBenchmark") == "true")
|
||||
events("standard_out")
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(project(":quartz"))
|
||||
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.jackson.module.kotlin)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
// Bundled SQLite driver — Relay's default in-memory EventStore creates
|
||||
// an in-memory DB at runtime.
|
||||
implementation(libs.androidx.sqlite.bundled.jvm)
|
||||
|
||||
// Ktor server engine + WebSocket plugin so Relay can serve real ws://
|
||||
// traffic. CIO is the coroutine-based engine — lighter than Netty.
|
||||
api(libs.ktor.server.core)
|
||||
api(libs.ktor.server.cio)
|
||||
api(libs.ktor.server.websockets)
|
||||
|
||||
// TOML parsing for the operator config file. Mirrors the section
|
||||
// layout of nostr-rs-relay's config.toml so existing operators can
|
||||
// port their configs nearly verbatim.
|
||||
implementation(libs.fourkoma)
|
||||
|
||||
// testFixtures: code in src/testFixtures/kotlin (RelayClientTest +
|
||||
// synthetic event builders). Not shipped in the production jar but
|
||||
// exposed to consumers via testImplementation(testFixtures(...)).
|
||||
testFixturesApi(project(":quartz"))
|
||||
testFixturesApi(libs.junit)
|
||||
testFixturesImplementation(libs.kotlinx.coroutines.core)
|
||||
|
||||
testImplementation(libs.kotlin.test)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.secp256k1.kmp.jni.jvm)
|
||||
testImplementation(libs.okhttp)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
# Example config for geode. Section layout mirrors
|
||||
# nostr-rs-relay's config.toml so existing operators can port across.
|
||||
#
|
||||
# Run with:
|
||||
# ./gradlew :geode:run --args="--config /etc/geode.toml"
|
||||
#
|
||||
# CLI flags override individual values: e.g. `--port 8888` wins over
|
||||
# `[network].port`.
|
||||
|
||||
[info]
|
||||
# The wss:// URL clients use to reach this relay (mandatory for NIP-42
|
||||
# AUTH challenges). If not set, the relay synthesises one from the
|
||||
# [network] section.
|
||||
relay_url = "wss://relay.example.com/"
|
||||
name = "Example Geode"
|
||||
description = "A geode deployment."
|
||||
contact = "admin@example.com"
|
||||
# Operator pubkey (NIP-11). Optional.
|
||||
# pubkey = "..."
|
||||
# Override the supported NIPs advertised on the NIP-11 endpoint. If
|
||||
# omitted, the relay advertises the NIPs it actually implements.
|
||||
# supported_nips = [1, 9, 11, 40, 42, 45, 50, 62]
|
||||
|
||||
[network]
|
||||
host = "0.0.0.0"
|
||||
port = 7447
|
||||
path = "/"
|
||||
|
||||
[database]
|
||||
# True keeps an in-memory SQLite db (events vanish on restart). Useful
|
||||
# for tests; set false + `file = "..."` for persistent storage.
|
||||
in_memory = false
|
||||
file = "/var/lib/geode/events.db"
|
||||
|
||||
[options]
|
||||
# Drop events whose Schnorr signature does not verify. Strongly
|
||||
# recommended for any relay accepting traffic from real clients.
|
||||
# Verify Schnorr signatures on every EVENT. Default: true. Disable
|
||||
# only for trusted-input scenarios (test fixtures, mirror replays).
|
||||
# verify_signatures = true
|
||||
|
||||
# Require clients to NIP-42 AUTH before REQ/EVENT/COUNT.
|
||||
require_auth = false
|
||||
|
||||
# Reject events whose `created_at` is more than this many seconds in
|
||||
# the future. Enforced by RejectFutureEventsPolicy.
|
||||
# reject_future_seconds = 1800
|
||||
|
||||
[limits]
|
||||
# Maximum WebSocket frame size. Frames larger than this are dropped at
|
||||
# the WS layer. (max_ws_message_bytes maps to the same setting since
|
||||
# Ktor's WebSockets plugin only exposes per-frame caps.)
|
||||
# max_ws_message_bytes = 1048576
|
||||
# max_ws_frame_bytes = 1048576
|
||||
|
||||
[authorization]
|
||||
# Allow / deny lists. Allow is a permissive ceiling; deny still
|
||||
# removes specific entries inside it. Enforced by Pubkey/KindAllowDenyPolicy.
|
||||
# pubkey_whitelist = ["abcdef...64hex..."]
|
||||
# pubkey_blacklist = []
|
||||
# kind_whitelist = [0, 1, 3, 7, 1059, 30023]
|
||||
# kind_blacklist = [4]
|
||||
|
||||
[admin]
|
||||
# NIP-86 relay management API. When `pubkeys` is non-empty, the relay
|
||||
# accepts HTTP POST application/nostr+json+rpc on the same URL,
|
||||
# authenticated with NIP-98 HTTP-Auth. Only events signed by one of
|
||||
# the listed pubkeys can run admin RPCs (banpubkey / banevent /
|
||||
# changerelayname / …). Empty (the default) disables the endpoint.
|
||||
# pubkeys = ["abcdef...64hex..."]
|
||||
#
|
||||
# Canonical URL the relay is reachable at, e.g. behind a reverse proxy.
|
||||
# NIP-98 binds requests to this URL via the `u` tag. **Required** in
|
||||
# any production deployment — without it, an attacker can spoof the
|
||||
# Host header to bypass URL binding.
|
||||
# public_url = "https://relay.example.com/"
|
||||
|
||||
# Path for the JSON snapshot that persists NIP-86 admin state (ban
|
||||
# lists + the live NIP-11 doc) across restarts. When unset, admin
|
||||
# state is in-memory only and forgotten on every restart. Convention
|
||||
# is to place this next to the SQLite event-store file.
|
||||
# state_file = "/var/lib/geode/events.db.admin.json"
|
||||
@@ -0,0 +1,93 @@
|
||||
# Connection scaling: pushing past 2 000
|
||||
|
||||
## Problem
|
||||
|
||||
Current measurement (`LoadBenchmark.connectionsHeldOpen`): **~2 000
|
||||
concurrent connections** before file-descriptor pressure / Ktor CIO
|
||||
event-loop saturation. Real-world relays (e.g. nostr.wine, nos.lol)
|
||||
sustain 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<String, Job>` for subs (likely 1–10 entries) | ~negligible |
|
||||
| `NegSessionRegistry` | `HashMap<String, NegentropyServerSession>` — usually 0 | ~negligible |
|
||||
| Ktor CIO buffers | TCP read + write buffers | ~10 MB |
|
||||
| Per-session writer Job | one coroutine | ~few KB |
|
||||
|
||||
The `outQueue` reservation is the dominant cost. The 8 192 was sized
|
||||
for a worst case "thousands of subscriptions, one event matches all" —
|
||||
but at 5 000 connections we've over-provisioned by ~300 MB just on
|
||||
the channel array, even though most connections never fan out.
|
||||
|
||||
## Sketch
|
||||
|
||||
### A — adaptive outQueue capacity
|
||||
|
||||
Start every connection with `INITIAL_OUTGOING_BUFFER = 64`. When the
|
||||
producer side trySends and we observe queue depth crossing a high-water
|
||||
mark (e.g. 75% full), grow the channel up to `MAX_OUTGOING_BUFFER =
|
||||
8192`. This is not how `kotlinx.coroutines.channels.Channel` is
|
||||
structured (capacity is fixed at construction), so the implementation
|
||||
is "swap in a wider channel under a per-session lock when watermark
|
||||
trips" — drains the old, then routes new sends through the new.
|
||||
|
||||
Expected: 90% of connections never fan out, so they stay at 64 slots
|
||||
× ~512 B per ref ≈ 32 KB. At 5 000 conns that's ~160 MB → ~5 MB.
|
||||
Hot-fanout connections still get the 2 MB cap.
|
||||
|
||||
### B — per-relay event-loop pool sizing
|
||||
|
||||
Ktor CIO defaults to one event-loop thread per available CPU.
|
||||
Beyond a few thousand connections, this becomes the bottleneck — and
|
||||
none of geode's per-connection work is CPU-bound (it's mostly waiting
|
||||
on incoming frames). Tune CIO via:
|
||||
|
||||
```kotlin
|
||||
embeddedServer(CIO, ...) {
|
||||
connectionGroupSize = max(2, Runtime.getRuntime().availableProcessors() / 2)
|
||||
workerGroupSize = max(4, Runtime.getRuntime().availableProcessors())
|
||||
callGroupSize = max(8, Runtime.getRuntime().availableProcessors() * 4)
|
||||
}
|
||||
```
|
||||
|
||||
Expose these through `RelayConfig.NetworkSection` so an operator on a
|
||||
big VM can lift them.
|
||||
|
||||
### C — reduce per-message JSON allocations
|
||||
|
||||
`OptimizedJsonMapper.fromJsonToCommand` allocates a `JsonNode` tree per
|
||||
incoming frame. At 10k connections with 1 msg/s each that's 10k tree
|
||||
allocations/sec. Investigate streaming Jackson + reusing `ObjectMapper`
|
||||
per session, or using kotlinx-serialization's lower-overhead path.
|
||||
|
||||
This is more of a quartz-level change than geode-specific, but
|
||||
geode's load benchmark is the right place to measure it.
|
||||
|
||||
## How to verify
|
||||
|
||||
Add to `geode.perf.LoadBenchmark`:
|
||||
|
||||
- `connectionsHeldOpen10k` — opens 10 000 idle WebSocket connections;
|
||||
asserts no FD exhaustion + RSS stays under 1 GB.
|
||||
- `connectionsHeldOpenWithFanout` — 5 000 idle subscribers,
|
||||
10 EPS published; measures p99 fanout latency at scale.
|
||||
|
||||
The current `connectionsHeldOpen` benchmark stays as the baseline
|
||||
floor (~2 000 conns).
|
||||
|
||||
## Risks
|
||||
|
||||
- **Adaptive channel swap is fiddly**: drains under the producer's nose
|
||||
must preserve OK ordering. A simpler alternative: keep capacity fixed,
|
||||
but lazily allocate a small `ArrayDeque<String>` only when the first
|
||||
message is sent. Channels in kotlinx.coroutines do allocate up-front.
|
||||
- **Bumping CIO group sizes can hurt**: more threads can mean worse
|
||||
L1/L2 locality. Always benchmark before/after, don't trust
|
||||
intuitive sizing.
|
||||
- **OS-level FD limit**: per-process FD limit on Linux defaults to
|
||||
1024 in many environments. Document the `ulimit -n` requirement
|
||||
for operators targeting >1k connections.
|
||||
@@ -0,0 +1,91 @@
|
||||
# Event ingestion: write batching + pipelined OK
|
||||
|
||||
## Problem
|
||||
|
||||
EVENT acceptance is the hot path on a busy relay — every published note,
|
||||
every reaction, every DM lands here. Today the per-event flow is fully
|
||||
serial:
|
||||
|
||||
1. `RelaySession.handleEvent` (`quartz/nip01Core/relay/server/RelaySession.kt:131`)
|
||||
awaits `policy.accept(cmd)` (Schnorr verify if `VerifyPolicy` is in
|
||||
the stack — ~0.1 ms on JVM).
|
||||
2. Awaits `store.insert(cmd.event)` — a single SQLite write, guarded by
|
||||
the connection-pool writer mutex (`SQLiteConnectionPool`).
|
||||
3. Sends `OkMessage` back through the writer coroutine.
|
||||
|
||||
`LoadBenchmark.publishThroughputSingleClient` measured **~760 EPS**;
|
||||
the concurrent variant **~2000 EPS** (limited by SQLite writer mutex
|
||||
contention, not WS throughput).
|
||||
|
||||
## Constraints we must keep
|
||||
|
||||
- **OK ordering**: NIP-01 requires the OK reply to follow its EVENT.
|
||||
We cannot reply OK before the insert decision (the OK carries
|
||||
accepted/rejected + reason).
|
||||
- **Durability semantics**: clients reasonably assume `OK true` means
|
||||
"stored." Batching must not make us reply OK before fsync.
|
||||
- **Per-connection FIFO**: a publisher that sends three EVENTs in a
|
||||
row expects three OKs in that order. Reordering across connections
|
||||
is fine.
|
||||
|
||||
## Sketch
|
||||
|
||||
### Tier 1 — SQLite WAL + group commit (cheap win)
|
||||
|
||||
Confirm `PRAGMA journal_mode=WAL` + `PRAGMA synchronous=NORMAL` on the
|
||||
event-store DB; group commits across the writer mutex's hold window.
|
||||
Today each insert is its own transaction. Wrap N inserts (or a 5 ms
|
||||
budget, whichever first) in a single transaction managed by the writer
|
||||
coroutine. On commit, fan back N OK replies.
|
||||
|
||||
Implementation lives in quartz's `EventStore` / `SQLiteConnectionPool`,
|
||||
not geode — but geode owns the benchmark and validates the gain.
|
||||
|
||||
Expected: **~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<EventCmd> with capacity = INGEST_PIPELINE_DEPTH` per
|
||||
connection, drained by a coroutine that batches into the group-commit
|
||||
above. OK responses are written to an `outQueue.send()` already — so
|
||||
the pipeline just needs to record arrival order and emit OKs in that
|
||||
order after each batch commits.
|
||||
|
||||
Expected: hides the verify+insert latency behind another EVENT's
|
||||
parse, gets us closer to network-bound throughput.
|
||||
|
||||
### Tier 3 — eager Schnorr verify off the writer thread
|
||||
|
||||
`VerifyPolicy` is in the policy stack and runs synchronously on
|
||||
`receive`. Move it into the ingest pipeline so verification of EVENT N+1
|
||||
runs concurrently with the SQLite commit of EVENT N. secp256k1 verify
|
||||
is parallelisable; the writer should never block on it.
|
||||
|
||||
## How to verify
|
||||
|
||||
Add to `geode.perf.LoadBenchmark`:
|
||||
|
||||
- `publishGroupCommitSingleClient` — same workload as the current
|
||||
single-client benchmark, asserts >5000 EPS.
|
||||
- `publishPipelinedSingleClient` — sends 100 EVENTs without awaiting
|
||||
intermediate OKs; measures end-to-end and OK-ordering correctness.
|
||||
|
||||
Existing benchmarks stay as the regression floor.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Group commit windows**: if a single bad event in the batch fails
|
||||
validation, we must not roll back the good ones. The batch needs
|
||||
per-row commit semantics (row-level errors → row-level OK false).
|
||||
- **Backpressure on slow disks**: deeper pipelines on slow storage
|
||||
amplify out-of-memory pressure. Cap the in-flight queue depth and
|
||||
apply existing slow-client backpressure if it fills.
|
||||
- **Replay protection**: the existing dedupe table needs to see the
|
||||
event before commit, not after — keep that check inside the writer
|
||||
coroutine.
|
||||
@@ -0,0 +1,100 @@
|
||||
# Live broadcast: indexed filter matching for fanout
|
||||
|
||||
## Problem
|
||||
|
||||
Every accepted EVENT runs through `LiveEventStore.newEventStream`
|
||||
(`quartz/nip01Core/relay/server/LiveEventStore.kt:43`) — a
|
||||
`MutableSharedFlow<Event>` that every active subscription collects.
|
||||
Each subscriber's collector then calls:
|
||||
|
||||
```kotlin
|
||||
if (filters.any { it.match(newEvent) }) onEach(newEvent)
|
||||
```
|
||||
|
||||
That's **O(N_subscribers × N_filters_per_sub)** per published event.
|
||||
With 5k connections × ~3 filters average that's 15k Filter.match
|
||||
calls per EVENT — and each `Filter.match` itself walks `kinds`,
|
||||
`authors`, tag prefixes, since/until, etc. At 2k EPS ingest that's
|
||||
~30M comparisons/sec.
|
||||
|
||||
Two specific cost shapes:
|
||||
|
||||
1. **Filters that almost never match.** Most subscriptions are scoped
|
||||
to a small author list. Today every published EVENT walks every
|
||||
such subscription to learn that. A `HashMap<HexKey, MutableList<Sub>>`
|
||||
keyed by author would cut this to O(1) average for the dominant case.
|
||||
2. **Pseudo-broadcast filters** (`{kinds: [1]}` with no other
|
||||
constraint) match almost everything. There's no avoiding the
|
||||
per-subscriber notification, but at least the index lookup is
|
||||
cheap.
|
||||
|
||||
`LoadBenchmark.fanoutLatency` already measures this — current
|
||||
results are not yet noted in tree, but back-of-envelope says fanout
|
||||
becomes the dominant cost above ~2k subscribers.
|
||||
|
||||
## Sketch
|
||||
|
||||
A new `LiveBroadcastIndex` inside `LiveEventStore`:
|
||||
|
||||
```kotlin
|
||||
private val byAuthor = ConcurrentHashMap<HexKey, MutableSet<Subscription>>()
|
||||
private val byKind = ConcurrentHashMap<Int, MutableSet<Subscription>>()
|
||||
private val byTag = ConcurrentHashMap<TagKey, MutableSet<Subscription>>()
|
||||
private val unindexed = CopyOnWriteArraySet<Subscription>() // subs with no
|
||||
// narrowing field
|
||||
```
|
||||
|
||||
Each `RelaySession.handleReq` registers its `Subscription` (a tuple of
|
||||
filters + the existing `EventMessage` send callback) into whichever
|
||||
buckets each filter narrows on. A filter with `kinds=[1] and
|
||||
authors=[a,b]` registers into `byKind[1]` AND `byAuthor[a]`,
|
||||
`byAuthor[b]` — broadcast unions the resulting candidate sets.
|
||||
|
||||
On EVENT arrival:
|
||||
|
||||
1. Build the candidate set: union of `byAuthor[event.pubkey]`,
|
||||
`byKind[event.kind]`, every `byTag[(letter, value)]` for the
|
||||
event's single-letter tags, plus `unindexed`.
|
||||
2. Run the existing `Filter.match` on each candidate to handle
|
||||
negative constraints (`since`, `until`, `limit` already-reached,
|
||||
composite predicates).
|
||||
3. Send.
|
||||
|
||||
Expected: **>10× speedup** on fanout for realistic subscriptions.
|
||||
Worst case (all filters in `unindexed`) degrades to current behaviour.
|
||||
|
||||
## Where it lives
|
||||
|
||||
`quartz/nip01Core/relay/server/LiveBroadcastIndex.kt` — protocol-level,
|
||||
reusable by any relay embed. `RelaySession.handleReq` registers/
|
||||
unregisters; `LiveEventStore.insert` calls
|
||||
`index.candidatesFor(event)`.
|
||||
|
||||
## How to verify
|
||||
|
||||
Add `geode.perf.LoadBenchmark.fanoutScaling`:
|
||||
|
||||
- N connections, each subscribes to `{authors: [pk_i], kinds: [1]}`.
|
||||
- Publish 10k EVENTs from a producer connection; each event matches
|
||||
exactly one subscriber.
|
||||
- Measure end-to-end latency p50/p99 for N ∈ {100, 1000, 5000}.
|
||||
|
||||
Without the index, p99 grows roughly linearly with N. With the
|
||||
index, p99 should be flat up to a much higher N.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Subscription churn**: re-subscribing on every page (the way some
|
||||
client features work) means many index insert/remove operations.
|
||||
`ConcurrentHashMap` value-set operations need to be lock-free or
|
||||
finely locked; benchmark this path explicitly.
|
||||
- **Tag explosion**: an EVENT with many `e`/`p` tags hits many tag
|
||||
buckets. Cap candidate-set union work or short-circuit when the
|
||||
union saturates.
|
||||
- **Memory**: the index is a per-bucket set of subscription handles.
|
||||
At 5k subs × average 3 narrowing fields, ~15k entries — negligible.
|
||||
- **Correctness fence**: the index must see new subscriptions before
|
||||
the next EVENT broadcast. Today `RelaySession.handleReq` writes its
|
||||
`Job` into a `LargeCache` then launches the collector. Order of
|
||||
operations needs to be revisited so the index is updated atomically
|
||||
with the collector being ready.
|
||||
@@ -0,0 +1,100 @@
|
||||
# NIP-77 negentropy at scale: snapshot memory + chunked replay
|
||||
|
||||
## Problem
|
||||
|
||||
`RelaySession` delegates NEG-OPEN to `NegSessionRegistry.open`
|
||||
(`quartz/nip01Core/relay/server/NegSessionRegistry.kt`), which calls
|
||||
`store.snapshotQuery(filters)` and feeds the **entire** result list
|
||||
into `NegentropyServerSession`. For a relay holding 5 M events that
|
||||
match a broad NEG-OPEN filter (`{kinds: [1, 7]}`), this is 5 M
|
||||
`Event` objects materialised in memory before the first NEG-MSG goes
|
||||
out.
|
||||
|
||||
The negentropy library itself is fine — it pivots into a sealed
|
||||
`StorageVector` (id + createdAt only, ~40 bytes/entry). But the
|
||||
`store.query<Event>(f)` step that produces the input materialises full
|
||||
`Event` objects with content, tags, sig — call it ~1 KB/event. 5 M ×
|
||||
1 KB = 5 GB transient pressure per concurrent NEG-OPEN.
|
||||
|
||||
Two operator-visible symptoms:
|
||||
|
||||
1. NEG-OPEN with a broad filter spikes JVM heap; under load, GC pause
|
||||
stalls every other handler on the same process.
|
||||
2. NEG-OPEN latency before the first NEG-MSG response is O(N) — for
|
||||
large stores the client waits seconds for what should be a
|
||||
millisecond round-trip.
|
||||
|
||||
## Sketch
|
||||
|
||||
### A — id-and-time-only snapshot path
|
||||
|
||||
Negentropy only needs `(createdAt, id)` pairs. Add a streaming
|
||||
`IEventStore.queryIdAndTime(filter)` that returns
|
||||
`Sequence<Pair<Long, ByteArray>>` (or a `Flow` of small chunks) —
|
||||
no content/tags/sig, no Event allocation. SQLite path is a SELECT
|
||||
on `event_headers` (the `created_at`, `id` columns are already
|
||||
indexed for query plans).
|
||||
|
||||
```kotlin
|
||||
suspend fun snapshotIdsForNegentropy(filter: Filter): IdTimeStream
|
||||
```
|
||||
|
||||
`NegentropyServerSession` is rewritten to take that stream and feed
|
||||
it directly into the `StorageVector`. Memory drops from O(N × 1 KB)
|
||||
to O(N × 40 B) — a 25× reduction; for 5 M events, ~200 MB instead
|
||||
of 5 GB.
|
||||
|
||||
### B — bounded-window subscriptions
|
||||
|
||||
Most NEG-OPENs from real Nostr clients want the last 30 days, not
|
||||
"everything." If the client doesn't supply `since`, the server can
|
||||
default to a configurable horizon (e.g. 90 days) and surface this in
|
||||
the NIP-11 `limitation.negentropy_max_lookback_seconds` field.
|
||||
Operators can lift the cap; clients reading the doc know the bound.
|
||||
|
||||
This is a NIP-spec-adjacent question more than a code change — needs
|
||||
a comment on whether the spec allows it. nostr-rs-relay does this
|
||||
already.
|
||||
|
||||
### C — frame-size cap on NEG-MSG
|
||||
|
||||
`NegentropyServerSession` is constructed with `frameSizeLimit = 0`
|
||||
(no limit). At very large reconciliations the message can grow large.
|
||||
Set a default `frameSizeLimit = 64 * 1024` (matching the typical WS
|
||||
frame budget) so NEG-MSGs don't blow past `[limits].max_ws_frame_bytes`.
|
||||
|
||||
The library already supports this — pure config change in
|
||||
`NegSessionRegistry.open`.
|
||||
|
||||
### D — concurrent NEG-OPEN cap
|
||||
|
||||
A NEG-OPEN holds session state until NEG-CLOSE (or connection close).
|
||||
Today nothing caps the number of concurrent open negentropy sessions
|
||||
per connection. A misbehaving (or hostile) client could open thousands
|
||||
and pin RAM. Add `MAX_NEG_SESSIONS_PER_CONNECTION = 16`, send NEG-ERR
|
||||
on overflow.
|
||||
|
||||
## How to verify
|
||||
|
||||
Add to `geode.perf.LoadBenchmark`:
|
||||
|
||||
- `negentropyOpenLatencyLargeCorpus` — preload 1 M events (use
|
||||
fixtures), measure NEG-OPEN → first NEG-MSG latency. Target <100 ms.
|
||||
- `negentropyMemoryPressure` — open 10 concurrent NEG-OPENs on the
|
||||
same large corpus; measure RSS delta, target <500 MB.
|
||||
|
||||
## Risks
|
||||
|
||||
- **`Sequence`/`Flow` over SQLite cursor**: holding a cursor open
|
||||
across the full sync is fragile if the client stalls. Materialise
|
||||
to a smaller in-memory list (just (id, createdAt)) once, reuse for
|
||||
the lifetime of the session. Memory bound is the same.
|
||||
- **Defaulting `since` is a behaviour change**: existing clients that
|
||||
expect "everything" silently get a bounded window. Either (a) make
|
||||
it opt-in via `RelayConfig.NegentropySection.default_lookback_seconds
|
||||
= null`, (b) advertise the cap in NIP-11 so well-behaved clients
|
||||
read it.
|
||||
- **Frame-size cap can break older clients**: the NIP-77 reference
|
||||
implementation (kmp-negentropy) handles this gracefully — multi-frame
|
||||
reconciliation is in spec — but field-test against a known-working
|
||||
client (e.g. nstart, primal-cache) before flipping the default.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,275 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.geode
|
||||
|
||||
import com.vitorpamplona.geode.server.Nip86HttpRoute
|
||||
import com.vitorpamplona.geode.server.WebSocketSessionPump
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession
|
||||
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
|
||||
import com.vitorpamplona.quartz.nip86RelayManagement.server.Nip86Server
|
||||
import com.vitorpamplona.quartz.nip98HttpAuth.Nip98AuthVerifier
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.application.install
|
||||
import io.ktor.server.cio.CIO
|
||||
import io.ktor.server.cio.CIOApplicationEngine
|
||||
import io.ktor.server.engine.embeddedServer
|
||||
import io.ktor.server.request.header
|
||||
import io.ktor.server.response.respondText
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.post
|
||||
import io.ktor.server.routing.routing
|
||||
import io.ktor.server.websocket.WebSockets
|
||||
import io.ktor.server.websocket.webSocket
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Hosts a [Relay] over a real `ws://` endpoint backed by Ktor + CIO.
|
||||
*
|
||||
* Use this when something other than the in-process
|
||||
* [com.vitorpamplona.quartz.nip01Core.relay.server.inprocess.InProcessWebSocket]
|
||||
* needs to talk to the relay — Android instrumented tests, the `cli`
|
||||
* tooling, external clients, or a standalone "run a Nostr relay"
|
||||
* process.
|
||||
*
|
||||
* For unit-test wiring inside a single JVM, prefer [RelayHub] + the
|
||||
* in-process socket — same protocol, no socket overhead.
|
||||
*
|
||||
* Lifecycle:
|
||||
* ```
|
||||
* val server = LocalRelayServer(Relay(url = ...)).start()
|
||||
* println("listening on ${server.url}")
|
||||
* // ... do stuff ...
|
||||
* server.stop()
|
||||
* ```
|
||||
*/
|
||||
class LocalRelayServer(
|
||||
val relay: Relay,
|
||||
val host: String = "127.0.0.1",
|
||||
/** Pass 0 to let the OS pick a free port. Read [url] after [start] to learn it. */
|
||||
val port: Int = 0,
|
||||
val path: String = "/",
|
||||
/**
|
||||
* Per-frame size cap; mirrors `[limits].max_ws_frame_bytes` in the
|
||||
* config. Frames larger than this are rejected at the WebSocket
|
||||
* layer, which is the only layer that sees the raw bytes. `null`
|
||||
* uses Ktor's default (~1 MiB).
|
||||
*/
|
||||
val maxFrameBytes: Long? = null,
|
||||
/**
|
||||
* Pubkeys allowed to call NIP-86 admin RPCs. Empty (the default)
|
||||
* disables the admin endpoint entirely — POSTs return 403.
|
||||
* Otherwise: HTTP POSTs to [path] with `Content-Type:
|
||||
* application/nostr+json+rpc` are dispatched to [Nip86Server],
|
||||
* gated by NIP-98 HTTP-Auth membership in this set.
|
||||
*/
|
||||
val adminPubkeys: Set<HexKey> = emptySet(),
|
||||
/**
|
||||
* Canonical public URL the relay is reachable at, e.g.
|
||||
* `https://relay.example.com/`. NIP-98 admin requests must sign
|
||||
* the **same** URL string they're sending to. When the relay sits
|
||||
* behind TLS termination or a reverse proxy, the `Host` header
|
||||
* the relay sees does not match what the client signs, so the
|
||||
* verifier must compare against this configured value.
|
||||
*
|
||||
* `null` (the default) falls back to the request's `Host` header
|
||||
* with `http://` — fine for local-loopback unit tests, **NOT
|
||||
* SAFE** in a public deployment because an attacker can spoof
|
||||
* `Host` and bind their signature to any URL.
|
||||
*/
|
||||
val publicUrl: String? = null,
|
||||
/**
|
||||
* Maximum body size accepted on the NIP-86 POST endpoint, in
|
||||
* bytes. Bounded *before* auth verification because we read the
|
||||
* body to compute its sha256 for NIP-98's payload binding —
|
||||
* unbounded reads would let an unauthenticated attacker stream
|
||||
* gigabytes and OOM the relay. 1 MiB easily fits any plausible
|
||||
* RPC payload.
|
||||
*/
|
||||
val maxAdminBodyBytes: Int = 1 shl 20,
|
||||
) {
|
||||
private val infoHolder =
|
||||
object : Nip86Server.InfoHolder {
|
||||
override fun get(): Nip11RelayInformation = relay.info.document
|
||||
|
||||
override fun set(info: Nip11RelayInformation) {
|
||||
relay.updateInfo { info }
|
||||
}
|
||||
}
|
||||
|
||||
private val nip86Server = Nip86Server(banStore = relay.banStore, infoHolder = infoHolder, store = relay.store)
|
||||
private val nip86Route =
|
||||
Nip86HttpRoute(
|
||||
server = nip86Server,
|
||||
verifier = Nip98AuthVerifier(),
|
||||
allowList = adminPubkeys.mapTo(HashSet()) { it.lowercase() },
|
||||
maxBodyBytes = maxAdminBodyBytes,
|
||||
signedUrlFor = { call ->
|
||||
publicUrl ?: ("http://" + (call.request.header(HttpHeaders.Host) ?: "$host:$resolvedPort") + path)
|
||||
},
|
||||
)
|
||||
|
||||
private var engine: CIOApplicationEngine? = null
|
||||
private var resolvedPort: Int = -1
|
||||
|
||||
/**
|
||||
* Set when [stop] begins. Once true, the WebSocket handler refuses
|
||||
* new upgrades — Ktor's `engine.stop` will eventually do this too,
|
||||
* but Ktor's grace window means new connections can land between
|
||||
* `notifyShutdown` and the actual port-close, missing the NOTICE
|
||||
* we just sent to existing clients.
|
||||
*/
|
||||
@Volatile
|
||||
private var shuttingDown: Boolean = false
|
||||
|
||||
/**
|
||||
* Active client sessions, registered when their WebSocket handler
|
||||
* runs and removed on disconnect. Exposed (read-only) so [stop] can
|
||||
* NOTICE every connected client during graceful drain, and so tests
|
||||
* can assert lifecycle bookkeeping.
|
||||
*/
|
||||
private val activeSessions: MutableSet<RelaySession> = ConcurrentHashMap.newKeySet()
|
||||
|
||||
/** Number of WebSocket sessions currently connected to the server. */
|
||||
val activeSessionCount: Int get() = activeSessions.size
|
||||
|
||||
/** `ws://host:port/path` — only valid after [start]. */
|
||||
val url: String
|
||||
get() {
|
||||
check(resolvedPort != -1) { "Server not started" }
|
||||
return "ws://$host:$resolvedPort$path"
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds the Ktor engine. Returns once the engine reports ready, so
|
||||
* [url] is safe to read on the very next line.
|
||||
*/
|
||||
fun start(): LocalRelayServer {
|
||||
val server =
|
||||
embeddedServer(CIO, host = host, port = port) {
|
||||
install(WebSockets) {
|
||||
maxFrameBytes?.let { maxFrameSize = it }
|
||||
}
|
||||
routing {
|
||||
// NIP-11: GET on the relay URL with Accept:
|
||||
// application/nostr+json returns the relay info doc.
|
||||
// We mount this *before* the webSocket route so Ktor
|
||||
// serves NIP-11 for plain HTTP GETs and only upgrades
|
||||
// to a WebSocket when the request is a WS upgrade.
|
||||
get(path) {
|
||||
val accept = call.request.header(HttpHeaders.Accept).orEmpty()
|
||||
if (accept.contains("application/nostr+json")) {
|
||||
call.response.headers.append("Access-Control-Allow-Origin", "*")
|
||||
call.respondText(
|
||||
relay.info.json,
|
||||
ContentType.parse("application/nostr+json"),
|
||||
)
|
||||
} else {
|
||||
call.respondText(
|
||||
"Use a Nostr client (NIP-01 WebSocket) or send Accept: application/nostr+json (NIP-11).",
|
||||
ContentType.Text.Plain,
|
||||
HttpStatusCode.UpgradeRequired,
|
||||
)
|
||||
}
|
||||
}
|
||||
// NIP-86: POST application/nostr+json+rpc with a NIP-98
|
||||
// signed Authorization header → JSON-RPC dispatch.
|
||||
post(path) {
|
||||
nip86Route.handle(call)
|
||||
}
|
||||
webSocket(path) {
|
||||
if (shuttingDown) {
|
||||
// Just return — Ktor closes the WS for us.
|
||||
return@webSocket
|
||||
}
|
||||
WebSocketSessionPump(this).pump(
|
||||
server = relay.server,
|
||||
registerSession = activeSessions::add,
|
||||
unregisterSession = activeSessions::remove,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
server.start(wait = false)
|
||||
engine = server.engine
|
||||
// Ktor 3.x made resolvedConnectors() suspend. We block here so
|
||||
// start() returns synchronously with [url] readable on the next line.
|
||||
resolvedPort =
|
||||
runBlocking {
|
||||
server.engine
|
||||
.resolvedConnectors()
|
||||
.first()
|
||||
.port
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Graceful shutdown. Safe to call multiple times.
|
||||
*
|
||||
* 1. Sends a NOTICE("closing: …") to every currently-connected
|
||||
* client so well-behaved clients know to reconnect later.
|
||||
* 2. Stops the Ktor engine: rejects new connections immediately,
|
||||
* then waits up to [gracePeriodMillis] for active WebSocket
|
||||
* handlers to finish whatever they're processing (so an in-flight
|
||||
* `EVENT` lands its `OK` reply before the socket dies). After
|
||||
* the grace window, in-progress handlers are cancelled and the
|
||||
* engine waits up to [timeoutMillis] - [gracePeriodMillis] for
|
||||
* that cancellation to complete.
|
||||
*
|
||||
* Defaults to 5 s grace / 10 s total — generous enough that a
|
||||
* SQLite write + reply round-trip can land for typical event
|
||||
* sizes. Override either with a tighter budget if your operator
|
||||
* knows their workload.
|
||||
*/
|
||||
fun stop(
|
||||
gracePeriodMillis: Long = 5_000,
|
||||
timeoutMillis: Long = 10_000,
|
||||
) {
|
||||
val e = engine ?: return
|
||||
// Order: (1) refuse new connections so they don't slip in and
|
||||
// miss the NOTICE; (2) NOTICE every existing session so
|
||||
// well-behaved clients reconnect later; (3) hand off to Ktor
|
||||
// for the grace + timeout dance.
|
||||
shuttingDown = true
|
||||
notifyShutdown()
|
||||
e.stop(gracePeriodMillis, timeoutMillis)
|
||||
engine = null
|
||||
resolvedPort = -1
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort NOTICE to every active client. Failures are
|
||||
* swallowed — a flaky socket on its way out is exactly the case
|
||||
* where a NOTICE will fail anyway, and the client's read of the
|
||||
* close frame is the authoritative shutdown signal.
|
||||
*/
|
||||
private fun notifyShutdown() {
|
||||
val notice = NoticeMessage("closing: relay is shutting down — please reconnect later")
|
||||
activeSessions.forEach { session ->
|
||||
runCatching { session.send(notice) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.geode
|
||||
|
||||
import com.vitorpamplona.geode.config.RelayConfig
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.KindAllowDenyPolicy
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PubkeyAllowDenyPolicy
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RejectFutureEventsPolicy
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyPolicy
|
||||
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
|
||||
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Standalone entry point.
|
||||
*
|
||||
* Run with:
|
||||
* ./gradlew :geode:run --args="--config /etc/geode.toml"
|
||||
* or
|
||||
* java -cp ... com.vitorpamplona.geode.MainKt --port 7447 --verify
|
||||
*
|
||||
* Configuration precedence (highest to lowest):
|
||||
* 1. CLI flags (`--host`, `--port`, …)
|
||||
* 2. TOML file passed via `--config <path>`
|
||||
* 3. Built-in defaults (host=0.0.0.0, port=7447, in-memory db, …)
|
||||
*
|
||||
* Every section is enforced: `[info]` populates the NIP-11 doc,
|
||||
* `[network]` controls the bind, `[database]` chooses the SQLite path,
|
||||
* `[options]` toggles AUTH/verify/future-skew, `[limits]` and
|
||||
* `[authorization]` plug into the relay's policy stack.
|
||||
*
|
||||
* CLI flags:
|
||||
* --config <file> TOML config (see config.example.toml)
|
||||
* --host <addr> bind address (default from config or 0.0.0.0)
|
||||
* --port <n> tcp port (default from config or 7447, 0 to autobind)
|
||||
* --path <p> ws path (default from config or /)
|
||||
* --info <file> NIP-11 doc file (overrides [info] section)
|
||||
* --db <file> sqlite db path (overrides [database].file)
|
||||
* --auth require NIP-42 AUTH (sets options.require_auth = true)
|
||||
* --no-verify DO NOT verify event signatures (off by default
|
||||
* verify is on; use only for trusted-input
|
||||
* scenarios like fixture replay).
|
||||
*/
|
||||
fun main(args: Array<String>) {
|
||||
val a = parseArgs(args)
|
||||
|
||||
val config: RelayConfig =
|
||||
a
|
||||
.opt("--config")
|
||||
?.let { RelayConfig.fromFile(File(it)) }
|
||||
?: RelayConfig()
|
||||
|
||||
val host = a.opt("--host") ?: config.network.host
|
||||
val port = a.opt("--port")?.toInt() ?: config.network.port
|
||||
val path = a.opt("--path") ?: config.network.path
|
||||
|
||||
val cliInfoFile = a.opt("--info")?.let { File(it) }
|
||||
val dbFile = a.opt("--db") ?: config.database.file?.takeUnless { config.database.in_memory }
|
||||
val requireAuth = a.flag("--auth") || config.options.require_auth
|
||||
// Verify is on by default; only disable when the operator explicitly
|
||||
// opts out (CLI `--no-verify` or `[options].verify_signatures = false`
|
||||
// in the config).
|
||||
val verifySigs = !a.flag("--no-verify") && config.options.verify_signatures
|
||||
|
||||
// Advertised URL: explicit `info.relay_url` wins, then build from
|
||||
// host/port/path. 0.0.0.0 bind → 127.0.0.1 in the URL so NIP-42
|
||||
// challenges are well-formed.
|
||||
val advertisedHost = if (host == "0.0.0.0") "127.0.0.1" else host
|
||||
val advertisedUrl =
|
||||
(config.info.relay_url ?: "ws://$advertisedHost:$port$path").normalizeRelayUrl()
|
||||
|
||||
val info =
|
||||
cliInfoFile?.let { RelayInfo.fromFile(it) }
|
||||
?: config.resolveInfo(advertisedUrl)
|
||||
|
||||
val store: IEventStore = EventStore(dbName = dbFile, relay = advertisedUrl)
|
||||
|
||||
val policyBuilder: () -> IRelayPolicy = {
|
||||
composePolicy(config, advertisedUrl, requireAuth, verifySigs)
|
||||
}
|
||||
|
||||
val stateFile = config.admin.state_file?.let { File(it) }
|
||||
val relay = Relay(advertisedUrl, store, info, policyBuilder, stateFile = stateFile)
|
||||
// Frame cap honors max_ws_frame_bytes when set; max_ws_message_bytes
|
||||
// is treated as the same cap (Ktor's WebSockets plugin only exposes
|
||||
// a single per-frame limit; multi-frame messages remain unbounded).
|
||||
val frameLimit =
|
||||
(config.limits.max_ws_frame_bytes ?: config.limits.max_ws_message_bytes)?.toLong()
|
||||
val server =
|
||||
LocalRelayServer(
|
||||
relay,
|
||||
host = host,
|
||||
port = port,
|
||||
path = path,
|
||||
maxFrameBytes = frameLimit,
|
||||
adminPubkeys = config.admin.pubkeys.toSet(),
|
||||
publicUrl = config.admin.public_url,
|
||||
).start()
|
||||
|
||||
Runtime.getRuntime().addShutdownHook(
|
||||
Thread {
|
||||
// Each step wrapped so a throw in `server.stop()` doesn't
|
||||
// skip `relay.close()` (which closes the SQLite store).
|
||||
runCatching { server.stop() }
|
||||
runCatching { relay.close() }
|
||||
},
|
||||
)
|
||||
|
||||
println("geode listening on ${server.url}")
|
||||
println("NIP-11 info doc: curl -H 'Accept: application/nostr+json' http://$advertisedHost:$port$path")
|
||||
|
||||
// Park the main thread; shutdown hook handles teardown.
|
||||
Thread.currentThread().join()
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the policy stack for one connection from the config.
|
||||
*
|
||||
* Order matters — cheap rejection paths run before expensive ones:
|
||||
* 1. AUTH (drops everything if not authenticated)
|
||||
* 2. Future-timestamp + allow/deny lists
|
||||
* 3. Signature verification (most expensive — Schnorr verify)
|
||||
*/
|
||||
private fun composePolicy(
|
||||
config: RelayConfig,
|
||||
advertisedUrl: com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl,
|
||||
requireAuth: Boolean,
|
||||
verifySigs: Boolean,
|
||||
): IRelayPolicy {
|
||||
val pieces = mutableListOf<IRelayPolicy>()
|
||||
|
||||
if (requireAuth) {
|
||||
pieces += FullAuthPolicy(advertisedUrl)
|
||||
}
|
||||
|
||||
config.options.reject_future_seconds?.let { secs ->
|
||||
pieces += RejectFutureEventsPolicy(secs)
|
||||
}
|
||||
|
||||
val auth = config.authorization
|
||||
if (auth.kind_whitelist.isNotEmpty() || auth.kind_blacklist.isNotEmpty()) {
|
||||
pieces += KindAllowDenyPolicy(auth.kind_whitelist.toSet(), auth.kind_blacklist.toSet())
|
||||
}
|
||||
if (auth.pubkey_whitelist.isNotEmpty() || auth.pubkey_blacklist.isNotEmpty()) {
|
||||
pieces += PubkeyAllowDenyPolicy(auth.pubkey_whitelist.toSet(), auth.pubkey_blacklist.toSet())
|
||||
}
|
||||
|
||||
if (verifySigs) {
|
||||
pieces += VerifyPolicy
|
||||
}
|
||||
|
||||
return pieces.fold<IRelayPolicy, IRelayPolicy>(EmptyPolicy) { acc, p ->
|
||||
if (acc === EmptyPolicy) p else acc + p
|
||||
}
|
||||
}
|
||||
|
||||
private class Args(
|
||||
private val opts: Map<String, String>,
|
||||
private val flags: Set<String>,
|
||||
) {
|
||||
fun opt(k: String) = opts[k]
|
||||
|
||||
fun flag(k: String) = k in flags
|
||||
}
|
||||
|
||||
private fun parseArgs(args: Array<String>): Args {
|
||||
val opts = mutableMapOf<String, String>()
|
||||
val flags = mutableSetOf<String>()
|
||||
var i = 0
|
||||
while (i < args.size) {
|
||||
val a = args[i]
|
||||
if (a.startsWith("--")) {
|
||||
// Support both `--key value` and `--key=value`. Splitting
|
||||
// on the first `=` lets operators paste config values that
|
||||
// happen to contain `=` (e.g. NIP-11 contact emails) by
|
||||
// using the space-separated form.
|
||||
val eq = a.indexOf('=')
|
||||
if (eq > 0) {
|
||||
opts[a.substring(0, eq)] = a.substring(eq + 1)
|
||||
i += 1
|
||||
} else {
|
||||
val next = args.getOrNull(i + 1)
|
||||
if (next != null && !next.startsWith("--")) {
|
||||
opts[a] = next
|
||||
i += 2
|
||||
} else {
|
||||
flags += a
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
} else {
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
return Args(opts, flags)
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.geode
|
||||
|
||||
import com.vitorpamplona.geode.persistence.BannedEntry
|
||||
import com.vitorpamplona.geode.persistence.RelayPersistedState
|
||||
import com.vitorpamplona.geode.persistence.RelayStateStore
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy
|
||||
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
|
||||
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
|
||||
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
|
||||
import com.vitorpamplona.quartz.nip86RelayManagement.server.BanListPolicy
|
||||
import com.vitorpamplona.quartz.nip86RelayManagement.server.BanStore
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import java.io.File
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
/**
|
||||
* A self-contained Nostr relay scoped to a single URL. Wraps a [NostrServer]
|
||||
* over an [EventStore] (defaults to an in-memory SQLite database).
|
||||
*
|
||||
* Speaks NIP-01 (REQ/EVENT/EOSE/CLOSE), NIP-11 (relay info via [info]),
|
||||
* NIP-42 (AUTH — supply [policyBuilder] = `{ FullAuthPolicy(url) }` or
|
||||
* stack one with [com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy.plus]),
|
||||
* NIP-45 (COUNT) and NIP-50 (search via the SQLite FTS index).
|
||||
*
|
||||
* Two transports:
|
||||
* - [com.vitorpamplona.quartz.nip01Core.relay.server.inprocess.InProcessWebSocket] /
|
||||
* [RelayHub] — no socket, fastest path, ideal
|
||||
* for unit tests inside one JVM.
|
||||
* - [LocalRelayServer] — Ktor `embeddedServer` listening on a real port.
|
||||
* Use when external clients need to connect (`cli`, instrumented tests,
|
||||
* standalone deployment).
|
||||
*/
|
||||
class Relay(
|
||||
val url: NormalizedRelayUrl,
|
||||
val store: IEventStore = EventStore(dbName = null, relay = url),
|
||||
info: RelayInfo = RelayInfo.default(url),
|
||||
policyBuilder: () -> IRelayPolicy = { EmptyPolicy },
|
||||
parentContext: CoroutineContext = SupervisorJob(),
|
||||
/**
|
||||
* Optional path for the operator-state JSON snapshot. When set,
|
||||
* the file is loaded at boot to seed [info] and [banStore], and
|
||||
* rewritten atomically on every NIP-86 mutation and
|
||||
* [updateInfo] call so admin actions survive restarts.
|
||||
*
|
||||
* Convention: place next to the SQLite event-store file
|
||||
* (e.g. `events.db` → `events.db.admin.json`). `null` keeps
|
||||
* everything in memory only — fine for tests.
|
||||
*/
|
||||
stateFile: File? = null,
|
||||
) : AutoCloseable {
|
||||
private val stateStore: RelayStateStore? = stateFile?.let { RelayStateStore(it) }
|
||||
|
||||
/**
|
||||
* NIP-11 doc. Mutable so NIP-86 admin RPCs (`changerelayname`,
|
||||
* `changerelaydescription`, `changerelayicon`) can swap the doc
|
||||
* atomically. Readers (the NIP-11 GET endpoint) re-read on every
|
||||
* request so changes are visible immediately, no restart needed.
|
||||
*
|
||||
* If a [RelayStateStore] is configured and the snapshot exists,
|
||||
* the persisted info doc takes precedence over the constructor
|
||||
* default — operators expect their last `changerelayname` to
|
||||
* survive a restart.
|
||||
*/
|
||||
@Volatile
|
||||
var info: RelayInfo =
|
||||
stateStore?.load()?.info?.let { RelayInfo(it) } ?: info
|
||||
private set
|
||||
|
||||
/** Mutates the live NIP-11 doc. Called by [Nip86Server]. */
|
||||
fun updateInfo(transform: (Nip11RelayInformation) -> Nip11RelayInformation) {
|
||||
info = RelayInfo(transform(info.document))
|
||||
snapshot()
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime-mutable ban / allow lists. NIP-86 RPC handlers in
|
||||
* [Nip86Server] mutate this; the policy stack consults it on
|
||||
* every accept call via [BanListPolicy].
|
||||
*/
|
||||
val banStore: BanStore = BanStore(onMutation = { snapshot() })
|
||||
|
||||
init {
|
||||
// Seed the in-memory ban state from disk *without* triggering
|
||||
// [snapshot] on every entry — the snapshot is exactly what we
|
||||
// just loaded.
|
||||
stateStore?.load()?.let { snap ->
|
||||
banStore.seedFromSnapshot(
|
||||
bannedPubkeys = snap.bannedPubkeys.map { it.key to it.reason },
|
||||
allowedPubkeys = snap.allowedPubkeys.map { it.key to it.reason },
|
||||
bannedEvents = snap.bannedEvents.map { it.key to it.reason },
|
||||
allowedKinds = snap.allowedKinds,
|
||||
disallowedKinds = snap.disallowedKinds,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the current state (NIP-11 doc + ban lists) to disk.
|
||||
* No-op when no `stateFile` was configured.
|
||||
*
|
||||
* Best-effort: any I/O failure is logged to stderr and swallowed
|
||||
* so an unwritable disk doesn't take the relay down. Operators
|
||||
* monitor for missing snapshots out-of-band.
|
||||
*/
|
||||
fun snapshot() {
|
||||
val s = stateStore ?: return
|
||||
runCatching {
|
||||
s.save(
|
||||
RelayPersistedState(
|
||||
info = info.document,
|
||||
bannedPubkeys = banStore.listBannedPubkeys().map { (k, r) -> BannedEntry(k, r) },
|
||||
allowedPubkeys = banStore.listAllowedPubkeys().map { (k, r) -> BannedEntry(k, r) },
|
||||
bannedEvents = banStore.listBannedEvents().map { (k, r) -> BannedEntry(k, r) },
|
||||
allowedKinds = banStore.listAllowedKinds(),
|
||||
disallowedKinds = banStore.listDisallowedKinds(),
|
||||
),
|
||||
)
|
||||
}.onFailure {
|
||||
System.err.println("warning: failed to write relay state file: ${it.message}")
|
||||
}
|
||||
}
|
||||
|
||||
val server =
|
||||
NostrServer(
|
||||
store,
|
||||
// Always prepend a BanListPolicy so NIP-86 admin actions
|
||||
// bite. When the operator-supplied builder returns
|
||||
// [EmptyPolicy] we use the dynamic policy alone; otherwise
|
||||
// we stack them so both layers must accept.
|
||||
policyBuilder = {
|
||||
val user = policyBuilder()
|
||||
if (user === EmptyPolicy) BanListPolicy(banStore) else user + BanListPolicy(banStore)
|
||||
},
|
||||
parentContext,
|
||||
)
|
||||
|
||||
/**
|
||||
* Inserts events directly into the underlying store, bypassing the wire protocol.
|
||||
*
|
||||
* Use this for **pre-test setup** — events that exist before any client connects.
|
||||
* It does NOT broadcast to active subscriptions. For sending events that should
|
||||
* fan out to live subscribers (post-EOSE), use [publish] instead.
|
||||
*/
|
||||
suspend fun preload(events: Iterable<Event>) {
|
||||
events.forEach { store.insert(it) }
|
||||
}
|
||||
|
||||
/** @see preload(Iterable) */
|
||||
suspend fun preload(vararg events: Event) = preload(events.toList())
|
||||
|
||||
/**
|
||||
* Publishes an event through the relay's session machinery so it both lands
|
||||
* in the store and fans out to active subscriptions matching its filters
|
||||
* (mirrors what a real client would do via an `EVENT` command).
|
||||
*/
|
||||
suspend fun publish(event: Event) {
|
||||
val session = server.connect { /* ignore OK echo */ }
|
||||
try {
|
||||
session.receive(OptimizedJsonMapper.toJson(EventCmd(event)))
|
||||
} finally {
|
||||
session.close()
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() = server.close()
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.geode
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.inprocess.InProcessWebSocket
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Registry of [Relay] instances keyed by relay URL. Implements
|
||||
* [WebsocketBuilder] so it can be plugged into
|
||||
* [com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient] in place of
|
||||
* `BasicOkHttpWebSocket.Builder` to redirect every outbound connection to an
|
||||
* in-memory relay.
|
||||
*
|
||||
* Usage:
|
||||
* ```
|
||||
* val hub = RelayHub()
|
||||
* val relay = hub.getOrCreate("ws://test.relay/")
|
||||
* runBlocking { relay.preload(listOf(event1, event2)) }
|
||||
* val client = NostrClient(hub, scope)
|
||||
* ```
|
||||
*
|
||||
* Unknown URLs auto-create an empty relay so a single hub can transparently
|
||||
* back any number of test endpoints.
|
||||
*/
|
||||
class RelayHub(
|
||||
private val defaultPolicy: () -> IRelayPolicy = { EmptyPolicy },
|
||||
) : WebsocketBuilder,
|
||||
AutoCloseable {
|
||||
private val relays = ConcurrentHashMap<NormalizedRelayUrl, Relay>()
|
||||
|
||||
@Volatile
|
||||
private var closed = false
|
||||
|
||||
fun getOrCreate(url: NormalizedRelayUrl): Relay {
|
||||
check(!closed) { "RelayHub has been closed" }
|
||||
return relays.getOrPut(url) {
|
||||
Relay(url = url, policyBuilder = defaultPolicy)
|
||||
}
|
||||
}
|
||||
|
||||
fun getOrCreate(url: String): Relay = getOrCreate(RelayUrlNormalizer.normalize(url))
|
||||
|
||||
fun get(url: NormalizedRelayUrl): Relay? = relays[url]
|
||||
|
||||
fun urls(): Set<NormalizedRelayUrl> = relays.keys.toSet()
|
||||
|
||||
override fun build(
|
||||
url: NormalizedRelayUrl,
|
||||
out: WebSocketListener,
|
||||
): WebSocket = InProcessWebSocket(getOrCreate(url).server, out)
|
||||
|
||||
/**
|
||||
* Idempotent. Sets the closed flag first so concurrent
|
||||
* `getOrCreate` calls fail-fast — otherwise a relay created
|
||||
* between iteration and clear would leak (its store would never
|
||||
* be closed).
|
||||
*/
|
||||
override fun close() {
|
||||
closed = true
|
||||
relays.values.forEach { runCatching { it.close() } }
|
||||
relays.clear()
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Default URL for tests that only need one relay. The URL itself
|
||||
* has no semantic meaning — it's just a stable key into the hub
|
||||
* — but it normalises through [RelayUrlNormalizer] (loopback) so
|
||||
* the production [com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer]
|
||||
* accepts it. Prefer this over typing `"ws://127.0.0.1:7770/"`
|
||||
* everywhere.
|
||||
*/
|
||||
val DEFAULT_URL: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.geode
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Relay-side handle for the NIP-11 information document. Wraps the
|
||||
* client-side [Nip11RelayInformation] model and provides loaders for
|
||||
* config files plus a default doc that advertises the NIPs this relay
|
||||
* actually implements.
|
||||
*/
|
||||
data class RelayInfo(
|
||||
val document: Nip11RelayInformation,
|
||||
) {
|
||||
/** Pre-rendered JSON, ready to write into the HTTP response body. */
|
||||
val json: String by lazy { JsonMapper.toJson(document) }
|
||||
|
||||
companion object {
|
||||
const val NAME = "geode"
|
||||
const val DESCRIPTION = "Embedded Nostr relay from the Amethyst quartz library."
|
||||
const val SOFTWARE = "https://github.com/vitorpamplona/amethyst/tree/main/geode"
|
||||
const val VERSION = "1.08.0"
|
||||
|
||||
/**
|
||||
* NIPs this relay implements out of the box. Single source of
|
||||
* truth — both [default] and [com.vitorpamplona.geode.config.RelayConfig.resolveInfo]
|
||||
* consult this list. Add a NIP here when its handler is wired
|
||||
* into [com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession]
|
||||
* (or in this module's policy stack).
|
||||
*
|
||||
* Currently:
|
||||
* - 1 NIP-01 basic
|
||||
* - 9 NIP-09 deletion (DeletionRequestModule)
|
||||
* - 11 NIP-11 this doc
|
||||
* - 40 NIP-40 expiration (ExpirationModule)
|
||||
* - 42 NIP-42 AUTH (when policy enables)
|
||||
* - 45 NIP-45 COUNT
|
||||
* - 50 NIP-50 search (SQLite FTS)
|
||||
* - 62 NIP-62 right to vanish
|
||||
* - 77 NIP-77 negentropy reconciliation
|
||||
* - 86 NIP-86 relay management API (when admin pubkeys configured)
|
||||
*/
|
||||
val SUPPORTED_NIPS: List<String> =
|
||||
listOf("1", "9", "11", "40", "42", "45", "50", "62", "77", "86")
|
||||
|
||||
/** Pre-built default for `Relay(url = ...)` — advertises the supported NIPs. */
|
||||
fun default(url: NormalizedRelayUrl): RelayInfo =
|
||||
RelayInfo(
|
||||
Nip11RelayInformation(
|
||||
name = NAME,
|
||||
description = DESCRIPTION,
|
||||
software = SOFTWARE,
|
||||
version = VERSION,
|
||||
supported_nips = SUPPORTED_NIPS,
|
||||
),
|
||||
)
|
||||
|
||||
/** Loads a NIP-11 doc from a JSON file (e.g. a relay operator's config). */
|
||||
fun fromFile(file: File): RelayInfo = RelayInfo(Nip11RelayInformation.fromJson(file.readText()))
|
||||
|
||||
/** Parses a NIP-11 doc from a raw JSON string. */
|
||||
fun fromJson(json: String): RelayInfo = RelayInfo(Nip11RelayInformation.fromJson(json))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.geode.config
|
||||
|
||||
import cc.ekblad.toml.decode
|
||||
import cc.ekblad.toml.tomlMapper
|
||||
import com.vitorpamplona.geode.RelayInfo
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
|
||||
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Operator-facing configuration. Section layout matches nostr-rs-relay's
|
||||
* `config.toml` so existing configs can be ported with little churn.
|
||||
*
|
||||
* Every section is optional; values not set fall back to sensible
|
||||
* defaults (or, for fields also exposed on the CLI, the CLI value wins).
|
||||
*/
|
||||
data class RelayConfig(
|
||||
val info: InfoSection = InfoSection(),
|
||||
val network: NetworkSection = NetworkSection(),
|
||||
val database: DatabaseSection = DatabaseSection(),
|
||||
val options: OptionsSection = OptionsSection(),
|
||||
val limits: LimitsSection = LimitsSection(),
|
||||
val authorization: AuthorizationSection = AuthorizationSection(),
|
||||
val admin: AdminSection = AdminSection(),
|
||||
) {
|
||||
/**
|
||||
* Maps the `[info]` section into a [RelayInfo] used by the NIP-11
|
||||
* endpoint. `relay_url` and CLI overrides take precedence.
|
||||
*/
|
||||
fun resolveInfo(advertisedUrl: NormalizedRelayUrl): RelayInfo =
|
||||
RelayInfo(
|
||||
Nip11RelayInformation(
|
||||
name = info.name ?: RelayInfo.NAME,
|
||||
description = info.description ?: RelayInfo.DESCRIPTION,
|
||||
pubkey = info.pubkey,
|
||||
contact = info.contact,
|
||||
icon = info.icon,
|
||||
software = info.software ?: RelayInfo.SOFTWARE,
|
||||
version = info.version ?: RelayInfo.VERSION,
|
||||
supported_nips =
|
||||
info.supported_nips?.map(Int::toString)
|
||||
?: RelayInfo.SUPPORTED_NIPS,
|
||||
privacy_policy = info.privacy_policy,
|
||||
terms_of_service = info.terms_of_service,
|
||||
relay_countries = info.relay_countries,
|
||||
language_tags = info.language_tags,
|
||||
tags = info.tags,
|
||||
),
|
||||
).also {
|
||||
// Touch [advertisedUrl] so the parameter isn't unused — we keep
|
||||
// it in the signature because future fields (e.g. self-pubkey
|
||||
// selection, fee URLs) will want it.
|
||||
advertisedUrl.url
|
||||
}
|
||||
|
||||
data class InfoSection(
|
||||
val relay_url: String? = null,
|
||||
val name: String? = null,
|
||||
val description: String? = null,
|
||||
val pubkey: String? = null,
|
||||
val contact: String? = null,
|
||||
val icon: String? = null,
|
||||
val software: String? = null,
|
||||
val version: String? = null,
|
||||
/** NIP numbers as ints (e.g. `[1, 9, 11]`). Stringified at render time. */
|
||||
val supported_nips: List<Int>? = null,
|
||||
val privacy_policy: String? = null,
|
||||
val terms_of_service: String? = null,
|
||||
val relay_countries: List<String>? = null,
|
||||
val language_tags: List<String>? = null,
|
||||
val tags: List<String>? = null,
|
||||
)
|
||||
|
||||
data class NetworkSection(
|
||||
val host: String = "0.0.0.0",
|
||||
val port: Int = 7447,
|
||||
val path: String = "/",
|
||||
)
|
||||
|
||||
data class DatabaseSection(
|
||||
/** True keeps an in-memory SQLite db (default — events vanish on restart). */
|
||||
val in_memory: Boolean = true,
|
||||
/** Filesystem path for a persistent SQLite db. Ignored when [in_memory] is true. */
|
||||
val file: String? = null,
|
||||
)
|
||||
|
||||
data class OptionsSection(
|
||||
/** Reject events whose `created_at` is more than this many seconds in the future. */
|
||||
val reject_future_seconds: Int? = null,
|
||||
/** Require NIP-42 AUTH for REQ/EVENT/COUNT. */
|
||||
val require_auth: Boolean = false,
|
||||
/**
|
||||
* Drop events whose Schnorr signature does not verify. **Defaults
|
||||
* to `true`**: any relay accepting traffic from real clients
|
||||
* should verify signatures, and verifying-by-default closes the
|
||||
* footgun of forgetting the flag. Set explicitly to `false` only
|
||||
* for trusted-input scenarios (test fixtures, mirror replays).
|
||||
*/
|
||||
val verify_signatures: Boolean = true,
|
||||
)
|
||||
|
||||
data class LimitsSection(
|
||||
val max_ws_message_bytes: Int? = null,
|
||||
val max_ws_frame_bytes: Int? = null,
|
||||
)
|
||||
|
||||
data class AuthorizationSection(
|
||||
val pubkey_whitelist: List<String> = emptyList(),
|
||||
val pubkey_blacklist: List<String> = emptyList(),
|
||||
val kind_whitelist: List<Int> = emptyList(),
|
||||
val kind_blacklist: List<Int> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* NIP-86 relay management API. When [pubkeys] is non-empty,
|
||||
* `LocalRelayServer` exposes a POST endpoint at the relay path
|
||||
* that accepts JSON-RPC admin requests authenticated via NIP-98
|
||||
* HTTP-Auth. Only requests signed by one of these pubkeys are
|
||||
* dispatched.
|
||||
*
|
||||
* [public_url] is the canonical URL the relay is reachable at,
|
||||
* e.g. `https://relay.example.com/`. NIP-98's URL binding compares
|
||||
* the signed `u` tag against this — without it, an attacker can
|
||||
* spoof the `Host` header to bind their signature to any URL.
|
||||
* Required when running behind TLS termination or a reverse proxy.
|
||||
*/
|
||||
data class AdminSection(
|
||||
val pubkeys: List<String> = emptyList(),
|
||||
val public_url: String? = null,
|
||||
/**
|
||||
* Path for the JSON snapshot that persists NIP-86 admin state
|
||||
* (ban lists + the live NIP-11 doc) across restarts. When
|
||||
* unset, admin state is in-memory only.
|
||||
*
|
||||
* Convention: place next to the SQLite event-store file —
|
||||
* e.g. `[database].file = "/var/lib/geode/events.db"`
|
||||
* pairs with `[admin].state_file = "/var/lib/geode/events.db.admin.json"`.
|
||||
*/
|
||||
val state_file: String? = null,
|
||||
)
|
||||
|
||||
companion object {
|
||||
private val mapper = tomlMapper { }
|
||||
|
||||
/** Parse a TOML string. */
|
||||
fun fromToml(toml: String): RelayConfig = mapper.decode<RelayConfig>(toml)
|
||||
|
||||
/** Load a TOML config file. */
|
||||
fun fromFile(file: File): RelayConfig = mapper.decode<RelayConfig>(file.toPath())
|
||||
|
||||
/**
|
||||
* Returns the URL the relay advertises in NIP-11 and NIP-42
|
||||
* challenges. Picks (in order):
|
||||
* 1. `info.relay_url` from the config
|
||||
* 2. The `network` section's host/port/path (with 0.0.0.0 → 127.0.0.1)
|
||||
* 3. The CLI override (handled in `Main.kt`).
|
||||
*/
|
||||
fun advertisedUrl(config: RelayConfig): NormalizedRelayUrl =
|
||||
(
|
||||
config.info.relay_url
|
||||
?: defaultUrl(config.network)
|
||||
).normalizeRelayUrl()
|
||||
|
||||
private fun defaultUrl(net: NetworkSection): String {
|
||||
val host = if (net.host == "0.0.0.0") "127.0.0.1" else net.host
|
||||
return "ws://$host:${net.port}${net.path}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.geode.persistence
|
||||
|
||||
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
|
||||
/**
|
||||
* On-disk snapshot of the relay's *operator-mutable* state — the
|
||||
* NIP-11 info doc (so `changerelayname/description/icon` survive a
|
||||
* restart) and the NIP-86 ban / allow / kind lists.
|
||||
*
|
||||
* One JSON file per relay. Lives next to the SQLite event store by
|
||||
* convention, but the path is configurable independently. Atomic
|
||||
* write via temp + atomic rename so a crash mid-save can never leave
|
||||
* the file half-written.
|
||||
*
|
||||
* The schema below intentionally mirrors NIP-86 list responses
|
||||
* (`pubkey + reason`, `id + reason`) so a future operator-tools CLI
|
||||
* can read these straight from disk without translation.
|
||||
*/
|
||||
class RelayStateStore(
|
||||
val file: File,
|
||||
) {
|
||||
/** Load the snapshot from disk, or `null` if the file does not yet exist. */
|
||||
@Synchronized
|
||||
fun load(): RelayPersistedState? {
|
||||
if (!file.exists()) return null
|
||||
return try {
|
||||
json.decodeFromString(RelayPersistedState.serializer(), file.readText())
|
||||
} catch (e: Exception) {
|
||||
// Corrupt file — log to stderr and refuse to overwrite. The
|
||||
// operator chooses whether to fix or delete; we don't blow
|
||||
// away their state silently.
|
||||
System.err.println("warning: failed to read relay state file ${file.absolutePath}: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** Atomically write the snapshot. */
|
||||
@Synchronized
|
||||
fun save(state: RelayPersistedState) {
|
||||
file.parentFile?.let { if (!it.exists()) it.mkdirs() }
|
||||
val tmp = File(file.parentFile ?: file.absoluteFile.parentFile, "${file.name}.tmp")
|
||||
tmp.writeText(json.encodeToString(RelayPersistedState.serializer(), state))
|
||||
Files.move(
|
||||
tmp.toPath(),
|
||||
file.toPath(),
|
||||
StandardCopyOption.REPLACE_EXISTING,
|
||||
StandardCopyOption.ATOMIC_MOVE,
|
||||
)
|
||||
}
|
||||
|
||||
private val json =
|
||||
Json {
|
||||
prettyPrint = true
|
||||
encodeDefaults = false
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class RelayPersistedState(
|
||||
val info: Nip11RelayInformation? = null,
|
||||
val bannedPubkeys: List<BannedEntry> = emptyList(),
|
||||
val allowedPubkeys: List<BannedEntry> = emptyList(),
|
||||
val bannedEvents: List<BannedEntry> = emptyList(),
|
||||
val allowedKinds: List<Int> = emptyList(),
|
||||
val disallowedKinds: List<Int> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BannedEntry(
|
||||
val key: String,
|
||||
val reason: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,181 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.geode.server
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
||||
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request
|
||||
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Response
|
||||
import com.vitorpamplona.quartz.nip86RelayManagement.server.Nip86Server
|
||||
import com.vitorpamplona.quartz.nip98HttpAuth.Nip98AuthVerifier
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.application.ApplicationCall
|
||||
import io.ktor.server.request.header
|
||||
import io.ktor.server.request.receiveChannel
|
||||
import io.ktor.server.response.respondText
|
||||
import io.ktor.utils.io.readAvailable
|
||||
|
||||
/**
|
||||
* NIP-86 admin POST handler. Owns the gating order:
|
||||
* 1. 403 if no admin pubkey list is configured (endpoint disabled).
|
||||
* 2. 413 if body exceeds [maxBodyBytes] (declared or actual).
|
||||
* 3. 401 if the NIP-98 Authorization header is missing/invalid.
|
||||
* 4. 403 if the verified pubkey isn't in [allowList].
|
||||
* 5. 400 if the body isn't a valid Nip86Request.
|
||||
* 6. 200 with a Nip86Response JSON body otherwise.
|
||||
*
|
||||
* The [signedUrlFor] callback resolves what URL the client must have
|
||||
* signed in their NIP-98 token. Operators configure the canonical
|
||||
* `publicUrl`; loopback tests fall back to the request's `Host`
|
||||
* header. We pass it as a callback rather than a string so the route
|
||||
* doesn't need to know about Ktor request internals.
|
||||
*/
|
||||
internal class Nip86HttpRoute(
|
||||
private val server: Nip86Server,
|
||||
private val verifier: Nip98AuthVerifier,
|
||||
private val allowList: Set<HexKey>,
|
||||
private val maxBodyBytes: Int,
|
||||
private val signedUrlFor: (ApplicationCall) -> String,
|
||||
) {
|
||||
suspend fun handle(call: ApplicationCall) {
|
||||
if (allowList.isEmpty()) {
|
||||
call.respondText(
|
||||
"NIP-86 management API is not enabled on this relay.",
|
||||
ContentType.Text.Plain,
|
||||
HttpStatusCode.Forbidden,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val body = readBoundedBody(call) ?: return
|
||||
val pubkey = verifyAuth(call, body) ?: return
|
||||
if (pubkey.lowercase() !in allowList) {
|
||||
call.respondText(
|
||||
"pubkey is not on the admin list",
|
||||
ContentType.Text.Plain,
|
||||
HttpStatusCode.Forbidden,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val req =
|
||||
try {
|
||||
JsonMapper.fromJson<Nip86Request>(body.decodeToString())
|
||||
} catch (e: Exception) {
|
||||
call.respondText(
|
||||
"invalid Nip86Request: ${e.message ?: e::class.simpleName}",
|
||||
ContentType.Text.Plain,
|
||||
HttpStatusCode.BadRequest,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val response: Nip86Response = server.dispatch(req)
|
||||
audit(pubkey, req, response)
|
||||
call.respondText(
|
||||
JsonMapper.toJson(response),
|
||||
ContentType.parse("application/nostr+json+rpc"),
|
||||
HttpStatusCode.OK,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun readBoundedBody(call: ApplicationCall): ByteArray? {
|
||||
val declared = call.request.headers[HttpHeaders.ContentLength]?.toLongOrNull()
|
||||
if (declared != null && declared > maxBodyBytes) {
|
||||
call.respondText(
|
||||
"request body exceeds $maxBodyBytes-byte cap",
|
||||
ContentType.Text.Plain,
|
||||
HttpStatusCode.PayloadTooLarge,
|
||||
)
|
||||
return null
|
||||
}
|
||||
val ch = call.receiveChannel()
|
||||
val buf = ByteArray(maxBodyBytes + 1)
|
||||
var pos = 0
|
||||
while (pos <= maxBodyBytes) {
|
||||
val read = ch.readAvailable(buf, pos, buf.size - pos)
|
||||
if (read <= 0) break
|
||||
pos += read
|
||||
}
|
||||
if (pos > maxBodyBytes) {
|
||||
call.respondText(
|
||||
"request body exceeds $maxBodyBytes-byte cap",
|
||||
ContentType.Text.Plain,
|
||||
HttpStatusCode.PayloadTooLarge,
|
||||
)
|
||||
return null
|
||||
}
|
||||
return buf.copyOfRange(0, pos)
|
||||
}
|
||||
|
||||
private suspend fun verifyAuth(
|
||||
call: ApplicationCall,
|
||||
body: ByteArray,
|
||||
): HexKey? {
|
||||
val header = call.request.header(HttpHeaders.Authorization)
|
||||
val verification = verifier.verify(header, method = "POST", url = signedUrlFor(call), body = body)
|
||||
return when (verification) {
|
||||
is Nip98AuthVerifier.Result.Verified -> {
|
||||
verification.pubkey
|
||||
}
|
||||
|
||||
Nip98AuthVerifier.Result.Missing -> {
|
||||
call.response.headers.append(HttpHeaders.WWWAuthenticate, Nip98AuthVerifier.SCHEME.trim())
|
||||
call.respondText(
|
||||
"missing Authorization header (NIP-98)",
|
||||
ContentType.Text.Plain,
|
||||
HttpStatusCode.Unauthorized,
|
||||
)
|
||||
null
|
||||
}
|
||||
|
||||
is Nip98AuthVerifier.Result.Malformed -> {
|
||||
call.respondText(
|
||||
"invalid NIP-98 Authorization: ${verification.reason}",
|
||||
ContentType.Text.Plain,
|
||||
HttpStatusCode.Unauthorized,
|
||||
)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Audit log: structured single line so an operator can grep
|
||||
* "nip86" / pubkey / method without a logging framework
|
||||
* dependency. Best-effort — a missing log line shouldn't fail
|
||||
* the response.
|
||||
*/
|
||||
private fun audit(
|
||||
pubkey: HexKey,
|
||||
req: Nip86Request,
|
||||
response: Nip86Response,
|
||||
) {
|
||||
runCatching {
|
||||
System.err.println(
|
||||
"nip86 audit pubkey=$pubkey method=${req.method} ok=${response.error == null}" +
|
||||
(response.error?.let { " error=$it" } ?: ""),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.geode.server
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession
|
||||
import io.ktor.server.websocket.DefaultWebSocketServerSession
|
||||
import io.ktor.websocket.Frame
|
||||
import io.ktor.websocket.readText
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.ClosedSendChannelException
|
||||
import kotlinx.coroutines.channels.consumeEach
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Per-WebSocket pump that owns the bounded outbound queue and the
|
||||
* writer coroutine. Pulled out of `LocalRelayServer` so that file
|
||||
* stays focused on Ktor wiring; the slow-client / backpressure
|
||||
* policy now lives next to the data structures it manages.
|
||||
*
|
||||
* Lifecycle:
|
||||
* 1. `connect(server, registerSession)` opens a [RelaySession],
|
||||
* registers it with the supplied callback, and starts the
|
||||
* writer coroutine that drains [outQueue] into [outgoing].
|
||||
* 2. `pump()` reads inbound frames until the socket closes.
|
||||
* 3. `finally`-style teardown closes the queue, cancels the
|
||||
* writer, unregisters the session, and closes it.
|
||||
*
|
||||
* Slow-client policy: when [outQueue] fills, [SESSION_OUTGOING_BUFFER]
|
||||
* frames behind, the connection is dropped rather than silently
|
||||
* losing EVENT/EOSE — silent drop would corrupt NIP-01.
|
||||
*/
|
||||
internal class WebSocketSessionPump(
|
||||
private val ws: DefaultWebSocketServerSession,
|
||||
) {
|
||||
private val outQueue = Channel<String>(capacity = SESSION_OUTGOING_BUFFER)
|
||||
private var droppedForBackpressure = false
|
||||
|
||||
suspend fun pump(
|
||||
server: NostrServer,
|
||||
registerSession: (RelaySession) -> Unit,
|
||||
unregisterSession: (RelaySession) -> Unit,
|
||||
) {
|
||||
val writerJob =
|
||||
ws.launch {
|
||||
try {
|
||||
for (json in outQueue) {
|
||||
ws.outgoing.send(Frame.Text(json))
|
||||
}
|
||||
} catch (_: ClosedSendChannelException) {
|
||||
// socket closed — outer handler runs normal teardown.
|
||||
}
|
||||
}
|
||||
val session =
|
||||
server.connect { json ->
|
||||
val res = outQueue.trySend(json)
|
||||
if (!res.isSuccess && !res.isClosed) {
|
||||
// Buffer is full → slow client. Mark + close the
|
||||
// queue; the writer drains, then the outer handler
|
||||
// closes the WS session.
|
||||
droppedForBackpressure = true
|
||||
outQueue.close()
|
||||
}
|
||||
}
|
||||
registerSession(session)
|
||||
try {
|
||||
ws.incoming.consumeEach { frame ->
|
||||
if (droppedForBackpressure) return@consumeEach
|
||||
if (frame is Frame.Text) {
|
||||
session.receive(frame.readText())
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
outQueue.close()
|
||||
writerJob.cancel()
|
||||
unregisterSession(session)
|
||||
session.close()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Per-session outbound buffer size. When a slow client falls
|
||||
* this many frames behind, we close their connection rather
|
||||
* than silently dropping further frames (which would corrupt
|
||||
* NIP-01 by missing EVENT/EOSE messages).
|
||||
*
|
||||
* Sized to hold fan-out for a connection holding several
|
||||
* thousand subscriptions when one event matches all of them
|
||||
* — the realistic upper bound for a relay client. At ~250B
|
||||
* per frame this caps per-session memory at ~2 MiB before
|
||||
* we drop the connection.
|
||||
*/
|
||||
const val SESSION_OUTGOING_BUFFER: Int = 8192
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.geode
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Tests [LocalRelayServer.stop] honours the graceful-shutdown contract:
|
||||
* 1. Active clients receive a `NOTICE` warning of imminent shutdown.
|
||||
* 2. The active session counter accurately tracks open WS sessions.
|
||||
* 3. After `stop()` returns, no sessions remain registered.
|
||||
*/
|
||||
class GracefulShutdownTest {
|
||||
private lateinit var relay: Relay
|
||||
private lateinit var server: LocalRelayServer
|
||||
private lateinit var scope: CoroutineScope
|
||||
private lateinit var client: NostrClient
|
||||
|
||||
private val httpClient = OkHttpClient.Builder().build()
|
||||
|
||||
@BeforeTest
|
||||
fun setup() {
|
||||
val placeholder = "ws://127.0.0.1:7771/".normalizeRelayUrl()
|
||||
relay = Relay(url = placeholder)
|
||||
server = LocalRelayServer(relay, host = "127.0.0.1", port = 0).start()
|
||||
scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
val builder = BasicOkHttpWebSocket.Builder { _ -> httpClient }
|
||||
client = NostrClient(builder, scope)
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
fun teardown() {
|
||||
client.disconnect()
|
||||
scope.cancel()
|
||||
// server may already be stopped by the test; calling stop()
|
||||
// again is a no-op.
|
||||
server.stop(gracePeriodMillis = 200, timeoutMillis = 500)
|
||||
relay.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun activeSessionCountTracksConnectAndDisconnect() =
|
||||
runBlocking {
|
||||
assertEquals(0, server.activeSessionCount, "no clients yet")
|
||||
|
||||
// Open a connection by subscribing — wait for EOSE so we
|
||||
// know the WebSocket handshake completed and the relay
|
||||
// session has registered.
|
||||
val gotEose = Channel<Unit>(UNLIMITED)
|
||||
val relayUrl = server.url.normalizeRelayUrl()
|
||||
client.subscribe(
|
||||
"track-1",
|
||||
mapOf(relayUrl to listOf(Filter(kinds = listOf(1)))),
|
||||
object : SubscriptionListener {
|
||||
override fun onEose(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
gotEose.trySend(Unit)
|
||||
}
|
||||
},
|
||||
)
|
||||
withTimeout(5000) { gotEose.receive() }
|
||||
|
||||
assertEquals(1, server.activeSessionCount, "one connected session")
|
||||
|
||||
client.unsubscribe("track-1")
|
||||
client.disconnect()
|
||||
|
||||
// Disconnect happens asynchronously on the relay side; allow
|
||||
// a short window for the handler's `finally` block to run.
|
||||
withTimeoutOrNull(2000) {
|
||||
while (server.activeSessionCount > 0) kotlinx.coroutines.delay(10)
|
||||
}
|
||||
assertEquals(0, server.activeSessionCount, "session must be removed after disconnect")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stopSendsShutdownNoticeToActiveClients() =
|
||||
runBlocking {
|
||||
val noticeChannel = Channel<NoticeMessage>(UNLIMITED)
|
||||
val gotEose = Channel<Unit>(UNLIMITED)
|
||||
val listener =
|
||||
object : RelayConnectionListener {
|
||||
override fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
) {
|
||||
if (msg is NoticeMessage) noticeChannel.trySend(msg)
|
||||
}
|
||||
}
|
||||
client.addConnectionListener(listener)
|
||||
|
||||
val relayUrl = server.url.normalizeRelayUrl()
|
||||
client.subscribe(
|
||||
"notice-watch",
|
||||
mapOf(relayUrl to listOf(Filter(kinds = listOf(1)))),
|
||||
object : SubscriptionListener {
|
||||
override fun onEose(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
gotEose.trySend(Unit)
|
||||
}
|
||||
},
|
||||
)
|
||||
withTimeout(5000) { gotEose.receive() }
|
||||
assertEquals(1, server.activeSessionCount)
|
||||
|
||||
// Trigger graceful shutdown.
|
||||
server.stop(gracePeriodMillis = 1_000, timeoutMillis = 2_000)
|
||||
|
||||
val notice = withTimeout(5000) { noticeChannel.receive() }
|
||||
assertNotNull(notice)
|
||||
assertTrue(
|
||||
notice.message.startsWith("closing:"),
|
||||
"expected NOTICE to start with 'closing:', got '${notice.message}'",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stopIsIdempotent() {
|
||||
// First call shuts the engine down.
|
||||
server.stop(gracePeriodMillis = 100, timeoutMillis = 500)
|
||||
// Second call must be a safe no-op (no exception).
|
||||
server.stop(gracePeriodMillis = 100, timeoutMillis = 500)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanity check on the grace window: a bare-bones ws client that
|
||||
* connects and never sends anything should *receive* the shutdown
|
||||
* NOTICE before the server fully closes the socket. Uses Ktor's
|
||||
* client-agnostic OkHttp transport directly so we can observe the
|
||||
* raw frames.
|
||||
*/
|
||||
@Test
|
||||
fun rawWsClientObservesNoticeBeforeServerCloses() =
|
||||
runBlocking {
|
||||
val httpUrl =
|
||||
server.url
|
||||
.replace("ws://", "http://")
|
||||
val request =
|
||||
okhttp3.Request
|
||||
.Builder()
|
||||
.url(httpUrl)
|
||||
.build()
|
||||
|
||||
val frames = Channel<String>(UNLIMITED)
|
||||
val socket =
|
||||
httpClient.newWebSocket(
|
||||
request,
|
||||
object : okhttp3.WebSocketListener() {
|
||||
override fun onMessage(
|
||||
ws: okhttp3.WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
frames.trySend(text)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
// Wait until the relay sees the connection.
|
||||
withTimeoutOrNull(2000) {
|
||||
while (server.activeSessionCount == 0) kotlinx.coroutines.delay(10)
|
||||
}
|
||||
assertEquals(1, server.activeSessionCount)
|
||||
|
||||
server.stop(gracePeriodMillis = 1_000, timeoutMillis = 2_000)
|
||||
|
||||
val text = withTimeout(3000) { frames.receive() }
|
||||
assertTrue(
|
||||
text.contains("\"NOTICE\"") && text.contains("closing"),
|
||||
"expected a NOTICE frame, got: $text",
|
||||
)
|
||||
} finally {
|
||||
socket.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.geode
|
||||
|
||||
import com.vitorpamplona.geode.fixtures.SyntheticEvents
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.count
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* End-to-end tests that drive a real `ws://` connection between the
|
||||
* production [NostrClient] (over OkHttp) and the [LocalRelayServer]
|
||||
* (Ktor + CIO). These prove the relay implements:
|
||||
*
|
||||
* - NIP-01 wire protocol (REQ/EVENT/EOSE) over real WebSockets
|
||||
* - NIP-11 relay info doc on HTTP GET with `Accept: application/nostr+json`
|
||||
* - NIP-42 AUTH (when [FullAuthPolicy] is enabled, REQ is rejected
|
||||
* until the client authenticates)
|
||||
* - NIP-45 COUNT
|
||||
* - NIP-50 search via the SQLite FTS index
|
||||
*
|
||||
* Tests use port 0 for autobind to avoid conflicts when multiple suites
|
||||
* run in parallel.
|
||||
*/
|
||||
class LocalRelayServerTest {
|
||||
private lateinit var relay: Relay
|
||||
private lateinit var server: LocalRelayServer
|
||||
private lateinit var scope: CoroutineScope
|
||||
private lateinit var client: NostrClient
|
||||
|
||||
private val httpClient = OkHttpClient.Builder().build()
|
||||
|
||||
@BeforeTest
|
||||
fun setup() {
|
||||
// Bind to 127.0.0.1:0 — the OS picks a free port. Note: the URL
|
||||
// must be resolvable by the Nostr URL normalizer, which only
|
||||
// accepts loopback addresses. 127.0.0.1 qualifies.
|
||||
val placeholderUrl = "ws://127.0.0.1:7771/".normalizeRelayUrl()
|
||||
relay = Relay(url = placeholderUrl)
|
||||
server = LocalRelayServer(relay, host = "127.0.0.1", port = 0).start()
|
||||
scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
val builder = BasicOkHttpWebSocket.Builder { _ -> httpClient }
|
||||
client = NostrClient(builder, scope)
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
fun teardown() {
|
||||
client.disconnect()
|
||||
scope.cancel()
|
||||
server.stop()
|
||||
relay.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nip01_realWebSocketRoundtrip() =
|
||||
runBlocking {
|
||||
val pubkey = SyntheticEvents.hexId(1)
|
||||
relay.preload(
|
||||
SyntheticEvents.fakeEvent(
|
||||
idSeed = 42,
|
||||
kind = MetadataEvent.KIND,
|
||||
pubKey = pubkey,
|
||||
content = """{"name":"vitor"}""",
|
||||
),
|
||||
)
|
||||
|
||||
val event =
|
||||
client.fetchFirst(
|
||||
relay = server.url,
|
||||
filter = Filter(kinds = listOf(MetadataEvent.KIND), authors = listOf(pubkey)),
|
||||
)
|
||||
|
||||
assertNotNull(event)
|
||||
assertEquals(MetadataEvent.KIND, event.kind)
|
||||
assertEquals(pubkey, event.pubKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nip11_returnsInfoDocOnHttpGetWithNostrAcceptHeader() {
|
||||
val httpUrl = server.url.replace("ws://", "http://")
|
||||
val response =
|
||||
httpClient
|
||||
.newCall(
|
||||
Request
|
||||
.Builder()
|
||||
.url(httpUrl)
|
||||
.header("Accept", "application/nostr+json")
|
||||
.build(),
|
||||
).execute()
|
||||
|
||||
response.use {
|
||||
assertEquals(200, it.code)
|
||||
val body = it.body.string()
|
||||
val info = Nip11RelayInformation.fromJson(body)
|
||||
assertEquals("geode", info.name)
|
||||
assertTrue(info.supported_nips!!.contains("11"), "NIP-11 must be advertised")
|
||||
assertTrue(info.supported_nips!!.contains("1"), "NIP-01 must be advertised")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nip45_countOverRealWebSocket() =
|
||||
runBlocking {
|
||||
// Each event needs a unique pubkey so kind-0 (replaceable)
|
||||
// doesn't collapse them all to one row.
|
||||
relay.preload(
|
||||
(1..7).map {
|
||||
SyntheticEvents.fakeEvent(
|
||||
idSeed = it,
|
||||
kind = MetadataEvent.KIND,
|
||||
pubKey = SyntheticEvents.hexId(1000 + it),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
val result =
|
||||
client.count(
|
||||
relay = server.url.normalizeRelayUrl(),
|
||||
filter = Filter(kinds = listOf(MetadataEvent.KIND)),
|
||||
)
|
||||
|
||||
assertEquals(7, result?.count)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nip50_searchHitsFtsIndex() =
|
||||
runBlocking {
|
||||
val signer =
|
||||
com.vitorpamplona.quartz.nip01Core.signers
|
||||
.NostrSignerSync(KeyPair())
|
||||
relay.preload(
|
||||
signer.sign(TextNoteEvent.build("How do I write a kotlin coroutine?")),
|
||||
signer.sign(TextNoteEvent.build("My favorite recipe for pancakes")),
|
||||
signer.sign(TextNoteEvent.build("Another note about kotlin")),
|
||||
)
|
||||
|
||||
val matches =
|
||||
client
|
||||
.count(
|
||||
relay = server.url.normalizeRelayUrl(),
|
||||
filter = Filter(search = "kotlin"),
|
||||
)?.count
|
||||
|
||||
// Two of the three notes mention "kotlin".
|
||||
assertEquals(2, matches)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nip42_authRejectsReqUntilClientAuthenticates() =
|
||||
runBlocking {
|
||||
// Spin up a second relay that requires AUTH. Bind on a
|
||||
// separate port so it doesn't collide with [setup]'s server.
|
||||
val authUrl = "ws://127.0.0.1:7772/".normalizeRelayUrl()
|
||||
val authRelay = Relay(authUrl, policyBuilder = { FullAuthPolicy(authUrl) })
|
||||
val authServer = LocalRelayServer(authRelay, host = "127.0.0.1", port = 0).start()
|
||||
try {
|
||||
val signer =
|
||||
com.vitorpamplona.quartz.nip01Core.signers
|
||||
.NostrSignerSync(KeyPair())
|
||||
authRelay.preload(signer.sign(TextNoteEvent.build("hello")))
|
||||
|
||||
// Without AUTH, publishAndConfirm should fail (relay
|
||||
// returns OK false / "auth-required").
|
||||
val noAuthEvent = signer.sign(TextNoteEvent.build("denied"))
|
||||
val ok =
|
||||
client.publishAndConfirm(
|
||||
event = noAuthEvent,
|
||||
relayList = setOf(authServer.url.normalizeRelayUrl()),
|
||||
)
|
||||
assertEquals(false, ok, "FullAuthPolicy must reject EVENT before AUTH")
|
||||
} finally {
|
||||
authServer.stop()
|
||||
authRelay.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Successful NIP-42 AUTH must unlock REQ/EVENT/COUNT. We can't bind
|
||||
* the server on a known port AND configure the policy with that URL
|
||||
* unless we discover the port first — so reserve a free TCP port
|
||||
* before constructing the relay, then bind to that exact port so
|
||||
* the policy's `relay` field matches what `RelayAuthenticator`
|
||||
* sends in the AUTH event's `relay` tag.
|
||||
*/
|
||||
@Test
|
||||
fun nip42_successfulAuthUnlocksPublishing() =
|
||||
runBlocking {
|
||||
val freePort =
|
||||
java.net.ServerSocket(0).use { it.localPort }
|
||||
val authUrl = "ws://127.0.0.1:$freePort/".normalizeRelayUrl()
|
||||
val authRelay = Relay(authUrl, policyBuilder = { FullAuthPolicy(authUrl) })
|
||||
val authServer = LocalRelayServer(authRelay, host = "127.0.0.1", port = freePort).start()
|
||||
try {
|
||||
val signer =
|
||||
com.vitorpamplona.quartz.nip01Core.signers
|
||||
.NostrSignerSync(KeyPair())
|
||||
|
||||
// RelayAuthenticator hooks the client: when the relay
|
||||
// sends the AUTH challenge, it auto-signs and replies.
|
||||
val authenticator =
|
||||
com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator(
|
||||
client = client,
|
||||
scope = scope,
|
||||
) { template ->
|
||||
listOf(signer.sign(template))
|
||||
}
|
||||
try {
|
||||
// Trigger the AUTH dance: subscribe to anything,
|
||||
// which the relay rejects with `auth-required:` →
|
||||
// [RelayAuthenticator] catches the challenge, signs
|
||||
// and sends the AUTH event, the relay's OK true
|
||||
// makes the client re-sync filters, and the second
|
||||
// REQ succeeds and EOSEs.
|
||||
val gotEose =
|
||||
kotlinx.coroutines.channels.Channel<Unit>(
|
||||
kotlinx.coroutines.channels.Channel.UNLIMITED,
|
||||
)
|
||||
client.subscribe(
|
||||
"auth-warmup",
|
||||
mapOf(authUrl to listOf(Filter(kinds = listOf(1)))),
|
||||
object : com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener {
|
||||
override fun onEose(
|
||||
relay: com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
gotEose.trySend(Unit)
|
||||
}
|
||||
},
|
||||
)
|
||||
kotlinx.coroutines.withTimeout(5000) { gotEose.receive() }
|
||||
client.unsubscribe("auth-warmup")
|
||||
|
||||
val event = signer.sign(TextNoteEvent.build("after-auth"))
|
||||
val ok =
|
||||
client.publishAndConfirm(
|
||||
event = event,
|
||||
relayList = setOf(authUrl),
|
||||
)
|
||||
assertEquals(true, ok, "after AUTH succeeds, publishing must work")
|
||||
} finally {
|
||||
authenticator.destroy()
|
||||
}
|
||||
} finally {
|
||||
authServer.stop()
|
||||
authRelay.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression for the OkMessage wire format (the Jackson serializer
|
||||
* was writing `success` as a JSON string). `publishAndConfirm`
|
||||
* relies on parsing the OK response — if the relay's serialization
|
||||
* regresses, this test catches it.
|
||||
*/
|
||||
@Test
|
||||
fun nip01_okMessageRoundtripWithEmptyAndNonEmptyMessage() =
|
||||
runBlocking {
|
||||
val signer =
|
||||
com.vitorpamplona.quartz.nip01Core.signers
|
||||
.NostrSignerSync(KeyPair())
|
||||
val event = signer.sign(TextNoteEvent.build("ok-roundtrip"))
|
||||
val ok =
|
||||
client.publishAndConfirm(
|
||||
event = event,
|
||||
relayList = setOf(server.url.normalizeRelayUrl()),
|
||||
)
|
||||
assertEquals(true, ok, "successful insert must round-trip OK true on the wire")
|
||||
|
||||
// Duplicate insert returns OK false; this also exercises the
|
||||
// "non-empty message" branch of the serializer.
|
||||
val ok2 =
|
||||
client.publishAndConfirm(
|
||||
event = event,
|
||||
relayList = setOf(server.url.normalizeRelayUrl()),
|
||||
)
|
||||
assertEquals(false, ok2, "duplicate insert must round-trip OK false")
|
||||
}
|
||||
|
||||
/**
|
||||
* A custom config's `[info]` section flows through to the NIP-11
|
||||
* doc returned on the HTTP endpoint.
|
||||
*/
|
||||
@Test
|
||||
fun nip11_servesConfigDrivenInfoDoc() {
|
||||
val freePort =
|
||||
java.net.ServerSocket(0).use { it.localPort }
|
||||
val customUrl = "ws://127.0.0.1:$freePort/".normalizeRelayUrl()
|
||||
val customInfo =
|
||||
RelayInfo(
|
||||
Nip11RelayInformation(
|
||||
name = "custom-relay-name",
|
||||
contact = "ops@example.com",
|
||||
description = "Custom from config",
|
||||
supported_nips = listOf("1", "11", "42"),
|
||||
),
|
||||
)
|
||||
val customRelay = Relay(customUrl, info = customInfo)
|
||||
val customServer =
|
||||
LocalRelayServer(customRelay, host = "127.0.0.1", port = freePort).start()
|
||||
try {
|
||||
val httpUrl = customServer.url.replace("ws://", "http://")
|
||||
val response =
|
||||
httpClient
|
||||
.newCall(
|
||||
Request
|
||||
.Builder()
|
||||
.url(httpUrl)
|
||||
.header("Accept", "application/nostr+json")
|
||||
.build(),
|
||||
).execute()
|
||||
response.use {
|
||||
assertEquals(200, it.code)
|
||||
val info = Nip11RelayInformation.fromJson(it.body.string())
|
||||
assertEquals("custom-relay-name", info.name)
|
||||
assertEquals("ops@example.com", info.contact)
|
||||
assertEquals(listOf("1", "11", "42"), info.supported_nips)
|
||||
}
|
||||
} finally {
|
||||
customServer.stop()
|
||||
customRelay.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nip01_closeStopsLiveSubscription() =
|
||||
runBlocking {
|
||||
val ch =
|
||||
kotlinx.coroutines.channels.Channel<com.vitorpamplona.quartz.nip01Core.core.Event>(
|
||||
kotlinx.coroutines.channels.Channel.UNLIMITED,
|
||||
)
|
||||
val gotEose =
|
||||
kotlinx.coroutines.channels.Channel<Unit>(
|
||||
kotlinx.coroutines.channels.Channel.UNLIMITED,
|
||||
)
|
||||
client.subscribe(
|
||||
"close-test",
|
||||
mapOf(server.url.normalizeRelayUrl() to listOf(Filter(kinds = listOf(1)))),
|
||||
object : com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener {
|
||||
override fun onEvent(
|
||||
event: com.vitorpamplona.quartz.nip01Core.core.Event,
|
||||
isLive: Boolean,
|
||||
relay: com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
ch.trySend(event)
|
||||
}
|
||||
|
||||
override fun onEose(
|
||||
relay: com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
gotEose.trySend(Unit)
|
||||
}
|
||||
},
|
||||
)
|
||||
kotlinx.coroutines.withTimeout(5000) { gotEose.receive() }
|
||||
|
||||
// CLOSE the subscription, then publish a matching event over
|
||||
// the wire. The unsubscribed client must NOT receive it.
|
||||
client.unsubscribe("close-test")
|
||||
|
||||
val signer =
|
||||
com.vitorpamplona.quartz.nip01Core.signers
|
||||
.NostrSignerSync(KeyPair())
|
||||
val late = signer.sign(TextNoteEvent.build("post-close"))
|
||||
relay.publish(late)
|
||||
|
||||
val seen = kotlinx.coroutines.withTimeoutOrNull(500) { ch.receive() }
|
||||
assertEquals(null, seen, "events arriving after CLOSE must not reach the unsubscribed client")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.geode
|
||||
|
||||
import com.vitorpamplona.geode.fixtures.SyntheticEvents
|
||||
import com.vitorpamplona.geode.testing.RelayClientTest
|
||||
import com.vitorpamplona.geode.testing.collectUntilEose
|
||||
import com.vitorpamplona.geode.testing.collectUntilEoseMulti
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Compatibility suite that drives the in-process relay through the same
|
||||
* `NostrClient` + WebSocket abstraction production code uses. These tests
|
||||
* are how we validate that:
|
||||
*
|
||||
* 1. The relay implements NIP-01 correctly (REQ/EVENT/EOSE/CLOSE/COUNT,
|
||||
* replaceable + parameterized-replaceable, filter matching, multi-relay
|
||||
* pools).
|
||||
* 2. The bridge between [com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket]
|
||||
* and [com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer] preserves
|
||||
* wire ordering and lifecycle.
|
||||
*
|
||||
* The same suite should be runnable, conceptually, against any
|
||||
* spec-compliant relay (nostr-rs-relay, strfry, khatru, …) — only the
|
||||
* `socketBuilder` and the relay URL would change.
|
||||
*/
|
||||
class Nip01ComplianceTest : RelayClientTest() {
|
||||
private suspend fun preload(vararg events: Event) {
|
||||
defaultRelay.preload(*events)
|
||||
}
|
||||
|
||||
private fun fakeEvent(
|
||||
idSeed: Int,
|
||||
kind: Int = 1,
|
||||
pubKey: String = SyntheticEvents.hexId(0),
|
||||
createdAt: Long = idSeed.toLong(),
|
||||
tags: Array<Array<String>> = emptyArray(),
|
||||
content: String = "",
|
||||
) = SyntheticEvents.fakeEvent(idSeed, kind, pubKey, createdAt, content, tags)
|
||||
|
||||
// -- Subscriptions -------------------------------------------------------
|
||||
|
||||
/** REQ returns events matching kinds, then EOSE, in order. */
|
||||
@Test
|
||||
fun reqByKindReturnsMatchesThenEose() =
|
||||
runBlocking {
|
||||
preload(
|
||||
fakeEvent(1, kind = 1),
|
||||
fakeEvent(2, kind = 4),
|
||||
fakeEvent(3, kind = 1),
|
||||
)
|
||||
|
||||
val (events, eose) = client.collectUntilEose(defaultRelayUrl, Filter(kinds = listOf(1)))
|
||||
|
||||
assertEquals(2, events.size)
|
||||
assertEquals(setOf(SyntheticEvents.hexId(1), SyntheticEvents.hexId(3)), events.map { it.id }.toSet())
|
||||
assertTrue(eose, "EOSE should fire after stored events")
|
||||
}
|
||||
|
||||
/** REQ honours `limit` — newest first, capped to limit. */
|
||||
@Test
|
||||
fun reqRespectsLimitAndOrdersNewestFirst() =
|
||||
runBlocking {
|
||||
preload(
|
||||
fakeEvent(1, kind = 1, createdAt = 100),
|
||||
fakeEvent(2, kind = 1, createdAt = 200),
|
||||
fakeEvent(3, kind = 1, createdAt = 300),
|
||||
fakeEvent(4, kind = 1, createdAt = 400),
|
||||
)
|
||||
|
||||
val (events, _) = client.collectUntilEose(defaultRelayUrl, Filter(kinds = listOf(1), limit = 2))
|
||||
|
||||
assertEquals(2, events.size)
|
||||
assertEquals(400L, events[0].createdAt)
|
||||
assertEquals(300L, events[1].createdAt)
|
||||
}
|
||||
|
||||
/** REQ with `authors` filters by pubkey. */
|
||||
@Test
|
||||
fun reqFiltersByAuthors() =
|
||||
runBlocking {
|
||||
val alice = SyntheticEvents.hexId(101)
|
||||
val bob = SyntheticEvents.hexId(102)
|
||||
preload(
|
||||
fakeEvent(1, kind = 1, pubKey = alice),
|
||||
fakeEvent(2, kind = 1, pubKey = bob),
|
||||
fakeEvent(3, kind = 1, pubKey = alice),
|
||||
)
|
||||
|
||||
val (events, _) = client.collectUntilEose(defaultRelayUrl, Filter(authors = listOf(alice)))
|
||||
|
||||
assertEquals(2, events.size)
|
||||
assertTrue(events.all { it.pubKey == alice })
|
||||
}
|
||||
|
||||
/** REQ with `ids` returns only the requested events. */
|
||||
@Test
|
||||
fun reqFiltersByIds() =
|
||||
runBlocking {
|
||||
preload(fakeEvent(1), fakeEvent(2), fakeEvent(3))
|
||||
|
||||
val (events, _) = client.collectUntilEose(defaultRelayUrl, Filter(ids = listOf(SyntheticEvents.hexId(2))))
|
||||
|
||||
assertEquals(1, events.size)
|
||||
assertEquals(SyntheticEvents.hexId(2), events[0].id)
|
||||
}
|
||||
|
||||
/** REQ with `since`/`until` filters on createdAt. */
|
||||
@Test
|
||||
fun reqFiltersBySinceAndUntil() =
|
||||
runBlocking {
|
||||
preload(
|
||||
fakeEvent(1, createdAt = 100),
|
||||
fakeEvent(2, createdAt = 200),
|
||||
fakeEvent(3, createdAt = 300),
|
||||
fakeEvent(4, createdAt = 400),
|
||||
)
|
||||
|
||||
val (events, _) = client.collectUntilEose(defaultRelayUrl, Filter(since = 150L, until = 350L))
|
||||
|
||||
assertEquals(setOf(200L, 300L), events.map { it.createdAt }.toSet())
|
||||
}
|
||||
|
||||
/** REQ with single-letter `#e` tag filter matches events whose tag values intersect. */
|
||||
@Test
|
||||
fun reqFiltersByETag() =
|
||||
runBlocking {
|
||||
val target = SyntheticEvents.hexId(999)
|
||||
preload(
|
||||
fakeEvent(1, tags = arrayOf(arrayOf("e", target))),
|
||||
fakeEvent(2, tags = arrayOf(arrayOf("e", SyntheticEvents.hexId(7)))),
|
||||
fakeEvent(3, tags = arrayOf(arrayOf("e", target), arrayOf("p", SyntheticEvents.hexId(8)))),
|
||||
)
|
||||
|
||||
val (events, _) = client.collectUntilEose(defaultRelayUrl, Filter(tags = mapOf("e" to listOf(target))))
|
||||
|
||||
assertEquals(setOf(SyntheticEvents.hexId(1), SyntheticEvents.hexId(3)), events.map { it.id }.toSet())
|
||||
}
|
||||
|
||||
/** REQ with `#p` tag filter — same single-letter machinery as `#e`. */
|
||||
@Test
|
||||
fun reqFiltersByPTag() =
|
||||
runBlocking {
|
||||
val targetPubkey = SyntheticEvents.hexId(2222)
|
||||
preload(
|
||||
fakeEvent(1, tags = arrayOf(arrayOf("p", targetPubkey))),
|
||||
fakeEvent(2, tags = arrayOf(arrayOf("p", SyntheticEvents.hexId(3333)))),
|
||||
fakeEvent(3, tags = arrayOf(arrayOf("p", targetPubkey), arrayOf("e", SyntheticEvents.hexId(99)))),
|
||||
)
|
||||
|
||||
val (events, _) = client.collectUntilEose(defaultRelayUrl, Filter(tags = mapOf("p" to listOf(targetPubkey))))
|
||||
|
||||
assertEquals(setOf(SyntheticEvents.hexId(1), SyntheticEvents.hexId(3)), events.map { it.id }.toSet())
|
||||
}
|
||||
|
||||
/** REQ with a generic single-letter tag (e.g. `#t`) for hashtags. */
|
||||
@Test
|
||||
fun reqFiltersByGenericSingleLetterTag() =
|
||||
runBlocking {
|
||||
preload(
|
||||
fakeEvent(1, tags = arrayOf(arrayOf("t", "nostr"))),
|
||||
fakeEvent(2, tags = arrayOf(arrayOf("t", "bitcoin"))),
|
||||
fakeEvent(3, tags = arrayOf(arrayOf("t", "nostr"), arrayOf("t", "kotlin"))),
|
||||
)
|
||||
|
||||
val (events, _) = client.collectUntilEose(defaultRelayUrl, Filter(tags = mapOf("t" to listOf("nostr"))))
|
||||
|
||||
assertEquals(setOf(SyntheticEvents.hexId(1), SyntheticEvents.hexId(3)), events.map { it.id }.toSet())
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-value tag filter — matches events with tag value in the OR
|
||||
* set. NIP-01 says values inside a single filter list are OR'd.
|
||||
*/
|
||||
@Test
|
||||
fun reqTagFilterValuesAreOred() =
|
||||
runBlocking {
|
||||
val a = SyntheticEvents.hexId(101)
|
||||
val b = SyntheticEvents.hexId(102)
|
||||
preload(
|
||||
fakeEvent(1, tags = arrayOf(arrayOf("e", a))),
|
||||
fakeEvent(2, tags = arrayOf(arrayOf("e", b))),
|
||||
fakeEvent(3, tags = arrayOf(arrayOf("e", SyntheticEvents.hexId(999)))),
|
||||
)
|
||||
|
||||
val (events, _) = client.collectUntilEose(defaultRelayUrl, Filter(tags = mapOf("e" to listOf(a, b))))
|
||||
|
||||
assertEquals(setOf(SyntheticEvents.hexId(1), SyntheticEvents.hexId(2)), events.map { it.id }.toSet())
|
||||
}
|
||||
|
||||
// -- Multi-filter REQ ----------------------------------------------------
|
||||
|
||||
/**
|
||||
* NIP-01: filters within a single REQ are OR'd. The relay returns
|
||||
* events matching ANY of the filters, deduplicated.
|
||||
*/
|
||||
@Test
|
||||
fun reqMultipleFiltersAreOred() =
|
||||
runBlocking {
|
||||
preload(
|
||||
fakeEvent(1, kind = 1),
|
||||
fakeEvent(2, kind = 4),
|
||||
fakeEvent(3, kind = 7),
|
||||
)
|
||||
|
||||
val (events, _) =
|
||||
client.collectUntilEoseMulti(
|
||||
defaultRelayUrl,
|
||||
listOf(
|
||||
Filter(kinds = listOf(1)),
|
||||
Filter(kinds = listOf(7)),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
setOf(SyntheticEvents.hexId(1), SyntheticEvents.hexId(3)),
|
||||
events.map { it.id }.toSet(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Two subscriptions on the same connection are independent — each
|
||||
* gets its own EOSE and its own event stream.
|
||||
*/
|
||||
@Test
|
||||
fun multipleSubscriptionsOnOneConnectionAreIndependent() =
|
||||
runBlocking {
|
||||
preload(
|
||||
fakeEvent(1, kind = 1),
|
||||
fakeEvent(2, kind = 4),
|
||||
)
|
||||
|
||||
val ch1 = Channel<Event>(UNLIMITED)
|
||||
val ch2 = Channel<Event>(UNLIMITED)
|
||||
val eose1 = Channel<Unit>(UNLIMITED)
|
||||
val eose2 = Channel<Unit>(UNLIMITED)
|
||||
|
||||
client.subscribe(
|
||||
"sub-A",
|
||||
mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(1)))),
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
ch1.trySend(event)
|
||||
}
|
||||
|
||||
override fun onEose(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
eose1.trySend(Unit)
|
||||
}
|
||||
},
|
||||
)
|
||||
client.subscribe(
|
||||
"sub-B",
|
||||
mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(4)))),
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
ch2.trySend(event)
|
||||
}
|
||||
|
||||
override fun onEose(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
eose2.trySend(Unit)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
withTimeout(5000) {
|
||||
eose1.receive()
|
||||
eose2.receive()
|
||||
}
|
||||
|
||||
// sub-A only saw kind=1, sub-B only saw kind=4.
|
||||
val a = withTimeoutOrNull(100) { ch1.receive() }
|
||||
val b = withTimeoutOrNull(100) { ch2.receive() }
|
||||
assertEquals(SyntheticEvents.hexId(1), a?.id)
|
||||
assertEquals(SyntheticEvents.hexId(2), b?.id)
|
||||
|
||||
client.unsubscribe("sub-A")
|
||||
client.unsubscribe("sub-B")
|
||||
}
|
||||
|
||||
// -- Replaceable + addressable ------------------------------------------
|
||||
|
||||
/** Kind 0 is replaceable by `(pubkey, kind)` — newer wins. */
|
||||
@Test
|
||||
fun replaceableEventsKeepNewestPerPubkey() =
|
||||
runBlocking {
|
||||
val pubkey = SyntheticEvents.hexId(50)
|
||||
preload(
|
||||
fakeEvent(1, kind = 0, pubKey = pubkey, createdAt = 100, content = "old"),
|
||||
fakeEvent(2, kind = 0, pubKey = pubkey, createdAt = 200, content = "new"),
|
||||
)
|
||||
|
||||
val (events, _) = client.collectUntilEose(defaultRelayUrl, Filter(kinds = listOf(0), authors = listOf(pubkey)))
|
||||
|
||||
assertEquals(1, events.size)
|
||||
assertEquals("new", events[0].content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Addressable events (kind 30000-39999, NIP-01 §"Kinds") are replaced
|
||||
* by `(pubkey, kind, d)`. Uses a real signed [LongTextNoteEvent] because
|
||||
* the SQLite store dispatches on the typed `AddressableEvent` subclass
|
||||
* to extract the d-tag — synthetic plain `Event`s aren't recognised.
|
||||
*/
|
||||
@Test
|
||||
fun parameterizedReplaceableEventsKeepNewestPerDTag() =
|
||||
runBlocking {
|
||||
val signer = NostrSignerSync(KeyPair())
|
||||
val v1 = signer.sign(LongTextNoteEvent.build("old", "title", dTag = "list-a", createdAt = 100))
|
||||
val v2 = signer.sign(LongTextNoteEvent.build("new", "title", dTag = "list-a", createdAt = 200))
|
||||
val v3 = signer.sign(LongTextNoteEvent.build("list-b", "title", dTag = "list-b", createdAt = 100))
|
||||
preload(v1, v2, v3)
|
||||
|
||||
val (events, _) =
|
||||
client.collectUntilEose(
|
||||
defaultRelayUrl,
|
||||
Filter(kinds = listOf(LongTextNoteEvent.KIND), authors = listOf(signer.pubKey)),
|
||||
)
|
||||
|
||||
assertEquals(2, events.size)
|
||||
assertEquals(setOf("new", "list-b"), events.map { it.content }.toSet())
|
||||
}
|
||||
|
||||
// -- Live updates --------------------------------------------------------
|
||||
|
||||
/** A subscription receives new matching events that arrive after EOSE. */
|
||||
@Test
|
||||
fun liveSubscriptionReceivesPostEoseEvents() =
|
||||
runBlocking {
|
||||
val ch = Channel<Event>(UNLIMITED)
|
||||
val gotEose = Channel<Unit>(UNLIMITED)
|
||||
client.subscribe(
|
||||
"live-1",
|
||||
mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(1)))),
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
ch.trySend(event)
|
||||
}
|
||||
|
||||
override fun onEose(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
gotEose.trySend(Unit)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
withTimeout(5000) { gotEose.receive() }
|
||||
|
||||
// Inject an event through the wire path (not preload — that bypasses
|
||||
// the live broadcast that subscriptions feed off of).
|
||||
defaultRelay.publish(fakeEvent(99, kind = 1, content = "live"))
|
||||
|
||||
val received = withTimeout(5000) { ch.receive() }
|
||||
assertEquals("live", received.content)
|
||||
client.unsubscribe("live-1")
|
||||
}
|
||||
|
||||
/** Non-matching live events are not pushed to a subscription. */
|
||||
@Test
|
||||
fun liveSubscriptionIgnoresNonMatchingEvents() =
|
||||
runBlocking {
|
||||
val ch = Channel<Event>(UNLIMITED)
|
||||
val gotEose = Channel<Unit>(UNLIMITED)
|
||||
client.subscribe(
|
||||
"live-2",
|
||||
mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(1)))),
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
ch.trySend(event)
|
||||
}
|
||||
|
||||
override fun onEose(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
gotEose.trySend(Unit)
|
||||
}
|
||||
},
|
||||
)
|
||||
withTimeout(5000) { gotEose.receive() }
|
||||
|
||||
defaultRelay.publish(fakeEvent(98, kind = 4, content = "off-topic"))
|
||||
|
||||
val seen = withTimeoutOrNull(500) { ch.receive() }
|
||||
assertNull(seen, "kind 4 should not match a kind-1 subscription")
|
||||
client.unsubscribe("live-2")
|
||||
}
|
||||
|
||||
// -- Ephemeral events (NIP-01: kinds 20000-29999) ----------------------
|
||||
|
||||
/**
|
||||
* Ephemeral events MUST be forwarded to active subscriptions whose
|
||||
* filters match, even though the relay does not persist them.
|
||||
*/
|
||||
@Test
|
||||
fun ephemeralEventForwardedToActiveSubscription() =
|
||||
runBlocking {
|
||||
val ch = Channel<Event>(UNLIMITED)
|
||||
val gotEose = Channel<Unit>(UNLIMITED)
|
||||
client.subscribe(
|
||||
"eph-1",
|
||||
mapOf(defaultRelayUrl to listOf(Filter(kinds = listOf(20_001)))),
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
ch.trySend(event)
|
||||
}
|
||||
|
||||
override fun onEose(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
gotEose.trySend(Unit)
|
||||
}
|
||||
},
|
||||
)
|
||||
withTimeout(5000) { gotEose.receive() }
|
||||
|
||||
defaultRelay.publish(fakeEvent(70, kind = 20_001, content = "ephemeral-payload"))
|
||||
|
||||
val received = withTimeout(5000) { ch.receive() }
|
||||
assertEquals("ephemeral-payload", received.content)
|
||||
assertEquals(20_001, received.kind)
|
||||
client.unsubscribe("eph-1")
|
||||
}
|
||||
|
||||
/**
|
||||
* Ephemeral events MUST NOT be persisted. A REQ issued after the
|
||||
* event was published returns nothing.
|
||||
*/
|
||||
@Test
|
||||
fun ephemeralEventIsNotStoredAndDoesNotShowOnFollowupReq() =
|
||||
runBlocking {
|
||||
// Publish ephemeral first — no live subscriber listening.
|
||||
defaultRelay.publish(fakeEvent(71, kind = 20_002, content = "vanish"))
|
||||
|
||||
// Late subscriber: should see EOSE with no events.
|
||||
val (events, eose) = client.collectUntilEose(defaultRelayUrl, Filter(kinds = listOf(20_002)))
|
||||
assertTrue(eose, "EOSE must fire for an ephemeral kind even if zero events match")
|
||||
assertEquals(0, events.size, "Ephemeral events must not be persisted")
|
||||
}
|
||||
|
||||
// -- Multi-relay --------------------------------------------------------
|
||||
|
||||
/** A single client can hold subscriptions against multiple relays simultaneously. */
|
||||
@Test
|
||||
fun multiRelayPoolReturnsContentFromEachRelay() =
|
||||
runBlocking {
|
||||
val relayA = RelayUrlNormalizer.normalize("ws://127.0.0.1:7771/")
|
||||
val relayB = RelayUrlNormalizer.normalize("ws://127.0.0.1:7772/")
|
||||
hub.getOrCreate(relayA).preload(fakeEvent(1, kind = 1, content = "from-a"))
|
||||
hub.getOrCreate(relayB).preload(fakeEvent(2, kind = 1, content = "from-b"))
|
||||
|
||||
val received = mutableMapOf<NormalizedRelayUrl, String>()
|
||||
val eosed = mutableSetOf<NormalizedRelayUrl>()
|
||||
val ch = Channel<Unit>(UNLIMITED)
|
||||
client.subscribe(
|
||||
"multi-1",
|
||||
mapOf(
|
||||
relayA to listOf(Filter(kinds = listOf(1))),
|
||||
relayB to listOf(Filter(kinds = listOf(1))),
|
||||
),
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
received[relay] = event.content
|
||||
}
|
||||
|
||||
override fun onEose(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
eosed += relay
|
||||
if (eosed.size == 2) ch.trySend(Unit)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
withTimeout(5000) { ch.receive() }
|
||||
client.unsubscribe("multi-1")
|
||||
|
||||
assertEquals("from-a", received[relayA])
|
||||
assertEquals("from-b", received[relayB])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.geode
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
/**
|
||||
* Verifies NIP-09 deletion request behavior end-to-end through
|
||||
* `NostrClient` → `RelayHub`. The relay's
|
||||
* [com.vitorpamplona.quartz.nip01Core.store.sqlite.DeletionRequestModule]
|
||||
* is responsible for honouring kind-5 events:
|
||||
*
|
||||
* 1. Existing events targeted by id are removed from the store.
|
||||
* 2. A SQL trigger blocks re-insertion of any event that matches a
|
||||
* stored kind-5 deletion (so a malicious relay can't sneak the
|
||||
* deleted event back in via another connection).
|
||||
* 3. Cross-author deletion is silently ignored: a kind-5 event from
|
||||
* pubkey X cannot delete pubkey Y's events.
|
||||
*/
|
||||
class Nip09DeletionTest {
|
||||
private lateinit var hub: RelayHub
|
||||
private lateinit var scope: CoroutineScope
|
||||
private lateinit var client: NostrClient
|
||||
private val relayUrl: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/")
|
||||
|
||||
@BeforeTest
|
||||
fun setup() {
|
||||
hub = RelayHub()
|
||||
scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
client = NostrClient(hub, scope)
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
fun teardown() {
|
||||
client.disconnect()
|
||||
scope.cancel()
|
||||
hub.close()
|
||||
}
|
||||
|
||||
private suspend fun query(filter: Filter): List<Event> {
|
||||
val ch = kotlinx.coroutines.channels.Channel<Either>(kotlinx.coroutines.channels.Channel.UNLIMITED)
|
||||
val subId = "sub-${System.nanoTime()}"
|
||||
client.subscribe(
|
||||
subId,
|
||||
mapOf(relayUrl to listOf(filter)),
|
||||
object : com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener {
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
ch.trySend(Either.Ev(event))
|
||||
}
|
||||
|
||||
override fun onEose(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
ch.trySend(Either.Eose)
|
||||
}
|
||||
},
|
||||
)
|
||||
val events = mutableListOf<Event>()
|
||||
kotlinx.coroutines.withTimeout(5000) {
|
||||
while (true) {
|
||||
when (val msg = ch.receive()) {
|
||||
is Either.Ev -> events += msg.event
|
||||
Either.Eose -> return@withTimeout
|
||||
}
|
||||
}
|
||||
}
|
||||
client.unsubscribe(subId)
|
||||
return events
|
||||
}
|
||||
|
||||
private sealed interface Either {
|
||||
data class Ev(
|
||||
val event: Event,
|
||||
) : Either
|
||||
|
||||
object Eose : Either
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deletionRemovesTargetedEventFromStore() =
|
||||
runBlocking {
|
||||
val signer = NostrSignerSync(KeyPair())
|
||||
val note = signer.sign(TextNoteEvent.build("delete me"))
|
||||
val deletion = signer.sign(DeletionEvent.build(listOf(note), createdAt = note.createdAt + 1))
|
||||
|
||||
// Publish original, confirm it's stored.
|
||||
assertEquals(true, client.publishAndConfirm(note, setOf(relayUrl)))
|
||||
assertEquals(1, query(Filter(ids = listOf(note.id))).size)
|
||||
|
||||
// Publish deletion; the store removes the targeted event.
|
||||
assertEquals(true, client.publishAndConfirm(deletion, setOf(relayUrl)))
|
||||
assertEquals(0, query(Filter(ids = listOf(note.id))).size, "event must be gone")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deletionEventItselfIsStoredAndQueryable() =
|
||||
runBlocking {
|
||||
val signer = NostrSignerSync(KeyPair())
|
||||
val note = signer.sign(TextNoteEvent.build("a"))
|
||||
val deletion = signer.sign(DeletionEvent.build(listOf(note), createdAt = note.createdAt + 1))
|
||||
|
||||
client.publishAndConfirm(note, setOf(relayUrl))
|
||||
client.publishAndConfirm(deletion, setOf(relayUrl))
|
||||
|
||||
val results = query(Filter(kinds = listOf(DeletionEvent.KIND), authors = listOf(signer.pubKey)))
|
||||
assertEquals(1, results.size)
|
||||
assertEquals(deletion.id, results[0].id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reinsertingADeletedEventIsRejected() =
|
||||
runBlocking {
|
||||
val signer = NostrSignerSync(KeyPair())
|
||||
val note = signer.sign(TextNoteEvent.build("once-upon-a-time"))
|
||||
val deletion = signer.sign(DeletionEvent.build(listOf(note), createdAt = note.createdAt + 1))
|
||||
|
||||
client.publishAndConfirm(note, setOf(relayUrl))
|
||||
client.publishAndConfirm(deletion, setOf(relayUrl))
|
||||
|
||||
// Trying to reinsert the same event must fail — relay returns OK false.
|
||||
val ok = client.publishAndConfirm(note, setOf(relayUrl))
|
||||
assertEquals(false, ok, "reinserting a deleted event must be blocked")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun crossAuthorDeletionDoesNotRemoveOtherUsersEvents() =
|
||||
runBlocking {
|
||||
val alice = NostrSignerSync(KeyPair())
|
||||
val mallory = NostrSignerSync(KeyPair())
|
||||
val aliceNote = alice.sign(TextNoteEvent.build("alice-private"))
|
||||
|
||||
client.publishAndConfirm(aliceNote, setOf(relayUrl))
|
||||
assertEquals(1, query(Filter(ids = listOf(aliceNote.id))).size)
|
||||
|
||||
// Mallory tries to delete Alice's event: relay accepts the
|
||||
// kind-5 event itself (it's just an event), but the SQL DELETE
|
||||
// is owner-scoped, so Alice's event survives.
|
||||
val malloryDelete =
|
||||
mallory.sign(DeletionEvent.build(listOf(aliceNote), createdAt = aliceNote.createdAt + 1))
|
||||
client.publishAndConfirm(malloryDelete, setOf(relayUrl))
|
||||
|
||||
assertEquals(
|
||||
1,
|
||||
query(Filter(ids = listOf(aliceNote.id))).size,
|
||||
"Mallory's deletion must NOT remove Alice's event",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.geode
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip40Expiration.expiration
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
|
||||
/**
|
||||
* NIP-40 expiration:
|
||||
* - The relay rejects EVENTs whose `expiration` tag is in the past
|
||||
* (the SQLite store's `insertEvent` raises on `event.isExpired()`).
|
||||
* - Events with a future expiration are stored and queryable until
|
||||
* the operator calls `deleteExpiredEvents()` (or sufficient time
|
||||
* passes that `isExpired()` returns true on read).
|
||||
*/
|
||||
class Nip40ExpirationTest {
|
||||
private lateinit var hub: RelayHub
|
||||
private lateinit var scope: CoroutineScope
|
||||
private lateinit var client: NostrClient
|
||||
private val relayUrl: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/")
|
||||
|
||||
@BeforeTest
|
||||
fun setup() {
|
||||
hub = RelayHub()
|
||||
scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
client = NostrClient(hub, scope)
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
fun teardown() {
|
||||
client.disconnect()
|
||||
scope.cancel()
|
||||
hub.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun expiredEventOnArrivalIsRejectedWithOkFalse() =
|
||||
runBlocking {
|
||||
val signer = NostrSignerSync(KeyPair())
|
||||
// expiration set to a past timestamp.
|
||||
val past = TimeUtils.now() - 60
|
||||
val event =
|
||||
signer.sign(
|
||||
TextNoteEvent.build("expired") {
|
||||
expiration(past)
|
||||
},
|
||||
)
|
||||
|
||||
val ok = client.publishAndConfirm(event, setOf(relayUrl))
|
||||
assertEquals(false, ok, "expired-on-arrival event must be rejected")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonExpiredEventIsStoredAndRetrievable() =
|
||||
runBlocking {
|
||||
val signer = NostrSignerSync(KeyPair())
|
||||
val future = TimeUtils.now() + 3600
|
||||
val event =
|
||||
signer.sign(
|
||||
TextNoteEvent.build("not-yet-expired") {
|
||||
expiration(future)
|
||||
},
|
||||
)
|
||||
|
||||
assertEquals(true, client.publishAndConfirm(event, setOf(relayUrl)))
|
||||
|
||||
val fetched =
|
||||
client.fetchFirst(
|
||||
relay = relayUrl,
|
||||
filter = Filter(ids = listOf(event.id)),
|
||||
)
|
||||
assertNotNull(fetched)
|
||||
assertEquals(event.id, fetched.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteExpiredEventsRemovesThemFromStore() =
|
||||
runBlocking {
|
||||
val signer = NostrSignerSync(KeyPair())
|
||||
val now = TimeUtils.now()
|
||||
|
||||
// Two events: one expires in the past, one in the future.
|
||||
// We set the past one to be in the past relative to NOW, but
|
||||
// not so far that the original `insertEvent` rejects on
|
||||
// arrival. The trick: use a createdAt slightly in the past
|
||||
// and an expiration also slightly in the past, both still
|
||||
// recent enough that the relay accepts the event but
|
||||
// `deleteExpiredEvents()` will sweep it.
|
||||
//
|
||||
// Actually `insertEvent` rejects on `isExpired()` —
|
||||
// [past] timestamps fail at insert. So instead we publish a
|
||||
// non-expired event (long-lived) and a barely-non-expired
|
||||
// one (1s out), then sleep 2s and call sweep.
|
||||
val longLived =
|
||||
signer.sign(TextNoteEvent.build("keep-me") { expiration(now + 3600) })
|
||||
val shortLived =
|
||||
signer.sign(TextNoteEvent.build("sweep-me") { expiration(now + 1) })
|
||||
|
||||
client.publishAndConfirm(longLived, setOf(relayUrl))
|
||||
client.publishAndConfirm(shortLived, setOf(relayUrl))
|
||||
|
||||
// Both currently in the store.
|
||||
assertNotNull(
|
||||
client.fetchFirst(relay = relayUrl, filter = Filter(ids = listOf(longLived.id))),
|
||||
)
|
||||
assertNotNull(
|
||||
client.fetchFirst(relay = relayUrl, filter = Filter(ids = listOf(shortLived.id))),
|
||||
)
|
||||
|
||||
// Wait until shortLived is past its expiration, then sweep.
|
||||
// SQLite's unixepoch() is integer seconds, so we need a full
|
||||
// second's gap from the (now + 1) expiration; bump to 2.5s
|
||||
// to absorb thread-scheduling jitter on busy CI runners.
|
||||
kotlinx.coroutines.delay(2500)
|
||||
hub.getOrCreate(relayUrl).store.deleteExpiredEvents()
|
||||
|
||||
// Long-lived survives.
|
||||
assertNotNull(
|
||||
client.fetchFirst(relay = relayUrl, filter = Filter(ids = listOf(longLived.id))),
|
||||
)
|
||||
// Short-lived is gone.
|
||||
assertEquals(
|
||||
null,
|
||||
client.fetchFirst(relay = relayUrl, filter = Filter(ids = listOf(shortLived.id))),
|
||||
"deleteExpiredEvents() must purge the short-lived event",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.geode
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
|
||||
/**
|
||||
* NIP-62 right-to-vanish:
|
||||
* - A kind-62 event scoped to a relay URL cascades-deletes ALL of the
|
||||
* author's earlier events on that relay.
|
||||
* - After the vanish, attempts to insert OLDER events from that author
|
||||
* are rejected (the SQL `reject_events_on_event_vanish` trigger).
|
||||
* - Newer events from the same author (createdAt > vanish.createdAt)
|
||||
* can still be published — the user is asking the relay to forget
|
||||
* their past, not to ban them.
|
||||
* - A vanish from author A does not affect author B's events.
|
||||
*/
|
||||
class Nip62VanishTest {
|
||||
private lateinit var hub: RelayHub
|
||||
private lateinit var scope: CoroutineScope
|
||||
private lateinit var client: NostrClient
|
||||
private val relayUrl: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/")
|
||||
|
||||
@BeforeTest
|
||||
fun setup() {
|
||||
hub = RelayHub()
|
||||
scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
client = NostrClient(hub, scope)
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
fun teardown() {
|
||||
client.disconnect()
|
||||
scope.cancel()
|
||||
hub.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun vanishCascadesPriorEventsFromSameAuthor() =
|
||||
runBlocking {
|
||||
val signer = NostrSignerSync(KeyPair())
|
||||
val now = TimeUtils.now()
|
||||
|
||||
val a = signer.sign(TextNoteEvent.build("first", createdAt = now - 100))
|
||||
val b = signer.sign(TextNoteEvent.build("second", createdAt = now - 50))
|
||||
|
||||
client.publishAndConfirm(a, setOf(relayUrl))
|
||||
client.publishAndConfirm(b, setOf(relayUrl))
|
||||
assertNotNull(client.fetchFirst(relay = relayUrl, filter = Filter(ids = listOf(a.id))))
|
||||
assertNotNull(client.fetchFirst(relay = relayUrl, filter = Filter(ids = listOf(b.id))))
|
||||
|
||||
val vanish =
|
||||
signer.sign(
|
||||
RequestToVanishEvent.build(
|
||||
relay = relayUrl,
|
||||
reason = "GDPR cleanup",
|
||||
createdAt = now,
|
||||
),
|
||||
)
|
||||
assertEquals(true, client.publishAndConfirm(vanish, setOf(relayUrl)))
|
||||
|
||||
assertNull(client.fetchFirst(relay = relayUrl, filter = Filter(ids = listOf(a.id))))
|
||||
assertNull(client.fetchFirst(relay = relayUrl, filter = Filter(ids = listOf(b.id))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun vanishBlocksReinsertionOfOlderEvents() =
|
||||
runBlocking {
|
||||
val signer = NostrSignerSync(KeyPair())
|
||||
val now = TimeUtils.now()
|
||||
|
||||
val vanish =
|
||||
signer.sign(
|
||||
RequestToVanishEvent.build(relay = relayUrl, createdAt = now),
|
||||
)
|
||||
client.publishAndConfirm(vanish, setOf(relayUrl))
|
||||
|
||||
// Older event from the same author must be rejected.
|
||||
val older = signer.sign(TextNoteEvent.build("comeback", createdAt = now - 100))
|
||||
val ok = client.publishAndConfirm(older, setOf(relayUrl))
|
||||
assertEquals(false, ok, "events older than the vanish must be rejected")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun vanishDoesNotAffectOtherAuthors() =
|
||||
runBlocking {
|
||||
val alice = NostrSignerSync(KeyPair())
|
||||
val bob = NostrSignerSync(KeyPair())
|
||||
val now = TimeUtils.now()
|
||||
|
||||
val aliceNote = alice.sign(TextNoteEvent.build("alice", createdAt = now - 100))
|
||||
val bobNote = bob.sign(TextNoteEvent.build("bob", createdAt = now - 100))
|
||||
client.publishAndConfirm(aliceNote, setOf(relayUrl))
|
||||
client.publishAndConfirm(bobNote, setOf(relayUrl))
|
||||
|
||||
val aliceVanish =
|
||||
alice.sign(RequestToVanishEvent.build(relay = relayUrl, createdAt = now))
|
||||
client.publishAndConfirm(aliceVanish, setOf(relayUrl))
|
||||
|
||||
assertNull(client.fetchFirst(relay = relayUrl, filter = Filter(ids = listOf(aliceNote.id))))
|
||||
val bobStillThere =
|
||||
client.fetchFirst(relay = relayUrl, filter = Filter(ids = listOf(bobNote.id)))
|
||||
assertEquals(bobNote.id, bobStillThere?.id, "bob's events must survive alice's vanish")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.geode
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip77Negentropy.NegErrMessage
|
||||
import com.vitorpamplona.quartz.nip77Negentropy.NegMsgMessage
|
||||
import com.vitorpamplona.quartz.nip77Negentropy.NegentropySession
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* End-to-end NIP-77 reconciliation through `RelayHub` + the
|
||||
* in-process WebSocket bridge.
|
||||
*
|
||||
* - The relay is preloaded with a known set of events.
|
||||
* - A [NegentropySession] is initialised with a partially-overlapping
|
||||
* set on the client side.
|
||||
* - We drive the NEG-OPEN / NEG-MSG round trips manually until
|
||||
* `processMessage` reports completion.
|
||||
* - We assert that `haveIds` (events the client has that the relay
|
||||
* doesn't) and `needIds` (events the relay has that the client
|
||||
* doesn't) cover exactly the symmetric difference.
|
||||
*
|
||||
* NEG-CLOSE is exercised separately — sending NEG-MSG after CLOSE
|
||||
* must surface a NEG-ERR from the relay.
|
||||
*/
|
||||
class Nip77NegentropyTest {
|
||||
private lateinit var hub: RelayHub
|
||||
private val relayUrl: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/")
|
||||
|
||||
@BeforeTest
|
||||
fun setup() {
|
||||
hub = RelayHub()
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
fun teardown() {
|
||||
hub.close()
|
||||
}
|
||||
|
||||
/**
|
||||
* Bare-bones WS client that captures every server message into a
|
||||
* channel and exposes a `send` that goes straight at the in-process
|
||||
* bridge. We don't need NostrClient's filter-management for these
|
||||
* tests — we drive the wire.
|
||||
*/
|
||||
private class WireClient(
|
||||
hub: RelayHub,
|
||||
url: NormalizedRelayUrl,
|
||||
) {
|
||||
val incoming: Channel<String> = Channel(UNLIMITED)
|
||||
private val ws =
|
||||
hub.build(
|
||||
url,
|
||||
object : WebSocketListener {
|
||||
override fun onOpen(
|
||||
pingMillis: Int,
|
||||
compression: Boolean,
|
||||
) {}
|
||||
|
||||
override fun onMessage(text: String) {
|
||||
incoming.trySend(text)
|
||||
}
|
||||
|
||||
override fun onClosed(
|
||||
code: Int,
|
||||
reason: String,
|
||||
) {
|
||||
incoming.close()
|
||||
}
|
||||
|
||||
override fun onFailure(
|
||||
t: Throwable,
|
||||
code: Int?,
|
||||
response: String?,
|
||||
) {
|
||||
incoming.close(t)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
init {
|
||||
ws.connect()
|
||||
}
|
||||
|
||||
fun send(json: String) {
|
||||
check(ws.send(json)) { "send returned false" }
|
||||
}
|
||||
|
||||
fun close() {
|
||||
ws.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun WireClient.nextMessage(timeoutMs: Long = 5_000): Message {
|
||||
val raw = withTimeout(timeoutMs) { incoming.receive() }
|
||||
return OptimizedJsonMapper.fromJsonToMessage(raw)
|
||||
}
|
||||
|
||||
/** Generates [count] signed text notes with monotonic createdAt. */
|
||||
private fun makeEvents(count: Int): List<Event> {
|
||||
val signer = NostrSignerSync(KeyPair())
|
||||
val now = 1_700_000_000L
|
||||
return List(count) { i ->
|
||||
signer.sign(TextNoteEvent.build("event-$i", createdAt = now + i))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun negentropyComputesSymmetricDifference() =
|
||||
runBlocking {
|
||||
// Universe of 10 events. Relay has events [0..7], client has [3..9] —
|
||||
// overlap [3..7], relay-only [0..2], client-only [8..9].
|
||||
val all = makeEvents(10)
|
||||
val relayEvents = all.subList(0, 8)
|
||||
val clientEvents = all.subList(3, 10)
|
||||
|
||||
hub.getOrCreate(relayUrl).preload(relayEvents)
|
||||
|
||||
val client = WireClient(hub, relayUrl)
|
||||
try {
|
||||
val session =
|
||||
NegentropySession(
|
||||
subId = "neg-1",
|
||||
filter = Filter(kinds = listOf(1)),
|
||||
localEvents = clientEvents,
|
||||
)
|
||||
|
||||
// Step 1: send NEG-OPEN.
|
||||
val openCmd = session.open()
|
||||
client.send(OptimizedJsonMapper.toJson(openCmd))
|
||||
|
||||
// Step 2: drive NEG-MSG round trips until reconciliation completes.
|
||||
val haveIds = mutableSetOf<String>()
|
||||
val needIds = mutableSetOf<String>()
|
||||
var safety = 32
|
||||
while (safety-- > 0) {
|
||||
val response = client.nextMessage()
|
||||
if (response is NegErrMessage) {
|
||||
kotlin.test.fail("relay sent NEG-ERR: ${response.reason}")
|
||||
}
|
||||
response as NegMsgMessage
|
||||
val result = session.processMessage(response.message)
|
||||
haveIds += result.haveIds
|
||||
needIds += result.needIds
|
||||
if (result.isComplete()) break
|
||||
client.send(OptimizedJsonMapper.toJson(result.nextCmd!!))
|
||||
}
|
||||
assertTrue(safety > 0, "reconciliation did not converge in 32 rounds")
|
||||
|
||||
// Step 3: verify the symmetric difference.
|
||||
val expectedNeed = relayEvents.subList(0, 3).map { it.id }.toSet() // [0..2]
|
||||
val expectedHave = clientEvents.subList(5, 7).map { it.id }.toSet() // [8..9]
|
||||
assertEquals(expectedNeed, needIds, "client should NEED events 0..2 from relay")
|
||||
assertEquals(expectedHave, haveIds, "client should HAVE events 8..9 to send to relay")
|
||||
} finally {
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun negCloseFreesServerStateAndReopenWorks() =
|
||||
runBlocking {
|
||||
val all = makeEvents(5)
|
||||
hub.getOrCreate(relayUrl).preload(all)
|
||||
|
||||
val client = WireClient(hub, relayUrl)
|
||||
try {
|
||||
// First session.
|
||||
val s1 = NegentropySession("neg", Filter(kinds = listOf(1)), localEvents = emptyList())
|
||||
client.send(OptimizedJsonMapper.toJson(s1.open()))
|
||||
val response = client.nextMessage() as NegMsgMessage
|
||||
val r1 = s1.processMessage(response.message)
|
||||
// Client had nothing, so it needs all 5 from the relay.
|
||||
assertEquals(5, r1.needIds.size)
|
||||
|
||||
// Close.
|
||||
client.send(OptimizedJsonMapper.toJson(s1.close()))
|
||||
|
||||
// Re-OPEN with the same subId and an empty client store —
|
||||
// server must build a new session and respond. If the
|
||||
// close didn't free state, this would either error or
|
||||
// continue the previous reconciliation.
|
||||
val s2 = NegentropySession("neg", Filter(kinds = listOf(1)), localEvents = emptyList())
|
||||
client.send(OptimizedJsonMapper.toJson(s2.open()))
|
||||
val resp2 = client.nextMessage() as NegMsgMessage
|
||||
val r2 = s2.processMessage(resp2.message)
|
||||
assertEquals(5, r2.needIds.size)
|
||||
} finally {
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun negMsgWithoutOpenReturnsNegErr() =
|
||||
runBlocking {
|
||||
val client = WireClient(hub, relayUrl)
|
||||
try {
|
||||
// Synthesise a stray NEG-MSG for a sub-id that was never opened.
|
||||
val raw = """["NEG-MSG","ghost-sub","00"]"""
|
||||
client.send(raw)
|
||||
val response = client.nextMessage()
|
||||
assertTrue(response is NegErrMessage, "expected NEG-ERR, got ${response::class.simpleName}")
|
||||
assertEquals("ghost-sub", response.subId)
|
||||
assertTrue(response.reason.contains("no negentropy session"))
|
||||
} finally {
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun negOpenWithSameSubIdReplacesPriorSession() =
|
||||
runBlocking {
|
||||
val a = makeEvents(3)
|
||||
val b = makeEvents(2)
|
||||
hub.getOrCreate(relayUrl).preload(a + b)
|
||||
|
||||
val client = WireClient(hub, relayUrl)
|
||||
try {
|
||||
// First open with localEvents = a; next we'll re-open
|
||||
// and confirm the new session sees a fresh state.
|
||||
val first = NegentropySession("dup", Filter(kinds = listOf(1)), localEvents = a)
|
||||
client.send(OptimizedJsonMapper.toJson(first.open()))
|
||||
client.nextMessage() as NegMsgMessage // discard
|
||||
|
||||
// Re-OPEN with same subId, different localEvents.
|
||||
val second = NegentropySession("dup", Filter(kinds = listOf(1)), localEvents = a + b)
|
||||
client.send(OptimizedJsonMapper.toJson(second.open()))
|
||||
val resp = client.nextMessage() as NegMsgMessage
|
||||
val r = second.processMessage(resp.message)
|
||||
// Client now has every event the relay has → nothing to need.
|
||||
assertEquals(0, r.needIds.size)
|
||||
assertEquals(0, r.haveIds.size)
|
||||
} finally {
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.geode.admin
|
||||
|
||||
import com.vitorpamplona.geode.LocalRelayServer
|
||||
import com.vitorpamplona.geode.Relay
|
||||
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
|
||||
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request
|
||||
import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.boolean
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Drives a real `LocalRelayServer` over HTTP and proves the NIP-86
|
||||
* admin RPC flow works end-to-end: NIP-98 auth, admin allow-list
|
||||
* gate, ban mutation, and the resulting policy effect on a follow-up
|
||||
* EVENT publish.
|
||||
*/
|
||||
class Nip86EndToEndTest {
|
||||
private lateinit var relay: Relay
|
||||
private lateinit var server: LocalRelayServer
|
||||
private lateinit var scope: CoroutineScope
|
||||
private lateinit var nostrClient: NostrClient
|
||||
|
||||
private val httpClient = OkHttpClient.Builder().build()
|
||||
|
||||
private val admin = NostrSignerSync(KeyPair())
|
||||
private val outsider = NostrSignerSync(KeyPair())
|
||||
private val targetUser = NostrSignerSync(KeyPair())
|
||||
|
||||
@BeforeTest
|
||||
fun setup() {
|
||||
val placeholder = "ws://127.0.0.1:7771/".normalizeRelayUrl()
|
||||
relay = Relay(url = placeholder)
|
||||
server =
|
||||
LocalRelayServer(
|
||||
relay = relay,
|
||||
host = "127.0.0.1",
|
||||
port = 0,
|
||||
adminPubkeys = setOf(admin.pubKey),
|
||||
).start()
|
||||
scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
val builder = BasicOkHttpWebSocket.Builder { _ -> httpClient }
|
||||
nostrClient = NostrClient(builder, scope)
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
fun teardown() {
|
||||
nostrClient.disconnect()
|
||||
scope.cancel()
|
||||
server.stop(gracePeriodMillis = 200, timeoutMillis = 500)
|
||||
relay.close()
|
||||
}
|
||||
|
||||
private val httpUrl get() = server.url.replace("ws://", "http://")
|
||||
|
||||
/** Sends a NIP-86 RPC request signed by [signer] and returns the raw HTTP response. */
|
||||
private fun rpc(
|
||||
request: Nip86Request,
|
||||
signer: NostrSignerSync,
|
||||
): okhttp3.Response {
|
||||
val body = JsonMapper.toJson(request).encodeToByteArray()
|
||||
val authTemplate =
|
||||
HTTPAuthorizationEvent.build(url = httpUrl, method = "POST", file = body)
|
||||
val authToken = signer.sign(authTemplate).toAuthToken()
|
||||
return httpClient
|
||||
.newCall(
|
||||
Request
|
||||
.Builder()
|
||||
.url(httpUrl)
|
||||
.post(body.toRequestBody("application/nostr+json+rpc".toMediaType()))
|
||||
.header("Authorization", authToken)
|
||||
.build(),
|
||||
).execute()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun supportedMethodsListsTheServersMethods() {
|
||||
rpc(Nip86Request.supportedMethods(), admin).use {
|
||||
assertEquals(200, it.code)
|
||||
val json = JsonMapper.fromJson<com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Response>(it.body.string())
|
||||
val arr = json.result as JsonArray
|
||||
val names = arr.map { e -> e.jsonPrimitive.content }
|
||||
assertTrue(names.contains("supportedmethods"))
|
||||
assertTrue(names.contains("banpubkey"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun foreignSignerReturns403() {
|
||||
rpc(Nip86Request.supportedMethods(), outsider).use {
|
||||
assertEquals(403, it.code)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingAuthHeaderReturns401() {
|
||||
val body = JsonMapper.toJson(Nip86Request.supportedMethods()).encodeToByteArray()
|
||||
httpClient
|
||||
.newCall(
|
||||
Request
|
||||
.Builder()
|
||||
.url(httpUrl)
|
||||
.post(body.toRequestBody("application/nostr+json+rpc".toMediaType()))
|
||||
.build(),
|
||||
).execute()
|
||||
.use {
|
||||
assertEquals(401, it.code)
|
||||
assertTrue(it.headers["WWW-Authenticate"]?.startsWith("Nostr") == true)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun banPubkeyBlocksSubsequentEventsFromThatAuthor() =
|
||||
runBlocking {
|
||||
val relayUrl = server.url.normalizeRelayUrl()
|
||||
|
||||
// Baseline: targetUser can publish.
|
||||
val before = nostrClient.publishAndConfirm(targetUser.sign(TextNoteEvent.build("first")), setOf(relayUrl))
|
||||
assertEquals(true, before)
|
||||
|
||||
// Admin bans them.
|
||||
rpc(Nip86Request.banPubkey(targetUser.pubKey, "spam"), admin).use {
|
||||
assertEquals(200, it.code)
|
||||
val resp = JsonMapper.fromJson<com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Response>(it.body.string())
|
||||
assertEquals(true, (resp.result as JsonPrimitive).boolean)
|
||||
}
|
||||
|
||||
// Subsequent EVENT from the banned author is rejected.
|
||||
val after = nostrClient.publishAndConfirm(targetUser.sign(TextNoteEvent.build("second")), setOf(relayUrl))
|
||||
assertEquals(false, after, "BanListPolicy must reject events from banned pubkeys")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun changeRelayNameFlowsToNip11Endpoint() {
|
||||
rpc(Nip86Request.changeRelayName("renamed-by-admin"), admin).use {
|
||||
assertEquals(200, it.code)
|
||||
}
|
||||
|
||||
// Read the NIP-11 endpoint and confirm the new name is live.
|
||||
val response =
|
||||
httpClient
|
||||
.newCall(
|
||||
Request
|
||||
.Builder()
|
||||
.url(httpUrl)
|
||||
.header("Accept", "application/nostr+json")
|
||||
.build(),
|
||||
).execute()
|
||||
response.use {
|
||||
val info = Nip11RelayInformation.fromJson(it.body.string())
|
||||
assertEquals("renamed-by-admin", info.name)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun adminEndpointDisabledWhenNoPubkeysConfigured() =
|
||||
runBlocking {
|
||||
// Spin up a *separate* server with no admin pubkeys.
|
||||
val placeholder = "ws://127.0.0.1:7771/".normalizeRelayUrl()
|
||||
val openRelay = Relay(url = placeholder)
|
||||
val openServer =
|
||||
LocalRelayServer(openRelay, host = "127.0.0.1", port = 0).start()
|
||||
try {
|
||||
val openHttpUrl = openServer.url.replace("ws://", "http://")
|
||||
val body =
|
||||
JsonMapper.toJson(Nip86Request.supportedMethods()).encodeToByteArray()
|
||||
val authToken =
|
||||
admin
|
||||
.sign(
|
||||
HTTPAuthorizationEvent.build(url = openHttpUrl, method = "POST", file = body),
|
||||
).toAuthToken()
|
||||
httpClient
|
||||
.newCall(
|
||||
Request
|
||||
.Builder()
|
||||
.url(openHttpUrl)
|
||||
.post(body.toRequestBody("application/nostr+json+rpc".toMediaType()))
|
||||
.header("Authorization", authToken)
|
||||
.build(),
|
||||
).execute()
|
||||
.use {
|
||||
assertEquals(403, it.code)
|
||||
}
|
||||
} finally {
|
||||
openServer.stop(gracePeriodMillis = 100, timeoutMillis = 500)
|
||||
openRelay.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.geode.config
|
||||
|
||||
import java.io.File
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class RelayConfigTest {
|
||||
@Test
|
||||
fun emptyTomlYieldsAllDefaults() {
|
||||
val c = RelayConfig.fromToml("")
|
||||
assertEquals("0.0.0.0", c.network.host)
|
||||
assertEquals(7447, c.network.port)
|
||||
assertEquals("/", c.network.path)
|
||||
assertEquals(true, c.database.in_memory)
|
||||
assertEquals(false, c.options.require_auth)
|
||||
// Verify is on by default — operators have to opt out explicitly.
|
||||
assertEquals(true, c.options.verify_signatures)
|
||||
assertTrue(c.authorization.pubkey_whitelist.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun verifySignaturesCanBeExplicitlyDisabled() {
|
||||
val c = RelayConfig.fromToml("[options]\nverify_signatures = false")
|
||||
assertEquals(false, c.options.verify_signatures)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesAllSectionsTogether() {
|
||||
val toml =
|
||||
"""
|
||||
[info]
|
||||
relay_url = "wss://relay.example.com/"
|
||||
name = "Example"
|
||||
contact = "ops@example.com"
|
||||
supported_nips = [1, 9, 11, 42]
|
||||
|
||||
[network]
|
||||
host = "127.0.0.1"
|
||||
port = 9988
|
||||
path = "/relay"
|
||||
|
||||
[database]
|
||||
in_memory = false
|
||||
file = "/var/lib/quartz-relay/events.db"
|
||||
|
||||
[options]
|
||||
verify_signatures = true
|
||||
require_auth = true
|
||||
reject_future_seconds = 1800
|
||||
|
||||
[limits]
|
||||
max_ws_frame_bytes = 1048576
|
||||
|
||||
[authorization]
|
||||
pubkey_blacklist = ["aaaa", "bbbb"]
|
||||
kind_blacklist = [4, 1059]
|
||||
""".trimIndent()
|
||||
|
||||
val c = RelayConfig.fromToml(toml)
|
||||
|
||||
assertEquals("wss://relay.example.com/", c.info.relay_url)
|
||||
assertEquals("Example", c.info.name)
|
||||
assertEquals(listOf(1, 9, 11, 42), c.info.supported_nips)
|
||||
|
||||
assertEquals("127.0.0.1", c.network.host)
|
||||
assertEquals(9988, c.network.port)
|
||||
assertEquals("/relay", c.network.path)
|
||||
|
||||
assertEquals(false, c.database.in_memory)
|
||||
assertEquals("/var/lib/quartz-relay/events.db", c.database.file)
|
||||
|
||||
assertEquals(true, c.options.verify_signatures)
|
||||
assertEquals(true, c.options.require_auth)
|
||||
assertEquals(1800, c.options.reject_future_seconds)
|
||||
|
||||
assertEquals(1_048_576, c.limits.max_ws_frame_bytes)
|
||||
|
||||
assertEquals(listOf("aaaa", "bbbb"), c.authorization.pubkey_blacklist)
|
||||
assertEquals(listOf(4, 1059), c.authorization.kind_blacklist)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun supportedNipsRenderedAsStringsInNip11Doc() {
|
||||
val c =
|
||||
RelayConfig.fromToml(
|
||||
"""
|
||||
[info]
|
||||
supported_nips = [1, 11, 42]
|
||||
""".trimIndent(),
|
||||
)
|
||||
val info =
|
||||
c.resolveInfo(
|
||||
"ws://127.0.0.1:7447/".let {
|
||||
com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
.normalize(it)
|
||||
},
|
||||
)
|
||||
assertEquals(listOf("1", "11", "42"), info.document.supported_nips)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadsTheBundledExampleConfigCleanly() {
|
||||
// The example file lives at the module root so operators have a
|
||||
// canonical reference. Read it via a relative path resolved
|
||||
// against the working directory (gradle runs tests from the
|
||||
// module dir).
|
||||
val candidates =
|
||||
listOf(
|
||||
File("config.example.toml"),
|
||||
File("geode/config.example.toml"),
|
||||
)
|
||||
val example =
|
||||
candidates.firstOrNull { it.exists() }
|
||||
?: error(
|
||||
"config.example.toml not found in any of: ${candidates.joinToString { it.absolutePath }}",
|
||||
)
|
||||
|
||||
val c = RelayConfig.fromFile(example)
|
||||
|
||||
assertEquals("wss://relay.example.com/", c.info.relay_url)
|
||||
assertEquals(true, c.options.verify_signatures)
|
||||
assertEquals(false, c.database.in_memory)
|
||||
assertNotNull(c.database.file)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingSectionsAreOptional() {
|
||||
val c = RelayConfig.fromToml("[info]\nname = \"only-info\"")
|
||||
assertEquals("only-info", c.info.name)
|
||||
// Defaults preserved for unspecified sections.
|
||||
assertEquals(7447, c.network.port)
|
||||
assertEquals(true, c.database.in_memory)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.geode.perf
|
||||
|
||||
import com.vitorpamplona.geode.LocalRelayServer
|
||||
import com.vitorpamplona.geode.Relay
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlin.test.Test
|
||||
import kotlin.time.measureTime
|
||||
|
||||
/**
|
||||
* Single-process load tests that report real numbers for:
|
||||
* - WebSocket connection establishment rate
|
||||
* - Concurrent subscription steady-state count
|
||||
* - Live-event fanout latency to N subscribers
|
||||
* - End-to-end EVENT publish throughput (EVENT → store → OK)
|
||||
*
|
||||
* Disabled by default — runs only when `runLoadBenchmark` system
|
||||
* property is set. Add `-DrunLoadBenchmark=true` to a Gradle test
|
||||
* invocation. Skipped under the normal test run because (a) numbers
|
||||
* vary on busy CI runners, and (b) some scenarios spin up thousands
|
||||
* of sockets which is rude on shared infra.
|
||||
*/
|
||||
class LoadBenchmark {
|
||||
private val enabled = System.getProperty("runLoadBenchmark") == "true"
|
||||
|
||||
private inline fun benchmark(
|
||||
name: String,
|
||||
block: () -> Unit,
|
||||
) {
|
||||
if (!enabled) {
|
||||
println("[skip] $name — set -DrunLoadBenchmark=true to enable")
|
||||
return
|
||||
}
|
||||
println("--- $name ---")
|
||||
block()
|
||||
}
|
||||
|
||||
/**
|
||||
* How many concurrent WebSocket *connections* can we hold open?
|
||||
* Each test client opens a raw WS, sends one REQ, expects EOSE.
|
||||
* Stays connected after that.
|
||||
*/
|
||||
@Test
|
||||
fun connectionsHeldOpen() =
|
||||
benchmark("connections held open") {
|
||||
for (target in listOf(100, 500, 1_000, 2_000, 5_000, 10_000)) {
|
||||
runBenchmarkServer { server, http ->
|
||||
val httpUrl =
|
||||
okhttp3.Request
|
||||
.Builder()
|
||||
.url(server.url.replace("ws://", "http://"))
|
||||
.build()
|
||||
val sockets = java.util.concurrent.CopyOnWriteArrayList<okhttp3.WebSocket>()
|
||||
val opened = AtomicLong()
|
||||
val gotEose = AtomicLong()
|
||||
val opens =
|
||||
measureTime {
|
||||
repeat(target) {
|
||||
val ws =
|
||||
http.newWebSocket(
|
||||
httpUrl,
|
||||
object : okhttp3.WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: okhttp3.WebSocket,
|
||||
response: okhttp3.Response,
|
||||
) {
|
||||
opened.incrementAndGet()
|
||||
webSocket.send(
|
||||
"""["REQ","s",{"kinds":[1],"limit":1}]""",
|
||||
)
|
||||
}
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: okhttp3.WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
if (text.startsWith("[\"EOSE\"")) {
|
||||
gotEose.incrementAndGet()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
sockets += ws
|
||||
}
|
||||
// Wait for either EOSE on every connection or 60s deadline.
|
||||
val deadline = System.currentTimeMillis() + 60_000
|
||||
while (gotEose.get() < target && System.currentTimeMillis() < deadline) {
|
||||
Thread.sleep(50)
|
||||
}
|
||||
}
|
||||
// Let activeSessionCount settle.
|
||||
Thread.sleep(200)
|
||||
println(
|
||||
"target=$target opened=${opened.get()} eosed=${gotEose.get()} " +
|
||||
"active=${server.activeSessionCount} elapsedMs=${opens.inWholeMilliseconds}",
|
||||
)
|
||||
sockets.forEach { runCatching { it.cancel() } }
|
||||
if (gotEose.get() < target) {
|
||||
println(" --> degradation at $target; stopping ramp-up")
|
||||
return@runBenchmarkServer
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One publisher sends 10k events serially. Measures the round-trip
|
||||
* `EVENT` → `OK true` time, which is dominated by SQLite write
|
||||
* throughput + the write side of the policy stack.
|
||||
*/
|
||||
@Test
|
||||
fun publishThroughputSingleClient() =
|
||||
benchmark("publish throughput single client") {
|
||||
runBenchmarkServer { server, http ->
|
||||
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
val client = NostrClient(BasicOkHttpWebSocket.Builder { _ -> http }, scope)
|
||||
try {
|
||||
val signer = NostrSignerSync(KeyPair())
|
||||
val relayUrl = server.url.normalizeRelayUrl()
|
||||
|
||||
val n = 10_000
|
||||
var ok = 0
|
||||
val elapsed =
|
||||
measureTime {
|
||||
runBlocking {
|
||||
repeat(n) { i ->
|
||||
val event = signer.sign(TextNoteEvent.build("hello $i"))
|
||||
if (client.publishAndConfirm(event, setOf(relayUrl))) ok++
|
||||
}
|
||||
}
|
||||
}
|
||||
val eps = (n * 1000.0) / elapsed.inWholeMilliseconds
|
||||
println("events=$n ok=$ok elapsedMs=${elapsed.inWholeMilliseconds} eps=${"%.0f".format(eps)}")
|
||||
} finally {
|
||||
client.disconnect()
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One publisher, N subscribers. Publishes one EVENT and measures
|
||||
* fan-out latency: time from publish to last subscriber receiving.
|
||||
*/
|
||||
@Test
|
||||
fun fanoutLatency() =
|
||||
benchmark("fanout latency") {
|
||||
for (subs in listOf(100, 500, 1_000, 2_000)) {
|
||||
runBenchmarkServer { server, http ->
|
||||
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
val subClient = NostrClient(BasicOkHttpWebSocket.Builder { _ -> http }, scope)
|
||||
val pubClient = NostrClient(BasicOkHttpWebSocket.Builder { _ -> http }, scope)
|
||||
try {
|
||||
val relayUrl = server.url.normalizeRelayUrl()
|
||||
val received = AtomicLong()
|
||||
val firstReceiveNs = AtomicLong(-1)
|
||||
val lastReceiveNs = AtomicLong(-1)
|
||||
|
||||
// Set up `subs` subscribers.
|
||||
val eosed = AtomicLong()
|
||||
repeat(subs) { i ->
|
||||
subClient.subscribe(
|
||||
"fanout-$i",
|
||||
mapOf(relayUrl to listOf(Filter(kinds = listOf(1)))),
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
event: com.vitorpamplona.quartz.nip01Core.core.Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
val now = System.nanoTime()
|
||||
firstReceiveNs.compareAndSet(-1, now)
|
||||
lastReceiveNs.set(now)
|
||||
received.incrementAndGet()
|
||||
}
|
||||
|
||||
override fun onEose(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
eosed.incrementAndGet()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
runBlocking {
|
||||
withTimeout(60_000) {
|
||||
while (eosed.get() < subs) kotlinx.coroutines.delay(50)
|
||||
}
|
||||
}
|
||||
println("$subs subs ready; publishing one event...")
|
||||
|
||||
val signer = NostrSignerSync(KeyPair())
|
||||
val event = signer.sign(TextNoteEvent.build("fanout"))
|
||||
val publishStart = System.nanoTime()
|
||||
runBlocking {
|
||||
pubClient.publishAndConfirm(event, setOf(relayUrl))
|
||||
}
|
||||
val publishEnd = System.nanoTime()
|
||||
|
||||
// Wait for fanout to complete.
|
||||
runBlocking {
|
||||
withTimeout(60_000) {
|
||||
while (received.get() < subs) kotlinx.coroutines.delay(10)
|
||||
}
|
||||
}
|
||||
|
||||
val firstFanoutMs = (firstReceiveNs.get() - publishStart) / 1_000_000.0
|
||||
val lastFanoutMs = (lastReceiveNs.get() - publishStart) / 1_000_000.0
|
||||
val publishMs = (publishEnd - publishStart) / 1_000_000.0
|
||||
println(
|
||||
"subs=$subs publishMs=${"%.1f".format(publishMs)} " +
|
||||
"fanoutFirstMs=${"%.1f".format(firstFanoutMs)} " +
|
||||
"fanoutLastMs=${"%.1f".format(lastFanoutMs)} " +
|
||||
"received=${received.get()}/$subs",
|
||||
)
|
||||
} finally {
|
||||
subClient.disconnect()
|
||||
pubClient.disconnect()
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Many concurrent publishers, each on their own WebSocket. Tells
|
||||
* us whether the SQLite single-writer bottleneck is the floor or
|
||||
* if there's contention upstream.
|
||||
*/
|
||||
@Test
|
||||
fun publishThroughputConcurrent() =
|
||||
benchmark("publish throughput concurrent") {
|
||||
for (parallel in listOf(2, 4, 8, 16, 32)) {
|
||||
runBenchmarkServer { server, http ->
|
||||
val total = 5_000
|
||||
val perThread = total / parallel
|
||||
val ok = AtomicLong()
|
||||
val elapsed =
|
||||
measureTime {
|
||||
val threads =
|
||||
(0 until parallel).map { tid ->
|
||||
Thread {
|
||||
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
val client = NostrClient(BasicOkHttpWebSocket.Builder { _ -> http }, scope)
|
||||
try {
|
||||
val signer = NostrSignerSync(KeyPair())
|
||||
val relayUrl = server.url.normalizeRelayUrl()
|
||||
runBlocking {
|
||||
repeat(perThread) { i ->
|
||||
val ev = signer.sign(TextNoteEvent.build("hi-$tid-$i"))
|
||||
if (client.publishAndConfirm(ev, setOf(relayUrl))) ok.incrementAndGet()
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
client.disconnect()
|
||||
scope.cancel()
|
||||
}
|
||||
}.also { it.start() }
|
||||
}
|
||||
threads.forEach { it.join() }
|
||||
}
|
||||
val eps = (ok.get() * 1000.0) / elapsed.inWholeMilliseconds
|
||||
println(
|
||||
"parallel=$parallel total=${ok.get()}/$total elapsedMs=${elapsed.inWholeMilliseconds} eps=${"%.0f".format(eps)}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Spin up an isolated relay + http client per scenario. */
|
||||
private inline fun runBenchmarkServer(block: (LocalRelayServer, OkHttpClient) -> Unit) {
|
||||
val placeholder = "ws://127.0.0.1:7771/".normalizeRelayUrl()
|
||||
val relay = Relay(url = placeholder)
|
||||
val server = LocalRelayServer(relay, host = "127.0.0.1", port = 0).start()
|
||||
val http =
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
// Don't bottleneck on the OkHttp dispatcher when we
|
||||
// open thousands of WS connections from one client.
|
||||
.dispatcher(
|
||||
okhttp3.Dispatcher().apply {
|
||||
maxRequests = 100_000
|
||||
maxRequestsPerHost = 100_000
|
||||
},
|
||||
).build()
|
||||
try {
|
||||
block(server, http)
|
||||
} finally {
|
||||
http.dispatcher.executorService.shutdownNow()
|
||||
server.stop(gracePeriodMillis = 200, timeoutMillis = 1_000)
|
||||
relay.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.geode.persistence
|
||||
|
||||
import com.vitorpamplona.geode.Relay
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class PersistenceTest {
|
||||
private lateinit var dir: File
|
||||
private lateinit var stateFile: File
|
||||
private val url = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/")
|
||||
|
||||
@BeforeTest
|
||||
fun setup() {
|
||||
dir = Files.createTempDirectory("quartz-relay-persist-").toFile()
|
||||
stateFile = File(dir, "admin.json")
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
fun teardown() {
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun firstBootWritesNothingUntilFirstMutation() {
|
||||
val relay = Relay(url = url, stateFile = stateFile)
|
||||
try {
|
||||
// No mutation yet → file does not exist.
|
||||
assertTrue(!stateFile.exists(), "fresh relay must not eagerly write a snapshot")
|
||||
} finally {
|
||||
relay.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun banPubkeyTriggersSnapshotAndSurvivesRestart() {
|
||||
val pk = "a".repeat(64)
|
||||
val r1 = Relay(url = url, stateFile = stateFile)
|
||||
try {
|
||||
r1.banStore.banPubkey(pk, "spam")
|
||||
} finally {
|
||||
r1.close()
|
||||
}
|
||||
assertTrue(stateFile.exists(), "snapshot must be written after a mutation")
|
||||
|
||||
// Fresh relay reads the snapshot and sees the ban.
|
||||
val r2 = Relay(url = url, stateFile = stateFile)
|
||||
try {
|
||||
assertTrue(r2.banStore.isBanned(pk))
|
||||
assertEquals("spam", r2.banStore.listBannedPubkeys()[0].second)
|
||||
} finally {
|
||||
r2.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun updateInfoSurvivesRestart() {
|
||||
val r1 = Relay(url = url, stateFile = stateFile)
|
||||
try {
|
||||
r1.updateInfo { it.copy(name = "renamed") }
|
||||
} finally {
|
||||
r1.close()
|
||||
}
|
||||
|
||||
val r2 = Relay(url = url, stateFile = stateFile)
|
||||
try {
|
||||
assertEquals("renamed", r2.info.document.name)
|
||||
} finally {
|
||||
r2.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun allowKindRoundTripsAcrossRestart() {
|
||||
val r1 = Relay(url = url, stateFile = stateFile)
|
||||
try {
|
||||
r1.banStore.allowKind(1)
|
||||
r1.banStore.allowKind(7)
|
||||
r1.banStore.disallowKind(4)
|
||||
} finally {
|
||||
r1.close()
|
||||
}
|
||||
|
||||
val r2 = Relay(url = url, stateFile = stateFile)
|
||||
try {
|
||||
assertEquals(listOf(1, 7), r2.banStore.listAllowedKinds())
|
||||
assertEquals(listOf(4), r2.banStore.listDisallowedKinds())
|
||||
} finally {
|
||||
r2.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun corruptStateFileIsTolerated() {
|
||||
stateFile.writeText("not valid json {")
|
||||
// Should not throw — just log and start fresh.
|
||||
val r = Relay(url = url, stateFile = stateFile)
|
||||
try {
|
||||
assertTrue(r.banStore.listBannedPubkeys().isEmpty())
|
||||
assertTrue(r.banStore.listAllowedKinds().isEmpty())
|
||||
} finally {
|
||||
r.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun snapshotWriteIsAtomicViaTempFile() {
|
||||
// After a mutation completes, no `.tmp` file should remain.
|
||||
val r = Relay(url = url, stateFile = stateFile)
|
||||
try {
|
||||
r.banStore.banPubkey("b".repeat(64))
|
||||
val tmp = File(dir, "admin.json.tmp")
|
||||
assertTrue(!tmp.exists(), "tmp file must be moved into place, not left behind")
|
||||
} finally {
|
||||
r.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingStateFileMeansInMemoryOnly() {
|
||||
val r = Relay(url = url) // no stateFile
|
||||
try {
|
||||
r.banStore.banPubkey("c".repeat(64))
|
||||
// No snapshot path → nothing on disk in our temp dir.
|
||||
assertNull(dir.list()?.firstOrNull { it.startsWith("admin") })
|
||||
} finally {
|
||||
r.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stateStoreRoundTripsAllSections() {
|
||||
val ss = RelayStateStore(stateFile)
|
||||
val state =
|
||||
RelayPersistedState(
|
||||
info = Nip11RelayInformation(name = "x", description = "y"),
|
||||
bannedPubkeys = listOf(BannedEntry("aa", "spam"), BannedEntry("bb", null)),
|
||||
allowedPubkeys = listOf(BannedEntry("cc", "trusted")),
|
||||
bannedEvents = listOf(BannedEntry("dd", "off-topic")),
|
||||
allowedKinds = listOf(1, 7),
|
||||
disallowedKinds = listOf(4, 1059),
|
||||
)
|
||||
ss.save(state)
|
||||
val loaded = ss.load()!!
|
||||
assertEquals("x", loaded.info!!.name)
|
||||
assertEquals(2, loaded.bannedPubkeys.size)
|
||||
assertEquals(listOf(1, 7), loaded.allowedKinds)
|
||||
assertEquals(listOf(4, 1059), loaded.disallowedKinds)
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual `Nip11RelayInformation.copy` — the class isn't a data
|
||||
* class so Kotlin doesn't generate one. We only need `name` here.
|
||||
*/
|
||||
private fun Nip11RelayInformation.copy(name: String? = this.name) =
|
||||
Nip11RelayInformation(
|
||||
id = id,
|
||||
name = name,
|
||||
description = description,
|
||||
icon = icon,
|
||||
pubkey = pubkey,
|
||||
self = self,
|
||||
contact = contact,
|
||||
supported_nips = supported_nips,
|
||||
supported_nip_extensions = supported_nip_extensions,
|
||||
software = software,
|
||||
version = version,
|
||||
limitation = limitation,
|
||||
relay_countries = relay_countries,
|
||||
language_tags = language_tags,
|
||||
tags = tags,
|
||||
posting_policy = posting_policy,
|
||||
privacy_policy = privacy_policy,
|
||||
terms_of_service = terms_of_service,
|
||||
payments_url = payments_url,
|
||||
retention = retention,
|
||||
fees = fees,
|
||||
nip50 = nip50,
|
||||
supported_grasps = supported_grasps,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.geode.policies
|
||||
|
||||
import com.vitorpamplona.geode.RelayHub
|
||||
import com.vitorpamplona.geode.fixtures.SyntheticEvents
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.KindAllowDenyPolicy
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PubkeyAllowDenyPolicy
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RejectFutureEventsPolicy
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
/**
|
||||
* End-to-end through `NostrClient → RelayHub → Relay` with the policies
|
||||
* actually wired into the relay. Proves an EVENT command sent on the
|
||||
* wire surfaces an OK false response when the policy rejects.
|
||||
*/
|
||||
class PoliciesIntegrationTest {
|
||||
private val relayUrl: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/")
|
||||
private lateinit var scope: CoroutineScope
|
||||
|
||||
@BeforeTest
|
||||
fun setup() {
|
||||
scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
fun teardown() {
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
/** Spin up a hub whose only relay uses the supplied policy factory. */
|
||||
private fun hubWith(policyFactory: () -> IRelayPolicy): Pair<NostrClient, RelayHub> {
|
||||
val hub = RelayHub(defaultPolicy = policyFactory)
|
||||
// Materialise the relay so the URL resolves in the hub.
|
||||
hub.getOrCreate(relayUrl)
|
||||
return NostrClient(hub, scope) to hub
|
||||
}
|
||||
|
||||
@Test
|
||||
fun kindBlacklistRejectsKind4OverWire() =
|
||||
runBlocking {
|
||||
val (client, hub) = hubWith { KindAllowDenyPolicy(deny = setOf(4)) }
|
||||
try {
|
||||
val signer = NostrSignerSync(KeyPair())
|
||||
val ok = client.publishAndConfirm(signer.sign(TextNoteEvent.build("ok")), setOf(relayUrl))
|
||||
assertEquals(true, ok, "kind 1 must pass")
|
||||
|
||||
// Synthetic kind-4 event — the relay's deny list rejects it.
|
||||
val kind4 = SyntheticEvents.fakeEvent(idSeed = 999, kind = 4, pubKey = signer.pubKey)
|
||||
val rejected = client.publishAndConfirm(kind4, setOf(relayUrl))
|
||||
assertEquals(false, rejected, "kind 4 must be rejected")
|
||||
} finally {
|
||||
client.disconnect()
|
||||
hub.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pubkeyAllowListRejectsForeignAuthorOverWire() =
|
||||
runBlocking {
|
||||
val alice = NostrSignerSync(KeyPair())
|
||||
val mallory = NostrSignerSync(KeyPair())
|
||||
val (client, hub) = hubWith { PubkeyAllowDenyPolicy(allow = setOf(alice.pubKey)) }
|
||||
try {
|
||||
val accepted = client.publishAndConfirm(alice.sign(TextNoteEvent.build("hi")), setOf(relayUrl))
|
||||
assertEquals(true, accepted)
|
||||
val denied = client.publishAndConfirm(mallory.sign(TextNoteEvent.build("nope")), setOf(relayUrl))
|
||||
assertEquals(false, denied)
|
||||
} finally {
|
||||
client.disconnect()
|
||||
hub.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectFutureEventsBlocksFarFutureCreatedAtOverWire() =
|
||||
runBlocking {
|
||||
// Use a fixed clock so the policy decision is deterministic.
|
||||
val frozen = 1_000_000L
|
||||
val (client, hub) =
|
||||
hubWith { RejectFutureEventsPolicy(maxFutureSeconds = 60, now = { frozen }) }
|
||||
try {
|
||||
val signer = NostrSignerSync(KeyPair())
|
||||
val nearby = signer.sign(TextNoteEvent.build("ok", createdAt = frozen + 30))
|
||||
assertEquals(true, client.publishAndConfirm(nearby, setOf(relayUrl)))
|
||||
val tooFar = signer.sign(TextNoteEvent.build("nope", createdAt = frozen + 3600))
|
||||
assertEquals(false, client.publishAndConfirm(tooFar, setOf(relayUrl)))
|
||||
} finally {
|
||||
client.disconnect()
|
||||
hub.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.geode.policies
|
||||
|
||||
import com.vitorpamplona.geode.fixtures.SyntheticEvents
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.KindAllowDenyPolicy
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PubkeyAllowDenyPolicy
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RejectFutureEventsPolicy
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.test.fail
|
||||
|
||||
/**
|
||||
* Per-policy unit tests. Each policy gets a small, focused suite that
|
||||
* proves accept/reject behaviour at the boundaries (empty config,
|
||||
* single hit, collision between allow + deny, etc.).
|
||||
*
|
||||
* The end-to-end "policy is applied through the Ktor server" coverage
|
||||
* lives in `LocalRelayServerTest` / `Nip01ComplianceTest` — these tests
|
||||
* just exercise the policy in isolation.
|
||||
*/
|
||||
class PoliciesTest {
|
||||
private fun event(
|
||||
kind: Int = 1,
|
||||
pubKey: String = SyntheticEvents.hexId(1),
|
||||
createdAt: Long = 1000L,
|
||||
content: String = "",
|
||||
) = SyntheticEvents.fakeEvent(idSeed = 1, kind = kind, pubKey = pubKey, createdAt = createdAt, content = content)
|
||||
|
||||
private fun assertAccepted(result: PolicyResult<*>) {
|
||||
if (result is PolicyResult.Rejected) fail("expected Accepted, got Rejected: ${result.reason}")
|
||||
}
|
||||
|
||||
private fun assertRejected(
|
||||
result: PolicyResult<*>,
|
||||
reasonContains: String? = null,
|
||||
) {
|
||||
when (result) {
|
||||
is PolicyResult.Accepted -> {
|
||||
fail("expected Rejected, got Accepted")
|
||||
}
|
||||
|
||||
is PolicyResult.Rejected -> {
|
||||
reasonContains?.let {
|
||||
assertTrue(
|
||||
result.reason.contains(it),
|
||||
"expected reason to contain '$it', got '${result.reason}'",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- KindAllowDenyPolicy -------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun kindPolicyEmptyListsAreNoOp() {
|
||||
val p = KindAllowDenyPolicy()
|
||||
assertAccepted(p.accept(EventCmd(event(kind = 1))))
|
||||
assertAccepted(p.accept(EventCmd(event(kind = 99))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun kindAllowListExcludesEverythingElse() {
|
||||
val p = KindAllowDenyPolicy(allow = setOf(1, 7))
|
||||
assertAccepted(p.accept(EventCmd(event(kind = 1))))
|
||||
assertAccepted(p.accept(EventCmd(event(kind = 7))))
|
||||
assertRejected(p.accept(EventCmd(event(kind = 4))), reasonContains = "kind 4 not allowed")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun kindDenyListBlocksLastWordOverAllowList() {
|
||||
// When both lists are set, allow is a permissive ceiling and
|
||||
// deny still removes specific kinds inside it.
|
||||
val p = KindAllowDenyPolicy(allow = setOf(1, 4, 7), deny = setOf(4))
|
||||
assertAccepted(p.accept(EventCmd(event(kind = 1))))
|
||||
assertRejected(p.accept(EventCmd(event(kind = 4))), reasonContains = "kind 4 denied")
|
||||
assertRejected(p.accept(EventCmd(event(kind = 999))), reasonContains = "not allowed")
|
||||
}
|
||||
|
||||
// -- PubkeyAllowDenyPolicy ----------------------------------------------
|
||||
|
||||
@Test
|
||||
fun pubkeyAllowList() {
|
||||
val alice = SyntheticEvents.hexId(101)
|
||||
val mallory = SyntheticEvents.hexId(102)
|
||||
val p = PubkeyAllowDenyPolicy(allow = setOf(alice))
|
||||
assertAccepted(p.accept(EventCmd(event(pubKey = alice))))
|
||||
assertRejected(p.accept(EventCmd(event(pubKey = mallory))), reasonContains = "not on allow")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pubkeyDenyList() {
|
||||
val alice = SyntheticEvents.hexId(101)
|
||||
val mallory = SyntheticEvents.hexId(102)
|
||||
val p = PubkeyAllowDenyPolicy(deny = setOf(mallory))
|
||||
assertAccepted(p.accept(EventCmd(event(pubKey = alice))))
|
||||
assertRejected(p.accept(EventCmd(event(pubKey = mallory))), reasonContains = "denied")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pubkeyMatchIsCaseInsensitive() {
|
||||
val pk = "ABCDEF".padEnd(64, '0')
|
||||
val p = PubkeyAllowDenyPolicy(deny = setOf(pk.lowercase()))
|
||||
// Event arrives with the upper-case form; policy must match.
|
||||
assertRejected(p.accept(EventCmd(event(pubKey = pk))))
|
||||
}
|
||||
|
||||
// -- RejectFutureEventsPolicy -------------------------------------------
|
||||
|
||||
@Test
|
||||
fun futureEventsBeyondSkewAreRejected() {
|
||||
val now = 1_000_000L
|
||||
val p = RejectFutureEventsPolicy(maxFutureSeconds = 60, now = { now })
|
||||
assertAccepted(p.accept(EventCmd(event(createdAt = now + 60))))
|
||||
assertAccepted(p.accept(EventCmd(event(createdAt = now))))
|
||||
assertAccepted(p.accept(EventCmd(event(createdAt = now - 9999)))) // past is fine
|
||||
assertRejected(p.accept(EventCmd(event(createdAt = now + 61))), reasonContains = "future")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun futureEventsZeroSkewMeansOnlyPastOrPresent() {
|
||||
val now = 1_000L
|
||||
val p = RejectFutureEventsPolicy(maxFutureSeconds = 0, now = { now })
|
||||
assertAccepted(p.accept(EventCmd(event(createdAt = now))))
|
||||
assertRejected(p.accept(EventCmd(event(createdAt = now + 1))))
|
||||
}
|
||||
|
||||
// -- Stack composition --------------------------------------------------
|
||||
|
||||
/**
|
||||
* Verifies that policies compose via `IRelayPolicy.plus` so an
|
||||
* EVENT must clear every policy in the stack to be accepted.
|
||||
*/
|
||||
@Test
|
||||
fun stackedPoliciesAllMustAccept() {
|
||||
val now = 1_000L
|
||||
val stack =
|
||||
(KindAllowDenyPolicy(allow = setOf(1)) as com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy) +
|
||||
RejectFutureEventsPolicy(maxFutureSeconds = 10, now = { now })
|
||||
|
||||
// Allowed kind, in window — accepted.
|
||||
assertAccepted(stack.accept(EventCmd(event(kind = 1, createdAt = now))))
|
||||
// Allowed kind, future timestamp — rejected by RejectFuture.
|
||||
assertRejected(
|
||||
stack.accept(EventCmd(event(kind = 1, createdAt = now + 1000))),
|
||||
reasonContains = "future",
|
||||
)
|
||||
// Disallowed kind — rejected by KindPolicy regardless of timestamp.
|
||||
assertRejected(
|
||||
stack.accept(EventCmd(event(kind = 99, createdAt = now))),
|
||||
reasonContains = "not allowed",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.geode.fixtures
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
|
||||
import java.util.zip.GZIPInputStream
|
||||
|
||||
/**
|
||||
* Loaders for test event corpora bundled in `quartz/src/commonTest/resources/`.
|
||||
* Lookup order:
|
||||
* 1. The `TEST_RESOURCES_ROOT` environment variable (set by quartz's Gradle
|
||||
* config to the absolute path of `commonTest/resources`).
|
||||
* 2. The classpath, for callers that copy fixtures into their own
|
||||
* `src/test/resources`.
|
||||
*/
|
||||
object RelayFixtures {
|
||||
/** Reads a fixture file as a UTF-8 string. */
|
||||
fun loadString(name: String): String {
|
||||
val envRoot = System.getenv("TEST_RESOURCES_ROOT")
|
||||
if (envRoot != null) {
|
||||
val file = java.io.File(envRoot, name)
|
||||
if (file.exists()) return file.readText()
|
||||
}
|
||||
val cp = RelayFixtures::class.java.classLoader?.getResourceAsStream(name)
|
||||
if (cp != null) return cp.bufferedReader().use { it.readText() }
|
||||
throw IllegalArgumentException(
|
||||
"Fixture not found: $name. Set TEST_RESOURCES_ROOT or place on classpath.",
|
||||
)
|
||||
}
|
||||
|
||||
/** Reads a gzipped fixture file as a UTF-8 string. */
|
||||
fun loadGzipString(name: String): String {
|
||||
val envRoot = System.getenv("TEST_RESOURCES_ROOT")
|
||||
if (envRoot != null) {
|
||||
val file = java.io.File(envRoot, name)
|
||||
if (file.exists()) {
|
||||
return GZIPInputStream(file.inputStream()).bufferedReader().use { it.readText() }
|
||||
}
|
||||
}
|
||||
val cp = RelayFixtures::class.java.classLoader?.getResourceAsStream(name)
|
||||
if (cp != null) return GZIPInputStream(cp).bufferedReader().use { it.readText() }
|
||||
throw IllegalArgumentException(
|
||||
"Fixture not found: $name. Set TEST_RESOURCES_ROOT or place on classpath.",
|
||||
)
|
||||
}
|
||||
|
||||
/** Loads `nostr_vitor_short.json` — the small handcrafted Vitor corpus. */
|
||||
fun vitorShort(): List<Event> = OptimizedJsonMapper.fromJsonToEventList(loadString("nostr_vitor_short.json"))
|
||||
|
||||
/** Loads `nostr_vitor_startup_data.json.gz` — the larger Vitor startup corpus. */
|
||||
fun vitorStartup(): List<Event> = OptimizedJsonMapper.fromJsonToEventList(loadGzipString("nostr_vitor_startup_data.json"))
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.geode.fixtures
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
|
||||
/**
|
||||
* Generators for cheap, deterministic, structurally valid Nostr events.
|
||||
*
|
||||
* The signatures and ids produced here are *not* cryptographically valid —
|
||||
* the in-process relay's default [com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy]
|
||||
* doesn't verify them, and neither does the underlying SQLite event store.
|
||||
* Use these when a test only needs to exercise relay logic (filter matching,
|
||||
* limits, EOSE, live updates) and not the cryptographic layer.
|
||||
*/
|
||||
object SyntheticEvents {
|
||||
/** A pubkey/sig pair that's syntactically valid (64/128 hex chars) but signs nothing. */
|
||||
private val DEFAULT_PUBKEY = "0".repeat(64)
|
||||
private val FAKE_SIG = "0".repeat(128)
|
||||
|
||||
/** Hex padding to 64 chars so deterministic ids look like real event ids. */
|
||||
fun hexId(seed: Int): String = seed.toString().padStart(64, '0')
|
||||
|
||||
fun fakeEvent(
|
||||
idSeed: Int,
|
||||
kind: Int = 1,
|
||||
pubKey: String = DEFAULT_PUBKEY,
|
||||
createdAt: Long = idSeed.toLong(),
|
||||
content: String = "",
|
||||
tags: Array<Array<String>> = emptyArray(),
|
||||
): Event = Event(hexId(idSeed), pubKey, createdAt, kind, tags, content, FAKE_SIG)
|
||||
|
||||
/**
|
||||
* Returns [count] events of [kind] with monotonic [createdAt] starting at 1
|
||||
* and a *distinct* pubkey per event. Distinct pubkeys are essential for
|
||||
* replaceable kinds (0, 3, 10000-19999): without them, the relay collapses
|
||||
* the whole batch to one row per (kind, pubkey).
|
||||
*/
|
||||
fun batch(
|
||||
count: Int,
|
||||
kind: Int = 1,
|
||||
pubKeyOf: (Int) -> String = { hexId(1_000_000 + it) },
|
||||
): List<Event> =
|
||||
List(count) { i ->
|
||||
val seed = i + 1
|
||||
fakeEvent(idSeed = seed, kind = kind, pubKey = pubKeyOf(seed), createdAt = seed.toLong())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.geode.testing
|
||||
|
||||
import com.vitorpamplona.geode.Relay
|
||||
import com.vitorpamplona.geode.RelayHub
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import org.junit.After
|
||||
|
||||
/**
|
||||
* Base class for tests that drive a real [NostrClient] against an
|
||||
* in-process [RelayHub]. Owns the lifecycle of the four pieces every
|
||||
* such test needs:
|
||||
*
|
||||
* - [hub] — the registry of in-process relays (also serves as
|
||||
* `WebsocketBuilder` for [NostrClient]).
|
||||
* - [scope] — application coroutine scope for the client.
|
||||
* - [client] — a [NostrClient] wired to [hub] and [scope].
|
||||
* - [defaultRelay] / [defaultRelayUrl] — convenience handles for the
|
||||
* single-relay case (the most common in tests).
|
||||
*
|
||||
* Cleanup happens in [tearDownRelayClientTest], registered with
|
||||
* JUnit's [@After][After], so an assertion failure does NOT leak the
|
||||
* scope, the SQLite event store, or the WebSocket bridge — a recurring
|
||||
* problem with the previous "clean up at the end of the test body"
|
||||
* pattern.
|
||||
*
|
||||
* Subclasses that need their own setup/teardown should add their own
|
||||
* `@Before` / `@After` methods; JUnit runs all of them.
|
||||
*
|
||||
* Multi-relay tests use [hub] directly:
|
||||
* ```
|
||||
* val relayA = RelayUrlNormalizer.normalize("ws://relay-a/")
|
||||
* hub.getOrCreate(relayA).preload(eventA)
|
||||
* hub.getOrCreate(relayB).preload(eventB)
|
||||
* ```
|
||||
*/
|
||||
open class RelayClientTest {
|
||||
val hub: RelayHub = RelayHub()
|
||||
val scope: CoroutineScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
val client: NostrClient = NostrClient(hub, scope)
|
||||
|
||||
/** Stable URL for the single-relay case — see [RelayHub.DEFAULT_URL]. */
|
||||
val defaultRelayUrl: NormalizedRelayUrl get() = RelayHub.DEFAULT_URL
|
||||
|
||||
/** Lazy handle to the relay at [defaultRelayUrl]. Auto-created on first read. */
|
||||
val defaultRelay: Relay get() = hub.getOrCreate(defaultRelayUrl)
|
||||
|
||||
@After
|
||||
fun tearDownRelayClientTest() {
|
||||
client.disconnect()
|
||||
scope.cancel()
|
||||
hub.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.geode.testing
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
|
||||
import kotlinx.coroutines.withTimeout
|
||||
|
||||
/** Tagged result of [collectUntilEose]: stored events plus whether EOSE actually arrived. */
|
||||
data class CollectResult(
|
||||
val events: List<Event>,
|
||||
val eoseReceived: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Subscribe with [filter] on a single [relay], drain the
|
||||
* historical-replay phase, and return when EOSE arrives. The
|
||||
* subscription is closed before this returns. The pattern that 80% of
|
||||
* REQ-style tests need.
|
||||
*
|
||||
* ```
|
||||
* val (events, eose) = client.collectUntilEose(defaultRelayUrl, Filter(kinds = listOf(1)))
|
||||
* assertEquals(20, events.size)
|
||||
* assertTrue(eose)
|
||||
* ```
|
||||
*
|
||||
* @param timeoutMillis time to wait for EOSE before failing the test.
|
||||
* Default 5 s — generous for in-process; tighten if needed.
|
||||
*/
|
||||
suspend fun NostrClient.collectUntilEose(
|
||||
relay: NormalizedRelayUrl,
|
||||
filter: Filter,
|
||||
timeoutMillis: Long = 5_000,
|
||||
): CollectResult = collectUntilEoseMulti(relay, listOf(filter), timeoutMillis)
|
||||
|
||||
/**
|
||||
* Multi-filter variant. NIP-01 allows a REQ to carry several filters
|
||||
* that the relay OR's together. EOSE fires once after the union of all
|
||||
* filters has been replayed.
|
||||
*/
|
||||
suspend fun NostrClient.collectUntilEoseMulti(
|
||||
relay: NormalizedRelayUrl,
|
||||
filters: List<Filter>,
|
||||
timeoutMillis: Long = 5_000,
|
||||
): CollectResult {
|
||||
val ch = Channel<Signal>(UNLIMITED)
|
||||
val subId = "test-sub-${nextSubId()}"
|
||||
subscribe(
|
||||
subId,
|
||||
mapOf(relay to filters),
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
ch.trySend(Signal.Ev(event))
|
||||
}
|
||||
|
||||
override fun onEose(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
ch.trySend(Signal.Eose)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
val events = mutableListOf<Event>()
|
||||
var eose = false
|
||||
try {
|
||||
withTimeout(timeoutMillis) {
|
||||
while (!eose) {
|
||||
when (val msg = ch.receive()) {
|
||||
is Signal.Ev -> events += msg.event
|
||||
Signal.Eose -> eose = true
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
unsubscribe(subId)
|
||||
}
|
||||
return CollectResult(events, eose)
|
||||
}
|
||||
|
||||
private sealed interface Signal {
|
||||
data class Ev(
|
||||
val event: Event,
|
||||
) : Signal
|
||||
|
||||
object Eose : Signal
|
||||
}
|
||||
|
||||
/** Monotonic counter for unique sub-ids inside a JVM. */
|
||||
private var subIdSeq: Int = 0
|
||||
|
||||
private fun nextSubId(): Int = ++subIdSeq
|
||||
@@ -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" }
|
||||
|
||||
@@ -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.
|
||||
+8
-6
@@ -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(
|
||||
|
||||
+4
-3
@@ -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
|
||||
|
||||
+3
-3
@@ -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.
|
||||
|
||||
+22
-4
@@ -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)
|
||||
|
||||
+62
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-1
@@ -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<NestsSpeakerState> = 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)
|
||||
|
||||
@@ -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<NestsSpeakerState>(
|
||||
@@ -252,6 +259,7 @@ suspend fun connectNestsSpeaker(
|
||||
scope = scope,
|
||||
mutableState = state,
|
||||
framesPerGroup = framesPerGroup,
|
||||
broadcastConfig = broadcastConfig,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+24
-1
@@ -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<NestsSpeakerState> = 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 {
|
||||
|
||||
@@ -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/<room>` 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
|
||||
|
||||
+50
-28
@@ -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<Pair<String, Int>, ByteArray> = HashMap()
|
||||
|
||||
fun opus48kJsonBytes(
|
||||
audioTrackName: String,
|
||||
numberOfChannels: Int = 1,
|
||||
): ByteArray {
|
||||
val key = audioTrackName to numberOfChannels
|
||||
cachedJsonBytes[key]?.let { return it }
|
||||
val bytes = opus48k(audioTrackName, numberOfChannels).encodeJsonBytes()
|
||||
cachedJsonBytes[key] = bytes
|
||||
return bytes
|
||||
}
|
||||
|
||||
/**
|
||||
* Opus frame duration in milliseconds — 960 samples / 48 kHz =
|
||||
|
||||
+7
@@ -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
|
||||
|
||||
+4
@@ -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
|
||||
}
|
||||
|
||||
+16
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+35
-3
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+18
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
+3
-3
@@ -42,7 +42,7 @@ object CountResultKSerializer : KSerializer<CountResult> {
|
||||
override val descriptor: SerialDescriptor =
|
||||
buildClassSerialDescriptor("CountResult") {
|
||||
element<Int>("count")
|
||||
element<Boolean>("pubkey")
|
||||
element<Boolean>("approximate")
|
||||
}
|
||||
|
||||
override fun serialize(
|
||||
@@ -56,8 +56,8 @@ object CountResultKSerializer : KSerializer<CountResult> {
|
||||
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)) }
|
||||
}
|
||||
|
||||
|
||||
+5
-5
@@ -68,12 +68,10 @@ object MessageKSerializer : KSerializer<Message> {
|
||||
}
|
||||
|
||||
is OkMessage -> {
|
||||
// NIP-01 wire format: ["OK", <event_id>, <true|false>, <message>]
|
||||
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<Message> {
|
||||
}
|
||||
|
||||
is CountMessage -> {
|
||||
// NIP-45 wire format: ["COUNT", <query_id>, <count_result>]
|
||||
add(JsonPrimitive(value.queryId))
|
||||
add(CountResultKSerializer.serializeToElement(value.result))
|
||||
}
|
||||
|
||||
|
||||
+61
-11
@@ -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<String>? = HashSet()
|
||||
val historicalOnEach: (Event) -> Unit = { event ->
|
||||
seenIds?.add(event.id)
|
||||
onEach(event)
|
||||
}
|
||||
newEventStream
|
||||
.onSubscription {
|
||||
store.query(filters, historicalOnEach)
|
||||
onEose()
|
||||
// Free the dedupe set once we've crossed EOSE: from
|
||||
// here on the live stream is the only source of
|
||||
// events, so duplicates aren't possible.
|
||||
inHistoricalPhase = false
|
||||
seenIds = null
|
||||
}.collect { newEvent ->
|
||||
if (inHistoricalPhase && seenIds?.contains(newEvent.id) == true) return@collect
|
||||
if (filters.any { it.match(newEvent) }) {
|
||||
onEach(newEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun count(filters: List<Filter>) = store.count(filters)
|
||||
|
||||
/**
|
||||
* One-shot snapshot query. Used by NIP-77 negentropy: the server
|
||||
* needs the full set of event ids matching the filter at the
|
||||
* moment the NEG-OPEN arrives, not a streamed/live result.
|
||||
*/
|
||||
suspend fun snapshotQuery(filter: Filter): List<Event> = store.query(filter)
|
||||
|
||||
/**
|
||||
* Multi-filter snapshot. Unions the per-filter results and
|
||||
* deduplicates by event id so an event matching N filters is
|
||||
* yielded once. Used by NIP-77 NEG-OPEN when the policy stack
|
||||
* rewrote the single incoming filter into several.
|
||||
*/
|
||||
suspend fun snapshotQuery(filters: List<Filter>): List<Event> {
|
||||
if (filters.size == 1) return snapshotQuery(filters[0])
|
||||
val seen = HashSet<String>()
|
||||
val merged = ArrayList<Event>()
|
||||
for (f in filters) {
|
||||
for (e in store.query<Event>(f)) {
|
||||
if (seen.add(e.id)) merged += e
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
}
|
||||
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.relay.server
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
|
||||
import com.vitorpamplona.quartz.nip77Negentropy.NegCloseCmd
|
||||
import com.vitorpamplona.quartz.nip77Negentropy.NegErrMessage
|
||||
import com.vitorpamplona.quartz.nip77Negentropy.NegMsgCmd
|
||||
import com.vitorpamplona.quartz.nip77Negentropy.NegOpenCmd
|
||||
import com.vitorpamplona.quartz.nip77Negentropy.NegentropyServerSession
|
||||
|
||||
/**
|
||||
* Per-connection NIP-77 negentropy state and dispatch.
|
||||
*
|
||||
* Owns the map of active reconciliation sessions keyed by NEG-OPEN
|
||||
* subId, and the open/msg/close handlers. Pulled out of [RelaySession]
|
||||
* so the connection class only routes commands while this class owns
|
||||
* the negentropy lifecycle and error mapping.
|
||||
*
|
||||
* Plain [HashMap] is sufficient because the registry is mutated only
|
||||
* from [RelaySession.receive] — that path is single-threaded per the
|
||||
* WebSocket handler contract.
|
||||
*/
|
||||
class NegSessionRegistry(
|
||||
private val store: LiveEventStore,
|
||||
private val send: (Message) -> Unit,
|
||||
) {
|
||||
private val sessions = HashMap<String, NegentropyServerSession>()
|
||||
|
||||
/**
|
||||
* Open a reconciliation session. The relay snapshots its matching
|
||||
* events at this instant — concurrent inserts during the sync are
|
||||
* not surfaced; clients re-open if they want fresh state.
|
||||
*
|
||||
* Access control reuses the REQ policy hook: a relay that requires
|
||||
* AUTH or has kind/pubkey allow-deny lists applies the same rules
|
||||
* to NEG-OPEN as it does to subscription REQs.
|
||||
*/
|
||||
suspend fun open(
|
||||
cmd: NegOpenCmd,
|
||||
policy: IRelayPolicy,
|
||||
) {
|
||||
val gate = policy.accept(ReqCmd(cmd.subId, listOf(cmd.filter)))
|
||||
if (gate is PolicyResult.Rejected) {
|
||||
send(NegErrMessage(cmd.subId, gate.reason))
|
||||
return
|
||||
}
|
||||
val filters = (gate as PolicyResult.Accepted).cmd.filters
|
||||
|
||||
// NIP-77: same-subId OPEN replaces any prior session.
|
||||
sessions.remove(cmd.subId)
|
||||
|
||||
val events = store.snapshotQuery(filters)
|
||||
val session = NegentropyServerSession(cmd.subId, events)
|
||||
sessions[cmd.subId] = session
|
||||
|
||||
runMessage(cmd.subId, session) { it.processMessage(cmd.initialMessage) }
|
||||
}
|
||||
|
||||
fun msg(cmd: NegMsgCmd) {
|
||||
val session = sessions[cmd.subId]
|
||||
if (session == null) {
|
||||
send(NegErrMessage(cmd.subId, "error: no negentropy session for ${cmd.subId}"))
|
||||
return
|
||||
}
|
||||
runMessage(cmd.subId, session) { it.processMessage(cmd.message) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Spec: clients send NEG-CLOSE to free server-side state.
|
||||
* Silent no-op if the session is unknown — there's no authoritative
|
||||
* error response in NIP-77 for an unknown close.
|
||||
*/
|
||||
fun close(cmd: NegCloseCmd) {
|
||||
sessions.remove(cmd.subId)
|
||||
}
|
||||
|
||||
/** Dropped on `RelaySession.cancelAllSubscriptions`. */
|
||||
fun clear() {
|
||||
sessions.clear()
|
||||
}
|
||||
|
||||
private inline fun runMessage(
|
||||
subId: String,
|
||||
session: NegentropyServerSession,
|
||||
block: (NegentropyServerSession) -> Message?,
|
||||
) {
|
||||
try {
|
||||
val response = block(session)
|
||||
if (response != null) send(response)
|
||||
} catch (e: Exception) {
|
||||
sessions.remove(subId)
|
||||
send(NegErrMessage(subId, "error: ${e.message ?: e::class.simpleName}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
-1
@@ -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<String, Job>()
|
||||
|
||||
/** 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.
|
||||
}
|
||||
}
|
||||
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.relay.server.inprocess
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.NostrServer
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* In-memory implementation of [WebSocket] that talks directly to a
|
||||
* [NostrServer] without touching the network. Each instance opens one
|
||||
* [RelaySession] on [connect] and routes:
|
||||
*
|
||||
* - Outbound (`send`) → server `RelaySession.receive()` via an inbound
|
||||
* channel drained by a single coroutine, preserving message order
|
||||
* per the [WebSocketListener] contract.
|
||||
* - Server-side `send` callbacks → [WebSocketListener.onMessage].
|
||||
*
|
||||
* Use this to wire a `NostrClient` to an embedded server in unit tests
|
||||
* or single-JVM scenarios without paying for a real TCP socket. Because
|
||||
* it implements [WebSocket], it slots into anywhere a `WebsocketBuilder`
|
||||
* expects.
|
||||
*
|
||||
* Reconnect-after-disconnect is supported: each [connect] creates a
|
||||
* fresh scope + drain channel so a previous [disconnect] (which
|
||||
* cancels both) doesn't leave a dead drainer behind.
|
||||
*/
|
||||
class InProcessWebSocket(
|
||||
private val server: NostrServer,
|
||||
private val out: WebSocketListener,
|
||||
) : WebSocket {
|
||||
private var scope: CoroutineScope? = null
|
||||
private var incoming: Channel<String>? = null
|
||||
private var drainJob: Job? = null
|
||||
private var session: RelaySession? = null
|
||||
|
||||
override fun needsReconnect(): Boolean = session == null
|
||||
|
||||
override fun connect() {
|
||||
if (session != null) return
|
||||
val newScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
val newIncoming = Channel<String>(UNLIMITED)
|
||||
val s = server.connect { json -> out.onMessage(json) }
|
||||
|
||||
scope = newScope
|
||||
incoming = newIncoming
|
||||
session = s
|
||||
drainJob =
|
||||
newScope.launch {
|
||||
for (msg in newIncoming) {
|
||||
s.receive(msg)
|
||||
}
|
||||
}
|
||||
|
||||
out.onOpen(0, false)
|
||||
}
|
||||
|
||||
override fun disconnect() {
|
||||
val s = session ?: return
|
||||
session = null
|
||||
incoming?.close()
|
||||
incoming = null
|
||||
drainJob = null
|
||||
scope?.cancel()
|
||||
scope = null
|
||||
s.close()
|
||||
out.onClosed(1000, "client disconnect")
|
||||
}
|
||||
|
||||
override fun send(msg: String): Boolean {
|
||||
// Capture the current channel reference: we want to fail
|
||||
// (return false) if the socket was disconnected, even if a
|
||||
// racing thread is mid-`connect()`.
|
||||
val ch = incoming ?: return false
|
||||
return ch.trySend(msg).isSuccess
|
||||
}
|
||||
}
|
||||
+6
-23
@@ -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()
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.relay.server.policies
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult
|
||||
|
||||
/**
|
||||
* Operator-controlled kind allow/deny list. Mirrors nostr-rs-relay's
|
||||
* `[authorization].kind_whitelist` / `kind_blacklist`.
|
||||
*
|
||||
* - When [allow] is non-empty, only events whose kind is in [allow]
|
||||
* are accepted; everything else is rejected.
|
||||
* - When [deny] is non-empty, events whose kind is in [deny] are
|
||||
* rejected.
|
||||
* - Both lists may be empty (no-op pass-through).
|
||||
* - When both are set, allow is checked first (deny inside allow is
|
||||
* still denied, matching nostr-rs-relay's precedence).
|
||||
*/
|
||||
class KindAllowDenyPolicy(
|
||||
val allow: Set<Int> = emptySet(),
|
||||
val deny: Set<Int> = emptySet(),
|
||||
) : PassThroughPolicy() {
|
||||
override fun accept(cmd: EventCmd): PolicyResult<EventCmd> {
|
||||
val k = cmd.event.kind
|
||||
if (allow.isNotEmpty() && k !in allow) {
|
||||
return PolicyResult.Rejected("blocked: kind $k not allowed")
|
||||
}
|
||||
if (k in deny) {
|
||||
return PolicyResult.Rejected("blocked: kind $k denied")
|
||||
}
|
||||
return PolicyResult.Accepted(cmd)
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.relay.server.policies
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.IRelayPolicy
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult
|
||||
|
||||
/**
|
||||
* Convenience base that accepts everything by default. Subclasses
|
||||
* override only the hook(s) they actually enforce so the call sites
|
||||
* stay readable.
|
||||
*
|
||||
* Concrete (not abstract) so [EmptyPolicy] can subclass it as a
|
||||
* singleton and external code that just wants a no-op policy can
|
||||
* instantiate this directly.
|
||||
*/
|
||||
open class PassThroughPolicy : IRelayPolicy {
|
||||
override fun onConnect(send: (Message) -> Unit) {}
|
||||
|
||||
override fun accept(cmd: EventCmd): PolicyResult<EventCmd> = PolicyResult.Accepted(cmd)
|
||||
|
||||
override fun accept(cmd: ReqCmd): PolicyResult<ReqCmd> = PolicyResult.Accepted(cmd)
|
||||
|
||||
override fun accept(cmd: CountCmd): PolicyResult<CountCmd> = PolicyResult.Accepted(cmd)
|
||||
|
||||
override fun accept(cmd: AuthCmd): PolicyResult<AuthCmd> = PolicyResult.Accepted(cmd)
|
||||
|
||||
override fun canSendToSession(event: Event): Boolean = true
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.relay.server.policies
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult
|
||||
|
||||
/**
|
||||
* Operator-controlled author allow/deny list. Mirrors nostr-rs-relay's
|
||||
* `[authorization].pubkey_whitelist` / `pubkey_blacklist`.
|
||||
*
|
||||
* - [allow] non-empty: only events from listed pubkeys are accepted.
|
||||
* This is the "private relay" mode.
|
||||
* - [deny] non-empty: events from listed pubkeys are rejected.
|
||||
* - Empty lists are no-op pass-through.
|
||||
* - When both are set, allow is checked first.
|
||||
*
|
||||
* Pubkeys are matched case-insensitively (lowercased on entry).
|
||||
*/
|
||||
class PubkeyAllowDenyPolicy(
|
||||
allow: Set<HexKey> = emptySet(),
|
||||
deny: Set<HexKey> = emptySet(),
|
||||
) : PassThroughPolicy() {
|
||||
private val allow = allow.mapTo(HashSet()) { it.lowercase() }
|
||||
private val deny = deny.mapTo(HashSet()) { it.lowercase() }
|
||||
|
||||
override fun accept(cmd: EventCmd): PolicyResult<EventCmd> {
|
||||
val pk = cmd.event.pubKey.lowercase()
|
||||
if (allow.isNotEmpty() && pk !in allow) {
|
||||
return PolicyResult.Rejected("blocked: pubkey not on allow list")
|
||||
}
|
||||
if (pk in deny) {
|
||||
return PolicyResult.Rejected("blocked: pubkey is denied")
|
||||
}
|
||||
return PolicyResult.Accepted(cmd)
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.relay.server.policies
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* Rejects events whose `created_at` is more than [maxFutureSeconds]
|
||||
* seconds in the future relative to the relay's clock. Mirrors
|
||||
* nostr-rs-relay's `[options].reject_future_seconds`.
|
||||
*
|
||||
* Catches both clock-skew accidents and intentional far-future
|
||||
* timestamps used to push events to the top of newest-first feeds.
|
||||
*
|
||||
* The current time is read from [TimeUtils.now] (epoch seconds), the
|
||||
* same source the [com.vitorpamplona.quartz.nip40Expiration.isExpired]
|
||||
* check uses, so the relay's "future" and "expired" decisions agree.
|
||||
*/
|
||||
class RejectFutureEventsPolicy(
|
||||
val maxFutureSeconds: Int,
|
||||
private val now: () -> Long = { TimeUtils.now() },
|
||||
) : PassThroughPolicy() {
|
||||
init {
|
||||
require(maxFutureSeconds >= 0) { "maxFutureSeconds must be >= 0, got $maxFutureSeconds" }
|
||||
}
|
||||
|
||||
override fun accept(cmd: EventCmd): PolicyResult<EventCmd> {
|
||||
val skew = cmd.event.createdAt - now()
|
||||
return if (skew > maxFutureSeconds) {
|
||||
PolicyResult.Rejected("invalid: created_at is $skew seconds in the future (max $maxFutureSeconds)")
|
||||
} else {
|
||||
PolicyResult.Accepted(cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -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<RelayInformationFee>? = null,
|
||||
val subscription: List<RelayInformationFee>? = null,
|
||||
val publication: List<RelayInformationFee>? = 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<Int>? = null,
|
||||
val time: Int? = null,
|
||||
val count: Int? = null,
|
||||
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip86RelayManagement.server
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PassThroughPolicy
|
||||
|
||||
/**
|
||||
* Reads the live [BanStore] on every EVENT and rejects events that
|
||||
* violate any of: banned-event-id, banned-pubkey, missing from a
|
||||
* non-empty pubkey allow list, or kind disallowed / not in the kind
|
||||
* allow list.
|
||||
*
|
||||
* This is the runtime-mutable counterpart of the static
|
||||
* [com.vitorpamplona.quartz.nip01Core.relay.server.policies.KindAllowDenyPolicy] +
|
||||
* [com.vitorpamplona.quartz.nip01Core.relay.server.policies.PubkeyAllowDenyPolicy] —
|
||||
* both layers compose: the event must clear all stacked policies.
|
||||
* NIP-86 admin RPC mutations land in the [BanStore]; the static
|
||||
* policies stay frozen at boot-time config values.
|
||||
*/
|
||||
class BanListPolicy(
|
||||
val banStore: BanStore,
|
||||
) : PassThroughPolicy() {
|
||||
override fun accept(cmd: EventCmd): PolicyResult<EventCmd> {
|
||||
val ev = cmd.event
|
||||
if (banStore.isBannedEvent(ev.id)) {
|
||||
return PolicyResult.Rejected("blocked: event id is banned")
|
||||
}
|
||||
if (banStore.isBanned(ev.pubKey)) {
|
||||
return PolicyResult.Rejected("blocked: pubkey is banned")
|
||||
}
|
||||
if (banStore.hasAllowList() && !banStore.isAllowedPubkey(ev.pubKey)) {
|
||||
return PolicyResult.Rejected("blocked: pubkey is not on the allow list")
|
||||
}
|
||||
if (!banStore.isKindAllowed(ev.kind)) {
|
||||
return PolicyResult.Rejected("blocked: kind ${ev.kind} not allowed")
|
||||
}
|
||||
return PolicyResult.Accepted(cmd)
|
||||
}
|
||||
}
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip86RelayManagement.server
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlin.concurrent.atomics.AtomicReference
|
||||
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||
|
||||
/**
|
||||
* Lock-free runtime state for the NIP-86 management API. Holds the
|
||||
* ban/allow lists that [BanListPolicy] consults on every accept call,
|
||||
* plus an [onMutation] hook so the relay can persist the latest
|
||||
* snapshot whenever an admin RPC mutates state.
|
||||
*
|
||||
* Each entry carries an optional reason string so list-* RPCs can echo
|
||||
* back why an admin took the action — useful for audit trails.
|
||||
*
|
||||
* Persistence is intentionally NOT inside this class; supply
|
||||
* [onMutation] to flush to disk (or wherever) and use [seedFromSnapshot]
|
||||
* at boot to load. `null` keeps the store in-memory only.
|
||||
*
|
||||
* Concurrency: state is held in a single [AtomicReference] and mutated
|
||||
* via copy-on-write CAS loops. Reads are wait-free single-load atomic.
|
||||
* The data structures are tiny (operator-controlled) so the per-write
|
||||
* map copy is negligible.
|
||||
*/
|
||||
@OptIn(ExperimentalAtomicApi::class)
|
||||
class BanStore(
|
||||
private val onMutation: (() -> Unit)? = null,
|
||||
) {
|
||||
/**
|
||||
* Single immutable snapshot of all ban/allow state. Combined into
|
||||
* one object so kind allow/disallow lock-step (allow adds to
|
||||
* allowedKinds AND removes from disallowedKinds) is naturally
|
||||
* atomic — no possibility of an interleaved reader observing a
|
||||
* kind in both sets.
|
||||
*/
|
||||
private data class State(
|
||||
val bannedPubkeys: Map<HexKey, String?> = emptyMap(),
|
||||
val allowedPubkeys: Map<HexKey, String?> = emptyMap(),
|
||||
val bannedEventIds: Map<HexKey, String?> = emptyMap(),
|
||||
val allowedKinds: Set<Int> = emptySet(),
|
||||
val disallowedKinds: Set<Int> = emptySet(),
|
||||
)
|
||||
|
||||
private val state = AtomicReference(State())
|
||||
|
||||
private inline fun mutate(transform: (State) -> State) {
|
||||
while (true) {
|
||||
val current = state.load()
|
||||
if (state.compareAndSet(current, transform(current))) break
|
||||
}
|
||||
onMutation?.invoke()
|
||||
}
|
||||
|
||||
// -- Pubkey ban list -----------------------------------------------------
|
||||
|
||||
fun banPubkey(
|
||||
pubkey: HexKey,
|
||||
reason: String? = null,
|
||||
) = mutate { it.copy(bannedPubkeys = it.bannedPubkeys + (pubkey.lowercase() to reason)) }
|
||||
|
||||
fun unbanPubkey(pubkey: HexKey) = mutate { it.copy(bannedPubkeys = it.bannedPubkeys - pubkey.lowercase()) }
|
||||
|
||||
fun isBanned(pubkey: HexKey): Boolean = pubkey.lowercase() in state.load().bannedPubkeys
|
||||
|
||||
fun listBannedPubkeys(): List<Pair<HexKey, String?>> =
|
||||
state
|
||||
.load()
|
||||
.bannedPubkeys.entries
|
||||
.map { it.key to it.value }
|
||||
|
||||
// -- Pubkey allow list ---------------------------------------------------
|
||||
|
||||
fun allowPubkey(
|
||||
pubkey: HexKey,
|
||||
reason: String? = null,
|
||||
) = mutate { it.copy(allowedPubkeys = it.allowedPubkeys + (pubkey.lowercase() to reason)) }
|
||||
|
||||
fun unallowPubkey(pubkey: HexKey) = mutate { it.copy(allowedPubkeys = it.allowedPubkeys - pubkey.lowercase()) }
|
||||
|
||||
fun isAllowedPubkey(pubkey: HexKey): Boolean = pubkey.lowercase() in state.load().allowedPubkeys
|
||||
|
||||
fun listAllowedPubkeys(): List<Pair<HexKey, String?>> =
|
||||
state
|
||||
.load()
|
||||
.allowedPubkeys.entries
|
||||
.map { it.key to it.value }
|
||||
|
||||
fun hasAllowList(): Boolean = state.load().allowedPubkeys.isNotEmpty()
|
||||
|
||||
// -- Event id ban list ---------------------------------------------------
|
||||
|
||||
fun banEvent(
|
||||
eventId: HexKey,
|
||||
reason: String? = null,
|
||||
) = mutate { it.copy(bannedEventIds = it.bannedEventIds + (eventId.lowercase() to reason)) }
|
||||
|
||||
/** Removes an event id from the ban list. Mirrors NIP-86 `allowevent`. */
|
||||
fun allowEvent(eventId: HexKey) = mutate { it.copy(bannedEventIds = it.bannedEventIds - eventId.lowercase()) }
|
||||
|
||||
fun isBannedEvent(eventId: HexKey): Boolean = eventId.lowercase() in state.load().bannedEventIds
|
||||
|
||||
fun listBannedEvents(): List<Pair<HexKey, String?>> =
|
||||
state
|
||||
.load()
|
||||
.bannedEventIds.entries
|
||||
.map { it.key to it.value }
|
||||
|
||||
// -- Kind allow / deny --------------------------------------------------
|
||||
|
||||
/**
|
||||
* `allowKind` and `disallowKind` are symmetric: each adds to its
|
||||
* own set AND removes the kind from the opposite set. Otherwise
|
||||
* an `allowKind(K)` after a `disallowKind(K)` would leave K in
|
||||
* both sets and stay blocked, surprising operators.
|
||||
*/
|
||||
fun allowKind(kind: Int) =
|
||||
mutate {
|
||||
it.copy(
|
||||
allowedKinds = it.allowedKinds + kind,
|
||||
disallowedKinds = it.disallowedKinds - kind,
|
||||
)
|
||||
}
|
||||
|
||||
fun disallowKind(kind: Int) =
|
||||
mutate {
|
||||
it.copy(
|
||||
allowedKinds = it.allowedKinds - kind,
|
||||
disallowedKinds = it.disallowedKinds + kind,
|
||||
)
|
||||
}
|
||||
|
||||
fun listAllowedKinds(): List<Int> = state.load().allowedKinds.sorted()
|
||||
|
||||
fun listDisallowedKinds(): List<Int> = state.load().disallowedKinds.sorted()
|
||||
|
||||
fun isKindAllowed(kind: Int): Boolean {
|
||||
val s = state.load()
|
||||
if (kind in s.disallowedKinds) return false
|
||||
if (s.allowedKinds.isEmpty()) return true
|
||||
return kind in s.allowedKinds
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-load state without firing [onMutation]. Used at startup to
|
||||
* seed the in-memory state from a persisted snapshot — we don't
|
||||
* want every individual `put` to trigger another disk write. After
|
||||
* this call the store behaves exactly as if every entry had been
|
||||
* mutated through the public API.
|
||||
*/
|
||||
fun seedFromSnapshot(
|
||||
bannedPubkeys: List<Pair<HexKey, String?>> = emptyList(),
|
||||
allowedPubkeys: List<Pair<HexKey, String?>> = emptyList(),
|
||||
bannedEvents: List<Pair<HexKey, String?>> = emptyList(),
|
||||
allowedKinds: List<Int> = emptyList(),
|
||||
disallowedKinds: List<Int> = emptyList(),
|
||||
) {
|
||||
state.store(
|
||||
State(
|
||||
bannedPubkeys = bannedPubkeys.associate { (k, r) -> k.lowercase() to r },
|
||||
allowedPubkeys = allowedPubkeys.associate { (k, r) -> k.lowercase() to r },
|
||||
bannedEventIds = bannedEvents.associate { (k, r) -> k.lowercase() to r },
|
||||
allowedKinds = allowedKinds.toSet(),
|
||||
disallowedKinds = disallowedKinds.toSet(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+256
@@ -0,0 +1,256 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip86RelayManagement.server
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
|
||||
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
|
||||
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.AllowedPubkey
|
||||
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedEvent
|
||||
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedPubkey
|
||||
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Method
|
||||
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request
|
||||
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Response
|
||||
import com.vitorpamplona.quartz.utils.Hex
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.int
|
||||
|
||||
/**
|
||||
* Server-side dispatcher for the NIP-86 relay management API.
|
||||
*
|
||||
* Holds the [BanStore] (mutated by ban/allow methods), an [InfoHolder]
|
||||
* for the live NIP-11 doc (mutated by `changerelay*` methods, which
|
||||
* atomically swap it), and an optional [IEventStore] so `banevent`
|
||||
* can also delete the offending event from the store.
|
||||
*
|
||||
* Transport-agnostic — relay implementations call [dispatch] from
|
||||
* whatever HTTP route they expose (e.g. POST `application/nostr+json+rpc`),
|
||||
* and in-process tests can build a [Nip86Request] directly.
|
||||
*
|
||||
* [supportedMethods] is the canonical list this server actually
|
||||
* implements; methods returned outside of it are no-ops and a NIP-86
|
||||
* client must not advertise them.
|
||||
*/
|
||||
class Nip86Server(
|
||||
val banStore: BanStore,
|
||||
/**
|
||||
* Read-write access to the relay's NIP-11 info doc. The dispatcher
|
||||
* mutates this when an admin calls `changerelayname` /
|
||||
* `changerelaydescription` / `changerelayicon`. Relay code reading
|
||||
* the doc (e.g. the NIP-11 endpoint) must consult this object on
|
||||
* every request, not cache it.
|
||||
*/
|
||||
private val infoHolder: InfoHolder,
|
||||
private val store: IEventStore? = null,
|
||||
) {
|
||||
/** Pluggable container so the relay's NIP-11 doc can be swapped at runtime. */
|
||||
interface InfoHolder {
|
||||
fun get(): Nip11RelayInformation
|
||||
|
||||
fun set(info: Nip11RelayInformation)
|
||||
}
|
||||
|
||||
val supportedMethods: List<String> =
|
||||
listOf(
|
||||
Nip86Method.SUPPORTED_METHODS,
|
||||
Nip86Method.BAN_PUBKEY,
|
||||
Nip86Method.UNBAN_PUBKEY,
|
||||
Nip86Method.LIST_BANNED_PUBKEYS,
|
||||
Nip86Method.ALLOW_PUBKEY,
|
||||
Nip86Method.UNALLOW_PUBKEY,
|
||||
Nip86Method.LIST_ALLOWED_PUBKEYS,
|
||||
Nip86Method.BAN_EVENT,
|
||||
Nip86Method.ALLOW_EVENT,
|
||||
Nip86Method.LIST_BANNED_EVENTS,
|
||||
Nip86Method.ALLOW_KIND,
|
||||
Nip86Method.DISALLOW_KIND,
|
||||
Nip86Method.LIST_ALLOWED_KINDS,
|
||||
Nip86Method.CHANGE_RELAY_NAME,
|
||||
Nip86Method.CHANGE_RELAY_DESCRIPTION,
|
||||
Nip86Method.CHANGE_RELAY_ICON,
|
||||
)
|
||||
|
||||
/**
|
||||
* Dispatches a single RPC request. Synchronous-looking but does
|
||||
* suspend internally for the `banevent` event-store delete path.
|
||||
*/
|
||||
suspend fun dispatch(req: Nip86Request): Nip86Response =
|
||||
runCatching {
|
||||
when (req.method) {
|
||||
Nip86Method.SUPPORTED_METHODS -> {
|
||||
ok(buildJsonArray { supportedMethods.forEach { add(JsonPrimitive(it)) } })
|
||||
}
|
||||
|
||||
Nip86Method.BAN_PUBKEY -> {
|
||||
withHexAndReason(req, "pubkey") { pk, reason -> banStore.banPubkey(pk, reason) }
|
||||
}
|
||||
|
||||
Nip86Method.UNBAN_PUBKEY -> {
|
||||
withHex(req, "pubkey") { pk -> banStore.unbanPubkey(pk) }
|
||||
}
|
||||
|
||||
Nip86Method.LIST_BANNED_PUBKEYS -> {
|
||||
ok(banStore.listBannedPubkeys().map { (pk, r) -> BannedPubkey(pk, r) }.toJsonArray(BannedPubkey.serializer()))
|
||||
}
|
||||
|
||||
Nip86Method.ALLOW_PUBKEY -> {
|
||||
withHexAndReason(req, "pubkey") { pk, reason -> banStore.allowPubkey(pk, reason) }
|
||||
}
|
||||
|
||||
Nip86Method.UNALLOW_PUBKEY -> {
|
||||
withHex(req, "pubkey") { pk -> banStore.unallowPubkey(pk) }
|
||||
}
|
||||
|
||||
Nip86Method.LIST_ALLOWED_PUBKEYS -> {
|
||||
ok(banStore.listAllowedPubkeys().map { (pk, r) -> AllowedPubkey(pk, r) }.toJsonArray(AllowedPubkey.serializer()))
|
||||
}
|
||||
|
||||
Nip86Method.BAN_EVENT -> {
|
||||
withHexAndReason(req, "event_id") { id, reason ->
|
||||
banStore.banEvent(id, reason)
|
||||
// Also remove the event from the store if present.
|
||||
store?.delete(Filter(ids = listOf(id)))
|
||||
}
|
||||
}
|
||||
|
||||
Nip86Method.ALLOW_EVENT -> {
|
||||
withHex(req, "event_id") { id -> banStore.allowEvent(id) }
|
||||
}
|
||||
|
||||
Nip86Method.LIST_BANNED_EVENTS -> {
|
||||
ok(banStore.listBannedEvents().map { (id, r) -> BannedEvent(id, r) }.toJsonArray(BannedEvent.serializer()))
|
||||
}
|
||||
|
||||
Nip86Method.ALLOW_KIND -> {
|
||||
withInt(req, "kind") { k -> banStore.allowKind(k) }
|
||||
}
|
||||
|
||||
Nip86Method.DISALLOW_KIND -> {
|
||||
withInt(req, "kind") { k -> banStore.disallowKind(k) }
|
||||
}
|
||||
|
||||
Nip86Method.LIST_ALLOWED_KINDS -> {
|
||||
ok(buildJsonArray { banStore.listAllowedKinds().forEach { add(JsonPrimitive(it)) } })
|
||||
}
|
||||
|
||||
Nip86Method.CHANGE_RELAY_NAME -> {
|
||||
withString(req, "name") { name -> rewriteInfo { it.copy(name = name) } }
|
||||
}
|
||||
|
||||
Nip86Method.CHANGE_RELAY_DESCRIPTION -> {
|
||||
withString(req, "description") { desc -> rewriteInfo { it.copy(description = desc) } }
|
||||
}
|
||||
|
||||
Nip86Method.CHANGE_RELAY_ICON -> {
|
||||
withString(req, "icon_url") { icon -> rewriteInfo { it.copy(icon = icon) } }
|
||||
}
|
||||
|
||||
else -> {
|
||||
Nip86Response(error = "method not supported: ${req.method}")
|
||||
}
|
||||
}
|
||||
}.getOrElse { e ->
|
||||
// CancellationException must propagate so structured
|
||||
// concurrency works — swallowing it would let a parent
|
||||
// cancellation be reported as a benign RPC error.
|
||||
if (e is CancellationException) throw e
|
||||
Nip86Response(error = "internal: ${e.message ?: e::class.simpleName}")
|
||||
}
|
||||
|
||||
private inline fun withHex(
|
||||
req: Nip86Request,
|
||||
label: String,
|
||||
action: (String) -> Unit,
|
||||
): Nip86Response {
|
||||
val (value, _) = req.params.stringPair() ?: return malformed("expected [$label]")
|
||||
if (!Hex.isHex64(value)) return malformed("$label must be 64-char hex")
|
||||
action(value)
|
||||
return okTrue
|
||||
}
|
||||
|
||||
private suspend inline fun withHexAndReason(
|
||||
req: Nip86Request,
|
||||
label: String,
|
||||
action: suspend (String, String?) -> Unit,
|
||||
): Nip86Response {
|
||||
val (value, reason) = req.params.stringPair() ?: return malformed("expected [$label, reason?]")
|
||||
if (!Hex.isHex64(value)) return malformed("$label must be 64-char hex")
|
||||
action(value, reason)
|
||||
return okTrue
|
||||
}
|
||||
|
||||
private inline fun withInt(
|
||||
req: Nip86Request,
|
||||
label: String,
|
||||
action: (Int) -> Unit,
|
||||
): Nip86Response {
|
||||
val v = req.params.firstInt() ?: return malformed("expected [$label]")
|
||||
action(v)
|
||||
return okTrue
|
||||
}
|
||||
|
||||
private inline fun withString(
|
||||
req: Nip86Request,
|
||||
label: String,
|
||||
action: (String) -> Unit,
|
||||
): Nip86Response {
|
||||
val v = req.params.firstString() ?: return malformed("expected [$label]")
|
||||
action(v)
|
||||
return okTrue
|
||||
}
|
||||
|
||||
private fun rewriteInfo(transform: (Nip11RelayInformation) -> Nip11RelayInformation) {
|
||||
infoHolder.set(transform(infoHolder.get()))
|
||||
}
|
||||
}
|
||||
|
||||
private fun malformed(reason: String) = Nip86Response(error = "invalid params: $reason")
|
||||
|
||||
private fun ok(j: JsonElement) = Nip86Response(result = j, error = null)
|
||||
|
||||
private val okTrue = ok(JsonPrimitive(true))
|
||||
|
||||
private val rpcJson = Json { encodeDefaults = false }
|
||||
|
||||
private fun <T> List<T>.toJsonArray(serializer: KSerializer<T>): JsonElement = rpcJson.encodeToJsonElement(ListSerializer(serializer), this)
|
||||
|
||||
private fun JsonArray.stringPair(): Pair<String, String?>? {
|
||||
val first = (getOrNull(0) as? JsonPrimitive)?.contentOrNull() ?: return null
|
||||
val second = (getOrNull(1) as? JsonPrimitive)?.contentOrNull()
|
||||
return first to second
|
||||
}
|
||||
|
||||
private fun JsonArray.firstString(): String? = (getOrNull(0) as? JsonPrimitive)?.contentOrNull()
|
||||
|
||||
private fun JsonArray.firstInt(): Int? =
|
||||
runCatching {
|
||||
(this[0] as? JsonPrimitive)?.int
|
||||
}.getOrNull()
|
||||
|
||||
private fun JsonPrimitive.contentOrNull(): String? = if (this == JsonNull) null else content
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip98HttpAuth
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.verify
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import com.vitorpamplona.quartz.utils.sha256.sha256
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* Server-side counterpart to [HTTPAuthorizationEvent]. Verifies a
|
||||
* NIP-98 `Authorization: Nostr <base64-event>` header.
|
||||
*
|
||||
* NIP-98 reuses kind 27235 events with `u`, `method`, and (for bodies)
|
||||
* `payload` tags. Verification checks:
|
||||
* 1. Header is `Nostr <base64>`.
|
||||
* 2. Decoded body is a kind-27235 event with a valid Schnorr signature.
|
||||
* 3. The event's `created_at` is within ±[toleranceSeconds] of now.
|
||||
* 4. The `method` tag matches the HTTP method.
|
||||
* 5. The `u` tag matches the requested URL.
|
||||
* 6. If a body is present, the `payload` tag matches `sha256(body)` hex.
|
||||
*
|
||||
* Returns the verified pubkey on success; a [Result.Malformed] /
|
||||
* [Result.Missing] otherwise (the caller turns these into 401/403).
|
||||
*/
|
||||
class Nip98AuthVerifier(
|
||||
private val now: () -> Long = { TimeUtils.now() },
|
||||
/** Allowed clock skew in seconds. NIP-98 says 60. */
|
||||
private val toleranceSeconds: Long = 60,
|
||||
) {
|
||||
/**
|
||||
* Recently-accepted event ids → expiry epoch second. Bounded to
|
||||
* [MAX_REPLAY_ENTRIES] (LRU eviction); each entry expires after
|
||||
* `2 × toleranceSeconds` (twice the accepted window so a token
|
||||
* can't be reused by an attacker who buffers across the boundary).
|
||||
*
|
||||
* Guarded by [seenLock] so the eviction sweep + insertion are
|
||||
* atomic. We use a coroutine [Mutex] so the type works in KMP
|
||||
* commonMain (no `synchronized` block).
|
||||
*/
|
||||
private val seenEventIds: LinkedHashMap<String, Long> =
|
||||
object : LinkedHashMap<String, Long>(64, 0.75f, true) {
|
||||
override fun removeEldestEntry(eldest: Map.Entry<String, Long>?): Boolean = size > MAX_REPLAY_ENTRIES
|
||||
}
|
||||
|
||||
private val seenLock = Mutex()
|
||||
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
suspend fun verify(
|
||||
authorizationHeader: String?,
|
||||
method: String,
|
||||
url: String,
|
||||
body: ByteArray?,
|
||||
): Result {
|
||||
if (authorizationHeader.isNullOrBlank()) return Result.Missing
|
||||
if (!authorizationHeader.startsWith(SCHEME)) return Result.Malformed("expected '$SCHEME <base64>' header")
|
||||
|
||||
val token = authorizationHeader.substring(SCHEME.length).trim()
|
||||
val json =
|
||||
try {
|
||||
Base64.decode(token).decodeToString()
|
||||
} catch (_: IllegalArgumentException) {
|
||||
return Result.Malformed("token is not valid base64")
|
||||
}
|
||||
|
||||
val event =
|
||||
try {
|
||||
OptimizedJsonMapper.fromJson(json)
|
||||
} catch (_: Exception) {
|
||||
return Result.Malformed("token does not decode to a Nostr event")
|
||||
}
|
||||
|
||||
if (event.kind != HTTPAuthorizationEvent.KIND) {
|
||||
return Result.Malformed("event kind ${event.kind} != ${HTTPAuthorizationEvent.KIND}")
|
||||
}
|
||||
if (!event.verify()) return Result.Malformed("bad event signature or id")
|
||||
|
||||
val nowSec = now()
|
||||
val skew = abs(event.createdAt - nowSec)
|
||||
if (skew > toleranceSeconds) {
|
||||
return Result.Malformed("created_at is ${skew}s away from now (max ${toleranceSeconds}s)")
|
||||
}
|
||||
|
||||
// Re-wrap as the typed event so the tag accessors work.
|
||||
val auth =
|
||||
HTTPAuthorizationEvent(
|
||||
event.id,
|
||||
event.pubKey,
|
||||
event.createdAt,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.sig,
|
||||
)
|
||||
|
||||
if (!auth.method().equals(method, ignoreCase = true)) {
|
||||
return Result.Malformed("method mismatch: expected $method, got ${auth.method()}")
|
||||
}
|
||||
if (auth.url() != url) {
|
||||
return Result.Malformed("url mismatch: expected $url, got ${auth.url()}")
|
||||
}
|
||||
if (body != null && body.isNotEmpty()) {
|
||||
val expected = sha256(body).toHexKey()
|
||||
if (auth.payloadHash() != expected) {
|
||||
return Result.Malformed("payload hash mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
// Replay check — done LAST so we don't burn a one-shot id on a
|
||||
// request that would otherwise have failed signature/url/etc.
|
||||
val expiry = nowSec + 2 * toleranceSeconds
|
||||
seenLock.withLock {
|
||||
// Evict expired entries while we hold the lock. Insertion
|
||||
// order (LinkedHashMap default) tracks expiry order
|
||||
// because every entry's expiry = now + 2·tolerance, so
|
||||
// the first non-expired entry guarantees no later entry
|
||||
// is expired either.
|
||||
val it = seenEventIds.entries.iterator()
|
||||
while (it.hasNext()) {
|
||||
if (it.next().value <= nowSec) it.remove() else break
|
||||
}
|
||||
if (seenEventIds.put(event.id, expiry) != null) {
|
||||
return Result.Malformed("replay: this NIP-98 token has already been used")
|
||||
}
|
||||
}
|
||||
|
||||
return Result.Verified(event.pubKey)
|
||||
}
|
||||
|
||||
sealed interface Result {
|
||||
data class Verified(
|
||||
val pubkey: HexKey,
|
||||
) : Result
|
||||
|
||||
object Missing : Result
|
||||
|
||||
data class Malformed(
|
||||
val reason: String,
|
||||
) : Result
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val SCHEME = "Nostr "
|
||||
|
||||
/**
|
||||
* Cap on the in-memory replay-cache size. With a 60s tolerance
|
||||
* an attacker would need to push >MAX/120 verified requests per
|
||||
* second (one new id per ~120 ms) to evict legitimate entries.
|
||||
* 1024 is generous for an admin endpoint.
|
||||
*/
|
||||
const val MAX_REPLAY_ENTRIES = 1024
|
||||
}
|
||||
}
|
||||
+12
-12
@@ -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()
|
||||
|
||||
+3
-3
@@ -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<Event>(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()
|
||||
}
|
||||
|
||||
+4
-1
@@ -30,9 +30,12 @@ class CountResultSerializer : StdSerializer<CountResult>(CountResult::class.java
|
||||
gen: JsonGenerator,
|
||||
provider: SerializerProvider,
|
||||
) {
|
||||
// NIP-45 result object: { "count": <int>, "approximate": <bool>? }.
|
||||
gen.writeStartObject()
|
||||
gen.writeNumberField("count", result.count)
|
||||
gen.writeBooleanField("pubkey", result.approximate)
|
||||
if (result.approximate) {
|
||||
gen.writeBooleanField("approximate", true)
|
||||
}
|
||||
gen.writeEndObject()
|
||||
}
|
||||
}
|
||||
|
||||
+6
-4
@@ -50,11 +50,11 @@ class MessageSerializer : StdSerializer<Message>(Message::class.java) {
|
||||
}
|
||||
|
||||
is OkMessage -> {
|
||||
// NIP-01 wire format: ["OK", <event_id>, <true|false>, <message>]
|
||||
// The third element is a JSON boolean, not a string.
|
||||
gen.writeString(msg.eventId)
|
||||
gen.writeString(msg.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>(Message::class.java) {
|
||||
}
|
||||
|
||||
is CountMessage -> {
|
||||
// NIP-45 wire format: ["COUNT", <query_id>, <count_result>]
|
||||
gen.writeString(msg.queryId)
|
||||
countSerializer.serialize(msg.result, gen, provider)
|
||||
}
|
||||
|
||||
|
||||
-54
@@ -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 }
|
||||
}
|
||||
}
|
||||
+20
-18
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+10
-25
@@ -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<String>(UNLIMITED)
|
||||
val events = mutableListOf<String>()
|
||||
@@ -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 })
|
||||
|
||||
+38
-41
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+26
-50
@@ -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<String>(UNLIMITED)
|
||||
val events = mutableListOf<String>()
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
+43
-50
@@ -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<Event>()
|
||||
|
||||
// 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<Event>()
|
||||
val contactListEvents = mutableListOf<Event>()
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
+9
-25
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+12
-31
@@ -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<Event>.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<Event>()
|
||||
@@ -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<Event>()
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+6
-22
@@ -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<Event>(UNLIMITED)
|
||||
val events = mutableSetOf<Event>()
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+12
-32
@@ -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<Event>.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<Event>()
|
||||
@@ -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<Event>()
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+96
@@ -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')])
|
||||
}
|
||||
}
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip86RelayManagement.server
|
||||
|
||||
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
|
||||
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.AllowedPubkey
|
||||
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedEvent
|
||||
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedPubkey
|
||||
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Method
|
||||
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.boolean
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class Nip86ServerTest {
|
||||
private fun fixture(): Triple<Nip86Server, BanStore, Holder> {
|
||||
val store = BanStore()
|
||||
val holder = Holder(Nip11RelayInformation(name = "before", description = "before-desc"))
|
||||
val server = Nip86Server(banStore = store, infoHolder = holder, store = null)
|
||||
return Triple(server, store, holder)
|
||||
}
|
||||
|
||||
private class Holder(
|
||||
var current: Nip11RelayInformation,
|
||||
) : Nip86Server.InfoHolder {
|
||||
override fun get() = current
|
||||
|
||||
override fun set(info: Nip11RelayInformation) {
|
||||
current = info
|
||||
}
|
||||
}
|
||||
|
||||
private val pk = "a".repeat(64)
|
||||
private val pk2 = "b".repeat(64)
|
||||
private val eventId = "c".repeat(64)
|
||||
|
||||
@Test
|
||||
fun supportedMethodsRoundTrip() {
|
||||
runBlocking {
|
||||
val (server, _, _) = fixture()
|
||||
val resp = server.dispatch(Nip86Request.supportedMethods())
|
||||
assertNull(resp.error)
|
||||
val arr = resp.result as JsonArray
|
||||
val names = arr.map { it.jsonPrimitive.content }
|
||||
assertTrue(Nip86Method.SUPPORTED_METHODS in names)
|
||||
assertTrue(Nip86Method.BAN_PUBKEY in names)
|
||||
assertTrue(Nip86Method.CHANGE_RELAY_NAME in names)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun banPubkeyMutatesStoreAndListsRoundTripWithReason() {
|
||||
runBlocking {
|
||||
val (server, banStore, _) = fixture()
|
||||
|
||||
val ok = server.dispatch(Nip86Request.banPubkey(pk, "spam"))
|
||||
assertEquals(true, (ok.result as JsonPrimitive).boolean)
|
||||
assertTrue(banStore.isBanned(pk))
|
||||
|
||||
val list = server.dispatch(Nip86Request.listBannedPubkeys())
|
||||
val parsed =
|
||||
kotlinx.serialization.json.Json
|
||||
.decodeFromJsonElement(
|
||||
kotlinx.serialization.builtins.ListSerializer(BannedPubkey.serializer()),
|
||||
list.result as JsonArray,
|
||||
)
|
||||
assertEquals(1, parsed.size)
|
||||
assertEquals(pk, parsed[0].pubkey)
|
||||
assertEquals("spam", parsed[0].reason)
|
||||
|
||||
server.dispatch(Nip86Request.unbanPubkey(pk))
|
||||
assertTrue(banStore.listBannedPubkeys().isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun allowPubkeyAndListRoundTrip() {
|
||||
runBlocking {
|
||||
val (server, banStore, _) = fixture()
|
||||
server.dispatch(Nip86Request.allowPubkey(pk, "trusted"))
|
||||
server.dispatch(Nip86Request.allowPubkey(pk2))
|
||||
assertTrue(banStore.hasAllowList())
|
||||
|
||||
val resp = server.dispatch(Nip86Request.listAllowedPubkeys())
|
||||
val list =
|
||||
kotlinx.serialization.json.Json
|
||||
.decodeFromJsonElement(
|
||||
kotlinx.serialization.builtins.ListSerializer(AllowedPubkey.serializer()),
|
||||
resp.result as JsonArray,
|
||||
)
|
||||
assertEquals(2, list.size)
|
||||
assertEquals(setOf(pk, pk2), list.map { it.pubkey }.toSet())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun banEventMarksIdAndDeletesFromStoreWhenStorePresent() {
|
||||
runBlocking {
|
||||
val (server, banStore, _) = fixture()
|
||||
server.dispatch(Nip86Request.banEvent(eventId, "off-topic"))
|
||||
assertTrue(banStore.isBannedEvent(eventId))
|
||||
|
||||
val resp = server.dispatch(Nip86Request.listBannedEvents())
|
||||
val list =
|
||||
kotlinx.serialization.json.Json
|
||||
.decodeFromJsonElement(
|
||||
kotlinx.serialization.builtins.ListSerializer(BannedEvent.serializer()),
|
||||
resp.result as JsonArray,
|
||||
)
|
||||
assertEquals(1, list.size)
|
||||
assertEquals(eventId, list[0].id)
|
||||
assertEquals("off-topic", list[0].reason)
|
||||
|
||||
// allowevent (which is "unban") removes the entry.
|
||||
server.dispatch(Nip86Request.allowEvent(eventId))
|
||||
assertTrue(banStore.listBannedEvents().isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun allowKindAndDisallowKind() {
|
||||
runBlocking {
|
||||
val (server, banStore, _) = fixture()
|
||||
server.dispatch(Nip86Request.allowKind(1))
|
||||
server.dispatch(Nip86Request.allowKind(7))
|
||||
server.dispatch(Nip86Request.disallowKind(4))
|
||||
|
||||
val list = server.dispatch(Nip86Request.listAllowedKinds())
|
||||
val ints = (list.result as JsonArray).map { it.jsonPrimitive.int }
|
||||
assertEquals(listOf(1, 7), ints)
|
||||
|
||||
assertTrue(banStore.isKindAllowed(1))
|
||||
assertTrue(banStore.isKindAllowed(7))
|
||||
assertEquals(false, banStore.isKindAllowed(4))
|
||||
assertEquals(false, banStore.isKindAllowed(99))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun changeRelayNameDescriptionIconRewriteInfoDoc() {
|
||||
runBlocking {
|
||||
val (server, _, holder) = fixture()
|
||||
assertEquals("before", holder.current.name)
|
||||
|
||||
server.dispatch(Nip86Request.changeRelayName("after"))
|
||||
assertEquals("after", holder.current.name)
|
||||
|
||||
server.dispatch(Nip86Request.changeRelayDescription("nice relay"))
|
||||
assertEquals("nice relay", holder.current.description)
|
||||
|
||||
server.dispatch(Nip86Request.changeRelayIcon("https://x/icon.png"))
|
||||
assertEquals("https://x/icon.png", holder.current.icon)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unsupportedMethodReturnsError() {
|
||||
runBlocking {
|
||||
val (server, _, _) = fixture()
|
||||
val resp = server.dispatch(Nip86Request(method = "frobnicate"))
|
||||
assertNotNull(resp.error)
|
||||
assertTrue(resp.error!!.contains("frobnicate"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingParamsAreReportedAsErrors() {
|
||||
runBlocking {
|
||||
val (server, _, _) = fixture()
|
||||
// banpubkey requires at least one positional param.
|
||||
val resp = server.dispatch(Nip86Request(method = Nip86Method.BAN_PUBKEY))
|
||||
assertNotNull(resp.error)
|
||||
assertTrue(resp.error!!.startsWith("invalid params"))
|
||||
}
|
||||
}
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip98HttpAuth
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class Nip98AuthVerifierTest {
|
||||
private val verifier = Nip98AuthVerifier(now = { 1_000L })
|
||||
|
||||
private fun signedToken(
|
||||
url: String,
|
||||
method: String,
|
||||
body: ByteArray? = null,
|
||||
signer: NostrSignerSync = NostrSignerSync(KeyPair()),
|
||||
createdAt: Long = 1_000L,
|
||||
): Pair<String, String> {
|
||||
val template = HTTPAuthorizationEvent.build(url = url, method = method, file = body, createdAt = createdAt)
|
||||
val signed = signer.sign(template)
|
||||
return signer.pubKey to signed.toAuthToken()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun verifiesAValidPostWithBody() =
|
||||
runBlocking {
|
||||
val body = "hello".encodeToByteArray()
|
||||
val (pubkey, header) = signedToken("http://x/", "POST", body)
|
||||
val r = verifier.verify(header, "POST", "http://x/", body)
|
||||
assertIs<Nip98AuthVerifier.Result.Verified>(r)
|
||||
assertEquals(pubkey, r.pubkey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingHeaderReturnsMissing() {
|
||||
runBlocking {
|
||||
val r = verifier.verify(null, "POST", "http://x/", null)
|
||||
assertIs<Nip98AuthVerifier.Result.Missing>(r)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun wrongSchemeIsMalformed() {
|
||||
runBlocking {
|
||||
val r = verifier.verify("Bearer abc", "POST", "http://x/", null)
|
||||
assertIs<Nip98AuthVerifier.Result.Malformed>(r)
|
||||
assertTrue(r.reason.contains("Nostr"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun urlMismatchIsMalformed() {
|
||||
runBlocking {
|
||||
val (_, header) = signedToken("http://x/", "POST")
|
||||
val r = verifier.verify(header, "POST", "http://y/", null)
|
||||
assertIs<Nip98AuthVerifier.Result.Malformed>(r)
|
||||
assertTrue(r.reason.contains("url mismatch"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun methodMismatchIsMalformed() {
|
||||
runBlocking {
|
||||
val (_, header) = signedToken("http://x/", "POST")
|
||||
val r = verifier.verify(header, "GET", "http://x/", null)
|
||||
assertIs<Nip98AuthVerifier.Result.Malformed>(r)
|
||||
assertTrue(r.reason.contains("method mismatch"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun payloadHashMismatchIsMalformed() {
|
||||
runBlocking {
|
||||
val (_, header) = signedToken("http://x/", "POST", "alpha".encodeToByteArray())
|
||||
val r = verifier.verify(header, "POST", "http://x/", "beta".encodeToByteArray())
|
||||
assertIs<Nip98AuthVerifier.Result.Malformed>(r)
|
||||
assertTrue(r.reason.contains("payload hash"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun staleCreatedAtIsMalformed() {
|
||||
runBlocking {
|
||||
// Verifier's clock is fixed at 1_000; sign a token created 5
|
||||
// minutes earlier — outside the 60s tolerance.
|
||||
val (_, header) = signedToken("http://x/", "POST", createdAt = 1_000L - 600)
|
||||
val r = verifier.verify(header, "POST", "http://x/", null)
|
||||
assertIs<Nip98AuthVerifier.Result.Malformed>(r)
|
||||
assertTrue(r.reason.contains("created_at"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonAuthEventKindIsMalformed() {
|
||||
runBlocking {
|
||||
// Build a kind-1 event by hand and shove it into the header — it
|
||||
// must be rejected because NIP-98 specifically uses kind 27235.
|
||||
val signer = NostrSignerSync(KeyPair())
|
||||
val template =
|
||||
com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
.build("not an auth event")
|
||||
val signed = signer.sign(template)
|
||||
val token =
|
||||
"Nostr " +
|
||||
kotlin.io.encoding.Base64
|
||||
.encode(signed.toJson().encodeToByteArray())
|
||||
val r = verifier.verify(token, "POST", "http://x/", null)
|
||||
assertIs<Nip98AuthVerifier.Result.Malformed>(r)
|
||||
assertTrue(r.reason.contains("kind"))
|
||||
}
|
||||
}
|
||||
}
|
||||
+66
-38
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -327,6 +327,46 @@ class InMemoryQuicPipe(
|
||||
*/
|
||||
fun buildServerApplicationPacket(frames: List<com.vitorpamplona.quic.frame.Frame>): ByteArray? = buildServerApplicationDatagram(frames)
|
||||
|
||||
/**
|
||||
* Decrypt the application-level (1-RTT) packet inside a client-emitted
|
||||
* datagram and return its frames in wire order. Test-only helper for
|
||||
* assertions that depend on per-frame ordering inside a packet (e.g.
|
||||
* stream priority scheduling). Walks past any coalesced long-header
|
||||
* packets (Initial / Handshake) at the front of the datagram, since the
|
||||
* client may still flush ACKs at those levels post-handshake. Returns
|
||||
* null if no short-header packet is present or decryption fails.
|
||||
*/
|
||||
fun decryptClientApplicationFrames(datagram: ByteArray): List<com.vitorpamplona.quic.frame.Frame>? {
|
||||
if (datagram.isEmpty()) return null
|
||||
var offset = 0
|
||||
while (offset < datagram.size) {
|
||||
val first = datagram[offset].toInt() and 0xFF
|
||||
if ((first and 0x80) == 0) {
|
||||
val proto = serverApplicationRx ?: return null
|
||||
val parsed =
|
||||
ShortHeaderPacket.parseAndDecrypt(
|
||||
bytes = datagram,
|
||||
offset = offset,
|
||||
dcidLen = serverScid.length,
|
||||
aead = proto.aead,
|
||||
key = proto.key,
|
||||
iv = proto.iv,
|
||||
hp = proto.hp,
|
||||
hpKey = proto.hpKey,
|
||||
largestReceivedInSpace = applicationPnSpace.largestReceived,
|
||||
) ?: return null
|
||||
applicationPnSpace.observeInbound(parsed.packet.packetNumber, 0L)
|
||||
return decodeFrames(parsed.packet.payload)
|
||||
}
|
||||
// Long header — skip past it using the encoded length field so
|
||||
// we can inspect any short-header packet that was coalesced
|
||||
// after it.
|
||||
val peeked = LongHeaderPacket.peekHeader(datagram, offset) ?: return null
|
||||
offset += peeked.totalLength
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun buildServerInitialPacket(crypto: ByteArray): ByteArray {
|
||||
val proto =
|
||||
PacketProtection(
|
||||
|
||||
+80
@@ -110,6 +110,86 @@ class QuicConnectionWriterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun writer_drains_higher_priority_streams_before_lower_priority() {
|
||||
// T11.3 follow-up: priority must dominate iteration order on
|
||||
// EVERY drain, not just the first. The naive "sort by priority
|
||||
// then apply the existing rotating start globally" shape looks
|
||||
// right at a glance but the rotating start advances on every
|
||||
// drain, so cross-tier ordering flips on alternate drains and
|
||||
// the priority hint is silently defeated. The correct shape is
|
||||
// strict priority across tiers + round-robin only within a
|
||||
// tier, which we pin here by draining TWICE and asserting the
|
||||
// higher-priority stream's StreamFrame lands first in BOTH
|
||||
// packets. Low-priority stream is opened FIRST so insertion-
|
||||
// order can't accidentally pass for priority ordering.
|
||||
runBlocking {
|
||||
val (client, pipe) = connectedClient()
|
||||
val low = client.openBidiStream()
|
||||
val high = client.openBidiStream()
|
||||
low.priority = 0
|
||||
high.priority = 10
|
||||
|
||||
repeat(2) { round ->
|
||||
low.send.enqueue(ByteArray(200) { 0xAA.toByte() })
|
||||
high.send.enqueue(ByteArray(200) { 0xBB.toByte() })
|
||||
|
||||
val datagram = drainOutbound(client, nowMillis = 0L)
|
||||
assertNotNull(datagram, "drain on round $round must emit a packet")
|
||||
val frames = pipe.decryptClientApplicationFrames(datagram)
|
||||
assertNotNull(frames, "decrypt must succeed on round $round")
|
||||
val streamFrames = frames.filterIsInstance<StreamFrame>()
|
||||
assertEquals(
|
||||
2,
|
||||
streamFrames.size,
|
||||
"expected one StreamFrame per stream on round $round, got $streamFrames",
|
||||
)
|
||||
assertEquals(
|
||||
high.streamId,
|
||||
streamFrames[0].streamId,
|
||||
"higher-priority stream must drain first on round $round; " +
|
||||
"saw ${streamFrames.map { it.streamId }}",
|
||||
)
|
||||
assertEquals(low.streamId, streamFrames[1].streamId, "low second on round $round")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun writer_round_robins_within_a_priority_tier() {
|
||||
// Regression guard for the tier-local round-robin: same-priority
|
||||
// streams must still rotate so an early-opened stream doesn't
|
||||
// monopolise a packet's stream-frame slot indefinitely. We open
|
||||
// three streams at the default (0) priority and verify the
|
||||
// rotating start advances by one per drain, matching the
|
||||
// pre-priority behaviour.
|
||||
runBlocking {
|
||||
val (client, pipe) = connectedClient()
|
||||
val a = client.openBidiStream()
|
||||
val b = client.openBidiStream()
|
||||
val c = client.openBidiStream()
|
||||
// All default priority — single tier, three streams.
|
||||
val expectedRotation =
|
||||
listOf(
|
||||
listOf(a.streamId, b.streamId, c.streamId),
|
||||
listOf(b.streamId, c.streamId, a.streamId),
|
||||
listOf(c.streamId, a.streamId, b.streamId),
|
||||
)
|
||||
for ((round, expected) in expectedRotation.withIndex()) {
|
||||
a.send.enqueue(ByteArray(64) { 0xA1.toByte() })
|
||||
b.send.enqueue(ByteArray(64) { 0xB2.toByte() })
|
||||
c.send.enqueue(ByteArray(64) { 0xC3.toByte() })
|
||||
|
||||
val datagram = drainOutbound(client, nowMillis = 0L)
|
||||
assertNotNull(datagram, "drain $round must emit a packet")
|
||||
val frames = pipe.decryptClientApplicationFrames(datagram)
|
||||
assertNotNull(frames, "decrypt must succeed on round $round")
|
||||
val ids = frames.filterIsInstance<StreamFrame>().map { it.streamId }
|
||||
assertEquals(expected, ids, "round $round round-robin order")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun writer_respects_connection_level_send_credit_cap() {
|
||||
// Audit-4 #9: pre-fix the writer ignored sendConnectionFlowCredit
|
||||
|
||||
@@ -34,6 +34,7 @@ rootProject.name = "Amethyst"
|
||||
include ':amethyst'
|
||||
include ':benchmark'
|
||||
include ':quartz'
|
||||
include ':geode'
|
||||
include ':commons'
|
||||
include ':ammolite'
|
||||
include ':quic'
|
||||
|
||||
Reference in New Issue
Block a user