From 9ebdfc0b8198e1485cf1cb48d8a530802f4a3df8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 02:36:18 +0000 Subject: [PATCH] feat(relay): drop max_event_bytes + rate-limit configs; verify on by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes config keys + policies that don't earn their complexity: - [limits].max_event_bytes / MaxEventBytesPolicy — duplicates [limits].max_ws_frame_bytes which is the right layer (Ktor frame cap at the wire), and the policy-level check ran AFTER the event was already parsed and bound for the store. - [limits].messages_per_sec, [limits].subscriptions_per_min / RateLimitPolicy — per-session token buckets without the matching per-IP / global-EPS infrastructure are mostly cosmetic; an operator who needs real rate limits will use a reverse proxy. Also flips [options].verify_signatures default from `false` to `true`. A relay accepting traffic from real clients should verify Schnorr signatures, and verify-by-default closes the footgun of forgetting the flag. The CLI gains `--no-verify` for explicit opt-out (test fixtures, mirror replays); the old `--verify` is dropped (a no-op now anyway). Removed: - quartz-relay/.../policies/MaxEventBytesPolicy.kt - quartz-relay/.../policies/RateLimitPolicy.kt - 7 obsolete tests in PoliciesTest + 1 in PoliciesIntegrationTest - The matching config fields in LimitsSection - Sample config entries in config.example.toml - Wiring in Main.composePolicy Added: - Test verifySignaturesCanBeExplicitlyDisabled covering the new explicit-opt-out path. Total :quartz-relay tests: 59, 0 failures. --- quartz-relay/config.example.toml | 26 ++-- .../com/vitorpamplona/quartz/relay/Main.kt | 59 +++------ .../quartz/relay/config/RelayConfig.kt | 27 ++-- .../relay/policies/MaxEventBytesPolicy.kt | 55 --------- .../quartz/relay/policies/RateLimitPolicy.kt | 115 ------------------ .../quartz/relay/config/RelayConfigTest.kt | 19 +-- .../relay/policies/PoliciesIntegrationTest.kt | 16 --- .../quartz/relay/policies/PoliciesTest.kt | 92 -------------- 8 files changed, 39 insertions(+), 370 deletions(-) delete mode 100644 quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/MaxEventBytesPolicy.kt delete mode 100644 quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/RateLimitPolicy.kt diff --git a/quartz-relay/config.example.toml b/quartz-relay/config.example.toml index b29108db7..a31532117 100644 --- a/quartz-relay/config.example.toml +++ b/quartz-relay/config.example.toml @@ -25,10 +25,6 @@ contact = "admin@example.com" host = "0.0.0.0" port = 7447 path = "/" -# Set when behind a reverse proxy (nginx/Caddy/Cloudflare). Required -# before any IP-based rate limit means anything. Parsed today, enforced -# once rate limits land. -# remote_ip_header = "X-Forwarded-For" [database] # True keeps an in-memory SQLite db (events vanish on restart). Useful @@ -39,32 +35,24 @@ file = "/var/lib/quartz-relay/events.db" [options] # Drop events whose Schnorr signature does not verify. Strongly # recommended for any relay accepting traffic from real clients. -verify_signatures = true +# 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. Parsed today, enforced once the matching policy lands. + +# Reject events whose `created_at` is more than this many seconds in +# the future. Enforced by RejectFutureEventsPolicy. # reject_future_seconds = 1800 [limits] -# Maximum byte size of an EVENT (canonical NIP-01 JSON form). -# Enforced by MaxEventBytesPolicy. -# max_event_bytes = 131072 - # 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 -# Per-session token-bucket caps. Enforced by RateLimitPolicy. -# messages_per_sec = 10 -# subscriptions_per_min = 60 - -# Parsed but NOT YET ENFORCED. -# max_subscriptions_per_session = 32 -# max_filters_per_req = 10 - [authorization] # Allow / deny lists. Allow is a permissive ceiling; deny still # removes specific entries inside it. Enforced by Pubkey/KindAllowDenyPolicy. diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Main.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Main.kt index c7302b073..838f8e9a0 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Main.kt +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/Main.kt @@ -29,9 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore import com.vitorpamplona.quartz.relay.config.RelayConfig import com.vitorpamplona.quartz.relay.policies.KindAllowDenyPolicy -import com.vitorpamplona.quartz.relay.policies.MaxEventBytesPolicy import com.vitorpamplona.quartz.relay.policies.PubkeyAllowDenyPolicy -import com.vitorpamplona.quartz.relay.policies.RateLimitPolicy import com.vitorpamplona.quartz.relay.policies.RejectFutureEventsPolicy import java.io.File @@ -48,10 +46,10 @@ import java.io.File * 2. TOML file passed via `--config ` * 3. Built-in defaults (host=0.0.0.0, port=7447, in-memory db, …) * - * Sections currently parsed AND enforced: `[info]`, `[network]`, - * `[database]`, `[options]`. Sections parsed but not yet enforced - * (forward-compat for the rate-limit / authorization work): - * `[limits]`, `[authorization]`. + * Every section is enforced: `[info]` populates the NIP-11 doc, + * `[network]` controls the bind, `[database]` chooses the SQLite path, + * `[options]` toggles AUTH/verify/future-skew, `[limits]` and + * `[authorization]` plug into the relay's policy stack. * * CLI flags: * --config TOML config (see config.example.toml) @@ -61,7 +59,9 @@ import java.io.File * --info NIP-11 doc file (overrides [info] section) * --db sqlite db path (overrides [database].file) * --auth require NIP-42 AUTH (sets options.require_auth = true) - * --verify verify event signatures (sets options.verify_signatures = true) + * --no-verify DO NOT verify event signatures (off by default + * verify is on; use only for trusted-input + * scenarios like fixture replay). */ fun main(args: Array) { val a = parseArgs(args) @@ -79,7 +79,10 @@ fun main(args: Array) { 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 - val verifySigs = a.flag("--verify") || config.options.verify_signatures + // 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 @@ -98,8 +101,6 @@ fun main(args: Array) { composePolicy(config, advertisedUrl, requireAuth, verifySigs) } - warnUnenforcedSections(config) - val relay = Relay(advertisedUrl, store, info, policyBuilder) // 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 @@ -133,13 +134,9 @@ fun main(args: Array) { * Builds the policy stack for one connection from the config. * * Order matters — cheap rejection paths run before expensive ones: - * 1. Rate limit (per-session, fastest reject path) - * 2. AUTH (drops everything if not authenticated) - * 3. Future-timestamp + size-cap + allow/deny lists - * 4. Signature verification (most expensive) - * - * The relay's `policyBuilder` factory is invoked per connection so - * rate-limit token buckets are session-scoped (not global). + * 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, @@ -149,11 +146,6 @@ private fun composePolicy( ): IRelayPolicy { val pieces = mutableListOf() - val l = config.limits - if (l.messages_per_sec != null || l.subscriptions_per_min != null) { - pieces += RateLimitPolicy(l.messages_per_sec, l.subscriptions_per_min) - } - if (requireAuth) { pieces += FullAuthPolicy(advertisedUrl) } @@ -162,10 +154,6 @@ private fun composePolicy( pieces += RejectFutureEventsPolicy(secs) } - l.max_event_bytes?.let { bytes -> - pieces += MaxEventBytesPolicy(bytes) - } - val auth = config.authorization if (auth.kind_whitelist.isNotEmpty() || auth.kind_blacklist.isNotEmpty()) { pieces += KindAllowDenyPolicy(auth.kind_whitelist.toSet(), auth.kind_blacklist.toSet()) @@ -183,25 +171,6 @@ private fun composePolicy( } } -/** - * Surface a warning for config sections we still don't enforce. As we - * add policies the matching branch is removed here. - */ -private fun warnUnenforcedSections(config: RelayConfig) { - val warnings = mutableListOf() - val l = config.limits - if (l.max_subscriptions_per_session != null) { - warnings += "[limits].max_subscriptions_per_session is parsed but NOT YET ENFORCED." - } - if (l.max_filters_per_req != null) { - warnings += "[limits].max_filters_per_req is parsed but NOT YET ENFORCED." - } - if (config.network.remote_ip_header != null) { - warnings += "[network].remote_ip_header is parsed but NOT YET ENFORCED — IP-based limits are pending." - } - warnings.forEach { System.err.println("warning: $it") } -} - private class Args( private val opts: Map, private val flags: Set, diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/config/RelayConfig.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/config/RelayConfig.kt index fb31fa071..5e2cd78f8 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/config/RelayConfig.kt +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/config/RelayConfig.kt @@ -34,19 +34,13 @@ import java.io.File * * Every section is optional; values not set fall back to sensible * defaults (or, for fields also exposed on the CLI, the CLI value wins). - * - * Sections that are parsed but **not yet enforced** by the relay are - * marked below; they're accepted so configs remain forward-compatible - * once the matching policy is implemented (rate limits, NIP-05, etc.). */ data class RelayConfig( val info: InfoSection = InfoSection(), val network: NetworkSection = NetworkSection(), val database: DatabaseSection = DatabaseSection(), val options: OptionsSection = OptionsSection(), - /** Parsed but not yet enforced. */ val limits: LimitsSection = LimitsSection(), - /** Parsed but not yet enforced. */ val authorization: AuthorizationSection = AuthorizationSection(), ) { /** @@ -103,12 +97,6 @@ data class RelayConfig( val host: String = "0.0.0.0", val port: Int = 7447, val path: String = "/", - /** - * When set, the relay reads the client IP from this header - * (typically `X-Forwarded-For` behind a reverse proxy). Required - * once IP-based rate limits land. - */ - val remote_ip_header: String? = null, ) data class DatabaseSection( @@ -123,18 +111,19 @@ data class RelayConfig( 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. */ - val verify_signatures: 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_event_bytes: Int? = null, val max_ws_message_bytes: Int? = null, val max_ws_frame_bytes: Int? = null, - val messages_per_sec: Int? = null, - val subscriptions_per_min: Int? = null, - val max_subscriptions_per_session: Int? = null, - val max_filters_per_req: Int? = null, ) data class AuthorizationSection( diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/MaxEventBytesPolicy.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/MaxEventBytesPolicy.kt deleted file mode 100644 index 6d531f9a6..000000000 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/MaxEventBytesPolicy.kt +++ /dev/null @@ -1,55 +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.relay.policies - -import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper -import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd -import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult - -/** - * Rejects events whose canonical JSON byte size exceeds [maxBytes]. - * Mirrors nostr-rs-relay's `[limits].max_event_bytes`. - * - * Note: this measures the size of the SERVER-side re-serialised event - * (via [OptimizedJsonMapper.toJson]), which is byte-equivalent to the - * canonical NIP-01 form a well-behaved client would have sent. It does - * NOT enforce `[limits].max_ws_message_bytes` — that one belongs at the - * WebSocket frame layer (Ktor `WebSockets { maxFrameSize = ... }` for - * [com.vitorpamplona.quartz.relay.LocalRelayServer]) because the policy - * layer never sees the raw frame. Both limits are enforced together - * when the operator sets them in the config. - */ -class MaxEventBytesPolicy( - val maxBytes: Int, -) : PassThroughPolicy() { - init { - require(maxBytes > 0) { "maxBytes must be > 0, got $maxBytes" } - } - - override fun accept(cmd: EventCmd): PolicyResult { - val size = OptimizedJsonMapper.toJson(cmd.event).length - return if (size > maxBytes) { - PolicyResult.Rejected("invalid: event size $size exceeds limit of $maxBytes bytes") - } else { - PolicyResult.Accepted(cmd) - } - } -} diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/RateLimitPolicy.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/RateLimitPolicy.kt deleted file mode 100644 index 877e8abc5..000000000 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/RateLimitPolicy.kt +++ /dev/null @@ -1,115 +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.relay.policies - -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.PolicyResult - -/** - * Per-session token-bucket rate limiter. Mirrors nostr-rs-relay's - * `[limits].messages_per_sec` and `[limits].subscriptions_per_min`. - * - * - [messagesPerSec] caps EVERY incoming command (EVENT/REQ/COUNT/AUTH) - * over a 1-second window. `null` disables. - * - [subscriptionsPerMin] caps REQ + COUNT (subscriptions opened) over - * a 60-second window. `null` disables. - * - * Each session gets its own buckets — instances of this policy must be - * created per-connection via the relay's `policyBuilder` factory. - * - * Time source defaults to monotonic [System.nanoTime] so wall-clock - * jumps don't reset the buckets. Tests inject a deterministic clock. - */ -class RateLimitPolicy( - val messagesPerSec: Int? = null, - val subscriptionsPerMin: Int? = null, - private val nowNanos: () -> Long = System::nanoTime, -) : PassThroughPolicy() { - private val msgBucket = - messagesPerSec?.let { - require(it > 0) { "messagesPerSec must be > 0" } - TokenBucket(capacity = it, refillIntervalNanos = 1_000_000_000L / it, nowNanos) - } - - private val subBucket = - subscriptionsPerMin?.let { - require(it > 0) { "subscriptionsPerMin must be > 0" } - TokenBucket(capacity = it, refillIntervalNanos = 60_000_000_000L / it, nowNanos) - } - - private fun checkMsgBucket(): String? = if (msgBucket?.tryTake() == false) "blocked: too many messages per second" else null - - private fun checkSubBucket(): String? = if (subBucket?.tryTake() == false) "blocked: too many subscriptions per minute" else null - - override fun accept(cmd: EventCmd): PolicyResult { - checkMsgBucket()?.let { return PolicyResult.Rejected(it) } - return PolicyResult.Accepted(cmd) - } - - override fun accept(cmd: ReqCmd): PolicyResult { - checkMsgBucket()?.let { return PolicyResult.Rejected(it) } - checkSubBucket()?.let { return PolicyResult.Rejected(it) } - return PolicyResult.Accepted(cmd) - } - - override fun accept(cmd: CountCmd): PolicyResult { - checkMsgBucket()?.let { return PolicyResult.Rejected(it) } - checkSubBucket()?.let { return PolicyResult.Rejected(it) } - return PolicyResult.Accepted(cmd) - } -} - -/** - * Token bucket with monotonic refill. Single-threaded by contract — - * RelaySession.receive runs serially within a session, so no locking - * is needed. - */ -private class TokenBucket( - val capacity: Int, - val refillIntervalNanos: Long, - val now: () -> Long, -) { - private var tokens: Long = capacity.toLong() - private var lastRefill: Long = now() - - fun tryTake(): Boolean { - refill() - return if (tokens > 0) { - tokens -= 1 - true - } else { - false - } - } - - private fun refill() { - val n = now() - val elapsed = n - lastRefill - if (elapsed <= 0) return - val newTokens = elapsed / refillIntervalNanos - if (newTokens > 0) { - tokens = (tokens + newTokens).coerceAtMost(capacity.toLong()) - lastRefill += newTokens * refillIntervalNanos - } - } -} diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/config/RelayConfigTest.kt b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/config/RelayConfigTest.kt index 071f371af..77b52c098 100644 --- a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/config/RelayConfigTest.kt +++ b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/config/RelayConfigTest.kt @@ -35,10 +35,17 @@ class RelayConfigTest { assertEquals("/", c.network.path) assertEquals(true, c.database.in_memory) assertEquals(false, c.options.require_auth) - assertEquals(false, c.options.verify_signatures) + // 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 = @@ -53,7 +60,6 @@ class RelayConfigTest { host = "127.0.0.1" port = 9988 path = "/relay" - remote_ip_header = "X-Forwarded-For" [database] in_memory = false @@ -65,9 +71,7 @@ class RelayConfigTest { reject_future_seconds = 1800 [limits] - max_event_bytes = 131072 - messages_per_sec = 10 - max_filters_per_req = 12 + max_ws_frame_bytes = 1048576 [authorization] pubkey_blacklist = ["aaaa", "bbbb"] @@ -83,7 +87,6 @@ class RelayConfigTest { assertEquals("127.0.0.1", c.network.host) assertEquals(9988, c.network.port) assertEquals("/relay", c.network.path) - assertEquals("X-Forwarded-For", c.network.remote_ip_header) assertEquals(false, c.database.in_memory) assertEquals("/var/lib/quartz-relay/events.db", c.database.file) @@ -92,9 +95,7 @@ class RelayConfigTest { assertEquals(true, c.options.require_auth) assertEquals(1800, c.options.reject_future_seconds) - assertEquals(131072, c.limits.max_event_bytes) - assertEquals(10, c.limits.messages_per_sec) - assertEquals(12, c.limits.max_filters_per_req) + 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) diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/policies/PoliciesIntegrationTest.kt b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/policies/PoliciesIntegrationTest.kt index 5b9f434f8..c4fcfe8dd 100644 --- a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/policies/PoliciesIntegrationTest.kt +++ b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/policies/PoliciesIntegrationTest.kt @@ -121,20 +121,4 @@ class PoliciesIntegrationTest { hub.close() } } - - @Test - fun maxEventBytesBlocksOversizeOverWire() = - runBlocking { - val (client, hub) = hubWith { MaxEventBytesPolicy(maxBytes = 400) } - try { - val signer = NostrSignerSync(KeyPair()) - val small = signer.sign(TextNoteEvent.build("hi")) - assertEquals(true, client.publishAndConfirm(small, setOf(relayUrl))) - val huge = signer.sign(TextNoteEvent.build("x".repeat(2_000))) - assertEquals(false, client.publishAndConfirm(huge, setOf(relayUrl))) - } finally { - client.disconnect() - hub.close() - } - } } diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/policies/PoliciesTest.kt b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/policies/PoliciesTest.kt index abc9e48d6..0c544c7f1 100644 --- a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/policies/PoliciesTest.kt +++ b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/policies/PoliciesTest.kt @@ -20,15 +20,10 @@ */ package com.vitorpamplona.quartz.relay.policies -import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper -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.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult import com.vitorpamplona.quartz.relay.fixtures.SyntheticEvents import kotlin.test.Test -import kotlin.test.assertEquals import kotlin.test.assertTrue import kotlin.test.fail @@ -148,82 +143,6 @@ class PoliciesTest { assertRejected(p.accept(EventCmd(event(createdAt = now + 1)))) } - // -- MaxEventBytesPolicy ------------------------------------------------ - - @Test - fun maxBytesAllowsSmallEvents() { - val small = event(content = "a") - val limit = OptimizedJsonMapper.toJson(small).length + 100 - val p = MaxEventBytesPolicy(maxBytes = limit) - assertAccepted(p.accept(EventCmd(small))) - } - - @Test - fun maxBytesRejectsOversizedEvents() { - val big = event(content = "x".repeat(2_000)) - val p = MaxEventBytesPolicy(maxBytes = 500) - assertRejected(p.accept(EventCmd(big)), reasonContains = "exceeds limit") - } - - // -- RateLimitPolicy ---------------------------------------------------- - - /** Helper that makes a clock we can advance in nanoseconds. */ - private class FakeClock { - var nanos = 0L - - fun read(): Long = nanos - - fun advanceMillis(ms: Long) { - nanos += ms * 1_000_000L - } - } - - @Test - fun rateLimitMessagesPerSecond() { - val clock = FakeClock() - val p = RateLimitPolicy(messagesPerSec = 3, nowNanos = clock::read) - - // First three pass within the same instant. - repeat(3) { assertAccepted(p.accept(EventCmd(event()))) } - // Fourth is rate-limited. - assertRejected(p.accept(EventCmd(event())), reasonContains = "messages per second") - - // After enough wall-time the bucket refills. - clock.advanceMillis(400) // 1s/3 = 333ms per token; 400ms gives at least 1 token - assertAccepted(p.accept(EventCmd(event()))) - } - - @Test - fun rateLimitSubscriptionsPerMinute() { - val clock = FakeClock() - val p = RateLimitPolicy(subscriptionsPerMin = 2, nowNanos = clock::read) - val req = ReqCmd("sub-1", listOf(Filter())) - - assertAccepted(p.accept(req)) - assertAccepted(p.accept(req)) - assertRejected(p.accept(req), reasonContains = "subscriptions per minute") - - // Refill after 30s for 2/min -> 1 token. - clock.advanceMillis(31_000) - assertAccepted(p.accept(req)) - } - - @Test - fun rateLimitCountAlsoCountsAsSubscription() { - val clock = FakeClock() - val p = RateLimitPolicy(subscriptionsPerMin = 1, nowNanos = clock::read) - val cnt = CountCmd("q1", listOf(Filter())) - assertAccepted(p.accept(cnt)) - assertRejected(p.accept(cnt), reasonContains = "subscriptions per minute") - } - - @Test - fun rateLimitDisabledWhenBothLimitsAreNull() { - val p = RateLimitPolicy() - repeat(1000) { assertAccepted(p.accept(EventCmd(event()))) } - repeat(1000) { assertAccepted(p.accept(ReqCmd("s", listOf(Filter())))) } - } - // -- Stack composition -------------------------------------------------- /** @@ -250,15 +169,4 @@ class PoliciesTest { reasonContains = "not allowed", ) } - - @Test - fun rateLimitConstructorRejectsInvalidValues() { - var threw = false - try { - RateLimitPolicy(messagesPerSec = 0) - } catch (_: IllegalArgumentException) { - threw = true - } - assertEquals(true, threw) - } }