diff --git a/.env.example b/.env.example index e74d0c0..0296f69 100644 --- a/.env.example +++ b/.env.example @@ -410,3 +410,8 @@ # CLI: --max-connections # Default: unlimited # NGIT_MAX_CONNECTIONS=4096 + +# Discoverable sync limits and the Git-specific event-size limit +# NGIT_RELAY_MAX_SUBSCRIPTIONS=500 +# NGIT_RELAY_FILTER_LIMIT=500 +# NGIT_RELAY_MAX_EVENT_SIZE_BYTES=196608 diff --git a/CHANGELOG.md b/CHANGELOG.md index e50a4fa..51371f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,9 +16,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Upgraded the rust-nostr crates from `0.45.0-alpha.8` to the stable `0.45.0` release, including the upstream NEG-OPEN handling fix and new local-relay - resource hardening. ngit-grasp explicitly overrides rust-nostr's new - 128-connection default to preserve its documented unbounded-by-default - inbound admission; explicitly configured connection caps remain exact. + resource hardening. The embedded relay now imposes 500 active REQs per + connection; per-minute connection quotas of 60 event writes, 120 queries, + 30 authentication events, and 300 text messages; 20 filters per REQ; 500 + results per filter; 250-byte + subscription IDs; 1 MiB retained subscription state; 10 active negentropy + sessions and 50,000 negentropy items per connection; 5 MiB WebSocket + messages; and a 10-second handshake deadline. ngit-grasp deliberately: + + - makes the discoverable subscription and filter-result limits configurable + and advertises them in NIP-11; + - raises rust-nostr's new 64 KiB event bound to a configurable 192 KiB + default because valid NIP-34 patch events in production reach about + 149 KiB; and + - overrides rust-nostr's new 128-connection default to preserve documented + unbounded-by-default inbound admission, while keeping configured caps exact. ### Security diff --git a/docs/explanation/defensive-measures.md b/docs/explanation/defensive-measures.md index f4dd588..13ea9a0 100644 --- a/docs/explanation/defensive-measures.md +++ b/docs/explanation/defensive-measures.md @@ -17,14 +17,25 @@ ngit-grasp employs multiple layers of defense: ### Per-Connection Rate Limits -**Source:** Built-in to rust-nostr relay-builder +**Source:** Enforced by rust-nostr relay-builder and explicitly selected by +ngit-grasp. Discoverable sync limits and the Git-specific event bound are +operator configurable; other dependency hardening is pinned in the builder. - **Subscription limit:** Max 500 concurrent subscriptions per connection - **Event publishing limit:** Max 60 events per minute per connection - **Subscription ID length:** Max 250 characters - **Filter limit:** Max 500 results per query (default) +- **Event size:** Max 192 KiB by default, raised from rust-nostr's 64 KiB + default because valid NIP-34 patch events in production reach about 149 KiB These limits prevent individual connections from overwhelming the relay. +The relay advertises the standard `max_subscriptions`, `max_limit`, +`default_limit`, `max_message_length`, and `max_subid_length` NIP-11 fields. +rust-nostr 0.45 also enforces fixed ngit-grasp-selected defaults of 120 queries, +30 authentication events, and 300 text messages per minute; 20 filters per +REQ; 1 MiB subscription state; 10 active negentropy sessions and 50,000 +negentropy items per connection; a 5 MiB WebSocket message; and a 10-second +handshake deadline. NIP-11 has no standard fields for most of those controls. ### Per-IP Connection Monitoring diff --git a/docs/explanation/sync-scaling-constraints.md b/docs/explanation/sync-scaling-constraints.md index fd43958..6c91640 100644 --- a/docs/explanation/sync-scaling-constraints.md +++ b/docs/explanation/sync-scaling-constraints.md @@ -70,8 +70,8 @@ The five reachable relays advertise their result cap as ### Discoverability gap (NIP-11) -NIP-11 `limitation` has **no field for tag values per filter**. It does define -`max_filters` (filters per subscription), `max_limit` (clamp applied to a +NIP-11 `limitation` has **no field for tag values per filter or filter count +per subscription**. It does define `max_limit` (clamp applied to a filter's explicit `limit`), and `default_limit` (maximum returned events when `limit` is omitted — the field pagination actually needs), in addition to `max_subscriptions` and `max_message_length`, but implementations and @@ -182,7 +182,7 @@ infrastructure. The full survey with citations is preserved in this file's history (commit `9723ff4`). NIP-11 describes hard relay limitations, not rate-limit algorithms. The -standard fields relevant here are `max_limit`, `max_filters`, and +standard fields relevant here are `max_limit` and `max_subscriptions`; it has no standard fields for simultaneous connections per IP, connection-attempt rate, message/event/query rate, burst size, window, decay model, or retry-after time. Even an advertised `max_limit` does not say @@ -250,10 +250,11 @@ Derived from the tightest commonly observed values; all sizing below assumes: - **Subscription budget B = 20** per connection (nos.lol, relay.primal.net, Ditto Relay default), shared between live REQs, NEG rounds, and fallback REQs. Caveat found by the 2026-08-06 limit matrix: nostream defaults to - **10** subscriptions per connection and 10 filters per REQ (both - advertised in NIP-11), below this floor — the fixed 4 NEG + 5 REQ + 2 + **10** subscriptions per connection and 10 filters per REQ (the former is + standard NIP-11; nostream emits the latter as a relay-specific field), below + this floor — the fixed 4 NEG + 5 REQ + 2 margin pattern alone would overdraw a default nostream before any live - subscriptions. Honouring advertised `max_subscriptions`/`max_filters` + subscriptions. Honouring advertised `max_subscriptions` is therefore required ledger work, not just an optimisation. - **Message budget M = 128 KB** (nos.lol); we target ≤ 96 KB of filter payload per message, a 1.3× margin for the envelope. @@ -422,15 +423,17 @@ consequences: ## Serving-Side Obligations We are also a relay, and peer GRASP instances run this same sync against us. -The embedded relay currently enforces **no negentropy concurrency limit** -(upstream nostr-sdk `TODO`) and no filter-size limits — the mirror image of -the client-side incident that motivated this document. At scale we must: +The rust-nostr 0.45 embedded relay now enforces 10 active negentropy sessions, +20 filters per REQ, and the other bounds recorded in the relay-limits +reference. ngit-grasp explicitly selects those defaults and advertises the +standard, discoverable subset. The remaining serving-side gap is a bound on +tag-value/filter payload size, for which NIP-11 has no standard field. At scale +we must: -1. Enforce server-side bounds (NEG concurrency, filters per REQ, filter - payload) so one peer cannot exhaust us. -2. Advertise our limits in NIP-11 `limitation` (`max_subscriptions`, - `max_message_length`) so well-behaved peers can budget against us — - partially compensating for the discoverability gap we suffer as a client. +1. Retain the existing NEG, REQ, subscription-memory, message, event, and rate + bounds, and design a filter-payload bound if production evidence requires it. +2. Keep NIP-11 `limitation` aligned with every enforced standard field so + well-behaved peers can budget against us. --- @@ -466,11 +469,11 @@ deterministic and testable. **Pros:** honest relays advertise `max_subscriptions` and `max_message_length`; budgets could be exact. -**Why partial:** the two advertised fields *are* consumed when present -(relaxing B and M above the floors), but per-filter and filters-per-REQ -limits simply have no NIP-11 field, and many relays omit `limitation` -entirely — so floors remain necessary. Proposing a NIP-11 extension for -filter-size limits is worthwhile upstream work. +**Why partial:** `max_subscriptions` and `default_limit` are consumed when +present, but message-size negotiation is not yet implemented, filter count has +no current standard NIP-11 field, and many relays omit `limitation` entirely — +so floors remain necessary. Proposing a NIP-11 extension for filter-size limits +is worthwhile upstream work. ### Timed batching with pause-on-rate-limit @@ -492,7 +495,7 @@ with the heuristics demoted to backstop. | 1 + 2 — byte-budgeted chunking and REQ packing | Landed with cycle 3 (same PR) | | Ledger unification (live + historic + fallback + purgatory polling against one NIP-11-aware subscription budget) | Implemented 2026-08-06; message-budget negotiation remains follow-up | | 4 — multi-connection sharding | Deferred until a relay's target set approaches the single-connection ceiling | -| Serving-side limits + NIP-11 advertisement | Follow-up work item | +| Serving-side limits + NIP-11 advertisement | Implemented 2026-08-07 for rust-nostr's enforceable limits and the standard discoverable subset; filter-payload bounding remains follow-up | --- diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index dbb12dc..e43b53a 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -1343,13 +1343,29 @@ NGIT_MAX_CONNECTIONS=100 - When limit is reached, new connections are rejected - Existing connections continue to work normally -**Related Limits:** +#### Embedded relay resource limits -Per-connection limits (built-in to relay-builder, not configurable): -- Max subscriptions per connection: 500 -- Max events per minute per connection: 60 -- Max subscription ID length: 250 characters -- Max results per filter: 500 +rust-nostr 0.45 introduced or tightened several local-relay defaults. ngit-grasp +sets every effective value explicitly in code, but exposes only limits that a +peer can discover and use to adapt sync, plus the Git-specific event-size +policy. + +| Environment variable | Default | Meaning | +| --- | ---: | --- | +| `NGIT_RELAY_MAX_SUBSCRIPTIONS` | `500` | Active REQ subscriptions per WebSocket connection | +| `NGIT_RELAY_FILTER_LIMIT` | `500` | Explicit and omitted-limit results per filter | +| `NGIT_RELAY_MAX_EVENT_SIZE_BYTES` | `196608` | Serialized event bytes (192 KiB) | + +Every value must be greater than zero. The event limit must not exceed the +fixed 5 MiB WebSocket message bound. Its default is three times rust-nostr's +64 KiB default because production history contains a valid NIP-34 patch event +of about 149 KiB; the bound retains approximately 29% headroom. + +The NIP-11 document advertises `NGIT_RELAY_MAX_SUBSCRIPTIONS` as +`max_subscriptions`, and `NGIT_RELAY_FILTER_LIMIT` as both `max_limit` and +`default_limit`. ngit-grasp peers already consume these fields for their +per-connection subscription ledger and adaptive pagination. Event size has no +equivalent standard NIP-11 field, so it remains documented configuration only. --- diff --git a/docs/reference/relay-limits.md b/docs/reference/relay-limits.md index bb15e20..4ef9b95 100644 --- a/docs/reference/relay-limits.md +++ b/docs/reference/relay-limits.md @@ -1,120 +1,49 @@ -# nostr-relay-builder Limits +# Embedded relay limits -This document describes the rate limiting, throttling, and query limits in `nostr-relay-builder` version 0.44. These are the limits that apply to ngit-grasp and any relay built with this crate. +ngit-grasp embeds rust-nostr `LocalRelay` 0.45.0. The application selects every +effective limit explicitly so future dependency defaults cannot silently alter +production admission policy. -**Note:** Other relay implementations (strfry, nostream, etc.) have different limits. This document focuses on `nostr-relay-builder` specifically. +## Effective limits -## Hard Limits (Cannot Be Changed) +| Limit | ngit-grasp default | Operator configuration | NIP-11 | +| --- | ---: | --- | --- | +| Total inbound connections | Unbounded | `NGIT_MAX_CONNECTIONS` | No standard field | +| Active REQs per connection | 500 | `NGIT_RELAY_MAX_SUBSCRIPTIONS` | `max_subscriptions` | +| Results per filter | 500 | `NGIT_RELAY_FILTER_LIMIT` | `max_limit`, `default_limit` | +| Serialized event size | 192 KiB | `NGIT_RELAY_MAX_EVENT_SIZE_BYTES` | No equivalent field | +| Event writes per minute | 60 | Fixed | No standard field | +| Queries per minute | 120 | Fixed | No standard field | +| Authentication events per minute | 30 | Fixed | No standard field | +| Text messages per minute | 300 | Fixed | No standard field | +| WebSocket message size | 5 MiB | Fixed | `max_message_length` | +| Handshake deadline | 10 seconds | Fixed | No standard field | +| Subscription-ID length | 250 bytes | Fixed | `max_subid_length` | +| Filters per REQ | 20 | Fixed | No standard field | +| Subscription state per connection | 1 MiB | Fixed | No standard field | +| Active negentropy sessions per connection | 10 | Fixed | No standard field | +| Negentropy items per connection | 50,000 | Fixed | No standard field | +| Negentropy frame | 60,000 bytes | Fixed upstream | No standard field | -These limits are enforced and cannot be overridden by configuration: +The filter setting is applied consistently to rust-nostr's explicit filter +cap, per-query result cap, and omitted-limit default. Limits are per filter; +results from multiple filters in one REQ are merged without an aggregate +truncation. -### WebSocket Message Limits (from tungstenite) +The 192 KiB event default is three times rust-nostr's new 64 KiB default. +Production history contains a valid NIP-34 patch event of about 149 KiB, so +64 KiB is incompatible with ngit-grasp's purpose. The raised limit remains +bounded and below the 5 MiB WebSocket message ceiling. -| Limit | Default Value | Source | -|-------|--------------|--------| -| `max_message_size` | **64 MB** (67,108,864 bytes) | tungstenite default | -| `max_frame_size` | **16 MB** (16,777,216 bytes) | tungstenite default | +## Client adaptation -nostr-relay-builder does **not** override these tungstenite defaults. +ngit-grasp refetches NIP-11 per connection session. Its outbound sync ledger +uses `max_subscriptions`, falling back conservatively when absent. Adaptive +historic pagination uses `default_limit` with a verification page and learns +from raw delivered page sizes. `max_limit` describes explicit filter limits; +historic sync currently omits `limit`, so it does not treat `max_limit` as an +omitted-filter page-size promise. -**Practical impact:** A single REQ message or EVENT with extremely large content could hit these limits. A filter with ~1,000,000 32-byte event IDs (~32MB in JSON) would fit, but 2 million would not. - -### Negentropy Frame Limit - -| Limit | Value | Source | -|-------|-------|--------| -| `frame_size_limit` | **60,000 bytes** (60KB) | Hardcoded in inner.rs | - -```rust -let mut negentropy = Negentropy::owned(storage, 60_000)?; -``` - -If reconciliation needs more data, negentropy splits across multiple NEG-MSG round-trips automatically. - -### No Hard Limits On - -nostr-relay-builder does **NOT** enforce hard limits on: - -| Item | Hard Limit? | Notes | -|------|-------------|-------| -| Tag values per filter (`#e`, `#p`, etc.) | ❌ None | Only limited by message size | -| Filters per REQ array | ❌ None | Only limited by message size | -| Filter JSON size | ❌ None | Only limited by WebSocket message | -| Authors per filter | ❌ None | Only limited by message size | -| Kinds per filter | ❌ None | Only limited by message size | -| IDs per filter | ❌ None | Only limited by message size | - -## Configurable Limits (Server-Side) - -These limits have defaults but can be configured via `RelayBuilder`: - -### Query/Response Limits - -| Setting | Default | Builder Method | -|---------|---------|----------------| -| `default_filter_limit` | **500** | `.default_filter_limit(n)` | -| `max_filter_limit` | `None` (no cap) | `.max_filter_limit(n)` | - -**Behavior:** -1. If filter has no `limit` field → server applies `default_filter_limit` (500) -2. If filter has `limit > max_filter_limit` → clamped to `max_filter_limit` -3. If filter has specific `ids` → uses `ids.len()` as limit - -### Rate Limiting - -| Setting | Default | Description | -|---------|---------|-------------| -| `max_reqs` | **500** | Max active subscriptions per session | -| `notes_per_minute` | **60** | Token bucket rate for EVENT writes | - -Rate limiting uses a token bucket: tokens regenerate proportionally over time, each EVENT consumes 1 token. - -### Connection/Session Limits - -| Setting | Default | Description | -|---------|---------|-------------| -| `max_connections` | `None` | Max concurrent WebSocket connections | -| `max_subid_length` | **250** | Max characters in subscription ID | - -## REQ vs Negentropy Limits - -| Aspect | REQ (NIP-01) | Negentropy (NIP-77) | -|--------|--------------|---------------------| -| Events returned | Limited (default: 500) | **Unlimited** (all IDs returned) | -| Filter limit applies? | ✅ Yes | ❌ No | -| Returns full events? | ✅ Yes | ❌ No (only EventId + Timestamp) | -| Message size limit | 64MB (WebSocket) | 60KB per frame | - -**Why negentropy returns all:** It only returns ~40 bytes per event (ID + timestamp) for set reconciliation. Full events are fetched separately after identifying what's missing. - -## Quick Reference - -| Limit | Value | Type | -|-------|-------|------| -| **WebSocket message** | 64 MB | Hard (tungstenite) | -| **WebSocket frame** | 16 MB | Hard (tungstenite) | -| **Negentropy frame** | 60 KB | Hard (hardcoded) | -| Tags per filter | **None** | Soft (message size only) | -| Filters per REQ | **None** | Soft (message size only) | -| Events per REQ | 500 | Configurable default | -| Max subscriptions | 500 | Configurable default | -| Write rate | 60/min | Configurable default | - -## ngit-grasp Configuration - -ngit-grasp uses defaults (no custom limits configured): - -```rust -let builder = RelayBuilder::default() - .database(database.clone()) - .write_policy(write_policy.clone()); -``` - -Additionally, ngit-grasp's memory database limits to **100,000 events** (LMDB has no such limit). - -## Related - -- [NIP-01: Basic Protocol](https://github.com/nostr-protocol/nips/blob/master/01.md) -- [NIP-77: Negentropy Sync](https://github.com/nostr-protocol/nips/blob/master/77.md) -- [nostr-relay-builder docs](https://docs.rs/nostr-relay-builder) -- [tungstenite WebSocket limits](https://docs.rs/tungstenite/latest/tungstenite/protocol/struct.WebSocketConfig.html) +NIP-11 has no standard `max_filters` field in the current schema. Filter-count +and serialized-message budgets therefore remain conservative client-side +constants rather than falsely negotiated capabilities. diff --git a/nix/module.nix b/nix/module.nix index 2322704..eca9b76 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -406,6 +406,22 @@ let "Maximum total connections to the relay (default: unlimited, defers to OS/infrastructure limits)"; }; + relayMaxSubscriptions = mkOption { + type = types.ints.positive; + default = 500; + description = "Maximum active REQ subscriptions per WebSocket connection"; + }; + relayMaxEventSizeBytes = mkOption { + type = types.ints.positive; + default = 192 * 1024; + description = "Maximum serialized event size in bytes"; + }; + relayFilterLimit = mkOption { + type = types.ints.positive; + default = 500; + description = "Per-filter result cap, including when limit is omitted"; + }; + user = mkOption { type = types.str; default = "ngit-grasp-${name}"; @@ -491,6 +507,9 @@ let NGIT_GRASP06_ENABLE = if cfg.grasp06Enable then "true" else "false"; NGIT_DELETION_REQUEST_DISRESPECTOR = if cfg.deletionRequestDisrespector then "true" else "false"; + NGIT_RELAY_MAX_SUBSCRIPTIONS = toString cfg.relayMaxSubscriptions; + NGIT_RELAY_MAX_EVENT_SIZE_BYTES = toString cfg.relayMaxEventSizeBytes; + NGIT_RELAY_FILTER_LIMIT = toString cfg.relayFilterLimit; } // optionalAttrs (cfg.maxConnections != null) { NGIT_MAX_CONNECTIONS = toString cfg.maxConnections; } // optionalAttrs (cfg.relayName != null) { diff --git a/src/config.rs b/src/config.rs index f8ab736..15b5f51 100644 --- a/src/config.rs +++ b/src/config.rs @@ -591,6 +591,22 @@ pub struct Config { #[arg(long, env = "NGIT_MAX_CONNECTIONS")] pub max_connections: Option, + /// Maximum active REQ subscriptions per WebSocket connection + #[arg(long, env = "NGIT_RELAY_MAX_SUBSCRIPTIONS", default_value_t = 500)] + pub relay_max_subscriptions: usize, + + /// Maximum serialized event size in bytes + #[arg( + long, + env = "NGIT_RELAY_MAX_EVENT_SIZE_BYTES", + default_value_t = 192 * 1024 + )] + pub relay_max_event_size_bytes: usize, + + /// Per-filter result cap, including when a filter omits limit + #[arg(long, env = "NGIT_RELAY_FILTER_LIMIT", default_value_t = 500)] + pub relay_filter_limit: usize, + /// Log level for application logging #[arg(long, env = "NGIT_LOG_LEVEL", default_value = "info")] pub log_level: String, @@ -883,6 +899,24 @@ impl Config { "used", )?; + let relay_limits = [ + ("NGIT_RELAY_MAX_SUBSCRIPTIONS", self.relay_max_subscriptions), + ( + "NGIT_RELAY_MAX_EVENT_SIZE_BYTES", + self.relay_max_event_size_bytes, + ), + ("NGIT_RELAY_FILTER_LIMIT", self.relay_filter_limit), + ]; + if let Some((name, _)) = relay_limits.iter().find(|(_, value)| *value == 0) { + return Err(anyhow!("{name} must be greater than 0")); + } + + if self.relay_max_event_size_bytes > 5 * 1024 * 1024 { + return Err(anyhow!( + "NGIT_RELAY_MAX_EVENT_SIZE_BYTES must not exceed the 5 MiB WebSocket message limit" + )); + } + // Fatal error: repository_whitelist with archive_read_only=true (incompatible) if !repository_whitelist.is_empty() { let read_only = self.archive_read_only.unwrap_or(archive_enabled); @@ -1076,6 +1110,9 @@ impl Config { event_blacklist: String::new(), deletion_request_disrespector: false, max_connections: None, + relay_max_subscriptions: 500, + relay_max_event_size_bytes: 192 * 1024, + relay_filter_limit: 500, log_level: "debug".to_string(), } } @@ -1136,6 +1173,29 @@ mod tests { assert_eq!(config.database_backend, DatabaseBackend::Memory); } + #[test] + fn relay_hardening_defaults_are_explicit() { + let config = Config::try_parse_from(["ngit-grasp", "--domain", "example.com"]) + .expect("relay hardening defaults should parse"); + + assert_eq!(config.relay_max_subscriptions, 500); + assert_eq!(config.relay_max_event_size_bytes, 192 * 1024); + assert_eq!(config.relay_filter_limit, 500); + } + + #[test] + fn event_limit_cannot_exceed_websocket_limit() { + let mut config = Config::for_testing(); + config.relay_max_event_size_bytes = 5 * 1024 * 1024 + 1; + + let error = config + .validate() + .expect_err("inconsistent size limits must fail"); + assert!(error + .to_string() + .contains("NGIT_RELAY_MAX_EVENT_SIZE_BYTES must not exceed")); + } + #[test] fn test_deletion_request_retention_cli_defaults() { let _environment_guard = CONFIG_ENV_LOCK.lock().expect("lock must not be poisoned"); diff --git a/src/http/nip11.rs b/src/http/nip11.rs index d3d8277..a98b57f 100644 --- a/src/http/nip11.rs +++ b/src/http/nip11.rs @@ -38,6 +38,9 @@ pub struct RelayInformationDocument { #[serde(skip_serializing_if = "Option::is_none")] pub icon: Option, + /// Standard NIP-11 limits enforced by the embedded relay. + pub limitation: RelayLimitation, + // GRASP-01 Extensions (lines 24-28 of GRASP-01 spec) /// List of supported GRASPs (e.g., ["GRASP-01"]) /// Required by GRASP-01 specification line 26 @@ -53,6 +56,16 @@ pub struct RelayInformationDocument { pub curation: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RelayLimitation { + pub max_message_length: usize, + pub max_subscriptions: usize, + pub max_limit: usize, + pub max_subid_length: usize, + pub default_limit: usize, + pub restricted_writes: bool, +} + impl RelayInformationDocument { /// Create NIP-11 relay information document from configuration pub fn from_config(config: &Config) -> Self { @@ -131,6 +144,14 @@ impl RelayInformationDocument { None => env!("CARGO_PKG_VERSION").to_string(), }, icon: Some(format!("https://{}/icon.png", config.domain)), + limitation: RelayLimitation { + max_message_length: 5 * 1024 * 1024, + max_subscriptions: config.relay_max_subscriptions, + max_limit: config.relay_filter_limit, + max_subid_length: 250, + default_limit: config.relay_filter_limit, + restricted_writes: true, + }, // GRASP Extensions supported_grasps, @@ -184,6 +205,11 @@ mod tests { doc.icon, Some("https://relay.example.com/icon.png".to_string()) ); + assert_eq!(doc.limitation.max_subscriptions, 500); + assert_eq!(doc.limitation.max_limit, 500); + assert_eq!(doc.limitation.default_limit, 500); + assert_eq!(doc.limitation.max_message_length, 5 * 1024 * 1024); + assert!(doc.limitation.restricted_writes); } #[test] @@ -211,6 +237,22 @@ mod tests { assert_eq!(parsed["supported_grasps"][0], "GRASP-01"); assert_eq!(parsed["supported_grasps"][1], "GRASP-02"); assert_eq!(parsed["icon"], "https://relay.example.com/icon.png"); + assert_eq!(parsed["limitation"]["max_subscriptions"], 500); + assert_eq!(parsed["limitation"]["max_limit"], 500); + assert_eq!(parsed["limitation"]["default_limit"], 500); + } + + #[test] + fn test_nip11_advertises_configured_sync_limits() { + let mut config = Config::for_testing(); + config.relay_max_subscriptions = 20; + config.relay_filter_limit = 300; + + let doc = RelayInformationDocument::from_config(&config); + + assert_eq!(doc.limitation.max_subscriptions, 20); + assert_eq!(doc.limitation.max_limit, 300); + assert_eq!(doc.limitation.default_limit, 300); } #[test] diff --git a/src/nostr/builder.rs b/src/nostr/builder.rs index 41dc055..b83981a 100644 --- a/src/nostr/builder.rs +++ b/src/nostr/builder.rs @@ -10,6 +10,7 @@ use std::num::NonZeroUsize; use std::path::Path; use std::pin::Pin; use std::sync::{Arc, RwLock}; +use std::time::Duration; use anyhow::Result; use nostr::nips::nip19::ToBech32; @@ -1007,12 +1008,24 @@ pub async fn create_relay( let mut builder = LocalRelayBuilder::default() .database(database.clone()) .write_policy(write_policy.clone()) - // Explicitly set rate limits (make defaults visible in code) - // Per-connection limits: 500 max subscriptions, 60 events/min .rate_limit(RateLimit { - max_reqs: 500, // Max concurrent subscriptions per connection - notes_per_minute: 60, // Max events per minute per connection - }); + max_reqs: config.relay_max_subscriptions, + notes_per_minute: 60, + }) + .queries_per_minute(120) + .auth_events_per_minute(30) + .messages_per_minute(300) + .max_websocket_message_size(5 * 1024 * 1024) + .max_event_size(config.relay_max_event_size_bytes) + .websocket_handshake_timeout(Duration::from_secs(10)) + .max_subid_length(250) + .max_filters_per_req(20) + .max_subscription_bytes(1024 * 1024) + .max_negentropy_subscriptions(10) + .max_negentropy_items(50_000) + .max_filter_limit(config.relay_filter_limit) + .max_query_results(config.relay_filter_limit) + .default_filter_limit(config.relay_filter_limit); // `LocalRelayBuilder` otherwise applies rust-nostr's own finite default. // In ngit-grasp, an unset limit deliberately delegates admission control to