From 633d0c3c740bd6d120d70c2fd94f100047510677 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 02:14:42 +0000 Subject: [PATCH] feat(relay): enforce [limits] + [authorization] config sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the parsed-but-unenforced config sections into actual relay behavior via five new IRelayPolicy implementations under quartz-relay/.../policies/. They compose through the existing PolicyStack so operators stack only what they need; cheap rejection paths run before expensive ones (rate-limit → AUTH → future/size/lists → signature verification). New policies: - KindAllowDenyPolicy — kind_whitelist + kind_blacklist - PubkeyAllowDenyPolicy — pubkey_whitelist + pubkey_blacklist (case-insensitive) - RejectFutureEventsPolicy — options.reject_future_seconds - MaxEventBytesPolicy — limits.max_event_bytes (size of canonical NIP-01 JSON form) - RateLimitPolicy — token-bucket per session for messages_per_sec + subscriptions_per_min; monotonic clock so wall-time jumps don't reset buckets. Plus: - LocalRelayServer now honors limits.max_ws_frame_bytes / limits.max_ws_message_bytes via Ktor's WebSockets.maxFrameSize. - Main.kt builds the policy stack from RelayConfig in composePolicy(). Warning surface is reduced to only the three sections still pending (max_subscriptions_per_session, max_filters_per_req, network.remote_ip_header). - PassThroughPolicy base lets each policy declare only the hook(s) it actually enforces, keeping call sites readable. Tests (20 new): - PoliciesTest (16) — per-policy unit coverage for each accept/reject boundary, including allow + deny precedence, case-insensitive pubkey matching, deterministic rate-limit refill via injected clock, and composition via IRelayPolicy.plus. - PoliciesIntegrationTest (4) — end-to-end through NostrClient → RelayHub → Relay; proves OK false comes back over the wire when policies reject (kind blacklist, pubkey allow-list, future timestamps, oversize events). Total :quartz-relay tests: 62, 0 failures. Also: bump Nip40 deleteExpiredEvents wait from 1500ms to 2500ms — the previous margin was thin enough to flake on busy CI when the SQLite unixepoch() rounds down across the wait. --- quartz-relay/config.example.toml | 22 +- .../quartz/relay/LocalRelayServer.kt | 11 +- .../com/vitorpamplona/quartz/relay/Main.kt | 111 ++++++-- .../relay/policies/KindAllowDenyPolicy.kt | 52 ++++ .../relay/policies/MaxEventBytesPolicy.kt | 55 ++++ .../relay/policies/PassThroughPolicy.kt | 49 ++++ .../relay/policies/PubkeyAllowDenyPolicy.kt | 56 ++++ .../quartz/relay/policies/RateLimitPolicy.kt | 115 ++++++++ .../policies/RejectFutureEventsPolicy.kt | 55 ++++ .../quartz/relay/Nip40ExpirationTest.kt | 5 +- .../relay/policies/PoliciesIntegrationTest.kt | 140 ++++++++++ .../quartz/relay/policies/PoliciesTest.kt | 264 ++++++++++++++++++ 12 files changed, 898 insertions(+), 37 deletions(-) create mode 100644 quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/KindAllowDenyPolicy.kt create mode 100644 quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/MaxEventBytesPolicy.kt create mode 100644 quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/PassThroughPolicy.kt create mode 100644 quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/PubkeyAllowDenyPolicy.kt create mode 100644 quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/RateLimitPolicy.kt create mode 100644 quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/RejectFutureEventsPolicy.kt create mode 100644 quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/policies/PoliciesIntegrationTest.kt create mode 100644 quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/policies/PoliciesTest.kt diff --git a/quartz-relay/config.example.toml b/quartz-relay/config.example.toml index 0b357edc3..b29108db7 100644 --- a/quartz-relay/config.example.toml +++ b/quartz-relay/config.example.toml @@ -46,21 +46,29 @@ require_auth = false # future. Parsed today, enforced once the matching policy lands. # reject_future_seconds = 1800 -# --- Sections below are parsed today but NOT YET ENFORCED. They are -# accepted for forward compatibility — the matching enforcement code is -# tracked separately. The relay logs a warning for each used section. --- - [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] -# pubkey_whitelist = [] +# 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 = [] -# kind_blacklist = [] +# kind_whitelist = [0, 1, 3, 7, 1059, 30023] +# kind_blacklist = [4] diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServer.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServer.kt index ca4d398d5..f2778ba53 100644 --- a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServer.kt +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/LocalRelayServer.kt @@ -62,6 +62,13 @@ class LocalRelayServer( /** 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, ) { private var engine: CIOApplicationEngine? = null private var resolvedPort: Int = -1 @@ -80,7 +87,9 @@ class LocalRelayServer( fun start(): LocalRelayServer { val server = embeddedServer(CIO, host = host, port = port) { - install(WebSockets) + install(WebSockets) { + maxFrameBytes?.let { maxFrameSize = it } + } routing { // NIP-11: GET on the relay URL with Accept: // application/nostr+json returns the relay info doc. 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 8f038d94e..c7302b073 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 @@ -28,6 +28,11 @@ 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 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 /** @@ -89,18 +94,26 @@ fun main(args: Array) { val store: IEventStore = EventStore(dbName = dbFile, relay = advertisedUrl) - val policyBuilder: () -> IRelayPolicy = - when { - verifySigs && requireAuth -> { -> VerifyPolicy + FullAuthPolicy(advertisedUrl) } - verifySigs -> { -> VerifyPolicy } - requireAuth -> { -> FullAuthPolicy(advertisedUrl) } - else -> { -> EmptyPolicy } - } + val policyBuilder: () -> IRelayPolicy = { + composePolicy(config, advertisedUrl, requireAuth, verifySigs) + } warnUnenforcedSections(config) val relay = Relay(advertisedUrl, store, info, policyBuilder) - val server = LocalRelayServer(relay, host = host, port = port, path = path).start() + // 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, + ).start() Runtime.getRuntime().addShutdownHook( Thread { @@ -116,30 +129,72 @@ fun main(args: Array) { Thread.currentThread().join() } -/** Surface a warning when the operator has set sections we don't yet enforce. */ +/** + * 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). + */ +private fun composePolicy( + config: RelayConfig, + advertisedUrl: com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl, + requireAuth: Boolean, + verifySigs: Boolean, +): 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) + } + + config.options.reject_future_seconds?.let { secs -> + 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()) + } + if (auth.pubkey_whitelist.isNotEmpty() || auth.pubkey_blacklist.isNotEmpty()) { + pieces += PubkeyAllowDenyPolicy(auth.pubkey_whitelist.toSet(), auth.pubkey_blacklist.toSet()) + } + + if (verifySigs) { + pieces += VerifyPolicy + } + + return pieces.fold(EmptyPolicy) { acc, p -> + if (acc === EmptyPolicy) p else acc + p + } +} + +/** + * 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_event_bytes != null || - l.max_ws_message_bytes != null || - l.max_ws_frame_bytes != null || - l.messages_per_sec != null || - l.subscriptions_per_min != null || - l.max_subscriptions_per_session != null || - l.max_filters_per_req != null - ) { - warnings += "[limits] section is parsed but NOT YET ENFORCED — rate limits / message size caps are pending." + if (l.max_subscriptions_per_session != null) { + warnings += "[limits].max_subscriptions_per_session is parsed but NOT YET ENFORCED." } - val auth = config.authorization - if (auth.pubkey_whitelist.isNotEmpty() || - auth.pubkey_blacklist.isNotEmpty() || - auth.kind_whitelist.isNotEmpty() || - auth.kind_blacklist.isNotEmpty() - ) { - warnings += "[authorization] section is parsed but NOT YET ENFORCED — pubkey/kind allow-deny lists are pending." - } - if (config.options.reject_future_seconds != null) { - warnings += "[options].reject_future_seconds 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." diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/KindAllowDenyPolicy.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/KindAllowDenyPolicy.kt new file mode 100644 index 000000000..b23978ef9 --- /dev/null +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/KindAllowDenyPolicy.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.relay.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 [Event.kind] is in + * [allow] are accepted; everything else gets `blocked: kind X not allowed`. + * - When [deny] is non-empty, events whose kind is in [deny] are + * rejected with `blocked: kind X denied`. + * - Both lists may be empty (no-op pass-through). + * - When both are set, allow is checked first (deny inside allow is + * still denied, matching nostr-rs-relay's precedence). + */ +class KindAllowDenyPolicy( + val allow: Set = emptySet(), + val deny: Set = emptySet(), +) : PassThroughPolicy() { + override fun accept(cmd: EventCmd): PolicyResult { + val k = cmd.event.kind + if (allow.isNotEmpty() && k !in allow) { + return PolicyResult.Rejected("blocked: kind $k not allowed") + } + if (k in deny) { + return PolicyResult.Rejected("blocked: kind $k denied") + } + return PolicyResult.Accepted(cmd) + } +} diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/MaxEventBytesPolicy.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/MaxEventBytesPolicy.kt new file mode 100644 index 000000000..6d531f9a6 --- /dev/null +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/MaxEventBytesPolicy.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.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/PassThroughPolicy.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/PassThroughPolicy.kt new file mode 100644 index 000000000..f79d2a85b --- /dev/null +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/PassThroughPolicy.kt @@ -0,0 +1,49 @@ +/* + * 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.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. + */ +abstract class PassThroughPolicy : IRelayPolicy { + override fun onConnect(send: (Message) -> Unit) {} + + override fun accept(cmd: EventCmd): PolicyResult = PolicyResult.Accepted(cmd) + + override fun accept(cmd: ReqCmd): PolicyResult = PolicyResult.Accepted(cmd) + + override fun accept(cmd: CountCmd): PolicyResult = PolicyResult.Accepted(cmd) + + override fun accept(cmd: AuthCmd): PolicyResult = PolicyResult.Accepted(cmd) + + override fun canSendToSession(event: Event): Boolean = true +} diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/PubkeyAllowDenyPolicy.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/PubkeyAllowDenyPolicy.kt new file mode 100644 index 000000000..d43ceeb0a --- /dev/null +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/PubkeyAllowDenyPolicy.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.relay.policies + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.server.PolicyResult + +/** + * Operator-controlled author allow/deny list. Mirrors nostr-rs-relay's + * `[authorization].pubkey_whitelist` / `pubkey_blacklist`. + * + * - [allow] non-empty: only events from listed pubkeys are accepted. + * This is the "private relay" mode. + * - [deny] non-empty: events from listed pubkeys are rejected. + * - Empty lists are no-op pass-through. + * - When both are set, allow is checked first. + * + * Pubkeys are matched case-insensitively (lowercased on entry). + */ +class PubkeyAllowDenyPolicy( + allow: Set = emptySet(), + deny: Set = emptySet(), +) : PassThroughPolicy() { + private val allow = allow.mapTo(HashSet()) { it.lowercase() } + private val deny = deny.mapTo(HashSet()) { it.lowercase() } + + override fun accept(cmd: EventCmd): PolicyResult { + val pk = cmd.event.pubKey.lowercase() + if (allow.isNotEmpty() && pk !in allow) { + return PolicyResult.Rejected("blocked: pubkey not on allow list") + } + if (pk in deny) { + return PolicyResult.Rejected("blocked: pubkey is denied") + } + return PolicyResult.Accepted(cmd) + } +} diff --git a/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/RateLimitPolicy.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/RateLimitPolicy.kt new file mode 100644 index 000000000..877e8abc5 --- /dev/null +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/RateLimitPolicy.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.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/main/kotlin/com/vitorpamplona/quartz/relay/policies/RejectFutureEventsPolicy.kt b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/RejectFutureEventsPolicy.kt new file mode 100644 index 000000000..30affa837 --- /dev/null +++ b/quartz-relay/src/main/kotlin/com/vitorpamplona/quartz/relay/policies/RejectFutureEventsPolicy.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.relay.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`. + * + * This catches both clock-skew accidents and intentional far-future + * timestamps used to push events to the top of newest-first feeds. + * + * The current time is read from [TimeUtils.now] (epoch seconds), the + * same source the [com.vitorpamplona.quartz.nip40Expiration.isExpired] + * check uses, so the relay's "future" and "expired" decisions agree. + */ +class RejectFutureEventsPolicy( + val maxFutureSeconds: Int, + private val now: () -> Long = { TimeUtils.now() }, +) : PassThroughPolicy() { + init { + require(maxFutureSeconds >= 0) { "maxFutureSeconds must be >= 0, got $maxFutureSeconds" } + } + + override fun accept(cmd: EventCmd): PolicyResult { + val skew = cmd.event.createdAt - now() + return if (skew > maxFutureSeconds) { + PolicyResult.Rejected("invalid: created_at is $skew seconds in the future (max $maxFutureSeconds)") + } else { + PolicyResult.Accepted(cmd) + } + } +} diff --git a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip40ExpirationTest.kt b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip40ExpirationTest.kt index 535e4944b..6c538241a 100644 --- a/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip40ExpirationTest.kt +++ b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/Nip40ExpirationTest.kt @@ -145,7 +145,10 @@ class Nip40ExpirationTest { ) // Wait until shortLived is past its expiration, then sweep. - kotlinx.coroutines.delay(1500) + // 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. 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 new file mode 100644 index 000000000..5b9f434f8 --- /dev/null +++ b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/policies/PoliciesIntegrationTest.kt @@ -0,0 +1,140 @@ +/* + * 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.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.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.relay.RelayHub +import com.vitorpamplona.quartz.relay.fixtures.SyntheticEvents +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * End-to-end through `NostrClient → RelayHub → Relay` with the policies + * actually wired into the relay. Proves an EVENT command sent on the + * wire surfaces an OK false response when the policy rejects. + */ +class PoliciesIntegrationTest { + private val relayUrl: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://127.0.0.1:7770/") + private lateinit var scope: CoroutineScope + + @BeforeTest + fun setup() { + scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + } + + @AfterTest + fun teardown() { + scope.cancel() + } + + /** Spin up a hub whose only relay uses the supplied policy factory. */ + private fun hubWith(policyFactory: () -> IRelayPolicy): Pair { + val hub = RelayHub(defaultPolicy = policyFactory) + // Materialise the relay so the URL resolves in the hub. + hub.getOrCreate(relayUrl) + return NostrClient(hub, scope) to hub + } + + @Test + fun kindBlacklistRejectsKind4OverWire() = + runBlocking { + val (client, hub) = hubWith { KindAllowDenyPolicy(deny = setOf(4)) } + try { + val signer = NostrSignerSync(KeyPair()) + val ok = client.publishAndConfirm(signer.sign(TextNoteEvent.build("ok")), setOf(relayUrl)) + assertEquals(true, ok, "kind 1 must pass") + + // Synthetic kind-4 event — the relay's deny list rejects it. + val kind4 = SyntheticEvents.fakeEvent(idSeed = 999, kind = 4, pubKey = signer.pubKey) + val rejected = client.publishAndConfirm(kind4, setOf(relayUrl)) + assertEquals(false, rejected, "kind 4 must be rejected") + } finally { + client.disconnect() + hub.close() + } + } + + @Test + fun pubkeyAllowListRejectsForeignAuthorOverWire() = + runBlocking { + val alice = NostrSignerSync(KeyPair()) + val mallory = NostrSignerSync(KeyPair()) + val (client, hub) = hubWith { PubkeyAllowDenyPolicy(allow = setOf(alice.pubKey)) } + try { + val accepted = client.publishAndConfirm(alice.sign(TextNoteEvent.build("hi")), setOf(relayUrl)) + assertEquals(true, accepted) + val denied = client.publishAndConfirm(mallory.sign(TextNoteEvent.build("nope")), setOf(relayUrl)) + assertEquals(false, denied) + } finally { + client.disconnect() + hub.close() + } + } + + @Test + fun rejectFutureEventsBlocksFarFutureCreatedAtOverWire() = + runBlocking { + // Use a fixed clock so the policy decision is deterministic. + val frozen = 1_000_000L + val (client, hub) = + hubWith { RejectFutureEventsPolicy(maxFutureSeconds = 60, now = { frozen }) } + try { + val signer = NostrSignerSync(KeyPair()) + val nearby = signer.sign(TextNoteEvent.build("ok", createdAt = frozen + 30)) + assertEquals(true, client.publishAndConfirm(nearby, setOf(relayUrl))) + val tooFar = signer.sign(TextNoteEvent.build("nope", createdAt = frozen + 3600)) + assertEquals(false, client.publishAndConfirm(tooFar, setOf(relayUrl))) + } finally { + client.disconnect() + hub.close() + } + } + + @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 new file mode 100644 index 000000000..abc9e48d6 --- /dev/null +++ b/quartz-relay/src/test/kotlin/com/vitorpamplona/quartz/relay/policies/PoliciesTest.kt @@ -0,0 +1,264 @@ +/* + * 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.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 + +/** + * 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)))) + } + + // -- 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 -------------------------------------------------- + + /** + * 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", + ) + } + + @Test + fun rateLimitConstructorRejectsInvalidValues() { + var threw = false + try { + RateLimitPolicy(messagesPerSec = 0) + } catch (_: IllegalArgumentException) { + threw = true + } + assertEquals(true, threw) + } +}