refactor(quartz): enforce session-level limits through policy hooks

Previously max_message_length and max_subscriptions were hard-coded in
RelaySession behind a parallel `limits` param, while the per-command limits
went through LimitsPolicy — two mechanisms, and a custom policy couldn't
influence the session-level ones.

Unify them: add two default-noop hooks to IRelayPolicy —
acceptMessage(raw) (pre-parse) and acceptSubscription(subId, openCount) —
chained through PolicyStack so they compose across multiple policies.
LimitsPolicy now implements all limit checks; RelaySession just invokes the
hooks and no longer takes a `limits` param. Servers compose LimitsPolicy
whenever `limits` is set and keep `limits` only to advertise via NIP-11.

Behaviour is unchanged (oversized -> NOTICE invalid:, sub cap -> CLOSED
rate-limited:); the enforcement now lives in the policy layer. Adds direct
hook unit tests; existing end-to-end limit tests still pass.

https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
This commit is contained in:
Claude
2026-06-03 18:58:20 +00:00
parent 70dcaa1b10
commit 37b07057b2
9 changed files with 119 additions and 53 deletions
+12 -9
View File
@@ -333,16 +333,19 @@ val limits = RelayLimits(
val server = NostrServer(store, policyBuilder = { FullAuthPolicy(relay) }, limits = limits)
```
Enforcement is split by where each limit can be checked:
All of it is enforced by a single `LimitsPolicy` (which the server prepends to
your policy when you pass `limits`), so limits compose through a `PolicyStack`
like any other policy — you can also split them across several policies:
- **Per-command** (a `LimitsPolicy` the server prepends to your policy): rejects
EVENTs over `maxContentLength` / `maxEventTags` / outside the `createdAt`
bounds; rejects REQ/COUNT with too many filters or an over-long sub id; and
**clamps** each filter's `limit` to `maxLimit` (substituting `defaultLimit`
when none is given). Rejections use the `invalid:` machine-readable prefix.
- **Per-connection** (`RelaySession`): oversized frames get a `NOTICE`
(`maxMessageLength`); a new subscription past `maxSubscriptions` is `CLOSED`
with `rate-limited:`.
- **Per-command** (`accept(...)`): rejects EVENTs over `maxContentLength` /
`maxEventTags` / outside the `createdAt` bounds; rejects REQ/COUNT with too
many filters or an over-long sub id; and **clamps** each filter's `limit` to
`maxLimit` (substituting `defaultLimit` when none is given). Rejections use
the `invalid:` machine-readable prefix.
- **Per-connection** (the `acceptMessage` / `acceptSubscription` policy hooks):
oversized frames get a `NOTICE` (`maxMessageLength`); a new subscription past
`maxSubscriptions` is `CLOSED` with `rate-limited:`. These hooks exist because
a policy otherwise can't see the raw frame or the live subscription count.
- **Advertised only**: `minPowDifficulty` (enforce with a PoW policy),
`authRequired` (use `FullAuthPolicy`), `paymentRequired`, `restrictedWrites`.
@@ -102,6 +102,32 @@ interface IRelayPolicy {
*/
fun onAuthenticationFailed(pubKey: HexKey) {}
/**
* Inspects a raw inbound message before it is parsed. Return a reason
* string to reject it (the engine sends it as a `NOTICE`), or null to let
* it through. This is the only hook that sees the unparsed frame, so guards
* that must run before JSON parsing live here — e.g. a `max_message_length`
* cap (see [com.vitorpamplona.quartz.nip01Core.relay.server.policies.LimitsPolicy]).
*
* Called on every inbound message, so keep it cheap. Default: accept.
*/
fun acceptMessage(message: String): String? = null
/**
* Decides whether a new subscription may open on this connection, given how
* many are already open ([openSubscriptions]). Called only for a genuinely
* new subscription id — a REQ that replaces an existing id does not grow the
* count. Return a reason string to reject it (the engine sends a `CLOSED`),
* or null to allow it. This is where a `max_subscriptions` cap lives, since
* a policy otherwise can't see the per-connection subscription count.
*
* Default: accept.
*/
fun acceptSubscription(
subId: String,
openSubscriptions: Int,
): String? = null
/**
* Filters a live event before it is forwarded to a subscriber.
*
@@ -102,7 +102,7 @@ class NostrServer(
*/
private fun buildPolicy(): IRelayPolicy {
val base = policyBuilder()
return if (limits != null && limits.hasCommandLimits()) LimitsPolicy(limits) + base else base
return if (limits != null) LimitsPolicy(limits) + base else base
}
/**
@@ -118,7 +118,6 @@ class NostrServer(
store = subStore,
scope = scope,
onSend = send,
limits = limits,
onClose = { closed ->
// Idempotent: only account for the first teardown of a
// given connection so a double close() can't underflow
@@ -35,14 +35,14 @@ import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation.RelayInform
* Every field is optional; a null leaves that limit unset (not enforced, not
* advertised). All fields map 1:1 to NIP-11's `limitation` object.
*
* Enforcement responsibility:
* - Per-command ([LimitsPolicy]): [maxFilters], [maxLimit], [defaultLimit],
* [maxSubidLength], [maxEventTags], [maxContentLength], [createdAtLowerLimit],
* [createdAtUpperLimit].
* - Per-connection ([RelaySession]): [maxMessageLength], [maxSubscriptions].
* - Advertised only (enforced elsewhere or operationally): [minPowDifficulty]
* (NIP-13), [authRequired] (use a `FullAuthPolicy`), [paymentRequired],
* [restrictedWrites].
* All of these are enforced by a single [LimitsPolicy] in the policy chain — the
* per-command ones via `accept(...)`, and the per-connection ones
* ([maxMessageLength], [maxSubscriptions]) via the `acceptMessage` /
* `acceptSubscription` policy hooks — so limits compose like any other policy.
*
* Advertised only (enforced elsewhere or operationally): [minPowDifficulty]
* (NIP-13), [authRequired] (use a `FullAuthPolicy`), [paymentRequired],
* [restrictedWrites].
*/
class RelayLimits(
/** Max size of an incoming message; oversized frames get a NOTICE. Measured in UTF-16 chars. */
@@ -74,17 +74,6 @@ class RelayLimits(
/** Reject events with `created_at` after this epoch-second. */
val createdAtUpperLimit: Long? = null,
) {
/** Whether any of the per-command [LimitsPolicy] fields are set. */
fun hasCommandLimits(): Boolean =
maxFilters != null ||
maxLimit != null ||
defaultLimit != null ||
maxSubidLength != null ||
maxEventTags != null ||
maxContentLength != null ||
createdAtLowerLimit != null ||
createdAtUpperLimit != null
/** Renders these limits as a NIP-11 `limitation` object for the relay info document. */
fun toNip11Limitation(): RelayInformationLimitation =
RelayInformationLimitation(
@@ -68,13 +68,6 @@ class RelaySession(
* open/close of the same connection. Defaults to a fresh monotonic id.
*/
val id: Long = nextConnectionId(),
/**
* Per-connection limits enforced here: the [RelayLimits.maxMessageLength]
* frame-size cap and the [RelayLimits.maxSubscriptions] cap. Per-command
* limits are enforced by [com.vitorpamplona.quartz.nip01Core.relay.server.policies.LimitsPolicy]
* in the policy chain, not here. Null disables session-level limits.
*/
private val limits: RelayLimits? = null,
) : AutoCloseable {
private val subscriptions = LargeCache<String, Job>()
@@ -117,11 +110,9 @@ class RelaySession(
* Parses the message as a NIP-01 command and dispatches it.
*/
suspend fun receive(command: String) {
limits?.maxMessageLength?.let { max ->
if (command.length > max) {
send(NoticeMessage("invalid: message too large (max $max)"))
return
}
policy.acceptMessage(command)?.let { reason ->
send(NoticeMessage(reason))
return
}
val cmd =
@@ -241,13 +232,14 @@ class RelaySession(
// -- NIP-01: REQ ----------------------------------------------------------
private fun handleReq(cmd: ReqCmd) {
// Enforce the per-connection subscription cap for *new* sub ids. A
// re-REQ on an existing id replaces it 1-for-1 (handled below) and so
// doesn't grow the count. Checked before the cancel so the existing
// subscription isn't dropped only to then reject its replacement.
limits?.maxSubscriptions?.let { max ->
if (!subscriptions.containsKey(cmd.subId) && subscriptions.size() >= max) {
send(ClosedMessage.of(cmd.subId, MachineReadablePrefix.RATE_LIMITED, "too many concurrent subscriptions (max $max)"))
// Ask the policy whether a *new* subscription may open (e.g. a
// max_subscriptions cap). A re-REQ on an existing id replaces it
// 1-for-1 and doesn't grow the count, so it's exempt. Checked before
// the cancel so the existing subscription isn't dropped only to then
// reject its replacement.
if (!subscriptions.containsKey(cmd.subId)) {
policy.acceptSubscription(cmd.subId, subscriptions.size())?.let { reason ->
send(ClosedMessage(cmd.subId, reason))
return
}
}
@@ -97,7 +97,7 @@ class ReqResponderServer(
private fun buildPolicy(): IRelayPolicy {
val base = policyBuilder()
return if (limits != null && limits.hasCommandLimits()) LimitsPolicy(limits) + base else base
return if (limits != null) LimitsPolicy(limits) + base else base
}
/**
@@ -113,7 +113,6 @@ class ReqResponderServer(
store = backend,
scope = scope,
onSend = send,
limits = limits,
onClose = { closed ->
// Idempotent teardown accounting (see NostrServer.connect).
if (connections.remove(closed.id) != null) {
@@ -39,12 +39,30 @@ import com.vitorpamplona.quartz.nip01Core.relay.server.RelayLimits
* Compose it ahead of your own policy so requests are clamped/rejected before
* the application logic runs — `LimitsPolicy(limits) + myPolicy`. The server
* classes do this automatically when you pass them `limits`. The session-level
* caps ([RelayLimits.maxMessageLength], [RelayLimits.maxSubscriptions]) are not
* enforceable from a policy and live in `RelaySession`.
* caps ([RelayLimits.maxMessageLength], [RelayLimits.maxSubscriptions]) are
* enforced through the [acceptMessage] / [acceptSubscription] policy hooks, so
* everything limit-related composes uniformly across a [PolicyStack].
*/
class LimitsPolicy(
private val limits: RelayLimits,
) : PassThroughPolicy() {
override fun acceptMessage(message: String): String? {
val max = limits.maxMessageLength ?: return null
return if (message.length > max) MachineReadablePrefix.INVALID.format("message too large (max $max)") else null
}
override fun acceptSubscription(
subId: String,
openSubscriptions: Int,
): String? {
val max = limits.maxSubscriptions ?: return null
return if (openSubscriptions >= max) {
MachineReadablePrefix.RATE_LIMITED.format("too many concurrent subscriptions (max $max)")
} else {
null
}
}
override fun accept(cmd: EventCmd): PolicyResult<EventCmd> {
val event = cmd.event
limits.maxContentLength?.let {
@@ -60,6 +60,23 @@ class PolicyStack(
policies.forEach { it.onAuthenticationFailed(pubKey) }
}
override fun acceptMessage(message: String): String? {
for (policy in policies) {
policy.acceptMessage(message)?.let { return it }
}
return null
}
override fun acceptSubscription(
subId: String,
openSubscriptions: Int,
): String? {
for (policy in policies) {
policy.acceptSubscription(subId, openSubscriptions)?.let { return it }
}
return null
}
private inline fun <T : Command> runPolicies(
initialCmd: T,
operation: (IRelayPolicy, T) -> PolicyResult<T>,
@@ -91,6 +91,29 @@ class RelayLimitsTest {
assertTrue(policy.accept(EventCmd(event(createdAt = 150L))) is PolicyResult.Accepted)
}
// -- LimitsPolicy: session-level hooks -------------------------------------
@Test
fun acceptMessageRejectsOversizedFrame() {
val policy = LimitsPolicy(RelayLimits(maxMessageLength = 10))
assertEquals(null, policy.acceptMessage("short"))
val reason = policy.acceptMessage("this is way too long")
assertTrue(reason != null && reason.startsWith("invalid:"))
}
@Test
fun acceptMessageIsNoopWhenUnset() {
assertEquals(null, LimitsPolicy(RelayLimits()).acceptMessage("anything at all, no cap"))
}
@Test
fun acceptSubscriptionRejectsAtCap() {
val policy = LimitsPolicy(RelayLimits(maxSubscriptions = 3))
assertEquals(null, policy.acceptSubscription("s", openSubscriptions = 2))
val reason = policy.acceptSubscription("s", openSubscriptions = 3)
assertTrue(reason != null && reason.startsWith("rate-limited:"))
}
// -- LimitsPolicy: REQ / COUNT ---------------------------------------------
@Test